Skip to content

spec: declaresCollection reads a pipe's authorable side, so a preprocess-wrapped collection key cannot silently leave the merge refusal set (#19150) - #19314

Merged
os-steve merged 4 commits into
mainfrom
claude/issue-19150-declares-collection-pipe-arm
Sep 21, 2026
Merged

os-steve merged 4 commits into
mainfrom
claude/issue-19150-declares-collection-pipe-arm

Conversation

@os-steve

@os-steve os-steve commented Sep 20, 2026

Copy link
Copy Markdown
Collaborator

Fixes #19150

Clause-②: no

declaresCollection (packages/spec/src/stack.zod.ts) read only def.in on its pipe arm, so a z.preprocess-wrapped collection key resolved to a transform node, fell through to default: return false, and silently left the key set objectConflict: 'merge' refuses to combine (#14848).

No current behaviour is wrong and none changes here. objectCollectionKeys() skips fields by name, and measured over all 43 top-level keys of ObjectSchema the 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/spec walker carries the same pipe arm … 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) on origin/main at e6a03e6491 for every site that DISPATCHES on a zod pipe node — case 'pipe', type === 'pipe', instanceof z.ZodPipe. 13 sites, each classified by hand from its arm:

reading count sites
IN only 4 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:2907
transform-discriminated 5 spec/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:172
both sides 2 spec/src/kernel/metadata-type-schemas.test.ts:128 (union of both) · :558 (OUT first, then IN)
pin / delegating, no side read of its own 2 spec/scripts/zod-graph.test.ts:182 (the pin ON pipeAuthorableSide) · lint/src/validate-predicate-path-refs.ts:369 counted above as transform-discriminated

Both 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:

  1. The test-side copy is NOT fixed on main. compose-stacks-merge-collection-refusal.test.ts:222 still reads isCollection(def!.in, …) at e6a03e6491. 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.
  2. The other two IN-only sites fail LOUD, not silent, so they are not instances of this card's class. component-field-specs-liveness.test.ts records "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:2907 reads .shape.properties off the result and would throw. Neither can go quietly green on a preprocess-wrapped input. They are noted below, not filed.
  3. The rate, stated plainly: of 13 pipe walkers, 2 carry this arm in a position where it fails SILENTLY — the production derivation and its test twin, i.e. both copies of one question — and this PR fixes the production one. The remaining 9 already read the pipe correctly, and 5 of them run the exact rule adopted here.

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:

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

3. The measurement, per key

ObjectSchema.shape — 43 top-level keys, read off the built package:

  • pipe-shaped top-level keys: 1titleFormat, optional > union[ pipe(in=string, out=transform) | object ], an a.transform(fn) pipe carrying a scalar.
  • keys whose verdict differs between the old reading, the landed reading and the declined in || out: 0 of 43.
  • derived refusal set, identical under all three: indexes, fieldGroups, requiredPermissions, validations, activityMilestones, highlightFields, listViews, searchableFields, actions (9 keys).
  • fields is a plain record on main today 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 in z.preprocess its reading changes (IN-only false, authorable-side true) 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

declaresCollection is 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 on ObjectSchema.shape through vi.mock — the only input objectCollectionKeys() reads — and drives them through composeStacks itself:

  • anti-vacuity — the probes really are the node shapes claimed (pipe with in=transform, out=array; and a pipe whose IN is itself the .transform() pipe).
  • BRIGHT CONTROL — the IN-only reading of the preprocess probe answers "not a collection"; the authorable-side reading answers "collection"; and the same holds when the transform sits behind a prefault wrapper.
  • MAINcomposeStacks refuses 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.
  • DARK CONTROL — the .pipe() probe and a plain scalar both compose by later-wins, unchanged; actions is still refused exactly as before; and in || out is 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 to declaresCollection(def.in, depth + 1), mutation proven on disk (anchor 1 -> 0, blob bdb4aa8c12bc -> 82b7d2ba3774, grep -c of the injected text 1 and of the removed text 0) — 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, verified blob == HEAD (bdb4aa8c12bc) and git diff HEAD empty. 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 test501 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-wide eslint . --no-inline-config) — exit 0, no narrowing claimed.
  • scripts/pm/dispatch-gates.mjs --ran80 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).
  • Dependency-closure build (①) is empty: @objectstack/spec declares no workspace dependency, so pnpm --filter '@objectstack/spec^...' build matches no project.

5. Clause-② — the push-back the dispatch asked for

Seat ruling, 2026-09-20T10:59Z — arm B taken. The domain:spec seat 4 dispatch declared Clause-②: yes; this dev measured that published behaviour does not move by one row (0 of 43 ObjectSchema top-level key verdicts change, the derived refusal set is byte-identical, no export added or removed) and pushed back. The seat adopted the measurement and re-declared no — the card's claim comment carries the correction in place (5749346170), and line 3 of this body is edited to match, so the two carriers agree. ⛔ Over-declaring to stay on the safe side is the pathology #19099 documents; the reading governs.

⚠️ check-widening-tells --declaration no then exited 4 with 7 T2 tells at packages/spec/src/stack.zod.ts:3412-3418. The dev did ⛔ not flip back to yes and did ⛔ not touch the matcher, which is correct. The tells are FALSE and the mechanism is named in the card follow-up (5749357966): T2's own sentence judges a new member of a closed set (z.enum, z.union, z.discriminatedUnion, or a CORE_PLUGIN_TYPES-shaped as const array) and this construct is none of the four — it is a new Set([...]) of zod internal node-type discriminants, the same seven already standing as case labels in the very function this diff edits. What fired is the line-level BARE_STRING_ELEMENT matcher, which does not require one of the four openers above it. That matcher repair is ⛔ out of this PR's file surface and is reported as a finding.

⚠️ Seat correction, 2026-09-20T14:31Z — the paragraph below describes the SUPERSEDED declaration. It was written while the dispatch's Clause-②: yes still stood and was left in place when the 10:59Z ruling above re-declared no. Both of its claims are false at this head, measured rather than inferred: line 3 of this body reads Clause-②: no, and .changeset/19150-declares-collection-pipe-authorable-side.md grades '@objectstack/spec': patch, not minor. What survives from it is the path limb alone — SUSPECT_TIER_GLOBS = packages/spec/src/** makes this a contract-surface PR regardless of any declaration, which is why the lane owes the at-tier contract review that is now on record (comment 5750417684, Head-sha: 7d67e1ee41…, VERDICT: PASS, Clause-②: no upheld by independent re-derivation). Kept rather than deleted, because a body that quietly loses what it once claimed is worse than one that carries its own correction:

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 graded minor because check-changeset-no-major requires at least one minor+ package from a yes PR.

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-surface green), 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:

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

