…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
Fixes#4605
@object-ui/types/zodis a published runtime validator hand-written to mirror theBaseSchemainterface. It had drifted narrower than the declaration it mirrors, soit refused at parse time spellings the published types invite and the renderer
implements — "declared = enforced" inverted.
.passthrough()rescued none of them: itadmits 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/descriptionwidened tostring | I18nLabelunder #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:
visible'${data.status === "open"}'expected boolean, received stringdisabled'${data.status === "locked"}'expected boolean, received stringariaLabel{ key, defaultValue }expected string, received objectlabel{ en: 'Owner', 'zh-CN': '负责人' }expected string, received objectdescription{ en: 'The record owner' }expected string, received objectTen cases were run (five widened + five already-valid controls); all ten matched their
written-first prediction, 0 mismatches.
Why
label/descriptionare in this PR rather than a follow-upThey 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 theanti-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/disabled→z.union([z.boolean(), z.string()])— whatevaluateConditionaccepts, no wider.
ariaLabel→ the KEYED reference, through a new exportedKeyedI18nLabelSchema.label/description→ the spec's ownI18nLabelSchema, embedded by reference soa change to the spec's label contract is picked up rather than re-typed (the same
property
specFieldsExceptin 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/descriptionare the spec's INLINE locale map (resolved byresolveI18nLabel(label, locale));ariaLabelis the KEYED reference (resolved byresolveKeyedI18nLabel, which returnsundefinedfor a locale map and would render anEMPTY 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
I18nLabelspelling for. Each slot admits only its ownvocabulary 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
.shapeandcompares each key against the declaration, so the next widening of
base.tsthat forgetsthis file turns red with no key list to maintain.
It reads
.shapeand not thekeyofof the schema's own inferred input type (z.inputapplied to the mirror) because that spelling was measured vacuous:
.passthrough()collapses the inferred key union to bare
string, and a pin written over it resolvedneverwhile five keys were demonstrably narrow. Two guards pin the derivation againstboth degenerations (
neverandstring).Ablation — five legs, each predicted before running
Every mutation was confirmed on disk by occurrence count (
grep -o | wc -l, injected anddeleted text both), restored under a
trap … EXIT INT TERM, and the restore verified bysha256 (both files matched their pristine hash). All five matched prediction.
visibleback toz.boolean()ariaLabelback toz.string()ariaLabel→ the withdrawnI18nLabelspellingMirroredKeys→neverMirroredKeys→stringLine 94 is
assertionMirrorIsNotNarrower, 106 isassertionShapeKeysResolve, 123 isassertionShapeKeysAreLiteral— L4 is the load-bearing one: the main pin passed whileenforcing nothing, and only the guard caught it.
L3 is the defect this card exists to prevent: the measured-wrong
I18nLabelspelling iscaught 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.jsrelatively, and the repo's vitest config aliases@object-ui/typestopackages/types/src— so resolution is to source. The proof:dist/still held thepre-fix build (
visible: z.boolean().optional()) at the moment the widened testspassed, so nothing under test resolves through
dist/.Verification — all at
85c441174pnpm --filter @object-ui/types buildvitest run packages/typespnpm --filter @object-ui/types type-checktscprojects)turbo type-check— the 6 packages consuming@object-ui/types/zodcheck:control-bytes,check:esm-specifiers,check:phantom-deps,check:self-import,check:spec-symbols,check:i18n-keysExit 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-checktasks was confirmed present in the turbo output, sono
--filtermatched 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 ispre-existing: it flags the
anytype argument onSchemaNodeSchema'sz.ZodTypeannotation, present verbatim at
origin/mainline 24, untouched by this diff and shiftedto line 51 by the insertion above it. The narrowing excludes nothing: no type-aware linting
is configured (no
project/projectServicein the eslint config), so this diff cannotmove 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 acquiredafter 540s on the container's sharedverify 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