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>
215 lines
8 KiB
Go
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
|
|
}
|