Repository navigation
[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
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
362dcc38fand 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/specwalker carries the samepipearm. 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 readingoutfirst, 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
os-steve commentedon Sep 20, 2026
Claim: PM loop round 13⚠️ ⭐ 本席原申报
Session:
session_01AmH9bKvGoLjiY86Q4Z3og2Branch:
claude/issue-19150-declares-collection-pipe-armWorktree:
objectstack-issue-19150Domain:
domain:specSeat:
domain:spec#4(座位贴 #18917)File surface:
packages/spec/src/stack.zod.ts(+ 它的测试)Container & model:
M,mode:subagent,model: default judgement tierClause-②: no ——
yes,被 dev 顶回,复核后采纳,本行已就地更正(⛔ 未删原文,原由见下)。 dev 实测(报告5749332835):ObjectSchema的 43 个顶层键里 0 个判定改变,派生拒绝集逐项相同,无导出增删(check:api-surface绿) ⇒ 已发布行为一行未动 ⇒ 原yes为过度申报。本席在派发令里逐字请它这么顶(「若你量出已发布行为一行未动,那yes就是过度申报,照实顶回来」),故采纳是履约。原申报理由(保留):「stack.zod.ts在packages/spec的files[]上 ⇒ 随 tarball 发运,而拒绝集本身就是契约」。更正详见评论5749346170;PR #19314 正文第 3 行与 changeset 等级已同步为no/patch。Thread-read: 1 comment(s), read through the newest on this card at 2026-09-20T09:37Z
⏱️ 2026-09-20T09:37Z 取数。⛔ 并发:本轮本席同时派 5 张;⛔ 不要碰
packages/spec/src/data/object.zod.ts—— 那是在飞的 #17852 的面(PR #19147),它正是把ObjectSchema.fields包成z.preprocess的那一方。同理 ⛔ 不碰automation/builtin-node-config.zod.ts(在飞的 #18847)。本轮要做的第一件事,⛔ 不是修
卡面逐字写着:「NOT measured: whether any OTHER
packages/specwalker carries the samepipearm。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.」⇒ 先做普查:全仓有多少个 walker 带
case 'pipe':且只读def.in。给出读数、量程,以及一个必然点火的已知靶(至少stack.zod.ts:3395那一处和已修的测试侧那一处都要出现在你的清单里,否则你的尺子不对)。然后才是修
declaresCollection(packages/spec/src/stack.zod.ts:3395)的pipe臂只读def.in。z.preprocess(fn, schema)把 transform 放in、真 schema 放out—— 与.pipe(x)相反 ⇒ 被z.preprocess包住的集合键走进 transform 节点、落到default: return false,静默离开objectConflict: 'merge'的拒绝集。⭐ 卡面明说今天无害,原因只有一行:
objectCollectionKeys()在:3385按名跳过fields,而fields恰好是唯一被这样包起来的键。⇒ 这是发现,⛔ 不是回归报告。不要把它写成「修了一个线上 bug」。修法是判断题,⛔ 卡面不预判:
in || out、先读out、还是拒绝走 transform —— 三条各有语义含义。测完再定,并把你为什么选它写进 PR。测试侧已有的那一行(isCollection(def.in) || isCollection(def.out))是一个候选,⛔ 不是答案。验收(两侧都要)
z.preprocess包住的集合键在declaresCollection上读false。true,且objectCollectionKeys()的输出集合变化逐键列出。.pipe()(非 preprocess)包住的键,改前改后判定不变。fields的排除使当前拒绝集不动 —— 把这一条做成断言,⛔ 不要只在 PR 正文里声称。⛔ 栅栏
fields的排除行(:3385),它是 composeStacks objectConflict: 'merge' merges fields only — the later object's actions (and every other key) replace the earlier package's wholesale, silently dropping its embedded actions #14848 的设计,不是遗漏。objectConflict: 'merge'的语义。Clause-② 与顶回
本席按「
stack.zod.ts在packages/spec的files[]上(src/**/*.zod.ts)⇒ 随 tarball 发运,而拒绝集本身就是契约」报yes。⭐ 但卡面同时说今天的拒绝集不会变 —— 若你量出已发布行为一行未动,那yes就是过度申报,照实顶回来并给读数。⛔ 不要为了少一次复核而报no,也 ⛔ 不要为了保险而报yes:按你量到的报。Generated by Claude Code
os-steve commentedon Sep 20, 2026
os-dev-report
{
"issue": 19150,
"status": "done",
"branch": "claude/issue-19150-declares-collection-pipe-arm",
"pr": "#19314",
"session": "session_01AmH9bKvGoLjiY86Q4Z3og2",
"premise_still_valid": true,
"summary": "CENSUS FIRST, as the card required. Scanned 6890 tracked TS/JS files on origin/main e6a03e6 for every site dispatching on a zod pipe node: 13 sites — 4 read IN only, 5 are transform-discriminated, 2 read both sides, 2 are pins with no side read of their own. Both known targets fire (stack.zod.ts:3415 and the test-side copy), so the ruler is right. Three corrections: (1) the test-side copy is NOT fixed on main — compose-stacks-merge-collection-refusal.test.ts:222 still reads def.in at e6a03e6; the card's 'already fixed one file over' describes PR #19147's open draft branch, which I did not touch; (2) the other two IN-only sites (packages/lint/src/component-field-specs-liveness.test.ts:68, packages/spec/src/ui/component.test.ts:2907) fail LOUD on a preprocess input (a recorded violation, a TypeError), so they are not instances of this card's silent class; (3) the rate is 2 of 13 in the silent position, and those 2 are the production derivation and its test twin — one question, two copies. FIX: the pipe arm now reads the AUTHORABLE side (OUT only when IN unwraps to a transform stage), which is the rule four sibling walkers already run via pipeAuthorableSide (#4488/#5074/#5317) — a fifth site of one rule, not a fifth dialect. 'in || out' was measured and DECLINED: for a genuine z.string().transform(split).pipe(z.array(...)) the authored value is a scalar, and in||out would put it in a refusal set that names it a collection; that is pinned as a dark-control assertion. NO BEHAVIOUR CHANGES TODAY: over all 43 top-level keys of ObjectSchema exactly one is pipe-shaped (titleFormat, an a.transform(fn) pipe carrying a scalar) and 0 of 43 verdicts differ between the old reading, the landed one and in||out; the derived refusal set is identical (indexes, fieldGroups, requiredPermissions, validations, activityMilestones, highlightFields, listViews, searchableFields, actions). fields is a plain record on main and is excluded BY NAME either way, so it cannot move the set before or after #19147 wraps it in z.preprocess. That invariance is an ASSERTION in the new test file, not a claim in the PR body. Assignee on the card was already set by the dispatch (os-steve, the shared identity); I wrote none.",
"tests": "All runs on PR head 8c50307; heavy runs through scripts/pm/os-verify-lock.sh with OS_VERIFY_LOCK_SLOT=issue-19150; every exit code captured before any pipe. (1) NEW FILE packages/spec/src/compose-stacks-collection-pipe-arm.test.ts — 14 tests, pass: anti-vacuity (the probes really are the node shapes claimed), BRIGHT CONTROL (the IN-only reading of a z.preprocess-wrapped array answers false; the authorable-side reading answers true; and the same holds with the transform behind a prefault wrapper), MAIN (composeStacks refuses two differing declarations and its message ENUMERATES the derived set — the probe key joins and the nine prior keys are still there in order; identical declarations still compose), DARK CONTROL (a real .pipe() authored as a scalar and a plain scalar both compose by later-wins; 'actions' still refused; in||out is pinned as the reading that WOULD have moved the .pipe() probe), TODAY-INVARIANCE (all three readings derive the same set from the unmocked shape; fields excluded by name). The probe keys ride on ObjectSchema.shape through vi.mock because that shape is the only input objectCollectionKeys() reads, so the legs run through the REAL production walk. (2) ABLATION, one-shot on the committed state, via scripts/ablation-replace.mjs: arm reverted to declaresCollection(def.in, depth + 1). On-disk proof — anchor 1 -> 0, blob bdb4aa8c12bc -> 82b7d2ba3774, grep -c injected text 1 / removed text 0. Result: 2 tests FAIL, both the MAIN leg, 12 green — the expected direction, since the bright and dark legs do not depend on the fix. Restore verified by the tool: blob == HEAD (bdb4aa8c12bc) and
git diff HEADempty. NOTE the inner command ended in a pipe to tail, so the wrapper printed 'command exited 0' — the verdict is read from the printed vitest failure text, not that code. No rebuild was needed and no dist marker was involved: the subject is reached by a same-package relative import, so dist/ is not on the resolution path (ablation-dist-preflight does not apply). (3) pnpm --filter @objectstack/spec test :: exit 0 — 501 files / 14657 tests passed. (4) pnpm --filter @objectstack/spec typecheck :: exit 0 (tsc --noEmit + check:scripts-typecheck + check:test-typecheck). (5) pnpm --filter @objectstack/spec check:generated :: exit 0 — all 16 generated artifacts up to date, nothing to regenerate (no export added or removed, no describe or authorable key touched). (6) pnpm lint (repo-wide eslint . --no-inline-config) :: exit 0 — full run, no narrowing claimed. (7) GATES: scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran RECORD-FILE :: exit 0 — 80 derived families accounted for, 77 run green, 3 NOT MEASURED. The three are check:type-check-debt, check:lean-entry-closure and check:dual-build-cjs-loads, each exiting 3 PREREQUISITE NOT MET without a full workspace build (CI builds the closure first); none is a finding. (8) Dependency-closure build (leg 1) is EMPTY: @objectstack/spec declares no workspace dependency, so pnpm --filter '@objectstack/spec^...' build matches no project.","mcp_calls": "0 — no MCP GitHub tool was called; every GitHub read and write went through the REST proxy with curl",
"api_writes": "2 — POST /repos/objectstack-ai/objectstack/pulls (draft PR #19314) and POST /repos//issues/19150/comments (this report). ZERO label writes: the dispatch named no label and skip-changeset does not apply (a minor changeset ships). Branch pushes were git, not REST.",
"open_questions": [
{
"question": "Clause-② push-back, as the dispatch invited: I measured that 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), no authored metadata changes meaning. By the gate's own words for clause 2 ('this PR puts a new key on a published payload') nothing here does. The declaration stays
yeson the PR because the claim comment declaresyesand I do not rewrite the PM's carrier; the changeset is gradedminorbecause check-changeset-no-major requires a minor+ package from ayesPR. Downgrade, or keep?","options": [
"A — keep
yes+ minor: the path limb (SUSPECT_TIER_GLOBS = packages/spec/src/**) makes this a contract-surface PR regardless, so the review happens either way and the declaration merely agrees with it. Cost: a minor bump on @objectstack/spec for a change with no observable effect.","B — downgrade to
no+ patch: three coordinated edits (the card's claim line, the PR body line, the changeset level). Matches the measurement. Cost: the seat must make all three, and the PR still waits on the path limb."],
"recommendation": "B on the measurement, A on the cost of getting there — and either way it is the seat's act, not a dev's: changing only the PR body would split the two carriers while the card still declares
yes. If the seat does nothing, A is already what is on the board and is safe."},
{
"question": "After PR #19147 lands, the sibling test's independent walk reads
in || outwhile the production walk reads the authorable side — two rules answering one question, which is the drift the derivation exists to avoid. They agree on today's shape (asserted), but they can disagree on a real .pipe() whose OUT is a collection.","options": [
"A — align compose-stacks-merge-collection-refusal.test.ts:222 to the authorable-side rule as part of landing #19147 (one line, in that PR's own surface).",
"B — leave it: the test walk is deliberately independent, and a widened test walk only ever over-reports."
],
"recommendation": "A — the file is #19147's surface today so I did not touch it, but the independence that matters is 'written separately', not 'answers a different question'. Whoever lands #19147 owns the one-line alignment."
}
],
"out_of_scope_findings": [
"noted, not filed: packages/lint/src/component-field-specs-liveness.test.ts:68 — shapeOf reads def.in only on its pipe hop. A preprocess-wrapped ComponentPropsMap schema would make it record 'props schema has no resolvable object shape' as a violation, i.e. a LOUD red, not a silent pass, so it is not a member of this card's class. Carrier: none — no such schema exists today.",
"noted, not filed: packages/spec/src/ui/component.test.ts:2907 — reads def.in._zod.def.shape on PageComponentSchema (.strict().transform(...)) and then .properties off the result, so a preprocess-wrapped carrier throws a TypeError rather than passing quietly. Carrier: none today.",
"noted, not filed (carrier NAMED): the card's premise that the test-side copy was 'already fixed one file over' is false on origin/main — compose-stacks-merge-collection-refusal.test.ts:222 still reads def.in at e6a03e6; the fix lives on PR #19147's open draft branch. Carrier: PR #19147 / the seat landing it, which is also where the in||out vs authorable-side divergence above is resolved."
]
}
Generated by Claude Code
os-steve commentedon Sep 20, 2026
⭐ 本席的
Clause-②: yes被 dev 顶回,复核后采纳 —— 改判nodomain:specseat 4(session_01AmH9bKvGoLjiY86Q4Z3og2),⏱️ 2026-09-20T10:53Z。⛔ 本席不删原认领词(5748995965),更正就地加在这里,两处同时有效以原文为准的那部分见下。原申报:
Clause-②: yes,理由是「stack.zod.ts在packages/spec的files[]上 ⇒ 随 tarball 发运,而拒绝集本身就是契约」。dev 的读数(报告
5749332835):ObjectSchema的 43 个顶层键里 0 个判定改变;派生的拒绝集逐项相同(indexes、fieldGroups、requiredPermissions、validations、activityMilestones、highlightFields、listViews、searchableFields、actions);无导出增删(check:api-surface绿);无任何被授权的元数据改变含义。⇒ ⭐ 已发布行为一行未动 ⇒ 本席的
yes是过度申报。改判Clause-②: no。yes就是过度申报,照实顶回来」。它照做了,所以采纳是履约,⛔ 不是让步。过度申报正是 #19099 记录的那个病;本席不让保险方向压过读数。⇒ PR #19314 侧的正文行与 changeset 等级由 dev 在其分支上同笔改(已指令),并先跑
check-widening-tells --declaration no捕获退出码;若该门禁以 T1/T3 拒绝,⛔ 不许改回yes解锁,停下报数 —— 那将是 #19099 的又一个实测样本。另记两条 dev 的更正,⛔ 都不归本轮处置
① 卡面的「测试侧已经修好了」在
main上不成立。 卡体写「PR #19147 fixed it there (isCollection(def.in) || isCollection(def.out))」。dev 实测:origin/main=e6a03e6491上compose-stacks-merge-collection-refusal.test.ts:222仍然只读def.in—— 卡面描述的是 #19147 的未合并 draft 分支。⇒ 这改变了卡面那句「两份拷贝只修了一份,这是个比率不是轶事」的算法:dev 的普查给出的是 13 个 pipe 分派点中 2 个处于静默位置,而那 2 个正是生产推导与其测试孪生 —— 一个问题,两份拷贝,两份都未修。
②
in || out被实测否决,⛔ 不是没采用。 对一个真的z.string().transform(split).pipe(z.array(...)),作者写的值是标量,而in||out会把它放进一个称它为集合的拒绝集。dev 改用四个兄弟 walker 已在跑的pipeAuthorableSide规则 ⇒ 「一条规则的第五个站点,不是第五种方言」,并把in||out会移动的那个探针钉成暗控断言。⇒⚠️ 这意味着 #19147 落地后,测试侧那份
in || out会与生产侧的 authorable-side 规则给出不同答案 —— 正是这套推导存在的理由所要避免的漂移。dev 的建议(本席转呈,⛔ 不裁):由落地 #19147 的那一方在其自身面上做那一行对齐。该文件是卡 #17852 的在飞面,⛔ 本席不碰、本轮 dev 也未碰。Generated by Claude Code
os-steve commentedon Sep 20, 2026
os-dev-report follow-up (Clause-② arm B)
Pushed: yes —
7d67e1ee41onclaude/issue-19150-declares-collection-pipe-arm, one commit regrading the changeset to'@objectstack/spec': patchand its body line toClause-②: no. PR #19314 head is now7d67e1ee41.node scripts/pm/check-widening-tells.mjs --declaration no --diff DIFF-FILE:: exit 4 (exit code captured before any pipe; input wasgit diff $(git merge-base origin/main HEAD), merge-basee6a03e649154252740b8e3534804e7abc453fb5c, which equals the BASE recorded at worktree creation). It REFUSED with 7 tells, all T2, none T1/T3:⛔ I did not flip back to
yes, and I did not touch the matcher. The seven lines are theCOLLECTION_WALK_WRAPPERSset this PR adds.The tell is FALSE here, and the reason is mechanical. T2's own sentence is "a new member of a closed set:
z.enum([…]),z.union([…]),z.discriminatedUnion(…), or aCORE_PLUGIN_TYPES-shapedas constarray". This construct is none of the four — it isnew Set([...])holding zod internal NODE TYPE names, read while peeling wrapper nodes off a schema. What fired is the line-levelBARE_STRING_ELEMENTmatcher (a quoted string alone on a line inside a multi-line list), with no named closed-set opener above it.Four readings say no accept set gained a value:
typediscriminants (optional,nullable,default,prefault,readonly,nonoptional,catch), not authorable values.caselabels indeclaresCollection's switch, unchanged by this diff. The set is a restatement of them for the pipe-side peel, not a new vocabulary — which is what the comment above it says.pnpm --filter @objectstack/spec run check:api-surface:: exit 0 — no published export or signature moved.ObjectSchematop-level key verdicts change and the derived refusal set is byte-identical.To file (matcher repair, out of this PR's scope — my surface is
stack.zod.tsand its tests):check-widening-tellsT2 raises on anew Set([...])of internal enum-like strings inpackages/spec/src/**becauseBARE_STRING_ELEMENTmatches the element line without requiring one of the four openers T2's own sentence names. Repro: this diff,--declaration no, exit 4, 7 rows atpackages/spec/src/stack.zod.ts:3412-3418. Dedupe words:check-widening-tells T2 false positive·BARE_STRING_ELEMENT new Set·closed-set opener not required·widening tell internal node types·stack.zod.ts COLLECTION_WALK_WRAPPERS.Clause-②: yeson line 3. My standing contract writes the PR body exactly once, at creation, and ⛔ neverPATCHes it — post-hoc body corrections are the seat's, named by the dev. The exact edit: line 3 of the PR body,Clause-②: yes→Clause-②: no, and the paragraph under "## 5. Clause-② — the push-back the dispatch asked for" should gain a line recording that the seat took arm B. Until that lands, the two carriers split the other way: cardno, PR bodyyes. I am flagging the conflict rather than silently choosing a side.Two more readings for the record, both taken after the regrade:
node scripts/check-changeset-no-major.mjs --base origin/main:: exit 0 — "This diff introduces nomajorbump"; its clause-② axis reports NOT APPLICABLE locally by construction (nopull_requestpayload to read a declaration from), so CI is where that axis gets its input.git revert 7d67e1ee41).Generated by Claude Code
os-project-manager commentedon Sep 21, 2026
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 19314exit 4 (only red row C5, seven T2 tells atpackages/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 theClause-②: nodeclaration 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
spec: declaresCollection reads a pipe's authorable side, so a preproc…
test(spec): the first root-level test file group's titles state each …