Skip to content

test(spec): wire the top-level zod-only direction of the metadata-form reconciliation gate (#19333, item 2) - #20520

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-19333-top-level-zodonly-wiring
Sep 28, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-19333-top-level-zodonly-wiring

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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 [Decision] is view reconciled per metadata type or per union arm? 36 keys of the #19188 census cannot be filed until this is settled #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 #19188 split: 39 top-level zod-only keys are structured controls needing a designed widget, not a row #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


Generated by Claude Code

…m reconciliation gate

The per-type top level asserted only form-only and retired, so a key the
schema declares and no form row offers had no reader there. Every
object-rooted type now reconciles its root the way nested lists already did:
an authorable key is offered, or a root ledger row records why it is not,
and any other key fails the gate by name. The ADR-0010 overlay and
tombstones need no row. `view` stays outside the direction by name, with its
reason, until its per-arm forms exist, and a pin holds that deferral equal to
the union-rooted registered types.

Re-derived first with the gate's own helper block: the object-rooted residue
is 0 on this base, so the ledger is unchanged.

Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs.

What this run could not see

Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 0bbe4005e82fce2058238720cac7ba5cd2f182d5 → packageMentionDocs.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: eeb01c7143cf935d24eb1c772febc7d182e37601
Local-runs: none

Inputs, and nothing else: card #19333 (body and every comment through the dev report 5879524947, including the unlock 5877593337, the claim 5878139536 and the earlier landing records 5825783183 / 5860224378); #19188 and #19330 (ruling A, 5754204415); PR #20520 body, comments and file list; the net diff from the merge base 9449512a31 (one file, +114 / -14, byte-equal to the feature commit 47ecd08a9f's own diff, so the merge commit brought nothing into the file); the check-runs on the head, read once. Read-only: git object reads on the shared checkout and the GitHub API; nothing built, run or re-run.

① Derived judgments

  1. Accept-set change: none. One test file. No .zod.ts, .form.ts, registry, type-schema map, describe(), liveness row or generated artefact moves. The reconciliation LEDGER block sits outside every hunk; counted row-aware on the head file it is 37 rows, 26 at ROOT_PATH, all omit and no root subset (object 9, field 5, view 5, action 2, app 2, page 2, agent 1) — the PR's "37 rows, 26 at the root" reproduces, and no row is added, deleted or moved. Right.
  2. Public surface change: none. @objectstack/spec files[] ships dist, json-schema, liveness, prompts, llms.txt, README.md, src/**/*.zod.ts, CHANGELOG.md, api-surface, spec-changes.json; src/system/metadata-form-zod-reconciliation.test.ts matches none of them. Right.
  3. reconcileRoot is a real reader built from the gate's own helpers, term for term. It resolves the root through resolveCoordinate(form, root, ROOT_PATH) (offered = topLevelFields(form), sub = the type's root schema — the same pair the existing form-only test and the resolve test read), takes offerableKeysAt(sub, ROOT_PATH) (= authorableKeysOf, so tombstones drop by node shape, minus FRAMEWORK_FIELDS at the root only), and consults isSubset / omittedAt over the same ledgerFor. Set beside reconcileNestedLists's zodOnly — keysOf(sub) minus offered minus excused minus retired, [] under a subset row — the two predicates are identical except for the ADR-0010 overlay skip at the root, which is PR test(spec): give the metadata-form reconciliation ledger a root coordinate and an ADR-0010 overlay skip #19639's design and is pinned root-only by the fixture's existing dark control. Right.
  4. A new, unexplained top-level key fails by name. it.each(TOP_LEVEL_TYPES) titles each case by type, the message names TYPE.(root), and toEqual([]) prints the offending keys; the ablation text the PR quotes (position.(root): … expected [ 'zzPlanted19333' ]) is exactly that shape. The not.toBeNull() guard refuses a non-key-bearing root instead of passing over it. Right.
  5. The synthetic controls are genuine, one lit and one dark. They drive reconcileRoot over the file's existing root-coordinate fixture (schema: name, label, tags, tombstone gone, composite nested carrying the overlay, protection, the overlay spread; form offers name, label, nested). Lit: with an empty ledger the result must equal exactly ['tags'], so the tombstone, the seven overlay keys, protection and the offered composite are asserted absent in the same expectation. Dark: a root omit for tags, and separately a root subset, each yield []. A second lit clause: the same key filed at nested, or at the root for another type, still yields ['tags'] — the excuse is keyed by coordinate and type. Right.
  6. TOP_LEVEL_DEFERRED is what ruling A allows. The map holds exactly view, with a reason citing [Decision] is view reconciled per metadata type or per union arm? 36 keys of the #19188 census cannot be filed until this is settled #19330 letter A. The ruling's execution text says that until an arm form exists "the reconciliation gate's top-level direction covers the 16 object-rooted types only", with view recorded there as 「待臂表单,按 [Decision] is view reconciled per metadata type or per union arm? 36 keys of the #19188 census cannot be filed until this is settled #19330 A」. The code does precisely that: view is recorded by name and not asserted, TOP_LEVEL_TYPES is the 16. Right.
  7. The pin is a sound guard. unionRooted is derived from the live registry with the same unwrap and the same 'union' || 'discriminated_union' predicate that keysOf / isRetiredAt / subSchemaOf use, so the pin cannot disagree with the gate about what a union root is. ViewMetadataSchema is lazySchema over z.preprocess(fn, z.union(members).check(…)); unwrap peels lazy, then the pipe (transform on the in side, so it takes out), and lands on the union. The other 16 roots are object nodes (strictObject(…); Object.assign(ObjectSchemaBase, …) keeps the object node; FieldSchema is base.superRefine(…); ActionSchema is actionObject().refine(…)). The pin fails in both directions — an object-rooted type placed in the map (deferred set no longer equals the union-rooted set) and a union-rooted type registered without an entry — and TOP_LEVEL_TYPES.length must equal TYPES.length - unionRooted.length and exceed 10, so the direction cannot go vacuous. Observation, not a defect: a future union-rooted type deferred WITH a reason over 20 characters passes the pin; that is the shape ruling A gives a union root (per arm), and the reason floor is the ledger's own discipline.
  8. Residue 0 on current main, with no row added or moved. The ledger is untouched (item 1). The dev re-derived at 4a1df19656 with the verbatim helper block and lit / dark controls asserted inside the probe, the gate itself was green in the full spec suite at the merged head, and the four ablation legs show the direction discriminates (planted key RED with the direction, GREEN without it; explained key GREEN; a deleted root row RED). Between that base and the PR base 0bbe4005e8 the only spec-source movement on any form, root schema, registry or type-map path is ui/view.zod.ts (feat(spec)!: type ViewFilterRule.operator's input as the canonical ViewFilterOperator (#20450) #20503, ViewFilterRule.operator — a nested node on the deferred type) and qa/testing.zod.ts (feat(spec,core,cli)!: a scenario's requires is checked before it runs — unmet params or services skip it with a reason; requires.plugins retires into requires.services #20511), which is reachable only through the qa barrel that no non-test spec source imports; neither can add a top-level key to any of the 16 roots, so the 0 reading carries to the PR base by construction, and the queue's rebuilt generation re-runs the gate regardless. app._unpublished is answered by its own platform-written root row from round 1, not by FRAMEWORK_FIELDS, so it counts as excused. Right.
  9. Comment rewrites. The two "stays unwired" passages and the two present-tense "132 of the 274" readings are made true / historical; that was round 2's carrier note for whoever wired item 2. Same file, in scope. Right.

② Semver level

  • skip-changeset matches what the diff publishes: nothing. The one changed path is outside files[] (item ② above); no .changeset/*.md is added (the file list is one path). Check Changeset on the head: success. The AGENTS.md criterion for the label — a diff that publishes nothing from any released package — holds, and the two predecessor rounds in this same file (test(spec): record why fourteen top-level keys are never offered by a metadata form #20064, test(spec): record why field.format is never offered by a metadata form #20322) landed on the same reading.
  • Clause-②: no is right. No accept-set moves in either direction, so no arm; a stricter repo-internal CI gate is not a published surface, so no changeset level is owed. Not governed (no register path; Governed Surface Queue Guard success); 128 changed lines, under the 5,000 human-merge threshold; the head repo is the base repo, not a fork.

③ Boundary flags

Dev deviations (5879524947), each answered:

  • Merge commit eeb01c7143 carries git's default message without the trailer pair — accepted: the squash landing discards it; the feature commit 47ecd08a9f carries the model-free Claude-Session / Co-authored-by pair AGENTS.md prescribes, and the pre-push check passed. Same disposition as rounds 1 and 2.
  • Detached long runs with recorded PIDs, one lock queue-timeout re-run, and a batch-last-exit lock result with each part's own exit code quoted — process notes with no contract bearing; accepted.
  • The model-free trailer pair over the harness's model-named form — AGENTS.md is the rule of record; correct.
  • Four comment passages rewritten beyond the wiring — judged in ① item 9; accepted; no ledger row moved.

Dev open question — should #19188 close when #20520 lands? The seat's answer is A (close it completed with a landing record citing PR #20520), and nothing in the code contradicts it: TOP_LEVEL_TYPES is the 16 object-rooted types, exactly the scope ruling A gives the direction, and view is recorded by name with the ruling as its reason in TOP_LEVEL_DEFERRED, so the record ruling A asked for lives in the gate itself. #19188's remaining Blocked-by lines are #19332 (closed) and #19333 (this PR Fixes it). The landing record on #19188 should say view's 44 keys stay recorded, not asserted, per ruling A, and point at TOP_LEVEL_DEFERRED as the reader.

Out-of-scope findings: view._isOverride rides the first arm-form card (carried, not this PR's); the 20-character floor on the five read root reasons is an observation the acceptance notes carry, and the TOP_LEVEL_DEFERRED reason sits under the same floor; the ledger's view block on owner / hidden goes to PR #20286, inherited and untouched. Nothing is escalated.

Check-runs on the head, read once (36 runs): 13 success, 7 skipped, 0 failure, 16 not concluded. Concluded success: Auto Label, Check Changeset, Check Documentation Links, Check PR Size, Flag docs affected by code changes, Governed Surface Queue Guard, No other open PR may claim the same issue, No other open PR may claim the same single-writer path, Part-of PR must not also close its card, Spec property liveness, The card this PR closes must claim this branch, Type Check · source gates, filter. Skipped: Build Docs, Console Pin Gate, both Packed-tarball smoke runs, and the second Auto Label / Check Changeset / Check PR Size runs. NOT concluded at the read (in progress; not presumed green, not polled): Build Core; Dogfood Regression Gate 1/3, 2/3 and 3/3; Dogfood Verify CLI; Lint and Repo Gates; Temporal Conformance (live PG + MySQL); Test Core 1/6 through 6/6; Type Check · consumer gates, · debt ledger and · workspace. Test Core is the run that executes this gate in CI; the owning seat reads it green before enqueue. There is no failure to attribute to this diff. This record was written and posted at 2026-09-28T22:12Z.

PR form: line one Fixes #19333, line two Clause-②: no; the claim 5878139536 names branch claude/issue-19333-top-level-zodonly-wiring, which is the head branch; assignee os-tesla mirrors the card; draft, auto-merge not armed.

Implemented-by: claude/issue-19333-top-level-zodonly-wiring
Reviewed-by: session_014EJ1ED8X4MMrT18BhVx4tx

VERDICT: PASS


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

protocol:system size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

#19188 split: 145 top-level zod-only keys need a RECORDED REASON, never a form row — and none can be recorded until the ledger learns a root path

2 participants