ZDDC/zddc/internal/zddc/field_codes.go
ZDDC 882d5e4c86 feat(zddc-server): server-stamped audit + history for record YAMLs
Adds cascade-driven schema + immutable audit history for the three table-style
record stores (mdl, rsk, ssr). Two new .zddc top-level keys carry the rules:

- field_codes: discriminated-union vocabulary (kind: enum|pattern|free) for
  the components used to compose tracking-number filenames and constrain
  record bodies. Map-merge across the cascade, mirror of apps: semantics.
- records: per-pattern rules (filename_format, field_defaults, locked,
  row_field, row_scope_fields). Filename-pattern scoping lets the SSR rule
  live at the party-folder level without bleeding onto mdl/rsk siblings.

PUTs to record YAML files route through a new WriteWithHistory orchestrator
(internal/handler/history.go) which:
- strips six client-supplied audit fields (created_at/by, updated_at/by,
  revision, previous_sha) so the client can't forge them
- validates body values against the cascade-resolved field_codes
- enforces filename_format composition (URL basename must match body fields)
- checks locked: defaults (422 mismatch)
- archives prior bytes to <dir>/.history/<base>/<RFC3339Nano>-<sha8>.<ext>
- stamps server-managed audit fields and writes the live file

History-before-live ordering preserves the prior version even on mid-write
crash. previous_sha forms a hash chain across revisions for tamper evidence.

The embedded defaults.zddc.yaml now declares records: entries for mdl, rsk,
and ssr.yaml. RSK rows carry the table-tracking components + row sequence
(filename = <table-tracking>-<row>); MDL rows compose to their own
tracking number; SSR records' identity is the party folder name.

GET <record>.yaml?history=1 returns a JSON list of prior revisions, ACL
gated identically to the live record. dot-segment rejection in
resolveTargetPath protects .history/ from direct client writes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 09:48:58 -05:00

215 lines
8 KiB
Go

