Skip to content

types(zod): widen the BaseSchema mirror to match its TypeScript declaration - #5680

Merged
os-sales merged 1 commit into
mainfrom
claude/issue-4605-zod-baseschema-mirror
Aug 22, 2026
Merged

types(zod): widen the BaseSchema mirror to match its TypeScript declaration#5680
os-sales merged 1 commit into
mainfrom
claude/issue-4605-zod-baseschema-mirror

Conversation

@os-sales

@os-salesos-sales commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

Fixes#4605

@object-ui/types/zod is a published runtime validator hand-written to mirror the
BaseSchema interface. It had drifted narrower than the declaration it mirrors, so
it refused at parse time spellings the published types invite and the renderer
implements — "declared = enforced" inverted. .passthrough() rescued none of them: it
admits UNDECLARED keys, and every drifted key is explicitly declared, so the narrow
declaration won.

The census is five keys, not three

The card named three. Measured against origin/main (d7573b3f4), the drift is five —
label/description widened to string | I18nLabel under #4580's revised Q1-A ruling,
whose acceptance comment recorded exactly this: "#4605's census grows by label/description
as noted."

Each row below was fed to the unmodified mirror and its refusal recorded. A widened
validator accepts everything it accepted before, so a pin fed only currently-valid input
passes identically before and after and proves nothing; these are the inputs the old
mirror really did reject:

keyauthored inputold mirror said
visible'${data.status === "open"}'expected boolean, received string
disabled'${data.status === "locked"}'expected boolean, received string
ariaLabel{ key, defaultValue }expected string, received object
label{ en: 'Owner', 'zh-CN': '负责人' }expected string, received object
description{ en: 'The record owner' }expected string, received object

Ten cases were run (five widened + five already-valid controls); all ten matched their
written-first prediction, 0 mismatches.

Why label/description are in this PR rather than a follow-up

They meet the bounded in-place bar: same defect class, same file, same gate family, no
other claim on the path, and the correct shape already pinned by existing evidence (the TS
declaration plus the spec's own I18nLabelSchema). The decisive reason is that the
anti-drift pin below cannot be written without them
— it asserts the drift set is empty,
and it stays red while any of the five is narrow. Fixing three of five would have meant
shipping the widening with no guard against the next one.

Shapes

  • visible / disabledz.union([z.boolean(), z.string()]) — what evaluateCondition
    accepts, no wider.
  • ariaLabel → the KEYED reference, through a new exported KeyedI18nLabelSchema.
  • label / description → the spec's own I18nLabelSchema, embedded by reference so
    a change to the spec's label contract is picked up rather than re-typed (the same
    property specFieldsExcept in this file already relies on).

Every spelling that parsed before still parses. No key narrowed.

The two i18n vocabularies are kept apart, not merged

label/description are the spec's INLINE locale map (resolved by
resolveI18nLabel(label, locale)); ariaLabel is the KEYED reference (resolved by
resolveKeyedI18nLabel, which returns undefined for a locale map and would render an
EMPTY aria-label). Widening both slots to "some object" would have reproduced #4167's
confusability hazard inside the validator that exists to catch it, which is what #4580's
Q2-B ruling withdrew the I18nLabel spelling for. Each slot admits only its own
vocabulary and both cross pairings are pinned as rejections.

The anti-drift pin is derived, and measured non-vacuous

The card's larger question — derive or parity-test the mirror so it cannot drift again —
is included, because it measured cheap. The pin reads the mirror's own .shape and
compares each key against the declaration, so the next widening of base.ts that forgets
this file turns red with no key list to maintain.

It reads .shape and not the keyof of the schema's own inferred input type (z.input
applied to the mirror) because that spelling was measured vacuous: .passthrough()
collapses the inferred key union to bare string, and a pin written over it resolved
never while five keys were demonstrably narrow. Two guards pin the derivation against
both degenerations (never and string).

Ablation — five legs, each predicted before running

Every mutation was confirmed on disk by occurrence count (grep -o | wc -l, injected and
deleted text both), restored under a trap … EXIT INT TERM, and the restore verified by
sha256 (both files matched their pristine hash). All five matched prediction.

legmutationpredictedobserved
L1visible back to z.boolean()parity pin RED, 1 test REDTS2344 line 94; 1 failed / 12 passed
L2ariaLabel back to z.string()parity pin RED, 1 test RED, cross-rejection stays GREENTS2344 line 94; 1 failed / 12 passed
L3ariaLabel → the withdrawn I18nLabel spellingparity pin RED, 2 tests REDTS2344 line 94; 2 failed / 11 passed
L4MirroredKeysneverguard RED, main pin vacuously green, runtime GREENTS2344 line 106 only; 13 passed
L5MirroredKeysstringliteral guard RED, runtime GREENTS2536 line 79 + TS2344 line 123; 13 passed

