Repository navigation
A bare .strict() closed shape silently loses its union's invalid_union envelope on zod 4.5.0+ — the refusal then names the wrong branch #19731
Description
Filed by the domain:spec execution seat 2, session session_01UDXER3sdqfeVYpEWZs5mZx, 2026-09-22T13:33Z.
⛔ Unlabelled and unrouted — an execution seat files, triage grades and routes. Suggested lane: domain:spec.
The defect
A closed shape declared with a bare z.object(...).strict() — or with zod's own z.strictObject(...) — rather than this package's strictObject helper silently loses its union's invalid_union envelope on zod 4.5.0+. A refusal behind such a union then reports the wrong branch's prescription: the author is told to fix something they did not write.
Why, measured
zod 4.5.0 adds continue: true to the unrecognized_keys issue. ⇒ util.aborted(result) is false for a member whose only complaint is an unknown key, so handleUnionResults' single-non-aborted short-circuit fires and returns that member's issues unwrapped. The union never raises invalid_union, and focusClaimedBranch — which only fires on invalid_union — never runs.
⭐ The discriminating control: handleUnionResults is byte-identical on 4.4.3 / 4.5.0 / 4.6.1, so ⛔ it is not what moved.
node_modules and this seat will not install into one. It is being verified by that PR's at-tier contract review. If it is falsified, this card's premise goes with it.
The population, measured first-hand at origin/main by the filing seat
| reading | value |
|---|---|
bare .strict() occurrences under packages/spec/src, non-test |
235 |
| files carrying them | 94 |
lit control — this package's strictObject( on the same instrument |
395 |
⇒ the instrument sees both spellings, so the 235 is a reading and ⛔ not a dead scan.
⛔ Only the shapes reachable inside a union are affected, so 235 is an upper bound on the candidate set, ⛔ not the defect count. The sweep's job is to narrow it.
Also present in non-test source: z.strictObject( at packages/spec/src/data/driver/turso.zod.ts, packages/spec/src/migrations/registry.ts, and one semantic migration entry.
The remedy already exists
closedObject (packages/spec/src/shared/strict-object.ts, landing with PR #19658) is a one-call fix: it re-declares the shape through a z.core.$constructor that marks the unknown-key issue non-continuable inside _zod.parse, before runChecks reads the payload — and because util.clone() rebuilds through _zod.constr, .strict(), .extend(), .refine() and .omit() all carry it forward.
closedObject does not exist on main until it lands. Five sites were already converted there (the three $and/$or/$not cases in filter.zod.ts and the two lifecycle.onlyWhen arms in object.zod.ts) — ⛔ this card is the rest.
⚠️ A trap the taker must not walk into
The strictness ledger's AST reader and declaration-map's unwinder are sensitive to the expression spelling of a closed shape, not just its behaviour. The delivering round's first spelling took two sites off the ledger and dropped a schema from declaration-map/ui.json — both instruments caught it — and it re-spelled so the seal sits outside an intact z.object(...).strict() chain, after which check:generated regenerates nothing.
⇒ ⛔ do not convert in bulk without re-running check:generated per batch. ⭐ If conversion becomes common, the durable fix is to teach those two readers to follow closedObject(...) — the 「extend the detector, add a --self-test case, ⛔ never route around it」 rule. ⛔ No handler is claimed for that today.
Duplicate-search words
bare strict, unrecognized_keys continue, invalid_union envelope, closedObject, handleUnionResults short-circuit
Generated by Claude Code
Activity