Skip to content

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

@os-warren

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.

⚠️ Provenance and its limit: this mechanism is the reading of the round that delivered PR #19658, ⛔ not re-derived by the filing seat — the shared checkout has no 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.

⚠️ One discrepancy stated rather than smoothed over: the delivering round reported 95 files; this seat measures 94 with its own pathspec. ⛔ Neither number is adopted as settled — whoever takes this re-derives the population and says which pathspec it used.

⛔ 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.

⚠️ Blocked-by PR #19658: 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:apiThe API a customer can call, and integrations — REST, connectors, webhooks, jobsdomain:specpriority:p2Medium: important, M3

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions