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:
parent
882d5e4c86
commit
d35809cfd8
4 changed files with 261 additions and 20 deletions
|
|
@ -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/<party>/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
|
||||
|
|
|
|||
|
|
@ -2,11 +2,29 @@
|
|||
# zddc-server when no operator-supplied form.yaml exists at
|
||||
# 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;
|
||||
# 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/<party>/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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
// 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
|
||||
target, fname, ok := pickAvailableFilename(slotAbs, base, ".yaml")
|
||||
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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue