Repository navigation
composeStacks re-merges bound standalone actions that defineStack already copied into their objects — every bound action appears twice in the composed object's actions #14847
Description
Activity
- addedbugSomething isn't workingSomething isn't workingpriority:p2Medium: important, M3Medium: important, M3
on Sep 3, 2026 Triage — graded.
bug·priority:p2·pm:queue·domain:spec·needs:contract-review.Verified at
origin/main— structurally, not just by the repro✅
mergeActionsIntoObjectsis called atstack.zod.ts:2342and:2404(thedefineStackpaths) and again at:2990(composeStacks). ⇒ The double application is visible in the call graph, independent of the fixture.
✅:1563documents the semantics that make it double rather than idempotent — "mergeActionsIntoObjectsAPPENDS, so both survived intoobject.actions".
✅compile.ts:318validates the lowered stack withObjectStackDefinitionSchema.safeParse, notdefineStack. ⇒ The card's key consequence holds:os builddoes not hit #14686's refusal, and the doubled entries reachdist/objectstack.jsonunremarked.Why
p2⭐ The sharpest consequence is not the duplicate itself — it is that
composeStacksoutput is not round-trippable through the door that built its inputs. Feed a composed artifact back intodefineStackand #14686's same-key rule refuses it (two embedded twins under one key). A pipeline whose output its own input door rejects is a real defect, not cosmetic.Downstream, per the runtime note the card cites: MCP
listActionslists every bound action twice, and bare-nameresolveActionByNamerefuses the ambiguity. ⇒ User-visible on a composed app.⛔ Not
p1:examples/app-multi-packagedeclares no action, so nothing shipped demonstrates it today.The fork is real — ⛔ do not pick it by shape
The card names two directions and correctly declines to choose.
⚠️ Whichever is taken, the binding constraint is the one it identifies:#14686 deliberately counts an identical hand-written twin as a duplicate, so the skip must be scoped to the build's own echo
⇒ ⛔ A naive "skip structurally equal pairs" is wrong: it would silently swallow the hand-written twin #14686 exists to refuse. The skip has to distinguish the build's own echo from an author writing the same action twice — by marking, or by comparing against the standalone list, not by equality alone.
needs:contract-reviewbecause the choice changes the shape of a published composed artifact, and because getting it wrong disarms a landed refusal.⚠️ If your measurement says neither direction can be scoped safely, ⛔ do not weaken #14686's rule to make room — stop and report; that trade is a maintainer's.✅ #14662's decision to count DISTINCT stacks per key rather than sites is correctly out of scope, and the card is right that it does not fix the doubling — it only stops the echo reading as a collision.
⚠️ Sequence with #14848 (same file,objectConflict: 'merge'dropping non-fieldskeys wholesale). Different defects; ⛔ not merged, ⛔ not parallel.
Generated by Claude Code
zhuangjianguo commented
on Sep 3, 2026 CollaboratorMore actionsClaim: PM loop,
domain:specseat (seat post #6017), R2 of this shift
Session:session_0174WZTU6XcFcS7g2kykC53i(GitHubzhuangjianguo)
Branch:claude/issue-14847-compose-stacks-action-echo
Worktree:objectstack-issue-14847
Domain:domain:spec
File surface:packages/spec/src/stack.zod.ts—mergeActionsIntoObjects(:2081) and/orcomposeStacks's final merge (:3151), whichever direction the measurement supports; ⛔defineStack's same-key refusal (#14686) andcollectComposedActionKeyCollisions(#14854) untouched, ⛔objectConflict: 'merge''s key handling untouched (#14848, serial behind this card) ·compose-stacks*.test.tspins: a bound standalone appears ONCE in the composed object (two stacks; three undermerge/override;manifest: 'preserve'), the composed output round-trips throughdefineStack, and — the binding constraint — an identical hand-written twin is STILL refused by #14686's rule · changeset@objectstack/specpatch (the build's output shape is corrected; accept set unchanged) · generated artifacts only if a describe moves. Stop on breach; explain in the report.
Container & model: M,mode:subagent,model: fable(CONTRACT_REVIEW_TIER— the emitted shape of a published composed artifact changes, and scoping the skip against #14686's refusal is contract judgment).node scripts/pm/dispatch-gates.mjs --tier packages/spec/src/stack.zod.tsat77a532d6(17:00Z): "no path-derived mandate" + "Clause ② SUSPECT surface".
Clause-②: yes (conservative: the emitted shape ofcomposeStacksoutput changes for every composed artifact with bound actions; the accept set itself does not move — the delivered diff decides at the gate).
Serial constraints cleared: fresh open-PR file scan at 17:00Z (27 open PRs) — none touchesstack.zod.ts(PR #14975 on this lane is test-only); last commits35dffeac(#14854, 2026-09-03T09:27Z) and279431e7(#14686) ⇒ same-day churn line carried; fold-or-serial for the queued siblings on the same file answered SERIAL — #14848 (different defect:mergedrops non-fieldskeys; triage: "⛔ not merged, ⛔ not parallel") and #14873 (comments) wait behind this card; H17 index: no row.Decision re-read (all comments read, one — triage 5521543316): the fork (idempotent
mergeActionsIntoObjectsvs composing the authored shape) is real and ⛔ not to be picked by shape; the skip must distinguish the build's own echo from an author's hand-written twin (by marking, or by comparing against the standalone list — ⛔ never by equality alone); if neither direction can be scoped safely ⇒ stop and report, ⛔ never weaken #14686. Premise re-read onorigin/main77a532d6(17:00Z):mergeActionsIntoObjectsdefined at:2081, called at:2344and:2406(defineStack) and:3151(composeStacks);compile.tsvalidates withsafeParse, notdefineStack(card's consequence stands). Premise holds.
Generated by Claude Code
zhuangjianguo commented
on Sep 3, 2026 CollaboratorMore actionsDispatch (R2 of this shift, 2026-09-03T17:03Z) —
domain:specseat,session_0174WZTU6XcFcS7g2kykC53i, seat post #6017.mode:subagent,model: fable(CONTRACT_REVIEW_TIER), size M, Clause ② yes (conservative). The dev leaves its ownClaim:comment below; the assignee stays the PM's.Dispatch text handed to the dev, in brief (triage 5521543316's binding constraint, the stop-and-report clause and the #14848 sequencing are quoted verbatim and marked non-renegotiable; #14686 / #14854 are landed rulings, untouchable):
- The fork (idempotent
mergeActionsIntoObjectsvs compose the authored shape) is decided by measurement: the direction under which every pin holds, feat(spec): defineStack refuses two actions that resolve to one scope-qualified runtime key #14686's twin refusal still fires on a hand-written twin, and the composed output round-trips throughdefineStack; neither ⇒blocked. - Hypotheses: H1 provenance (identity against the same input's standalone list, or a build-time marker if it survives parse) as the discriminator — never equality; H2 pins per
objectConflictmode andmanifest: 'preserve'; H3 the round-trip pin with the refusal'scodeasserted; H4os buildof the multi-package example byte-identical; H5 changeset patch unless the gates say otherwise. - Same-day churn line carried (
35dffeac, feat(spec): composeStacks refuses two stacks whose actions resolve to one scope-qualified runtime key #14854); 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 / [finding] Twopackages/spec/src/stack.zod.tscomments name the deletedactionObjectKeyas the LIVE registration-key reader — the two sites #14669's face did not reach #14873 wait serial behind this card. - Landing: draft PR with
Fixes #14847+needs:contract-reviewon the PR in the same stroke (the card already carries it); ⛔ no ready flip. In-seat contract review at tier by this seat on delivery.
Generated by Claude Code
- The fork (idempotent
Claim: os-dev subagent on #14847, dispatched by the
domain:specseat (seat post #6017; PM claim comment 5529219489).
Session:session_0174WZTU6XcFcS7g2kykC53i(GitHubzhuangjianguo)
Branch:claude/issue-14847-compose-stacks-action-echo(pushed empty — write-route probe green)
Worktree:objectstack-issue-14847(base6392b9c2, currentorigin/main)
Clause-②: yesFile surface (complete):
packages/spec/src/stack.zod.ts—mergeActionsIntoObjectsand/orcomposeStacks's final merge, whichever direction the measurement supports. ⛔ Untouched:defineStack's same-key refusal (feat(spec): defineStack refuses two actions that resolve to one scope-qualified runtime key #14686),collectComposedActionKeyCollisionsand its message (feat(spec): composeStacks refuses two stacks whose actions resolve to one scope-qualified runtime key #14854), andobjectConflict: 'merge''s key handling (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, serial behind this card).packages/spec/src/compose-stacks-action-echo.test.ts— NEW pins: one copy per declaration in the composed object under the default,override,merge(three stacks) andmanifest: 'preserve'; a hand-written twin STILL refused by feat(spec): defineStack refuses two actions that resolve to one scope-qualified runtime key #14686's rule (code and message); the round-trip throughdefineStackin whatever form the measurement supports.packages/spec/src/compose-stacks-action-key-collision.test.ts— only the app-multi-package mirror pin that names composeStacks re-merges bound standalone actions that defineStack already copied into their objects — every bound action appears twice in the composed object's actions #14847 as "the measured shape, not the contract" is tightened to the contract; no refusal message moves..changeset/compose-stacks-action-echo.md—@objectstack/specpatch.packages/specgenerated artifacts only ifcheck:generatedproves one stale.
Generated by Claude Code
Generated by Claude Code
os-dev-report
{ "issue": 14847, "status": "done", "branch": "claude/issue-14847-compose-stacks-action-echo", "pr": "https://github.com/objectstack-ai/objectstack/pull/15022", "premise_still_valid": true, "summary": "The doubling is real and fixed: mergeActionsIntoObjects is now idempotent over its own output — a bound action the object already carries BY IDENTITY is not appended again — so composeStacks (which concatenates its inputs' actions and re-runs the merge over inputs defineStack already merged) carries each bound standalone once: measured on BASE 6392b9c2, two stacks gave [dup_x/BOUND, dup_x/BOUND] and three stacks under override/merge gave [emb3, b3, b1, b2, b3]; after: [dup_x/BOUND] and [emb3, b3, b1, b2]. Fork decided by measurement: identity is the discriminator under BOTH directions (H1 confirmed — a marker key is refused by the strict ActionSchema, and object identity survives build, mergeObjects and the concat; a strict parse yields fresh objects so a hand-written twin never shares identity and #14686's refusal, which runs before the merge, still fires — pinned); the idempotent-merge direction was taken as the smaller published-shape change: output = previous output minus the duplicates, nothing reordered, and collectComposedActionKeyCollisions (#14854) plus its message untouched, whereas stripping echoes before the walk would move #14854's landed message pin (it names the echo site) and stripping after it reorders the surviving stack's own bound action. H2 pinned (default, preserve incl. packages[] halves agreeing, three stacks override/merge). H3 FALSIFIED in its literal form by a landed ruling, not by this change: defineStack refuses ANY built stack that binds an action (stack-duplicate-action-key.test.ts:236, 'author the source shape, not the artifact'), so defineStack(composeStacks([a, b])) cannot parse with bound actions under either direction without weakening #14686 (forbidden); criterion (c) holds in the achievable form and is pinned — composition adds no refusal of its own: no bound action ⇒ parses cleanly; bound actions ⇒ the same 'declared twice' line a lone built input gets, no longer '3 times'. Consequence for the card/triage: 'not round-trippable through the door that built its inputs' is a property of every built artifact, not of composition — left for the contract review, not acted on. H4: os build itself not run (the CLI closure is 57 packages against the 10-minute foreground cap); the exact object compile.ts:318 parses — examples/app-multi-package's composed default export — was hashed through the built dist before and after: sha256 b1225296475ff3c7… both times (7351 bytes, both objects carry 0 actions), so byte-identical; compile-level fixture with a bound action judged not cheap, pinned at the spec level instead. H5: @objectstack/spec patch changeset; check:adr-0087-registration, check:empty-changeset, check:changeset-no-major all green; no skip-changeset. One measured consequence outside composition, pinned and flagged for the reviewer: under strict:false, ONE action object placed in both positions is now carried once (before: twice). PR is draft with needs:contract-review added in the same stroke; the PM's assignee untouched; my Claim comment is 5529342354. Observation not filed (unsure it is a defect): dispatch-gates derived scripts/pm/check-half-states.mjs — a live-repo PM sweeper that re-execs itself with --use-env-proxy and needs network minutes — into a spec-only diff's family; it timed out twice at 180s and is recorded NOT MEASURED. origin/main moved 4 commits during the run (two derived gate scripts changed upstream: check-cross-package-test-inputs.mjs, engine-double-contract.pinned.json); local runs used the base's copies, CI runs the current ones; branch not merged forward.", "tests": "All on head f1ed87e8 (git rev-parse --short HEAD after the final commit; the tree never changed after it except the ablation, restored and proven). Through scripts/pm/os-verify-lock.sh (shared-box seconds): (1) pnpm --filter @objectstack/spec build — 'check-dts-emitted: @objectstack/spec - 34/34', '.build-input-hash ← 6192f26ef03a8310'; dist/index.js carries 'base.includes(action)' ×1 and the old append form ×0. (2) pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 over 13 suites (compose-stacks-action-echo, compose-stacks-action-key-collision, compose-stacks, compose-stacks-i18n-merge, compose-stacks-key-loss, compose-stacks-manifest-preserve, stack-artifact-packages, stack-duplicate-action-key, stack-inline-action-crossref, stack-requires, stack-top-level-strict, stack, assembled-package-body) — 'Test Files 13 passed (13)', 'Tests 339 passed (339)'. (3) pnpm --filter @objectstack/spec typecheck — exit 0; 'check:test-typecheck: OK — 54 file(s) / 261 error(s) / 145 pinned signature(s) held'; tsc -p tsconfig.test.json --listFiles names compose-stacks-action-echo.test.ts (1 hit) so the test-layer verdict covers the new file. (4) pnpm --filter @objectstack/spec check:generated — '✓ All 15 generated artifacts are up to date' (api-surface, docs, authorable-surface, liveness, strictness-ledger … all ✓; react-declaration-parity 'cannot run here' by design). (5) Gate family from 'node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack' on the actual diff, 58 commands, each redirected then captured: 53 green at exit 0 — incl. check:nul-bytes, check:adr-0087-registration, check:empty-changeset, check:changeset-no-major, check:closing-keyword-parity, check:cross-package-test-inputs, check:test-source-alias, check:engine-double-contract, check:type-check-coverage, every spec check:* in the list, and check:doc-formula-expressions after building @objectstack/formula, @objectstack/sdui-parser and @objectstack/lint (its tail: the \"re-authorable\" half held a predicate that is correct exactly where it is (#11651, #11673).). NOT MEASURED by each gate's own verdict text (exit 3 / prerequisite, none a finding): check-dev-prereqs (exit 1 — '66 of 67 workspace packages … dist not on disk'), check:dual-build-cjs-loads (exit 3 — 'reads built output, and some package has no dist/'), check:type-check-debt (exit 3 — '32 workspace dependenc(ies) … no built type entry point'), check-test-completeness (exit 3 — needs a turbo test log; its text says record NOT MEASURED locally), scripts/pm/check-half-states.mjs (exit 124 twice — live-repo sweeper, 180s timeout). (6) H4: examples/app-multi-package composed object through the built dist, JSON sha256 b1225296475ff3c7f7a1a03c6af5a701eaa7a8259b1727722af794414a8fb6a7 before (BASE dist) and after (fix dist) — identical. (7) Reverse verification with the fix committed first (f1ed87e8): mutation = drop the identity filter ('const fresh = actionsByObject.get(obj.name) ?? [];'), confirmed on disk by anchor counts injected=1 / removed=0 and blob hash 790cfe5c… ≠ HEAD 8c27145e…; direction predicted in the script header before the run (8 red / 4 green controls in the new suite, 1 red mirror pin in the collision suite, 0 red in the #14686 suite); observed 'compose-stacks-action-echo.test.ts (12 tests | 8 failed)', 'compose-stacks-action-key-collision.test.ts (23 tests | 1 failed)', stack-duplicate-action-key all green, 'Tests 9 failed | 42 passed (51)'. No dist leg: the suites import './stack.zod' (relative source, not the package exports), so no rebuild was needed and dist stayed on the fix throughout (marker re-checked after restore: includes-filter=1, old-append=0). Restore by 'git checkout HEAD -- packages/spec/src/stack.zod.ts' under an EXIT/INT/TERM trap with absolute paths, proven by git hash-object == HEAD blob 8c27145e… and 'git diff HEAD --stat' EMPTY, porcelain empty.", "mcp_calls": "0 — every GitHub read and write went through git, the public issue page's embedded payload (card body + triage comment), and repo-scoped REST after a green probe (GET comment 5529219489 → 200, X-Ratelimit-Remaining 12766/15000): Claim comment 5529342354, draft PR POST /pulls, additive POST /labels, this report. No MCP call was made; no issue search was needed (nothing filed).", "open_questions": [ { "question": "Criterion (c) / H3 — 'the composed output round-trips through defineStack' — is unsatisfiable in its literal form under EITHER direction whenever an input binds an action, because defineStack refuses every built stack with a bound action by #14686's landed pin (stack-duplicate-action-key.test.ts:236). The PR pins the achievable form (no bound action ⇒ parses; bound actions ⇒ the same 'declared twice' line a lone build gets, never '3 times'). Is that the accepted reading of (c), or does the card's round-trip goal require a maintainer decision?", "options": [ "A — accept the corrected reading; the pins as landed in the PR are the contract (composition adds no refusal of its own).", "B — reopen #14686's 'author the source shape, not the artifact' ruling so a built artifact round-trips through defineStack — a maintainer's trade, a separate card, not this one.", "C — make composeStacks emit the authored shape (objects without their bound actions) — a published-shape break for every consumer of objects[].actions (metadata service, MCP listActions); rejected here." ], "recommendation": "A, because the doubling (this card's defect) is fixed with no landed ruling weakened; the non-round-trippability the triage called the sharpest consequence pre-exists this card and belongs to every built artifact, which only a maintainer can re-rule (B)." }, { "question": "Contract-review flag: under strict: false, ONE action object placed in both positions (objects[].actions and stack.actions) is now carried once — before, twice. It is pinned as a measured consequence of identity-scoped idempotence. Keep it, or confine the identity skip to composeStacks so defineStack's non-strict output is byte-for-byte unchanged?", "options": [ "A — keep: one JS object is one declaration; strict mode is unaffected (the parse clones), #14686 does not run in that mode by the author's choice, and the runtime dedupes a standalone against an embedded entry by key anyway.", "B — confine the skip to composeStacks (a compose-only flag or a separate merge call), leaving defineStack's strict:false shape doubled as before." ], "recommendation": "A, because B keeps a known double alive in one mode for no consumer that wants it and adds a second merge path (the drift ADR-0116 exists about); the pin makes the consequence visible to the reviewer either way." } ], "out_of_scope_findings": [] }Generated by Claude Code
Generated by Claude Code
zhuangjianguo commented
on Sep 3, 2026 CollaboratorMore actionsContract review — PASS (in-seat, at tier) · ACCEPT — PR #15022 (head
f1ed87e8,Fixes #14847).domain:specseat,session_0174WZTU6XcFcS7g2kykC53i(seat post #6017), 2026-09-03T17:50Z. Fuse:last_served_model = claude-fable-5-1=CONTRACT_REVIEW_TIER(read 15:29Z). Provenance: the 2026-08-31 ruling (in-seat contract review by the dispatching lane).Report:
os-dev-report5529788476 (status: done,premise_still_valid: true, readings onf1ed87e8= the PR head); dev claim 5529342354. Reviewed against the card, triage 5521543316, the full diff on GitHub and this seat's readings oforigin/mainfddfc8db.① Derived judgments (Clause ② declared yes, conservatively): no schema, export, error code or payload key moves — the diff is a 31/−4 change inside
mergeActionsIntoObjects(packages/spec/src/stack.zod.ts) plus acomposeStackscomment, one new test file, one tightened mirror pin and a changeset. Accept set unchanged in both directions (nothing that composed before is refused; nothing refused is accepted). Emitted shape: a composed object'sactionsis the previous output minus the duplicate entries the second merge re-appended — measured before/after on the dev's fixtures ([dup_x/BOUND, dup_x/BOUND]→[dup_x/BOUND]; three stacks underoverride/merge:[emb3, b3, b1, b2, b3]→[emb3, b3, b1, b2];manifest: 'preserve''s two halves now agree); nothing reordered,ordersorts the once-merged set as before; theexamples/app-multi-packagecomposed object is byte-identical (sha256 unchanged). The discriminator is identity against the standalone list, never equality — the triage's binding constraint is met and pinned: a hand-written twin is still refused by #14686's rule with the exact message line; a marker key is refused by the strictActionSchema(why identity, not marking);collectComposedActionKeyCollisions(#14854) and its 23 message pins untouched. Fork decided by measurement: the idempotent merge is the smaller published-shape change; the alternative moves #14854's landed message pin.
② Semver / changeset:@objectstack/specpatch — correct (a build-output correction, no accept-set move);check:adr-0087-registration,check:empty-changeset,check:changeset-no-majorgreen.
③ Boundary flags — the two open questions, answered by the PM (neither is a maintainer decision: no ruling is weakened, no product semantics chosen):- Q1 → A. H3 as dispatched ("composed output round-trips through
defineStack") was falsified by a LANDED ruling, not by this change:defineStackrefuses every built stack that binds an action (pin atstack-duplicate-action-key.test.ts:236, "author the source shape, not the artifact" — confirmed onorigin/main). The achievable form is pinned: composition adds no refusal of its own (no bound action ⇒ parses; bound actions ⇒ the samedeclared twiceline a lone built input gets, never3 times). Consequence for the record: the "not round-trippable through the door that built its inputs" sentence in the card and the triage is a property of every built artifact under feat(spec): defineStack refuses two actions that resolve to one scope-qualified runtime key #14686's design, not of composition — the doubling this card reports is real and is fixed; reopening feat(spec): defineStack refuses two actions that resolve to one scope-qualified runtime key #14686 (option B) has no pull and is not filed. - Q2 → A (keep). Under
strict: false, one JS object placed in both positions is one declaration and is now carried once (before: twice) — pinned and documented; strict mode is unaffected (the parse clones), feat(spec): defineStack refuses two actions that resolve to one scope-qualified runtime key #14686's walk does not run in that mode by the author's choice, and the runtime already dedupes a standalone against an embedded entry by key. Confining the skip tocomposeStackswould keep a known double alive in one mode for no consumer and add a second merge path.
Checklist: draft;
Fixes #14847on line 1 (correct — the card's defect is fixed); #14848 is named without a closing keyword and stays open (itsmergekey handling untouched); scope = the four claimed files; nocontent/docs/releases/; NOT governed (0 of 4 paths); trial merge vsorigin/mainfddfc8dbclean; 13 suites / 339 tests green on the head, typecheck with--listFilesproof;check:generated15/15; reverse verification measured with the predicted mixed direction (identity filter removed ⇒ 8 red in the new suite + the tightened mirror pin red + the #14686 suite green; mutation and restore proven by blob hashes and an emptygit diff HEAD); gate family 58 derived — 53 green, 5 NOT MEASURED (whole-workspace prerequisites, the test-log grader, andcheck-half-states— #14899). Declared and accepted:os buildof the example not run (57-package CLI closure vs the 10-minute cap) — the exact objectcompile.ts:318parses was hashed through the built dist instead; the branch is 4 commits behindorigin/mainwith two gate scripts changed upstream — CI runs the current ones and is the authority;mcp_calls: 0(REST after a green probe), recorded not faulted. The drift bot's two pages (getting-started/examples.mdx,glossary.mdx) only mentioncomposeStacksgenerically — verified, nothing falsified.Landing shape: NOT governed, Clause ② PASS ⇒ ordinary queue landing. Same stroke:
needs:contract-reviewcleared on both carriers (PR #15022 + this card), each read back. Ready flip + auto-merge (squash) follow once every check onf1ed87e8is green (this seat's checkpoint). On MERGED: the card closes viaFixes,pm:dispatchedstripped,origin/mainprobed for the identity filter; #14848 and #14873 become dispatchable onstack.zod.ts(serial, in that order).
Generated by Claude Code
- Q1 → A. H3 as dispatched ("composed output round-trips through
zhuangjianguo commented
on Sep 3, 2026 CollaboratorMore actionsLanding note (PM seat
domain:spec, session_0174WZTU6XcFcS7g2kykC53i, 2026-09-03T18:59Z) — PR #15022 merged through the queue at 18:58:17Z as773a9996(enqueued 18:12:53Z); this card closed viaFixesat 18:58:19Z,closed_by_pull_requestsreads exactly that one PR.- Probe on
origin/main(fetched 18:59Z, tip0fb944b4):packages/spec/src/stack.zod.ts:2128carries the identity skipconst fresh = (actionsByObject.get(obj.name) ?? []).filter((action) => !base.includes(action));, andpackages/spec/src/compose-stacks-action-echo.test.tsis on the tree. Landed as reviewed (PASS + ACCEPT 5529809100). - Labels: closing strips pm state —
pm:dispatchedremoved with a compared readback (bug, domain:spec, priority:p2remain, MATCH at 18:59Z). - Lane inventory diff after the merge: this card is the only lane departure attributable to the merge; [finding] The #11284 convergence shipped only its producer half: the react-blocks contract deprecates
objectName/viewTypeon ListView in favour ofdata={{ provider: 'object', object }}/type, and the lint blesses that spelling — but objectui's ListView reads neither, so the canonical spelling validates green and renders an empty list #14791 (the other PR merged in the same minute,Part of) stays open by design. - Serial queue on
stack.zod.tsreleased: 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 (mergekey handling) is next, then [finding] Twopackages/spec/src/stack.zod.tscomments name the deletedactionObjectKeyas the LIVE registration-key reader — the two sites #14669's face did not reach #14873 (comments) — dispatched when a dev slot frees (cap 3, all three in use at 18:59Z).
Generated by Claude Code
- Probe on
- added a commit that references this issue
on Sep 9, 2026 - added a commit that references this issue
on Oct 9, 2026
Found while implementing #14662 (the cross-stack action-key check in
composeStacks); out of scope there and recorded here instead. Measured onmain@f3ae441fa,packages/spec/src/stack.zod.ts, twodefineStackoutputs composed — the shapeexamples/app-multi-packagecomposes.What was measured
defineStackends withmergeActionsIntoObjects(data): every standalone action carryingobjectNameis COPIED into that object'sactionson the way out, and the standalone stays instack.actions.composeStacksconcatenatesactionsacross its inputs and ends withmergeActionsIntoObjects(composed)again — so each bound standalone action is appended to its object a SECOND time, beside the copy the input's own build already put there:Same with three stacks under
objectConflict: 'merge'/'override'(the surviving object's copy plus every concatenated standalone): three entries for two declarations.manifest: 'preserve'inherits it.examples/app-multi-packagedeclares no action, so the shipped fixture does not show it.Why it matters
The runtime note recorded in the header of
packages/spec/src/stack-duplicate-action-key.test.ts(#14686):collectActionDeclarationspushes every embedded entry and dedupes only a standalone against an embedded one, MCPlistActionslists both, and bare-nameresolveActionByNamerefuses the ambiguity. A composed artifact therefore lists every bound action twice and cannot resolve it by bare name — and a composed artifact fed back intodefineStackis refused by #14686's same-key rule (two embedded twins under one key), socomposeStacksoutput is not round-trippable through the door that built its inputs.os builddoes not hit the refusal today becausecompile.tsvalidates the lowered stack withObjectStackDefinitionSchema.safeParse(schema only), notdefineStack; the doubled entries reachdist/objectstack.jsonunremarked.Suggested direction (for triage, not decided here)
Either
mergeActionsIntoObjectsbecomes idempotent — skip a standalone whose copy is already embedded on the object (identity or structural equality; #14686 deliberately counts an identical hand-written twin as a duplicate, so the skip must be scoped to the build's own echo, e.g. by marking or by comparing against the standalone list rather than treating any equal pair as an echo) — orcomposeStackscomposes the authored shape (standalone actions stripped of their build echo) before merging once. Which of the two is right is a contract question fordomain:spec.#14662's check deliberately counts DISTINCT stacks per key, not sites, precisely so this echo never reads as a collision; that decision is documented in
collectComposedActionKeyCollisionsand does not fix the doubling.Filing unassigned for triage.
Generated by Claude Code