package zddc
import (
"fmt"
"path"
"regexp"
"gopkg.in/yaml.v3"
)
// FieldCodeKind discriminates the validation behaviour of a field code.
type FieldCodeKind string
const (
// FieldCodeEnum: body value must be one of the keys in Codes.
FieldCodeEnum FieldCodeKind = "enum"
// FieldCodePattern: body value must match Pattern (anchored).
FieldCodePattern FieldCodeKind = "pattern"
// FieldCodeFree: any string passes (Description is human-readable
// only).
FieldCodeFree FieldCodeKind = "free"
)
// FieldCode is one entry in a .zddc field_codes: map. A field code
// declares the allowed shape of one component used in record bodies
// and filename composition (e.g. originator, discipline, type,
// sequence). Operators define these at the project root or higher
// in the cascade; child levels can narrow or replace individual
// codes via the map-merge semantics in mergeOverlay.
//
// One discriminator field (Kind) selects which of the three shape
// fields applies:
//
// - Kind=enum: Codes is a code → human-label map. Labels surface
// in form dropdowns; validation checks membership of the key
// set only.
// - Kind=pattern: Pattern is a regular expression matched against
// the whole value (the server anchors it with ^…$ on compile).
// - Kind=free: no constraint; Description is the only field used
// and it's surfaced as help-text in the form UI.
//
// The struct is intentionally permissive in storage (all shape
// fields are present) but enforces grammar at unmarshal time so
// downstream consumers can rely on the kind matching the populated
// fields.
type FieldCode struct {
Kind FieldCodeKind `yaml:"kind" json:"kind"`
Codes map[string]string `yaml:"codes,omitempty" json:"codes,omitempty"`
Pattern string `yaml:"pattern,omitempty" json:"pattern,omitempty"`
Description string `yaml:"description,omitempty" json:"description,omitempty"`
}
// UnmarshalYAML enforces the discriminated-union grammar. A FieldCode
// must declare exactly one of {codes, pattern} matching its kind; for
// free codes neither is allowed (Description is optional).
func (fc *FieldCode) UnmarshalYAML(node *yaml.Node) error {
// Decode into a plain struct first so we can validate after.
type raw FieldCode
var r raw
if err := node.Decode(&r); err != nil {
return err
}
switch r.Kind {
case FieldCodeEnum:
if len(r.Codes) == 0 {
return fmt.Errorf("field_code kind=enum requires non-empty codes:")
}
if r.Pattern != "" {
return fmt.Errorf("field_code kind=enum must not declare pattern:")
}
case FieldCodePattern:
if r.Pattern == "" {
return fmt.Errorf("field_code kind=pattern requires pattern:")
}
if len(r.Codes) > 0 {
return fmt.Errorf("field_code kind=pattern must not declare codes:")
}
if _, err := regexp.Compile("^(?:" + r.Pattern + ")$"); err != nil {
return fmt.Errorf("field_code kind=pattern: invalid regex: %w", err)
}
case FieldCodeFree:
if len(r.Codes) > 0 || r.Pattern != "" {
return fmt.Errorf("field_code kind=free must not declare codes: or pattern:")
}
case "":
return fmt.Errorf("field_code: kind is required (one of enum|pattern|free)")
default:
return fmt.Errorf("field_code: unknown kind %q (want enum|pattern|free)", r.Kind)
}
*fc = FieldCode(r)
return nil
}
// Validate checks a body value against the FieldCode's rule. Empty
// values are allowed only when the caller treats this code as
// optional — Validate itself doesn't know about optionality, only
// shape.
func (fc FieldCode) Validate(value string) error {
switch fc.Kind {
case FieldCodeEnum:
if _, ok := fc.Codes[value]; !ok {
return fmt.Errorf("value %q is not in the allowed code set", value)
}
case FieldCodePattern:
// Anchor at unmarshal-time would require holding a *Regexp on
// the struct; for simplicity we recompile here. Hot paths can
// cache via a sync.Map keyed by the pattern string if this
// shows up in profiles.
re, err := regexp.Compile("^(?:" + fc.Pattern + ")$")
if err != nil {
return fmt.Errorf("internal: pattern recompile: %w", err)
}
if !re.MatchString(value) {
return fmt.Errorf("value %q does not match pattern %q", value, fc.Pattern)
}
case FieldCodeFree:
// No constraint.
}
return nil
}
// RecordRule is one entry in a .zddc records: map. The map key is a
// filename-basename pattern (literal name like "ssr.yaml" or a glob
// like "*.yaml"); the entry describes the rules that apply to files
// matching that pattern in the directory at-or-below this cascade
// level.
//
// FilenameFormat is a composition template referencing field-code
// keys in braces, with `?` marking optional segments (omitted from
// the filename if the body field is empty or missing). Example:
//
// {originator}-{phase?}-{project}-{type}-{sequence}{suffix?}
//
// Field references must match keys declared in the cascade's
// field_codes: map; ServerSide composition + validation enforces
// that the body fields validate against the codes before composing.
//
// FieldDefaults supplies per-folder default values that the server
// injects when the client omits the field. Combined with Locked, a
// folder can pin a field to a single value (e.g. rsk/ pinning
// type=RSK).
//
// Locked is the list of field names that must not be overridden by
// the client. When the client submits a value that differs from
// FieldDefaults[field], the server returns 422.
//
// RowField names the per-row sequence field for tables whose rows
// are children of a parent deliverable (RSK pattern). When set,
// POST-create requests omit the field and the server assigns the
// next available value within the group identified by
// RowScopeFields. PUT-update preserves the existing value.
//
// RowScopeFields names the fields that, together, identify the
// parent deliverable that a row belongs to. Two records with the
// same scope-field values share a row-numbering sequence.
type RecordRule struct {
FilenameFormat string `yaml:"filename_format,omitempty" json:"filename_format,omitempty"`
FieldDefaults map[string]string `yaml:"field_defaults,omitempty" json:"field_defaults,omitempty"`
Locked []string `yaml:"locked,omitempty" json:"locked,omitempty"`
RowField string `yaml:"row_field,omitempty" json:"row_field,omitempty"`
RowScopeFields []string `yaml:"row_scope_fields,omitempty" json:"row_scope_fields,omitempty"`
}
// mergeRecordRule composes two RecordRules, top taking precedence on
// scalars and FieldDefaults map-merge; Locked is concat-dedupe so
// children can add locks but never unlock. Used by mergeOverlay for
// per-pattern entries in Records.
func mergeRecordRule(base, top RecordRule) RecordRule {
out := base
if top.FilenameFormat != "" {
out.FilenameFormat = top.FilenameFormat
}
out.FieldDefaults = mergeStringMap(out.FieldDefaults, top.FieldDefaults)
out.Locked = mergeStringSlice(out.Locked, top.Locked)
if top.RowField != "" {
out.RowField = top.RowField
}
if len(top.RowScopeFields) > 0 {
// Scope-fields are an ordered list (the composition relies on
// the order); top entirely replaces base when set.
out.RowScopeFields = append([]string(nil), top.RowScopeFields...)
}
return out
}
// matchRecordRule picks the RecordRule that applies to a given file
// basename. Literal-key matches win over glob matches; for globs,
// the first matching entry in iteration order wins (callers wanting
// determinism should structure their patterns disjointly).
//
// Returns ("", RecordRule{}, false) when no entry matches.
func matchRecordRule(rules map[string]RecordRule, basename string) (string, RecordRule, bool) {
if rules == nil {
return "", RecordRule{}, false
}
// Pass 1: exact key.
if r, ok := rules[basename]; ok {
return basename, r, true
}
// Pass 2: glob via path.Match (basename-only, no separators).
for k, v := range rules {
if k == basename {
continue
}
ok, err := path.Match(k, basename)
if err != nil {
// Bad pattern: skip rather than aborting the cascade walk.
continue
}
if ok {
return k, v, true
}
}
return "", RecordRule{}, false
}