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) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-19 09:55:07 -05:00
parent 882d5e4c86
commit d35809cfd8
4 changed files with 261 additions and 20 deletions

View file

@ -9,7 +9,13 @@
# (free-text strings, no regex / enum constraints) — projects choose # (free-text strings, no regex / enum constraints) — projects choose
# their own conventions for originator codes, discipline vocabularies, # their own conventions for originator codes, discipline vocabularies,
# etc., and a default that imposed a fixed set would just get in the # 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/<party>/mdl/ # To customize: drop your own form.yaml into archive/<party>/mdl/
# (the same directory as table.yaml). Tighten constraints with # (the same directory as table.yaml). Tighten constraints with
@ -91,6 +97,40 @@ schema:
notes: notes:
type: string type: string
title: Notes 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: ui:
notes: notes:
ui:widget: textarea ui:widget: textarea

View file

@ -2,11 +2,29 @@
# zddc-server when no operator-supplied form.yaml exists at # zddc-server when no operator-supplied form.yaml exists at
# archive/<party>/rsk/. # archive/<party>/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 = <table-tracking>-<row>.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; # Likelihood and impact use the standard 1-5 ordinal scales;
# severity is also 1-25 (typically L*I) and stored on each row so # 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 # operators can override it when the simple product doesn't capture
# the actual risk profile. # 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/<party>/rsk/ # To customize: drop your own form.yaml into archive/<party>/rsk/
# (the same directory as table.yaml). Tighten constraints with # (the same directory as table.yaml). Tighten constraints with
# `enum:`, `pattern:`, etc. Add fields and they'll appear in the # `enum:`, `pattern:`, etc. Add fields and they'll appear in the
@ -14,18 +32,64 @@
# the field in the table view too. # the field in the table view too.
title: Risk 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: schema:
type: object type: object
required: [id, title] required: [originator, project, discipline, type, sequence, title]
additionalProperties: false additionalProperties: false
properties: 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 type: string
title: ID title: Originator
description: Stable identifier, e.g. R-001. description: Organizational unit responsible for this risk register.
minLength: 1 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: title:
type: string type: string
title: Risk title: Risk
@ -74,6 +138,38 @@ schema:
notes: notes:
type: string type: string
title: Notes 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: ui:
description: description:
ui:widget: textarea ui:widget: textarea

View file

@ -69,6 +69,40 @@ schema:
notes: notes:
type: string type: string
title: Notes 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: ui:
scopeSummary: scopeSummary:
ui:widget: textarea ui:widget: textarea

View file

@ -166,18 +166,28 @@ func serveFormCreateSSR(cfg config.Config, req *FormRequest, w http.ResponseWrit
http.Error(w, "marshal yaml: "+err.Error(), http.StatusInternalServerError) http.Error(w, "marshal yaml: "+err.Error(), http.StatusInternalServerError)
return return
} }
if err := zddc.WriteAtomic(yamlAbs, yamlBytes); err != nil { // Route through WriteWithHistory so audit fields (created_*,
auditFile(r, "ssr-create", rowURL, http.StatusInternalServerError, len(yamlBytes), err) // updated_*, revision=1) are stamped uniformly with the PUT
http.Error(w, "write: "+err.Error(), http.StatusInternalServerError) // 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 return
} }
if len(verrs) > 0 {
writeValidationErrors(w, verrs)
return
}
finalBody := res.FinalBody
w.Header().Set("Location", rowURL) w.Header().Set("Location", rowURL)
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-ZDDC-Source", "ssr-create") w.Header().Set("X-ZDDC-Source", "ssr-create")
w.WriteHeader(http.StatusCreated) w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(fmt.Sprintf(`{"ok":true,"location":%q}`, rowURL))) _, _ = 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 // 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 return
} }
dateStr := time.Now().UTC().Format("2006-01-02") // Resolve the cascade rule at slotAbs to pick a composed filename.
emailSan := sanitizeEmail(email) // The defaults.zddc.yaml records: entries declare a "*.yaml" rule
base := dateStr + "-" + emailSan // for both mdl/ and rsk/ folders with filename_format pointing at
target, fname, ok := pickAvailableFilename(slotAbs, base, ".yaml") // body fields; for RSK, the rule also carries row_field +
if !ok { // row_scope_fields so the server can assign the next row sequence
http.Error(w, "could not pick a free filename (>100 collisions)", http.StatusConflict) // 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 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) yamlBytes, err := yaml.Marshal(dataMap)
if err != nil { 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) http.Error(w, "marshal yaml: "+err.Error(), http.StatusInternalServerError)
return return
} }
if err := zddc.WriteAtomic(target, yamlBytes); err != nil { // Route through WriteWithHistory for audit stamping. The
auditFile(r, "rollup-create", req.SubmitURL, http.StatusInternalServerError, len(yamlBytes), err) // filename_format check inside WriteWithHistory passes because
http.Error(w, "write: "+err.Error(), http.StatusInternalServerError) // 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 return
} }
if len(verrs) > 0 {
writeValidationErrors(w, verrs)
return
}
finalBody := res.FinalBody
rowURL := "/" + req.Project + "/" + req.Slot + "/" + party + "__" + fname rowURL := "/" + req.Project + "/" + req.Slot + "/" + party + "__" + fname
w.Header().Set("Location", rowURL) 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.Header().Set("X-ZDDC-Source", "rollup-create")
w.WriteHeader(http.StatusCreated) w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(fmt.Sprintf(`{"ok":true,"location":%q}`, rowURL))) _, _ = 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. // serveSSRRename renames a party folder by rewriting an SSR row URL.