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>
This commit is contained in:
ZDDC 2026-05-19 09:48:58 -05:00
parent f9ba493145
commit 882d5e4c86
8 changed files with 1081 additions and 7 deletions

View file

@ -1233,6 +1233,15 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
// see RecognizeVirtualConvert). The .md source serves
// normally here.)
// Record-history list: GET <record>.yaml?history=1 returns the
// list of prior revisions stored under <dir>/.history/<base>/.
// ACL already passed (parent-dir chain). Non-record paths fall
// through to the normal file serve.
if (r.Method == http.MethodGet || r.Method == http.MethodHead) && r.URL.Query().Get("history") == "1" {
handler.ServeHistoryList(w, r, absPath)
return
}
handler.ServeFile(w, r, absPath)
}

View file

@ -409,11 +409,36 @@ func serveFilePut(cfg config.Config, w http.ResponseWriter, r *http.Request) {
}
}
// Record files (mdl rows, rsk rows, ssr.yaml) route through
// WriteWithHistory which strips client-supplied audit fields,
// stamps server-managed ones, archives the prior version to
// <dir>/.history/<base>/, validates body fields against
// cascade-resolved field_codes, and enforces filename_format
// composition. Non-record YAML files (table.yaml, form.yaml,
// .zddc) and binary files take the plain write path below.
finalBody := body
stamped := false
if isRecordPath(abs) {
res, verrs, herr := WriteWithHistory(cfg, abs, cleanURL, body, EmailFromContext(r))
if herr != nil {
auditFile(r, "put", cleanURL, http.StatusInternalServerError, len(body), herr)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if len(verrs) > 0 {
writeValidationErrors(w, verrs)
auditFile(r, "put", cleanURL, http.StatusUnprocessableEntity, len(body), fmt.Errorf("validation: %d errors", len(verrs)))
return
}
finalBody = res.FinalBody
stamped = true
} else {
if err := zddc.WriteAtomic(abs, body); err != nil {
auditFile(r, "put", cleanURL, http.StatusInternalServerError, len(body), err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
}
// Invalidate ETag cache (static.go memoizes by mtime; rename produces
// a fresh mtime so a stale entry is harmless, but clearing is cheap).
etagCacheM.Delete(abs)
@ -421,15 +446,47 @@ func serveFilePut(cfg config.Config, w http.ResponseWriter, r *http.Request) {
// the sibling .converted/ dir for this source.
purgeConverted(abs)
etag := fileETag(body)
etag := fileETag(finalBody)
w.Header().Set("ETag", `"`+etag+`"`)
w.Header().Set("X-ZDDC-Source", "fileapi:put")
respStatus := http.StatusCreated
if existed {
respStatus = http.StatusOK
}
// For record-stamped writes, echo the server-truth body so the
// tables save flow can update row.data without a re-GET. Other
// writes return no body (historical contract preserved).
if stamped {
w.Header().Set("Content-Type", "application/yaml")
w.WriteHeader(respStatus)
auditFile(r, "put", cleanURL, respStatus, len(body), nil)
_, _ = w.Write(finalBody)
} else {
w.WriteHeader(respStatus)
}
auditFile(r, "put", cleanURL, respStatus, len(finalBody), nil)
}
// isRecordPath returns true if abs is a candidate for record-style
// handling (audit stamping + history). Excludes the well-known
// configuration filenames that share record directories: table.yaml
// (table spec), form.yaml (form schema), and .zddc (cascade
// configuration). Non-YAML extensions also fall through to the plain
// write path.
func isRecordPath(abs string) bool {
base := filepath.Base(abs)
switch base {
case "table.yaml", "form.yaml", ".zddc":
return false
}
ext := filepath.Ext(base)
if ext != ".yaml" && ext != ".yml" {
return false
}
// Exclude *.table.yaml and *.form.yaml (alternate spec naming).
if strings.HasSuffix(base, ".table.yaml") || strings.HasSuffix(base, ".form.yaml") {
return false
}
return true
}
func serveFileDelete(cfg config.Config, w http.ResponseWriter, r *http.Request) {

View file

@ -0,0 +1,629 @@
// Package handler — history.go: orchestrates writes of "record" YAML
// files (mdl rows, rsk rows, ssr.yaml) with three guarantees the
// generic file API cannot make on its own:
//
// 1. Audit fields are server-managed. created_at / created_by /
// updated_at / updated_by / revision / previous_sha are stripped
// from incoming bodies and stamped from the request context,
// making client-side forgery impossible.
//
// 2. Prior bytes are preserved. Before the live file is overwritten
// the previous content is copied (byte-for-byte) into
// <dir>/.history/<base>/<RFC3339Nano-UTC>-<sha8>.<ext>. The
// filename embeds the timestamp + the SHA-256 prefix of the prior
// bytes — the same value that's stamped into the new record's
// previous_sha field — so the chain is auditable.
//
// 3. Filename composition is enforced. When the matched RecordRule
// declares a filename_format, the server composes the expected
// basename from body fields and rejects writes whose URL doesn't
// agree. This binds the on-disk identity to the body's
// tracking-number components, eliminating drift.
//
// Records are identified by the cascade: a .zddc records: entry
// matched against the basename selects the rule. Files that don't
// match any rule fall through to a plain write — non-record YAML
// (table.yaml, form.yaml, plain documents) is unaffected.
package handler
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/jsonschema"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
"gopkg.in/yaml.v3"
)
// Audit-field key names. Snake-case to match the existing .zddc
// `created_by:` precedent rather than the camelCase used in form
// schemas (those describe domain data; these describe provenance).
const (
auditFieldCreatedAt = "created_at"
auditFieldCreatedBy = "created_by"
auditFieldUpdatedAt = "updated_at"
auditFieldUpdatedBy = "updated_by"
auditFieldRevision = "revision"
auditFieldPreviousSha = "previous_sha"
)
// historyDirName is the dot-prefixed bookkeeping folder under each
// record-containing directory. resolveTargetPath's dot-segment
// rejection means no client URL can reach into .history/ — only the
// server's own history-write code path touches it.
const historyDirName = ".history"
// WriteRecordResult carries what serveFilePut needs to surface a
// response after a successful record write.
type WriteRecordResult struct {
FinalBody []byte // bytes actually written to disk (after stamping)
Created bool // true if no prior file existed (response 201 vs 200)
}
// WriteWithHistory orchestrates a record write at abs (which must be
// the canonical on-disk path — virtual-view rewriting already
// applied). cleanURL is the URL the caller surfaces (for audit
// logging). body is the raw request bytes.
//
// Returns:
// - res, nil, nil: success; caller writes 200/201 + ETag.
// - _, errs, nil: 422 with the validation errors (locked
// mismatch, field_code violation, filename
// composition mismatch).
// - _, _, err: internal error; caller writes 500.
//
// The function does NOT do ACL, ETag-precondition, or canonical-
// ancestor seeding — those are still serveFilePut's job and run
// before this call. The function DOES handle prior-bytes capture,
// audit stamping, history write, and live write.
func WriteWithHistory(cfg config.Config, abs, cleanURL string, body []byte, principalEmail string) (WriteRecordResult, []jsonschema.Error, error) {
dir := filepath.Dir(abs)
base := filepath.Base(abs)
// Resolve cascade at the record's parent dir.
chain, err := zddc.EffectivePolicy(cfg.Root, dir)
if err != nil {
return WriteRecordResult{}, nil, fmt.Errorf("effective policy: %w", err)
}
_, rule, hasRule := chain.EffectiveRecordRule(base)
// Read prior bytes (nil if create).
var priorBody []byte
priorExisted := false
if data, err := os.ReadFile(abs); err == nil {
priorBody = data
priorExisted = true
} else if !errors.Is(err, os.ErrNotExist) {
return WriteRecordResult{}, nil, fmt.Errorf("read prior: %w", err)
}
// Parse incoming body as a YAML map. Empty body is allowed
// (the schema validator catches required-field omissions, or
// the caller-side spec is permissive); we use an empty map.
bodyMap := map[string]any{}
if len(body) > 0 {
if err := yaml.Unmarshal(body, &bodyMap); err != nil {
return WriteRecordResult{}, []jsonschema.Error{{Path: "/", Message: "body is not valid YAML: " + err.Error()}}, nil
}
if bodyMap == nil {
bodyMap = map[string]any{}
}
}
// Strip client-supplied audit fields. The server is the sole
// authority for these; any value we'd accept here is forgeable.
stripAuditFields(bodyMap)
// Honor records: rule. If no rule matched the basename, fall
// through to a plain write (no stamping, no history) — this
// covers non-record YAML files like table.yaml that may share
// a directory with records.
if !hasRule {
if err := zddc.WriteAtomic(abs, body); err != nil {
return WriteRecordResult{}, nil, fmt.Errorf("write: %w", err)
}
return WriteRecordResult{FinalBody: body, Created: !priorExisted}, nil, nil
}
codes := chain.EffectiveFieldCodes()
// Inject field_defaults for keys the body omitted (so the
// stamped result is self-describing) and check locked: against
// any conflicting client values.
var verrs []jsonschema.Error
for k, want := range rule.FieldDefaults {
got, present := bodyMap[k]
if !present {
bodyMap[k] = want
continue
}
if locked := containsString(rule.Locked, k); locked {
gotStr := asString(got)
if gotStr != want {
verrs = append(verrs, jsonschema.Error{
Path: "/" + k,
Message: fmt.Sprintf("field is locked to %q in this folder; got %q", want, gotStr),
})
}
}
}
// Validate body values against field_codes (best-effort: only
// fields actually present in the body are checked; absent
// fields are someone else's concern — typically the form
// schema's required: list).
for k, code := range codes {
raw, ok := bodyMap[k]
if !ok {
continue
}
s := asString(raw)
if s == "" {
continue // empty/optional — schema enforces presence
}
if err := code.Validate(s); err != nil {
verrs = append(verrs, jsonschema.Error{
Path: "/" + k,
Message: err.Error(),
})
}
}
if len(verrs) > 0 {
return WriteRecordResult{}, verrs, nil
}
// Compose filename from body when filename_format is set, and
// verify the URL basename matches. Skipped when the rule has
// no format (SSR: identity is the parent folder name).
if rule.FilenameFormat != "" {
composed, cerr := composeFilename(rule.FilenameFormat, bodyMap)
if cerr != nil {
return WriteRecordResult{}, []jsonschema.Error{{Path: "/", Message: cerr.Error()}}, nil
}
ext := filepath.Ext(base)
composedWithExt := composed + ext
if composedWithExt != base {
return WriteRecordResult{}, []jsonschema.Error{{
Path: "/",
Message: fmt.Sprintf("filename mismatch: URL is %q, body composes to %q", base, composedWithExt),
}}, nil
}
}
// Stamp audit fields. On create: created_* and updated_* are
// both the current principal/timestamp; revision = 1. On
// update: preserve created_* (parse from priorBody), refresh
// updated_*, increment revision, set previous_sha = sha-prefix
// of priorBody.
now := time.Now().UTC().Format(time.RFC3339Nano)
if principalEmail == "" {
principalEmail = "anonymous"
}
priorAudit := parsePriorAudit(priorBody)
if !priorExisted {
bodyMap[auditFieldCreatedAt] = now
bodyMap[auditFieldCreatedBy] = principalEmail
bodyMap[auditFieldRevision] = 1
} else {
if priorAudit.createdAt != "" {
bodyMap[auditFieldCreatedAt] = priorAudit.createdAt
} else {
// Lazy migration: the prior file had no created_*
// stamp. Treat this write as the establishment of
// audit history — created and updated are the same
// principal/timestamp (we don't know who originally
// authored it).
bodyMap[auditFieldCreatedAt] = now
}
if priorAudit.createdBy != "" {
bodyMap[auditFieldCreatedBy] = priorAudit.createdBy
} else {
bodyMap[auditFieldCreatedBy] = principalEmail
}
bodyMap[auditFieldRevision] = priorAudit.revision + 1
bodyMap[auditFieldPreviousSha] = sha8(priorBody)
}
bodyMap[auditFieldUpdatedAt] = now
bodyMap[auditFieldUpdatedBy] = principalEmail
finalBody, err := yaml.Marshal(bodyMap)
if err != nil {
return WriteRecordResult{}, nil, fmt.Errorf("marshal: %w", err)
}
// Write history BEFORE live. If we crash after history but
// before live, the prior version is safe (still on disk under
// its history filename). The reverse order would lose the
// prior bytes if the live write succeeded but history failed.
// On a clean retry, the history filename is deterministic
// (timestamp+sha8 of priorBody) — rewriting it idempotently
// is harmless when the live write later succeeds.
if priorExisted {
histDir := filepath.Join(dir, historyDirName, stripExt(base))
if err := os.MkdirAll(histDir, 0o755); err != nil {
return WriteRecordResult{}, nil, fmt.Errorf("mkdir history: %w", err)
}
histName := now + "-" + sha8(priorBody) + filepath.Ext(base)
histPath := filepath.Join(histDir, histName)
if err := zddc.WriteAtomic(histPath, priorBody); err != nil {
return WriteRecordResult{}, nil, fmt.Errorf("write history: %w", err)
}
}
if err := zddc.WriteAtomic(abs, finalBody); err != nil {
return WriteRecordResult{}, nil, fmt.Errorf("write live: %w", err)
}
return WriteRecordResult{FinalBody: finalBody, Created: !priorExisted}, nil, nil
}
// priorAuditSnapshot is the minimum we need from a prior version's
// body to stamp the next revision: who created it (preserved
// forever) and what revision number it carried (so we can ++).
type priorAuditSnapshot struct {
createdAt string
createdBy string
revision int
}
func parsePriorAudit(body []byte) priorAuditSnapshot {
if len(body) == 0 {
return priorAuditSnapshot{}
}
m := map[string]any{}
if err := yaml.Unmarshal(body, &m); err != nil {
return priorAuditSnapshot{}
}
out := priorAuditSnapshot{}
if v, ok := m[auditFieldCreatedAt].(string); ok {
out.createdAt = v
}
if v, ok := m[auditFieldCreatedBy].(string); ok {
out.createdBy = v
}
switch v := m[auditFieldRevision].(type) {
case int:
out.revision = v
case int64:
out.revision = int(v)
case float64:
out.revision = int(v)
}
return out
}
func stripAuditFields(m map[string]any) {
delete(m, auditFieldCreatedAt)
delete(m, auditFieldCreatedBy)
delete(m, auditFieldUpdatedAt)
delete(m, auditFieldUpdatedBy)
delete(m, auditFieldRevision)
delete(m, auditFieldPreviousSha)
}
// composeFilename interpolates a filename_format template against
// the supplied body fields. Placeholders are {fieldname} for
// required segments and {fieldname?} for optional ones; an optional
// placeholder with an empty/missing body field is dropped along
// with one adjacent separator if both neighbors are static text.
//
// Example, format = "{originator}-{phase?}-{project}-{type}-{sequence}{suffix?}"
// with body = {originator: ACM, project: PRJ, type: SPC, sequence: 0001}
// (phase + suffix absent) yields "ACM-PRJ-SPC-0001".
//
// Adjacent-separator handling: the function recognises a "-" or "_"
// literal immediately preceding an optional placeholder and drops
// it together with the placeholder when the field is empty. Static
// text not adjacent to a placeholder is preserved as-is. A literal
// "{" or "}" must be escaped as "{{" / "}}" (currently unused —
// the embedded defaults don't need it).
func composeFilename(format string, body map[string]any) (string, error) {
var out strings.Builder
out.Grow(len(format))
i := 0
for i < len(format) {
c := format[i]
// Literal { or } escapes: {{ → {, }} → }.
if c == '{' && i+1 < len(format) && format[i+1] == '{' {
out.WriteByte('{')
i += 2
continue
}
if c == '}' && i+1 < len(format) && format[i+1] == '}' {
out.WriteByte('}')
i += 2
continue
}
if c != '{' {
out.WriteByte(c)
i++
continue
}
// Placeholder: scan to '}'.
end := strings.IndexByte(format[i+1:], '}')
if end == -1 {
return "", fmt.Errorf("filename_format: unterminated placeholder at offset %d", i)
}
name := format[i+1 : i+1+end]
i += end + 2 // past the '}'
optional := false
if strings.HasSuffix(name, "?") {
optional = true
name = name[:len(name)-1]
}
val := asString(body[name])
if val == "" {
if !optional {
return "", fmt.Errorf("filename_format: required field %q is missing or empty", name)
}
// Drop the trailing separator we just wrote, if any:
// avoids "A-B-" or "A--C" when an optional middle
// segment elides.
s := out.String()
if n := len(s); n > 0 && (s[n-1] == '-' || s[n-1] == '_') {
out.Reset()
out.WriteString(s[:n-1])
}
// And skip a leading separator that immediately
// follows the elided field.
if i < len(format) && (format[i] == '-' || format[i] == '_') {
i++
}
continue
}
out.WriteString(val)
}
return out.String(), nil
}
// AssignNextRow finds the next free row sequence within the
// row-scope group identified by scopeFields. Used by POST-create
// handlers (rsk row creation) before invoking WriteWithHistory.
// Returns the zero-padded string value to inject into bodyMap[rowField].
//
// Width is fixed at 3 (covers up to 999 rows per table). Operators
// who need more declare a per-deployment field_codes:row pattern;
// the width here is for the auto-assign output, not for parsing
// (which uses the matched pattern from the cascade).
func AssignNextRow(dir, rowField string, scopeFields []string, body map[string]any) (string, error) {
if rowField == "" {
return "", fmt.Errorf("row_field is empty")
}
entries, err := os.ReadDir(dir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return "001", nil
}
return "", err
}
max := 0
rowRe := regexp.MustCompile(`^[0-9]+$`)
for _, ent := range entries {
if ent.IsDir() {
continue
}
name := ent.Name()
if !strings.HasSuffix(name, filepath.Ext(name)) {
continue
}
path := filepath.Join(dir, name)
data, err := os.ReadFile(path)
if err != nil {
continue
}
other := map[string]any{}
if err := yaml.Unmarshal(data, &other); err != nil {
continue
}
// Same scope group?
sameGroup := true
for _, f := range scopeFields {
if asString(other[f]) != asString(body[f]) {
sameGroup = false
break
}
}
if !sameGroup {
continue
}
v := asString(other[rowField])
if !rowRe.MatchString(v) {
continue
}
n := atoiSafe(v)
if n > max {
max = n
}
}
return fmt.Sprintf("%03d", max+1), nil
}
// ServeHistoryList responds to GET <record>.yaml?history=1 with the
// list of prior revisions archived under .history/<base>/. The
// caller has already evaluated ACL against the live record (read
// permission on the parent dir gates history visibility too — if
// you can read the current state you can read its history).
//
// Returns 404 when abs doesn't exist or isn't a record (the caller
// should rely on the live record's GET 404 path instead of leaking
// existence here, but defense in depth costs nothing).
func ServeHistoryList(w http.ResponseWriter, r *http.Request, abs string) {
if !isRecordPathForHistory(abs) {
http.NotFound(w, r)
return
}
if _, err := os.Stat(abs); err != nil {
if errors.Is(err, os.ErrNotExist) {
// Record file gone; the caller's normal 404 path
// suppresses existence-leak, so we mirror that.
http.NotFound(w, r)
return
}
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
entries, err := ListHistory(abs)
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-ZDDC-Source", "history-list")
// json.NewEncoder for streaming; sort already happened in
// ListHistory.
enc := json.NewEncoder(w)
if err := enc.Encode(entries); err != nil {
// Body already partially flushed at this point; nothing to do.
return
}
}
// isRecordPathForHistory mirrors isRecordPath but lives in this file
// so the history-list handler doesn't need to import its caller's
// internal helper. Keep the two in sync — if one accepts a new
// extension the other should too.
func isRecordPathForHistory(abs string) bool {
base := filepath.Base(abs)
switch base {
case "table.yaml", "form.yaml", ".zddc":
return false
}
ext := filepath.Ext(base)
if ext != ".yaml" && ext != ".yml" {
return false
}
if strings.HasSuffix(base, ".table.yaml") || strings.HasSuffix(base, ".form.yaml") {
return false
}
return true
}
// HistoryEntry describes one prior revision of a record, as listed
// by ServeHistoryList.
type HistoryEntry struct {
Revision int `json:"revision"`
Ts string `json:"ts"`
By string `json:"by"`
Sha8 string `json:"sha"`
Path string `json:"path"`
}
// ListHistory walks the .history/<base>/ directory adjacent to abs
// and returns one HistoryEntry per archived revision, sorted newest
// first. Empty list when the dir doesn't exist (e.g. record never
// updated).
//
// Filename format: <RFC3339Nano>-<sha8>.<ext>. Author/revision are
// read from the YAML body's audit fields — those describe the
// archived bytes' provenance.
func ListHistory(abs string) ([]HistoryEntry, error) {
dir := filepath.Dir(abs)
base := filepath.Base(abs)
histDir := filepath.Join(dir, historyDirName, stripExt(base))
ents, err := os.ReadDir(histDir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return nil, err
}
out := make([]HistoryEntry, 0, len(ents))
for _, e := range ents {
if e.IsDir() {
continue
}
name := e.Name()
// Expected shape: <ts>-<sha8>.<ext>. Parse from the right
// to be lenient about timestamps that contain '-'.
ext := filepath.Ext(name)
stem := strings.TrimSuffix(name, ext)
idx := strings.LastIndexByte(stem, '-')
if idx < 0 {
continue
}
ts := stem[:idx]
sha := stem[idx+1:]
entry := HistoryEntry{Ts: ts, Sha8: sha, Path: filepath.Join(historyDirName, stripExt(base), name)}
// Pull author + revision from the archived body.
if data, err := os.ReadFile(filepath.Join(histDir, name)); err == nil {
snap := parsePriorAudit(data)
entry.Revision = snap.revision
entry.By = snap.createdBy
// updated_by is more informative when present.
m := map[string]any{}
if err := yaml.Unmarshal(data, &m); err == nil {
if v, ok := m[auditFieldUpdatedBy].(string); ok && v != "" {
entry.By = v
}
}
}
out = append(out, entry)
}
sort.Slice(out, func(i, j int) bool { return out[i].Ts > out[j].Ts })
return out, nil
}
// ---- small helpers ----
func sha8(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])[:8]
}
func stripExt(name string) string {
return strings.TrimSuffix(name, filepath.Ext(name))
}
func containsString(haystack []string, needle string) bool {
for _, s := range haystack {
if s == needle {
return true
}
}
return false
}
func asString(v any) string {
switch s := v.(type) {
case string:
return s
case nil:
return ""
case int:
return fmt.Sprintf("%d", s)
case int64:
return fmt.Sprintf("%d", s)
case float64:
// Strip trailing .0 for the common integer-in-JSON case.
if s == float64(int64(s)) {
return fmt.Sprintf("%d", int64(s))
}
return fmt.Sprintf("%v", s)
default:
return fmt.Sprintf("%v", s)
}
}
func atoiSafe(s string) int {
n := 0
for _, c := range s {
if c < '0' || c > '9' {
return 0
}
n = n*10 + int(c-'0')
}
return n
}

View file

@ -218,6 +218,60 @@ func EffectivePolicy(fsRoot, dirPath string) (PolicyChain, error) {
return chain, nil
}
// EffectiveFieldCodes returns the merged field-code vocabulary
// visible at the leaf of this chain. Walks root → leaf, applying
// map-merge per top-level key (a leaf entry for the same code
// replaces the root entry, mirroring mergeOverlay).
//
// Embedded defaults are layered in below the on-disk root unless
// inherit:false on any level dropped them (chain.Embedded is zeroed
// in that case, so reading it as a baseline is safe either way).
func (chain PolicyChain) EffectiveFieldCodes() map[string]FieldCode {
out := map[string]FieldCode{}
for k, v := range chain.Embedded.FieldCodes {
out[k] = v
}
for _, lvl := range chain.Levels {
for k, v := range lvl.FieldCodes {
out[k] = v
}
}
return out
}
// EffectiveRecordRule returns the merged RecordRule for files whose
// basename matches a pattern in any level's Records map. Walks root
// → leaf, mergeRecordRule-combining successive matches so a
// per-folder .zddc can refine an ancestor's rule (add a lock, set a
// default) without restating everything.
//
// pattern is the most-specific pattern that matched (deepest level's
// chosen key); rule is the merged result; ok is false when no level
// declared a matching pattern.
//
// Matching at each level prefers literal-key over glob; see
// matchRecordRule.
func (chain PolicyChain) EffectiveRecordRule(basename string) (string, RecordRule, bool) {
merged := RecordRule{}
any := false
pattern := ""
consider := func(rules map[string]RecordRule) {
if pat, rule, hit := matchRecordRule(rules, basename); hit {
merged = mergeRecordRule(merged, rule)
pattern = pat
any = true
}
}
consider(chain.Embedded.Records)
for _, lvl := range chain.Levels {
consider(lvl.Records)
}
if !any {
return "", RecordRule{}, false
}
return pattern, merged, true
}
// InvalidateCache removes the cached policy for dirPath and all descendants.
func InvalidateCache(dirPath string) {
dirPath = filepath.Clean(dirPath)

View file

@ -145,6 +145,17 @@ paths:
# to received/issued). That lets them set up the
# counterparty's own .zddc afterward.
auto_own: true
# SSR record: the party folder's ssr.yaml carries this
# party's vendor / contract / status data. Scoped by
# filename pattern so the lock on `kind` only applies to
# ssr.yaml — the mdl/, rsk/, received/ subfolders are
# untouched. No filename_format because identity is the
# party folder name, not a composed tracking number.
records:
"ssr.yaml":
field_defaults:
kind: SSR
locked: [kind]
paths:
mdl:
default_tool: tables
@ -153,6 +164,16 @@ paths:
# tables tool serves it from the embedded default
# spec even when the on-disk folder doesn't exist.
virtual: true
# MDL records: each .yaml file is an independent
# deliverable with its own composed tracking number.
# No locks — the row's body fields drive the
# filename, type is free-choice from the deployment's
# field_codes. Operators define field_codes at the
# project root (or higher) to supply the originator /
# discipline / type / sequence vocabularies.
records:
"*.yaml":
filename_format: "{originator}-{phase?}-{project}-{area?}-{discipline}-{type}-{sequence}{suffix?}"
rsk:
default_tool: tables
available_tools: [tables]
@ -160,6 +181,20 @@ paths:
# as mdl/. Embedded default-rsk spec backs it when no
# operator override is on disk.
virtual: true
# RSK records: each .yaml file is a row of a parent
# rsk-type deliverable. The table itself has a
# tracking number (same shape as an MDL deliverable
# with type=RSK); rows append a -{row} suffix. The
# server auto-assigns row within the row-scope group
# on POST-create.
records:
"*.yaml":
filename_format: "{originator}-{phase?}-{project}-{area?}-{discipline}-{type}-{sequence}{suffix?}-{row}"
field_defaults:
type: RSK
locked: [type]
row_field: row
row_scope_fields: [originator, phase, project, area, discipline, type, sequence, suffix]
incoming:
# incoming/ is the COUNTERPARTY's drop zone. The flow:
# 1. the other party's document controller uploads

View file

@ -0,0 +1,215 @@
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
}

View file

@ -343,6 +343,43 @@ type ZddcFile struct {
// menu item. Set in an ancestor .zddc to enable.
OnPlanReview *OnPlanReviewConfig `yaml:"on_plan_review,omitempty" json:"on_plan_review,omitempty"`
// FieldCodes declares the vocabulary of "field codes" used as
// components of tracking numbers and as constrained body fields
// on record YAMLs (mdl rows, rsk rows, ssr rows). The map key is
// the field name (originator, project, discipline, type,
// sequence, phase, area, suffix, row, …); the value declares the
// allowed shape via a discriminated union — see FieldCode.
//
// Cascade semantics: map-merge per top-level key (child entirely
// replaces parent's entry for the same code). This mirrors the
// Apps: cascade: a sub-tree can narrow or override a single
// field-code's vocabulary without dropping unrelated codes.
//
// Operators define field_codes at the project root (or higher),
// and lower-level .zddc files inherit. Empty in the embedded
// defaults — every deployment populates this per project.
FieldCodes map[string]FieldCode `yaml:"field_codes,omitempty" json:"field_codes,omitempty"`
// Records declares per-record-type rules keyed by filename
// pattern (literal basename like "ssr.yaml" or a glob like
// "*.yaml"). The entry describes the rules that apply to files
// matching that pattern in the directory the .zddc controls.
//
// See RecordRule for the structure: FilenameFormat (composition
// template), FieldDefaults (per-folder defaults), Locked (fields
// that must not be overridden), RowField + RowScopeFields (for
// table-with-rows record types like RSK).
//
// Cascade semantics: map-merge keyed by pattern; each RecordRule
// merges per-field (scalars overwrite, FieldDefaults map-merge,
// Locked concat-dedupe — see mergeRecordRule).
//
// Filename-pattern scoping is what lets SSR rules live at the
// party-folder level without bleeding onto the mdl/ or rsk/
// subfolders in the same folder: the SSR entry keys on
// "ssr.yaml", which only that one file matches.
Records map[string]RecordRule `yaml:"records,omitempty" json:"records,omitempty"`
// Paths declares virtual sub-directory rules without those
// directories needing to exist on disk. Each key is a single path
// segment — either a literal name or `*` (matches any segment).

View file

@ -158,6 +158,44 @@ func mergeOverlay(base, top ZddcFile) ZddcFile {
}
}
// FieldCodes: map-merge keyed by field name. Top wins on key
// clash — a sub-tree can narrow or replace a single code's
// vocabulary without dropping unrelated codes. Mirror of Apps.
if len(top.FieldCodes) > 0 {
if out.FieldCodes == nil {
out.FieldCodes = make(map[string]FieldCode, len(top.FieldCodes))
} else {
merged := make(map[string]FieldCode, len(out.FieldCodes)+len(top.FieldCodes))
for k, v := range out.FieldCodes {
merged[k] = v
}
out.FieldCodes = merged
}
for k, v := range top.FieldCodes {
out.FieldCodes[k] = v
}
}
// Records: map-merge keyed by filename pattern. Each entry's
// inner fields merge via mergeRecordRule (scalars overwrite,
// FieldDefaults map-merge, Locked concat-dedupe). Two different
// patterns at different cascade levels coexist as independent
// entries; identical patterns merge their contents.
if len(top.Records) > 0 {
if out.Records == nil {
out.Records = make(map[string]RecordRule, len(top.Records))
} else {
merged := make(map[string]RecordRule, len(out.Records)+len(top.Records))
for k, v := range out.Records {
merged[k] = v
}
out.Records = merged
}
for k, v := range top.Records {
out.Records[k] = mergeRecordRule(out.Records[k], v)
}
}
// Paths: top entirely replaces base if set. Recursive descent of
// the walker is what threads ancestor Paths through to the right
// level — merging Paths maps themselves at this layer would