feat(records): simplest default tracking number + folder-bound originator

Two coupled cleanups so the baked-in defaults reflect the actual
convention instead of leaking one project's choices into every
deployment:

- Drop the project-wide phase/area components from the default
  filename_format, form schemas, and table columns. They must be
  all-on or all-off across a project to keep filenames lexically
  consistent, so the simplest default omits them; operators re-enable
  via the commented-out templates + a .zddc filename_format override.
  Teaching comments (incl. a field_codes: example) now ride along in
  defaults.zddc.yaml, which `show-defaults` dumps verbatim.
- Separate suffix from sequence with a template hyphen
  ({sequence}-{suffix?}); stored suffix is now just the part marker
  (A, 01) with no leading dash.
- New records: key `folder_fields: {field: parent-distance}` binds a
  body field to an ancestor folder name. The default mdl/rsk records
  bind originator to the party folder (distance 1) — the folder is the
  sole source of truth. The server overwrites the body value before
  validation + composition (WriteWithHistory and the rollup create
  path), and the form renderer marks the field read-only and pre-fills
  it. Rollup forms drop originator from required (server derives it
  from the selected party).

Tests: folder-binding overwrite + wrong-originator-filename 422, and a
form-render readOnly/prefill assertion; existing record tests realigned
so the party folder name equals the originator.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-21 14:31:49 -05:00
parent cc7f34e922
commit e3db2f8473
12 changed files with 378 additions and 108 deletions

View file

