Skip to content

Commit c6c8f77

Browse files
test(spec): wire the top-level zod-only direction of the metadata-form reconciliation gate (#19333, item 2) (#20520)
Fixes #19333 Clause-②: no ## What this does Item 2 of #19333, its last remaining item (landing record 5860224378): the top-level `zodOnly` direction of the metadata-form reconciliation gate, `packages/spec/src/system/metadata-form-zod-reconciliation.test.ts`, is now wired. Before this PR, the per-type top level asserted only form-only and retired. So "the schema declares this key and no form row offers it" had no reader at the root, while the nested lists already had one. Now every object-rooted type reconciles its root the same way: - **`reconcileRoot`** is the nested predicate's `zodOnly` at `ROOT_PATH`, built from the same `resolveCoordinate` / `offerableKeysAt` / `omittedAt` / `isSubset` helpers the resolve test uses. The ADR-0010 overlay and `retiredKey()` tombstones need no row. - **A new `it.each(TOP_LEVEL_TYPES)`** fails a type by name when a key the author may write at the top level is neither offered by the form nor excused by a root ledger row. Failure text: `TYPE.(root): accepted by the Zod but unauthorable in the form — offer it, or add a root ledger entry that records why it is not offered`, with the offending keys in the diff. - **`view` is deferred by name, with its reason, in `TOP_LEVEL_DEFERRED`.** Its root is a union, and it is reconciled per arm once an arm form exists (the #19330 ruling, letter A). A pin holds the deferred set equal to the union-rooted registered types, so the map cannot excuse an object-rooted type, and a new union-rooted type cannot slip into the direction unexcused. The direction judges 16 of 17 types. - **Synthetic positive and negative controls** drive the same `reconcileRoot` over the file's existing root-coordinate fixture: - An unoffered, unexcused key is named. - A root `omit` or root `subset` row excuses it. - A row at a nested path, or for another type, excuses nothing. - **Comments made true again.** The two "the top-level zod-only direction stays unwired" passages are rewritten. The present-tense "132 of the 274" readings now read as the historical census they are. That was the carrier note left for whoever wired this item. **The reason ledger is unchanged:** 37 rows, 26 at the root. No schema, form, `describe()`, liveness row or generated artefact changes. One file, +114 / −14. ## Verification record ### 1. The residue, re-derived first on `main` `4a1df19656`, with the gate's own helper block - **Instrument.** The gate file's bytes 0 up to the first line-start `describe(` (0..48202, sha256 `06ccb54e052ad2b2…`), copied verbatim into a throwaway probe beside it. The prefix was checked byte-identical, and the probe was deleted after the run. - Identity: the same slicer at `736c63a85` reproduces sha256 `7b97432d8408f12e…`, the instrument recorded in 5825062779. - Census per type: `resolveCoordinate(form, root, ROOT_PATH)`, `authorableKeysOf`, `offerableKeysAt(…, ROOT_PATH)`, `omittedAt(LEDGER, type, ROOT_PATH)` and `isSubset`. - Run under `os-verify-lock`: VERDICT command-exit 0, 2 files / 58 tests. - **Controls, asserted inside the probe:** - LIT: `name` is offered by 17 of 17 forms and declared by 17 of 17 schemas. - DARK: a fabricated key is offered by 0, declared by 0, and is in the residue 0 times. - Residue LIT: dropping the one `field.format` row from a ledger copy surfaces `format`. - Residue DARK: with the ledger as it stands, `format` stays out. - **Reading.** | top-level keys no form offers | overlay | excused by a root row | residue | of which object-rooted | |---|---|---|---|---| | 202 | 132 | 26 | 44 | **0** | All 44 residue keys are `view`'s, which is union-rooted and outside the direction (ruling A). ⇒ the claim's branch "it reads 0" holds, and the direction was wired. - **Against the previous round** (5859927065 at `096a8dbab`: 230 / 132 / 83, object-rooted 39): the 39 object-rooted keys were resolved by #19332's flights. 11 got root rows (root rows 15 → 26); the other 28 left the not-offered set through form rows or the `action.aria` retirement (230 − 28 = 202). `view` stayed at 44. - **`app._unpublished`** is not in `FRAMEWORK_FIELDS`, and today's ledger answers it with its own platform-written root row. The census counts it as excused, not as residue, so the wiring does not fail on it. - **Re-read after merging `main` (`9449512a31`):** the gate's new direction, green at the merged head, IS the same census, at 0 object-rooted residue. `main` has since moved to `9e9bb46417`, touching no form, registered root schema or registry path. ### 2. Ablation, from the committed state (`47ecd08a9f`), one lock hold (VERDICT command-exit 0) Every mutation went through `scripts/ablation-replace.mjs` in WRAP mode: anchor hit x1 → x0, blob changed, on-disk `grep -c` of the planted and removed text printed inside the wrapped child. | leg | mutation | on disk | gate | |---|---|---|---| | L1 lit, wired | plant `zzPlanted19333` in `PositionSchema`, no reason | planted=1, direction=1 | **RED** 1 failed / 75: `position.(root): accepted by the Zod but unauthorable in the form …` expected `[ 'zzPlanted19333' ]` | | L2 lit, direction removed | same plant, and the new `it.each(TOP_LEVEL_TYPES)` block deleted | planted=1, direction=0 | **GREEN** 60 / 60: the gate misses the planted key | | L3 dark, explained | same plant, plus a root `omit` row recording its reason | planted=1, row=1 | **GREEN** 76 / 76 | | L4 lit, reason removed | the `field.format` root row deleted | formatRow=0 | **RED** 1 failed / 75: `field.(root): …` expected `[ 'format' ]` | - **Restore.** The gate's blob `1aa1b108e284` and `position.zod.ts`'s blob `989e07cae48e` each equal HEAD, `git diff HEAD` is empty, and `git status --porcelain` shows 0 lines. The tool's own proof after each leg and a final script-level hash check agree. - **No build in the loop:** the gate imports `src` by relative path. - For contrast: in 5825062779 (ablation 2), deleting a root row left this gate green. That was the measured meaning of "unwired" then. ### 3. Tests, typecheck, lint, gates - **Gate at `47ecd08a9f`:** 1 file / 76 tests, VERDICT command-exit 0. That is 57 before, plus 16 per-type root cases, 1 deferral pin and 2 synthetic controls. - **Whole-closure build** after the merge: `turbo run build --concurrency=2 --filter='./packages/*' --filter='./packages/*/*'`, 71 of 71 successful (VERDICT command-exit 0). The tree was clean afterwards. - **At `eeb01c7143`** (the merge; the diff vs `main` is this one file): - `pnpm --filter @objectstack/spec exec vitest run --project local --maxWorkers=2`: exit 0, 573 files, 16820 passed + 1 todo. - `pnpm --filter @objectstack/spec typecheck`: exit 0 (`tsc --noEmit`, `check:scripts-typecheck`, and `check:test-typecheck` holding 53 files / 251 errors / 138 pinned signatures). - Coverage counted, not assumed: `tsc --noEmit -p tsconfig.test.json --listFiles` lists this file (1 hit; control `src/identity/position.zod.ts` 1 hit) with 0 errors in it. The program's 251 errors equal the pinned count, and its exit 2 is that debt. - **Lint, narrowed with measurement:** `eslint --no-inline-config --format json` on the one file gives 1 file, 0 errors, 0 warnings. - Population: `eslint --print-config` resolves a config for it, so it is linted, not ignored. - Invariance: `parserOptions` holds only `ecmaVersion` / `sourceType` (no `project`, no `projectService`), with 4 rules, none type-aware. So this edit cannot move any other file's verdict. - **Gates:** `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` at `eeb01c7143` derived 78 commands. Each was run with its exit code captured before any pipe, and all 78 exit 0. `--ran`: `78 derived famil(ies) accounted for — 78 run, 0 NOT-MEASURED (a DERIVED zero …)`. - **Changeset: none, the change is test-only.** `npm pack --dry-run --ignore-scripts` of `@objectstack/spec` lists 2028 files with 0 `*.test.ts`. The changed path is absent; the positive control `src/identity/position.zod.ts` is present. The new symbols (`reconcileRoot`, `TOP_LEVEL_DEFERRED`) hit 0 files in `dist/`, against the control `MetadataProtectionFields` in `dist/identity/index.js`. ⇒ `skip-changeset`. ## Acceptance notes - **An in-flight PR that adds a top-level key to one of the 16 object-rooted schemas without a form row now goes red in the queue.** That is the design: the fix is an offer, or a root ledger row with its reason. - **What "explained" is worth depends on the rows.** The three ruled root reasons are closed by admission tests (ruling record 5861442317). The five read reasons admit any root row whose `why` is over 20 characters, which is the same discipline the nested ledger has always had. The wiring does not change that. It is noted here because "explained" at the root is now exactly as strong as that discipline. - **`view`'s 44 residue keys stay recorded, not asserted, until the first arm form is registered (ruling A).** Among them is `_isOverride`, the console-stamped wire discriminant: underscore-prefixed, but not in the ADR-0010 envelope. Whoever registers an arm form meets it. - **The ledger's `view` block** still says `owner` / `hidden` are no longer writable at all. The earlier round routed that to PR #20286; it is untouched here. - **#19188 is the card that named this defect.** It remains open for the seat's own disposition; its `Blocked-by` lines name this card and #19332. --- _Generated by [Claude Code](https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent d1c01ff commit c6c8f77

1 file changed

Lines changed: 114 additions & 14 deletions

File tree

‎packages/spec/src/system/metadata-form-zod-reconciliation.test.ts‎

Lines changed: 114 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,7 @@
7575
*
7676
* ## The coordinates include the root, and the overlay is not surface
7777
*
78-
* Two instruments the top-level direction (#19188) needs, neither of them
79-
* wired to an assertion here:
78+
* Two instruments the top-level direction (#19188) needs:
8079
*
8180
* - **The ledger had no top-level coordinate.** Every `path` was a
8281
* `nestedLists` path, so a deliberate omission at the *top* level could not
@@ -86,17 +85,28 @@
8685
* `['']` and not `[]`. `ROOT_PATH` is that missing coordinate and
8786
* `resolveCoordinate` is the single place that knows both spellings.
8887
* - **The ADR-0010 provenance/lock overlay is not authoring surface.** 132 of
89-
* the 274 top-level keys no form offers are that overlay — 119 of them the
90-
* seven `_`-prefixed envelope keys on all 17 forms, plus `protection` on 13
91-
* — so a top-level zod-only direction without a skip is half overlay noise,
92-
* and 132 ledger rows for one overlay with one reason is the wrong shape.
88+
* the 274 top-level keys no form offered when the skip was written were that
89+
* overlay — 119 of them the seven `_`-prefixed envelope keys on all 17 forms,
90+
* plus `protection` on 13 — so a top-level zod-only direction without a skip
91+
* would have been half overlay noise, and 132 ledger rows for one overlay
92+
* with one reason is the wrong shape.
9393
* `FRAMEWORK_FIELDS` skips it, mirroring the liveness gate, which grades the
9494
* same set auto-live (`FRAMEWORK_FIELDS` in `scripts/liveness/`).
9595
*
96-
* Neither changes what this gate asserts: the top-level zod-only direction
97-
* stays unwired, and the skip is kept off every nested coordinate — where a
98-
* leg is asserting today, over a sub-schema that really does carry the
99-
* overlay.
96+
* The skip is kept off every nested coordinate, where a leg asserts over a
97+
* sub-schema that really does carry the overlay.
98+
*
99+
* ## The top-level zod-only direction is wired (#19188, #19333)
100+
*
101+
* The per-type top level used to assert only form-only and retired, so "the
102+
* schema declares this key and no form row offers it" had no reader there.
103+
* Now it does: on every object-rooted type, a key the author may write at the
104+
* top level is either offered by the form or excused by a root ledger row that
105+
* carries its reason, and any other key fails the gate by name. The overlay
106+
* and tombstones need no row, for the reasons above. `view` is the one type
107+
* outside the direction, recorded by name with its reason in
108+
* `TOP_LEVEL_DEFERRED`: its root is a union, and it is reconciled per arm once
109+
* an arm form exists (the #19330 ruling, letter A).
100110
*
101111
* @see control-flow-form-zod-ledger.test.ts — same pattern for the flow designer
102112
*/
@@ -144,7 +154,8 @@ const ROOT_PATH = '(root)';
144154
* the loader, never authored in a form. The liveness gate grades exactly this
145155
* set auto-live (`FRAMEWORK_FIELDS`, `scripts/liveness/check-liveness.mts`);
146156
* this is the reconciliation gate's equivalent, and it exists because the
147-
* overlay is 132 of the 274 top-level keys the forms do not offer.
157+
* overlay was 132 of the 274 top-level keys the forms did not offer when it
158+
* was written.
148159
*
149160
* **Derived, not hand-copied.** The seven `_`-prefixed keys ARE
150161
* `MetadataProtectionFields` — the one raw shape every metadata schema spreads
@@ -849,6 +860,38 @@ function reconcileNestedLists(type: string, form: any, root: unknown, ledger: Le
849860
});
850861
}
851862

863+
/**
864+
* The top-level zod-only predicate: the keys an author may write at the root
865+
* that the form does not offer and no root ledger row excuses. It is the
866+
* nested predicate's `zodOnly` at {@link ROOT_PATH}, resolved through the same
867+
* `resolveCoordinate` / `offerableKeysAt` pair the resolve test uses, so a
868+
* tombstone and the ADR-0010 overlay are left out without a row. `null` when
869+
* the root is not key-bearing.
870+
*/
871+
function reconcileRoot(type: string, form: any, root: unknown, ledger: Ledger): string[] | null {
872+
const at = resolveCoordinate(form, root, ROOT_PATH)!;
873+
const offerable = offerableKeysAt(at.sub, ROOT_PATH);
874+
if (!offerable) return null;
875+
if (isSubset(ledger, type, ROOT_PATH)) return [];
876+
const excused = omittedAt(ledger, type, ROOT_PATH);
877+
return offerable.filter((k) => !at.offered.includes(k) && !excused.includes(k));
878+
}
879+
880+
/**
881+
* The registered types outside the top-level zod-only direction, each with its
882+
* reason. A union root answers `keysOf` with the union of its arms' keys: the
883+
* safe side for form-only, and the unsafe side here, because one form would be
884+
* asked to offer mutually exclusive arms. The test below holds this map equal
885+
* to the union-rooted registered types, so it cannot excuse an object-rooted
886+
* type, and a union-rooted one cannot slip into the direction unexcused.
887+
*/
888+
const TOP_LEVEL_DEFERRED: Readonly<Record<string, string>> = {
889+
view: "union-rooted: its four arms declare mutually exclusive keys, so the one registered view form cannot offer them all. It is reconciled per arm, each arm against its own registered form (the #19330 ruling, letter A); until the first arm form exists, the top-level direction covers the object-rooted types only",
890+
};
891+
892+
/** The types the top-level zod-only direction judges. */
893+
const TOP_LEVEL_TYPES = TYPES.filter((type) => !(type in TOP_LEVEL_DEFERRED));
894+
852895
describe('metadata form ↔ Zod reconciliation (#3786)', () => {
853896
it('the registry is non-empty and every form resolves a schema', () => {
854897
// Without this the per-type assertions below would pass over an empty set —
@@ -881,6 +924,32 @@ describe('metadata form ↔ Zod reconciliation (#3786)', () => {
881924
).toEqual([]);
882925
});
883926

927+
it.each(TOP_LEVEL_TYPES)('%s: every top-level key the author may write is offered, or its omission is recorded', (type) => {
928+
// The cell that had no reader: a key the schema declares that no form row
929+
// offers is unauthorable in the Studio, and every gate stayed green over it.
930+
const zodOnly = reconcileRoot(type, METADATA_FORM_REGISTRY[type], getMetadataTypeSchema(type), LEDGER);
931+
expect(zodOnly, `${type}: root schema is not key-bearing`).not.toBeNull();
932+
expect(
933+
zodOnly,
934+
`${type}.${ROOT_PATH}: accepted by the Zod but unauthorable in the form — offer it, or add a root ledger entry that records why it is not offered`,
935+
).toEqual([]);
936+
});
937+
938+
it('the types outside the top-level direction are exactly the union-rooted ones, each with its reason', () => {
939+
const unionRooted = TYPES.filter((type) => {
940+
const u = unwrap(getMetadataTypeSchema(type));
941+
const kind = (u?.def ?? u?._def)?.type;
942+
return kind === 'union' || kind === 'discriminated_union';
943+
});
944+
expect(Object.keys(TOP_LEVEL_DEFERRED).sort(), 'the deferred types are not the union-rooted types').toEqual(unionRooted.sort());
945+
for (const [type, why] of Object.entries(TOP_LEVEL_DEFERRED)) {
946+
expect(why.length, `${type} needs a reason a reader can act on`).toBeGreaterThan(20);
947+
}
948+
// Not vacuous: the direction judges every registered type but the deferred ones.
949+
expect(TOP_LEVEL_TYPES.length).toBeGreaterThan(10);
950+
expect(TOP_LEVEL_TYPES.length).toBe(TYPES.length - unionRooted.length);
951+
});
952+
884953
it.each(TYPES)('%s: every hand-written nested list matches its sub-schema', (type) => {
885954
const root = getMetadataTypeSchema(type);
886955

@@ -1170,9 +1239,12 @@ describe('the nested walk reaches every depth (#14327)', () => {
11701239
// root entry can be recorded" and "the overlay is skipped at the root and
11711240
// nowhere else" are measured facts rather than assumptions.
11721241
//
1173-
// What is deliberately NOT here: an assertion that the top-level zod-only set
1174-
// is empty. It is not — 274 keys across the 17 forms, 132 of them this overlay
1175-
// — and wiring that direction is #19188's work, not this instrument's.
1242+
// The direction itself is asserted over the live registry in the first block
1243+
// of this file. Here the same `reconcileRoot` is driven over the synthetic
1244+
// pair, so the predicate is shown to name an unexcused key (positive control)
1245+
// and to stay quiet over an excused key, the overlay and a tombstone (negative
1246+
// control): a direction observed only green would otherwise be
1247+
// indistinguishable from one that matches nothing.
11761248
// ────────────────────────────────────────────────────────────────────────────
11771249

11781250
describe('the ledger has a root coordinate, and the overlay is not surface', () => {
@@ -1289,6 +1361,34 @@ describe('the ledger has a root coordinate, and the overlay is not surface', ()
12891361
expect(isFrameworkField('protection')).toBe(true);
12901362
expect(offerable).not.toContain('_lock');
12911363
});
1364+
1365+
it('positive control: the top-level direction names an unoffered, unexcused key', () => {
1366+
// `tags` is authorable and no section offers it. The overlay, `protection`
1367+
// and the tombstone `gone` are not reported, and neither is the offered
1368+
// composite `nested`.
1369+
expect(reconcileRoot('probe', form, schema, [])).toEqual(['tags']);
1370+
});
1371+
1372+
it('negative control: a root row excuses the key, and a row at any other coordinate does not', () => {
1373+
expect(
1374+
reconcileRoot('probe', form, schema, [
1375+
{ kind: 'omit', type: 'probe', path: ROOT_PATH, key: 'tags', why: 'synthetic: tags is deliberately not offered' },
1376+
]),
1377+
).toEqual([]);
1378+
expect(
1379+
reconcileRoot('probe', form, schema, [
1380+
{ kind: 'subset', type: 'probe', path: ROOT_PATH, why: 'synthetic: the probe form is a curated subset' },
1381+
]),
1382+
).toEqual([]);
1383+
// Keyed by coordinate: the same key at a nested path, or a root row for
1384+
// another type, excuses nothing at this root.
1385+
expect(
1386+
reconcileRoot('probe', form, schema, [
1387+
{ kind: 'omit', type: 'probe', path: 'nested', key: 'tags', why: 'synthetic: filed at the nested coordinate' },
1388+
{ kind: 'omit', type: 'other', path: ROOT_PATH, key: 'tags', why: 'synthetic: filed against another type' },
1389+
]),
1390+
).toEqual(['tags']);
1391+
});
12921392
});
12931393

12941394
// ────────────────────────────────────────────────────────────────────────────

0 commit comments

Comments
 (0)