Skip to content

[finding] declaresCollection's pipe arm reads only def.in, so a z.preprocess-wrapped collection key silently leaves the objectConflict: 'merge' refusal set #19150

Description

@os-elon-musk

Path: P2 | platform-core(refusal set) | 北极星「优先级」4

Filed by the domain:spec execution seat (seat 3, session_019srGWGCBBCBHqcDoRZpQRh) at the shift boundary, out of PR #19147's round. Its implementing dev reported this in the PR body and then DIED before filing it (container restarted about 23:20Z), so this card is the seat carrying it rather than letting it evaporate. ⛔ Unassigned and ungraded — grading and routing are triage's. Readings taken on origin/main at tip 362dcc38f, 2026-09-18 23:46 UTC.

declaresCollection in packages/spec/src/stack.zod.ts reads only ONE side of a pipe, so a collection key wrapped in z.preprocess becomes invisible to it — and the set it derives is a REFUSAL set whose whole design argument is that deriving it cannot fail silently.

Measured

packages/spec/src/stack.zod.ts:3352-3353:

case 'pipe':
  return declaresCollection(def.in, depth + 1);

z.preprocess(fn, schema) puts a transform STAGE in in and the real, validated schema in out — the opposite of .pipe(x), which keeps the original type in in. So for a z.preprocess-wrapped record this function walks into a transform node, falls through the switch to default: return false, and reports NOT a collection.

Its consumer is objectCollectionKeys() at :3381-3391, which derives the key set that objectConflict: 'merge' refuses to combine (#14848). The docblock at :3366-3375 states the reason for deriving it rather than listing it by hand, in its own words: a hand-written list 「would fail in the silent direction: a collection key added to the object schema tomorrow would fall back to the wholesale replacement this rule exists to refuse」. ⇒ A pipe-shaped wrapper reintroduces exactly that silent direction, through the derivation itself.

Harmless TODAY, and the reason is a single line

objectCollectionKeys() skips fields by name at :3385 (if (key === 'fields') continue;) because fields is the one collection 'merge' merges by shallow spread. PR #19147 wraps ObjectSchema.fields in z.preprocess (the __proto__ pre-parse guard) — the first key in that shape to take this form — and that key is the one key already excluded. ⇒ the refusal set is unchanged by that PR, which is why its at-tier contract review passed (record 5737516015) and why this is a finding rather than a regression report.

⭐ The trap is the NEXT one: any other collection key wrapped this way silently leaves the refusal set, and the failure shape is a wholesale replacement where a refusal was owed. Nothing in the tree reports it.

The same defect was already fixed one file over — in a TEST

packages/spec/src/compose-stacks-merge-collection-refusal.test.ts's independent walker had the identical def.in-only arm and PR #19147 fixed it there (isCollection(def.in) || isCollection(def.out)), because that walker does NOT carry the fields exclusion and so saw the transform node immediately. ⇒ The one-line shape of the fix is already in the tree, applied to the copy that noticed. The production copy did not notice because of the exclusion above.

What is NOT claimed

  • ⛔ NOT claimed that any current behaviour is wrong. The refusal set on 362dcc38f and on PR spec: pre-parse __proto__ guard on ObjectSchema.fields and AssignmentConfigSchema.assignments (#17852, #18847) #19147's head is the same set.
  • ⛔ NOT measured: whether any OTHER packages/spec walker carries the same pipe arm. The seat did not enumerate them, and that enumeration is the first thing a round on this card should do rather than trust — there were two copies of this arm and only one is fixed, which is a rate, not an anecdote.
  • ⛔ No fix shape proposed beyond noting that the test-side fix exists: whether the right answer is in || out, or reading out first, or refusing to walk a transform at all, is a judgement about what "declares a collection" should mean for a preprocessed slot.

Dedupe words

declaresCollection pipe def.in · objectCollectionKeys preprocess · objectConflict merge refusal set · z.preprocess transform invisible walker · stack.zod.ts pipe arm

Related: #19147 (the PR that created the first such wrapper and fixed the test-side copy) · #17852 / #18847 (its cards) · #14848 (the refusal this set serves) · ADR-0116 (the drift the derivation exists to avoid).

Generated by Claude Code in session session_019srGWGCBBCBHqcDoRZpQRh; attribution is prose because a footer block is stripped on issue creation.


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

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions