feat(forms): augment served schema with cascade field_codes + locks

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) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-19 09:58:21 -05:00
parent d35809cfd8
commit d947f616d1
3 changed files with 99 additions and 0 deletions

View file

@ -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,

View file

@ -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 {

View file

@ -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)