Uh oh!
There was an error while loading. Please reload this page.
feat(spec): defineStack refuses two actions that resolve to one scope-qualified runtime key - #14686
Conversation
…ed key Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE
…ry and the differing-twin refusal Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE
📓 Docs Drift Check5 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. 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 3145bc34358f244c21acc887a8da2608119fb922 && git checkout 3145bc34358f244c21acc887a8da2608119fb922
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c616c2cc20fa74428900d5bb7376dd9d506c717e aa937c775121bb92aaa90ebd9a31c5005ba23b3a && git checkout -B drift-repro c616c2cc20fa74428900d5bb7376dd9d506c717e && git merge --no-ff aa937c775121bb92aaa90ebd9a31c5005ba23b3a
node scripts/docs-audit/affected-docs.mjs --json c616c2cc20fa74428900d5bb7376dd9d506c717e |
os-sam
commented
Sep 2, 2026
Contract review (clause ②) of head Blocking item: the case-(c) carve-out (a byte-identical embedded copy of a bound standalone counted as one declaration). After
Generated by Claude Code |
…d; the vacuity pin feeds the merged shape authored directly Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE
Uh oh!
There was an error while loading. Please reload this page.
defineStacknow refuses two actions that resolve to the same scope-qualified runtime key, inside its existing cross-reference walk and envelope. The cross-scope pair (one global, one object-bound, samename) stays accepted and is pinned as accepted; its precedence is documented on theactionscollection, not changed. Patch round (option B from the contract review): every site under one key counts, byte-identical twins included; the #7397 vacuity pin feeds the merged shape authored directly instead of a re-fed build.Rulings executed (triage 5503034098, verbatim in the card)
packages/spec/src/stack.zod.ts,validateCrossReferences— one✗line per colliding key inside the existingdefineStack cross-reference validation failed (N issue(s)):envelope. Envelope shape untouched (bareError, no ADR-0112 code) — that is finding(spec):defineStack's cross-reference refusals are bareErrors — no ADR-0112code/status— so five REFUSED item classes in the ADR-0130 matrix are distinguishable only by message text #14552's card and is not absorbed here.global:NAME/OBJECT:NAME) and where each declaration was written (stack.actions[i],objects['OBJECT'].actions[j]).resolveRouteActionDeclaration: the object's ownactionsfirst) is now written into the JSDoc and.describe()ofObjectStackDefinitionSchema.actions.Mechanism measured on
origin/main@2aa8456cf(before any edit)One probe per row through
defineStackfrom source:validateCrossReferencesruns on the parseddataBEFOREmergeActionsIntoObjects, and reads bothconfig.actions(standalone, scoped byobjectNameor global) andconfig.objects[].actions(embedded, scoped by the owning object). Both positions are in its input today; nothing had to be widened.mergeActionsIntoObjectsdoes[...base, ...objActions]: append. Both survive intoobject.actionsunder oneOBJECT:NAMEkey, so (c) is same-scope and is refused — identical twins included (see the next section).GLOBAL_ACTION_OBJECT_KEY = 'global'(packages/objectql/src/action-governance.ts:61;packages/runtime/src/action-execution.tsstandaloneActionObjectNamefalls back to it;collectActionDeclarationsbuilds${objectName}:${action.name}). The refusal uses that literal, spelled in spec as a module-privateGLOBAL_ACTION_SCOPEwith a lockstep note (spec sits below objectql and cannot import it).ObjectQL.registerActionisthis.actions.set(key, …)(packages/objectql/src/engine.ts), so the second declaration to register replaces the first with no diagnostic — the shadow the card describes.composeStacksnever routes through the walk (actions: 'concat', thenmergeActionsIntoObjectsdirectly) — left untouched per the ruling; the cross-package collision it admits is filed ascomposeStacksconcatenatesactionsacross packages with no same-scope duplicate-key check — two packages each declaring oneglobal:NAMEcompose into one collapsed handler key #14662.Every site counts — the #7397 vacuity pin now feeds the merged SHAPE, not a re-fed build (contract review, option B)
The whole spec suite surfaced one red on the first head:
stack-inline-action-crossref.test.ts"is vacuity-guarded: the ordinary merged shape a shipped stack produces still builds" feddefineStack's OUTPUT back intodefineStack. On the way out,mergeActionsIntoObjectsAPPENDS every bound standalone action into its object'sactions, so a built stack carries each bound action in both positions — case (c) by shape. The first head absorbed a byte-identical pair as one declaration; the contract review (card comment 5514638537, item 2) measured that this premise does not survive the merge:collectActionDeclarations(packages/runtime/src/action-execution.ts) dedupes a standalone against an embedded entry only and pushes every embedded entry unconditionally, so the absorbed pair became TWO embedded entries under one key in the shipped artifact — MCPlistActions(packages/runtime/src/domains/mcp.ts) lists it twice and bare-nameresolveActionByNamethrowsexists on multiple objects (X, X). The walk said "one declaration" while the artifact carried two, and the re-entrancy it protected has no production caller and is acceptance-only (re-feeding doublesobjects[X].actionseach pass).So every site under one key counts, byte-identical twins included — the identical case is a delete, not a rename, and the remedy tail now says so ("Rename one of them within this scope, bind one to a different object, or remove the duplicate."). A stack BUILT by
defineStackis therefore refused when fed back in (pinned as a refusal instack-duplicate-action-key.test.ts, one✗line per bound action). Re-entrancy ofdefineStackoutput as a contract is not decided here; if wanted, it pairs with an idempotentmergeActionsIntoObjectson its own card.What the #7397 pin feeds now, and why it still guards. The rewritten pin authors the merged shape DIRECTLY on the object —
objects[0].actions= a modal action targetingprobe_homeand a flow action targetingprobe_flow, each carryingobjectName: 'probe_task'exactly as the merge would have written them, with no top-level twin — and assertsrefusals(authored)is[]and the object's action names are['probe_new_task', 'probe_run']. #7397's guard is that the embedded walk's target checks refuse nothing on the shape the merge produces; that shape is what is fed, without the doubled positions the refusal now (correctly) reads as two declarations.Scope key of an embedded action (reviewer's non-blocking note). The walk keys an embedded action by its OWNING object — the declaration-resolution key
collectActionDeclarations/resolveRouteActionDeclarationuse — not by the action's ownobjectName, which is the registration keycollectBundleActions(packages/runtime/src/app-plugin.ts) /actionObjectKey(packages/objectql/src/plugin.ts) read. The by-name collision the card describes happens on the resolution side, so the walk follows it; this is stated in the JSDoc onObjectStackDefinitionSchema.actionsand oncollectDuplicateActionKeyErrors.Re-feed grep (origin/main, before the patch).
git grep -n "defineStack(" origin/main -- packages ':!packages/spec' | grep -v -E "\.md:"lists only authoring sites (CLI templates, qa/dogfood fixtures, i18n-extract configs, pluginobjectstack.config.ts, verify harness fixtures) plus test helpers that feed PLAIN inputs. Tests whose argument is a variable rather than a literal:packages/lint/src/authoring-rule-input-tier.test.ts:76andpackages/lint/src/validate-form-layout.test.ts:350(cliTierFor:defineStack(stack)on a hand-written fixture, thennormalizeStackInputon the OUTPUT — the CLI tier model, not a re-feed),packages/spec/src/stack-requires.test.ts:77(build(stack)on plain inputs), andpackages/spec/src/assembled-package-body.test.ts:225(defineStack(composed())— acomposeStacksoutput of two built stacks that declare NO actions, so no bound action is doubled). All four ran green on the patched head (the two spec files inside the whole suite and in the targeted run; the two lint files in the lint run).Corpus pass — the four shipped examples, loaded through
defineStackwith the refusal in place (final head, built spec)examples/app-crmexamples/app-showcaseexamples/app-todoexamples/app-multi-package(core,orders, composed)Loading is the measurement: each
objectstack.config.ts(and each multi-package sub-stack) randefineStackat import with the refusal live and threw nothing (corpus pass: every example loaded through defineStack with the refusal in place). No example file is edited.Changeset level
'@objectstack/spec': minorwith a**BREAKING**banner andadr-0087: not-required (no-migration-prescription). Reasoning: a stack that parses today is refused, so this is an accept-set narrowing — breaking by ADR-0059's definition.scripts/check-changeset-no-major.mjs(launch-window guard, end condition at GA) forbids amajorbump and names the**BREAKING**banner plus the ADR-0087 disposition as the mandatory carriers;check-adr-0087-registration.mjskeys on that banner.no-migration-prescriptionbecause nothing is renamed or removed — no authorable key changes spelling, no export moves, the refusal message names the fix, and the fix (rename one declaration, or rebind it) is a per-stack judgment, not a FROM → TO rewrite the ledger could carry. Nearest precedent:.changeset/define-stack-trigger-capability-refusal.md(#14330, same file, same day), which chose exactly this shape.Verification (all on the patched head
aa937c775; every exit code captured before any pipe; lock-held runs are shared-box readings)@objectstack/specwhole suite, sharded into two lock-held runs so each stays under the container's foreground cap: shard A (src/data src/ui src/kernel src/system)Test Files 227 passed (227),Tests 7011 passed (7011); shard B (every other directory,scripts/, and the 12 root files by name)Test Files 228 passed (228),Tests 5229 passed (5229)— together 455 files / 12240 tests,VERDICT command-exit 0on both.@objectstack/spectypecheck —check:test-typecheck: OK … 54 file(s) / 262 error(s) / 146 pinned signature(s)(ledger held, no growth);tsc -p tsconfig.test.json --listFileslists bothstack-duplicate-action-key.test.tsandstack-inline-action-crossref.test.ts.VERDICT command-exit 0.errors.push(...collectDuplicateActionKeyErrors(config));replaced on disk (marker 1 → 0, injected 1, disk bloba208575fvs HEAD493e9307),stack-duplicate-action-key.test.tswent11 failed | 5 passed (16)— every refusal pin red (the built-stack re-feed and identical-copy pins included), every accept pin green; restore viagit checkout HEAD -- ABSPATHproven by blob hash (493e9307…both sides),git diff HEADempty, porcelain empty. Trap-restored, absolute paths. No build needed — the pins import./stack.zodfrom source. (Its restore re-touchedstack.zod.tsafter the DTS build, so spec was rebuilt once more before the declaration gates below.)pnpm --filter PKG exec vitest run --maxWorkers=2 FILESunderos-verify-lock.sh, exit 0 each:@objectstack/runtime—src/http-dispatcher.actions-global-key.test.ts+ the foursrc/action-execution-*.test.ts:Test Files 5 passed (5),Tests 133 passed (133).@objectstack/lint—validate-action-name-refs.test.ts+reference-integrity-suite.test.ts+ the two re-feed candidatesauthoring-rule-input-tier.test.ts+validate-form-layout.test.ts:Test Files 4 passed (4),Tests 69 passed (69).@objectstack/objectql—action-governance.test.ts+plugin-action-governance-rung.test.ts:Test Files 2 passed (2),Tests 20 passed (20).@objectstack/example-crmwhole suite:Test Files 5 passed (5),Tests 45 passed (45);@objectstack/example-todo:Test Files 4 passed (4),Tests 106 passed (106);@objectstack/example-showcase:Test Files 26 passed (26),Tests 367 passed (367);@objectstack/example-multi-packagehas no vitest suite — itstypecheck(tsc --noEmit) exit 0, and its two sub-stacks plus the composed artifact loaded in the corpus pass (same numbers as the first head: crm 1 / showcase 70 / todo 8 / multi-package 0 declared, zero same-scope duplicates).OS_SKIP_DTS=1; note for the next author: turbo's^buildre-ran@objectstack/specunder that flag despite--filter='!@objectstack/spec', clobbering its.d.ts— spec was rebuilt without the flag before every declaration-reading gate.check:generated(All 15 generated artifacts are up to date— the.describe()edit projects into no generated page, measured),check:api-surface(public API surface + factory signatures unchanged ✓),check:exported-any(2449 types + 1523 schemas across 17 entry points),check:dual-source-exports(5031 names … 0 accepted dual-source),check:entry-nameability(432 call probes … 0 new unnameable),check:export-origins,check:docs(229 generated files in sync),check:doc-authoring,check:nul-bytes,check:adr-0087-registration,check:changeset-no-major,check:empty-changeset,check:changeset-gate-self-tests,check:cross-package-test-inputs,check:stack-collection-maps,check:test-source-alias; the first-head runs ofcheck:spec-parsed-alias,check:keyed-text-bounds,check:page-declaration-shape, docs-auditcheck-affected-docs/check-drift-comment, speccheck:empty-state/check:variant-docs/check:llms-txt/check:browser-reachable-entriesread files this patch did not touch.check-test-completenessexits 3 locally by design (NOT MEASURED — needs a turbo test log). The derived family list (53 commands fromdispatch-gates.mjs --commands --repo objectstack-ai/objectstackataa937c775) is unchanged from the first head; the remaining families are CI's.Out of scope, filed
composeStacksconcatenatesactionsacross packages with no same-scope duplicate-key check — two packages each declaring oneglobal:NAMEcompose into one collapsed handler key #14662 —composeStacksconcatenatesactionsacross packages with no same-scope key check (measured: two packages each withglobal:shared_refreshcompose to two identical keys).defineStack's cross-reference refusals are bareErrors — no ADR-0112code/status— so five REFUSED item classes in the ADR-0130 matrix are distinguishable only by message text #14552 (open, not touched) — the refusal envelope shape.Contract review
Draft PR;
needs:contract-reviewparked on this PR and on #14124. Not marked ready, auto-merge not armed — the seat runs the in-seat contract review, clears the gate on both carriers, and lands it.Fixes#14124
🤖 Generated with Claude Code
https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE
Generated by Claude Code