stripImportedDefaults drops the .describe() of every imported default. Measured across the whole published protocol surface: 110 of 110 described ZodDefault members lose their description, 0 survive.
Provenance
Found by the contract reviewer of PR #9017 (card #8992) and declared out of scope for that PR. Filed here as its own card rather than ridden in — the PR's diff is three keys, this is the whole boundary. The numbers below are the seat's own re-measurement, not the reviewer's; they came out larger than the reviewer's PR-scoped "10 of 11".
The mechanism, from source
packages/types/src/zod/imported-defaults.ts, the case 'default': arm of the walker (re-derive by content — anchors drift; it was at :200-202 when this was written):
case 'default': {
const inner = walk((schema as unknown as { removeDefault: () => z.ZodType }).removeDefault());
out = isAlreadyOptional(inner) ? inner : z.optional(inner);
break;
}
.removeDefault() returns the node's inner type. The protocol's spelling is .describe(...) applied after .default(...), so the description lives on the outer ZodDefault node — the node this line discards. Nothing carries it onto the replacement.
Measurement — spec 17.4.0, whole surface
Script: desc-loss-v3.mjs / desc-loss-split.mjs (seat scratchpad). It walks every schema-shaped export of every subpath export of the installed @objectstack/spec tarball and replays the two operations the arm performs — .removeDefault(), then z.optional(...) when the result is not already optional-in.
| reading |
value |
| subpath exports declared / loaded |
18 / 17 |
| schema-shaped exports walked |
246 |
| object shape members visited |
811 |
members that are ZodDefault |
116 |
... of those, carrying a .description |
110 |
| ... description LOST through the strip |
110 |
| ... description kept |
0 |
The 18th subpath is ./openapi.json — not a module, excluded explicitly rather than swallowed by the catch.
Where the loss happens, split by step:
| step |
count |
why |
lost at .removeDefault() |
110 |
the description sat on the OUTER ZodDefault |
lost at the z.optional() re-wrap |
0 |
— |
| survived both |
0 |
— |
One mechanism, not two. (The re-wrap would also lose a description, if any key were spelled .describe().default() — none is. Probe: z.optional(z.string().describe('D')).description === undefined.)
Instrument controls. Positive: a hand-built z.object({ k: z.string().default('x').describe('D') }) is detected by the same predicate (type === 'default'), reads "D" before and undefined after. Negative: the same traversal for a token that cannot exist returns 0. An earlier run of this instrument returned 0 ZodDefault members — that was the instrument loading only the root export (14 schema-shaped exports, 38 members); the distribution print showed no default node anywhere, which is what sent me to the subpath exports. Recorded because a bare 0 from this script is not evidence without the export count beside it.
Pull — measured, and it is currently ZERO
⛔ Do not dispatch this on the strength of the 110 alone. Nothing in objectui reads a zod node's .description in shipping code today:
- Non-test readers of
shape[...].description: 0. The three hits outside __tests__/ are flow-canvas-seeds.spec-parse.test.tsx ×2 and flow-node-config.spec-reconciliation.test.ts ×1 — tests that merely live outside the __tests__/ directory. The other 25 are retirement/tombstone tests reading objectui's own retiredKey(...) guidance strings, which never pass through the strip.
- The five
z.toJSONSchema emitters bypass the boundary. packages/app-shell/src/views/metadata-admin/{view,page,package,dashboard,report}-schema.ts feed SchemaForm — the surface where a JSON-Schema description becomes the field's help text — but every one of them imports from @objectstack/spec/ui or @objectstack/spec/kernel directly, not from @object-ui/types. They see the unstripped schemas, so they see the descriptions. This was the hypothesised consumer and it is refuted.
So the defect is real and latent, not live. It bites the day any emitter, doc generator, or inspector is pointed at @object-ui/types instead of at the protocol — at which point 110 keys silently lose their help text with no gate to catch it.
Why it is still a defect, in the module's own words
The module's docblock states an identity property: a subtree with no ZodDefault in it comes back reference-equal, so the module "is exactly the identity function the day @objectstack/spec adopts the same principle, with nothing to roll back." A subtree that does contain a ZodDefault currently comes back less faithful than it went in — it loses documentation the protocol declared. That is a departure from the property the module claims for itself, not a preference.
It also matters for axis ③ (防 AI 写元数据 app 犯错): the descriptions are the protocol's own guidance to whoever authors a metadata document. Dropping them at the import boundary is a silent narrowing of what the boundary conveys, in the forbidden direction (narrower than the protocol).
Candidate fix, probed
One guarded line in the default arm — carry the outer node's description onto the replacement:
case 'default': {
const inner = walk(schema.removeDefault());
const next = isAlreadyOptional(inner) ? inner : z.optional(inner);
out = schema.description === undefined ? next : next.describe(schema.description);
break;
}
Probe on zod 4.4.3: z.optional(src.removeDefault()).describe(src.description).description === "DESC"; the result still parses undefined (true) and 'a' (true) and carries no default (defOf(...).type !== 'default'). So the accept-set and the strip are both unchanged — only the description is preserved.
⚠️ Not measured, and a dev must settle it before touching this: whether adding a .describe() breaks the reference-equality half of the identity property for the unchanged(...) short-circuit above (a described node is a new node, but it was already a new node here), and whether zod-mirror-authors-no-defaults-7735 / imported-defaults-8317 assert anything about the description of a stripped member.
What was NOT measured
- Whether spec 17.5.x/17.6.x changes any of the 110 (measured against the 17.4.0 tarball installed in a local worktree).
- The
@object-ui/types build — this replays the two operations against the protocol tarball rather than importing objectui's stripped bindings.
- Any consumer outside this repo.
Refs objectui#8992 · PR objectui#9017 (where the reviewer found it and correctly refused to ride it).
Generated by Claude Code
stripImportedDefaultsdrops the.describe()of every imported default. Measured across the whole published protocol surface: 110 of 110 describedZodDefaultmembers lose their description, 0 survive.Provenance
Found by the contract reviewer of PR #9017 (card #8992) and declared out of scope for that PR. Filed here as its own card rather than ridden in — the PR's diff is three keys, this is the whole boundary. The numbers below are the seat's own re-measurement, not the reviewer's; they came out larger than the reviewer's PR-scoped "10 of 11".
The mechanism, from source
packages/types/src/zod/imported-defaults.ts, thecase 'default':arm of the walker (re-derive by content — anchors drift; it was at:200-202when this was written):.removeDefault()returns the node's inner type. The protocol's spelling is.describe(...)applied after.default(...), so the description lives on the outerZodDefaultnode — the node this line discards. Nothing carries it onto the replacement.Measurement — spec 17.4.0, whole surface
Script:
desc-loss-v3.mjs/desc-loss-split.mjs(seat scratchpad). It walks every schema-shaped export of every subpath export of the installed@objectstack/spectarball and replays the two operations the arm performs —.removeDefault(), thenz.optional(...)when the result is not already optional-in.ZodDefault.descriptionThe 18th subpath is
./openapi.json— not a module, excluded explicitly rather than swallowed by thecatch.Where the loss happens, split by step:
.removeDefault()ZodDefaultz.optional()re-wrapOne mechanism, not two. (The re-wrap would also lose a description, if any key were spelled
.describe().default()— none is. Probe:z.optional(z.string().describe('D')).description === undefined.)Instrument controls. Positive: a hand-built
z.object({ k: z.string().default('x').describe('D') })is detected by the same predicate (type === 'default'), reads"D"before andundefinedafter. Negative: the same traversal for a token that cannot exist returns 0. An earlier run of this instrument returned0 ZodDefault members— that was the instrument loading only the root export (14 schema-shaped exports, 38 members); the distribution print showed nodefaultnode anywhere, which is what sent me to the subpath exports. Recorded because a bare0from this script is not evidence without the export count beside it.Pull — measured, and it is currently ZERO
⛔ Do not dispatch this on the strength of the 110 alone. Nothing in objectui reads a zod node's
.descriptionin shipping code today:shape[...].description: 0. The three hits outside__tests__/areflow-canvas-seeds.spec-parse.test.tsx×2 andflow-node-config.spec-reconciliation.test.ts×1 — tests that merely live outside the__tests__/directory. The other 25 are retirement/tombstone tests reading objectui's ownretiredKey(...)guidance strings, which never pass through the strip.z.toJSONSchemaemitters bypass the boundary.packages/app-shell/src/views/metadata-admin/{view,page,package,dashboard,report}-schema.tsfeedSchemaForm— the surface where a JSON-Schemadescriptionbecomes the field's help text — but every one of them imports from@objectstack/spec/uior@objectstack/spec/kerneldirectly, not from@object-ui/types. They see the unstripped schemas, so they see the descriptions. This was the hypothesised consumer and it is refuted.So the defect is real and latent, not live. It bites the day any emitter, doc generator, or inspector is pointed at
@object-ui/typesinstead of at the protocol — at which point 110 keys silently lose their help text with no gate to catch it.Why it is still a defect, in the module's own words
The module's docblock states an identity property: a subtree with no
ZodDefaultin it comes back reference-equal, so the module "is exactly the identity function the day@objectstack/specadopts the same principle, with nothing to roll back." A subtree that does contain aZodDefaultcurrently comes back less faithful than it went in — it loses documentation the protocol declared. That is a departure from the property the module claims for itself, not a preference.It also matters for axis ③ (防 AI 写元数据 app 犯错): the descriptions are the protocol's own guidance to whoever authors a metadata document. Dropping them at the import boundary is a silent narrowing of what the boundary conveys, in the forbidden direction (narrower than the protocol).
Candidate fix, probed
One guarded line in the
defaultarm — carry the outer node's description onto the replacement:Probe on zod 4.4.3:
z.optional(src.removeDefault()).describe(src.description).description === "DESC"; the result still parsesundefined(true) and'a'(true) and carries no default (defOf(...).type !== 'default'). So the accept-set and the strip are both unchanged — only the description is preserved..describe()breaks the reference-equality half of the identity property for theunchanged(...)short-circuit above (a described node is a new node, but it was already a new node here), and whetherzod-mirror-authors-no-defaults-7735/imported-defaults-8317assert anything about the description of a stripped member.What was NOT measured
@object-ui/typesbuild — this replays the two operations against the protocol tarball rather than importing objectui's stripped bindings.Refs objectui#8992 · PR objectui#9017 (where the reviewer found it and correctly refused to ride it).
Generated by Claude Code