@ -405,7 +405,7 @@ Read/aggregate counterpart to the form system. Renders a directory of YAML row f
**Default-MDL fallback at `archive/<party>/mdl/`**: when no `table.yaml` (or `form.yaml`) exists on disk in this exact location, the server serves embedded default bytes. The `mdl/` directory itself doesn't even need to exist — the URL renders the default MDL view fully virtually so a fresh archive surfaces the master document list without operator setup. Outside `archive/<party>/mdl/`, presence-based discovery is the rule. **Default-MDL fallback at `archive/<party>/mdl/`**: when no `table.yaml` (or `form.yaml`) exists on disk in this exact location, the server serves embedded default bytes. The `mdl/` directory itself doesn't even need to exist — the URL renders the default MDL view fully virtually so a fresh archive surfaces the master document list without operator setup. Outside `archive/<party>/mdl/`, presence-based discovery is the rule.
**Default-MDL columns mirror the tracking-number components** documented at `zddc.varasys.io/reference.html#tracking-numbers`: `originator`, `phase`, `project`, `area`, `discipline`, `type`, `sequence`, `suffix` — each one a slot of the deliverable's permanent identifier — plus `title`, `plannedRevision`, `plannedDate`, `status`, `owner`. The form schema accepts free-text on every component by default. Projects narrow the vocabulary via the cascade's `field_codes:` (see below) without rewriting the schema — operator overrides at `archive/<party>/mdl/{table,form}.yaml` still win atomically over the embedded defaults. Source: `zddc/internal/handler/default-mdl.{table,form}.yaml`. **Default-MDL columns mirror the tracking-number components** documented at `zddc.varasys.io/reference.html#tracking-numbers`. The default ships the five required components + an optional per-deliverable `suffix`: `originator`, `project`, `discipline`, `type`, `sequence`, `suffix` — each a slot of the deliverable's permanent identifier — plus `title`, `plannedRevision`, `plannedDate`, `status`, `owner`. The project-wide `phase` / `area` components are shipped only as commented-out templates in the default form/table YAML (a project that uses them must enable them on *every* deliverable to keep filenames lexically consistent, so the simplest default omits them). `originator` is **folder-bound**: the cascade's `folder_fields` pins it to the party-folder name, so the form renders it read-only and the server sets it from the path. The form schema accepts free-text on every other component by default; projects narrow the vocabulary via the cascade's `field_codes:` (see below). Operator overrides at `archive/<party>/mdl/{table,form}.yaml` still win atomically over the embedded defaults. Source: `zddc/internal/handler/default-mdl.{table,form}.yaml`.
**Adding a new table**: create a directory `<dir>/` and drop `table.yaml` (and optionally `form.yaml` for row editing) into it. No code change required. Visit `<dir>/table.html`. **Adding a new table**: create a directory `<dir>/` and drop `table.yaml` (and optionally `form.yaml` for row editing) into it. No code change required. Visit `<dir>/table.html`.
@ -422,9 +422,10 @@ The "records" subset of the tables system carries three guarantees the generic f
**Two new `.zddc` keys** carry the rules (see `zddc/internal/zddc/file.go` + `field_codes.go`): **Two new `.zddc` keys** carry the rules (see `zddc/internal/zddc/file.go` + `field_codes.go`):
- `field_codes:` — vocabulary for the components used in filename composition and constrained body fields. Each entry is a discriminated union over `kind: enum|pattern|free` (`{kind: enum, codes: {ACM: Acme Inc, …}}` / `{kind: pattern, pattern: "^[0-9]{4}$"}` / `{kind: free, description: "..."}`). Map-merge across the cascade (mirror of `apps:`) — a deeper level can narrow or replace a single code's vocabulary without dropping unrelated codes. - `field_codes:` — vocabulary for the components used in filename composition and constrained body fields. Each entry is a discriminated union over `kind: enum|pattern|free` (`{kind: enum, codes: {ACM: Acme Inc, …}}` / `{kind: pattern, pattern: "^[0-9]{4}$"}` / `{kind: free, description: "..."}`). Map-merge across the cascade (mirror of `apps:`) — a deeper level can narrow or replace a single code's vocabulary without dropping unrelated codes.
- `records:` — per-pattern rules keyed by filename basename (literal `ssr.yaml` or glob `*.yaml`). Each entry carries `filename_format` (composition template with `{field}` and `{field?}` placeholders), `field_defaults`, `locked`, plus `row_field` + `row_scope_fields` for RSK-style tables-of-rows. Filename-pattern scoping is what lets the SSR rule live at the party-folder level without affecting `mdl/`, `rsk/`, `received/`, etc., siblings. - `records:` — per-pattern rules keyed by filename basename (literal `ssr.yaml` or glob `*.yaml`). Each entry carries `filename_format` (composition template with `{field}` and `{field?}` placeholders), `field_defaults`, `locked`, `folder_fields`, plus `row_field` + `row_scope_fields` for RSK-style tables-of-rows. Filename-pattern scoping is what lets the SSR rule live at the party-folder level without affecting `mdl/`, `rsk/`, `received/`, etc., siblings.
- `folder_fields:` — map of `field → parent-distance` that binds a body field to an ancestor folder name (the folder is the sole source of truth). The map value is how many directories ABOVE the record's own directory the source folder sits (`originator: 1` under `archive/<party>/mdl/` resolves to the `<party>` folder). The server overwrites the body field with the derived name before validation + composition (so a client value can never disagree; a mismatched URL still trips the `filename_format` check), and the form renderer marks the field read-only and pre-fills it.
Defaults are baked into `defaults.zddc.yaml`; `field_codes:` ships empty (every deployment writes its own vocabulary). Defaults are baked into `defaults.zddc.yaml`; `field_codes:` ships empty (every deployment writes its own vocabulary). The default mdl/rsk records bind `originator` via `folder_fields: {originator: 1}` so the party folder is the originator's source of truth — `originator` is therefore *not* a `field_codes:` entry by default.
**Six server-managed audit fields** are injected on every write and stripped from incoming bodies before validation (snake_case to match `.zddc`'s existing `created_by:`): **Six server-managed audit fields** are injected on every write and stripped from incoming bodies before validation (snake_case to match `.zddc`'s existing `created_by:`):
- `created_at`, `created_by` — stamped on create; preserved untouched on every update - `created_at`, `created_by` — stamped on create; preserved untouched on every update
@ -445,7 +446,7 @@ Defaults are baked into `defaults.zddc.yaml`; `field_codes:` ships empty (every
**Record-vs-config distinction**: `WriteWithHistory` fires only for genuine record paths. The gate (`isRecordPath` in `fileapi.go`) excludes `table.yaml`, `form.yaml`, `.zddc`, and the spec naming variants `*.table.yaml` / `*.form.yaml`. Those bypass audit stamping (they're configuration, not data) and go through plain `WriteAtomic`. **Record-vs-config distinction**: `WriteWithHistory` fires only for genuine record paths. The gate (`isRecordPath` in `fileapi.go`) excludes `table.yaml`, `form.yaml`, `.zddc`, and the spec naming variants `*.table.yaml` / `*.form.yaml`. Those bypass audit stamping (they're configuration, not data) and go through plain `WriteAtomic`.
**Operator customization**: **Operator customization**:
- To narrow a deployment's originator codes: write `field_codes: originator: {kind: enum, codes: {ACM: …, BET: …}}` at the project root `.zddc`. - To narrow a deployment's discipline codes: write `field_codes: discipline: {kind: enum, codes: {EL: Electrical, ME: Mechanical, …}}` at the project root `.zddc`. (`originator` is folder-bound by default — see `folder_fields` above — so it's set from the party folder rather than constrained by a code list.)
- To add a new table type: declare a `records:` entry under the appropriate `paths:` level (or a sibling `.zddc` in the folder) with a `filename_format` referencing fields the body carries. - To add a new table type: declare a `records:` entry under the appropriate `paths:` level (or a sibling `.zddc` in the folder) with a `filename_format` referencing fields the body carries.
- To inspect a record's revision history: `curl https://<host>/<path>.yaml?history=1 -H 'Authorization: Bearer …'`. - To inspect a record's revision history: `curl https://<host>/<path>.yaml?history=1 -H 'Authorization: Bearer …'`.

View file

@ -2,15 +2,22 @@
# zddc-server when no operator-supplied form.yaml exists at # zddc-server when no operator-supplied form.yaml exists at
# archive/<party>/mdl/. # archive/<party>/mdl/.
# #
# Properties cover every component of the ZDDC tracking-number model # Properties cover the default ZDDC tracking-number components
# (zddc.varasys.io/reference.html#tracking-numbers) plus the standard # (zddc.varasys.io/reference.html#tracking-numbers) plus the standard
# MDL metadata (title, planned revision, planned date, status, owner, # MDL metadata (title, planned revision, planned date, status, owner,
# notes). The schema is intentionally permissive on the components # notes). The default ships the five required components + an optional
# (free-text strings, no regex / enum constraints) — projects choose # per-deliverable suffix; the project-wide phase / area components are
# their own conventions for originator codes, discipline vocabularies, # present only as commented-out templates (see below). The schema is
# etc., and a default that imposed a fixed set would just get in the # intentionally permissive on the components (free-text strings, no
# way. Tightening per project is done via .zddc field_codes:, which # regex / enum constraints) — projects choose their own conventions,
# the cascade resolves before the form is rendered. # and a default that imposed a fixed set would just get in the way.
# Tightening per project is done via .zddc field_codes:, which the
# cascade resolves before the form is rendered.
#
# `originator` is folder-bound: the cascade's folder_fields pins it to
# the party-folder name, so the form renders it read-only and the
# server sets it from the path — the folder is its single source of
# truth.
# #
# The six audit fields at the bottom are server-managed: clients must # The six audit fields at the bottom are server-managed: clients must
# render them as read-only and never submit values for them. # render them as read-only and never submit values for them.
@ -19,12 +26,15 @@
# #
# 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
# `enum:`, `pattern:`, `minLength:`, etc. Add fields and they'll # `enum:`, `pattern:`, `minLength:`, etc. To add the project-wide
# phase / area components, uncomment them below AND override the
# cascade's filename_format to include them — apply to EVERY
# deliverable in the project, never a subset. Add fields and they'll
# appear in the row-edit form; add a matching column to table.yaml # appear in the row-edit form; add a matching column to table.yaml
# to surface the field in the table view too. # to surface the field in the table view too.
title: Deliverable title: Deliverable
description: One planned or in-flight deliverable. The first eight fields are components of this row's tracking number; the rest are deliverable metadata. description: One planned or in-flight deliverable. The first fields are components of this row's tracking number (originator is set from the party folder); the rest are deliverable metadata.
schema: schema:
type: object type: object
@ -33,26 +43,31 @@ schema:
properties: properties:
# --- Tracking-number components (matches the reference doc's # --- Tracking-number components (matches the reference doc's
# field definitions, in order). originator / project / discipline # field definitions, in order). originator / project / discipline
# / type / sequence are the structural minimum; phase / area / # / type / sequence are the structural minimum that ships by
# suffix are optional and project-dependent. # default; suffix is an optional per-deliverable part marker. The
# project-wide phase / area components are commented out below —
# uncomment them (and the matching .zddc filename_format) only if
# your project uses them on EVERY deliverable.
originator: originator:
type: string type: string
title: Originator title: Originator
description: Organizational unit responsible for this deliverable (e.g. ACME). description: Bound to the party-folder name — the folder is the source of truth for the originator code. Server-set and read-only; you don't edit it here.
minLength: 1 minLength: 1
phase: # phase: # project-wide; sits between originator and project
type: string # type: string
title: Phase # title: Phase
description: Optional project phase code (e.g. ECI, EPC). Leave blank if your tracking-number schema doesn't use phases. # description: Project phase code (e.g. ECI, EPC).
# minLength: 1
project: project:
type: string type: string
title: Project title: Project
description: Project identifier, or your corporate placeholder (e.g. 000000) for non-project deliverables. description: Project identifier, or your corporate placeholder (e.g. 000000) for non-project deliverables.
minLength: 1 minLength: 1
area: # area: # project-wide; sits between project and discipline
type: string # type: string
title: Area # title: Area
description: Optional area / budget code (e.g. B02). Leave blank if unused. # description: Area / budget code (e.g. B02).
# minLength: 1
discipline: discipline:
type: string type: string
title: Discipline title: Discipline
@ -71,7 +86,7 @@ schema:
suffix: suffix:
type: string type: string
title: Suffix title: Suffix
description: Optional structural-part suffix (-A for Appendix A, -01 for Sheet 1). Use only for parts of the SAME deliverable; separate documents get their own tracking number. description: Optional structural-part suffix (A for Appendix A, 01 for Sheet 1). Just the letters/digits — the leading dash is added by the cascade's filename_format. Use only for parts of the SAME deliverable; separate documents get their own sequence.
# --- Deliverable metadata. # --- Deliverable metadata.
title: title:

View file

@ -4,10 +4,11 @@
# Columns mirror the tracking-number component model documented at # Columns mirror the tracking-number component model documented at
# zddc.varasys.io/reference.html#tracking-numbers — every column from # zddc.varasys.io/reference.html#tracking-numbers — every column from
# `originator` through `suffix` is one slot of a deliverable's # `originator` through `suffix` is one slot of a deliverable's
# permanent identifier. Optional components ([phase], [area], [suffix]) # permanent identifier. The default ships the five required components
# render in the table even when blank so the layout stays consistent # + the optional per-deliverable suffix; the project-wide phase / area
# across rows; users on schemas that don't use them can hide the # columns are commented out below — uncomment them (alongside the
# columns by overriding this spec (see customization note below). # matching form.yaml properties + .zddc filename_format) only if your
# project uses them on every deliverable.
# #
# Beyond the tracking-number fields, the table tracks the deliverable's # Beyond the tracking-number fields, the table tracks the deliverable's
# title, planned revision and date, current status, owner, and notes — # title, planned revision and date, current status, owner, and notes —
@ -33,21 +34,21 @@ description: Planned and actual deliverables for this party. Columns mirror the
columns: columns:
# --- Tracking-number components (in the order they appear in the # --- Tracking-number components (in the order they appear in the
# canonical filename: originator-[phase-]project-[area-]discipline- # canonical filename: originator-project-discipline-type-sequence
# type-sequence[-suffix]). Optional components are kept narrow so # [-suffix]). originator is folder-bound (set from the party folder);
# they don't clutter the layout when unused. # suffix is the optional per-deliverable part marker.
- field: originator - field: originator
title: Originator title: Originator
width: 8em width: 8em
- field: phase # - field: phase # project-wide; uncomment with form.yaml + filename_format
title: Phase # title: Phase
width: 5em # width: 5em
- field: project - field: project
title: Project title: Project
width: 8em width: 8em
- field: area # - field: area # project-wide; uncomment with form.yaml + filename_format
title: Area # title: Area
width: 5em # width: 5em
- field: discipline - field: discipline
title: Disc. title: Disc.
width: 5em width: 5em

View file

@ -19,7 +19,9 @@ description: One deliverable across all parties. The first field (Package) route
schema: schema:
type: object type: object
required: [party, originator, project, discipline, type, sequence, title] # originator is omitted from required: the server derives it from
# the selected party folder (folder_fields) after this schema runs.
required: [party, project, discipline, type, sequence, title]
additionalProperties: false additionalProperties: false
properties: properties:
party: party:
@ -31,21 +33,23 @@ schema:
originator: originator:
type: string type: string
title: Originator title: Originator
description: Organizational unit responsible for this deliverable (e.g. ACME). description: Auto-set from the selected Package (party folder) — the folder is the source of truth for the originator code. Read-only; leave blank.
minLength: 1 readOnly: true
phase: # phase: # project-wide; sits between originator and project
type: string # type: string
title: Phase # title: Phase
description: Optional project phase code (e.g. ECI, EPC). # description: Project phase code (e.g. ECI, EPC).
# minLength: 1
project: project:
type: string type: string
title: Project title: Project
description: Project identifier, or your corporate placeholder for non-project deliverables. description: Project identifier, or your corporate placeholder for non-project deliverables.
minLength: 1 minLength: 1
area: # area: # project-wide; sits between project and discipline
type: string # type: string
title: Area # title: Area
description: Optional area / budget code (e.g. B02). # description: Area / budget code (e.g. B02).
# minLength: 1
discipline: discipline:
type: string type: string
title: Discipline title: Discipline
@ -64,7 +68,7 @@ schema:
suffix: suffix:
type: string type: string
title: Suffix title: Suffix
description: Optional structural-part suffix. description: Optional structural-part suffix (A, 01, ...). Just the letters/digits — the leading dash is added by the cascade's filename_format.
title: title:
type: string type: string
title: Deliverable title title: Deliverable title

View file

@ -26,15 +26,15 @@ columns:
- field: originator - field: originator
title: Originator title: Originator
width: 8em width: 8em
- field: phase # - field: phase # project-wide; uncomment with form.yaml + filename_format
title: Phase # title: Phase
width: 5em # width: 5em
- field: project - field: project
title: Project title: Project
width: 8em width: 8em
- field: area # - field: area # project-wide; uncomment with form.yaml + filename_format
title: Area # title: Area
width: 5em # width: 5em
- field: discipline - field: discipline
title: Disc. title: Disc.
width: 5em width: 5em

View file

@ -22,7 +22,10 @@ schema:
# form renderer surfaces it as a locked readOnly field. Requiring # form renderer surfaces it as a locked readOnly field. Requiring
# it here would 422 well-behaved clients that omit the cascade- # it here would 422 well-behaved clients that omit the cascade-
# owned field. # owned field.
required: [party, originator, project, discipline, sequence, title] # `originator` is also omitted from required: the server derives it
# from the selected party folder (folder_fields) after this schema
# runs, same as the rollup MDL form.
required: [party, project, discipline, sequence, title]
additionalProperties: false additionalProperties: false
properties: properties:
party: party:
@ -37,17 +40,20 @@ schema:
originator: originator:
type: string type: string
title: Originator title: Originator
minLength: 1 description: Auto-set from the selected Package (party folder) — the folder is the source of truth. Read-only; leave blank.
phase: readOnly: true
type: string # phase: # project-wide; sits between originator and project
title: Phase # type: string
# title: Phase
# minLength: 1
project: project:
type: string type: string
title: Project title: Project
minLength: 1 minLength: 1
area: # area: # project-wide; sits between project and discipline
type: string # type: string
title: Area # title: Area
# minLength: 1
discipline: discipline:
type: string type: string
title: Discipline title: Discipline
@ -64,6 +70,7 @@ schema:
suffix: suffix:
type: string type: string
title: Suffix title: Suffix
description: Optional structural-part suffix (A, 01, ...). Just the letters/digits — the leading dash is added by the cascade's filename_format.
row: row:
type: string type: string
title: Row title: Row

View file

@ -48,21 +48,23 @@ schema:
originator: originator:
type: string type: string
title: Originator title: Originator
description: Organizational unit responsible for this risk register. description: Bound to the party-folder name — the folder is the source of truth for the originator code. Server-set and read-only; you don't edit it here.
minLength: 1 minLength: 1
phase: # phase: # project-wide; sits between originator and project
type: string # type: string
title: Phase # title: Phase
description: Optional project phase code (ECI, EPC, ...). # description: Project phase code (ECI, EPC, ...).
# minLength: 1
project: project:
type: string type: string
title: Project title: Project
description: Project identifier, or your corporate placeholder for non-project deliverables. description: Project identifier, or your corporate placeholder for non-project deliverables.
minLength: 1 minLength: 1
area: # area: # project-wide; sits between project and discipline
type: string # type: string
title: Area # title: Area
description: Optional area / budget code. # description: Area / budget code.
# minLength: 1
discipline: discipline:
type: string type: string
title: Discipline title: Discipline
@ -81,7 +83,7 @@ schema:
suffix: suffix:
type: string type: string
title: Suffix title: Suffix
description: Optional structural-part suffix on the parent register. description: Optional structural-part suffix on the parent register (A, 01, ...). Just the letters/digits — the leading dash is added by the cascade's filename_format.
# --- Row sequence within the table. Server-assigned on # --- Row sequence within the table. Server-assigned on
# POST-create; preserved as-is on PUT-update. # POST-create; preserved as-is on PUT-update.

View file

@ -160,6 +160,15 @@ func WriteWithHistory(cfg config.Config, abs, cleanURL string, body []byte, prin
} }
} }
// Bind folder-derived fields (e.g. originator = party-folder
// name) before field-code validation and filename composition.
// The folder is authoritative, so this overwrites any client
// value; a wrong value still surfaces as a filename_format
// mismatch below on direct PUT.
if ferr := applyFolderFields(rule, dir, cfg.Root, bodyMap); ferr != nil {
return WriteRecordResult{}, nil, fmt.Errorf("folder fields: %w", ferr)
}
// Validate body values against field_codes (best-effort: only // Validate body values against field_codes (best-effort: only
// fields actually present in the body are checked; absent // fields actually present in the body are checked; absent
// fields are someone else's concern — typically the form // fields are someone else's concern — typically the form
@ -388,6 +397,36 @@ func composeFilename(format string, body map[string]any) (string, error) {
return out.String(), nil return out.String(), nil
} }
// applyFolderFields overwrites the body fields a rule binds to an
// ancestor folder name (RecordRule.FolderFields), making the folder
// the single source of truth for those components. recordDir is the
// directory the record file lives in; root bounds the upward walk.
// The folder name is authoritative: any client-supplied value is
// replaced, so a body can never disagree with the path it's filed
// under (and a mismatched URL still trips the filename_format check
// downstream on direct PUT). Returns an error only when the configured
// parent distance is negative or would escape the root.
func applyFolderFields(rule zddc.RecordRule, recordDir, root string, bodyMap map[string]any) error {
if len(rule.FolderFields) == 0 {
return nil
}
rootClean := filepath.Clean(root)
for field, dist := range rule.FolderFields {
if dist < 0 {
return fmt.Errorf("folder_fields[%q]: negative distance %d", field, dist)
}
src := filepath.Clean(recordDir)
for i := 0; i < dist; i++ {
src = filepath.Dir(src)
}
if src != rootClean && !strings.HasPrefix(src, rootClean+string(filepath.Separator)) {
return fmt.Errorf("folder_fields[%q]: distance %d escapes root", field, dist)
}
bodyMap[field] = filepath.Base(src)
}
return nil
}
// AssignNextRow finds the next free row sequence within the // AssignNextRow finds the next free row sequence within the
// row-scope group identified by scopeFields. Used by POST-create // row-scope group identified by scopeFields. Used by POST-create
// handlers (rsk row creation) before invoking WriteWithHistory. // handlers (rsk row creation) before invoking WriteWithHistory.
@ -650,6 +689,26 @@ func augmentSchemaFromCascade(schema *jsonschema.Schema, chain zddc.PolicyChain,
} }
} }
} }
// Folder-bound fields: the folder is authoritative, so render
// them read-only and pre-fill the value derived from gateDir
// (the directory the cascade was resolved at, == the record
// dir for per-party forms). Rollup forms resolve no rule here
// — their virtual path carries no records: entry — so the
// rollup schema marks originator read-only statically instead.
for name, dist := range rule.FolderFields {
prop, present := schema.Properties[name]
if !present || dist < 0 {
continue
}
prop.ReadOnly = true
src := filepath.Clean(gateDir)
for i := 0; i < dist; i++ {
src = filepath.Dir(src)
}
if prop.Default == nil {
prop.Default = filepath.Base(src)
}
}
} }
} }

