diff --git a/zddc/cmd/zddc-server/main.go b/zddc/cmd/zddc-server/main.go index 4195281..13da302 100644 --- a/zddc/cmd/zddc-server/main.go +++ b/zddc/cmd/zddc-server/main.go @@ -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 .yaml?history=1 returns the + // list of prior revisions stored under /.history//. + // 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) } diff --git a/zddc/internal/handler/fileapi.go b/zddc/internal/handler/fileapi.go index c0b8da7..173283d 100644 --- a/zddc/internal/handler/fileapi.go +++ b/zddc/internal/handler/fileapi.go @@ -409,10 +409,35 @@ func serveFilePut(cfg config.Config, w http.ResponseWriter, r *http.Request) { } } - 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 + // 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 + // /.history//, 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). @@ -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 } - w.WriteHeader(respStatus) - auditFile(r, "put", cleanURL, respStatus, len(body), nil) + // 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.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) { diff --git a/zddc/internal/handler/history.go b/zddc/internal/handler/history.go new file mode 100644 index 0000000..efceea6 --- /dev/null +++ b/zddc/internal/handler/history.go @@ -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 +// /.history//-.. 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 .yaml?history=1 with the +// list of prior revisions archived under .history//. 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// 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: -.. 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: -.. 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 +} diff --git a/zddc/internal/zddc/cascade.go b/zddc/internal/zddc/cascade.go index df09051..e581fe4 100644 --- a/zddc/internal/zddc/cascade.go +++ b/zddc/internal/zddc/cascade.go @@ -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) diff --git a/zddc/internal/zddc/defaults.zddc.yaml b/zddc/internal/zddc/defaults.zddc.yaml index efc2ac2..e54dbfc 100644 --- a/zddc/internal/zddc/defaults.zddc.yaml +++ b/zddc/internal/zddc/defaults.zddc.yaml @@ -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 diff --git a/zddc/internal/zddc/field_codes.go b/zddc/internal/zddc/field_codes.go new file mode 100644 index 0000000..c5bc76d --- /dev/null +++ b/zddc/internal/zddc/field_codes.go @@ -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 +} diff --git a/zddc/internal/zddc/file.go b/zddc/internal/zddc/file.go index 79dd731..9620133 100644 --- a/zddc/internal/zddc/file.go +++ b/zddc/internal/zddc/file.go @@ -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). diff --git a/zddc/internal/zddc/walker.go b/zddc/internal/zddc/walker.go index b8c31ce..60f6089 100644 --- a/zddc/internal/zddc/walker.go +++ b/zddc/internal/zddc/walker.go @@ -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