Skip to content

fix(types): strip the imported defaults at the spec import boundary — this mirror authors no default, imported subschemas included (objectui#8317) - #8721

Merged
os-justin merged 2 commits into
mainfrom
claude/issue-8317-strip-imported-defaults
Sep 9, 2026
Merged

os-justin merged 2 commits into
mainfrom
claude/issue-8317-strip-imported-defaults

Conversation

@os-warren

@os-warren os-warren commented Sep 9, 2026 •

Copy link
Copy Markdown
Collaborator

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 safeValidateSchema answers, not only the 41 this repo authored. The ZodDefault nodes imported by reference from @objectstack/spec are 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:

file reads result.data?
packages/cli/src/commands/validate.ts yes, line 65
packages/cli/src/commands/check.ts no — .success only
packages/plugin-map/src/ObjectMap.tsx no — .success / .error, returns the raw authored config

Positive 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:65 reads data.type, data.id, data.label, data.title, data.children, each presence-guarded. Probed across all 107 arms of AnyComponentSchema: 450 root members named type|id|label|title|children inspected, 0 carrying a ZodDefault.

Positive control: the same probe, pointed at active / isDefault / kind — 4 inspected, 3 carry a ZodDefault, 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/types in 4 / 31 / 9 production files of those three directories, so it is reaching them; packages/app-shell also 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:248 navigation?.mode ?? 'page', InterfaceListPage.tsx:504 userActions.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 ZodDefault graph walk over every schema exported by the barrel), on da5e4f69 (this branch's merge-base with main):

face ZodDefault nodes, before after
the tolerant face — what safeValidateSchema runs 57 0
the derived strict authoring face (StrictAnyComponentSchema, objectui#8345) 57 0
whole barrel 114 0

⚠️ 114, not 57, is what the instrument returns today, and the difference is not drift: objectui#8345 landed a derived clone of the whole tolerant tree after the card was measured, so every node is reachable twice. The card's 57 is the tolerant face, exactly. Both go to zero, because the strict face derives from the tolerant one.

Nodes walked: 7,753 → 7,752, unreachable: [] on both.

The reproducer, both directions

safeValidateSchema({ type: 'object-view', objectName: 'account', navigation: {} })
before → navigation: { mode: 'page', preventNavigation: false, openNewTab: false, size: 'auto' }
after  → navigation: {}                                            ← the author's document

safeValidateSchema({ …, navigation: { mode: 'page', preventNavigation: false, openNewTab: false, size: 'auto' } })
after  → unchanged, key for key                                    ← still DECLARED, still accepted

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 for ListColumnSchema.prefix and for object-view's navigation slot.

Measured live on the other named families too (app active/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

  • Permanent differential, imported-defaults-8317.test.ts: all 28 imported spec schemas answer 20 probes exactly as the raw @objectstack/spec schema does. Both sides are importable, so this is a pin rather than a one-off.
  • Omissibility: for every member of every schema that carried a default, _zod.optin is 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.
  • Nothing else changes: a parallel walk compares node type, shape keys, union arm count and def.checks at 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.
  • Corpus differential (one-off, against a materialised pre-change face): 1,077 documents from 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.
  • ⛔ The spec's own objects are not mutated — pinned: after the strip has run, the raw @objectstack/spec schemas still carry their defaults (9 in AppSchema, 17 in DashboardSchema, 34 in ListViewSchema, …). 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 on strict-authoring-face.ts: each ZodDefault is replaced by .removeDefault()'s inner type, re-optionalised; objects are cloned by patching a copy of _zod.def and calling their own constructor, ⛔ never rebuilt with z.object(shape) (that drops def.checks); callable $ZodObjectJIT nodes 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/spec adopts 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).

⚠️ The z.lazy arm is the one place it cannot hold. Measured and pinned rather than hand-waved: 3 reachable lazy nodes, 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:

The first cut re-bound each import to a local const Spec… = stripImportedDefaults(Imported…). That turned check:spec-symbols red (16 findings) — the gate reads exactly one hop, so a mirror export under a spec-owned name must show the spec import binding in its own initializer, and the intermediate const put it one hop away. Root-caused by ablation: green (exit 0) at da5e4f69, red (exit 1) at 99bde74a — the mutation was proved on disk (blob hash differed, stripImportedDefaults count 0 in the reverted file) and the restore proved by an empty git diff HEAD. ⛔ Neither gate surgery nor 16 ALLOW entries (the gate's own header names a large ALLOW map as the anti-pattern): the repair is to wrap at the crossing, where the provenance is what the gate — and a reader — looks for. d1a83a60 is that rework; check:spec-symbols is exit 0.

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

pin was now
zod-mirror-authors-no-defaults-7735.test.ts residue > 0, "NOT this repository's to remove" ratchet at 0 — a floor over another package's contents moved with every bump; zero does not, because a default added upstream arrives through the same boundary
spec-subschema-parity.test.ts ×6 re-exports are the raw spec object are stripImportedDefaults(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 not
spec-subschema-parity.test.ts prefix asserted the substitution ({ field } → { field, type: 'text' }) asserts the author's document, and the round-trip of one that writes type
object-view-unmirrored-keys-7779.test.ts ×2 slot is the raw spec slot; mode parses to 'page' slot is the entered spec slot; mode stays absent — and every probe is compared against the RAW spec slot too, which is the measurement that says the strip moved no accept set
report-chart-query-spec-parity.test.ts read valueKey/labelKey/persist back out of parse output asserts they stay declared and are no longer written. Its own comment already said the important half: "a materialised default is indistinguishable from an authored value" — that indistinguishability is what this card removes

Verification — exit codes captured before any pipe

what result
vitest run packages/types/ exit 0 — Test Files 153 passed (153), Tests 3013 passed (3013)
pnpm --filter @object-ui/types type-check (tsc --noEmit + tsconfig.examples.json + tsconfig.test.json) exit 0
pnpm --filter @object-ui/types build + check:dist-completeness exit 0 — 128 emitted files verified
pnpm check:spec-symbols (runs in ci.yml) exit 0 — 1358 files vs 5050 spec export names
pnpm check (runs in lint.yml:481) exit 0 — 628 files analysed
npx eslint . — whole repo, not narrowed exit 0 — 4575 files judged, 0 errors, 12349 warnings (known non-blocking debt per AGENTS.md; type-aware linting is not enabled, projectService count 0)
check:control-bytes · self-import · phantom-deps · unused-deps · unreferenced-sources · handler-key-reads · side-effects-array · published-tsconfig-exclude · esm-specifiers · entry-guard exit 0 each
node scripts/check-changeset-presence.mjs exit 0 — 16 changed files, 1 changeset
check:governed-queue-guard --test (the 16 changed paths) NOT GOVERNED — an ordinary PR
every package that touches the zod barrel — vitest run over the 20 dirs that reference @object-ui/types/zod (apps/console, packages/app-shell, cli, core, fields, react-consumers, all 11 plugin-*, runner, scripts, both examples/) exit 0 — Test Files 1926 passed / 2 skipped (1928), Tests 25487 passed / 3 skipped
full suite vitest run --shard=1/4 exit 0 — 696 passed, 1 skipped (697 files); 9256 passed, 2 skipped

⚠️ pnpm check prints 3 warnings (two filter-builder catalog schemas and the vscode JSON schema "did not validate"). Pre-existing, not this change: the corpus differential above covers examples/schema-catalog and 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/cache replayed @object-ui/auth and @object-ui/react-runtime from 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. ⚠️ This gate is cron + push-to-main only — 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 needs apps/console/dist/eager-closure.json, which only a console build writes.
  • check:readme-exports / check:spec-floors — both exit 1 with no-artifact / "run pnpm build first"; prerequisite not met, not findings. Both have their own workflows.
  • Build Docs — this diff touches neither apps/site/ nor content/, so its site build would be skipped anyway (objectui#8647).
  • Full 4-shard suite — shard 1/4 measured green above, and the consumer sweep above covers every package that references the barrel; shards 2–4 in full are CI's Test (shard N/4). ⚠️ The consumer sweep is the reading that matters for a published-behaviour change, and it is on the FINAL head: an earlier sweep was started before the d1a83a60 rework and discarded unread rather than reported, because a run whose tree changed under it is not a measurement.
  • Browser / dogfood — none. This is a validator-output change with no rendering path (the census establishes that no renderer reads a parse result).

Clause ②

Clause-②: yes — the published validator's returned document changes on those keys. needs:contract-review is 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

os-warren and others added 2 commits September 9, 2026 00:17
…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
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 50 chunks) 3479.6 KB 3512.7 KB
Main entry chunk (gzip) 143.9 KB 350 KB
Entry file index-BzGfBMza.js —
Status PASS —

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

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 15.67KB 5.75KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 499.42KB 114.32KB
core (index.js) 7.48KB 2.96KB
create-plugin (index.js) 10.12KB 3.28KB
data-objectstack (index.js) 198.39KB 55.29KB
fields (index.js) 244.96KB 61.76KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 6.57KB 2.76KB
i18n (index.js) 3.65KB 1.47KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.84KB 10.94KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 4.39KB 1.66KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.52KB 4.88KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.39KB 3.10KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 15.16KB 3.68KB
plugin-calendar (index.js) 49.00KB 13.91KB
plugin-charts (index.js) 71.39KB 19.92KB
plugin-chatbot (index.js) 194.53KB 46.34KB
plugin-dashboard (index.js) 131.43KB 34.44KB
plugin-designer (index.js) 215.51KB 44.29KB
plugin-detail (index.js) 251.39KB 65.04KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 134.16KB 33.47KB
plugin-gantt (index.js) 167.16KB 40.99KB
plugin-grid (index.js) 208.18KB 56.62KB
plugin-kanban (index.js) 55.44KB 15.73KB
plugin-list (index.js) 112.73KB 27.69KB
plugin-map (index.js) 20.49KB 6.83KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.42KB 11.92KB
plugin-timeline (index.js) 30.10KB 8.74KB
plugin-tree (index.js) 9.33KB 3.25KB
plugin-view (index.js) 84.54KB 20.84KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 81.07KB 26.86KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.55KB 2.45KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 13.64KB 4.59KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 14.27KB 5.47KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Copy link
Copy Markdown
Collaborator

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.

.removeDefault() drops optionality. In Zod, .default(v) is what makes a member omissible; z.string().default('x').removeDefault() is z.string() — required. A strip that stopped at .removeDefault() would have flipped all 57 keys from omissible to mandatory and started rejecting documents that validate today, on a barrel three production packages import. The boundary restores it, and distinguishes the two spellings instead of blanket-wrapping:

const inner = walk(schema.removeDefault());
out = isAlreadyOptional(inner) ? inner : z.optional(inner);

with isAlreadyOptional reading _zod.optin === 'optional', so ZodDefault(ZodOptional(T)) (the .optional().default(v) spelling) is left alone and a bare ZodDefault(T) is re-optionalised. That is why "the accept set does not move" is a claim this diff can actually make.

The test file is built the way I would have asked for and did not have to. What I checked rather than read:

  • Vacuity is guarded on both branches, before either is exercised — CARRIES_DEFAULT.length > 5 and CARRIES_NONE.length > 1, titled "so neither branch below is vacuous". The identity half of a walk is exactly where a silent no-op hides.
  • the RAW spec schemas still carry their defaults after the strip has run — the pin against mutating the upstream. Zod schema objects are shared references; a walk that rebuilt in place would have reached into @objectstack/spec's own exports and been invisible from this side.
  • A parallel walk compares node types, checks length and union arm counts, with ${name}${path}: a check was DROPPED by the walk as the message — so a .refine() lost in traversal is named, not merely counted — and then asserts "the parallel walk covered the whole imported population, not a corner of it".
  • A census that no @objectstack/spec value read in the mirrors bypasses the boundary, whose declared exceptions must still exist and still read a RAW binding — an exception list that cannot go stale in either direction.
  • ⭐ the walker docblock's 'lazy' count is re-derived, not quoted. A docblock number that a test re-derives is the opposite of the defect class this session has been filing findings about all day.

The reversibility argument is the right one. Not touching @objectstack/spec (1,546 call sites on another repository's release train) and instead making the boundary the identity function on a subtree with nothing to strip means the day upstream adopts the same principle, every strip becomes a no-op and nothing here needs unwinding. That is a fork avoided, not deferred.

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.

⚠️ One asymmetry, recorded not blocking. The docblock and changeset both state 57 ZodDefault nodes, and nothing asserts 57 — the tests assert > 5, > 1, and === 0 after the strip. That is the same shape as the lazy count the neighbouring test explicitly refuses to quote. Here the asymmetry is defensible and I would not change the tests: pinning 57 would tie this repo's suite to another repository's release train and redden on any upstream minor, which is churn rather than signal. But the number should read as a snapshot rather than as something measured continuously — one clause in the docblock, whenever that file is next touched.

The migration note earns its place by naming the fallbacks that already exist (navigation?.mode ?? 'page', userActions.search !== false) instead of asserting that consumers will cope, and by backing it with a consumer census: three production importers, one reading result.data at all, reading only keys that carry no default on any of the 107 arms.


Generated by Claude Code

Merged via the queue into main with commit 645087c Sep 9, 2026
35 checks passed
@os-justin
os-justin deleted the claude/issue-8317-strip-imported-defaults branch September 9, 2026 02:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants