From d35809cfd86eeee4c3e7b35843719025950665b6 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Tue, 19 May 2026 09:55:07 -0500 Subject: [PATCH] feat(forms): cascade-driven filename composition + audit on row create Schemas: - default-mdl.form.yaml: declare the six readOnly audit fields (created_at/by, updated_at/by, revision, previous_sha) so the form UI renders them disabled. additionalProperties: false is preserved; WriteWithHistory strips any client-supplied values before validation. - default-rsk.form.yaml: overhaul to reflect the new shape. Each row now carries the table-tracking components (originator/phase?/project/ area?/discipline/type/sequence/suffix?) plus a server-assigned `row` field; type is enum-locked to RSK to mirror the cascade's locked: rule. Drops the old `id` field (D-001/R-001-style identifiers are now composed from the components and stored in the filename). - default-ssr.form.yaml: append the six audit fields. Handlers: - serveFormCreateSSR routes the write through WriteWithHistory so audit fields are stamped on first create (revision=1, created_*= updated_*=request principal/now). ssr.yaml's identity stays the party folder name; no filename composition runs. - serveFormCreateRollup now resolves the cascade at the row's parent folder and uses the matched records: entry's filename_format to compose the row filename from body fields. For RSK rows the rule carries row_field+row_scope_fields, so the server auto-assigns the next sequence (001, 002, ...) within the table-tracking group and injects it into the body before composition. Defaults from field_defaults: are injected where the client omitted them (type=RSK locks in via the locked: list). Falls back to the historical date+email naming only when no records: rule is in scope (covers deployments that override defaults.zddc.yaml without declaring their own records: entries). Co-Authored-By: Claude Opus 4.7 (1M context) --- zddc/internal/handler/default-mdl.form.yaml | 42 +++++++- zddc/internal/handler/default-rsk.form.yaml | 106 +++++++++++++++++++- zddc/internal/handler/default-ssr.form.yaml | 34 +++++++ zddc/internal/handler/ssrhandler.go | 99 +++++++++++++++--- 4 files changed, 261 insertions(+), 20 deletions(-) diff --git a/zddc/internal/handler/default-mdl.form.yaml b/zddc/internal/handler/default-mdl.form.yaml index 9d8bcd2..9d05e73 100644 --- a/zddc/internal/handler/default-mdl.form.yaml +++ b/zddc/internal/handler/default-mdl.form.yaml @@ -9,7 +9,13 @@ # (free-text strings, no regex / enum constraints) — projects choose # their own conventions for originator codes, discipline vocabularies, # etc., and a default that imposed a fixed set would just get in the -# way. +# way. Tightening per project is done via .zddc field_codes:, which +# the cascade resolves before the form is rendered. +# +# The six audit fields at the bottom are server-managed: clients must +# render them as read-only and never submit values for them. +# WriteWithHistory strips any client-supplied audit fields before +# schema validation and re-injects the authoritative values. # # To customize: drop your own form.yaml into archive//mdl/ # (the same directory as table.yaml). Tighten constraints with @@ -91,6 +97,40 @@ schema: notes: type: string title: Notes + + # --- Audit fields (server-managed; clients must not submit + # values). WriteWithHistory strips any client-supplied versions + # before validation and re-injects authoritative values. + created_at: + type: string + title: Created + format: date-time + readOnly: true + created_by: + type: string + title: Created by + format: email + readOnly: true + updated_at: + type: string + title: Updated + format: date-time + readOnly: true + updated_by: + type: string + title: Updated by + format: email + readOnly: true + revision: + type: integer + title: Revision + minimum: 1 + readOnly: true + previous_sha: + type: string + title: Previous SHA + description: SHA-256 (first 8 hex chars) of the prior revision's bytes. + readOnly: true ui: notes: ui:widget: textarea diff --git a/zddc/internal/handler/default-rsk.form.yaml b/zddc/internal/handler/default-rsk.form.yaml index b8ab03b..eaf1e74 100644 --- a/zddc/internal/handler/default-rsk.form.yaml +++ b/zddc/internal/handler/default-rsk.form.yaml @@ -2,11 +2,29 @@ # zddc-server when no operator-supplied form.yaml exists at # archive//rsk/. # +# The risk register is structurally different from MDL: the RSK +# TABLE is itself a tracked deliverable (with its own tracking +# number — same shape as an MDL deliverable, type locked to RSK by +# the cascade), and each row in the table is a CHILD of that +# deliverable identified by a per-row sequence (`row`). The row's +# filename = -.yaml, composed by the server +# from the components below. +# +# Why the table-tracking components live on every row: the row .yaml +# is self-describing — you can pick up any single file and identify +# both the deliverable it contributes to AND its position within +# that deliverable. Multiple RSK tables (different table-tracking +# numbers) can coexist as siblings in the same rsk/ folder; the +# scope-fields shared by their rows are what groups them. +# # Likelihood and impact use the standard 1-5 ordinal scales; # severity is also 1-25 (typically L*I) and stored on each row so # operators can override it when the simple product doesn't capture # the actual risk profile. # +# Audit fields are server-managed and read-only (clients must not +# submit values). +# # To customize: drop your own form.yaml into archive//rsk/ # (the same directory as table.yaml). Tighten constraints with # `enum:`, `pattern:`, etc. Add fields and they'll appear in the @@ -14,18 +32,64 @@ # the field in the table view too. title: Risk -description: One identified risk. Likelihood and impact use 1-5 ordinals; severity is stored separately so it can be overridden when L*I underrepresents the residual exposure. +description: One identified risk. The first eight fields together identify the parent risk-register deliverable; `row` is this entry's position within it. Likelihood and impact use 1-5 ordinals; severity is stored separately so it can be overridden when L*I underrepresents the residual exposure. schema: type: object - required: [id, title] + required: [originator, project, discipline, type, sequence, title] additionalProperties: false properties: - id: + # --- Table-tracking components: identify which RSK deliverable + # this row belongs to. Together with `row`, they compose the + # row's filename via the cascade's filename_format. + originator: type: string - title: ID - description: Stable identifier, e.g. R-001. + title: Originator + description: Organizational unit responsible for this risk register. minLength: 1 + phase: + type: string + title: Phase + description: Optional project phase code (ECI, EPC, ...). + project: + type: string + title: Project + description: Project identifier, or your corporate placeholder for non-project deliverables. + minLength: 1 + area: + type: string + title: Area + description: Optional area / budget code. + discipline: + type: string + title: Discipline + description: Engineering or functional group code (EL, ME, CV, PM, ...). + minLength: 1 + type: + type: string + title: Document type + description: Locked to RSK by the cascade's field_defaults; the form renders this read-only and the server returns 422 if a different value is submitted. + enum: [RSK] + sequence: + type: string + title: Sequence + description: Zero-padded integer identifying this risk register among the originator's deliverables. + minLength: 1 + suffix: + type: string + title: Suffix + description: Optional structural-part suffix on the parent register. + + # --- Row sequence within the table. Server-assigned on + # POST-create; preserved as-is on PUT-update. + row: + type: string + title: Row + description: Zero-padded sequence within this risk register (001, 002, ...). Server-assigned on add; do not edit. + minLength: 1 + readOnly: true + + # --- Risk-level data. title: type: string title: Risk @@ -74,6 +138,38 @@ schema: notes: type: string title: Notes + + # --- Audit fields (server-managed; read-only). + created_at: + type: string + title: Created + format: date-time + readOnly: true + created_by: + type: string + title: Created by + format: email + readOnly: true + updated_at: + type: string + title: Updated + format: date-time + readOnly: true + updated_by: + type: string + title: Updated by + format: email + readOnly: true + revision: + type: integer + title: Revision + minimum: 1 + readOnly: true + previous_sha: + type: string + title: Previous SHA + description: SHA-256 (first 8 hex chars) of the prior revision's bytes. + readOnly: true ui: description: ui:widget: textarea diff --git a/zddc/internal/handler/default-ssr.form.yaml b/zddc/internal/handler/default-ssr.form.yaml index 1e16c29..01cd84a 100644 --- a/zddc/internal/handler/default-ssr.form.yaml +++ b/zddc/internal/handler/default-ssr.form.yaml @@ -69,6 +69,40 @@ schema: notes: type: string title: Notes + + # --- Audit fields (server-managed; read-only). WriteWithHistory + # strips any client-supplied versions before validation and + # re-injects authoritative values on every write. + created_at: + type: string + title: Created + format: date-time + readOnly: true + created_by: + type: string + title: Created by + format: email + readOnly: true + updated_at: + type: string + title: Updated + format: date-time + readOnly: true + updated_by: + type: string + title: Updated by + format: email + readOnly: true + revision: + type: integer + title: Revision + minimum: 1 + readOnly: true + previous_sha: + type: string + title: Previous SHA + description: SHA-256 (first 8 hex chars) of the prior revision's bytes. + readOnly: true ui: scopeSummary: ui:widget: textarea diff --git a/zddc/internal/handler/ssrhandler.go b/zddc/internal/handler/ssrhandler.go index f9372aa..f0e20f1 100644 --- a/zddc/internal/handler/ssrhandler.go +++ b/zddc/internal/handler/ssrhandler.go @@ -166,18 +166,28 @@ func serveFormCreateSSR(cfg config.Config, req *FormRequest, w http.ResponseWrit http.Error(w, "marshal yaml: "+err.Error(), http.StatusInternalServerError) return } - if err := zddc.WriteAtomic(yamlAbs, yamlBytes); err != nil { - auditFile(r, "ssr-create", rowURL, http.StatusInternalServerError, len(yamlBytes), err) - http.Error(w, "write: "+err.Error(), http.StatusInternalServerError) + // Route through WriteWithHistory so audit fields (created_*, + // updated_*, revision=1) are stamped uniformly with the PUT + // path. No prior file exists, so the history-write branch is a + // no-op — only the stamping + live write fire. + res, verrs, herr := WriteWithHistory(cfg, yamlAbs, rowURL, yamlBytes, email) + if herr != nil { + auditFile(r, "ssr-create", rowURL, http.StatusInternalServerError, len(yamlBytes), herr) + http.Error(w, "write: "+herr.Error(), http.StatusInternalServerError) return } + if len(verrs) > 0 { + writeValidationErrors(w, verrs) + return + } + finalBody := res.FinalBody w.Header().Set("Location", rowURL) w.Header().Set("Content-Type", "application/json") w.Header().Set("X-ZDDC-Source", "ssr-create") w.WriteHeader(http.StatusCreated) _, _ = w.Write([]byte(fmt.Sprintf(`{"ok":true,"location":%q}`, rowURL))) - auditFile(r, "ssr-create", rowURL, http.StatusCreated, len(yamlBytes), nil) + auditFile(r, "ssr-create", rowURL, http.StatusCreated, len(finalBody), nil) } // serveFormCreateRollup adds a row to a project-level MDL or RSK @@ -277,14 +287,66 @@ func serveFormCreateRollup(cfg config.Config, req *FormRequest, w http.ResponseW return } - dateStr := time.Now().UTC().Format("2006-01-02") - emailSan := sanitizeEmail(email) - base := dateStr + "-" + emailSan - target, fname, ok := pickAvailableFilename(slotAbs, base, ".yaml") - if !ok { - http.Error(w, "could not pick a free filename (>100 collisions)", http.StatusConflict) + // Resolve the cascade rule at slotAbs to pick a composed filename. + // The defaults.zddc.yaml records: entries declare a "*.yaml" rule + // for both mdl/ and rsk/ folders with filename_format pointing at + // body fields; for RSK, the rule also carries row_field + + // row_scope_fields so the server can assign the next row sequence + // within the table-tracking group. + chain, err := zddc.EffectivePolicy(cfg.Root, slotAbs) + if err != nil { + auditFile(r, "rollup-create", req.SubmitURL, http.StatusInternalServerError, 0, err) + http.Error(w, "cascade resolve: "+err.Error(), http.StatusInternalServerError) return } + // Probe with the wildcard placeholder; the embedded defaults + // declare a "*.yaml" entry for both slots. + _, rule, hasRule := chain.EffectiveRecordRule("placeholder.yaml") + + var target, fname string + if hasRule && rule.FilenameFormat != "" { + // Apply field_defaults (e.g. type=RSK for rsk/). + for k, want := range rule.FieldDefaults { + if _, present := dataMap[k]; !present { + dataMap[k] = want + } + } + // Auto-assign the per-row sequence for RSK-style rules. + if rule.RowField != "" { + rowVal, rerr := AssignNextRow(slotAbs, rule.RowField, rule.RowScopeFields, dataMap) + if rerr != nil { + auditFile(r, "rollup-create", req.SubmitURL, http.StatusInternalServerError, 0, rerr) + http.Error(w, "row assign: "+rerr.Error(), http.StatusInternalServerError) + return + } + dataMap[rule.RowField] = rowVal + } + composed, cerr := composeFilename(rule.FilenameFormat, dataMap) + if cerr != nil { + writeValidationErrors(w, []jsonschema.Error{{Path: "/", Message: cerr.Error()}}) + return + } + fname = composed + ".yaml" + target = filepath.Join(slotAbs, fname) + if _, err := os.Stat(target); err == nil { + http.Error(w, "Conflict — a row with this composed tracking number already exists", http.StatusConflict) + return + } + } else { + // Fallback for deployments that override the embedded + // defaults without providing records: entries — keep the + // historical date+email naming so they aren't broken by + // this upgrade. + dateStr := time.Now().UTC().Format("2006-01-02") + emailSan := sanitizeEmail(email) + base := dateStr + "-" + emailSan + var ok bool + target, fname, ok = pickAvailableFilename(slotAbs, base, ".yaml") + if !ok { + http.Error(w, "could not pick a free filename (>100 collisions)", http.StatusConflict) + return + } + } yamlBytes, err := yaml.Marshal(dataMap) if err != nil { @@ -292,11 +354,20 @@ func serveFormCreateRollup(cfg config.Config, req *FormRequest, w http.ResponseW http.Error(w, "marshal yaml: "+err.Error(), http.StatusInternalServerError) return } - if err := zddc.WriteAtomic(target, yamlBytes); err != nil { - auditFile(r, "rollup-create", req.SubmitURL, http.StatusInternalServerError, len(yamlBytes), err) - http.Error(w, "write: "+err.Error(), http.StatusInternalServerError) + // Route through WriteWithHistory for audit stamping. The + // filename_format check inside WriteWithHistory passes because + // the path we constructed above used the same composition. + res, verrs, herr := WriteWithHistory(cfg, target, "/"+req.Project+"/"+req.Slot+"/"+party+"__"+fname, yamlBytes, email) + if herr != nil { + auditFile(r, "rollup-create", req.SubmitURL, http.StatusInternalServerError, len(yamlBytes), herr) + http.Error(w, "write: "+herr.Error(), http.StatusInternalServerError) return } + if len(verrs) > 0 { + writeValidationErrors(w, verrs) + return + } + finalBody := res.FinalBody rowURL := "/" + req.Project + "/" + req.Slot + "/" + party + "__" + fname w.Header().Set("Location", rowURL) @@ -304,7 +375,7 @@ func serveFormCreateRollup(cfg config.Config, req *FormRequest, w http.ResponseW w.Header().Set("X-ZDDC-Source", "rollup-create") w.WriteHeader(http.StatusCreated) _, _ = w.Write([]byte(fmt.Sprintf(`{"ok":true,"location":%q}`, rowURL))) - auditFile(r, "rollup-create", rowURL, http.StatusCreated, len(yamlBytes), nil) + auditFile(r, "rollup-create", rowURL, http.StatusCreated, len(finalBody), nil) } // serveSSRRename renames a party folder by rewriting an SSR row URL.