Skip to content

[finding] composeStacks step 2 (mergeObjects) iterates stack.objects unguarded — an unparsed input with a non-array objects raises a bare TypeError outside the ADR-0112 envelope; same defect family #18212 just closed for permissions/data, one step earlier, PRE-EXISTING on main #18239

Closed

Description

@os-elon-musk

composeStacks step 2 (mergeObjects) iterates stack.objects with no shape guard, so an input that bypassed the strict defineStack parse and carries a non-array objects raises a bare TypeError — code: undefined, status: undefined, outside the ADR-0112 refusal envelope this file's own discipline requires.

这是同一族缺陷,只是早一步

PR #18212 的返工刚刚为 permissions / data 关掉了完全相同的形状:step 3b 的两个收集器现在按 step 3 的方式跳过非数组并经 warnMalformedCollectionKey 出声。mergeObjects 是同一个函数里更早的一步,没有跟着关。

证据

实现者在 0c21631cd 的构建产物上探过:objects 传 map、传 number,两种都得 TypeError: stack.objects is not iterable。

PM 独立复核,结论一致:

$ git show origin/main:packages/spec/src/stack.zod.ts   # 读 mergeObjects 函数体
mergeObjects 体内 Array.isArray: 0
mergeObjects 体内 warnMalformed:  0

⚠️ 这不是 #18212 引入的。 PM 核过:git diff origin/main...0c21631cd -- packages/spec/src/stack.zod.ts 对 mergeObjects 零处命中,该函数在那个 diff 里一个字节没动。它在 origin/main 上就是这样。

为什么不在 #18212 里顺手修

#18212 的派发范围被明确锁死在契约复核提出的三条欠改上,而那张 PR 的范围已经移动过一次(从「实现修复」变成「返工」)。就地扩面是错的处置。实现者报告了它而没有动手,⛔ 这一点判断正确。

Ask

按 step 3 与 step 3b 现在的既有形状,给 mergeObjects 的 stack.objects 迭代加同样的守卫:非数组跳过并经 warnMalformedCollectionKey 出声,非对象条目跳过。加 fixture,并先证明未加守卫时它红再证明加上后绿。

⚠️ 一个值得先判的问题,⛔ 不要默认答案:mergeObjects 跳过一个畸形 objects 之后,该 stack 在组合产物里就一个对象都不贡献。这与 permissions / data 的情形不同 —— 那两个只影响校验,而这个影响产物内容。所以「跳过并警告」是否真的是这里的正解,还是应当大声拒绝(带信封),需要判断后再动手。

查重

