fix(spec): composeStacks carries each bound standalone action once in the composed object - #15022
Conversation
… the composed object `mergeActionsIntoObjects` is idempotent over its own output: a bound action the object already carries by identity is not appended again, so the second merge `composeStacks` runs over inputs built by `defineStack` no longer doubles every bound action in the composed object. Identity against the standalone list, not equality — a hand-written twin is two objects after the strict parse and stays refused by the same-key rule, which runs before the merge and is untouched. Card: issue 14847 (spec lane). Pins in compose-stacks-action-echo.test.ts; the app-multi-package mirror pin in the collision test tightened to the contract. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i
📓 Docs Drift CheckThis PR changes 1 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 128 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 41a7a209583ca5e4bac713bdb085e54f647ed503 && git checkout 41a7a209583ca5e4bac713bdb085e54f647ed503
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin fddfc8db062d61ca68ba482531f5368326109554 f1ed87e8c84e5fd90836cbf904a44af06684ebf8 && git checkout -B drift-repro fddfc8db062d61ca68ba482531f5368326109554 && git merge --no-ff f1ed87e8c84e5fd90836cbf904a44af06684ebf8
node scripts/docs-audit/affected-docs.mjs --json fddfc8db062d61ca68ba482531f5368326109554
|
|
Landing provenance (PM seat
Generated by Claude Code |
Fixes #14847
Summary
defineStackends withmergeActionsIntoObjects: every standalone action carryingobjectNameis copied into that object'sactionson the way out, and the standalone stays instack.actions.composeStacksconcatenates its inputs'actionsand ended with the same merge — so each bound action was appended to its object a SECOND time, beside the copy the input's own build had put there.mergeActionsIntoObjectsis now idempotent over its own output: a bound action the object already carries by identity is not appended again. Identity against the standalone list, deliberately not equality — the only way an entry ofstack.actionsis the very same object as an entry ofobject.actionsis that a previous merge put it there. Nothing else in the merge moves: order, theordersort, the untouched-object fast path.Measured on
main@6392b9c2(BASE),defineStackoutputs as inputs — before / aftera_item.actions = [dup_x/BOUND, dup_x/BOUND][dup_x/BOUND]manifest: 'preserve'objects;packages[].manifest.objectscarried ONE each (the two halves disagreed)[dup_x/BOUND], both halves agreeembNand bindingbNto one object,override/mergeshared.actions = [emb3/EMB, b3/BOUND, b1/BOUND, b2/BOUND, b3/BOUND][emb3/EMB, b3/BOUND, b1/BOUND, b2/BOUND]— the surviving stack's declared actions plus each concatenated standalone oncedefineStack(composeStacks([a, b]))'a_item:dup_x' is declared 3 timesis declared twice, the identical linedefineStack(a)gets for a lone built inputdefineStack(a)(a lone built input)declared twice(the landed #14686 pin atstack-duplicate-action-key.test.ts:236)'t_item:tw' is declared twice)actions.0: Unrecognized key(s) on this action: \__echo``strict: false, ONE action object in both positionsexamples/app-multi-packagecomposed object (the objectcompile.ts:318parses), JSON sha256b1225296475ff3c7…(7351 bytes, both objects carry 0 actions)The fork, decided by measurement (dispatch ruling)
Both directions can satisfy (a) the pins and (b) the twin refusal — both would key on identity, since a marker does not survive the strict parse. They differ on the published shape and on what they touch:
collectComposedActionKeyCollisionsand its message untouched (it runs before the merge). Smallest shape change.mergeObjectschanges the input of feat(spec): composeStacks refuses two stacks whose actions resolve to one scope-qualified runtime key #14854's walk and therefore its message — the landed pin'com.example.b' (stack #1) at stack.actions[0] + objects['shared'].actions[0]names the echo site — and stripping after the walk but before the final merge is the same identity skip with a reorder of the surviving stack's own bound action ([emb3, b1, b2, b3]instead of[emb3, b3, b1, b2]). Larger shape change for the same result.Criterion (c), round-trip through
defineStack, holds in the only form the landed rulings allow.defineStackrefuses ANY built stack that binds an action — pinned atstack-duplicate-action-key.test.ts:236("a built stack fed back in … is refused — author the source shape, not the artifact") — sodefineStack(composeStacks([a, b]))cannot parse when an input binds an action under either direction without weakening that rule, which is ⛔. What this change restores is that composition adds no refusal of its own: with no bound action the composed output parses cleanly (as before), and with bound actions it is refused with the samedeclared twiceline a single built input gets, no longer3 times. Both are pinned. The card's and the triage's "not round-trippable through the door that built its inputs" is therefore a property of every built artifact, not of composition — noted for the contract review, not acted on here.Rulings honoured
collectComposedActionKeyCollisions: untouched; its message pins all green (23/23).objectConflict: 'merge'dropping non-fieldskeys): out of scope: 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 remains open and is not touched here;mergeObjectsis not edited.Changes
packages/spec/src/stack.zod.ts—mergeActionsIntoObjectsfilters the bound list by identity against the object's currentactionsbefore appending; TSDoc states the idempotence;composeStacksgains a step-7 comment at its final merge. 31 insertions, 4 deletions.packages/spec/src/compose-stacks-action-echo.test.ts— NEW, 12 pins: once under default /preserve/ three stacksoverride/merge; add-on binding to another stack's object;orderacross the once-merged set; twin refusal; marker refusal;strict: falseshared reference; idempotent re-merge by reference; round-trip with and without bound actions.packages/spec/src/compose-stacks-action-key-collision.test.ts— only the app-multi-package mirror pin that named 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 totoEqual; no refusal message moves..changeset/compose-stacks-action-echo.md—@objectstack/specpatch.Verification (all on
f1ed87e8, the head; throughscripts/pm/os-verify-lock.sh, shared-box seconds)pnpm --filter @objectstack/spec build—check-dts-emitted: 34/34,.build-input-hash 6192f26e…; dist carriesbase.includes(action)(1) and the old append form (0).pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2over the 13compose-stacks*/stack*/assembled-package-bodysuites —Test Files 13 passed (13),Tests 339 passed (339).pnpm --filter @objectstack/spec typecheck— green, incl.check:test-typecheck: OK — 54 file(s) / 261 error(s) … held;tsc -p tsconfig.test.json --listFilesnames the new test file (1 hit), so the test layer verdict covers it.pnpm --filter @objectstack/spec check:generated—✓ All 15 generated artifacts are up to date(check:api-surface,check:docs,check:authorable-surface,check:liveness… all ✓).node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackon the actual diff (58 commands): 52 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, and every speccheck:*in the list. The script flagged its tree as 4 commits behindorigin/mainwith two gate scripts changed upstream (check-cross-package-test-inputs,engine-double-contract.pinned.json); CI runs the current ones. NOT MEASURED, each by its own verdict text (exit 3 / prerequisite, not a finding):check-dev-prereqsandcheck:dual-build-cjs-loadsandcheck:type-check-debt(need the whole workspace built — CI owns),check-test-completeness(needs a turbo test log — its usage text says record NOT MEASURED locally),scripts/pm/check-half-states.mjs(live-repo PM sweeper; timed out at 180s with and without--use-env-proxy).check:doc-formula-expressionswas re-run after building@objectstack/formula— see the report comment for its line.Reverse verification (fix committed first)
Mutation: the identity filter removed (
const fresh = actionsByObject.get(obj.name) ?? [];— the pre-fix append), proven on disk by anchor counts (injected=1 removed=0) and a differing blob hash (790cfe5c…vs HEAD8c27145e…). Prediction recorded in the script header before the run: 8 red / 4 green controls in the new suite, 1 red in the collision suite (the tightened mirror pin), 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.test.tsall green —Tests 9 failed | 42 passed (51). No dist leg: these suites import./stack.zod(relative source), not the packageexports, so no rebuild was needed anddist/stayed on the fix throughout (marker re-checked after restore). Restore bygit checkout HEAD -- packages/spec/src/stack.zod.tsunder an EXIT/INT/TERM trap with absolute paths, proven bygit hash-objectequal to the HEAD blob andgit diff HEAD --statEMPTY.Contract review notes
actionsis the previous output minus the duplicate entries. Accept set unchanged in both directions.strict: false, one action object placed in both positions is now carried once (before: twice). Pinned and documented; flagged here for the reviewer.defineStack(composeStacks([a, b]))parses when no hand-written twin exists") is falsified by the landed feat(spec): defineStack refuses two actions that resolve to one scope-qualified runtime key #14686 pin whenever an input binds an action; the pins assert the achievable form described above.🤖 Generated with Claude Code
https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i
Generated by Claude Code
Generated by Claude Code