View file

@ -64,7 +64,7 @@ func TestRecordPut_CreateStampsAuditFields(t *testing.T) {
// Build a body with the right components for the embedded // Build a body with the right components for the embedded
// mdl rule's filename_format. // mdl rule's filename_format.
body := []byte("originator: ACM\nproject: PRJ\ndiscipline: EL\ntype: SPC\nsequence: '0001'\ntitle: Test spec\n") body := []byte("originator: ACM\nproject: PRJ\ndiscipline: EL\ntype: SPC\nsequence: '0001'\ntitle: Test spec\n")
url := "/Project/archive/Acme/mdl/ACM-PRJ-EL-SPC-0001.yaml" url := "/Project/archive/ACM/mdl/ACM-PRJ-EL-SPC-0001.yaml"
rec := do(http.MethodPut, url, "alice@example.com", body, nil) rec := do(http.MethodPut, url, "alice@example.com", body, nil)
if rec.Code != http.StatusCreated { if rec.Code != http.StatusCreated {
@ -93,7 +93,7 @@ func TestRecordPut_CreateStampsAuditFields(t *testing.T) {
} }
// On-disk file matches the response body. // On-disk file matches the response body.
abs := filepath.Join(cfg.Root, "Project", "archive", "Acme", "mdl", "ACM-PRJ-EL-SPC-0001.yaml") abs := filepath.Join(cfg.Root, "Project", "archive", "ACM", "mdl", "ACM-PRJ-EL-SPC-0001.yaml")
disk, err := os.ReadFile(abs) disk, err := os.ReadFile(abs)
if err != nil { if err != nil {
t.Fatalf("read disk: %v", err) t.Fatalf("read disk: %v", err)
@ -103,7 +103,7 @@ func TestRecordPut_CreateStampsAuditFields(t *testing.T) {
} }
// No history dir yet (create only). // No history dir yet (create only).
histDir := filepath.Join(cfg.Root, "Project", "archive", "Acme", "mdl", ".history") histDir := filepath.Join(cfg.Root, "Project", "archive", "ACM", "mdl", ".history")
if _, err := os.Stat(histDir); !os.IsNotExist(err) { if _, err := os.Stat(histDir); !os.IsNotExist(err) {
t.Errorf(".history/ should not exist after create-only; got err=%v", err) t.Errorf(".history/ should not exist after create-only; got err=%v", err)
} }
@ -114,7 +114,7 @@ func TestRecordPut_CreateStampsAuditFields(t *testing.T) {
// chains previous_sha, and increments revision. // chains previous_sha, and increments revision.
func TestRecordPut_UpdateIncrementsRevisionAndArchivesPrior(t *testing.T) { func TestRecordPut_UpdateIncrementsRevisionAndArchivesPrior(t *testing.T) {
cfg, do := historyTestSetup(t) cfg, do := historyTestSetup(t)
url := "/Project/archive/Acme/mdl/ACM-PRJ-EL-SPC-0001.yaml" url := "/Project/archive/ACM/mdl/ACM-PRJ-EL-SPC-0001.yaml"
body1 := []byte("originator: ACM\nproject: PRJ\ndiscipline: EL\ntype: SPC\nsequence: '0001'\ntitle: V1\n") body1 := []byte("originator: ACM\nproject: PRJ\ndiscipline: EL\ntype: SPC\nsequence: '0001'\ntitle: V1\n")
rec := do(http.MethodPut, url, "alice@example.com", body1, nil) rec := do(http.MethodPut, url, "alice@example.com", body1, nil)
@ -149,7 +149,7 @@ func TestRecordPut_UpdateIncrementsRevisionAndArchivesPrior(t *testing.T) {
} }
// .history/ACM-PRJ-EL-SPC-0001/ has exactly one entry (the v1 bytes). // .history/ACM-PRJ-EL-SPC-0001/ has exactly one entry (the v1 bytes).
histDir := filepath.Join(cfg.Root, "Project", "archive", "Acme", "mdl", ".history", "ACM-PRJ-EL-SPC-0001") histDir := filepath.Join(cfg.Root, "Project", "archive", "ACM", "mdl", ".history", "ACM-PRJ-EL-SPC-0001")
ents, err := os.ReadDir(histDir) ents, err := os.ReadDir(histDir)
if err != nil { if err != nil {
t.Fatalf("read history dir: %v", err) t.Fatalf("read history dir: %v", err)
@ -171,7 +171,7 @@ func TestRecordPut_UpdateIncrementsRevisionAndArchivesPrior(t *testing.T) {
// write anything — no history entry, no overwrite. // write anything — no history entry, no overwrite.
func TestRecordPut_ConflictPreservesHistory(t *testing.T) { func TestRecordPut_ConflictPreservesHistory(t *testing.T) {
cfg, do := historyTestSetup(t) cfg, do := historyTestSetup(t)
url := "/Project/archive/Acme/mdl/ACM-PRJ-EL-SPC-0001.yaml" url := "/Project/archive/ACM/mdl/ACM-PRJ-EL-SPC-0001.yaml"
body1 := []byte("originator: ACM\nproject: PRJ\ndiscipline: EL\ntype: SPC\nsequence: '0001'\ntitle: V1\n") body1 := []byte("originator: ACM\nproject: PRJ\ndiscipline: EL\ntype: SPC\nsequence: '0001'\ntitle: V1\n")
if rec := do(http.MethodPut, url, "alice@example.com", body1, nil); rec.Code != http.StatusCreated { if rec := do(http.MethodPut, url, "alice@example.com", body1, nil); rec.Code != http.StatusCreated {
@ -186,7 +186,7 @@ func TestRecordPut_ConflictPreservesHistory(t *testing.T) {
t.Fatalf("expected 412, got %d body=%s", rec.Code, rec.Body.String()) t.Fatalf("expected 412, got %d body=%s", rec.Code, rec.Body.String())
} }
histDir := filepath.Join(cfg.Root, "Project", "archive", "Acme", "mdl", ".history") histDir := filepath.Join(cfg.Root, "Project", "archive", "ACM", "mdl", ".history")
if _, err := os.Stat(histDir); !os.IsNotExist(err) { if _, err := os.Stat(histDir); !os.IsNotExist(err) {
t.Errorf("history dir should not exist after 412 conflict; got err=%v", err) t.Errorf("history dir should not exist after 412 conflict; got err=%v", err)
} }
@ -196,7 +196,7 @@ func TestRecordPut_ConflictPreservesHistory(t *testing.T) {
// audit fields → server silently strips and overwrites them. // audit fields → server silently strips and overwrites them.
func TestRecordPut_ClientAuditFieldsStripped(t *testing.T) { func TestRecordPut_ClientAuditFieldsStripped(t *testing.T) {
_, do := historyTestSetup(t) _, do := historyTestSetup(t)
url := "/Project/archive/Acme/mdl/ACM-PRJ-EL-SPC-0001.yaml" url := "/Project/archive/ACM/mdl/ACM-PRJ-EL-SPC-0001.yaml"
body := []byte("originator: ACM\nproject: PRJ\ndiscipline: EL\ntype: SPC\nsequence: '0001'\ntitle: Forged\n" + body := []byte("originator: ACM\nproject: PRJ\ndiscipline: EL\ntype: SPC\nsequence: '0001'\ntitle: Forged\n" +
"created_by: eve@evil.com\nupdated_by: eve@evil.com\nrevision: 999\n") "created_by: eve@evil.com\nupdated_by: eve@evil.com\nrevision: 999\n")
@ -221,7 +221,7 @@ func TestRecordPut_ClientAuditFieldsStripped(t *testing.T) {
func TestRecordPut_FilenameMismatch(t *testing.T) { func TestRecordPut_FilenameMismatch(t *testing.T) {
_, do := historyTestSetup(t) _, do := historyTestSetup(t)
// URL claims sequence=0002 but body says 0001 → mismatch. // URL claims sequence=0002 but body says 0001 → mismatch.
url := "/Project/archive/Acme/mdl/ACM-PRJ-EL-SPC-0002.yaml" url := "/Project/archive/ACM/mdl/ACM-PRJ-EL-SPC-0002.yaml"
body := []byte("originator: ACM\nproject: PRJ\ndiscipline: EL\ntype: SPC\nsequence: '0001'\ntitle: X\n") body := []byte("originator: ACM\nproject: PRJ\ndiscipline: EL\ntype: SPC\nsequence: '0001'\ntitle: X\n")
rec := do(http.MethodPut, url, "alice@example.com", body, nil) rec := do(http.MethodPut, url, "alice@example.com", body, nil)
if rec.Code != http.StatusUnprocessableEntity { if rec.Code != http.StatusUnprocessableEntity {
@ -229,12 +229,88 @@ func TestRecordPut_FilenameMismatch(t *testing.T) {
} }
} }
// TestAugmentSchema_OriginatorReadOnlyAndPrefilled verifies the form
// renderer marks the folder-bound originator read-only and pre-fills
// it with the party-folder name resolved from the cascade at the
// per-party mdl/ directory.
func TestAugmentSchema_OriginatorReadOnlyAndPrefilled(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, ".zddc"),
[]byte("acl:\n permissions:\n \"*@example.com\": rwcd\n"), 0o644); err != nil {
t.Fatal(err)
}
zddc.InvalidateCache(root)
gateDir := filepath.Join(root, "Project", "archive", "ACM", "mdl")
if err := os.MkdirAll(gateDir, 0o755); err != nil {
t.Fatal(err)
}
chain, err := zddc.EffectivePolicy(root, gateDir)
if err != nil {
t.Fatal(err)
}
var spec FormSpec
if err := yaml.Unmarshal(DefaultMdlFormYAML(), &spec); err != nil {
t.Fatal(err)
}
augmentSchemaFromCascade(spec.Schema, chain, gateDir)
orig := spec.Schema.Properties["originator"]
if orig == nil {
t.Fatal("originator property missing from default mdl schema")
}
if !orig.ReadOnly {
t.Errorf("originator.ReadOnly = false, want true (folder-bound)")
}
if orig.Default != "ACM" {
t.Errorf("originator.Default = %v, want ACM (party-folder name)", orig.Default)
}
}
// TestRecordPut_OriginatorBoundToPartyFolder: the mdl rule's
// folder_fields binds originator to the party-folder name. A client
// value is overwritten silently (folder is the sole source of truth),
// and a URL whose filename uses a different originator 422s on the
// filename-composition check.
func TestRecordPut_OriginatorBoundToPartyFolder(t *testing.T) {
cfg, do := historyTestSetup(t)
// Body claims originator=WRONG; the party folder is ACM. The URL
// filename correctly uses the folder name, so the server overwrites
// the body field and the write succeeds.
url := "/Project/archive/ACM/mdl/ACM-PRJ-EL-SPC-0001.yaml"
body := []byte("originator: WRONG\nproject: PRJ\ndiscipline: EL\ntype: SPC\nsequence: '0001'\ntitle: X\n")
rec := do(http.MethodPut, url, "alice@example.com", body, nil)
if rec.Code != http.StatusCreated {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
abs := filepath.Join(cfg.Root, "Project", "archive", "ACM", "mdl", "ACM-PRJ-EL-SPC-0001.yaml")
disk, err := os.ReadFile(abs)
if err != nil {
t.Fatalf("read disk: %v", err)
}
out := map[string]any{}
if err := yaml.Unmarshal(disk, &out); err != nil {
t.Fatal(err)
}
if out["originator"] != "ACM" {
t.Errorf("originator=%v want ACM (party-folder name overrides body)", out["originator"])
}
// A URL whose filename uses a different originator than the folder
// can't be composed to match — 422 filename mismatch.
badURL := "/Project/archive/ACM/mdl/WRONG-PRJ-EL-SPC-0002.yaml"
badBody := []byte("originator: WRONG\nproject: PRJ\ndiscipline: EL\ntype: SPC\nsequence: '0002'\ntitle: X\n")
rec = do(http.MethodPut, badURL, "alice@example.com", badBody, nil)
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("expected 422 for wrong-originator filename, got %d body=%s", rec.Code, rec.Body.String())
}
}
// TestRecordPut_LockedFieldRejected: rsk rule locks type=RSK; a // TestRecordPut_LockedFieldRejected: rsk rule locks type=RSK; a
// client submitting type=SPC for an rsk row gets 422 with // client submitting type=SPC for an rsk row gets 422 with
// path=/type. // path=/type.
func TestRecordPut_LockedFieldRejected(t *testing.T) { func TestRecordPut_LockedFieldRejected(t *testing.T) {
_, do := historyTestSetup(t) _, do := historyTestSetup(t)
url := "/Project/archive/Acme/rsk/ACM-PRJ-EL-RSK-0001-001.yaml" url := "/Project/archive/ACM/rsk/ACM-PRJ-EL-RSK-0001-001.yaml"
// Client tries type=SPC even though rsk/ locks type=RSK. // Client tries type=SPC even though rsk/ locks type=RSK.
body := []byte("originator: ACM\nproject: PRJ\ndiscipline: EL\ntype: SPC\nsequence: '0001'\nrow: '001'\ntitle: X\n") body := []byte("originator: ACM\nproject: PRJ\ndiscipline: EL\ntype: SPC\nsequence: '0001'\nrow: '001'\ntitle: X\n")
rec := do(http.MethodPut, url, "alice@example.com", body, nil) rec := do(http.MethodPut, url, "alice@example.com", body, nil)
@ -311,38 +387,39 @@ func TestRollupCreate_AssignsRowAndComposesFilename(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
// First row: full table-tracking components + the routing party // First row: table-tracking components + the routing party field.
// field. Server should pick row=001. // originator is omitted — the server derives it from the party
body1 := `{"party":"0330C1","originator":"ACM","project":"PRJ","discipline":"EL","sequence":"0001","title":"Schedule slip"}` // folder (0330C1) via folder_fields. Server should pick row=001.
body1 := `{"party":"0330C1","project":"PRJ","discipline":"EL","sequence":"0001","title":"Schedule slip"}`
rec := doForm(t, cfg, "POST", "/Project/rsk/form.html", "alice@example.com", body1) rec := doForm(t, cfg, "POST", "/Project/rsk/form.html", "alice@example.com", body1)
if rec.Code != http.StatusCreated { if rec.Code != http.StatusCreated {
t.Fatalf("first rsk create status=%d body=%s", rec.Code, rec.Body.String()) t.Fatalf("first rsk create status=%d body=%s", rec.Code, rec.Body.String())
} }
loc := rec.Result().Header.Get("Location") loc := rec.Result().Header.Get("Location")
if !strings.Contains(loc, "ACM-PRJ-EL-RSK-0001-001.yaml") { if !strings.Contains(loc, "0330C1-PRJ-EL-RSK-0001-001.yaml") {
t.Errorf("first row location=%q want ...-RSK-0001-001.yaml", loc) t.Errorf("first row location=%q want ...0330C1-PRJ-EL-RSK-0001-001.yaml", loc)
} }
// Second row in the same table: row=002. // Second row in the same table: row=002.
body2 := `{"party":"0330C1","originator":"ACM","project":"PRJ","discipline":"EL","sequence":"0001","title":"Cost overrun"}` body2 := `{"party":"0330C1","project":"PRJ","discipline":"EL","sequence":"0001","title":"Cost overrun"}`
rec = doForm(t, cfg, "POST", "/Project/rsk/form.html", "alice@example.com", body2) rec = doForm(t, cfg, "POST", "/Project/rsk/form.html", "alice@example.com", body2)
if rec.Code != http.StatusCreated { if rec.Code != http.StatusCreated {
t.Fatalf("second rsk create status=%d body=%s", rec.Code, rec.Body.String()) t.Fatalf("second rsk create status=%d body=%s", rec.Code, rec.Body.String())
} }
loc = rec.Result().Header.Get("Location") loc = rec.Result().Header.Get("Location")
if !strings.Contains(loc, "ACM-PRJ-EL-RSK-0001-002.yaml") { if !strings.Contains(loc, "0330C1-PRJ-EL-RSK-0001-002.yaml") {
t.Errorf("second row location=%q want ...-RSK-0001-002.yaml", loc) t.Errorf("second row location=%q want ...0330C1-PRJ-EL-RSK-0001-002.yaml", loc)
} }
// Different table-scope (sequence=0002) restarts at row=001. // Different table-scope (sequence=0002) restarts at row=001.
body3 := `{"party":"0330C1","originator":"ACM","project":"PRJ","discipline":"EL","sequence":"0002","title":"Risk in second register"}` body3 := `{"party":"0330C1","project":"PRJ","discipline":"EL","sequence":"0002","title":"Risk in second register"}`
rec = doForm(t, cfg, "POST", "/Project/rsk/form.html", "alice@example.com", body3) rec = doForm(t, cfg, "POST", "/Project/rsk/form.html", "alice@example.com", body3)
if rec.Code != http.StatusCreated { if rec.Code != http.StatusCreated {
t.Fatalf("third rsk create status=%d body=%s", rec.Code, rec.Body.String()) t.Fatalf("third rsk create status=%d body=%s", rec.Code, rec.Body.String())
} }
loc = rec.Result().Header.Get("Location") loc = rec.Result().Header.Get("Location")
if !strings.Contains(loc, "ACM-PRJ-EL-RSK-0002-001.yaml") { if !strings.Contains(loc, "0330C1-PRJ-EL-RSK-0002-001.yaml") {
t.Errorf("third row (new scope) location=%q want ...-RSK-0002-001.yaml", loc) t.Errorf("third row (new scope) location=%q want ...0330C1-PRJ-EL-RSK-0002-001.yaml", loc)
} }
// All three files contain audit fields (proves WriteWithHistory ran). // All three files contain audit fields (proves WriteWithHistory ran).

View file

@ -312,6 +312,15 @@ func serveFormCreateRollup(cfg config.Config, req *FormRequest, w http.ResponseW
dataMap[k] = want dataMap[k] = want
} }
} }
// Bind folder-derived fields (e.g. originator = party-folder
// name) BEFORE composing the filename so the composed name and
// the value WriteWithHistory will re-derive agree. slotAbs is
// <party>/<slot>, so originator: 1 resolves to the party folder.
if ferr := applyFolderFields(rule, slotAbs, cfg.Root, dataMap); ferr != nil {
auditFile(r, "rollup-create", req.SubmitURL, http.StatusInternalServerError, 0, ferr)
http.Error(w, "folder fields: "+ferr.Error(), http.StatusInternalServerError)
return
}
// Auto-assign the per-row sequence for RSK-style rules. // Auto-assign the per-row sequence for RSK-style rules.
if rule.RowField != "" { if rule.RowField != "" {
rowVal, rerr := AssignNextRow(slotAbs, rule.RowField, rule.RowScopeFields, dataMap) rowVal, rerr := AssignNextRow(slotAbs, rule.RowField, rule.RowScopeFields, dataMap)

View file

@ -265,6 +265,36 @@ paths:
field_defaults: field_defaults:
kind: SSR kind: SSR
locked: [kind] locked: [kind]
# ── Field-code vocabularies (field_codes:) ──────────────
# Each tracking-number component can be constrained by a
# field_codes entry at this (per-party) level — or higher
# if every party shares the same vocabulary. Three kinds:
#
# enum — closed code list; the label surfaces in form
# dropdowns and is enforced on POST/PUT.
# pattern — anchored regex (server wraps it with ^…$).
# free — no constraint; `description:` is help-text in
# the form UI.
#
# Map-merge across the cascade: a deeper .zddc can narrow
# or replace a single code without re-listing the others.
# `originator` is normally NOT listed here — it's bound to
# the party-folder name via folder_fields on the mdl/ + rsk/
# records below, so the folder is its sole source of truth.
#
# field_codes:
# discipline:
# kind: enum
# codes:
# EL: "Electrical"
# ME: "Mechanical"
# CV: "Civil"
# sequence:
# kind: pattern
# pattern: "[0-9]{4}" # zero-padded 4-digit
# type:
# kind: free
# description: "Document category code within the discipline"
paths: paths:
mdl: mdl:
default_tool: tables default_tool: tables
@ -275,14 +305,36 @@ paths:
virtual: true virtual: true
# MDL records: each .yaml file is an independent # MDL records: each .yaml file is an independent
# deliverable with its own composed tracking number. # deliverable with its own composed tracking number.
# No locks — the row's body fields drive the # No type lock — the row's body fields drive the
# filename, type is free-choice from the deployment's # filename; type is free-choice from the deployment's
# field_codes. Operators define field_codes at the # field_codes (see the field_codes block above).
# project root (or higher) to supply the originator / #
# discipline / type / sequence vocabularies. # Default template — five required components plus an
# optional per-deliverable suffix that marks parts of
# the SAME deliverable (A = Appendix A, 01 = Sheet 1):
#
# originator-project-discipline-type-sequence[-suffix]
#
# `originator` is folder-bound: the server sets it from
# the party-folder name (folder_fields below) and the
# form renders it read-only — the party folder is the
# single source of truth for the originator code.
#
# To add PROJECT-WIDE components (phase, area, ...),
# override filename_format here AND add matching
# properties to mdl/form.yaml + columns to mdl/table.yaml.
# Pick once per project and apply to EVERY deliverable;
# mixing schemas within one project breaks lexical sort
# and filtering. Example:
# records:
# "*.yaml":
# folder_fields: { originator: 1 }
# filename_format: "{originator}-{phase}-{project}-{area}-{discipline}-{type}-{sequence}-{suffix?}"
records: records:
"*.yaml": "*.yaml":
filename_format: "{originator}-{phase?}-{project}-{area?}-{discipline}-{type}-{sequence}{suffix?}" folder_fields:
originator: 1
filename_format: "{originator}-{project}-{discipline}-{type}-{sequence}-{suffix?}"
rsk: rsk:
default_tool: tables default_tool: tables
available_tools: [tables] available_tools: [tables]
@ -291,19 +343,28 @@ paths:
# 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 records: each .yaml file is a row of a parent
# rsk-type deliverable. The table itself has a # rsk-type deliverable. The table itself has a tracking
# tracking number (same shape as an MDL deliverable # number (same default components as an MDL deliverable
# with type=RSK); rows append a -{row} suffix. The # with type=RSK); rows append a -{row} suffix the server
# server auto-assigns row within the row-scope group # auto-assigns within the row-scope group on POST-create.
# on POST-create. # `originator` is folder-bound to the party folder, same
# as MDL.
#
# To add project-wide phase / area components, override
# BOTH filename_format AND row_scope_fields here — the
# scope fields decide which rows share a row-number
# sequence, so they must list the same components the
# filename does.
records: records:
"*.yaml": "*.yaml":
filename_format: "{originator}-{phase?}-{project}-{area?}-{discipline}-{type}-{sequence}{suffix?}-{row}" folder_fields:
originator: 1
filename_format: "{originator}-{project}-{discipline}-{type}-{sequence}-{suffix?}-{row}"
field_defaults: field_defaults:
type: RSK type: RSK
locked: [type] locked: [type]
row_field: row row_field: row
row_scope_fields: [originator, phase, project, area, discipline, type, sequence, suffix] row_scope_fields: [originator, project, 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

View file

@ -153,12 +153,29 @@ func (fc FieldCode) Validate(value string) error {
// RowScopeFields names the fields that, together, identify the // RowScopeFields names the fields that, together, identify the
// parent deliverable that a row belongs to. Two records with the // parent deliverable that a row belongs to. Two records with the
// same scope-field values share a row-numbering sequence. // same scope-field values share a row-numbering sequence.
//
// FolderFields binds a body field to an ancestor folder name, making
// the folder the single source of truth for that component. The map
// value is the number of parent directories ABOVE the record's own
// directory whose folder name supplies the value (0 = the record's
// own directory, 1 = its parent, …). The server overwrites the body
// field with the derived name before validation and filename
// composition, so a client value can never disagree with the path.
// Example: under archive/<party>/mdl/, a record file's directory is
// mdl/ and its parent (distance 1) is the <party> folder, so
//
// folder_fields: { originator: 1 }
//
// pins every deliverable's originator to its party-folder name. The
// form renderer marks such fields read-only and pre-fills the derived
// value.
type RecordRule struct { type RecordRule struct {
FilenameFormat string `yaml:"filename_format,omitempty" json:"filename_format,omitempty"` FilenameFormat string `yaml:"filename_format,omitempty" json:"filename_format,omitempty"`
FieldDefaults map[string]string `yaml:"field_defaults,omitempty" json:"field_defaults,omitempty"` FieldDefaults map[string]string `yaml:"field_defaults,omitempty" json:"field_defaults,omitempty"`
Locked []string `yaml:"locked,omitempty" json:"locked,omitempty"` Locked []string `yaml:"locked,omitempty" json:"locked,omitempty"`
RowField string `yaml:"row_field,omitempty" json:"row_field,omitempty"` RowField string `yaml:"row_field,omitempty" json:"row_field,omitempty"`
RowScopeFields []string `yaml:"row_scope_fields,omitempty" json:"row_scope_fields,omitempty"` RowScopeFields []string `yaml:"row_scope_fields,omitempty" json:"row_scope_fields,omitempty"`
FolderFields map[string]int `yaml:"folder_fields,omitempty" json:"folder_fields,omitempty"`
} }
// mergeRecordRule composes two RecordRules, top taking precedence on // mergeRecordRule composes two RecordRules, top taking precedence on
@ -180,6 +197,23 @@ func mergeRecordRule(base, top RecordRule) RecordRule {
// the order); top entirely replaces base when set. // the order); top entirely replaces base when set.
out.RowScopeFields = append([]string(nil), top.RowScopeFields...) out.RowScopeFields = append([]string(nil), top.RowScopeFields...)
} }
out.FolderFields = mergeIntMap(out.FolderFields, top.FolderFields)
return out
}
// mergeIntMap composes two map[string]int with top taking precedence
// per-key. Mirrors mergeStringMap's semantics for FolderFields.
func mergeIntMap(base, top map[string]int) map[string]int {
if len(top) == 0 {
return base
}
out := make(map[string]int, len(base)+len(top))
for k, v := range base {
out[k] = v
}
for k, v := range top {
out[k] = v
}
return out return out
} }