fix(types): strip the imported defaults at the spec import boundary — this mirror authors no default, imported subschemas included (objectui#8317) - #8721
Conversation
…objectui#8317) Decision batch #90 (2026-09-08) ruled that batch #69's principle — a validator validates, it does not write values into an author's document — holds for every key `safeValidateSchema` answers, not only the 41 this repo authored. The 57 `ZodDefault` nodes that arrived by reference from `@objectstack/spec` are now stripped where the spec enters this package. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w
…e per file `check:spec-symbols` (ci.yml) reads exactly ONE hop: a mirror export under a spec-owned name must show the `@objectstack/spec` import binding in its own initializer. Re-binding the imports to a local `const Spec… = stripImportedDefaults(Imported…)` put that binding one hop away and turned 16 declarations red (measured: green at da5e4f6, red at 99bde74). Wrapping each crossing instead keeps the provenance where the gate — and a reader — looks for it, with no gate surgery and no ALLOW entries. 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
|
|
Contract review — accepted, flipped out of draft, auto-merge armed. 33/33 green. This PR had one way to go badly wrong, and it is handled at the one site where it matters.
const inner = walk(schema.removeDefault());
out = isAlreadyOptional(inner) ? inner : z.optional(inner);with The test file is built the way I would have asked for and did not have to. What I checked rather than read:
The reversibility argument is the right one. Not touching Honest about what is a pin and what was a measurement: the differential against the raw spec schemas is permanent (both sides are importable), the 1,077-document corpus run was a one-off, and the changeset says which is which rather than implying the corpus is re-run.
The migration note earns its place by naming the fallbacks that already exist ( Generated by Claude Code |
Fixes #8317
Decision batch #90 (director seat, 2026-09-08, under the maintainer's standing delegation), option A, measure-first: batch #69's principle — a validator validates; it does not write values into an author's document — holds for every key
safeValidateSchemaanswers, not only the 41 this repo authored. TheZodDefaultnodes imported by reference from@objectstack/specare stripped where the spec crosses into this package.⛔ Option B (a 1546-site change on
@objectstack/spec's release train) is not taken; A is reversible into it. ⛔ C-unstated is refused.⛔ The hard precondition first — the consumer census, with a control that fires
The ruling made this not optional, and a non-zero hit would have changed the shape of the card. Result: zero, and the zero is non-vacuous.
Leg 1 — who can hold a parsed document at all. Production (non-test) importers of
@object-ui/types/zod, whole repo:result.data?packages/cli/src/commands/validate.tspackages/cli/src/commands/check.ts.successonlypackages/plugin-map/src/ObjectMap.tsx.success/.error, returns the raw authoredconfigPositive control: a synthetic consumer doing
result.data.navigation!.mode!was placed in the scanned tree; the same instrument reported it (TOTAL .data reads on a mirror parse result: 1) and reported the three real files unchanged. The instrument fires.Leg 2 — is that one read on any of the affected keys?
validate.ts:65readsdata.type,data.id,data.label,data.title,data.children, each presence-guarded. Probed across all 107 arms ofAnyComponentSchema: 450 root members namedtype|id|label|title|childreninspected, 0 carrying aZodDefault.Positive control: the same probe, pointed at
active/isDefault/kind— 4 inspected, 3 carry aZodDefault, so the probe demonstrably finds one when one exists.Leg 3 — the three packages the ruling named.
apps/console,packages/app-shell,packages/react: 0 production importers. (apps/console's single hit is a vite alias entry,vite.config.ts:464, not a consumer.)Reach control: the same search finds
@object-ui/typesin 4 / 31 / 9 production files of those three directories, so it is reaching them;packages/app-shellalso shows 4 test importers of the zod barrel under the un-filtered search.Leg 4 — what those packages actually read. The affected key paths are read off the raw authored schema, never a parse result, and every read carries its own fallback —
useNavigationOverlay.ts:248navigation?.mode ?? 'page',InterfaceListPage.tsx:504userActions.search !== false. That is batch #69's ruling already in force: the renderer's fallback is the authoritative default.⇒ No consumer relies on a substituted value being present. Proceeding was correct.
The count, re-derived on this head
⛔ Not inherited. Re-derived with the same instrument that priced #8299 (the
ZodDefaultgraph walk over every schema exported by the barrel), onda5e4f69(this branch's merge-base withmain):ZodDefaultnodes, beforesafeValidateSchemarunsStrictAnyComponentSchema, objectui#8345)Nodes walked: 7,753 → 7,752,
unreachable: []on both.The reproducer, both directions
Both are pinned in
zod-mirror-authors-no-defaults-7735.test.ts. The second direction is what separates stopped substituting from stopped declaring — a mirror that had merely dropped the keys would satisfy the first alone. The same pair is pinned forListColumnSchema.prefixand forobject-view'snavigationslot.Measured live on the other named families too (
appactive/isDefault,object-gallery.gallery.*,kanban.grouping.fields[].*,page.kind+interfaceConfig,list-view.sharing.type): every one now round-trips the authored document.The accept set does not move — measured, not asserted
imported-defaults-8317.test.ts: all 28 imported spec schemas answer 20 probes exactly as the raw@objectstack/specschema does. Both sides are importable, so this is a pin rather than a one-off._zod.optinis identical before and after..default(v)carries optionality as well as a value; the boundary re-optionalises what.removeDefault()hands back, so no key becomes required.def.checksat every reachable node (>500 nodes aggregate). A walk that dropped a.superRefine()would make this package accept what the spec refuses and would leave no trace in any count.examples/,content/docs,apps/,packages/types/src— 0 acceptance differences on the tolerant face, 0 on the strict face; 27 documents change parse output, which is the intended change.@objectstack/specschemas still carry their defaults (9 inAppSchema, 17 inDashboardSchema, 34 inListViewSchema, …). Every other workspace consumer imports that same module instance.Shape of the change
packages/types/src/zod/imported-defaults.ts— a memoised clone-walk modelled onstrict-authoring-face.ts: eachZodDefaultis replaced by.removeDefault()'s inner type, re-optionalised; objects are cloned by patching a copy of_zod.defand calling their own constructor, ⛔ never rebuilt withz.object(shape)(that dropsdef.checks); callable$ZodObjectJITnodes are admitted by the type guard, which is where the imported population actually lives.⭐ Identity property: a subtree with nothing to strip comes back reference-equal. That is the ruling's reversibility made mechanical — the day
@objectstack/specadopts the same principle, every call here is literally the identity function, with nothing to roll back. It is pinned in both directions (the schemas with no default are the raw object; the four that had one are not).z.lazyarm is the one place it cannot hold. Measured and pinned rather than hand-waved: 3 reachablelazynodes, each inside a schema that is rebuilt anyway, so today the exception costs nothing.The boundary is spelled at every crossing, not once per file — and that is a repair, recorded because it cost a round:
Two reads are declared exceptions, enumerated in the pin file rather than pattern-matched: the value vocabularies
SpecListViewTypeEnum/ViewKindEnum(they unwrap the spec's own.default('grid')to reach its enum — a set of values cannot write a key into a document; and they must read the raw binding, because the strip leaves the STATIC type unchanged so.removeDefault()on the stripped member would typecheck and throw), and TYPE positions. The census asserts each exception still matches a live, still-unwrapped read.The boundary sentence is written once, per the ruling — in the barrel's docblock (
zod/index.zod.ts) and in the changeset: "this mirror authors no default, imported subschemas included."Pins updated, and why each is an inversion rather than a weakening
zod-mirror-authors-no-defaults-7735.test.ts> 0, "NOT this repository's to remove"spec-subschema-parity.test.ts×6stripImportedDefaults(spec)— plus both controls: the 3 with nothing to strip are still reference-equal to the raw object, the 4 that carried a default are provably notspec-subschema-parity.test.tsprefix{ field }→{ field, type: 'text' })typeobject-view-unmirrored-keys-7779.test.ts×2modeparses to'page'modestays absent — and every probe is compared against the RAW spec slot too, which is the measurement that says the strip moved no accept setreport-chart-query-spec-parity.test.tsvalueKey/labelKey/persistback out of parse outputVerification — exit codes captured before any pipe
vitest run packages/types/Test Files 153 passed (153),Tests 3013 passed (3013)pnpm --filter @object-ui/types type-check(tsc --noEmit+tsconfig.examples.json+tsconfig.test.json)pnpm --filter @object-ui/types build+check:dist-completenesspnpm check:spec-symbols(runs inci.yml)pnpm check(runs inlint.yml:481)npx eslint .— whole repo, not narrowedprojectServicecount 0)check:control-bytes·self-import·phantom-deps·unused-deps·unreferenced-sources·handler-key-reads·side-effects-array·published-tsconfig-exclude·esm-specifiers·entry-guardnode scripts/check-changeset-presence.mjscheck:governed-queue-guard --test(the 16 changed paths)vitest runover the 20 dirs that reference@object-ui/types/zod(apps/console,packages/app-shell,cli,core,fields,react-consumers, all 11plugin-*,runner,scripts, bothexamples/)Test Files 1926 passed / 2 skipped (1928),Tests 25487 passed / 3 skippedvitest run --shard=1/4696 passed, 1 skipped (697 files);9256 passed, 2 skippedpnpm checkprints 3 warnings (twofilter-buildercatalog schemas and the vscode JSON schema "did not validate"). Pre-existing, not this change: the corpus differential above coversexamples/schema-catalogand found 0 acceptance differences across 1,077 documents.⛔ NOT measured — stated rather than implied
check:node-esm-load— ran, and the run is VOID, not red: the shared.turbo/cachereplayed@object-ui/authand@object-ui/react-runtimefrom another worktree, and the gate refuses to grade another tree's artifacts (32 of 39 entries loaded clean; the 2 refusals are outside this diff). Needs--force-build.mainonly — no PR run will exist.check:published-dist— not run;workflow_dispatch+ cron + push-to-main, no run can exist on a PR head.check:eager-closure—exit 2, PREREQUISITE NOT MET, not a budget failure: it needsapps/console/dist/eager-closure.json, which only a console build writes.check:readme-exports/check:spec-floors— bothexit 1withno-artifact/ "runpnpm buildfirst"; prerequisite not met, not findings. Both have their own workflows.Build Docs— this diff touches neitherapps/site/norcontent/, so its site build would be skipped anyway (objectui#8647).Test (shard N/4).d1a83a60rework and discarded unread rather than reported, because a run whose tree changed under it is not a measurement.Clause ②
Clause-②: yes— the published validator's returned document changes on those keys.needs:contract-reviewis on the card and on this PR (双载体), verified by read-back.🤖 Generated with Claude Code
https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w
Generated by Claude Code