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:
parent
f9ba493145
commit
882d5e4c86
8 changed files with 1081 additions and 7 deletions
|
|
@ -1233,6 +1233,15 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
||||||
// see RecognizeVirtualConvert). The .md source serves
|
// see RecognizeVirtualConvert). The .md source serves
|
||||||
// normally here.)
|
// 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)
|
handler.ServeFile(w, r, absPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
if err := zddc.WriteAtomic(abs, body); err != nil {
|
||||||
auditFile(r, "put", cleanURL, http.StatusInternalServerError, len(body), err)
|
auditFile(r, "put", cleanURL, http.StatusInternalServerError, len(body), err)
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// Invalidate ETag cache (static.go memoizes by mtime; rename produces
|
// Invalidate ETag cache (static.go memoizes by mtime; rename produces
|
||||||
// a fresh mtime so a stale entry is harmless, but clearing is cheap).
|
// a fresh mtime so a stale entry is harmless, but clearing is cheap).
|
||||||
etagCacheM.Delete(abs)
|
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.
|
// the sibling .converted/ dir for this source.
|
||||||
purgeConverted(abs)
|
purgeConverted(abs)
|
||||||
|
|
||||||
etag := fileETag(body)
|
etag := fileETag(finalBody)
|
||||||
w.Header().Set("ETag", `"`+etag+`"`)
|
w.Header().Set("ETag", `"`+etag+`"`)
|
||||||
w.Header().Set("X-ZDDC-Source", "fileapi:put")
|
w.Header().Set("X-ZDDC-Source", "fileapi:put")
|
||||||
respStatus := http.StatusCreated
|
respStatus := http.StatusCreated
|
||||||
if existed {
|
if existed {
|
||||||
respStatus = http.StatusOK
|
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)
|
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) {
|
func serveFileDelete(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
|
||||||
629
zddc/internal/handler/history.go
Normal file
629
zddc/internal/handler/history.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -218,6 +218,60 @@ func EffectivePolicy(fsRoot, dirPath string) (PolicyChain, error) {
|
||||||
return chain, nil
|
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.
|
// InvalidateCache removes the cached policy for dirPath and all descendants.
|
||||||
func InvalidateCache(dirPath string) {
|
func InvalidateCache(dirPath string) {
|
||||||
dirPath = filepath.Clean(dirPath)
|
dirPath = filepath.Clean(dirPath)
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,17 @@ paths:
|
||||||
# to received/issued). That lets them set up the
|
# to received/issued). That lets them set up the
|
||||||
# counterparty's own .zddc afterward.
|
# counterparty's own .zddc afterward.
|
||||||
auto_own: true
|
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:
|
paths:
|
||||||
mdl:
|
mdl:
|
||||||
default_tool: tables
|
default_tool: tables
|
||||||
|
|
@ -153,6 +164,16 @@ paths:
|
||||||
# tables tool serves it from the embedded default
|
# tables tool serves it from the embedded default
|
||||||
# spec even when the on-disk folder doesn't exist.
|
# spec even when the on-disk folder doesn't exist.
|
||||||
virtual: true
|
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:
|
rsk:
|
||||||
default_tool: tables
|
default_tool: tables
|
||||||
available_tools: [tables]
|
available_tools: [tables]
|
||||||
|
|
@ -160,6 +181,20 @@ paths:
|
||||||
# as mdl/. Embedded default-rsk spec backs it when no
|
# as mdl/. Embedded default-rsk spec backs it when no
|
||||||
# operator override is on disk.
|
# operator override is on disk.
|
||||||
virtual: true
|
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:
|
||||||
# incoming/ is the COUNTERPARTY's drop zone. The flow:
|
# incoming/ is the COUNTERPARTY's drop zone. The flow:
|
||||||
# 1. the other party's document controller uploads
|
# 1. the other party's document controller uploads
|
||||||
|
|
|
||||||
215
zddc/internal/zddc/field_codes.go
Normal file
215
zddc/internal/zddc/field_codes.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -343,6 +343,43 @@ type ZddcFile struct {
|
||||||
// menu item. Set in an ancestor .zddc to enable.
|
// menu item. Set in an ancestor .zddc to enable.
|
||||||
OnPlanReview *OnPlanReviewConfig `yaml:"on_plan_review,omitempty" json:"on_plan_review,omitempty"`
|
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
|
// Paths declares virtual sub-directory rules without those
|
||||||
// directories needing to exist on disk. Each key is a single path
|
// directories needing to exist on disk. Each key is a single path
|
||||||
// segment — either a literal name or `*` (matches any segment).
|
// segment — either a literal name or `*` (matches any segment).
|
||||||
|
|
|
||||||
|
|
@ -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
|
// Paths: top entirely replaces base if set. Recursive descent of
|
||||||
// the walker is what threads ancestor Paths through to the right
|
// the walker is what threads ancestor Paths through to the right
|
||||||
// level — merging Paths maps themselves at this layer would
|
// level — merging Paths maps themselves at this layer would
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue