From d947f616d13bd0fea2a3721433a746c0d7beadcc Mon Sep 17 00:00:00 2001 From: ZDDC Date: Tue, 19 May 2026 09:58:21 -0500 Subject: [PATCH] feat(forms): augment served schema with cascade field_codes + locks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two extension fields added to jsonschema.Schema so server-injected constraints survive the YAML→Schema→JSON round-trip: - Pattern: regex hint for the form renderer (server-side validation for field_codes already runs via WriteWithHistory). - ReadOnly: surfaces locked / audit fields as disabled in the UI. - Labels: x-labels extension carrying human-readable display strings paired with enum keys (e.g. ACM → "Acme Inc"), so dropdowns can show "ACM — Acme Inc" rather than bare codes. serveFormRender now calls augmentSchemaFromCascade after loading the spec: per-field, it injects enum (from field_codes:codes), pattern (from field_codes:pattern), readOnly (from records:locked), and default (from records:field_defaults). The augmentation is per-request and never touches the on-disk *.form.yaml — operators who declare their own enum/pattern in the spec take precedence (injection is "if absent"). Co-Authored-By: Claude Opus 4.7 (1M context) --- zddc/internal/handler/formhandler.go | 7 +++ zddc/internal/handler/history.go | 78 ++++++++++++++++++++++++++++ zddc/internal/jsonschema/schema.go | 14 +++++ 3 files changed, 99 insertions(+) diff --git a/zddc/internal/handler/formhandler.go b/zddc/internal/handler/formhandler.go index abaeef6..a4fd61b 100644 --- a/zddc/internal/handler/formhandler.go +++ b/zddc/internal/handler/formhandler.go @@ -330,6 +330,13 @@ func serveFormRender(cfg config.Config, req *FormRequest, w http.ResponseWriter, data = normalizeYAMLForJSON(data) } + // Augment the schema with cascade-resolved field_codes (enum / + // pattern / labels) and any records: rule that applies in this + // folder (readOnly for locked fields, default for field_defaults). + // The augmentation is per-request and never mutates the on-disk + // spec — it's purely additive context the form renderer needs. + augmentSchemaFromCascade(spec.Schema, chain, gateDir) + ctx := formContext{ Title: spec.Title, Schema: spec.Schema, diff --git a/zddc/internal/handler/history.go b/zddc/internal/handler/history.go index efceea6..9d99baf 100644 --- a/zddc/internal/handler/history.go +++ b/zddc/internal/handler/history.go @@ -576,6 +576,84 @@ func ListHistory(abs string) ([]HistoryEntry, error) { return out, nil } +// augmentSchemaFromCascade mutates schema in place to inject +// cascade-resolved field_codes and records:-rule constraints. For +// every property whose name matches a field-code key, the relevant +// enum/pattern/labels are injected. For every record-rule's locked +// field, the corresponding property is marked readOnly. For every +// field_default, the corresponding property's Default is set if +// absent. +// +// gateDir is the directory the cascade was resolved at — needed +// only to pick the right records: rule when multiple patterns +// could match. The current cascade interface gives us the chain +// already; we pull a single "*.yaml" representative rule (matching +// the create-time behaviour in serveFormCreateRollup). +// +// Mutates the input schema. No-op when schema is nil. +func augmentSchemaFromCascade(schema *jsonschema.Schema, chain zddc.PolicyChain, gateDir string) { + if schema == nil || schema.Properties == nil { + return + } + codes := chain.EffectiveFieldCodes() + for name, prop := range schema.Properties { + if code, ok := codes[name]; ok { + switch code.Kind { + case zddc.FieldCodeEnum: + // Populate Enum with the code keys (sorted for + // deterministic order). Labels carries the + // human-readable display strings. + keys := make([]string, 0, len(code.Codes)) + for k := range code.Codes { + keys = append(keys, k) + } + sort.Strings(keys) + if len(prop.Enum) == 0 { + prop.Enum = make([]any, len(keys)) + for i, k := range keys { + prop.Enum[i] = k + } + } + if prop.Labels == nil && len(code.Codes) > 0 { + prop.Labels = make(map[string]string, len(code.Codes)) + for k, v := range code.Codes { + prop.Labels[k] = v + } + } + case zddc.FieldCodePattern: + if prop.Pattern == "" { + prop.Pattern = code.Pattern + } + case zddc.FieldCodeFree: + // No constraint to inject; description is the + // only field and the operator can author it + // directly in the form spec. + } + } + } + + // Apply the matched records:-rule's readOnly + default to + // matching properties. We probe with "*.yaml" — the records + // entries shipped in the embedded defaults all match that + // glob; operator schemas with literal-keyed rules would still + // be honoured by serveFormCreateRollup but won't be reflected + // in the form-render augmentation here. + if _, rule, ok := chain.EffectiveRecordRule("placeholder.yaml"); ok { + for _, name := range rule.Locked { + if prop, present := schema.Properties[name]; present { + prop.ReadOnly = true + } + } + for name, val := range rule.FieldDefaults { + if prop, present := schema.Properties[name]; present { + if prop.Default == nil { + prop.Default = val + } + } + } + } +} + // ---- small helpers ---- func sha8(data []byte) string { diff --git a/zddc/internal/jsonschema/schema.go b/zddc/internal/jsonschema/schema.go index 689a5cc..79f1862 100644 --- a/zddc/internal/jsonschema/schema.go +++ b/zddc/internal/jsonschema/schema.go @@ -32,11 +32,25 @@ type Schema struct { Maximum *float64 `yaml:"maximum" json:"maximum,omitempty"` MinLength *int `yaml:"minLength" json:"minLength,omitempty"` MaxLength *int `yaml:"maxLength" json:"maxLength,omitempty"` + Pattern string `yaml:"pattern,omitempty" json:"pattern,omitempty"` Format string `yaml:"format" json:"format,omitempty"` AdditionalProperties any `yaml:"additionalProperties" json:"additionalProperties,omitempty"` Title string `yaml:"title" json:"title,omitempty"` Description string `yaml:"description" json:"description,omitempty"` Default any `yaml:"default" json:"default,omitempty"` + + // ReadOnly: if true, clients render this property as disabled and + // suppress edit affordances. Not enforced by the validator (a + // rejected write is the wrong UX for a read-only field — the + // server strips the value instead). Surface to clients via JSON. + ReadOnly bool `yaml:"readOnly,omitempty" json:"readOnly,omitempty"` + + // Labels is an extension used by the form renderer to display a + // human label next to a code in enum-with-labels dropdowns. The + // key matches an entry in Enum; the value is the display label + // (e.g. {"ACM": "Acme Inc"}). Server-injected from the cascade's + // field_codes:codes map; not enforced by the validator. + Labels map[string]string `yaml:"x-labels,omitempty" json:"x-labels,omitempty"` } // Error reports a single validation failure. Path is a JSON Pointer (RFC 6901)