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)