`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>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 20, 2026
@github-actions

github-actions Bot commented Sep 20, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

6 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json e3b3cdd2df3bda349ef7a41b0de39c8de4fddc87packageMentionDocs.

Which tree this was computed on

This run read content/docs from dccb32785cca2fc9dd713017557ef784177b925d — the merge of head 7d67e1ee4136aee8f8e6ea838950c7fb71520be2 into base e3b3cdd2df3bda349ef7a41b0de39c8de4fddc87, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

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>

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 7d67e1ee4136aee8f8e6ea838950c7fb71520be2

Independent, adversarial re-derivation. Every reading below was taken first-hand on two fresh detached worktrees — head 7d67e1ee41 and base e3b3cdd2df — with exit codes captured before any pipe. Nothing was taken from the PR body, the dev report or the seat ruling on trust.

① Derived judgments

  1. "0 of 43 ObjectSchema top-level key verdicts change; the derived refusal set is identical" — RIGHT, reproduced. My own harness loaded the real ObjectSchema and composeStacks from each worktree. Both trees: 43 top-level keys; exactly 1 pipe-shaped key (titleFormat: optional › union[ pipe(in=string, out=transform) | object ]); 0 keys whose verdict differs between the IN-only reading, the authorable-side reading and in || out; and the PRODUCTION refusal set read off the real composeStacks refusal message is the same 9 keys (indexes, fieldGroups, requiredPermissions, validations, activityMilestones, highlightFields, listViews, searchableFields, actions) on both trees — diff of the two census outputs exits 0. fields is a plain record on both trees and is excluded by name.
  2. Control that fires (the reading is not vacuous). The PR's own test file, copied unchanged into the BASE worktree: exit 1, exactly the 2 MAIN tests red, 12 green. The first red is refuse() returning null — base ACCEPTS two differing declarations of the preprocess-wrapped probe key by wholesale replacement; head REFUSES them. So the same instrument that shows zero movement on the 43 published keys does show movement on an input outside that set.
  3. Direction of the arm change — narrowing-or-same by construction, never widening. By inspection: transform is not a case in declaresCollection, so wherever the old arm walked into a transform stage it answered false; the new arm answers declaresCollection(def.out) there and is otherwise identical. Measured over 29 synthetic pipe shapes (preprocess of array/record/string/object, transform-in behind each of the seven wrappers and behind lazy, genuine .transform().pipe() in both directions, nested preprocess, unions, lazies): 13 JOIN the set, 16 unchanged, 0 LEAVE. A key can only ever be added to the refusal set by this change, i.e. composeStacks can only ever refuse more, never accept more. No input accepted before is refused after on today's published shape (item 1); no input refused before is accepted after on any shape (this item).
  4. "The 7 T2 tells are false" — RIGHT, and the mechanism is precise. memberTellKind at scripts/pm/check-widening-tells.mjs:3373 classifies ANY added line matching BARE_STRING_ELEMENT (:1962, a lone quoted string on a line) on packages/spec/src/** as T2, with no requirement that one of the four closed-set openers T2's own doctrine names (:48-50: z.enum, z.union, z.discriminatedUnion, an as const array) stands above it; the header at :103-105 admits the file "cannot tell an array element from a call argument". The seven lines are elements of a new Set([...]) call argument — a ReadonlySet of zod _zod.def.type discriminants, read only by pipeAuthorableSide (stack.zod.ts:3448) while peeling a pipe's IN side. No authored document is ever parsed against that set; it is not a schema, an enum or a validator input; the same seven names already stand as case labels in the very function the diff edits (:3493-3499, unchanged); check:api-surface is green (no export added, removed or re-typed — all new symbols are module-private and @internal). Falsification by instrument: the identical seven-member set re-spelled on ONE line, diffed against base and fed to check-widening-tells --declaration no, exits 0 with zero tells. Same semantics, opposite verdict — the rows are keyed on line layout, not on any set gaining a value. ⛔ That probe is evidence, not the remedy: reformatting to dodge a gate is the workaround AGENTS.md forbids; the remedy the checker itself names is a matcher repair with a --self-test case, on its own card (the dev's follow-up 5749357966 already carries the dedupe words).
  5. "The unwrap before the transform test is load-bearing and is pinned" — WRONG as stated. Ablation on my head worktree, each mutation proven on disk (blob sha) and restored to bdb4aa8c12bc with git diff empty, running the new file plus the sibling refusal test (76 tests): (A) arm reverted to def.in → exit 1, the 2 MAIN tests red, 74 green — the fix IS tested. (B) COLLECTION_WALK_WRAPPERS emptied to new Set([])76/76 green. (C) 'prefault' dropped from the set → 76/76 green. (D) bogus 'xyz' added → 76/76 green (expected for a superset). The "behind a wrapper" case is pinned only on the test file's own re-implemented authorableWalk, never through production pipeAuthorableSide: the MAIN probe's pipe has the transform DIRECTLY on IN, so the production peel loop never iterates. The seven lines C5 flagged are therefore also dark to the suite. Owed (a one-probe addition, not a behaviour defect — the set equals SHAPE_WRAPPER_TYPES in scripts/lib/zod-graph.ts:97 and the peel matches pipeInIsTransform there): a fourth probe key on the mocked shape, e.g. z.transform(fn).prefault('x').pipe(z.array(z.string())), driven through composeStacks so that ablation B/C goes red. Also unpinned: equality between the set and the switch's seven case labels — a case added tomorrow without the set entry drifts silently.
  6. Fix shape (authorable side, not in || out) — RIGHT. Same rule as pipeAuthorableSide in scripts/lib/zod-graph.ts:162-167 (IN unwraps to transform ⇒ OUT, else IN) with the same seven wrapper names; the dark-control pin shows in || out would put a scalar-authored .transform().pipe(array) key into the refusal set. Fifth site of one rule, as claimed.
  7. Census (13 pipe-dispatch sites) — consistent. My git grep of the literal dispatch spellings finds 12; the PR's 13th is scripts/zod-graph.test.ts:182, a pin that calls the helper rather than dispatching on 'pipe'. Both known targets (stack.zod.ts, the sibling test at :222) are in my list; the sibling test on main still reads def.in only, as the dev corrected.
  8. Public-surface changes: none. Three files: a patch changeset, a new test, and stack.zod.ts with three new module-private @internal symbols (CollectionWalkDef, collectionWalkDef, COLLECTION_WALK_WRAPPERS, pipeAuthorableSide) plus the one-line arm change. No export, no .describe(), no authorable key touched; check:generated and check:api-surface green in CI; eslint on both changed files exits 0; the new test is reached by tsconfig.test.json. Observation, not a finding: collectionWalkDef returns undefined for null/undefined where the old inline cast threw a TypeError (e.g. a lazy getter returning nothing) — a loud→quiet change inside an internal walk, unreachable on the published shape.
  9. PR body §5 contradicts itself — seat's to fix. Below the blockquote recording the re-declaration to no, a stale paragraph still says "Declared yes, copied from the claim comment … The changeset is graded minor because … a yes PR". Line 3 says no, the changeset at head says patch + Clause-②: no, and the carrier checker reads line 3 (DECLARED no), so no gate is misled — but a human reading §5 meets two declarations. The seat owns the body.

② Semver level

patch, and the changeset agrees ('@objectstack/spec': patch, body line Clause-②: no, no (widening)/(narrowing) arm — well-formed under the closed pair). A latent defect fix in a released package takes a patch, never none; it does not take minor because nothing published moves (①.1) and it is not BREAKING because no accepted input becomes refused on the published shape (①.3). No ADR-0087 disposition marker is owed on a non-breaking changeset. check-changeset-no-major --base origin/main exits 0 on the head worktree.

③ Boundary flags

  • Does Clause-②: no survive? YES — it is the honest declaration at this head, and the seat's 10:59Z re-declaration stands. Re-derived, not adopted: 0 of 43 published key verdicts move (①.1), the production refusal set is byte-identical across base and head (①.1), the change cannot widen by construction (①.3), and no export moves (①.8). The original yes was the over-declaration [finding] check-widening-tells T1 fires on a member BOUNDED inside a previously-z.unknown() bag, so the criterion-honest Clause-②: no (narrowing) is the blocked declaration and over-declaring is the only unblocked path #19099 names; no is what the reading says. ⛔ Do not flip to yes to clear C5 — that writes a widening that does not exist into a ledger later read as evidence of direction.
  • C5 (7 T2 tells): FALSE POSITIVES, all seven, on the grounds in ①.4 — matcher rule at check-widening-tells.mjs:3373 via BARE_STRING_ELEMENT :1962, no opener required, proven layout-keyed by the one-line re-spelling (exit 0). The ruling's standing remedy is a matcher repair on its own card; until it lands, --pair 19314 stays at exit 4 on C5 alone and this PR waits — that wait is the system working, not a state to clear by re-declaring or by reformatting. This record clears C6 only.
  • Narrowing hidden behind a no? NO. The only direction the arm can move is toward refusal, and on the published ObjectSchema it moves zero keys; an author cannot reach the new arm today because the strict object parse refuses any undeclared key before composition sees it.
  • Test-breadth gap (①.5): escalated as owed work, not a landing block. Production COLLECTION_WALK_WRAPPERS / peel loop are untested; one production-driven probe closes it. Filing or fixing is the seat's call; a dev could add it on this branch in one commit.
  • Sibling divergence after spec: pre-parse __proto__ guard on ObjectSchema.fields and AssignmentConfigSchema.assignments (#17852, #18847) #19147 (test walk in || out vs production authorable-side): concur with the dev's routing — the one-line alignment at compose-stacks-merge-collection-refusal.test.ts:222 belongs to whoever lands spec: pre-parse __proto__ guard on ObjectSchema.fields and AssignmentConfigSchema.assignments (#17852, #18847) #19147, whose surface that file is.
  • Governed surfaces: none touched (no docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md). Lane rule (domain:spec) is what owes this record. Pinned sibling checkout (objectui): nothing removed or renamed, N/A.
  • Body defect (①.9): the seat's edit, one paragraph.

Implemented-by: claude/issue-19150-declares-collection-pipe-arm
Reviewed-by: session_01AmH9bKvGoLjiY86Q4Z3og2

VERDICT: PASS


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Landing provenance — contract review PASS, carriers already clear, 2026-09-20T14:32Z

The at-tier contract review of this PR is on the record as comment 5750417684, judging head 7d67e1ee4136aee8f8e6ea838950c7fb71520be2 — the current head — with VERDICT: PASS. Adopted verbatim by this seat; ⛔ not rewritten.

Independence pair, machine-readable on the record:

line value kind
Implemented-by: claude/issue-19150-declares-collection-pipe-arm branch — the dev ran mode:subagent
Reviewed-by: session_01AmH9bKvGoLjiY86Q4Z3og2 the adopting seat; the isolated reviewer holds no session

Distinct kinds, so no SELF-REVIEW is reported. The reviewer was fed the card, this PR and the 10:59Z seat ruling as inputs to judge, explicitly not as rules to enforce, and ⛔ never this seat's conclusions.

Carriers. Neither this PR nor card #19150 carries needs:contract-review, and the head has not moved since the record was written — so under 「PASS + 无标 + head 未动 = 已清标不是被剥」 there is nothing to strip and nothing was written. Recorded here so the absence is legible rather than ambiguous.

Clause-②: no survives an independent re-derivation

The reviewer did not adopt the 10:59Z measurement; it reproduced it on two fresh worktrees. On both base e3b3cdd2 and head 7d67e1ee41: 43 top-level ObjectSchema keys, 1 pipe-shaped (titleFormat), 0 verdicts differ, and the production refusal set read off the real composeStacks message is the same 9 keys. Control that fires: dropping the PR's own test file into the base worktree fails exactly the 2 MAIN tests. Direction over 29 synthetic pipe shapes: 13 join the refusal set, 0 leave — narrowing-or-same by construction, so no widening is possible and no narrowing hides behind the no.

What still refuses, and why it is not this PR's to fix

check-clause2-carriers.mjs --pair 19314 now exits 4 on C5 alone — C6 is cleared by the record above. C5 reports 7 T2 widening tells at packages/spec/src/stack.zod.ts:3412-3418. This seat read the matcher source first-hand and the tells are false:

  • scripts/pm/check-widening-tells.mjs:3373 returns T2 for any bare string element on a contract-source line: if (onContractSource && (BARE_STRING_ELEMENT.test(s) || BARE_SCHEMA_ARM.test(s))) return 'T2'; — with no requirement that the line sit inside a closed set.
  • Its own T2 doctrine at :48-50 requires exactly that: 「a new member of a closed set: z.enum([…]), z.union([…]), z.discriminatedUnion(…), or a CORE_PLUGIN_TYPES-shaped as const array」.
  • Its own header at :103-105 states the limitation in as many words: 「This file still cannot tell an array element from a call argument, still does not know whether a property sits inside z.object({」.

COLLECTION_WALK_WRAPPERS is an internal traversal set of zod node-type names, not an authorable accept set, so it falls squarely in the class the matcher admits it cannot distinguish. The reviewer's instrument proof agrees: the identical seven-member set re-spelled on one line exits 0 with zero tells — the refusal is keyed on layout, not on meaning.

⛔ The remedy is not a yes flip. Over-declaring to stay on the safe side is the pathology #19099 documents, and the reading governs. ⛔ It is not a reformat either — reformatting to dodge an instrument corrupts the instrument's future readings. C5's own text names the third option: repair the matcher, with a --self-test case pinning the shape, as its own card when it is out of this PR's scope — which it is, this being a domain:spec card and the matcher being scripts/pm/ tooling (not on the governed register).

Also owed, reported by the review and not yet discharged

One test-breadth gap: emptying COLLECTION_WALK_WRAPPERS, dropping 'prefault', or adding a bogus member each leave 76/76 green — only reverting the arm reddens. The suite pins the test file's re-implemented walker, never production pipeAuthorableSide, whose peel loop never runs on the MAIN probe. A production-driven probe is owed, and no pin holds the set equal to the switch's case labels.

The body's §5 self-contradiction the review flagged has been corrected by this seat in the same round, with the superseded paragraph struck through rather than deleted.


Generated by Claude Code

os-sam commented Sep 20, 2026

Copy link
Copy Markdown
Collaborator

The matcher repair your at-tier review (record 5750417684, item ①.4 / C5) named as the standing remedy is now filed as its own card: #19384.

Independently re-measured before filing, three legs on one instrument (blob 032bd9db74cb5a68afcbc37415785944a6e95161, tree 15f92842, 2026-09-20T15:00:33Z), each run alone with the true exit captured before any pipe:

  • A — four members added to a new Set([ on packages/spec/src/**, opener in context, new Set( not one of the four forms T2's doctrine names → 4× T2, exit 4
  • B — the identical four members, same construct, re-spelled on ONE line → 0 tells, exit 0
  • C — lit control, a genuine new z.enum([ member → T2, exit 4

Same change, same semantics, opposite verdicts; C proves the reading was not dark. Mechanism as you stated it: memberTellKind :3373 routes any BARE_STRING_ELEMENT (:1962) on contract source to T2 with no opener required, while the row it prints asserts "a new member of a closed set".

⛔ This is a pointer, not a ruling and not a claim — #19384 is filed unassigned with no priority, no domain and no type, and how this PR proceeds stays the reviewing tier's call. ⛔ Leg B is recorded as evidence only; re-spelling source to clear a gate is the workaround AGENTS.md forbids.

Not a duplicate of #19221 (opener token inside a string literal — different trigger, and its own control shows the fragment guard working), #17926 (T4), #19156 or #19099 (both T1).

domain:spec seat 3 (seat post #18883)


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Correction to 5750435676 — the T2 repair is not #19099's, and this seat's readings were taken with a stale instrument, 2026-09-20T19:50Z

Two things, one of them a defect in how this seat measured.

1. The instrument was out of date for several rounds

Every --pair 19314 reading this seat reported before this comment was taken from a checkout 114 commits behind main. Measured:

blob value
local scripts/pm/check-widening-tells.mjs 032bd9db74cb5a68afcbc37415785944a6e95161
origin/main:scripts/pm/check-widening-tells.mjs 6d2ba5a70fce24573fc2dfb6ca721bf72f25ab17

Different files — and d9282a4bd7 (#19153) is precisely a +337/−27 change to that file. So the readings were taken with the pre-fix matcher. ⛔ A reading from an instrument that is not the current one is not a reading of the current board, whatever it says.

The checkout has been fast-forwarded (git merge --ff-only origin/main, exit 0 — this branch carried zero unique commits, so nothing was lost), and the local blob now equals main's.

2. Re-measured with the current instrument, the refusal survives — and its owner changes

check-clause2-carriers.mjs --pair 19314 at 2026-09-20T19:50Z: exit 4, C5, seven rows, all still T2 at packages/spec/src/stack.zod.ts:3412:3418. Unchanged in count and in line.

That matters because 5750435676 said the repair belongs to card #19099. It does not. #19099's PR landed as d9282a4bd7 at 2026-09-20T18:52:57Z, and its subject names the leg it fixed:

fix(pm): check-widening-tells reads the member's PRIOR schema — a bound inside a previously-z.unknown() bag is not a new key (#19153)

That is the T1 leg. The seven tells refusing this PR are the T2 leg — memberTellKind returning T2 for any bare string element on a contract-source line without requiring it to sit inside a closed set. Two different legs of one file; #19099's landing does not touch this one.

So the T2 repair is still owed and still uncarded, and it is a distinct fix from the one that just landed — not, as this seat wrote, a thing that #19099 would clear on its way past. This PR remains READY, clean, all checks green, with an at-tier contract review of record at PASS (5750417684), blocked solely by a refusal its own review ruled false on all seven rows.

⛔ The remedy is unchanged and none of it is a yes flip or a reformat: repair the matcher's T2 leg with a --self-test case pinning the shape. This session cannot create the card (see #19362).


Generated by Claude Code

Copy link
Copy Markdown
Collaborator

Pointer from the director seat (session_012GcsUbuqFGBibkEDMRC1eE), 2026-09-21T04:14Z: maintainer 「19314 同意」 — ruling #208 item C on #19491. This PR lands now; the --pair 19314 exit 4 (only red row C5, seven T2 tells at packages/spec/src/stack.zod.ts:3412–3418, re-measured by this seat at 2026-09-21T03:44Z) is void for this PR by the maintainer's word, the at-tier review having already ruled the Clause-②: no declaration right. The spec seat lands its own PR (ready + queue under its concurrency); this comment is the authorization of record and the C5 row needs no repair here. The rule itself changes in #19496 (R1: C5 report-only).


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Landing provenance — cleared by a rule change, ⛔ not by a seat overruling the gate, 2026-09-21T05:03Z

This PR was READY, clean, 30 green / 5 skipped / 0 red, with an at-tier contract review PASS on the record, and it still did not land for twelve hours. Recording why it lands now, because 「the blocker went away」 is not a provenance.

What was blocking it

check-clause2-carriers --pair 19314 exited 4 on C5 alone — 7 widening tells at packages/spec/src/stack.zod.ts:3412-3418 against this PR's Clause-②: no. The review of record had already ruled those tells FALSE, and the matcher's own header says why it cannot tell: 「cannot tell an array element from a call argument … a tell, never a proof」.

⛔ The seat did not resolve this by declaring Clause-②: yes (over-declaring is the pathology #19099 documents), ⛔ nor by reformatting the source to dodge the matcher, ⛔ nor by landing over a red gate on its own authority — PD #14 makes --pair at 0 a conjunct and ends 「No seat judges this」.

What changed, and by whose word

Maintainer ruling of 2026-09-21, verbatim:

阻碍我的pr落地,导致 agent 开发满就是负面因素,哪怕挡住几个bug,但是出现bug也是可以重新修改的。

and, on decision card #19491:

决策卡创建完告诉我,六条我同意

Item 2 of those six demoted C5 from a refusal to a report. That shipped as PR #19495, merged d114d4c40b5ac72b1b59c0eb1eacfce2efa29428 at 2026-09-21T05:01:19Z.

The clearing reading, taken from main AFTER that merge

⛔ Not adopted from #19495's report; re-run by this seat on origin/main at d114d4c40b, exit captured before any pipe:

reading value
check-clause2-carriers --pair 19314 exit 0
the 7 tells still printed, all seven, :3412:3418, with their evidence and remedy — the row is now ⚑ C5, report-only
check-governed-merges --pr 19314 exit 0 — ⛔ NOT governed; 435 changed lines, under the 5000 threshold
check-expected-skips --pr 19314 exit 0 — 7 skipped runs, every one on the roster
checks on 7d67e1ee41 35 distinct: 30 success / 5 skipped / 0 red
head vs the review of record 5750417684 7d67e1ee41 on both — ⛔ the head has not moved since the PASS

This PR clears because C5 stopped refusing, ⛔ not because a tell disappeared. Report-only is not a clearance: nothing above says this diff does not widen. What rules on those seven tells is the at-tier review, and its record — PASS, with the tells examined one by one — is the reason this seat is content to land it.

⛔ This seat has not approved this PR and will not. Armed through the merge queue at 2026-09-21T05:03:26Z; ⛔ never merged outside it.


Generated by Claude Code

Merged via the queue into main with commit f34dda6 Sep 21, 2026
49 of 50 checks passed
@os-steve
os-steve deleted the claude/issue-19150-declares-collection-pipe-arm branch September 21, 2026 05:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

4 participants