Compare commits
6 commits
cc7f34e922
...
8ef2ce01d0
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ef2ce01d0 | |||
| 7dfedc2342 | |||
| 9341c47937 | |||
| 875827d484 | |||
| 662bfbdbf9 | |||
| e3db2f8473 |
20 changed files with 861 additions and 158 deletions
21
AGENTS.md
21
AGENTS.md
|
|
@ -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 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`.
|
||||
|
||||
|
|
@ -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`):
|
||||
|
||||
- `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:`):
|
||||
- `created_at`, `created_by` — stamped on create; preserved untouched on every update
|
||||
|
|
@ -438,18 +439,26 @@ Defaults are baked into `defaults.zddc.yaml`; `field_codes:` ships empty (every
|
|||
|
||||
**Strip-and-stamp policy**: clients can't forge audit fields. `WriteWithHistory` strips all six keys from the incoming body BEFORE schema validation runs, then injects authoritative values from request context. A client that sends `created_by: eve@evil` finds it silently overwritten with the request principal.
|
||||
|
||||
**Wire surface**:
|
||||
**Wire surface** — every record-write entry point converges on `WriteWithHistory`:
|
||||
- `PUT /<record>.yaml` — routed through `WriteWithHistory` automatically when the basename matches a `records:` rule. Response echoes the stamped YAML as the body (Content-Type: application/yaml) so the tables client can update its row state without a re-GET.
|
||||
- `POST /<dir>/form.html` (in-dir create) and `POST /<dir>/<id>.yaml.html` (in-dir update), plus the project rollup `POST /<project>/(mdl|rsk)/form.html` — when a `records:` rule with a `filename_format` applies in the target directory, these compose the filename (shared `recordCreatePrep`: field_defaults + folder_fields + row-assign + compose), then route through `WriteWithHistory` for audit + history + the composed-name match check. So "+ Add row" from a per-party table no longer drops un-stamped, date+email-named rows. Directories with no record rule keep the generic date+email submission write.
|
||||
- `GET /<record>.yaml?history=1` — JSON list of prior revisions: `[{revision, ts, by, sha, path}, …]`. ACL gates against the live record (read it → read its history).
|
||||
|
||||
**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**:
|
||||
- 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 inspect a record's revision history: `curl https://<host>/<path>.yaml?history=1 -H 'Authorization: Bearer …'`.
|
||||
|
||||
Source: `zddc/internal/handler/history.go`, `zddc/internal/zddc/field_codes.go`, `zddc/internal/zddc/walker.go`, `zddc/internal/zddc/cascade.go`, `zddc/internal/zddc/defaults.zddc.yaml`. Tests: `zddc/internal/handler/history_test.go`.
|
||||
**Server-side only (offline gap)**: every record guarantee — audit stamping, immutable history, `filename_format` composition, `field_codes`/`locked` validation, and `folder_fields` binding — runs in zddc-server (`WriteWithHistory` + the form handlers). The tools opened offline (`file://` or the File-System-Access picker, no server) **cannot** enforce any of it: a record write needs the server. This is by design — the server is the authority — but it means folder-bound originator, composed filenames, and audit fields don't materialize for purely-offline edits.
|
||||
|
||||
**Upgrading a pre-folder-binding deployment** (records created before these defaults):
|
||||
- Stored `suffix:` values that carried a leading dash under the old `-A` convention now compose a doubled dash (`…0001--A`) and 422 on next edit. Strip the leading dash from `suffix:` values (`-A` → `A`); the cascade's `filename_format` supplies the separator now.
|
||||
- A row whose `originator` differs from its party-folder name is silently rewritten to the folder name on the next write (the folder is the source of truth). Filenames whose originator segment disagrees with the folder will 422 until the file is renamed to match.
|
||||
- Deployments that used the project-wide `phase`/`area` components already supplied a custom `form.yaml` + `.zddc` override (the prior default couldn't compose those slots otherwise), so the phase/area removal from the embedded defaults doesn't affect them.
|
||||
|
||||
Source: `zddc/internal/handler/history.go`, `zddc/internal/zddc/field_codes.go`, `zddc/internal/zddc/walker.go`, `zddc/internal/zddc/cascade.go`, `zddc/internal/zddc/defaults.zddc.yaml`. Tests: `zddc/internal/handler/history_test.go`, `zddc/internal/zddc/field_codes_test.go`.
|
||||
|
||||
## Implementation-vs-dependency policy
|
||||
|
||||
|
|
|
|||
|
|
@ -470,7 +470,7 @@ app.state.subscribe((property, newValue) => {
|
|||
|
||||
**Server-side counterpart:** `zddc/internal/handler/formhandler.go` recognizes `*.form.html` and `*.yaml.html` URLs, parses the spec, validates submissions via `zddc/internal/jsonschema/`, writes via `zddc.WriteAtomic` (plain submissions) or `zddc/internal/handler/history.go` `WriteWithHistory` (record-typed YAML — mdl rows, rsk rows, ssr.yaml). Existence of `<name>.form.yaml` is the trigger; without it, the URL falls through to static-file serving.
|
||||
|
||||
**Record-vs-submission distinction.** "Records" are the three table-store types (mdl/rsk/ssr); everything else is a "submission." Records get server-stamped audit fields (`created_at`/`_by`, `updated_at`/`_by`, `revision`, `previous_sha`), an immutable per-record history at `<dir>/.history/<base>/<RFC3339Nano>-<sha8>.<ext>`, cascade-driven filename composition (via the `records:` + `field_codes:` `.zddc` keys), and per-folder field locking (e.g. type=RSK in rsk/). The mechanism intercepts at the file-API write path (`serveFilePut`): if `isRecordPath` matches, the call routes through `WriteWithHistory`; otherwise the historical `WriteAtomic` path is used. See AGENTS.md "Records, audit, and history" for the operator surface; `zddc/internal/handler/history.go` for the orchestration.
|
||||
**Record-vs-submission distinction.** "Records" are the three table-store types (mdl/rsk/ssr); everything else is a "submission." Records get server-stamped audit fields (`created_at`/`_by`, `updated_at`/`_by`, `revision`, `previous_sha`), an immutable per-record history at `<dir>/.history/<base>/<RFC3339Nano>-<sha8>.<ext>`, cascade-driven filename composition (via the `records:` + `field_codes:` `.zddc` keys), per-folder field locking (e.g. type=RSK in rsk/), and folder-bound fields (`folder_fields`, e.g. originator = party-folder name). The mechanism intercepts at every write entry point — the file-API `serveFilePut` (if `isRecordPath` matches → `WriteWithHistory`, else `WriteAtomic`), the in-dir form create/update (`serveFormCreate`/`serveFormUpdate`), and the project rollup (`serveFormCreateRollup`). Each resolves the `records:` rule for the target directory and, when one with a `filename_format` applies, composes the name via the shared `recordCreatePrep` and routes through `WriteWithHistory`; non-record paths keep the historical date+email `WriteAtomic` write. The convergence means there's no back door that writes an un-stamped, un-composed record. All of it is server-side: the tools opened offline (`file://` / FS-Access, no server) can't enforce audit, composition, `field_codes`, or `folder_fields` — record writes need zddc-server. See AGENTS.md "Records, audit, and history" for the operator surface (incl. the offline gap and pre-folder-binding upgrade notes); `zddc/internal/handler/history.go` for the orchestration.
|
||||
|
||||
**Round-trip philosophy:** v0 is "form-as-truth" — submission YAML is regenerated from form state on every save. Hand-edits to submission files are not preserved across re-edit→re-submit. v1 will add an opt-in "file-as-truth" mode (eemeli/yaml Document API) for forms like `.zddc` itself where users hand-edit and comments must survive.
|
||||
|
||||
|
|
|
|||
|
|
@ -71,6 +71,29 @@
|
|||
fs.appendChild(childWidget.el);
|
||||
}
|
||||
|
||||
// Cross-field mirror: a field with `ui:mirrorFrom: <sibling>`
|
||||
// shows the live value of that sibling. Used by the project-
|
||||
// rollup forms so the read-only `originator` reflects the
|
||||
// selected Package (party) — the party folder is the
|
||||
// originator's source of truth. Display-only: the server is
|
||||
// still authoritative via the cascade's folder_fields.
|
||||
for (let i = 0; i < ordered.length; i++) {
|
||||
const name = ordered[i];
|
||||
const mirrorFrom = ui && ui[name] && ui[name]['ui:mirrorFrom'];
|
||||
if (!mirrorFrom || !children[name] || !children[mirrorFrom]) {
|
||||
continue;
|
||||
}
|
||||
const targetInput = children[name].el.querySelector('input, select, textarea');
|
||||
const sourceInput = children[mirrorFrom].el.querySelector('input, select, textarea');
|
||||
if (!targetInput || !sourceInput) {
|
||||
continue;
|
||||
}
|
||||
const sync = function () { targetInput.value = sourceInput.value; };
|
||||
sourceInput.addEventListener('input', sync);
|
||||
sourceInput.addEventListener('change', sync);
|
||||
sync(); // initialize from any pre-filled party value
|
||||
}
|
||||
|
||||
return {
|
||||
el: fs,
|
||||
path: path,
|
||||
|
|
|
|||
|
|
@ -231,6 +231,16 @@
|
|||
|
||||
const propSchema = propertySchemaFor(col);
|
||||
|
||||
// Read-only cells (schema readOnly:true — e.g. the folder-bound
|
||||
// originator the server derives from the party folder, or
|
||||
// server-managed audit fields) can't be edited: any value the
|
||||
// user typed would be overwritten on write. Suppress edit entry
|
||||
// entirely; selection still works for keyboard navigation, same
|
||||
// as the $-prefixed synthesized columns above.
|
||||
if (propSchema && propSchema.readOnly) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Complex-type cells (nested object, generic array, oneOf)
|
||||
// can't be inline-edited cleanly — punt to the row's form
|
||||
// editor in a side panel / new page. Phase 2 ships the
|
||||
|
|
|
|||
|
|
@ -376,8 +376,28 @@
|
|||
const newEtag = (resp.headers.get('ETag') || '').replace(/"/g, '');
|
||||
row.yamlUrl = location;
|
||||
row.url = location ? location + '.html' : row.url;
|
||||
// Re-fetch the just-written row so server-derived fields
|
||||
// surface immediately: folder-bound originator, the composed
|
||||
// tracking number's components, and audit stamps. The local
|
||||
// `merged` lacks these (e.g. originator is read-only and
|
||||
// never typed). Fall back to merged if the GET fails.
|
||||
row.data = merged;
|
||||
row.etag = newEtag || null;
|
||||
if (location) {
|
||||
try {
|
||||
const back = await fetch(location, { credentials: 'same-origin' });
|
||||
if (back.ok) {
|
||||
const text = await back.text();
|
||||
if (text && text.trim() && window.jsyaml) {
|
||||
row.data = window.jsyaml.load(text) || merged;
|
||||
}
|
||||
const fetchedEtag = (back.headers.get('ETag') || '').replace(/"/g, '');
|
||||
if (fetchedEtag) row.etag = fetchedEtag;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[tables] post-create re-fetch failed; using local merge', e);
|
||||
}
|
||||
}
|
||||
if (!row.etag) row.etag = newEtag || null;
|
||||
row.isNew = false;
|
||||
// Move the drafts entry (was keyed on the synthetic id) to
|
||||
// the new url, then clear it (data has the merged values).
|
||||
|
|
@ -417,6 +437,20 @@
|
|||
return { status: 'forbidden' };
|
||||
}
|
||||
|
||||
if (resp.status === 409) {
|
||||
// The composed tracking number collides with an existing
|
||||
// row (the server rejects duplicates). Surface it on the
|
||||
// sequence cell — the usual disambiguator — rather than the
|
||||
// generic errored state, so the user knows to bump a
|
||||
// component instead of retrying the same values.
|
||||
let msg = 'Duplicate tracking number — change a component (e.g. sequence).';
|
||||
try { const t = await resp.text(); if (t && t.trim()) msg = t.trim(); } catch (_) { /* ignore */ }
|
||||
clearCellInvalid(rowId);
|
||||
markCellInvalid(rowId, 'sequence', msg);
|
||||
setRowState(rowId, 'invalid');
|
||||
return { status: 'duplicate', message: msg };
|
||||
}
|
||||
|
||||
console.warn('[tables] createRow returned', resp.status);
|
||||
setRowState(rowId, 'errored');
|
||||
return { status: 'http-error', code: resp.status };
|
||||
|
|
|
|||
|
|
@ -91,6 +91,39 @@ test.describe('form/ — safety check-in renderer', () => {
|
|||
await expect(page.locator('#table-title')).toContainText('Safety Check-In');
|
||||
});
|
||||
|
||||
test('ui:mirrorFrom reflects a sibling field into a read-only field', async ({ page }) => {
|
||||
// The project-rollup forms use this so the read-only originator
|
||||
// shows the selected Package (party) — the party folder is the
|
||||
// originator's source of truth.
|
||||
await loadFormWithContext(page, {
|
||||
title: 'Rollup deliverable',
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['party'],
|
||||
properties: {
|
||||
party: { type: 'string', title: 'Package' },
|
||||
originator: { type: 'string', title: 'Originator', readOnly: true },
|
||||
},
|
||||
},
|
||||
ui: { originator: { 'ui:mirrorFrom': 'party' } },
|
||||
data: null,
|
||||
submitUrl: '/test/rollup.form.html',
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => document.getElementById('form-root') && document.getElementById('form-root').children.length > 0,
|
||||
null,
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
|
||||
const inputs = page.locator('#form-root input[type="text"]');
|
||||
await expect(inputs).toHaveCount(2);
|
||||
const party = inputs.nth(0);
|
||||
const originator = inputs.nth(1);
|
||||
await expect(originator).not.toBeEditable(); // read-only
|
||||
await party.fill('0330C1');
|
||||
await expect(originator).toHaveValue('0330C1');
|
||||
});
|
||||
|
||||
test('add/remove hazard rows works', async ({ page }) => {
|
||||
await loadFormWithContext(page, {
|
||||
schema: SAFETY_SCHEMA,
|
||||
|
|
|
|||
|
|
@ -536,6 +536,44 @@ test.describe('tables/ — directory-of-YAML table view', () => {
|
|||
expect(target).toContain('.yaml.html');
|
||||
});
|
||||
|
||||
test('Phase 2: readOnly column suppresses the inline editor', async ({ page }) => {
|
||||
// Self-contained fixture: a folder-bound / server-managed field
|
||||
// (schema readOnly:true) must not become an editable cell, while
|
||||
// a normal sibling column still edits.
|
||||
await loadTableWithContext(page, {
|
||||
columns: [
|
||||
{ field: 'originator', title: 'Originator' },
|
||||
{ field: 'title', title: 'Title' },
|
||||
],
|
||||
rows: [{
|
||||
url: '/Working/MDL/ACM-PRJ-EL-SPC-0001.yaml.html',
|
||||
data: { originator: 'ACM', title: 'Spec' },
|
||||
editable: true,
|
||||
}],
|
||||
rowSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
originator: { type: 'string', readOnly: true },
|
||||
title: { type: 'string' },
|
||||
},
|
||||
},
|
||||
});
|
||||
await page.waitForSelector('#table-root tbody tr');
|
||||
|
||||
const ro = page.locator('#table-root tbody tr').nth(0)
|
||||
.locator('[role="gridcell"]').nth(0);
|
||||
await ro.dblclick();
|
||||
// No inline editor mounts; the displayed value is untouched.
|
||||
await expect(ro.locator('.zddc-table__cell-input')).toHaveCount(0);
|
||||
await expect(ro).toHaveText('ACM');
|
||||
|
||||
// A normal column in the same table still edits.
|
||||
const editable = page.locator('#table-root tbody tr').nth(0)
|
||||
.locator('[role="gridcell"]').nth(1);
|
||||
await editable.dblclick();
|
||||
await expect(editable.locator('input.zddc-table__cell-input')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Phase 2: no rowSchema → falls back to plain text editor', async ({ page }) => {
|
||||
await loadTableWithContext(page, {
|
||||
// No rowSchema in the context — same as a directory with
|
||||
|
|
|
|||
|
|
@ -2,15 +2,22 @@
|
|||
# zddc-server when no operator-supplied form.yaml exists at
|
||||
# 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
|
||||
# MDL metadata (title, planned revision, planned date, status, owner,
|
||||
# notes). The schema is intentionally permissive on the components
|
||||
# (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. Tightening per project is done via .zddc field_codes:, which
|
||||
# the cascade resolves before the form is rendered.
|
||||
# notes). The default ships the five required components + an optional
|
||||
# per-deliverable suffix; the project-wide phase / area components are
|
||||
# present only as commented-out templates (see below). The schema is
|
||||
# intentionally permissive on the components (free-text strings, no
|
||||
# regex / enum constraints) — projects choose their own conventions,
|
||||
# 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
|
||||
# render them as read-only and never submit values for them.
|
||||
|
|
@ -19,40 +26,51 @@
|
|||
#
|
||||
# To customize: drop your own form.yaml into archive/<party>/mdl/
|
||||
# (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
|
||||
# to surface the field in the table view too.
|
||||
|
||||
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:
|
||||
type: object
|
||||
required: [originator, project, discipline, type, sequence, title]
|
||||
# originator is omitted from required: it's folder-bound — the server
|
||||
# derives it from the party-folder name (folder_fields) and the form
|
||||
# renders it read-only.
|
||||
required: [project, discipline, type, sequence, title]
|
||||
additionalProperties: false
|
||||
properties:
|
||||
# --- Tracking-number components (matches the reference doc's
|
||||
# field definitions, in order). originator / project / discipline
|
||||
# / type / sequence are the structural minimum; phase / area /
|
||||
# suffix are optional and project-dependent.
|
||||
# / type / sequence are the structural minimum that ships by
|
||||
# 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:
|
||||
type: string
|
||||
title: Originator
|
||||
description: Organizational unit responsible for this deliverable (e.g. ACME).
|
||||
minLength: 1
|
||||
phase:
|
||||
type: string
|
||||
title: Phase
|
||||
description: Optional project phase code (e.g. ECI, EPC). Leave blank if your tracking-number schema doesn't use phases.
|
||||
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.
|
||||
readOnly: true
|
||||
# phase: # project-wide; sits between originator and project
|
||||
# type: string
|
||||
# title: Phase
|
||||
# description: Project phase code (e.g. ECI, EPC).
|
||||
# minLength: 1
|
||||
project:
|
||||
type: string
|
||||
title: Project
|
||||
description: Project identifier, or your corporate placeholder (e.g. 000000) for non-project deliverables.
|
||||
minLength: 1
|
||||
area:
|
||||
type: string
|
||||
title: Area
|
||||
description: Optional area / budget code (e.g. B02). Leave blank if unused.
|
||||
# area: # project-wide; sits between project and discipline
|
||||
# type: string
|
||||
# title: Area
|
||||
# description: Area / budget code (e.g. B02).
|
||||
# minLength: 1
|
||||
discipline:
|
||||
type: string
|
||||
title: Discipline
|
||||
|
|
@ -71,7 +89,7 @@ schema:
|
|||
suffix:
|
||||
type: string
|
||||
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.
|
||||
title:
|
||||
|
|
|
|||
|
|
@ -4,10 +4,11 @@
|
|||
# Columns mirror the tracking-number component model documented at
|
||||
# zddc.varasys.io/reference.html#tracking-numbers — every column from
|
||||
# `originator` through `suffix` is one slot of a deliverable's
|
||||
# permanent identifier. Optional components ([phase], [area], [suffix])
|
||||
# render in the table even when blank so the layout stays consistent
|
||||
# across rows; users on schemas that don't use them can hide the
|
||||
# columns by overriding this spec (see customization note below).
|
||||
# permanent identifier. The default ships the five required components
|
||||
# + the optional per-deliverable suffix; the project-wide phase / area
|
||||
# columns are commented out below — uncomment them (alongside the
|
||||
# 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
|
||||
# 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:
|
||||
# --- Tracking-number components (in the order they appear in the
|
||||
# canonical filename: originator-[phase-]project-[area-]discipline-
|
||||
# type-sequence[-suffix]). Optional components are kept narrow so
|
||||
# they don't clutter the layout when unused.
|
||||
# canonical filename: originator-project-discipline-type-sequence
|
||||
# [-suffix]). originator is folder-bound (set from the party folder);
|
||||
# suffix is the optional per-deliverable part marker.
|
||||
- field: originator
|
||||
title: Originator
|
||||
width: 8em
|
||||
- field: phase
|
||||
title: Phase
|
||||
width: 5em
|
||||
# - field: phase # project-wide; uncomment with form.yaml + filename_format
|
||||
# title: Phase
|
||||
# width: 5em
|
||||
- field: project
|
||||
title: Project
|
||||
width: 8em
|
||||
- field: area
|
||||
title: Area
|
||||
width: 5em
|
||||
# - field: area # project-wide; uncomment with form.yaml + filename_format
|
||||
# title: Area
|
||||
# width: 5em
|
||||
- field: discipline
|
||||
title: Disc.
|
||||
width: 5em
|
||||
|
|
|
|||
|
|
@ -19,7 +19,9 @@ description: One deliverable across all parties. The first field (Package) route
|
|||
|
||||
schema:
|
||||
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
|
||||
properties:
|
||||
party:
|
||||
|
|
@ -31,21 +33,23 @@ schema:
|
|||
originator:
|
||||
type: string
|
||||
title: Originator
|
||||
description: Organizational unit responsible for this deliverable (e.g. ACME).
|
||||
minLength: 1
|
||||
phase:
|
||||
type: string
|
||||
title: Phase
|
||||
description: Optional project phase code (e.g. ECI, EPC).
|
||||
description: Auto-set from the selected Package (party folder) — the folder is the source of truth for the originator code. Read-only; leave blank.
|
||||
readOnly: true
|
||||
# phase: # project-wide; sits between originator and project
|
||||
# type: string
|
||||
# title: Phase
|
||||
# description: Project phase code (e.g. ECI, EPC).
|
||||
# minLength: 1
|
||||
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 (e.g. B02).
|
||||
# area: # project-wide; sits between project and discipline
|
||||
# type: string
|
||||
# title: Area
|
||||
# description: Area / budget code (e.g. B02).
|
||||
# minLength: 1
|
||||
discipline:
|
||||
type: string
|
||||
title: Discipline
|
||||
|
|
@ -64,7 +68,7 @@ schema:
|
|||
suffix:
|
||||
type: string
|
||||
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:
|
||||
type: string
|
||||
title: Deliverable title
|
||||
|
|
@ -118,5 +122,10 @@ schema:
|
|||
title: Previous SHA
|
||||
readOnly: true
|
||||
ui:
|
||||
# originator is server-derived from the selected Package (party
|
||||
# folder); mirror the party value into its read-only field so the
|
||||
# composing tracking number is visible as the user fills the form.
|
||||
originator:
|
||||
ui:mirrorFrom: party
|
||||
notes:
|
||||
ui:widget: textarea
|
||||
|
|
|
|||
|
|
@ -26,15 +26,15 @@ columns:
|
|||
- field: originator
|
||||
title: Originator
|
||||
width: 8em
|
||||
- field: phase
|
||||
title: Phase
|
||||
width: 5em
|
||||
# - field: phase # project-wide; uncomment with form.yaml + filename_format
|
||||
# title: Phase
|
||||
# width: 5em
|
||||
- field: project
|
||||
title: Project
|
||||
width: 8em
|
||||
- field: area
|
||||
title: Area
|
||||
width: 5em
|
||||
# - field: area # project-wide; uncomment with form.yaml + filename_format
|
||||
# title: Area
|
||||
# width: 5em
|
||||
- field: discipline
|
||||
title: Disc.
|
||||
width: 5em
|
||||
|
|
|
|||
|
|
@ -22,7 +22,10 @@ schema:
|
|||
# form renderer surfaces it as a locked readOnly field. Requiring
|
||||
# it here would 422 well-behaved clients that omit the cascade-
|
||||
# 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
|
||||
properties:
|
||||
party:
|
||||
|
|
@ -37,17 +40,20 @@ schema:
|
|||
originator:
|
||||
type: string
|
||||
title: Originator
|
||||
minLength: 1
|
||||
phase:
|
||||
type: string
|
||||
title: Phase
|
||||
description: Auto-set from the selected Package (party folder) — the folder is the source of truth. Read-only; leave blank.
|
||||
readOnly: true
|
||||
# phase: # project-wide; sits between originator and project
|
||||
# type: string
|
||||
# title: Phase
|
||||
# minLength: 1
|
||||
project:
|
||||
type: string
|
||||
title: Project
|
||||
minLength: 1
|
||||
area:
|
||||
type: string
|
||||
title: Area
|
||||
# area: # project-wide; sits between project and discipline
|
||||
# type: string
|
||||
# title: Area
|
||||
# minLength: 1
|
||||
discipline:
|
||||
type: string
|
||||
title: Discipline
|
||||
|
|
@ -64,6 +70,7 @@ schema:
|
|||
suffix:
|
||||
type: string
|
||||
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:
|
||||
type: string
|
||||
title: Row
|
||||
|
|
@ -145,6 +152,11 @@ schema:
|
|||
title: Previous SHA
|
||||
readOnly: true
|
||||
ui:
|
||||
# originator is server-derived from the selected Package (party
|
||||
# folder); mirror the party value into its read-only field so the
|
||||
# composing tracking number is visible as the user fills the form.
|
||||
originator:
|
||||
ui:mirrorFrom: party
|
||||
description:
|
||||
ui:widget: textarea
|
||||
mitigation:
|
||||
|
|
|
|||
|
|
@ -39,7 +39,10 @@ schema:
|
|||
# `type` is intentionally absent from required: — the cascade's
|
||||
# field_defaults inject type=RSK after schema validation, and the
|
||||
# form renderer surfaces it as a locked readOnly field.
|
||||
required: [originator, project, discipline, sequence, title]
|
||||
# originator is omitted from required: it's folder-bound — the server
|
||||
# derives it from the party-folder name (folder_fields) and the form
|
||||
# renders it read-only.
|
||||
required: [project, discipline, sequence, title]
|
||||
additionalProperties: false
|
||||
properties:
|
||||
# --- Table-tracking components: identify which RSK deliverable
|
||||
|
|
@ -48,21 +51,23 @@ schema:
|
|||
originator:
|
||||
type: string
|
||||
title: Originator
|
||||
description: Organizational unit responsible for this risk register.
|
||||
minLength: 1
|
||||
phase:
|
||||
type: string
|
||||
title: Phase
|
||||
description: Optional project phase code (ECI, EPC, ...).
|
||||
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.
|
||||
readOnly: true
|
||||
# phase: # project-wide; sits between originator and project
|
||||
# type: string
|
||||
# title: Phase
|
||||
# description: Project phase code (ECI, EPC, ...).
|
||||
# minLength: 1
|
||||
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.
|
||||
# area: # project-wide; sits between project and discipline
|
||||
# type: string
|
||||
# title: Area
|
||||
# description: Area / budget code.
|
||||
# minLength: 1
|
||||
discipline:
|
||||
type: string
|
||||
title: Discipline
|
||||
|
|
@ -81,7 +86,7 @@ schema:
|
|||
suffix:
|
||||
type: string
|
||||
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
|
||||
# POST-create; preserved as-is on PUT-update.
|
||||
|
|
|
|||
|
|
@ -408,23 +408,74 @@ func serveFormCreate(cfg config.Config, req *FormRequest, w http.ResponseWriter,
|
|||
return
|
||||
}
|
||||
|
||||
dateStr := time.Now().UTC().Format("2006-01-02")
|
||||
emailSan := sanitizeEmail(email)
|
||||
base := dateStr + "-" + emailSan
|
||||
target, fname, ok := pickAvailableFilename(submissionsDir, base, ".yaml")
|
||||
if !ok {
|
||||
http.Error(w, "could not pick a free filename (>100 collisions)", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
var target, fname string
|
||||
|
||||
yamlBytes, err := yaml.Marshal(data)
|
||||
if err != nil {
|
||||
http.Error(w, "marshal yaml: "+err.Error(), http.StatusInternalServerError)
|
||||
// Record path: when a records: rule with a filename_format applies
|
||||
// in this directory, route the create through the same compose +
|
||||
// folder_fields + audit + history machinery as a PUT or the project
|
||||
// rollup — NOT the generic date+email submission write. This keeps
|
||||
// in-dir "+ Add row" on a per-party mdl/rsk table consistent with
|
||||
// every other record-write entry point (no un-stamped, un-composed
|
||||
// rows leaking in through this door).
|
||||
rowChain, perr := zddc.EffectivePolicy(cfg.Root, submissionsDir)
|
||||
if perr != nil {
|
||||
http.Error(w, "cascade resolve: "+perr.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := zddc.WriteAtomic(target, yamlBytes); err != nil {
|
||||
http.Error(w, "write: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
if _, rule, hasRule := rowChain.EffectiveRecordRule("placeholder.yaml"); hasRule && rule.FilenameFormat != "" {
|
||||
dataMap, ok := data.(map[string]interface{})
|
||||
if !ok {
|
||||
http.Error(w, "request body must be a YAML/JSON object", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var composeErr *jsonschema.Error
|
||||
fname, composeErr, err = recordCreatePrep(cfg, submissionsDir, rule, dataMap)
|
||||
if err != nil {
|
||||
http.Error(w, "record prep: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if composeErr != nil {
|
||||
writeValidationErrors(w, []jsonschema.Error{*composeErr})
|
||||
return
|
||||
}
|
||||
target = filepath.Join(submissionsDir, fname)
|
||||
if _, statErr := os.Stat(target); statErr == nil {
|
||||
http.Error(w, "Conflict — a row with this composed tracking number already exists", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
yamlBytes, mErr := yaml.Marshal(dataMap)
|
||||
if mErr != nil {
|
||||
http.Error(w, "marshal yaml: "+mErr.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if _, verrs, herr := WriteWithHistory(cfg, target, req.SubmitURL, yamlBytes, email); herr != nil {
|
||||
http.Error(w, "write: "+herr.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
} else if len(verrs) > 0 {
|
||||
writeValidationErrors(w, verrs)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Generic submission: server-dated, email-tagged filename + a
|
||||
// plain write (no audit stamping — these aren't records).
|
||||
dateStr := time.Now().UTC().Format("2006-01-02")
|
||||
emailSan := sanitizeEmail(email)
|
||||
base := dateStr + "-" + emailSan
|
||||
var ok bool
|
||||
target, fname, ok = pickAvailableFilename(submissionsDir, base, ".yaml")
|
||||
if !ok {
|
||||
http.Error(w, "could not pick a free filename (>100 collisions)", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
yamlBytes, mErr := yaml.Marshal(data)
|
||||
if mErr != nil {
|
||||
http.Error(w, "marshal yaml: "+mErr.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if wErr := zddc.WriteAtomic(target, yamlBytes); wErr != nil {
|
||||
http.Error(w, "write: "+wErr.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Capability URL: the path to the new submission file. The renderer
|
||||
|
|
@ -491,8 +542,20 @@ func serveFormUpdate(cfg config.Config, req *FormRequest, w http.ResponseWriter,
|
|||
http.Error(w, "marshal yaml: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := zddc.WriteAtomic(req.DataPath, yamlBytes); err != nil {
|
||||
http.Error(w, "write: "+err.Error(), http.StatusInternalServerError)
|
||||
// Route through WriteWithHistory: for record paths (matching a
|
||||
// records: rule) this stamps audit fields, captures prior bytes into
|
||||
// .history/, re-derives folder_fields, and enforces that the body
|
||||
// still composes to the existing filename — a tracking-number
|
||||
// component can't be edited in place (that's a delete + create). For
|
||||
// non-record submissions WriteWithHistory falls through to a plain
|
||||
// atomic write of the body.
|
||||
_, verrs, herr := WriteWithHistory(cfg, req.DataPath, req.SubmitURL, yamlBytes, email)
|
||||
if herr != nil {
|
||||
http.Error(w, "write: "+herr.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if len(verrs) > 0 {
|
||||
writeValidationErrors(w, verrs)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// fields actually present in the body are checked; absent
|
||||
// fields are someone else's concern — typically the form
|
||||
|
|
@ -388,6 +397,75 @@ func composeFilename(format string, body map[string]any) (string, error) {
|
|||
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
|
||||
}
|
||||
|
||||
// recordCreatePrep applies a record rule's field_defaults +
|
||||
// folder_fields and, for row-based rules, the auto-assigned row
|
||||
// sequence to dataMap, then composes the row's filename. dir is the
|
||||
// slot directory the new row will land in. The returned fname carries
|
||||
// the .yaml extension.
|
||||
//
|
||||
// It centralizes the dataMap mutation + name composition shared by the
|
||||
// in-dir form create (serveFormCreate) and the project rollup
|
||||
// (serveFormCreateRollup). Callers still own collision-checking and the
|
||||
// WriteWithHistory call (which re-applies these same steps as the
|
||||
// authority and enforces the composed name matches the path).
|
||||
//
|
||||
// composeErr is a 422-worthy validation error (body can't compose a
|
||||
// filename); err is a 500-worthy internal error (folder-field
|
||||
// misconfig, row-assign failure). Only call when rule.FilenameFormat
|
||||
// is non-empty.
|
||||
func recordCreatePrep(cfg config.Config, dir string, rule zddc.RecordRule, dataMap map[string]any) (fname string, composeErr *jsonschema.Error, err error) {
|
||||
for k, want := range rule.FieldDefaults {
|
||||
if _, present := dataMap[k]; !present {
|
||||
dataMap[k] = want
|
||||
}
|
||||
}
|
||||
if ferr := applyFolderFields(rule, dir, cfg.Root, dataMap); ferr != nil {
|
||||
return "", nil, ferr
|
||||
}
|
||||
if rule.RowField != "" {
|
||||
rowVal, rerr := AssignNextRow(dir, rule.RowField, rule.RowScopeFields, dataMap)
|
||||
if rerr != nil {
|
||||
return "", nil, rerr
|
||||
}
|
||||
dataMap[rule.RowField] = rowVal
|
||||
}
|
||||
composed, cerr := composeFilename(rule.FilenameFormat, dataMap)
|
||||
if cerr != nil {
|
||||
return "", &jsonschema.Error{Path: "/", Message: cerr.Error()}, nil
|
||||
}
|
||||
return composed + ".yaml", nil, nil
|
||||
}
|
||||
|
||||
// AssignNextRow finds the next free row sequence within the
|
||||
// row-scope group identified by scopeFields. Used by POST-create
|
||||
// handlers (rsk row creation) before invoking WriteWithHistory.
|
||||
|
|
@ -650,6 +728,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ func TestRecordPut_CreateStampsAuditFields(t *testing.T) {
|
|||
// Build a body with the right components for the embedded
|
||||
// mdl rule's filename_format.
|
||||
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)
|
||||
if rec.Code != http.StatusCreated {
|
||||
|
|
@ -93,7 +93,7 @@ func TestRecordPut_CreateStampsAuditFields(t *testing.T) {
|
|||
}
|
||||
|
||||
// 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)
|
||||
if err != nil {
|
||||
t.Fatalf("read disk: %v", err)
|
||||
|
|
@ -103,7 +103,7 @@ func TestRecordPut_CreateStampsAuditFields(t *testing.T) {
|
|||
}
|
||||
|
||||
// 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) {
|
||||
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.
|
||||
func TestRecordPut_UpdateIncrementsRevisionAndArchivesPrior(t *testing.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")
|
||||
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).
|
||||
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)
|
||||
if err != nil {
|
||||
t.Fatalf("read history dir: %v", err)
|
||||
|
|
@ -171,7 +171,7 @@ func TestRecordPut_UpdateIncrementsRevisionAndArchivesPrior(t *testing.T) {
|
|||
// write anything — no history entry, no overwrite.
|
||||
func TestRecordPut_ConflictPreservesHistory(t *testing.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")
|
||||
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())
|
||||
}
|
||||
|
||||
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) {
|
||||
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.
|
||||
func TestRecordPut_ClientAuditFieldsStripped(t *testing.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" +
|
||||
"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) {
|
||||
_, do := historyTestSetup(t)
|
||||
// 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")
|
||||
rec := do(http.MethodPut, url, "alice@example.com", body, nil)
|
||||
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
|
||||
// client submitting type=SPC for an rsk row gets 422 with
|
||||
// path=/type.
|
||||
func TestRecordPut_LockedFieldRejected(t *testing.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.
|
||||
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)
|
||||
|
|
@ -311,38 +387,39 @@ func TestRollupCreate_AssignsRowAndComposesFilename(t *testing.T) {
|
|||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// First row: full table-tracking components + the routing party
|
||||
// field. Server should pick row=001.
|
||||
body1 := `{"party":"0330C1","originator":"ACM","project":"PRJ","discipline":"EL","sequence":"0001","title":"Schedule slip"}`
|
||||
// First row: table-tracking components + the routing party field.
|
||||
// originator is omitted — the server derives it from the party
|
||||
// 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)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("first rsk create status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
loc := rec.Result().Header.Get("Location")
|
||||
if !strings.Contains(loc, "ACM-PRJ-EL-RSK-0001-001.yaml") {
|
||||
t.Errorf("first row location=%q want ...-RSK-0001-001.yaml", loc)
|
||||
if !strings.Contains(loc, "0330C1-PRJ-EL-RSK-0001-001.yaml") {
|
||||
t.Errorf("first row location=%q want ...0330C1-PRJ-EL-RSK-0001-001.yaml", loc)
|
||||
}
|
||||
|
||||
// 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)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("second rsk create status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
loc = rec.Result().Header.Get("Location")
|
||||
if !strings.Contains(loc, "ACM-PRJ-EL-RSK-0001-002.yaml") {
|
||||
t.Errorf("second row location=%q want ...-RSK-0001-002.yaml", loc)
|
||||
if !strings.Contains(loc, "0330C1-PRJ-EL-RSK-0001-002.yaml") {
|
||||
t.Errorf("second row location=%q want ...0330C1-PRJ-EL-RSK-0001-002.yaml", loc)
|
||||
}
|
||||
|
||||
// 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)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("third rsk create status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
loc = rec.Result().Header.Get("Location")
|
||||
if !strings.Contains(loc, "ACM-PRJ-EL-RSK-0002-001.yaml") {
|
||||
t.Errorf("third row (new scope) location=%q want ...-RSK-0002-001.yaml", loc)
|
||||
if !strings.Contains(loc, "0330C1-PRJ-EL-RSK-0002-001.yaml") {
|
||||
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).
|
||||
|
|
@ -374,6 +451,81 @@ func TestRollupCreate_AssignsRowAndComposesFilename(t *testing.T) {
|
|||
// RecognizeFormRequest → ServeForm (the rollup/SSR create entry
|
||||
// point). Lets the history tests share the same harness without
|
||||
// pulling in the full ssrTestSetup helper.
|
||||
// TestInDirCreate_RecordComposesAndStampsAudit: "+ Add row" on a
|
||||
// per-party mdl table posts to the in-dir form.html create endpoint
|
||||
// (serveFormCreate). Convergence requires it to compose the
|
||||
// tracking-number filename, fold in the folder-bound originator, and
|
||||
// stamp audit fields — i.e. behave like the rollup / PUT, NOT drop a
|
||||
// date+email submission file.
|
||||
func TestInDirCreate_RecordComposesAndStampsAudit(t *testing.T) {
|
||||
cfg, _ := historyTestSetup(t)
|
||||
// originator is omitted on purpose — it's folder-bound to ACM.
|
||||
body := `{"project":"PRJ","discipline":"EL","type":"SPC","sequence":"0001","title":"Switchgear spec"}`
|
||||
rec := doForm(t, cfg, "POST", "/Project/archive/ACM/mdl/form.html", "alice@example.com", body)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
loc := rec.Result().Header.Get("Location")
|
||||
if !strings.Contains(loc, "ACM-PRJ-EL-SPC-0001.yaml") {
|
||||
t.Errorf("location=%q want composed ACM-PRJ-EL-SPC-0001.yaml (not a date+email name)", loc)
|
||||
}
|
||||
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 (folder-bound)", out["originator"])
|
||||
}
|
||||
if out["created_by"] != "alice@example.com" || out["revision"] != 1 {
|
||||
t.Errorf("audit not stamped on in-dir create: %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInDirUpdate_RecordStampsAuditAndRejectsRename: the in-dir
|
||||
// form.html update endpoint (serveFormUpdate) must route records
|
||||
// through WriteWithHistory — incrementing revision and refusing an
|
||||
// in-place tracking-number change (identity is the filename).
|
||||
func TestInDirUpdate_RecordStampsAuditAndRejectsRename(t *testing.T) {
|
||||
cfg, do := historyTestSetup(t)
|
||||
url := "/Project/archive/ACM/mdl/ACM-PRJ-EL-SPC-0001.yaml"
|
||||
seed := []byte("originator: ACM\nproject: PRJ\ndiscipline: EL\ntype: SPC\nsequence: '0001'\ntitle: V1\n")
|
||||
if rec := do(http.MethodPut, url, "alice@example.com", seed, nil); rec.Code != http.StatusCreated {
|
||||
t.Fatalf("seed status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Same components, new title → revision bumps to 2 (proves the form
|
||||
// update went through WriteWithHistory, not a plain WriteAtomic).
|
||||
upd := `{"originator":"ACM","project":"PRJ","discipline":"EL","type":"SPC","sequence":"0001","title":"V2"}`
|
||||
rec := doForm(t, cfg, "POST", "/Project/archive/ACM/mdl/ACM-PRJ-EL-SPC-0001.yaml.html", "bob@example.com", upd)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("update status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
disk, _ := os.ReadFile(filepath.Join(cfg.Root, "Project", "archive", "ACM", "mdl", "ACM-PRJ-EL-SPC-0001.yaml"))
|
||||
out := map[string]any{}
|
||||
if err := yaml.Unmarshal(disk, &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out["revision"] != 2 {
|
||||
t.Errorf("revision=%v want 2 (form update must route through WriteWithHistory)", out["revision"])
|
||||
}
|
||||
if out["updated_by"] != "bob@example.com" {
|
||||
t.Errorf("updated_by=%v want bob", out["updated_by"])
|
||||
}
|
||||
|
||||
// Editing a tracking-number component in place → 422 (composed name
|
||||
// would differ from the file's name).
|
||||
rename := `{"originator":"ACM","project":"PRJ","discipline":"EL","type":"SPC","sequence":"0099","title":"V3"}`
|
||||
rec = doForm(t, cfg, "POST", "/Project/archive/ACM/mdl/ACM-PRJ-EL-SPC-0001.yaml.html", "bob@example.com", rename)
|
||||
if rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("expected 422 for in-place component edit, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func doForm(t *testing.T, cfg config.Config, method, target, email, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, target, bytes.NewReader([]byte(body)))
|
||||
|
|
|
|||
|
|
@ -306,28 +306,21 @@ func serveFormCreateRollup(cfg config.Config, req *FormRequest, w http.ResponseW
|
|||
|
||||
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()}})
|
||||
// Shared record-create prep: field_defaults + folder_fields
|
||||
// (originator = party folder, since slotAbs is <party>/<slot>)
|
||||
// + per-row sequence + filename composition. WriteWithHistory
|
||||
// below re-applies these as the authority.
|
||||
var composeErr *jsonschema.Error
|
||||
fname, composeErr, err = recordCreatePrep(cfg, slotAbs, rule, dataMap)
|
||||
if err != nil {
|
||||
auditFile(r, "rollup-create", req.SubmitURL, http.StatusInternalServerError, 0, err)
|
||||
http.Error(w, "record prep: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if composeErr != nil {
|
||||
writeValidationErrors(w, []jsonschema.Error{*composeErr})
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -265,6 +265,36 @@ paths:
|
|||
field_defaults:
|
||||
kind: SSR
|
||||
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:
|
||||
mdl:
|
||||
default_tool: tables
|
||||
|
|
@ -275,14 +305,36 @@ paths:
|
|||
virtual: true
|
||||
# MDL records: each .yaml file is an independent
|
||||
# deliverable with its own composed tracking number.
|
||||
# No locks — the row's body fields drive the
|
||||
# filename, type is free-choice from the deployment's
|
||||
# field_codes. Operators define field_codes at the
|
||||
# project root (or higher) to supply the originator /
|
||||
# discipline / type / sequence vocabularies.
|
||||
# No type lock — the row's body fields drive the
|
||||
# filename; type is free-choice from the deployment's
|
||||
# field_codes (see the field_codes block above).
|
||||
#
|
||||
# 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:
|
||||
"*.yaml":
|
||||
filename_format: "{originator}-{phase?}-{project}-{area?}-{discipline}-{type}-{sequence}{suffix?}"
|
||||
folder_fields:
|
||||
originator: 1
|
||||
filename_format: "{originator}-{project}-{discipline}-{type}-{sequence}-{suffix?}"
|
||||
rsk:
|
||||
default_tool: tables
|
||||
available_tools: [tables]
|
||||
|
|
@ -291,19 +343,28 @@ paths:
|
|||
# operator override is on disk.
|
||||
virtual: true
|
||||
# RSK records: each .yaml file is a row of a parent
|
||||
# rsk-type deliverable. The table itself has a
|
||||
# tracking number (same shape as an MDL deliverable
|
||||
# with type=RSK); rows append a -{row} suffix. The
|
||||
# server auto-assigns row within the row-scope group
|
||||
# on POST-create.
|
||||
# rsk-type deliverable. The table itself has a tracking
|
||||
# number (same default components as an MDL deliverable
|
||||
# with type=RSK); rows append a -{row} suffix the server
|
||||
# auto-assigns within the row-scope group 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:
|
||||
"*.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:
|
||||
type: RSK
|
||||
locked: [type]
|
||||
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/ is the COUNTERPARTY's drop zone. The flow:
|
||||
# 1. the other party's document controller uploads
|
||||
|
|
|
|||
|
|
@ -4,10 +4,31 @@ import (
|
|||
"fmt"
|
||||
"path"
|
||||
"regexp"
|
||||
"sync"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// compiledPatternCache memoizes anchored field-code regexes keyed by
|
||||
// the raw pattern string. Patterns are validated (compiled) at
|
||||
// .zddc-unmarshal time and re-used on every Validate call, so the
|
||||
// cache is effectively populated once per distinct pattern.
|
||||
var compiledPatternCache sync.Map // string -> *regexp.Regexp
|
||||
|
||||
// compileFieldPattern returns the anchored regexp for a field-code
|
||||
// pattern, compiling+caching on first use.
|
||||
func compileFieldPattern(pattern string) (*regexp.Regexp, error) {
|
||||
if v, ok := compiledPatternCache.Load(pattern); ok {
|
||||
return v.(*regexp.Regexp), nil
|
||||
}
|
||||
re, err := regexp.Compile("^(?:" + pattern + ")$")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
compiledPatternCache.Store(pattern, re)
|
||||
return re, nil
|
||||
}
|
||||
|
||||
// FieldCodeKind discriminates the validation behaviour of a field code.
|
||||
type FieldCodeKind string
|
||||
|
||||
|
|
@ -75,7 +96,7 @@ func (fc *FieldCode) UnmarshalYAML(node *yaml.Node) error {
|
|||
if len(r.Codes) > 0 {
|
||||
return fmt.Errorf("field_code kind=pattern must not declare codes:")
|
||||
}
|
||||
if _, err := regexp.Compile("^(?:" + r.Pattern + ")$"); err != nil {
|
||||
if _, err := compileFieldPattern(r.Pattern); err != nil {
|
||||
return fmt.Errorf("field_code kind=pattern: invalid regex: %w", err)
|
||||
}
|
||||
case FieldCodeFree:
|
||||
|
|
@ -102,11 +123,10 @@ func (fc FieldCode) Validate(value string) error {
|
|||
return fmt.Errorf("value %q is not in the allowed code set", value)
|
||||
}
|
||||
case FieldCodePattern:
|
||||
// Anchor at unmarshal-time would require holding a *Regexp on
|
||||
// the struct; for simplicity we recompile here. Hot paths can
|
||||
// cache via a sync.Map keyed by the pattern string if this
|
||||
// shows up in profiles.
|
||||
re, err := regexp.Compile("^(?:" + fc.Pattern + ")$")
|
||||
// Compiled once and cached by pattern string (see
|
||||
// compiledPatternCache); the unmarshal-time compile already
|
||||
// proved the pattern is valid, so an error here is internal.
|
||||
re, err := compileFieldPattern(fc.Pattern)
|
||||
if err != nil {
|
||||
return fmt.Errorf("internal: pattern recompile: %w", err)
|
||||
}
|
||||
|
|
@ -153,12 +173,52 @@ func (fc FieldCode) Validate(value string) error {
|
|||
// RowScopeFields names the fields that, together, identify the
|
||||
// parent deliverable that a row belongs to. Two records with the
|
||||
// same scope-field values share a row-numbering sequence.
|
||||
//
|
||||
// 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 {
|
||||
FilenameFormat string `yaml:"filename_format,omitempty" json:"filename_format,omitempty"`
|
||||
FieldDefaults map[string]string `yaml:"field_defaults,omitempty" json:"field_defaults,omitempty"`
|
||||
Locked []string `yaml:"locked,omitempty" json:"locked,omitempty"`
|
||||
RowField string `yaml:"row_field,omitempty" json:"row_field,omitempty"`
|
||||
RowScopeFields []string `yaml:"row_scope_fields,omitempty" json:"row_scope_fields,omitempty"`
|
||||
FolderFields map[string]int `yaml:"folder_fields,omitempty" json:"folder_fields,omitempty"`
|
||||
}
|
||||
|
||||
// UnmarshalYAML validates a RecordRule when its .zddc is parsed, so a
|
||||
// misconfiguration fails at load time rather than as a 500 on the
|
||||
// first record write. The `type raw` alias avoids re-invoking this
|
||||
// method (mirrors FieldCode.UnmarshalYAML).
|
||||
//
|
||||
// folder_fields distances count parent directories above the record's
|
||||
// own directory (0 = that directory, N = N levels up), so a negative
|
||||
// value is meaningless — reject it with a message that names the field.
|
||||
func (rr *RecordRule) UnmarshalYAML(node *yaml.Node) error {
|
||||
type raw RecordRule
|
||||
var r raw
|
||||
if err := node.Decode(&r); err != nil {
|
||||
return err
|
||||
}
|
||||
for field, dist := range r.FolderFields {
|
||||
if dist < 0 {
|
||||
return fmt.Errorf("folder_fields[%q]: distance must be >= 0 (parents above the record dir), got %d", field, dist)
|
||||
}
|
||||
}
|
||||
*rr = RecordRule(r)
|
||||
return nil
|
||||
}
|
||||
|
||||
// mergeRecordRule composes two RecordRules, top taking precedence on
|
||||
|
|
@ -180,6 +240,23 @@ func mergeRecordRule(base, top RecordRule) RecordRule {
|
|||
// the order); top entirely replaces base when set.
|
||||
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
|
||||
}
|
||||
|
||||
|
|
|
|||
67
zddc/internal/zddc/field_codes_test.go
Normal file
67
zddc/internal/zddc/field_codes_test.go
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
package zddc
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// TestRecordRule_RejectsNegativeFolderDistance — a folder_fields entry
|
||||
// with a negative parent-distance must fail at .zddc parse time (via
|
||||
// RecordRule.UnmarshalYAML), not surface as a 500 on the first record
|
||||
// write.
|
||||
func TestRecordRule_RejectsNegativeFolderDistance(t *testing.T) {
|
||||
src := "folder_fields:\n originator: -1\nfilename_format: \"{originator}-{sequence}\"\n"
|
||||
var rr RecordRule
|
||||
err := yaml.Unmarshal([]byte(src), &rr)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for negative folder_fields distance, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "folder_fields") || !strings.Contains(err.Error(), "originator") {
|
||||
t.Errorf("error should name folder_fields + the field: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecordRule_AcceptsValidFolderFields — a non-negative distance
|
||||
// unmarshals and round-trips.
|
||||
func TestRecordRule_AcceptsValidFolderFields(t *testing.T) {
|
||||
src := "folder_fields:\n originator: 1\nfilename_format: \"{originator}-{sequence}\"\n"
|
||||
var rr RecordRule
|
||||
if err := yaml.Unmarshal([]byte(src), &rr); err != nil {
|
||||
t.Fatalf("valid folder_fields should unmarshal: %v", err)
|
||||
}
|
||||
if rr.FolderFields["originator"] != 1 {
|
||||
t.Errorf("folder_fields[originator]=%d want 1", rr.FolderFields["originator"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecordRule_NegativeDistanceInRecordsMap — the same rejection
|
||||
// applies when the rule is nested in a records: map (the real cascade
|
||||
// shape: map[string]RecordRule decodes each value via UnmarshalYAML).
|
||||
func TestRecordRule_NegativeDistanceInRecordsMap(t *testing.T) {
|
||||
src := "\"*.yaml\":\n folder_fields:\n originator: -2\n"
|
||||
var rules map[string]RecordRule
|
||||
if err := yaml.Unmarshal([]byte(src), &rules); err == nil {
|
||||
t.Fatal("expected error for negative distance in records map, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFieldCode_PatternValidate — pattern field codes match values
|
||||
// against the anchored regex (exercises the compiled-pattern cache).
|
||||
func TestFieldCode_PatternValidate(t *testing.T) {
|
||||
src := "kind: pattern\npattern: \"[0-9]{4}\"\n"
|
||||
var fc FieldCode
|
||||
if err := yaml.Unmarshal([]byte(src), &fc); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if err := fc.Validate("0042"); err != nil {
|
||||
t.Errorf("0042 should match [0-9]{4}: %v", err)
|
||||
}
|
||||
if err := fc.Validate("42"); err == nil {
|
||||
t.Error("42 should NOT match [0-9]{4} (anchored, 4 digits)")
|
||||
}
|
||||
if err := fc.Validate("0042x"); err == nil {
|
||||
t.Error("0042x should NOT match (anchored)")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue