test(spec): walk every depth of hand-written form lists in the form ↔ Zod reconciliation gate (#14327) - #14408
Merged
Conversation
… Zod reconciliation gate
`nestedLists` collected a hand-written list only for a top-level section entry,
so a repeater or composite nested inside another nested list — the object
designer's `fields.options` / `fields.summaryOperations` rows and its four
`lifecycle.*` blocks — was outside the population entirely. The walk is now
recursive and keyed by dotted path; each level's sub-schema is resolved by
walking `subSchemaOf` down the same path. The nested-list predicate is
extracted (`reconcileNestedLists`) and pinned against a synthetic depth-two
fixture: form-only and tombstoned keys go red at depth two, a designed subset
goes green only with a ledger entry at the full dotted path. Nested-list
assertions are soft so one run names every drifted list.
Triage of the six newly reached paths, from the gate's own first run: the one
form-only offer (`fields.options.icon`) is the already-carded withdrawal; the
four zod-only differences are designed and ledgered — the two `fields.*` rows
inherit the quick-add subset one level up, and `lifecycle.{retention,ttl}.onlyWhen`
is a row-filter map written only by code-declared platform system objects.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GDA48PuRFrHyRfdkBz8m21
…rm-reconciliation-depth
Contributor
📓 Docs Drift CheckNothing 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): |
os-zhuang
marked this pull request as ready for review
September 2, 2026 05:06
os-zhuang
enabled auto-merge
September 2, 2026 05:06
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #14327
What
packages/spec/src/system/metadata-form-zod-reconciliation.test.ts(the #3786 metadata form ↔ Zod reconciliation gate) walked only ONE level of hand-written nested form lists:nestedListscollected a list for a top-level section entry carryingfieldsand never descended intoentry.fields[*].fields. A repeater or composite nested inside another nested list was outside the population entirely — which is how the object designer's options repeater offered aniconinputSelectOptionSchemarefuses (#13671) with this gate green throughout.nestedListsis now recursive and keyed by dotted path (fields.options,lifecycle.ttl, …), carryingkeyFieldat every depth as it did at depth 1.subSchemaAt, onesubSchemaOfstep per segment (unions looked through, arrays / records peeled by the existingunwrap).reconcileNestedLists(type, form, root, ledger); theit.eachover the registry and the new self-test pins call the SAME function.expect.soft, so one run names every drifted list in a form instead of the first (the triage table below is the gate's own output).LEDGERvocabulary and the meaning of every existing assertion are unchanged; entries simply carry a dottedpathnow.Test-only diff: no package output changes, no
*.form.tsedit — henceskip-changeset.Population, before → after (gate's own traversal, all 17 registered forms)
Measured by mirroring the depth-1 walk on
origin/mainagainst the recursive one overMETADATA_FORM_REGISTRY:The six newly reached lists are exactly the card's table, all in
object:fields.options,fields.summaryOperations,lifecycle.retention,lifecycle.ttl,lifecycle.storage,lifecycle.archive. The other 12 forms carry no hand-written list below depth 1 (their nested composites / repeaters declare nofieldsand are schema-derived by the renderer).Triage of every newly reached path — first run, no ledger row added yet
The widened gate's first run on the pre-#14326 base (
ce1b37901parent =d62f990a9), every assertion message verbatim:fields.optionsiconbefore #14326)SelectOptionSchema(strict)icon(refused at publish) — the card's predicted real finding; zod-onlydefault,visibleWheniconwithdrawn by #14326 (merged into this branch, see serial constraint);default/visibleWhen: ledgersubsetfields.summaryOperationsFieldSchema.summaryOperations(strict)relationshipField,filtersubsetlifecycle.retentionLifecycleSchema.retention(strict)onlyWhenomitlifecycle.ttlLifecycleSchema.ttl(strict)onlyWhenomitlifecycle.storageLifecycleSchema.storage(strict)lifecycle.archiveLifecycleSchema.archive(strict)No newly reached path offers a key its schema refuses beyond the already-carded
icon, so no offer is withdrawn by this PR and no*.form.tsis edited.Ledger entries added (designed differences only)
subset object.fields.options— one row of thefieldsquick-add grid (the existingsubsetentry one level up), so the same design applies: an option is captured as label / value / color / description;defaultand the per-optionvisibleWhenCEL predicate are the long tail, authored in the full per-field editor (field.form.ts), whoseoptionsrepeater is schema-derived and offers everySelectOptionSchemakey.subset object.fields.summaryOperations— same quick-add row: the roll-up is captured as object / field / function;relationshipField(auto-detected unless the child references this object twice) and thefilterFilterCondition are authored infield.form.ts, whose ownsummaryOperationscomposite offers both with dedicated widgets (ref:object,filter-condition).omit object.lifecycle.retention.onlyWhen— a per-field row-filter map ({ field: value | { $in: [...] } | { $null: bool } }) with no scalar rendering among the block's text inputs. Measured writers (git grep -w onlyWhen, non-test, non-generated): every one is a platform system object declared in code —sys_job_queue,sys_automation_run, the storage service'ssystem_file/system_upload_session— where the interleaved live-vs-terminal rows it exists for live. Offering it in Studio needs a structured control: a form-face addition, not a reconciliation.omit object.lifecycle.ttl.onlyWhen— the mirror of the retention key (one shape by design,lifecycleOnlyWhenSchema); its one writer today is the code-declaredsys_sessionobject (revoked_at: { $null: true }).Not ledgered, by the gate's own rule: nothing form-only. The two
subsetrows keep the form-only direction enforced at depth 2 (asubsetonly relaxes the zod-only direction).Self-test pins (same predicate as the live gate)
objectpaths above — a walk that collapses back to depth 1 turns this red.icon) is reported atitems.options; a depth-2 tombstoned key (retiredKey()) is reported as retired, not form-only.zodOnly: ['extra'], the tombstone excused automatically), green with asubsetrow at the full dotted path, green with anomitrow, and still red with asubsetrow at the parent path — a parent entry excuses nothing one level down.subSchemaAtwalks record → object → array → object; an undeclared segment resolves toundefined(reported as unanchored).Serial constraint (
packages/spec/src/data/object.form.tsbelongs to #13671 / PR #14326)git fetch origin main && git log --oneline origin/main --grep='(#14326)' | head -1empty each time; no*.form.tstouched.e80889095 fix(spec): withdraw the offered-but-refused icon input from the object.form options repeater (#14326).git merge --no-edit origin/main(no rebase) →3b3203d23, clean, noos-regen-pendingmarker. After the merge the repeater reads label / value / color / description and the widened gate is fully green.iconinput from the object.form options repeater #14326'sicon).Verification
See the union block below — every command was run on the final head, exit codes captured before any pipe, verdict lines quoted from the gates themselves.
Union on the final head
3b3203d233b3203d23= merge oforigin/main7ad57e17a(carries #14326 ase80889095) intoce1b37901; nothing was committed after it. Derived withnode scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(no paths — the change set comes from the merge base; 47 families;--repoheld againstorigin). Every command ran in the foreground on this head with the exit captured before any pipe (cmd > log 2>&1; ex=$?); build / test runs went throughscripts/pm/os-verify-lock.sh(thecheck:*scripts are outside that lock's coverage by its own header and ran unlocked).Must-haves:
pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 src/system/metadata-form-zod-reconciliation.test.ts—Test Files 1 passed (1) · Tests 46 passed (46)(41 onorigin/main; +5 self-test pins), lockVERDICT command-exit 0.pnpm --filter @objectstack/spec typecheck—tsc --noEmitclean,check:scripts-typecheckclean,check:test-typecheck: OK — 54 file(s) / 262 error(s) / 146 pinned signature(s) held(debt ledger unchanged; this test file is not in it), lockVERDICT command-exit 0.pnpm --filter @objectstack/spec buildthenpnpm --filter @objectstack/spec check:generated—✓ All 15 generated artifacts are up to date.(exit 0; the tree was clean after the build, sogen:schemarewrote nothing).pnpm check:nul-bytes—check-nul-bytes: OK (scanned 7849 text file(s) -- 7849 tracked, 0 untracked-not-ignored; skipped 7 binary; no raw ASCII control bytes).pnpm check:doc-authoring— exit 0.pnpm check:i18n— not run: no*.form.tsmoved in this diff (the fix(spec): withdraw the offered-but-refusediconinput from the object.form options repeater #14326 bundle regeneration arrived through the merge, already gated onmain).pnpm lint(repo-wide eslint) — not run locally; CI's run, and no narrowed measurement is claimed.ce1b37901:Test Files 1 failed | 450 passed (451) · Tests 1 failed | 12112 passed (12113), the one red being this gate'sobject.fields.options … expected [ 'icon' ]case, i.e. fix(spec): withdraw the offered-but-refusediconinput from the object.form options repeater #14326 not yet merged. On this head: three lock runs (--maxWorkers=2, no bare--) —Test Files 212 passed (212),229 passed (229),10 passed (10)= 451 files;Tests 6213 passed+5624 passed+280 passed= 12117 passed, 0 failed. The three commands cover everysrc/**subdirectory,scripts/, and the tensrc/*.test.tsat the package root — 451 is the file count vitest reports for the whole suite, so nothing was narrowed.Derived families (47): 42 exit 0 —
check-adr-0087-registration,check-ci-filter-parity,check-comment-mask-adoption,check-cross-package-test-inputs(both spellings),check-keyed-text-bounds,check-plugin-teardown-shape,check-shard-attestation,check-system-context-census,check-undeclared-dep-imports,docs-audit/check-affected-docs,docs-audit/check-drift-comment, speccheck:api-surface/check:authorable-surface/check:browser-reachable-entries/check:docs/check:dual-source-exports/check:empty-state/check:entry-nameability/check:export-origins/check:exported-any/check:liveness/check:llms-txt/check:skill-refs/check:strictness-ledger/check:variant-docs/check:yaml-examples,check:changeset-gate-self-tests,check:doc-authoring,check:engine-double-contract,check:logger-receiver-detach,check:merge-driver,check:objectql-double-limit,check:page-declaration-shape,check:published-files,check:query-options-erasure,check:slot-lookup,check:spec-parsed-alias,check:test-source-alias,check:type-check-coverage,check:type-source-resolution,check:where-matcher.NOT MEASURED — 5 families whose own verdict text says nothing was measured (exit 3
PREREQUISITE NOT MET, or the precondition exit 1), all needing the whole workspace built (65 of 67 packages have nodist/in this worktree; for a test-only diff that build is CI's run, not a local one):node scripts/check-dev-prereqs.mjs— exit 1, "The workspace is not built — 1 unmet precondition, not a list of problems".node scripts/check-test-completeness.mjs— exit 3, needs a savedturbo run testlog ("record this gate as NOT MEASURED").pnpm --filter @objectstack/lint run check:doc-formula-expressions— exit 3; after building@objectstack/formulait asks for@objectstack/lint's own dist next.pnpm check:dual-build-cjs-loads— exit 3, reads every package's built output.pnpm check:type-check-debt— exit 3,--re-measureneeds the built closure of 55 workspace dependencies.Session: https://claude.ai/code/session_01GDA48PuRFrHyRfdkBz8m21
🤖 Generated with Claude Code
https://claude.ai/code/session_01GDA48PuRFrHyRfdkBz8m21
Generated by Claude Code