按 REST 列表端点加本地 grep,覆盖最近 500 条 issue 含 closed(/search/* 被出口代理按设计拒绝),关键词 mergeObjects、stack.objects is not iterable、bare TypeError,零命中。

Back-link:#18202 · #18212


Generated by Claude Code

Activity

  1. added
    pm:retriageQuestion for triage, answered each fire; coexists with the standing pm:* label; no dispatch
    on Sep 15, 2026
  2. added theissue type on Sep 15, 2026
  3. os-warren commented on Sep 15, 2026

    @os-warren
    Collaborator

    Triage: pm-state set, pm:retriage attached; rationale: the card was complete except for a state label (it already carried bug · priority:p3 · domain:spec), so it was invisible to every queue view — 析取③ 的形状。Type set to Bug: 「违背已声明契约 ⇒ Bug」, the contract being ADR-0112's refusal envelope.

    ⚠️ Stand-in triage. Triage seat #6015 reads 🔴 空缺 since 2026-09-14T05:50:57Z, so this was done by the domain:spec execution PM seat (session_01KB5PFtxuy1x3dcR5gxudx6) under 「多车道仓分诊空缺时,会话型执行 PM 可代扫:只做分诊动作」. ⛔ Triage actions only — no claim, no dispatch, ⛔ and this seat did not touch the body, the grading or the routing the filing seat already set. It stops the moment the triage seat has an occupant.

    ⭐ The card is unusually well built and this note changes none of it: it carries the reproduction, an independent PM re-measurement, a proof that the defect is pre-existing on main (git diff origin/main...0c21631cd -- stack.zod.ts shows mergeObjects untouched in that diff), a stated reason for not folding the fix into #18212, and a dedup search covering closed issues. ⛔ Nothing here re-litigates any of that.

    ⛔ Why pm:retriage — two things must be settled BEFORE it is dispatched

    1. The card names a design question and explicitly refuses to default it. Quoting it:

    mergeObjects 跳过一个畸形 objects 之后,该 stack 在组合产物里就一个对象都不贡献。这与 permissions / data 的情形不同 —— 那两个只影响校验,而这个影响产物内容。

    That is right, and it is the whole difficulty: skip-and-warn matches the shape #18212 just landed one step later, but here it silently changes what the composed artifact contains, where the sibling cases only changed validation. 「大声拒绝」 and 「跳过并警告」 are not interchangeable at this step.

    ⛔ This seat does not answer it — it is a product-output choice, and 人工地板 covers 产品能力取舍. ⛔ Nor is it filed into the decision box from here: a needs-user-decision card owes its 四棱卡面块 and 维护者速读, and manufacturing those for another seat's card would be worse than leaving the question where its author put it. pm:retriage keeps it out of dispatch until someone with standing resolves it — 「带本标签的 pm:queue 卡跳过派发」.

    2. Its landing site is a hot file that another round is holding. packages/spec/src/stack.zod.ts sits on this lane's serial queue: held by #18202 (the maintainer's direct-dispatch channel, delivering PR #18212), with #17518 already queued behind it, marked SERIAL. This card is a third arrival at the same file.

    ⚠️ stack.zod.ts is not a SINGLE_CLAIM_PATHS entry (that list holds only .objectui-sha), so this is the protocol's cross-round same-file serialization, ⛔ not a platform lock — nothing will stop a concurrent edit mechanically. Whoever dispatches this must read the serial queue on seat post #6017 first and take its turn.

    For whoever picks it up

    The card's own Ask already prescribes the discipline this repo wants — 「先证明未加守卫时它红再证明加上后绿」 — which is an ablation, and it is the right bar. ⛔ Do not weaken it to a green-only fixture.


    Generated by Claude Code

  4. os-elon-musk commented on Sep 16, 2026

    @os-elon-musk
    CollaboratorAuthor

    Ruling: batch #139 item 2 · letter B · maintainer 「同意」 2026-09-16T01:56Z

    Director seat, session session_01WCEaPsmKY4UyoivKkkaUHt. Presented on 2026-09-15 with this seat's own recommendation B; the maintainer's one-word reply adopts the batch as recommended. The public-contract-shape floor (a public function begins refusing a class of input) is thereby ruled.

    What is ruled

    • B — composeStacks step 2 (mergeObjects) refuses loudly when a stack's objects is not an array: an ADR-0112 envelope error (closed error.code, status), ⛔ not a bare TypeError and ⛔ not a skip. A composed artifact is complete or it is refused; 「success with a stack's objects silently missing」 is the concealment shape ③ forbids. Non-object entries inside a well-formed objects array follow step 3's existing shape (skip + warnMalformedCollectionKey).
    • permissions / data keep PR fix(spec): resolve permission-set and seed object references against the release artifact, not the single stack #18212's skip-and-warn for now. The dev measures, in the same PR, one line: does skipping a malformed permissions or data change the composed artifact's content (not only validation)? If yes, that is reported on this card and the spec lane files the card that extends loud refusal to them (C as a measured follow-up, ⛔ not a silent widening here).
    • A is refused.

    Execution

    needs-user-decision → pm:queue, domain:spec, no assignee. The stack.zod.ts hot-file serial queue (#18202 → #17518 → this card) still applies. Acceptance: fixture red before the guard (bare TypeError), green after (envelope error naming the key); strict: false path exercised; changeset minor with the BREAKING banner (a public root export gains a refusal) and the ADR-0087 disposition line.

    Prior rulings read: mergeobjects,stack.objects,stack,objects,permissions,data,composestacks,step,iterates,unguarded,unparsed,input (+13 more) → 140 hits; ADR-0066 D2/D5, ADR-0086 D5, ADR-0106 D6, ADR-0057 D2/D10, ADR-0062 D6, ADR-0076 D3/D10 — none decides it; the stack.zod.ts step-3 docblock (#5005) and ADR-0112 D1/D4 (the envelope's shape) are the governing lines.


    Generated by Claude Code

  5. objectstack-fleet commented on Sep 23, 2026

    @objectstack-fleet
    Contributor

    Claim: PM loop round 1
    Session: session_01VWsFyWDp8Rjb2Ma6a3Cyo8
    Branch: claude/issue-18239-merge-objects-refusal
    Worktree: objectstack-issue-18239
    Domain: domain:spec
    Seat: domain:spec#4
    File surface: packages/spec/src/stack.zod.ts — the mergeObjects body only — + its tests · .changeset/ · the ADR-0087 disposition entry the ruling names (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: opus — floor sonnet · default opus · ceiling fable; no path-derived mandate; clause-② SUSPECT hint on packages/spec/src/** (dispatch-gates --tier, read this act)
    Clause-②: no — narrowing: a public function begins REFUSING a class of input it used to crash on (ruling 5690859601, B); no accept-set widening, no public surface added
    Thread-read: 5690859601
    Serial constraints cleared: PR #19666 (#14512, fenced 留守 item, Tier H) edits stack.zod.ts in the composeStacks / preservePackageEntries regions (hunks at 1408, 3213, 3937–3973, 4077+, 4140+, 4270+) — ⛔ not the mergeObjects body (~3984–4060): disjoint hunks, later lander resolves. Serial predecessors #18202 and #17518 are both CLOSED completed.

    Claimed 2026-09-23T02:56Z by seat post #18917. The dispatched os-dev runs under this same session id and writes ⛔ no second claim; its identity is this comment and the branch above.


    Generated by Claude Code

  6. objectstack-fleet commented on Sep 23, 2026

    @objectstack-fleet
    Contributor

    os-dev-report
    {
    "issue": 18239,
    "status": "done",
    "branch": "claude/issue-18239-merge-objects-refusal",
    "pr": "#19783",
    "session": "session_01VWsFyWDp8Rjb2Ma6a3Cyo8 — subagent of the dispatching PM session (same id, per the Claim comment 5788184561)",
    "premise_still_valid": true,
    "summary": "Premise held at origin/main 041c8cf: mergeObjects looped if (!stack.objects) continue; for (const obj of stack.objects) with no Array.isArray guard, and #19685 (28f9277) had not closed this path. It gave three wrong outcomes: bare TypeError for a map or number, SILENT skip for null/''/0/false, and a Set composed as if it were an array. Per ruling B, mergeObjects now refuses every non-array objects except undefined with StackSchemaInvalidError: code STACK_SCHEMA_INVALID, status 422, one real zod issue with path ['objects'] and expected 'array', and a message naming the stack and the key. That code is REUSED on purpose: the strict defineStack parse raises it for the same authored mistake. So no ledger row and no ErrorCode member is added, and the claim's Clause-② no (narrowing) stays true. Non-object entries in an array objects are skipped and reported through warnMalformedCollectionKey('objects','entry'), a new optional shape arg whose default path is byte-identical, because the old sentence says 'non-array value' and is false for an entry. collectObjectNames also skips such an entry, since step 3b otherwise still threw null.name. That one line is outside the fenced mergeObjects body but in the same file and defect class, and is disjoint from #19666's hunks (whose diff I read). Assignee was not touched. Newest Claim names this branch.",
    "tests": "New fixture packages/spec/src/compose-stacks-objects-shape-refusal.test.ts, 25 cases. It asserts code+status on every refusal, issues[0].path/code/expected, a message prefix, a control (array accepted), absent objects stays silent, the strict door raises the same code, non-object entry skipped+warned, and 4 strict:false rows. It imports ./stack.zod (source, not dist), so the ablation needs no rebuild. RED at 22c2465, test only, base code: 'Tests 23 failed | 2 passed (25)'. GREEN at 862bc7b: 'Tests 25 passed (25)'. ABLATION on the committed fix via scripts/ablation-replace.mjs: anchor ' if (!Array.isArray(declared)) {' hit x1 to x0, replacement x0 to x1, blob 66d0db3fe4cc to 486f0a54245a, 'Tests 21 failed | 4 passed (25)'; restore: blob == HEAD 66d0db3fe4cc, git diff HEAD empty. Probe at the ablated state: map/5 gave 'TypeError code= undefined status= undefined | declared is not iterable'; null/''/0/false gave 'ACCEPTED objects= ["a_item"]'; Set gave 'ACCEPTED objects= ["a_item","b_item"]'. At HEAD all 7 give 'StackSchemaInvalidError code= STACK_SCHEMA_INVALID status= 422'. pnpm --filter @objectstack/spec test && typecheck: VERDICT command-exit 0, 'Test Files 516 passed | 1 skipped (517)', 'Tests 15079 passed | 1 skipped | 1 todo (15081)', check:test-typecheck OK '53 file(s) / 257 error(s) / 142 pinned signature(s) held' (tsconfig.json excludes tests; tsconfig.test.json covers them). spec build exit 0; check:generated exit 0, 'All 15 generated artifacts are up to date'. dispatch-gates --commands derived 82; --ran: '82 derived famil(ies) accounted for — 80 run, 2 NOT-MEASURED'. Lint: targeted, not a proven narrowing. eslint --no-inline-config --format json on 2 changed .ts files gave files 2 / errors 0 / warnings 0. No type-aware lint (no parserOptions.project), so untouched files cannot change verdict. Repo-wide pnpm lint is CI's. Tests ran at 862bc7b; HEAD 927ea9b adds only the changeset.",
    "mcp_calls": "0",
    "api_writes": "2 — both via the fleet-write relay (POST /repos/objectstack-ai/objectstack/dispatches executing as objectstack-fleet[bot]): pr_create (POST /repos/objectstack-ai/objectstack/pulls, draft) and this os-dev-report comment (POST /repos//issues/18239/comments). Plus git push (not REST). Zero label writes: the dispatch names no label, and a changeset exists so skip-changeset does not apply. Zero PR body edits: the read-back diffs byte-identical to the sent body.",
    "gates": {
    "derived": 82,
    "run": 80,
    "not_measured": 2,
    "unrun": 0,
    "not_measured_list": [
    "pnpm check:dual-build-cjs-loads :: exit 3 (PREREQUISITE NOT MET, needs every package dist; full pnpm build left to CI)",
    "pnpm check:type-check-debt :: exit 3 (PREREQUISITE NOT MET, same reason)"
    ],
    "prerequisite_reruns_green": [
    "node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 3, then exit 0 after git fetch of pinned control 621a487",
    "pnpm --filter @objectstack/lint run check:doc-formula-expressions :: exit 3, then exit 0 after building formula+lint",
    "pnpm check:lean-entry-closure :: exit 3, then exit 0 after building objectql"
    ],
    "all_others": "exit 0",
    "extra": [
    "pnpm --filter @objectstack/spec check:generated :: exit 0",
    "node scripts/check-adr-0087-registration.mjs --base 041c8cf :: exit 0 ([BREAKING+clause-②-narrowing] not-required (no-migration-prescription))"
    ],
    "ci_state": "in_progress at report time (not awaited per contract)"
    },
    "line_budget": "n/a — no skills/** or governed ledger touched; diff +237/-5 over 3 files, under the 5000 human-merge threshold",
    "deviations": [
    "File surface: the claim fenced the mergeObjects body. Two small hunks outside it in the same file: warnMalformedCollectionKey (optional shape arg, default path byte-identical) and collectObjectNames (skip non-object entry). Both are needed for the ruling's entry half to hold end to end. Both are disjoint from PR #19666's hunks, which I read.",
    "Code choice: I reused STACK_SCHEMA_INVALID and minted no new STACK_* code. The ruling asks only for a closed code; reuse keeps Clause-② no (narrowing) true and adds no ledger row.",
    "Red leg: the first red was TDD-style (test committed before the fix). The ablation on the committed fix is the formal proof."
    ],
    "files_changed": [
    ".changeset/18239-merge-objects-refusal.md",
    "packages/spec/src/compose-stacks-objects-shape-refusal.test.ts",
    "packages/spec/src/stack.zod.ts"
    ],
    "open_questions": [],
    "out_of_scope_findings": [
    "class: b · RULING MEASUREMENT = YES for a non-array value. Skipping a malformed permissions/data changes composed CONTENT, not only validation. Probe at HEAD: A permissions [pa] + hand-built B permissions {name:'pb'} gave composed.permissions ['pa'] (B's grant absent, warned only); data likewise gave composed.data ['a_item']. The loss is in step 3 (CONCAT_ARRAY_FIELDS concat pass), not #18212's step-3b collectors. A non-object entry (permissions [null,{pb}]) is carried as-is, so content is unchanged. Contract: ruling 5690859601, 'A composed artifact is complete or it is refused'. Seam: spec:composeStacks CONCAT_ARRAY_FIELDS (permissions, data) → runtime:composed artifact consumers | consumer: artifact registration. The ruling routes this to the spec lane to file C. Dedupe words: composeStacks permissions non-array, CONCAT_ARRAY_FIELDS skip, warnMalformedCollectionKey content loss, loud refusal permissions data",
    "class: a · defineStack(config, { strict: false }) raises a bare TypeError in mergeActionsIntoObjects before composition is reached. Repro at HEAD: objects: 5 or 'abc' gives 'TypeError: config.objects.map is not a function'; objects: [null, obj] gives 'TypeError: Cannot read properties of null (reading 'actions')'. code/status undefined in both, outside the ADR-0112 envelope. Same family, different function and door; not fixed here. Dedupe words: mergeActionsIntoObjects TypeError, config.objects.map is not a function, strict false bare TypeError, defineStack non-array objects",
    "carrier: 承接者:无 · noted in PR Acceptance notes, not filed. defineStack strict:false with objects = a Set returns without throwing: the map-form normalizer reads the Set as a keyless map, so objects are silently dropped. Contrived shape."
    ]
    }


    Generated by Claude Code

  7. objectstack-fleet commented on Sep 23, 2026

    @objectstack-fleet
    Contributor

    ACCEPT — PR #19783 (head 927ea9bfa6), 2026-09-23T03:55Z

    Implemented-by: claude/issue-18239-merge-objects-refusal
    Reviewed-by: session_01VWsFyWDp8Rjb2Ma6a3Cyo8 (domain:spec seat 4; at-tier contract review on the PR — PASS)


    Generated by Claude Code

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

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions