Skip to content

test(spec): walk every depth of hand-written form lists in the form ↔ Zod reconciliation gate (#14327) - #14408

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-14327-form-reconciliation-depth
Sep 2, 2026
Merged

test(spec): walk every depth of hand-written form lists in the form ↔ Zod reconciliation gate (#14327)#14408
os-zhuang merged 2 commits into
mainfrom
claude/issue-14327-form-reconciliation-depth

Conversation

@claude

@claude claude Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

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: nestedLists collected a list for a top-level section entry carrying fields and never descended into entry.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 an icon input SelectOptionSchema refuses (#13671) with this gate green throughout.

  • nestedLists is now recursive and keyed by dotted path (fields.options, lifecycle.ttl, …), carrying keyField at every depth as it did at depth 1.
  • Each level's sub-schema is resolved by subSchemaAt, one subSchemaOf step per segment (unions looked through, arrays / records peeled by the existing unwrap).
  • The nested-list predicate is extracted as reconcileNestedLists(type, form, root, ledger); the it.each over the registry and the new self-test pins call the SAME function.
  • The per-type nested-list assertions are 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).
  • LEDGER vocabulary and the meaning of every existing assertion are unchanged; entries simply carry a dotted path now.

Test-only diff: no package output changes, no *.form.ts edit — hence skip-changeset.

Population, before → after (gate's own traversal, all 17 registered forms)

Measured by mirroring the depth-1 walk on origin/main against the recursive one over METADATA_FORM_REGISTRY:

registered forms: 17
object: depth-1 lists=3 [fields, enable, lifecycle] | full-depth lists=9
field:  depth-1 lists=1 [summaryOperations]          | full-depth lists=1
hook:   depth-1 lists=2 [body, retryPolicy]          | full-depth lists=2
action: depth-1 lists=1 [body]                       | full-depth lists=1
page:   depth-1 lists=2 [variables, interfaceConfig] | full-depth lists=2
TOTAL: depth-1 (origin/main gate) = 9; full-depth = 15; newly reached (depth>=2) = 6

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 no fields and 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 (ce1b37901 parent = d62f990a9), every assertion message verbatim:

object.fields.options: offered by the form but not declared by the Zod (saved value is dropped): expected [ 'icon' ] to deeply equal []
object.fields.options: accepted by the Zod but unauthorable in the form — offer it, or add a ledger entry: expected [ 'default', 'visibleWhen' ] to deeply equal []
object.fields.summaryOperations: accepted by the Zod but unauthorable in the form — offer it, or add a ledger entry: expected [ 'filter', 'relationshipField' ] to deeply equal []
object.lifecycle.retention: accepted by the Zod but unauthorable in the form — offer it, or add a ledger entry: expected [ 'onlyWhen' ] to deeply equal []
object.lifecycle.ttl: accepted by the Zod but unauthorable in the form — offer it, or add a ledger entry: expected [ 'onlyWhen' ] to deeply equal []
path offered keys sub-schema verdict action card
fields.options label, value, color, description (+ icon before #14326) SelectOptionSchema (strict) form-only icon (refused at publish) — the card's predicted real finding; zod-only default, visibleWhen icon withdrawn by #14326 (merged into this branch, see serial constraint); default / visibleWhen: ledger subset already carded as #13671 — no new card
fields.summaryOperations object, field, function FieldSchema.summaryOperations (strict) zod-only relationshipField, filter ledger subset none
lifecycle.retention maxAge LifecycleSchema.retention (strict) zod-only onlyWhen ledger omit none
lifecycle.ttl field, expireAfter LifecycleSchema.ttl (strict) zod-only onlyWhen ledger omit none
lifecycle.storage strategy, shards, unit LifecycleSchema.storage (strict) clean — offered = declared none none
lifecycle.archive after, to, keep LifecycleSchema.archive (strict) clean — offered = declared none none

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.ts is edited.

Ledger entries added (designed differences only)

  • subset object.fields.options — one row of the fields quick-add grid (the existing subset entry one level up), so the same design applies: an option is captured as label / value / color / description; default and the per-option visibleWhen CEL predicate are the long tail, authored in the full per-field editor (field.form.ts), whose options repeater is schema-derived and offers every SelectOptionSchema key.
  • 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 the filter FilterCondition are authored in field.form.ts, whose own summaryOperations composite 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's system_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-declared sys_session object (revoked_at: { $null: true }).

Not ledgered, by the gate's own rule: nothing form-only. The two subset rows keep the form-only direction enforced at depth 2 (a subset only relaxes the zod-only direction).

Self-test pins (same predicate as the live gate)

  • Population: the live registry's depth ≥ 2 lists are asserted by name to include the six object paths above — a walk that collapses back to depth 1 turns this red.
  • Positive control: a synthetic record editor whose rows carry a repeater; a depth-2 form-only key (icon) is reported at items.options; a depth-2 tombstoned key (retiredKey()) is reported as retired, not form-only.
  • Negative control: a depth-2 designed subset is red bare (zodOnly: ['extra'], the tombstone excused automatically), green with a subset row at the full dotted path, green with an omit row, and still red with a subset row at the parent path — a parent entry excuses nothing one level down.
  • Resolution: subSchemaAt walks record → object → array → object; an undeclared segment resolves to undefined (reported as unanchored).

Serial constraint (packages/spec/src/data/object.form.ts belongs to #13671 / PR #14326)

  • 03:20:38Z, 03:25:52Z, 03:31:04Z, 03:38:22Z, 03:55:12Z — git fetch origin main && git log --oneline origin/main --grep='(#14326)' | head -1 empty each time; no *.form.ts touched.
  • 04:05:33Z — hit: 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, no os-regen-pending marker. After the merge the repeater reads label / value / color / description and the widened gate is fully green.
  • Outcome: no form edit was needed from this PR at all (the only form-only offer at depth 2 was fix(spec): withdraw the offered-but-refused icon input from the object.form options repeater #14326's icon).

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 3b3203d23

3b3203d23 = merge of origin/main 7ad57e17a (carries #14326 as e80889095) into ce1b37901; nothing was committed after it. Derived with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (no paths — the change set comes from the merge base; 47 families; --repo held against origin). 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 through scripts/pm/os-verify-lock.sh (the check:* 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.tsTest Files 1 passed (1) · Tests 46 passed (46) (41 on origin/main; +5 self-test pins), lock VERDICT command-exit 0.
  • pnpm --filter @objectstack/spec typechecktsc --noEmit clean, check:scripts-typecheck clean, 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), lock VERDICT command-exit 0.
  • pnpm --filter @objectstack/spec build then pnpm --filter @objectstack/spec check:generated✓ All 15 generated artifacts are up to date. (exit 0; the tree was clean after the build, so gen:schema rewrote nothing).
  • pnpm check:nul-bytescheck-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.ts moved in this diff (the fix(spec): withdraw the offered-but-refused icon input from the object.form options repeater #14326 bundle regeneration arrived through the merge, already gated on main).
  • pnpm lint (repo-wide eslint) — not run locally; CI's run, and no narrowed measurement is claimed.
  • Full spec suite — on the pre-merge head ce1b37901: Test Files 1 failed | 450 passed (451) · Tests 1 failed | 12112 passed (12113), the one red being this gate's object.fields.options … expected [ 'icon' ] case, i.e. fix(spec): withdraw the offered-but-refused icon input 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 every src/** subdirectory, scripts/, and the ten src/*.test.ts at the package root — 451 is the file count vitest reports for the whole suite, so nothing was narrowed.

Derived families (47): 42 exit 0check-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, spec check: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 no dist/ 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 saved turbo run test log ("record this gate as NOT MEASURED").
  • pnpm --filter @objectstack/lint run check:doc-formula-expressions — exit 3; after building @objectstack/formula it 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-measure needs 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

… 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
@github-actions github-actions Bot added the size/m label Sep 2, 2026
@github-actions

github-actions Bot commented Sep 2, 2026

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
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

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 f7d92d364e0d7def2fc3ec21d841b33658e38d49packageMentionDocs.

@claude claude Bot added needs:contract-review skip-changeset PR has no user-facing published change; bypasses the changeset gate labels Sep 2, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review September 2, 2026 05:06
@os-zhuang
os-zhuang enabled auto-merge September 2, 2026 05:06
@os-zhuang
os-zhuang added this pull request to the merge queue Sep 2, 2026
Merged via the queue into main with commit f358210 Sep 2, 2026
49 of 50 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-14327-form-reconciliation-depth branch September 2, 2026 05:32
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

2 participants