spec: declaresCollection reads a pipe's authorable side, so a preprocess-wrapped collection key cannot silently leave the merge refusal set (#19150) - #19314
Conversation
`declaresCollection` read only `def.in` on its `pipe` arm. `z.preprocess(fn, schema)` puts the transform stage in `in` and the validated schema in `out` — the opposite of `a.transform(fn)` — so a preprocess-wrapped collection key resolved to a `transform` node, fell through to `default: return false`, and silently left the refusal set `objectCollectionKeys()` derives for `objectConflict: 'merge'`. Reads OUT only when IN is a transform stage: the rule four sibling walkers already run, and not `in || out`, which would pull a key whose authored value is a scalar into the refusal set. Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude <noreply@anthropic.com>
A preprocess-wrapped collection key on `ObjectSchema.shape` is the shape the real production walk cannot see today, so the probe keys ride on that shape through `vi.mock` and the legs are read through `composeStacks` itself: bright control (the IN-only reading still answers "not a collection"), main (the key is now enumerated in the refusal), dark control (a genuine `.pipe()` authored as a scalar stays out — the leg that discriminates the landed rule from `in || out`), plus a today-invariance block asserting all three candidate readings derive the same set on the unmocked shape. Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check6 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin dccb32785cca2fc9dd713017557ef784177b925d && git checkout dccb32785cca2fc9dd713017557ef784177b925d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e3b3cdd2df3bda349ef7a41b0de39c8de4fddc87 7d67e1ee4136aee8f8e6ea838950c7fb71520be2 && git checkout -B drift-repro e3b3cdd2df3bda349ef7a41b0de39c8de4fddc87 && git merge --no-ff 7d67e1ee4136aee8f8e6ea838950c7fb71520be2
node scripts/docs-audit/affected-docs.mjs --json e3b3cdd2df3bda349ef7a41b0de39c8de4fddc87 |
The seat ruled arm B on the push-back: the measurement governs. Published behaviour does not move by one row — 0 of 43 ObjectSchema top-level key verdicts change, the derived refusal set is identical, and no export is added or removed — so `yes` was over-declared and the bump is a patch. Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude <noreply@anthropic.com>
Fixes #19150
Clause-②: no
declaresCollection(packages/spec/src/stack.zod.ts) read onlydef.inon itspipearm, so az.preprocess-wrapped collection key resolved to atransformnode, fell through todefault: return false, and silently left the key setobjectConflict: 'merge'refuses to combine (#14848).⭐ No current behaviour is wrong and none changes here.
objectCollectionKeys()skipsfieldsby name, and measured over all 43 top-level keys ofObjectSchemathe derived refusal set is identical before and after. This is a finding fixed before it can bite, not a regression report.1. The census — what the card asked for FIRST
The card records this as NOT measured: "whether any OTHER
packages/specwalker carries the samepipearm … there were two copies of this arm and only one is fixed, which is a rate, not an anecdote."Scanned 6890 tracked TS/JS files (
node_modules/,dist/excluded) onorigin/mainate6a03e6491for every site that DISPATCHES on a zodpipenode —case 'pipe',type === 'pipe',instanceof z.ZodPipe. 13 sites, each classified by hand from its arm:spec/src/stack.zod.ts:3415·spec/src/compose-stacks-merge-collection-refusal.test.ts:222·lint/src/component-field-specs-liveness.test.ts:68·spec/src/ui/component.test.ts:2907spec/scripts/lib/zod-graph.ts:232(pipeAuthorableSide, the canonical one) ·spec/scripts/liveness/check-liveness.mts:592·spec/scripts/liveness/tombstoned-row-status.test.ts:101·spec/src/kernel/metadata-authoring-lint.ts:134·spec/src/system/metadata-form-zod-reconciliation.test.ts:172spec/src/kernel/metadata-type-schemas.test.ts:128(union of both) ·:558(OUT first, then IN)spec/scripts/zod-graph.test.ts:182(the pin ONpipeAuthorableSide) ·lint/src/validate-predicate-path-refs.ts:369counted above as transform-discriminatedBoth known targets fire, which is the ruler check the card asked for:
stack.zod.ts(this card) and the test-side copy.Three corrections the census produces:
main.compose-stacks-merge-collection-refusal.test.ts:222still readsisCollection(def!.in, …)ate6a03e6491. The card's "already fixed one file over" describes PR spec: pre-parse __proto__ guard on ObjectSchema.fields and AssignmentConfigSchema.assignments (#17852, #18847) #19147's BRANCH, which is still open and draft. ⛔ Untouched here on purpose — that file is spec: pre-parse __proto__ guard on ObjectSchema.fields and AssignmentConfigSchema.assignments (#17852, #18847) #19147's surface.component-field-specs-liveness.test.tsrecords"TYPE: props schema has no resolvable object shape"(the type name, then that sentence) as a violation when the walk reaches no shape;component.test.ts:2907reads.shape.propertiesoff the result and would throw. Neither can go quietly green on a preprocess-wrapped input. They are noted below, not filed.2. The fix shape — measured, then chosen
The card deliberately left three candidates open. The landed rule reads OUT only when IN unwraps to a transform stage:
in || out(the shape spec: pre-parse __proto__ guard on ObjectSchema.fields and AssignmentConfigSchema.assignments (#17852, #18847) #19147 applied test-side): for a genuinea.transform(fn).pipe(b)the author writesa.z.string().transform((s) => s.split(',')).pipe(z.array(z.string()))is a key whose AUTHORED value is a scalar and whose parsed value is an array;in || outputs it in a refusal set that then tells the author their scalar is a collection whose entries would be dropped. Pinned as a dark-control assertion, not argued in prose:eitherSideWalkanswerstruefor that shape, the landed rule answersfalse, andcomposeStackscomposes it by later-wins.composeStacksat author time; the derivation's job is to answer a structural question about every key, and a throw on a shape that is legal today would convert a silent gap into an outage.pipeAuthorableSideinscripts/lib/zod-graph.tssincebuild-schemas.ts的zodShapeOf对z.preprocess走错管道方向(#4488 已在 check-liveness 修过的同一个盲点) #5317,metadata-authoring-lint.tsandmetadata-form-zod-reconciliation.test.tssinceViewItemSchema同时是授权形状和 Studio 往返的 wire 成员 —— 拆成两个 schema 还是保持宽松?(挡住 #4001 批 18 最后 2 站点) #5074,packages/lint'svalidate-predicate-path-refs.ts), each carrying the 活性账本覆盖 worklist:9 个已注册 metadata type 仍未治理(#4487 建立闸门后的剩余债务) #4488 citation. Adopting it makes this a fifth SITE of one rule rather than a fifth dialect. The unwrap before the transform test is load-bearing and is pinned: a transform one level down is still a transform.3. The measurement, per key
ObjectSchema.shape— 43 top-level keys, read off the built package:titleFormat,optional > union[ pipe(in=string, out=transform) | object ], ana.transform(fn)pipe carrying a scalar.in || out: 0 of 43.indexes, fieldGroups, requiredPermissions, validations, activityMilestones, highlightFields, listViews, searchableFields, actions(9 keys).fieldsis a plainrecordonmaintoday and is excluded by NAME either way, so its own reading cannot move the set. After spec: pre-parse __proto__ guard on ObjectSchema.fields and AssignmentConfigSchema.assignments (#17852, #18847) #19147 wraps it inz.preprocessits reading changes (IN-onlyfalse, authorable-sidetrue) and the set is still unmoved, because the exclusion is by name.That invariant is an ASSERTION, not a claim in this body:
compose-stacks-collection-pipe-arm.test.ts's last block derives the set under all three readings from the unmocked shape and fails the day they stop agreeing — which is the day this fix starts doing observable work.4. Tests — bright / main / dark, driven through the real production walk
declaresCollectionis internal and today's shape has no preprocess-wrapped collection key, so a pin written against the shape alone cannot tell a fixed walker from an unfixed one. The new file mounts three probe keys onObjectSchema.shapethroughvi.mock— the only inputobjectCollectionKeys()reads — and drives them throughcomposeStacksitself:pipewithin=transform, out=array; and apipewhose IN is itself the.transform()pipe).prefaultwrapper.composeStacksrefuses two differing declarations of that key, and the refusal message ENUMERATES the derived set, so the set change is read per key: the probe key joins, and the nine keys that were there before are still there, in order. Identical declarations still compose..pipe()probe and a plain scalar both compose by later-wins, unchanged;actionsis still refused exactly as before; andin || outis pinned as the reading that WOULD have moved the.pipe()probe.Ablation (one-shot, on the committed state,
scripts/ablation-replace.mjs): the arm reverted todeclaresCollection(def.in, depth + 1), mutation proven on disk (anchor1 -> 0, blobbdb4aa8c12bc -> 82b7d2ba3774,grep -cof the injected text1and of the removed text0) — 2 tests fail, both of them the MAIN leg, with the other 12 green, which is the expected direction: the bright and dark legs do not depend on the fix. Restored by the same tool, verifiedblob == HEAD (bdb4aa8c12bc)andgit diff HEADempty.dist/is not on the resolution path here — the subject is reached by a same-package relative import from the test — so the rebuild-to-dist preflight does not apply and no dist marker was involved.Runs (all on
8c50307884, this PR's head; shared box, so seconds are contention figures):pnpm --filter @objectstack/spec test— 501 files / 14657 tests passed, exit 0.pnpm --filter @objectstack/spec typecheck— exit 0 (tsc --noEmit+ scripts + test layer).pnpm --filter @objectstack/spec check:generated— all 16 generated artifacts up to date; nothing to regenerate.pnpm lint(repo-wideeslint . --no-inline-config) — exit 0, no narrowing claimed.scripts/pm/dispatch-gates.mjs --ran— 80 derived families accounted for: 77 run green, 3 NOT MEASURED (check:type-check-debt,check:lean-entry-closure,check:dual-build-cjs-loads— each exits 3 PREREQUISITE NOT MET without a full workspace build, which CI does first; none is a finding).@objectstack/specdeclares no workspace dependency, sopnpm --filter '@objectstack/spec^...' buildmatches no project.5. Clause-② — the push-back the dispatch asked for
Declared
yes, copied from the claim comment, and the path limb (SUSPECT_TIER_GLOBS=packages/spec/src/**) makes this a contract-surface PR regardless of any declaration. The changeset is gradedminorbecausecheck-changeset-no-majorrequires at least oneminor+ package from ayesPR.⭐ The reading the dispatch asked for, and it points the other way: published behaviour does not move by one row. 0 of 43 key verdicts change, the refusal set is identical, no export is added or removed (
check:api-surfacegreen), and no authored metadata changes meaning. By the gate's own words for clause ② — "this PR puts a new key on a published payload" — nothing here does. If the seat accepts that reading, the downgrade is three coordinated edits (the card's claim line, this body's line, and the changeset level) and is the PM's to make, not a dev's unilateral carrier split.Acceptance notes
Out of scope, noted and NOT filed — neither is a reproducible defect, a declared-contract violation or a metadata-authoring trap:
packages/lint/src/component-field-specs-liveness.test.ts:68—shapeOfreadsdef.inonly. A preprocess-wrappedComponentPropsMapschema would make it record"props schema has no resolvable object shape"— a LOUD red, not a silent pass. Carrier: none today; no such schema exists.packages/spec/src/ui/component.test.ts:2907—def.in._zod.def.shapeonPageComponentSchema(.strict().transform(…)). Same shape, same loud failure (a TypeError on the next line). Carrier: none today.in || outwhile the production walk reads the authorable side. Measured on today's shape the two agree, and the invariance block above asserts it — but they are two rules answering one question, which is the drift the derivation exists to avoid. The one-line alignment belongs to whoever lands spec: pre-parse __proto__ guard on ObjectSchema.fields and AssignmentConfigSchema.assignments (#17852, #18847) #19147, since that file is its surface today.Authored by Claude Code in session
session_01AmH9bKvGoLjiY86Q4Z3og2; attribution is repeated in prose because the platform rewrites the footer block on some write channels.Generated by Claude Code