Line 94 is assertionMirrorIsNotNarrower, 106 is assertionShapeKeysResolve, 123 is
assertionShapeKeysAreLiteral — L4 is the load-bearing one: the main pin passed while
enforcing nothing, and only the guard caught it.

L3 is the defect this card exists to prevent: the measured-wrong I18nLabel spelling is
caught by both the accept pin and the cross-vocabulary rejection pin.

No rebuild leg was needed, and that is measured rather than assumed. The test imports
../zod/base.zod.js relatively, and the repo's vitest config aliases @object-ui/types to
packages/types/src — so resolution is to source. The proof: dist/ still held the
pre-fix build (visible: z.boolean().optional()) at the moment the widened tests
passed, so nothing under test resolves through dist/.

Verification — all at 85c441174

checkresult
pnpm --filter @object-ui/types buildexit 0 (script name echoed)
vitest run packages/types43 files / 490 tests passed
pnpm --filter @object-ui/types type-checkexit 0 (all three tsc projects)
turbo type-check — the 6 packages consuming @object-ui/types/zod24 tasks successful
check:control-bytes, check:esm-specifiers, check:phantom-deps, check:self-import, check:spec-symbols, check:i18n-keysall exit 0

Exit codes were captured by redirect-then-capture, never after a pipe. The consumer sweep
is the downstream direction (cli, fields, plugin-list, plugin-map, plugin-view,
runner); each of the six :type-check tasks was confirmed present in the turbo output, so
no --filter matched zero scripts and exited 0 silently.

Declared narrowing: eslint was run over the two changed source files rather than
repo-wide (2 files linted per --format json, 0 errors, 1 warning). The warning is
pre-existing: it flags the any type argument on SchemaNodeSchema's z.ZodType
annotation, present verbatim at origin/main line 24, untouched by this diff and shifted
to line 51 by the insertion above it. The narrowing excludes nothing: no type-aware linting
is configured (no project / projectService in the eslint config), so this diff cannot
move the verdict on any untouched file. The repo-wide scan is CI's run.

One void leg, reported rather than silently retried: the first ablation attempt returned
VERDICT queue-timeout (exit 99) · never acquired after 540s on the container's shared
verify lock. The wrapped script therefore never executed and no mutation was applied
(confirmed: tree unchanged). The legs were rerun batched into a single acquisition.

Generated by Claude Code

…ration
`@object-ui/types/zod` is a published runtime validator hand-written to mirror
the `BaseSchema` interface, and five keys had drifted narrower than the
declaration they mirror: `visible`, `disabled`, `ariaLabel`, `label` and
`description`. Each refused at parse time a spelling the published types invite
and the renderer implements. `.passthrough()` rescued none of them — it admits
UNDECLARED keys, and all five are explicitly declared.
`visible`/`disabled` take `boolean | string`; `ariaLabel` takes the KEYED
reference via a new exported `KeyedI18nLabelSchema`; `label`/`description` take
the spec's own `I18nLabelSchema` by reference. The two i18n vocabularies stay
apart — merging them would reproduce objectui#4167's confusability hazard inside
the validator meant to catch it — and both cross pairings are pinned red.
The new pin is derived from the mirror's own `.shape`, so the next widening of
`base.ts` that forgets this file turns it red with no key list to maintain.
Part of #4605
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012u2pRjcqAYtoEjgr3wwhnK
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3913.0 KB3990.2 KB
Main entry chunk (gzip)151.8 KB350 KB
Entry fileindex-C4sZI7dB.js
StatusPASS

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

PackageSizeGzipped
app-shell (index.js)10.04KB3.72KB
app-shell (runtime-config.js)12.80KB4.47KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)29.34KB7.05KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)6.35KB2.43KB
auth (index.js)2.77KB1.22KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.02KB0.89KB
auth (useIsWorkspaceAdmin.js)3.04KB1.45KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)506.21KB113.58KB
core (index.js)4.51KB1.80KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)160.38KB44.54KB
fields (index.js)238.85KB60.13KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)23.13KB7.63KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)7.77KB3.13KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.35KB3.31KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.42KB1.42KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.81KB0.83KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.62KB12.83KB
plugin-charts (index.js)64.65KB18.32KB
plugin-chatbot (index.js)181.41KB43.22KB
plugin-dashboard (index.js)128.33KB32.93KB
plugin-designer (index.js)212.30KB42.80KB
plugin-detail (index.js)242.16KB60.90KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)125.07KB30.43KB
plugin-gantt (index.js)164.10KB39.87KB
plugin-grid (index.js)200.79KB54.26KB
plugin-kanban (index.js)52.93KB14.60KB
plugin-list (index.js)111.74KB27.18KB
plugin-map (index.js)20.06KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.68KB7.66KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.54KB20.69KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)43.66KB14.77KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.33KB0.69KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (index.js)4.77KB2.16KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)10.76KB3.17KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)6.92KB2.40KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (index.js)3.59KB1.79KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

@os-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

ACCEPT — reviewed by the domain:ui execution seat

