feat(types): derive and export the strict authoring twin of the node face - #8642
feat(types): derive and export the strict authoring twin of the node face#8642os-warren wants to merge 4 commits into
Conversation
…face Publish `StrictAnyComponentSchema`, `StrictSchemaNodeSchema` and `deriveStrictAuthoringSchema` from `@object-ui/types/zod` — a derived twin of the node face that closes every declared object at every depth. Additive: the rendering face keeps its `.passthrough()`, no accept set moves, and no consumer is wired to the new face. The twins are derived, never hand-written. Objects are cloned by patching a copy of their own def so `.refine()` / `.superRefine()` checks survive; a twin rebuilt with `z.object(shape)` would drop them and under-report red. Both sides of a pipe are walked, so a `z.preprocess`'s real schema is closed too. Part of #8345 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w
The snippet used `ButtonSchema` without importing it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
…e population The walker's type guard was `typeof value === 'object'`. Zod 4.4.3 builds some objects through `$ZodObjectJIT`, whose instances are CALLABLE — 20 of them are reachable on this face, all through `@objectstack/spec`-derived subtrees. The guard handed each one back untouched, so the subtree beneath went unwalked and 6 objects stayed open on the twin: an invented key inside one of them was accepted and silently dropped, while the same key at the root was refused and named. Nothing could catch that. No document in the 556-document corpus carries an undeclared key inside those 6 objects, and the pin file's own census began `typeof node !== 'object'` — it shared the blind spot with what it measured. Both guards now admit callable nodes, and the pin that was missing is here: a POPULATION pin requiring every object reachable on the twin to carry `catchall: never`, with the function-typed count asserted non-zero so the control cannot silently regress. REPRO-A is pinned as a document too, and the cycle's real invariant — the barrel is the sole entry — is pinned in both of its halves. Part of #8345 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w
Contract review (
|
| reading | value |
|---|---|
| pins (a) known-good / (b) root key refused + named / (b) tolerant accepts / (c) 5-row tolerant table after forcing | true / false + ["inventedTop"] / true / all 5 rows unchanged |
child slot (recursion point live): invented child key refused + named; child's own variant/size accepted |
["inventedChild"]; true — R4 shape, not R2 |
StrictSchemaNodeSchema: bare string accepted / text + nope refused |
true / false |
corpus (scripts/measure-strict-authoring-face.mjs --json against my scratch dist) |
556 docs · 174 whole-tree strict · 46 red today · 2099 nodes · 179 refused (127 strict-only / 52 red) · 107 / 94 / 62 · collisions 0 · arms w/o literal type 0 — identical to every cell the PR quotes |
| plausibility vs card tables | card R4 = 176/553 @ 5505aec, R2 = 294/553; this head 174/556 sits on the R4 line, the R2 blow-up is gone. Controls present (red-today, collisions, arms-without-literal). |
opaque census (onOpaqueShape, one forced parse) |
custom 71 · function 4 · transform 1 = 76 |
ZodError on the strict path (#8498 cliff, closed by #8544) |
one invented leaf key at depth 0/1/2/4/6 → flat issues 1/12/23/45/67, JSON 111 B → 9,177 B, ≤0.5 ms/parse — linear, not the 25×/level cliff; cloneWithDef preserves the discriminator, so #8544's fix carries into the twin |
| tolerant face non-mutation (function-aware census) | closed objects reachable from AnyComponentSchema: 58 before and after forcing both twins |
| module cycle, Node on dist | deep-module-first: same object as barrel's, nested key refused; barrel-first: same object, export present. No load-time derivation (createStrictWalker() = one empty Map, two z.lazy wrappers). New edges: strict-authoring-face.ts ⇄ zod/index.zod.ts only — no edge from base.zod.ts (the refused getter-binding route is not taken; base.zod.ts is byte-identical to base). |
Findings
1. BLOCKER — the strict twin does not close every reachable object; 6 stay open, and an off-spec key inside them is silently accepted and dropped.
src/strict-authoring-face.ts:141-142: isZodType = (value) => typeof value === 'object' && value !== null && '_zod' in value. On this face 20 zod 4.4.3 schema nodes are typeof 'function' (def kinds: object 15, enum 3, record 1, pipe 1; they parse normally). The guard returns each one unwalked, so its whole subtree keeps the tolerant shape. Function-aware census of the forced StrictAnyComponentSchema: 302 objects, 296 closed, 6 open — page.interfaceConfig.sort[] (strip), page.slots.header[0].in.visibleWhen[1] + its .meta (strip), page.slots.header[0].in.dataSource.filter(lazy).right (strip), list-view.bulkActionDefs[].params[] and …params[].options[] (catchall: unknown, i.e. passthrough).
Reproducer: { type:'page', interfaceConfig:{ source:'x', sort:[{ field:'a', order:'asc', inventedDeepKey:1 }] } } → tolerant true, strict true, no unrecognized_keys, strict output {"field":"a","order":"asc"} (key dropped). Control, same document + inventedTopKey at root → strict false, ["inventedTopKey"] named.
Fix ablation (scratch copy, guard admits typeof value === 'function'): 300 objects / 300 closed / 0 open; reproducer → strict false, ["inventedDeepKey"]; known-good still true; list-view.…params[0].bogus now named alongside the pre-existing required-value issues. The prototype's guard (if (!schema?._zod) return schema;, script line 260) does not have this hole — the shipped walker regressed coverage relative to the prototype on exactly this class.
Expectation: admit function-typed nodes in isZodType; add a pin that walks the forced twin with a function-aware visitor and asserts every reachable object def has catchall.type === 'never' (with a non-vacuity assertion that ≥1 function-typed node was visited); add the reproducer above as a pin, both directions.
2. MAJOR — the published contract text is false at those six depths. Changeset: "refusing any undeclared key at any depth with an unrecognized_keys issue that names it"; README: "closes every declared object, at every depth"; PR body: "Every reachable object is closed". The changeset is the payload that ships. Expectation: make the text true (finding 1) — do not soften the text instead.
3. MINOR (latent) — the walker's default: arm swallows container def kinds silently and reports nothing. Synthetic probe: z.set(obj), z.map(k, obj), obj.prefault(…) → strict twin accepts {a:'x', bogus:1} inside them, inner objects 0 closed, and no onOpaqueShape call — so the "complete limit list = custom/function/transform" claim holds only for kinds the switch names. None of these kinds is on the face today (after the finding-1 fix the census reads 0 open), so no accept set moves now. Controls: catchall: obj and z.record(k, obj) are walked correctly (strict refuses). Expectation: add set/map/prefault/promise arms, or make default: report unknown non-leaf kinds through onOpaqueShape so the limit list is measured rather than enumerated by hand.
4. MINOR — the pin file's instrument shares the defect. closedObjectCount (test file, bottom) starts with typeof node !== 'object', so the "identical before and after" reading and the "39 closed" figure could not see the six open objects; the corpus is blind too (no document among the 556 carries an undeclared key inside those objects — base, head and the swapped-in shipped face all read 174 regardless). Expectation: function-aware counter; the pin in finding 1 is the control the file is missing.
5. INFO — the module cycle's real invariant is "the barrel is the sole entry", and nothing pins it. "Read only inside the lazy getters" is true of the shipped source (import at line 84, runtime reads only at 300/312) and is what makes both Node orders green — but rollup's synthesized namespace on a deep-first entry is the shape #8344 paid for, and the only thing keeping it unreachable is the exports map plus the fact that no file but the barrel imports strict-authoring-face. Build Docs on this head is the skip path (12 s, "No docs-related files changed") and says nothing about Turbopack. Expectation (non-blocking): a source-scan pin that no module other than zod/index.zod.ts imports strict-authoring-face.
6. INFO — check:node-esm-load is not a per-PR gate. node-esm-load-gate.yml triggers on schedule (17 4 * * *) and on push for its own two paths; only check:esm-specifiers runs per PR. So "cannot arise on CI" is true only because CI never runs the load leg on a PR. The provenance explanation is plausible and not this diff, but the PR should state the --force-build re-read rather than lean on CI.
7. INFO — placement (option A) and the census collision, reproduced. Unmutated: pin file 19/19 and parity file 32/32 under vitest. Appending one export const to zod/index.zod.ts → Tests 1 failed | 31 passed (32), failure naming index.zod.ts#__StrictAuthoringCensusProbe. zod-mirror-parity.test.ts is unchanged vs base (fence honoured); the zod-lazy-getter-identity-7918 row lives in its own test file, also unchanged. Seat ruled A on the card; nothing here is a maintainer item.
8. INFO — the claim comment on #8345 (5590024076) carries no Container & model: line. Tier stated: none. (The dispatch comment says "built at TIER_DEFAULT"; the claim itself does not carry the line.) Commit trailers: both commits carry Co-Authored-By + Claude-Session; commit 1 carries Part of #8345. RULE 2: satisfied.
Changeset verdict
.changeset/8345-strict-authoring-face.md: '@object-ui/types': minor — grade correct (additive export, nothing removed, no BREAKING). Text false at the six depths in finding 1; must be corrected with the fix, not reworded around it.
Tests
19 pins pass; no .skip/.only/.todo; both directions pinned (strict refuses root and child invented keys and names them; tolerant control accepts the same documents; five-row tolerant table after forcing); revert reddens (the exports do not exist at base). What is missing is the whole-graph closure pin (finding 1/4).
CI on ad99bee
33 check runs, all completed, none in progress: Type Check ✓ (19:33:19) · Build Docs ✓ (skip path, 12 s) · Bundle Analysis ✓ (types (strict-authoring-face.js) 11.72 KB / 4.48 KB gz; console eager closure 3477.4 KB under the 3512.7 KB budget) · Test (shard 1/4 … 4/4) all ✓ · Lint ✓ · Changeset Bump Policy / Declaration / Fixed Group / Overwrite ✓ · README Export Check ✓ · Governed Surface Queue Guard ✓. mergeable_state: unknown at read (draft; 8 commits behind origin/main 4dc80d0f).
Maintainer-only merge: no — non-governed additive export under a maintainer-ruled programme; every finding above is implementer-floor. Carrier needs:contract-review stays on PR and card until finding 1 is fixed and re-reviewed.
Generated by Claude Code
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
The `pipe` arm's comment said "one pipe reachable, `in` an array and `out` the transform". That reading was taken through the object-only guard. With callable nodes admitted, four pipes are reachable — including a preprocessor under `page.interfaceConfig.filterBy[]` that the old walker could not see at all. No code changes; the comment now states what the tree measures. Part of #8345 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
Part of #8345 — the strict face itself, under the #5250 ruling (director comment
5534418546, maintainer 2026-09-04, decision batch #25, option 2). ⛔ No consumer is wired:objectui validate, the JSON-fence gate andobjectui checkare devx's half and are ruled to come after.Attribution, in prose because a body edit downgrades the footer: written by Claude Code, seat session
session_01Jmxdo7bmeqCQHLSfmLVX9w.Notation. Generic parameters and placeholders are written as UPPERCASE words rather than in their real angle-bracket spelling, because GitHub deletes tag-shaped fragments on save — backticks and fences included.
Patch round at
8e9b563— the contract review returned FAIL, and it was rightThe ceiling-tier review (verdict
5591195581) found a real defect underneath every passing pin. It is fixed here, and the two prerequisites it named are now pinned.The defect
The walker's type guard was
typeof value === 'object'. Zod 4.4.3 builds some objects through$ZodObjectJIT, and those instances are CALLABLE — they answertypeof 'function', their constructor prints as a boundZodObject, their traits readZodObject/$ZodObjectJIT/$ZodObject/$ZodType, and they parse like any other object. 20 of them are reachable on this face, all through@objectstack/spec-derived subtrees. The guard handed each one straight back, so the whole subtree beneath went unwalked.Measured on the built
dist, before and after, by the same probe:ad99bee8e9b563page.interfaceConfig.sort[]unrecognized_keysnamesinventedDeepKeyThe six that were open:
page.interfaceConfig.sort[],page.slots.header[0].in.visibleWhen[1]and its.meta,page.slots.header[0].in.dataSource.filter(lazy).right(all zod strip mode), andlist-view.bulkActionDefs[].params[]with…params[].options[](bothcatchall: unknown, i.e. passthrough).⛔ The claim was not weakened to match the walker. The walker was fixed so the published sentences — in the changeset, the README and this body — became true. All three are unchanged.
⭐ Why nothing here could have caught it, and the pin that now does
Two instruments shared the defect with the thing they were measuring.
typeof node !== 'object'— the identical blind spot. That is why "39 closed, identical before and after" read clean.⇒ The new pin is over the population, not over a sample document: walk the forced twin and require every object in it to carry
catchall: never. Its non-vacuity control is the one whose absence let this through —functionTypedmust be greater than zero, so a census that cannot see callable nodes fails loudly instead of reporting a graph it never entered. Both halves are ablated below.The cycle invariant, now pinned
The review measured the module cycle under four bundlers. Node, Vite/rolldown and Next 16.3.1 Turbopack are green in both entry orders. rollup 4.62.2, deep-module-first with a namespace import used as a value, is RED —
ReferenceError: Cannot access 'StrictAnyComponentSchema' before initialization, from the namespace object rollup synthesizes ahead of the deep module's body. Named imports in the same order are green; barrel-first is green.Unreachable today, and that is the point: my stated reason for safety — "read only inside the lazy getters" — is true and not sufficient. The load-bearing invariant is the barrel is the sole entry into the cycle, and it now has a pin covering both routes a caller has:
exportsmap has no wildcard subpath and no entry reaching the deep module (so nothing outside the package can deep-link it);packages,apps,examples,scripts,e2e, matched on import specifiers so thatscripts/measure-strict-authoring-face.mjsdoes not read as an importer merely by sharing the words in its own name.@object-ui/typestopackages/types/srcby prefix, so inside this repo a deep specifier resolves even though theexportsmap blocks it for consumers. The repo scan is the half that covers that route; the manifest assertion alone would not.Two censuses moved, exactly as the review predicted
Opaque limits. Re-derived over the published face, forced through one document parse: custom 71 · function 4 · transform 4 = 79 (was
71 · 4 · 1 = 76atad99bee). The old count was a census of what the walker visited, not of the face. The shipped walker and the prototype each reported exactly onetransform, and they were different ones: mine at a pipe'soutunderreport.exportOptions, the prototype's a preprocessor-shaped pipe underpage.interfaceConfig.filterBy[]that my walker could not reach because thefilterByarray element is function-typed. Both are now visited.Pipes. The
pipearm's own comment said "one pipe reachable,inan array andoutthe transform" — a reading taken through the blind guard, and therefore false. Re-derived: four pipes, onetransformintoenum(the preprocessor) and three into atransformfromobject,stringandarray. The comment now states the measurement. ⇒ Today no OBJECT sits on anoutside, so walking both sides still moves no accept set; it is there so the first preprocessor wrapping an object does not open a hole.What lands
Three values and two types, published from
@object-ui/types/zod:StrictAnyComponentSchemaAnyComponentSchemaStrictSchemaNodeSchemaSchemaNodeSchemaderiveStrictAuthoringSchema(schema, options)StrictAuthoringLimit,DeriveStrictAuthoringOptionsonOpaqueShapereportDerived, never hand-written. Every reachable object is closed through unions, discriminated unions, arrays, tuples, records, intersections, optionals, nullables, defaults, both sides of a pipe,
z.lazy, and — since the patch round — callable JIT object nodes. Objects are cloned by patching a copy of their own def and calling their own constructor — ⛔ never rebuilt with a fresh object literal, which dropsdef.checksand would make the twin under-report red. A callable node clones through its own bound constructor and comes back object-typed: a difference in representation, not in behaviour, and the population pin measures behaviour.⛔ The rendering face's passthrough is not flipped. This adds a face; it does not change the existing one — pinned, not promised.
All at
8e9b563, zod 4.4.3, corpus 556 documents / 2099 nodes (catalog + docs + authored). The card's 176/553 and 184/2099 are pinned at5505aec1and are not these numbers.c4326fe(base)typeBase and head are identical in every cell — the corpus-scale statement that this PR moves nothing about the existing face.⚠️ And, as above, the same table was identical before the guard fix, which is exactly why it cannot stand as evidence that the face is closed.
⭐ Agreement with the prototype, with the script's own whole-tree twin swapped for the shipped
StrictAnyComponentSchema(scratch copy, never committed): 174 / 556, red-today control 46 — identical.The blocker's property, re-read on this tree
The old 294/553 blow-up is gone: whole-tree strict (174) now sits beside per-node strict (179 of 2099) instead of an order of magnitude above it, because since #8344 a child slot resolves to the component union rather than the base keys. That is the error #7935 existed to prevent.
Where the module lives — ruled A by the seat (
5590686191), not reopened__tests__/zod-mirror-parity.test.tsruns a census closed over theexport consts ofsrc/zod/*.zod.ts, and that file is fenced this round (PR #8553). So the derivation module lives atpackages/types/src/strict-authoring-face.ts, outside the mirror directory, and the barrel re-exports from it. The collision is measured, not predicted — ablation leg 1 below. B (move it in, add anEXCLUSIONSrow) is recorded as a follow-up for after #8553 lands.The pins
packages/types/src/__tests__/strict-authoring-face-8345.test.ts— 25 tests.cardwithbuttonandtextchildren parses under the strict face, and under the tolerant one.unrecognized_keysnaming exactly that key; the same document accepted by the tolerant face.catchall: never; plus the callable-node control; plus a discriminator control showing the tolerant face is not closed.variant/sizeare ACCEPTED.chatbotbody clause still refuses a recordbodyone slot down while the root form is accepted, on both faces.outside of a pipe.tsc -p tsconfig.test.jsonand by nothing else — vitest does not typecheck. Non-vacuity with--listFiles: the test program lists the pin file (1 hit), the emitting program does not (0), both list the source.Ablations — each proved on disk, each restored under a trap
Every leg: blob hash before and after (a byte-identical mutation aborts the leg as a no-op), an
EXIT INT TERMtrap with absolute paths, and a restore verified by the hash returning to the HEAD blob andgit diff HEADbeing empty. Every leg printed its RESTORED-OK line.export constappended tozod/index.zod.ts(b866917tofb5e6a4)index.zod.ts#__StrictAuthoringCensusProbeoutwalkoutbranch deletedtsc -p tsconfig.test.jsonf3a8397tocd8a54b)expected [ …(6) ] to deeply equal [], and REPRO-A4f24ec9to98de385)4f24ec9to4cb80b4)packages/core/srcimporting the deep modulegit status --porcelainemptyGates
All at
8e9b563(the readings were taken at75d31cband re-run at head; the intervening commit changes one comment and no code); each exit code captured before any pipe.pnpm exec vitest run packages/types/pnpm --filter @object-ui/types type-check(all three programs)pnpm --filter @object-ui/types lintpnpm --filter @object-ui/types buildcheck:control-bytes·check:published-tsconfig-exclude·check:side-effects-array·check:esm-specifiers·check:self-importcheck:dist-completeness·check:published-distcheck:doc-fences·check:doc-snippets·check:doc-types·check:doc-examplescheck:governed-queue-guard--teston all five changed paths: NOT GOVERNEDnode scripts/check-changeset-presence.mjsgrep -naPcontrol-byte self-scan of the changed filescheck:node-esm-load, plaincheck:node-esm-load --force-build37 of 37 … built by this tree,34 of 39 … imported and evaluated, 5 by design.turbo/cacheacross every worktree of this checkout, and the plain run refuses two entries it did not build — this time@object-ui/authand@object-ui/react-runtimereplayed from the reviewer's own scratch worktree, the side effect the review disclosed. Neither package is in this diff and@object-ui/typeswas built by this tree in both runs. But my sentence "on CI the refusal cannot arise" implied a coverage CI does not provide.node-esm-load-gate.ymlruns on a nightly cron and on push tomain; onlycheck:esm-specifiersruns per PR. ⇒ the load leg is unmeasured by anyone on a PR head — ⛔ not "left to CI". The--force-buildrun above is the only reading of it that exists for this head.needs:contract-reviewwas on this PR, then read back MISSING after the first body edit — something else wrote the label set whole. I re-hung it; I did not clear it and ⛔ will not clear it.Not measured, stated rather than implied: the repo-wide
turbo run lintand the fullpnpm test(CI's, and those genuinely do run per PR); esbuild/tsup bundling of the cycle; any browser or runtime rendering (nothing renders this face); import-time cost in a bundled app (the derivation is deferred behindz.lazy, so it is not paid unless the face is parsed).Scope
Changeset:
@object-ui/typesminor — a new published face on a published entry point; grade confirmed by the review against AGENTS.md 238–240 and the precedents. Five files: the derivation module, the barrel re-export block, the pin file, the package README, the changeset. ⛔ No fenced file is touched: notpackages/types/package.json(the cycle pin reads it, never writes it), not the package's build configuration, notsrc/record-components.ts, not__tests__/zod-mirror-parity.test.ts.Falls off the back, as ruled: wiring any consumer; repairing any of the 174; the TypeScript authoring face (#7927); closing the opaque validators.
Carrier:
needs:contract-reviewis on the card and on this PR. ⛔ Not to be cleared here — only the PM seat clears it, on a transcript-verified pass.