Uh oh!
There was an error while loading. Please reload this page.
feat(spec): composeStacks refuses two stacks whose actions resolve to one scope-qualified runtime key - #14854
Conversation
… one scope-qualified runtime key `composeStacks` concatenated `actions` across its inputs with no same-scope duplicate-key check, so two packages each legal on their own and each declaring `global:shared_refresh` composed into one collapsed handler key — the dead button `defineStack` refuses within one stack, one composition step later. The composed set is now walked with the same scope-qualified key rule, judged on what the composition carries, and a collision throws naming both source stacks by manifest id and where each declaration sits. No `actionConflict` option (maintainer ruling 2026-09-03). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE
📓 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 d1e36c64763f20a09fe34dfcc598969e032c9405 && git checkout d1e36c64763f20a09fe34dfcc598969e032c9405
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2263ca4d679026335f559184c0bed4e76d35a242 3887008447bfc66b3a7323052d1496090f8c9051 && git checkout -B drift-repro 2263ca4d679026335f559184c0bed4e76d35a242 && git merge --no-ff 3887008447bfc66b3a7323052d1496090f8c9051
node scripts/docs-audit/affected-docs.mjs --json 2263ca4d679026335f559184c0bed4e76d35a242
|
Contract review — FAIL (patch round 1) — Everything substantive passed — ruling A faithful, both dev boundary flags CONFIRMED as shipped (distinct-stack counting; judged on what composition carries), semver/changeset precedent-identical, scope fences intact, Required patches before landing:
Recommended, non-blocking: stack.zod.ts:395 — "runs the same key rule across its input stacks (counting distinct stacks, not sites) and names both source stacks on a collision." Dev: apply exactly these two on the same branch ( Generated by Claude Code |
…cks collision walk; pin it; loosen the multi-package mirror pin Contract review patch round 1 (isolated reviewer verdict on the card): the standalone branch keyed `objectName` by nullish coalescing where the sibling walk, `mergeActionsIntoObjects` and objectql's owner-key ladder all resolve an empty string by truthiness — now `||`, with a pin. The app-multi-package mirror pin no longer encodes the doubled bound entry (#14847) as the contract. Describe text says "same key rule (counting distinct stacks, not sites)". Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE
os-sam
commented
Sep 3, 2026
CI on That is #14832 — the Failed jobs re-run once (MCP Generated by Claude Code |
os-sam
commented
Sep 3, 2026
Landing provenance (seat, 06:44Z) — ready + auto-merge (squash) from here.
Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#14662
Summary
composeStacksconcatenatedactionsacross its inputs with no same-scope duplicate-key check. Two packages, each legal on its own, each declaring a global action namedshared_refresh, composed into["global:shared_refresh", "global:shared_refresh"]— and the runtime, which registers and dispatches every action under that one exact-string key, collapsed both handlers to one registration: the dead buttondefineStackrefuses within one stack (#14686), arriving one composition step later.Maintainer ruling on the card (5520465813, director seat, 2026-09-03, verbatim): 「同意,然后执行契约复审」 — option A.
composeStacksnow runs the same scope-qualified key check over the composed action set; a collision throws, naming both source stacks by manifest id and where each declaration sits. ⛔ NoactionConflictoption. ⛔objectConflict,devPlugins(#14722),ManifestSchemaandformatZodErrorare untouched; #14124 is not widened.Clause-②: yes — a published API's accept set narrows. This PR is a DRAFT and carries
needs:contract-review; it is not flipped to ready by this seat. Contract review atCONTRACT_REVIEW_TIERper the pm-dispatch reference.What changed (
packages/spec/src/stack.zod.tsonly, plus tests and a changeset)mergeObjectsnow also returnsactionsOwner— per composed object name, the index of the input stack whoseactionsarray the composed object carries. Recorded beside the spread that decides it (first sighting and'override'hand the whole object to stack i;'merge'handsactionsto the later object only when that object has the key itself), so the provenance is never re-derived from the strategy elsewhere.collectComposedActionKeyCollisions(stacks, composedObjects, actionsOwner): walks every input's standaloneactions(they concatenate, attributed to the stack that wrote them) and every composed object's embeddedactions(attributed toactionsOwner), keys each asobjectName:name/global:nameexactly ascollectDuplicateActionKeyErrorsdoes, and reports every key declared by two or more DISTINCT stacks.formatComposedActionKeyCollisions:defineStack's envelope shape under thecomposeStacks conflict:prefix; names no strategy option.composeStacksstep 6 runs the check after every collection is composed and BEFOREmergeActionsIntoObjects(see H1 below for why that order is load-bearing).composeStacksJSDoc and theactionscollection's describe text state the rule.The exact refusal (the card's case, two legal stacks each declaring a global
shared_refresh)A manifest-less input is named by position (
stack #0); a stack that declares a key at several sites lists them joined by+(stack.actions[0] + objects['shared'].actions[0]); N colliding keys are N✗lines under(N issues).PM mechanism hypotheses — measured (all probes on
origin/mainf3ae441fa,defineStackoutputs as inputs, source not dist)collectDuplicateActionKeyErrorsover the composed set beforemergeActionsIntoObjects; provenance is lost after'concat'defineStackOUTPUT already carries each bound standalone action twice — instack.actionsand as the copymergeActionsIntoObjectsappended to its object on the way out. Running the site-counting walk over a composition of built stacks would refuse every bound action as a collision with itself (composeStacks([a, b])with A bindingdup_xtoa_itemand B unrelated:a_item:dup_xatstack.actions[0]ANDobjects['a_item'].actions[0], both from A). So the check counts DISTINCT STACKS per key, not sites; provenance for embedded actions comes frommergeObjects(actionsOwner), for standalone ones from the input index. The check still runs BEFOREmergeActionsIntoObjectsso the composition's own echo is not counted either. Same error class (Error).composeStacks conflict:shape; noactionConflictin the remedy; check the error-code ledgercomposeSingleValue/mergeObjectsthrow plainErrorwith acomposeStacks conflict:prefix and aFix:tail — matched, withdefineStack's(N issue(s)):+✗line envelope for aggregation. Message names no option (pinned:not.toMatch(/actionConflict|objectConflict/)).check:dispatcher-error-vocabulary(ADR-0112) governs HTTP dispatchererror.codeenvelopes — an authoring-timeErrorthrown bycomposeStacksis not on that surface; the gate ran green (#34 below) andcheck:error-code-casingis not in the derived family. No ledger entry needed.mergeObjectsper strategy'merge'={ ...existing, ...obj, fields: { ...existing.fields, ...obj.fields } }: onlyfieldsmerges;actions(like every other key) is the LATER object's whenever it carries the key.'override'replaces the object wholesale. Measured base behaviour and the pinned verdicts: (P2) bound(A→a_item) + global(B) — two keys, ACCEPTED; (P6) same name bound to a_item (A) and b_item (B) — two keys, ACCEPTED; (P4) both stacks bind a standalonedup_sto a shared object,mergeoroverride— both standalones concatenate, REFUSED naming A atstack.actions[0]and B atstack.actions[0] + objects['shared'].actions[0]; (P5b) B binds a standalone toshared, A (last) embeds the same name — A's object wins, A's embedded + B's standalone, REFUSED; (P5) the same pair with A first — B's object wins and A's embedded action is NOT carried, ACCEPTED (one handler reaches the key); (P3) both stacks EMBED the same name on one object,mergeoroverride— the later array replaces the earlier, one entry, ACCEPTED. The rule is judged on what composition CARRIES: an embedded action the object strategy discarded cannot collide — that loss is the strategy's own semantics (filed as #14848, not addressed here).examples/app-multi-packagecomposes unchanged; re-run #14686's corpus passdefineStackand counted declared actions (crm 1 / showcase 70 / todo 8 / multi-package 0); it is a PR-recorded run, not a checked-in script.app-multi-packageis the onlycomposeStackscaller in the repo (and hotcrm / objectui carry none — director measurement on the card). Run here through the BUILT spec (the example resolves@objectstack/spectodist/) at this head:manifest.id=com.example.multi.core,packages=[com.example.multi.orders, com.example.multi.core],objects=[crm_order, crm_account], top actions 0, embedded actions 0 — composes unchanged. A mirror of that shape with one distinct action per package is pinned in the new suite.Derived judgments for the contract review (each one a decision this diff makes; the reviewer should confirm or refuse them)
defineStack's door (strict: falseopted out of it there by choice) and is NOT re-checked bycomposeStacks— pinned. The alternative (re-running the within-stack walk) is impossible without first solving the build echo in H1.objectConflict: 'override'/'merge', an earlier stack's embedded action that the strategy did not carry into the composed object is not a collision party (P5 / P3 accepted). The alternative — counting every input's declarations regardless of strategy — would refuse the add-on-overrides-core case that'override'exists for whenever both objects embed a same-named action.global:NAME; two stacks each binding a standalone action to one object name; a standalone bound to an object the OTHER stack's composed object embeds under the same name. Semver:minorwith a BREAKING banner, the same convention feat(spec): defineStack refuses two actions that resolve to one scope-qualified runtime key #14686 shipped under; ADR-0087 dispositionnot-required (no-migration-prescription)in the changeset (the marker line itself is in the changeset file; it is not reproduced here because the GitHub sanitizer eats comment-shaped fragments).compose-stacks-action-key-collision.test.ts, including the full envelope for the card's case.Changeset (
.changeset/compose-stacks-action-key-collision.md,@objectstack/spec: minor)Opens with the BREAKING banner (
**BREAKING** accept-set narrowing, shipped as minor under the repo's launch-window convention for breaking changes), names the refused shape (two or more input stacks each declaring an action resolving to oneobjectName:name/global:namekey), reproduces the envelope, states what the check judges (what composition carries; distinct stacks; the build echo is never a collision with itself), what stays accepted (the cross-scope pair; one name on two objects), the corpus fact (app-multi-packagecomposes unchanged; no callers in objectui / hotcrm), the migration (rename within scope, rebind, or drop — an authoring decision), and the ADR-0087 disposition marker.Verification record — every command below ran on the final head
d3081ab88(this branch has one commit; the tree was clean before and after every run)pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2over the new suite pluscompose-stacks.test.ts,stack-duplicate-action-key.test.ts,compose-stacks-manifest-preserve.test.ts,compose-stacks-key-loss.test.ts,compose-stacks-i18n-merge.test.ts,stack-artifact-packages.test.ts,stack.test.ts,stack-inline-action-crossref.test.ts(underscripts/pm/os-verify-lock.sh):Test Files 9 passed (9)/Tests 284 passed (284); lock VERDICTcommand-exit 0. The new suite is 22 tests.git restore --source=f3ae441fa -- packages/spec/src/stack.zod.ts(tree only), on-disk proofgrep -c collectComposedActionKeyCollisions= 0 andgit hash-object= the base blob493e930…; the new suite then readsTests 11 failed | 11 passed (22)— every refusal pin red, every acceptance pin green (measured direction: red, as predicted). Restored withgit checkout HEAD -- PATH(absolute path, trap-guarded): marker count 4,git hash-object= HEAD blob6af34ee…,git diff HEADempty. The suite imports./stack.zodfrom source (no dist resolution, no vitest alias), so no rebuild is part of this leg.pnpm --filter @objectstack/spec typecheck(tsc +check:scripts-typecheck+check:test-typecheck, under the lock): exit 0,check:test-typecheckself-test line printed green.pnpm --filter @objectstack/spec build(under the lock, 147 s):check-dts-emitted: 34/34. Thenpnpm --filter @objectstack/spec check:generated:✓ All 15 generated artifacts are up to date— the describe-text edit produced no artifact diff (the tree stayed clean aftergen:schema), so no regeneration commit exists.node scripts/pm/dispatch-gates.mjs --commands(no paths; the script derived from the merge-base itself, header line names this repo atd3081ab88): 53 commands — 47 of the PM's family plus the changeset / new-test additions. Each ran with its exit code captured before any pipe (bash -c CMD > LOG 2>&1; EXIT=$?). 48 exited 0. The other five, read from their own verdict lines:check-dev-prereqs:The workspace is not built — 1 unmet precondition, not a list of problems(66 of 67 dists absent; apnpm devprecondition, not a reading of this diff) — NOT MEASURED locally, CI builds.check-test-completeness:PREREQUISITE NOT MET — this gate grades a saved turbo run test log, and no log was named(exit 3; the script itself says to record it as NOT MEASURED when run from the derived family) — NOT MEASURED locally.check:doc-formula-expressions:PREREQUISITE NOT MET — the workspace package @objectstack/formula is not built(exit 3); afterturbo run build --filter=@objectstack/formulathe re-run reports the next unbuilt link,@objectstack/lint(exit 3 again). Its input is the built lint package, not this diff — NOT MEASURED locally, CI builds the closure.check:dual-build-cjs-loads:PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/(85 packages) — NOT MEASURED locally.check:type-check-debt:--re-measure cannot run: 54 workspace dependencies of the ledgered packages have no built type entry point— NOT MEASURED locally (the coverage half printed OK — 69/79 type-checked).Named-family reads of note:
check:adr-0087-registration0,check:changeset-no-major0,check:api-surface0,check:docs0,check:dispatcher-error-vocabulary0,check:stack-collection-maps0,check:cross-package-test-inputs0 (the new suite reads nothing outside its package).pnpm check:nul-bytes:check-nul-bytes: OK (scanned 8072 text file(s) … no raw ASCII control bytes).dist/at this head:app-multi-packagecomposes; output above.pnpm lintis CI's run and was not run here (declared narrowing; no eslint measurement is claimed).Out of scope — filed unassigned, neither is addressed here
composeStacksre-merges bound standalone actions thatdefineStackalready copied into their objects: every bound action appears twice in the composed object'sactions(measured P2/P4/P6). 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 remains open.objectConflict: 'merge'mergesfieldsonly; the later object'sactions(and every other key) replace the earlier package's wholesale, silently dropping its embedded actions (measured P3). 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.Dedupe before filing: REST list of all 481 open issues plus 1,200 recently-closed ones, grepped locally for
composeStacks/mergeActionsIntoObjects/objectConflict; #14512 (artifact serialized twice) and #14599 (attribution) are the nearest neighbours and are different defects.Generated by Claude Code
🤖 Generated with Claude Code
https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE
Generated by Claude Code