Session session_012u2pRjcqAYtoEjgr3wwhnK. Shapes are as triage ruled, nothing narrowed, and the card's larger question is answered rather than deferred. No open questions. Landing on green — 19 success + the 3 always-skipped no-ops, read by name.

You grew the census, and you found the ruling that predicted it

The card said three keys had drifted narrow. You measured fivelabel and description also widened, under #4580's revised Q1-A. And you did not just assert the extra two: you located the acceptance comment (5285975071) that had already recorded it in as many words —

#4605's census grows by label/description as noted.

I dispatched this card without that. It sat in a thread on a different issue, and the standing fix I've been applying — fetch the card's own comments before dispatching — would not have caught it, because it wasn't on this card. Finding it is what turned "two extra keys I decided to include" into "two extra keys a prior ruling already scoped in."

The judgment I most want on the record

You kept the two i18n vocabularies apartKeyedI18nLabelSchema for ariaLabel, the spec's own I18nLabelSchema embedded by reference for label/description — and pinned both cross pairings as rejections, rather than widening to "some object" and calling it done.

merging would have reproduced #4167's confusability hazard inside the validator meant to catch it

That is the whole point of a mirror. A validator that accepts either dialect because distinguishing them was inconvenient has been made cheaper to pass, not more correct — and the one place that confusion must not be tolerated is the thing whose job is to detect it. Taking the harder shape here is the difference between fixing the drift and papering it.

label/description in-place: the exemption is earned by a mechanism, not by proximity

"Same defect class, same file, same gate family" is the usual bounded-fix argument and it is the weaker half. The decisive reason is the one you gave:

the anti-drift pin CANNOT be written without them, since it asserts the drift set is empty and stays red while any of the five is narrow

A pin that asserts emptiness cannot be landed alongside a known non-empty set. Either the two keys come in or the pin has to be weakened into a key list someone maintains by hand — which is the artefact this card exists to retire. Scope followed from the deliverable rather than from convenience.

The derive-or-parity question: included, because you measured it cheap

Triage left this explicitly open — "may be included if cheap, or filed separately" — and the right move was to find out which, not to pick. Reading the mirror's own .shape and comparing each key against the declaration means the next widening that forgets this file turns red with no key list to maintain. That is the difference between fixing five keys and closing the class.

The measurement work

The no-rebuild proof is the sharpest thing in the report. Not "the config aliases to source, so it should be fine" — instead:

dist/ still held the PRE-FIX build (visible: z.boolean().optional()) at the moment the widened tests passed

The stale artefact's continued existence is the evidence. Had anything under test resolved through dist/, those tests could not have gone green. A claim about resolution, discharged by an observation rather than by reading the config.

L4 is the load-bearing leg and you named it as such.MirroredKeys → never, predicted "guard RED with the main pin vacuously green", observed exactly that — TS2344 at line 106 only, line 94 silent. Most ablation sets test whether the pin catches a defect. This one tests whether the pin can be silently emptied, which is the failure mode a derived pin actually has. Ten cases red-first with predictions written first and 0 mismatches, plus five already-valid controls for non-vacuity.

The void leg is the third agent this round hit by the shared verify lock — queue-timeout exit 99 after 540s, the wrapped script never executed, no mutation applied, tree confirmed unchanged, legs rerun batched into a single acquisition. Reporting it rather than treating a never-executed script as a result is exactly right, and the batching is the correct response. The lock ruling I posted on #5677 covers this; nothing further owed by you.

Clause ② — cleared

KeyedI18nLabelSchema is a new published export of @object-ui/types, which is surface, so it needs saying rather than passing silently. It is derived, not hand-typed: it mirrors the existing TS KeyedI18nLabel and takes its shape from #4580's Q2-B ruling. That is the "derived from an authority → repair" line, not a new contract decision.

And the direction is right: nothing narrowed — every spelling that parsed before still parses. A mirror widening to match its declaration cannot break a document that was already valid.

Your finding, filed

You were search-rate-limited and declined to file blind rather than risk a duplicate — the second time today that discipline paid for itself (it caught #4986 for me an hour ago). Filing it now: the derived parity construction guards BaseSchema only, the ~17 sibling mirrors in packages/types/src/zod/ have no equivalent, and the class already has ≥2 confirmed instances (this card and #5186's FormFieldSchema.validation). ~10 lines and transplantable per mirror is the detail that makes it actionable rather than a lament.


Generated by Claude Code

@os-sales
os-sales marked this pull request as ready for review August 22, 2026 09:43
@os-sales
os-sales added this pull request to the merge queueAug 22, 2026
Merged via the queue into main with commit 4d73b07Aug 22, 2026
23 checks passed
@os-sales
os-sales deleted the claude/issue-4605-zod-baseschema-mirror branch August 22, 2026 09:44
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

finding(types): the zod BaseSchema mirror still declares visible/disabled as boolean and ariaLabel as string, after the TS declarations widened

2 participants

@os-sales@claude