feat(spec): defineStack refuses two actions that resolve to one scope-qualified runtime key - #14686

Merged
os-sam merged 4 commits into
mainfrom
claude/issue-14124-define-stack-duplicate-action-key
Sep 2, 2026
Merged

feat(spec): defineStack refuses two actions that resolve to one scope-qualified runtime key#14686
os-sam merged 4 commits into
mainfrom
claude/issue-14124-define-stack-duplicate-action-key

Conversation

@os-sam

@os-samos-sam commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

defineStack now 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, same name) stays accepted and is pinned as accepted; its precedence is documented on the actions collection, 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)

Mechanism measured on origin/main @ 2aa8456cf (before any edit)

One probe per row through defineStack from source:

(a) two standalone globals, one name : ACCEPTED stack.actions=["global:dup_a","global:dup_a"]
(b) two standalone bound to the same object : ACCEPTED object.actions=["dup_b/BOUND","dup_b/BOUND"]
(c) standalone bound to X + embedded on X : ACCEPTED object.actions=["dup_c/EMB","dup_c/BOUND"] -- the merge APPENDS
(d) two embedded on one object : ACCEPTED object.actions=["dup_d/EMB","dup_d/EMB"]
(z) object-less stack, two globals : ACCEPTED stack.actions=["global:dup_z","global:dup_z"]
(x) cross-scope: global + bound to X : ACCEPTED stack.actions=["global:dup_x","probe_item:dup_x"]
(y) cross-scope: global + embedded on X : ACCEPTED stack.actions=["global:dup_y"], object.actions=["dup_y/EMB"]
  • Where the walk gets its action set:validateCrossReferences runs on the parsed data BEFORE mergeActionsIntoObjects, and reads both config.actions (standalone, scoped by objectName or global) and config.objects[].actions (embedded, scoped by the owning object). Both positions are in its input today; nothing had to be widened.
  • Case (c) — replace or append?mergeActionsIntoObjects does [...base, ...objActions]: append. Both survive into object.actions under one OBJECT:NAME key, so (c) is same-scope and is refused — identical twins included (see the next section).
  • Global-scope literal: the runtime keys an object-less action under GLOBAL_ACTION_OBJECT_KEY = 'global' (packages/objectql/src/action-governance.ts:61; packages/runtime/src/action-execution.tsstandaloneActionObjectName falls back to it; collectActionDeclarations builds ${objectName}:${action.name}). The refusal uses that literal, spelled in spec as a module-private GLOBAL_ACTION_SCOPE with a lockstep note (spec sits below objectql and cannot import it).
  • Registration is a silent last-writer-wins:ObjectQL.registerAction is this.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.
  • composeStacks never routes through the walk (actions: 'concat', then mergeActionsIntoObjects directly) — left untouched per the ruling; the cross-package collision it admits is filed as composeStacks concatenates actions across packages with no same-scope duplicate-key check — two packages each declaring one global:NAME compose 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" fed defineStack's OUTPUT back into defineStack. On the way out, mergeActionsIntoObjects APPENDS every bound standalone action into its object's actions, 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 — MCP listActions (packages/runtime/src/domains/mcp.ts) lists it twice and bare-name resolveActionByName throws exists 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 doubles objects[X].actions each 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 defineStack is therefore refused when fed back in (pinned as a refusal in stack-duplicate-action-key.test.ts, one line per bound action). Re-entrancy of defineStack output as a contract is not decided here; if wanted, it pairs with an idempotent mergeActionsIntoObjects on 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 targeting probe_home and a flow action targeting probe_flow, each carrying objectName: 'probe_task' exactly as the merge would have written them, with no top-level twin — and asserts refusals(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 / resolveRouteActionDeclaration use — not by the action's own objectName, which is the registration key collectBundleActions (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 on ObjectStackDefinitionSchema.actions and on collectDuplicateActionKeyErrors.

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, plugin objectstack.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:76 and packages/lint/src/validate-form-layout.test.ts:350 (cliTierFor: defineStack(stack) on a hand-written fixture, then normalizeStackInput on the OUTPUT — the CLI tier model, not a re-feed), packages/spec/src/stack-requires.test.ts:77 (build(stack) on plain inputs), and packages/spec/src/assembled-package-body.test.ts:225 (defineStack(composed()) — a composeStacks output 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 defineStack with the refusal in place (final head, built spec)

appdeclared actionsstandalone (bound)embeddedscope keys (global / object-bound)same-scope duplicates
examples/app-crm11 (1)01 (0 / 1)0
examples/app-showcase7070 (69)070 (1 / 69)0
examples/app-todo88 (8)08 (0 / 8)0
examples/app-multi-package (core, orders, composed)0 / 0 / 00000

Loading is the measurement: each objectstack.config.ts (and each multi-package sub-stack) ran defineStack at 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': minor with a **BREAKING** banner and adr-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 a major bump and names the **BREAKING** banner plus the ADR-0087 disposition as the mandatory carriers; check-adr-0087-registration.mjs keys on that banner. no-migration-prescription because 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/spec whole 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 0 on both.
  • @objectstack/spec typecheck — check:test-typecheck: OK … 54 file(s) / 262 error(s) / 146 pinned signature(s) (ledger held, no growth); tsc -p tsconfig.test.json --listFiles lists both stack-duplicate-action-key.test.ts and stack-inline-action-crossref.test.ts. VERDICT command-exit 0.
  • Reverse verification on the committed head: with errors.push(...collectDuplicateActionKeyErrors(config)); replaced on disk (marker 1 → 0, injected 1, disk blob a208575f vs HEAD 493e9307), stack-duplicate-action-key.test.ts went 11 failed | 5 passed (16) — every refusal pin red (the built-stack re-feed and identical-copy pins included), every accept pin green; restore via git checkout HEAD -- ABSPATH proven by blob hash (493e9307… both sides), git diff HEAD empty, porcelain empty. Trap-restored, absolute paths. No build needed — the pins import ./stack.zod from source. (Its restore re-touched stack.zod.ts after the DTS build, so spec was rebuilt once more before the declaration gates below.)
  • Consumer readings, each pnpm --filter PKG exec vitest run --maxWorkers=2 FILES under os-verify-lock.sh, exit 0 each:
    • @objectstack/runtimesrc/http-dispatcher.actions-global-key.test.ts + the four src/action-execution-*.test.ts: Test Files 5 passed (5), Tests 133 passed (133).
    • @objectstack/lintvalidate-action-name-refs.test.ts + reference-integrity-suite.test.ts + the two re-feed candidates authoring-rule-input-tier.test.ts + validate-form-layout.test.ts: Test Files 4 passed (4), Tests 69 passed (69).
    • @objectstack/objectqlaction-governance.test.ts + plugin-action-governance-rung.test.ts: Test Files 2 passed (2), Tests 20 passed (20).
    • @objectstack/example-crm whole 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-package has no vitest suite — its typecheck (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).
    • Consumer dists were restored through turbo with OS_SKIP_DTS=1; note for the next author: turbo's ^build re-ran @objectstack/spec under that flag despite --filter='!@objectstack/spec', clobbering its .d.ts — spec was rebuilt without the flag before every declaration-reading gate.
  • Gates green on the patched head: 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 of check:spec-parsed-alias, check:keyed-text-bounds, check:page-declaration-shape, docs-audit check-affected-docs / check-drift-comment, spec check:empty-state / check:variant-docs / check:llms-txt / check:browser-reachable-entries read files this patch did not touch. check-test-completeness exits 3 locally by design (NOT MEASURED — needs a turbo test log). The derived family list (53 commands from dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at aa937c775) is unchanged from the first head; the remaining families are CI's.

Out of scope, filed

Contract review

Draft PR; needs:contract-review parked 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

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
@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@github-actions

github-actionsBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

5 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 128 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json c616c2cc20fa74428900d5bb7376dd9d506c717epackageMentionDocs.

Which tree this was computed on

This run read content/docs from 3145bc34358f244c21acc887a8da2608119fb922 — the merge of head aa937c775121bb92aaa90ebd9a31c5005ba23b3a into base c616c2cc20fa74428900d5bb7376dd9d506c717e, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@os-samClaude

Copy link
Copy Markdown
CollaboratorAuthor

Contract review (clause ②) of head d56c22ce2 by the domain:spec seat: FAIL on one item ⇒ patch round on this branch. The full seven-item judgment list, produced by an isolated claude-fable-5 reviewer and adopted verbatim by the seat, is on the card: #14124 (comment posted 2026-09-02T18:4xZ).

Blocking item: the case-(c) carve-out (a byte-identical embedded copy of a bound standalone counted as one declaration). After mergeActionsIntoObjects appends, the absorbed pair becomes two embedded entries under one key in the shipped artifact — listActions lists it twice and bare-name resolveActionByName throws exists on multiple objects (X, X) — so the runtime does not treat that pair as one. Required: option B — refuse every one-key pair, rewrite the #7397 vacuity pin to feed hand-written embedded actions rather than a re-fed build, flip the carve-out pins, re-run spec + consumer suites. Everything else (accept-set enumeration, changeset level and ADR-0087 disposition, public surface, pin quality, scope, envelope) passed.

needs:contract-review stays on both carriers; the seat re-reviews the patched head.


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
@os-sam
os-sam marked this pull request as ready for review September 2, 2026 19:50
@os-sam
os-sam enabled auto-merge September 2, 2026 19:50
@os-sam
os-sam added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 279431eSep 2, 2026
42 checks passed
@os-sam
os-sam deleted the claude/issue-14124-define-stack-duplicate-action-key branch September 2, 2026 20:56
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-sam@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

feat(spec): defineStack refuses two actions that resolve to one scope-qualified runtime key - #14686

Merged
os-sam merged 4 commits into
mainfrom
claude/issue-14124-define-stack-duplicate-action-key
Sep 2, 2026
Merged

feat(spec): defineStack refuses two actions that resolve to one scope-qualified runtime key#14686
os-sam merged 4 commits into
mainfrom
claude/issue-14124-define-stack-duplicate-action-key

Conversation

@os-sam

@os-samos-sam commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

defineStack now 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, same name) stays accepted and is pinned as accepted; its precedence is documented on the actions collection, 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)

Mechanism measured on origin/main @ 2aa8456cf (before any edit)

One probe per row through defineStack from source:

(a) two standalone globals, one name : ACCEPTED stack.actions=["global:dup_a","global:dup_a"]
(b) two standalone bound to the same object : ACCEPTED object.actions=["dup_b/BOUND","dup_b/BOUND"]
(c) standalone bound to X + embedded on X : ACCEPTED object.actions=["dup_c/EMB","dup_c/BOUND"] -- the merge APPENDS
(d) two embedded on one object : ACCEPTED object.actions=["dup_d/EMB","dup_d/EMB"]
(z) object-less stack, two globals : ACCEPTED stack.actions=["global:dup_z","global:dup_z"]
(x) cross-scope: global + bound to X : ACCEPTED stack.actions=["global:dup_x","probe_item:dup_x"]
(y) cross-scope: global + embedded on X : ACCEPTED stack.actions=["global:dup_y"], object.actions=["dup_y/EMB"]
  • Where the walk gets its action set:validateCrossReferences runs on the parsed data BEFORE mergeActionsIntoObjects, and reads both config.actions (standalone, scoped by objectName or global) and config.objects[].actions (embedded, scoped by the owning object). Both positions are in its input today; nothing had to be widened.
  • Case (c) — replace or append?mergeActionsIntoObjects does [...base, ...objActions]: append. Both survive into object.actions under one OBJECT:NAME key, so (c) is same-scope and is refused — identical twins included (see the next section).
  • Global-scope literal: the runtime keys an object-less action under GLOBAL_ACTION_OBJECT_KEY = 'global' (packages/objectql/src/action-governance.ts:61; packages/runtime/src/action-execution.tsstandaloneActionObjectName falls back to it; collectActionDeclarations builds ${objectName}:${action.name}). The refusal uses that literal, spelled in spec as a module-private GLOBAL_ACTION_SCOPE with a lockstep note (spec sits below objectql and cannot import it).
  • Registration is a silent last-writer-wins:ObjectQL.registerAction is this.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.
  • composeStacks never routes through the walk (actions: 'concat', then mergeActionsIntoObjects directly) — left untouched per the ruling; the cross-package collision it admits is filed as composeStacks concatenates actions across packages with no same-scope duplicate-key check — two packages each declaring one global:NAME compose 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" fed defineStack's OUTPUT back into defineStack. On the way out, mergeActionsIntoObjects APPENDS every bound standalone action into its object's actions, 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 — MCP listActions (packages/runtime/src/domains/mcp.ts) lists it twice and bare-name resolveActionByName throws exists 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 doubles objects[X].actions each 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 defineStack is therefore refused when fed back in (pinned as a refusal in stack-duplicate-action-key.test.ts, one line per bound action). Re-entrancy of defineStack output as a contract is not decided here; if wanted, it pairs with an idempotent mergeActionsIntoObjects on 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 targeting probe_home and a flow action targeting probe_flow, each carrying objectName: 'probe_task' exactly as the merge would have written them, with no top-level twin — and asserts refusals(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 / resolveRouteActionDeclaration use — not by the action's own objectName, which is the registration key collectBundleActions (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 on ObjectStackDefinitionSchema.actions and on collectDuplicateActionKeyErrors.

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, plugin objectstack.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:76 and packages/lint/src/validate-form-layout.test.ts:350 (cliTierFor: defineStack(stack) on a hand-written fixture, then normalizeStackInput on the OUTPUT — the CLI tier model, not a re-feed), packages/spec/src/stack-requires.test.ts:77 (build(stack) on plain inputs), and packages/spec/src/assembled-package-body.test.ts:225 (defineStack(composed()) — a composeStacks output 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 defineStack with the refusal in place (final head, built spec)

appdeclared actionsstandalone (bound)embeddedscope keys (global / object-bound)same-scope duplicates
examples/app-crm11 (1)01 (0 / 1)0
examples/app-showcase7070 (69)070 (1 / 69)0
examples/app-todo88 (8)08 (0 / 8)0
examples/app-multi-package (core, orders, composed)0 / 0 / 00000

Loading is the measurement: each objectstack.config.ts (and each multi-package sub-stack) ran defineStack at 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': minor with a **BREAKING** banner and adr-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 a major bump and names the **BREAKING** banner plus the ADR-0087 disposition as the mandatory carriers; check-adr-0087-registration.mjs keys on that banner. no-migration-prescription because 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/spec whole 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 0 on both.
  • @objectstack/spec typecheck — check:test-typecheck: OK … 54 file(s) / 262 error(s) / 146 pinned signature(s) (ledger held, no growth); tsc -p tsconfig.test.json --listFiles lists both stack-duplicate-action-key.test.ts and stack-inline-action-crossref.test.ts. VERDICT command-exit 0.
  • Reverse verification on the committed head: with errors.push(...collectDuplicateActionKeyErrors(config)); replaced on disk (marker 1 → 0, injected 1, disk blob a208575f vs HEAD 493e9307), stack-duplicate-action-key.test.ts went 11 failed | 5 passed (16) — every refusal pin red (the built-stack re-feed and identical-copy pins included), every accept pin green; restore via git checkout HEAD -- ABSPATH proven by blob hash (493e9307… both sides), git diff HEAD empty, porcelain empty. Trap-restored, absolute paths. No build needed — the pins import ./stack.zod from source. (Its restore re-touched stack.zod.ts after the DTS build, so spec was rebuilt once more before the declaration gates below.)
  • Consumer readings, each pnpm --filter PKG exec vitest run --maxWorkers=2 FILES under os-verify-lock.sh, exit 0 each:
    • @objectstack/runtimesrc/http-dispatcher.actions-global-key.test.ts + the four src/action-execution-*.test.ts: Test Files 5 passed (5), Tests 133 passed (133).
    • @objectstack/lintvalidate-action-name-refs.test.ts + reference-integrity-suite.test.ts + the two re-feed candidates authoring-rule-input-tier.test.ts + validate-form-layout.test.ts: Test Files 4 passed (4), Tests 69 passed (69).
    • @objectstack/objectqlaction-governance.test.ts + plugin-action-governance-rung.test.ts: Test Files 2 passed (2), Tests 20 passed (20).
    • @objectstack/example-crm whole 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-package has no vitest suite — its typecheck (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).
    • Consumer dists were restored through turbo with OS_SKIP_DTS=1; note for the next author: turbo's ^build re-ran @objectstack/spec under that flag despite --filter='!@objectstack/spec', clobbering its .d.ts — spec was rebuilt without the flag before every declaration-reading gate.
  • Gates green on the patched head: 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 of check:spec-parsed-alias, check:keyed-text-bounds, check:page-declaration-shape, docs-audit check-affected-docs / check-drift-comment, spec check:empty-state / check:variant-docs / check:llms-txt / check:browser-reachable-entries read files this patch did not touch. check-test-completeness exits 3 locally by design (NOT MEASURED — needs a turbo test log). The derived family list (53 commands from dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at aa937c775) is unchanged from the first head; the remaining families are CI's.

Out of scope, filed

Contract review

Draft PR; needs:contract-review parked 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

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
@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@github-actions

github-actionsBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

5 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 128 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json c616c2cc20fa74428900d5bb7376dd9d506c717epackageMentionDocs.

Which tree this was computed on

This run read content/docs from 3145bc34358f244c21acc887a8da2608119fb922 — the merge of head aa937c775121bb92aaa90ebd9a31c5005ba23b3a into base c616c2cc20fa74428900d5bb7376dd9d506c717e, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@os-samClaude

Copy link
Copy Markdown
CollaboratorAuthor

Contract review (clause ②) of head d56c22ce2 by the domain:spec seat: FAIL on one item ⇒ patch round on this branch. The full seven-item judgment list, produced by an isolated claude-fable-5 reviewer and adopted verbatim by the seat, is on the card: #14124 (comment posted 2026-09-02T18:4xZ).

Blocking item: the case-(c) carve-out (a byte-identical embedded copy of a bound standalone counted as one declaration). After mergeActionsIntoObjects appends, the absorbed pair becomes two embedded entries under one key in the shipped artifact — listActions lists it twice and bare-name resolveActionByName throws exists on multiple objects (X, X) — so the runtime does not treat that pair as one. Required: option B — refuse every one-key pair, rewrite the #7397 vacuity pin to feed hand-written embedded actions rather than a re-fed build, flip the carve-out pins, re-run spec + consumer suites. Everything else (accept-set enumeration, changeset level and ADR-0087 disposition, public surface, pin quality, scope, envelope) passed.

needs:contract-review stays on both carriers; the seat re-reviews the patched head.


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
@os-sam
os-sam marked this pull request as ready for review September 2, 2026 19:50
@os-sam
os-sam enabled auto-merge September 2, 2026 19:50
@os-sam
os-sam added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 279431eSep 2, 2026
42 checks passed
@os-sam
os-sam deleted the claude/issue-14124-define-stack-duplicate-action-key branch September 2, 2026 20:56
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-sam@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(spec): defineStack refuses two actions that resolve to one scope-qualified runtime key - #14686

Merged
os-sam merged 4 commits into
mainfrom
claude/issue-14124-define-stack-duplicate-action-key
Sep 2, 2026
Merged

feat(spec): defineStack refuses two actions that resolve to one scope-qualified runtime key#14686
os-sam merged 4 commits into
mainfrom
claude/issue-14124-define-stack-duplicate-action-key

Conversation

@os-sam

@os-samos-sam commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

defineStack now 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, same name) stays accepted and is pinned as accepted; its precedence is documented on the actions collection, 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)

Mechanism measured on origin/main @ 2aa8456cf (before any edit)

One probe per row through defineStack from source:

(a) two standalone globals, one name : ACCEPTED stack.actions=["global:dup_a","global:dup_a"]
(b) two standalone bound to the same object : ACCEPTED object.actions=["dup_b/BOUND","dup_b/BOUND"]
(c) standalone bound to X + embedded on X : ACCEPTED object.actions=["dup_c/EMB","dup_c/BOUND"] -- the merge APPENDS
(d) two embedded on one object : ACCEPTED object.actions=["dup_d/EMB","dup_d/EMB"]
(z) object-less stack, two globals : ACCEPTED stack.actions=["global:dup_z","global:dup_z"]
(x) cross-scope: global + bound to X : ACCEPTED stack.actions=["global:dup_x","probe_item:dup_x"]
(y) cross-scope: global + embedded on X : ACCEPTED stack.actions=["global:dup_y"], object.actions=["dup_y/EMB"]
  • Where the walk gets its action set:validateCrossReferences runs on the parsed data BEFORE mergeActionsIntoObjects, and reads both config.actions (standalone, scoped by objectName or global) and config.objects[].actions (embedded, scoped by the owning object). Both positions are in its input today; nothing had to be widened.
  • Case (c) — replace or append?mergeActionsIntoObjects does [...base, ...objActions]: append. Both survive into object.actions under one OBJECT:NAME key, so (c) is same-scope and is refused — identical twins included (see the next section).
  • Global-scope literal: the runtime keys an object-less action under GLOBAL_ACTION_OBJECT_KEY = 'global' (packages/objectql/src/action-governance.ts:61; packages/runtime/src/action-execution.tsstandaloneActionObjectName falls back to it; collectActionDeclarations builds ${objectName}:${action.name}). The refusal uses that literal, spelled in spec as a module-private GLOBAL_ACTION_SCOPE with a lockstep note (spec sits below objectql and cannot import it).
  • Registration is a silent last-writer-wins:ObjectQL.registerAction is this.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.
  • composeStacks never routes through the walk (actions: 'concat', then mergeActionsIntoObjects directly) — left untouched per the ruling; the cross-package collision it admits is filed as composeStacks concatenates actions across packages with no same-scope duplicate-key check — two packages each declaring one global:NAME compose 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" fed defineStack's OUTPUT back into defineStack. On the way out, mergeActionsIntoObjects APPENDS every bound standalone action into its object's actions, 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 — MCP listActions (packages/runtime/src/domains/mcp.ts) lists it twice and bare-name resolveActionByName throws exists 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 doubles objects[X].actions each 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 defineStack is therefore refused when fed back in (pinned as a refusal in stack-duplicate-action-key.test.ts, one line per bound action). Re-entrancy of defineStack output as a contract is not decided here; if wanted, it pairs with an idempotent mergeActionsIntoObjects on 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 targeting probe_home and a flow action targeting probe_flow, each carrying objectName: 'probe_task' exactly as the merge would have written them, with no top-level twin — and asserts refusals(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 / resolveRouteActionDeclaration use — not by the action's own objectName, which is the registration key collectBundleActions (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 on ObjectStackDefinitionSchema.actions and on collectDuplicateActionKeyErrors.

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, plugin objectstack.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:76 and packages/lint/src/validate-form-layout.test.ts:350 (cliTierFor: defineStack(stack) on a hand-written fixture, then normalizeStackInput on the OUTPUT — the CLI tier model, not a re-feed), packages/spec/src/stack-requires.test.ts:77 (build(stack) on plain inputs), and packages/spec/src/assembled-package-body.test.ts:225 (defineStack(composed()) — a composeStacks output 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 defineStack with the refusal in place (final head, built spec)

appdeclared actionsstandalone (bound)embeddedscope keys (global / object-bound)same-scope duplicates
examples/app-crm11 (1)01 (0 / 1)0
examples/app-showcase7070 (69)070 (1 / 69)0
examples/app-todo88 (8)08 (0 / 8)0
examples/app-multi-package (core, orders, composed)0 / 0 / 00000

Loading is the measurement: each objectstack.config.ts (and each multi-package sub-stack) ran defineStack at 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': minor with a **BREAKING** banner and adr-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 a major bump and names the **BREAKING** banner plus the ADR-0087 disposition as the mandatory carriers; check-adr-0087-registration.mjs keys on that banner. no-migration-prescription because 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/spec whole 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 0 on both.
  • @objectstack/spec typecheck — check:test-typecheck: OK … 54 file(s) / 262 error(s) / 146 pinned signature(s) (ledger held, no growth); tsc -p tsconfig.test.json --listFiles lists both stack-duplicate-action-key.test.ts and stack-inline-action-crossref.test.ts. VERDICT command-exit 0.
  • Reverse verification on the committed head: with errors.push(...collectDuplicateActionKeyErrors(config)); replaced on disk (marker 1 → 0, injected 1, disk blob a208575f vs HEAD 493e9307), stack-duplicate-action-key.test.ts went 11 failed | 5 passed (16) — every refusal pin red (the built-stack re-feed and identical-copy pins included), every accept pin green; restore via git checkout HEAD -- ABSPATH proven by blob hash (493e9307… both sides), git diff HEAD empty, porcelain empty. Trap-restored, absolute paths. No build needed — the pins import ./stack.zod from source. (Its restore re-touched stack.zod.ts after the DTS build, so spec was rebuilt once more before the declaration gates below.)
  • Consumer readings, each pnpm --filter PKG exec vitest run --maxWorkers=2 FILES under os-verify-lock.sh, exit 0 each:
    • @objectstack/runtimesrc/http-dispatcher.actions-global-key.test.ts + the four src/action-execution-*.test.ts: Test Files 5 passed (5), Tests 133 passed (133).
    • @objectstack/lintvalidate-action-name-refs.test.ts + reference-integrity-suite.test.ts + the two re-feed candidates authoring-rule-input-tier.test.ts + validate-form-layout.test.ts: Test Files 4 passed (4), Tests 69 passed (69).
    • @objectstack/objectqlaction-governance.test.ts + plugin-action-governance-rung.test.ts: Test Files 2 passed (2), Tests 20 passed (20).
    • @objectstack/example-crm whole 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-package has no vitest suite — its typecheck (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).
    • Consumer dists were restored through turbo with OS_SKIP_DTS=1; note for the next author: turbo's ^build re-ran @objectstack/spec under that flag despite --filter='!@objectstack/spec', clobbering its .d.ts — spec was rebuilt without the flag before every declaration-reading gate.
  • Gates green on the patched head: 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 of check:spec-parsed-alias, check:keyed-text-bounds, check:page-declaration-shape, docs-audit check-affected-docs / check-drift-comment, spec check:empty-state / check:variant-docs / check:llms-txt / check:browser-reachable-entries read files this patch did not touch. check-test-completeness exits 3 locally by design (NOT MEASURED — needs a turbo test log). The derived family list (53 commands from dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at aa937c775) is unchanged from the first head; the remaining families are CI's.

Out of scope, filed

Contract review

Draft PR; needs:contract-review parked 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

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
@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@github-actions

github-actionsBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

5 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 128 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json c616c2cc20fa74428900d5bb7376dd9d506c717epackageMentionDocs.

Which tree this was computed on

This run read content/docs from 3145bc34358f244c21acc887a8da2608119fb922 — the merge of head aa937c775121bb92aaa90ebd9a31c5005ba23b3a into base c616c2cc20fa74428900d5bb7376dd9d506c717e, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@os-samClaude

Copy link
Copy Markdown
CollaboratorAuthor

Contract review (clause ②) of head d56c22ce2 by the domain:spec seat: FAIL on one item ⇒ patch round on this branch. The full seven-item judgment list, produced by an isolated claude-fable-5 reviewer and adopted verbatim by the seat, is on the card: #14124 (comment posted 2026-09-02T18:4xZ).

Blocking item: the case-(c) carve-out (a byte-identical embedded copy of a bound standalone counted as one declaration). After mergeActionsIntoObjects appends, the absorbed pair becomes two embedded entries under one key in the shipped artifact — listActions lists it twice and bare-name resolveActionByName throws exists on multiple objects (X, X) — so the runtime does not treat that pair as one. Required: option B — refuse every one-key pair, rewrite the #7397 vacuity pin to feed hand-written embedded actions rather than a re-fed build, flip the carve-out pins, re-run spec + consumer suites. Everything else (accept-set enumeration, changeset level and ADR-0087 disposition, public surface, pin quality, scope, envelope) passed.

needs:contract-review stays on both carriers; the seat re-reviews the patched head.


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
@os-sam
os-sam marked this pull request as ready for review September 2, 2026 19:50
@os-sam
os-sam enabled auto-merge September 2, 2026 19:50
@os-sam
os-sam added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 279431eSep 2, 2026
42 checks passed
@os-sam
os-sam deleted the claude/issue-14124-define-stack-duplicate-action-key branch September 2, 2026 20:56
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-sam@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(spec): defineStack refuses two actions that resolve to one scope-qualified runtime key - #14686

Merged
os-sam merged 4 commits into
mainfrom
claude/issue-14124-define-stack-duplicate-action-key
Sep 2, 2026
Merged

feat(spec): defineStack refuses two actions that resolve to one scope-qualified runtime key#14686
os-sam merged 4 commits into
mainfrom
claude/issue-14124-define-stack-duplicate-action-key

Conversation

@os-sam

@os-samos-sam commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

defineStack now 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, same name) stays accepted and is pinned as accepted; its precedence is documented on the actions collection, 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)

Mechanism measured on origin/main @ 2aa8456cf (before any edit)

One probe per row through defineStack from source:

(a) two standalone globals, one name : ACCEPTED stack.actions=["global:dup_a","global:dup_a"]
(b) two standalone bound to the same object : ACCEPTED object.actions=["dup_b/BOUND","dup_b/BOUND"]
(c) standalone bound to X + embedded on X : ACCEPTED object.actions=["dup_c/EMB","dup_c/BOUND"] -- the merge APPENDS
(d) two embedded on one object : ACCEPTED object.actions=["dup_d/EMB","dup_d/EMB"]
(z) object-less stack, two globals : ACCEPTED stack.actions=["global:dup_z","global:dup_z"]
(x) cross-scope: global + bound to X : ACCEPTED stack.actions=["global:dup_x","probe_item:dup_x"]
(y) cross-scope: global + embedded on X : ACCEPTED stack.actions=["global:dup_y"], object.actions=["dup_y/EMB"]
  • Where the walk gets its action set:validateCrossReferences runs on the parsed data BEFORE mergeActionsIntoObjects, and reads both config.actions (standalone, scoped by objectName or global) and config.objects[].actions (embedded, scoped by the owning object). Both positions are in its input today; nothing had to be widened.
  • Case (c) — replace or append?mergeActionsIntoObjects does [...base, ...objActions]: append. Both survive into object.actions under one OBJECT:NAME key, so (c) is same-scope and is refused — identical twins included (see the next section).
  • Global-scope literal: the runtime keys an object-less action under GLOBAL_ACTION_OBJECT_KEY = 'global' (packages/objectql/src/action-governance.ts:61; packages/runtime/src/action-execution.tsstandaloneActionObjectName falls back to it; collectActionDeclarations builds ${objectName}:${action.name}). The refusal uses that literal, spelled in spec as a module-private GLOBAL_ACTION_SCOPE with a lockstep note (spec sits below objectql and cannot import it).
  • Registration is a silent last-writer-wins:ObjectQL.registerAction is this.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.
  • composeStacks never routes through the walk (actions: 'concat', then mergeActionsIntoObjects directly) — left untouched per the ruling; the cross-package collision it admits is filed as composeStacks concatenates actions across packages with no same-scope duplicate-key check — two packages each declaring one global:NAME compose 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" fed defineStack's OUTPUT back into defineStack. On the way out, mergeActionsIntoObjects APPENDS every bound standalone action into its object's actions, 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 — MCP listActions (packages/runtime/src/domains/mcp.ts) lists it twice and bare-name resolveActionByName throws exists 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 doubles objects[X].actions each 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 defineStack is therefore refused when fed back in (pinned as a refusal in stack-duplicate-action-key.test.ts, one line per bound action). Re-entrancy of defineStack output as a contract is not decided here; if wanted, it pairs with an idempotent mergeActionsIntoObjects on 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 targeting probe_home and a flow action targeting probe_flow, each carrying objectName: 'probe_task' exactly as the merge would have written them, with no top-level twin — and asserts refusals(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 / resolveRouteActionDeclaration use — not by the action's own objectName, which is the registration key collectBundleActions (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 on ObjectStackDefinitionSchema.actions and on collectDuplicateActionKeyErrors.

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, plugin objectstack.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:76 and packages/lint/src/validate-form-layout.test.ts:350 (cliTierFor: defineStack(stack) on a hand-written fixture, then normalizeStackInput on the OUTPUT — the CLI tier model, not a re-feed), packages/spec/src/stack-requires.test.ts:77 (build(stack) on plain inputs), and packages/spec/src/assembled-package-body.test.ts:225 (defineStack(composed()) — a composeStacks output 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 defineStack with the refusal in place (final head, built spec)

appdeclared actionsstandalone (bound)embeddedscope keys (global / object-bound)same-scope duplicates
examples/app-crm11 (1)01 (0 / 1)0
examples/app-showcase7070 (69)070 (1 / 69)0
examples/app-todo88 (8)08 (0 / 8)0
examples/app-multi-package (core, orders, composed)0 / 0 / 00000

Loading is the measurement: each objectstack.config.ts (and each multi-package sub-stack) ran defineStack at 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': minor with a **BREAKING** banner and adr-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 a major bump and names the **BREAKING** banner plus the ADR-0087 disposition as the mandatory carriers; check-adr-0087-registration.mjs keys on that banner. no-migration-prescription because 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/spec whole 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 0 on both.
  • @objectstack/spec typecheck — check:test-typecheck: OK … 54 file(s) / 262 error(s) / 146 pinned signature(s) (ledger held, no growth); tsc -p tsconfig.test.json --listFiles lists both stack-duplicate-action-key.test.ts and stack-inline-action-crossref.test.ts. VERDICT command-exit 0.
  • Reverse verification on the committed head: with errors.push(...collectDuplicateActionKeyErrors(config)); replaced on disk (marker 1 → 0, injected 1, disk blob a208575f vs HEAD 493e9307), stack-duplicate-action-key.test.ts went 11 failed | 5 passed (16) — every refusal pin red (the built-stack re-feed and identical-copy pins included), every accept pin green; restore via git checkout HEAD -- ABSPATH proven by blob hash (493e9307… both sides), git diff HEAD empty, porcelain empty. Trap-restored, absolute paths. No build needed — the pins import ./stack.zod from source. (Its restore re-touched stack.zod.ts after the DTS build, so spec was rebuilt once more before the declaration gates below.)
  • Consumer readings, each pnpm --filter PKG exec vitest run --maxWorkers=2 FILES under os-verify-lock.sh, exit 0 each:
    • @objectstack/runtimesrc/http-dispatcher.actions-global-key.test.ts + the four src/action-execution-*.test.ts: Test Files 5 passed (5), Tests 133 passed (133).
    • @objectstack/lintvalidate-action-name-refs.test.ts + reference-integrity-suite.test.ts + the two re-feed candidates authoring-rule-input-tier.test.ts + validate-form-layout.test.ts: Test Files 4 passed (4), Tests 69 passed (69).
    • @objectstack/objectqlaction-governance.test.ts + plugin-action-governance-rung.test.ts: Test Files 2 passed (2), Tests 20 passed (20).
    • @objectstack/example-crm whole 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-package has no vitest suite — its typecheck (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).
    • Consumer dists were restored through turbo with OS_SKIP_DTS=1; note for the next author: turbo's ^build re-ran @objectstack/spec under that flag despite --filter='!@objectstack/spec', clobbering its .d.ts — spec was rebuilt without the flag before every declaration-reading gate.
  • Gates green on the patched head: 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 of check:spec-parsed-alias, check:keyed-text-bounds, check:page-declaration-shape, docs-audit check-affected-docs / check-drift-comment, spec check:empty-state / check:variant-docs / check:llms-txt / check:browser-reachable-entries read files this patch did not touch. check-test-completeness exits 3 locally by design (NOT MEASURED — needs a turbo test log). The derived family list (53 commands from dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at aa937c775) is unchanged from the first head; the remaining families are CI's.

Out of scope, filed

Contract review

Draft PR; needs:contract-review parked 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

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
@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@github-actions

github-actionsBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

5 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 128 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json c616c2cc20fa74428900d5bb7376dd9d506c717epackageMentionDocs.

Which tree this was computed on

This run read content/docs from 3145bc34358f244c21acc887a8da2608119fb922 — the merge of head aa937c775121bb92aaa90ebd9a31c5005ba23b3a into base c616c2cc20fa74428900d5bb7376dd9d506c717e, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@os-samClaude

Copy link
Copy Markdown
CollaboratorAuthor

Contract review (clause ②) of head d56c22ce2 by the domain:spec seat: FAIL on one item ⇒ patch round on this branch. The full seven-item judgment list, produced by an isolated claude-fable-5 reviewer and adopted verbatim by the seat, is on the card: #14124 (comment posted 2026-09-02T18:4xZ).

Blocking item: the case-(c) carve-out (a byte-identical embedded copy of a bound standalone counted as one declaration). After mergeActionsIntoObjects appends, the absorbed pair becomes two embedded entries under one key in the shipped artifact — listActions lists it twice and bare-name resolveActionByName throws exists on multiple objects (X, X) — so the runtime does not treat that pair as one. Required: option B — refuse every one-key pair, rewrite the #7397 vacuity pin to feed hand-written embedded actions rather than a re-fed build, flip the carve-out pins, re-run spec + consumer suites. Everything else (accept-set enumeration, changeset level and ADR-0087 disposition, public surface, pin quality, scope, envelope) passed.

needs:contract-review stays on both carriers; the seat re-reviews the patched head.


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
@os-sam
os-sam marked this pull request as ready for review September 2, 2026 19:50
@os-sam
os-sam enabled auto-merge September 2, 2026 19:50
@os-sam
os-sam added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 279431eSep 2, 2026
42 checks passed
@os-sam
os-sam deleted the claude/issue-14124-define-stack-duplicate-action-key branch September 2, 2026 20:56
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-sam@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

feat(spec): defineStack refuses two actions that resolve to one scope-qualified runtime key - #14686

Merged
os-sam merged 4 commits into
mainfrom
claude/issue-14124-define-stack-duplicate-action-key
Sep 2, 2026
Merged

feat(spec): defineStack refuses two actions that resolve to one scope-qualified runtime key#14686
os-sam merged 4 commits into
mainfrom
claude/issue-14124-define-stack-duplicate-action-key

Conversation

@os-sam

@os-samos-sam commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

defineStack now 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, same name) stays accepted and is pinned as accepted; its precedence is documented on the actions collection, 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)

Mechanism measured on origin/main @ 2aa8456cf (before any edit)

One probe per row through defineStack from source:

(a) two standalone globals, one name : ACCEPTED stack.actions=["global:dup_a","global:dup_a"]
(b) two standalone bound to the same object : ACCEPTED object.actions=["dup_b/BOUND","dup_b/BOUND"]
(c) standalone bound to X + embedded on X : ACCEPTED object.actions=["dup_c/EMB","dup_c/BOUND"] -- the merge APPENDS
(d) two embedded on one object : ACCEPTED object.actions=["dup_d/EMB","dup_d/EMB"]
(z) object-less stack, two globals : ACCEPTED stack.actions=["global:dup_z","global:dup_z"]
(x) cross-scope: global + bound to X : ACCEPTED stack.actions=["global:dup_x","probe_item:dup_x"]
(y) cross-scope: global + embedded on X : ACCEPTED stack.actions=["global:dup_y"], object.actions=["dup_y/EMB"]
  • Where the walk gets its action set:validateCrossReferences runs on the parsed data BEFORE mergeActionsIntoObjects, and reads both config.actions (standalone, scoped by objectName or global) and config.objects[].actions (embedded, scoped by the owning object). Both positions are in its input today; nothing had to be widened.
  • Case (c) — replace or append?mergeActionsIntoObjects does [...base, ...objActions]: append. Both survive into object.actions under one OBJECT:NAME key, so (c) is same-scope and is refused — identical twins included (see the next section).
  • Global-scope literal: the runtime keys an object-less action under GLOBAL_ACTION_OBJECT_KEY = 'global' (packages/objectql/src/action-governance.ts:61; packages/runtime/src/action-execution.tsstandaloneActionObjectName falls back to it; collectActionDeclarations builds ${objectName}:${action.name}). The refusal uses that literal, spelled in spec as a module-private GLOBAL_ACTION_SCOPE with a lockstep note (spec sits below objectql and cannot import it).
  • Registration is a silent last-writer-wins:ObjectQL.registerAction is this.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.
  • composeStacks never routes through the walk (actions: 'concat', then mergeActionsIntoObjects directly) — left untouched per the ruling; the cross-package collision it admits is filed as composeStacks concatenates actions across packages with no same-scope duplicate-key check — two packages each declaring one global:NAME compose 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" fed defineStack's OUTPUT back into defineStack. On the way out, mergeActionsIntoObjects APPENDS every bound standalone action into its object's actions, 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 — MCP listActions (packages/runtime/src/domains/mcp.ts) lists it twice and bare-name resolveActionByName throws exists 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 doubles objects[X].actions each 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 defineStack is therefore refused when fed back in (pinned as a refusal in stack-duplicate-action-key.test.ts, one line per bound action). Re-entrancy of defineStack output as a contract is not decided here; if wanted, it pairs with an idempotent mergeActionsIntoObjects on 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 targeting probe_home and a flow action targeting probe_flow, each carrying objectName: 'probe_task' exactly as the merge would have written them, with no top-level twin — and asserts refusals(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 / resolveRouteActionDeclaration use — not by the action's own objectName, which is the registration key collectBundleActions (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 on ObjectStackDefinitionSchema.actions and on collectDuplicateActionKeyErrors.

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, plugin objectstack.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:76 and packages/lint/src/validate-form-layout.test.ts:350 (cliTierFor: defineStack(stack) on a hand-written fixture, then normalizeStackInput on the OUTPUT — the CLI tier model, not a re-feed), packages/spec/src/stack-requires.test.ts:77 (build(stack) on plain inputs), and packages/spec/src/assembled-package-body.test.ts:225 (defineStack(composed()) — a composeStacks output 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 defineStack with the refusal in place (final head, built spec)

appdeclared actionsstandalone (bound)embeddedscope keys (global / object-bound)same-scope duplicates
examples/app-crm11 (1)01 (0 / 1)0
examples/app-showcase7070 (69)070 (1 / 69)0
examples/app-todo88 (8)08 (0 / 8)0
examples/app-multi-package (core, orders, composed)0 / 0 / 00000

Loading is the measurement: each objectstack.config.ts (and each multi-package sub-stack) ran defineStack at 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': minor with a **BREAKING** banner and adr-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 a major bump and names the **BREAKING** banner plus the ADR-0087 disposition as the mandatory carriers; check-adr-0087-registration.mjs keys on that banner. no-migration-prescription because 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/spec whole 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 0 on both.
  • @objectstack/spec typecheck — check:test-typecheck: OK … 54 file(s) / 262 error(s) / 146 pinned signature(s) (ledger held, no growth); tsc -p tsconfig.test.json --listFiles lists both stack-duplicate-action-key.test.ts and stack-inline-action-crossref.test.ts. VERDICT command-exit 0.
  • Reverse verification on the committed head: with errors.push(...collectDuplicateActionKeyErrors(config)); replaced on disk (marker 1 → 0, injected 1, disk blob a208575f vs HEAD 493e9307), stack-duplicate-action-key.test.ts went 11 failed | 5 passed (16) — every refusal pin red (the built-stack re-feed and identical-copy pins included), every accept pin green; restore via git checkout HEAD -- ABSPATH proven by blob hash (493e9307… both sides), git diff HEAD empty, porcelain empty. Trap-restored, absolute paths. No build needed — the pins import ./stack.zod from source. (Its restore re-touched stack.zod.ts after the DTS build, so spec was rebuilt once more before the declaration gates below.)
  • Consumer readings, each pnpm --filter PKG exec vitest run --maxWorkers=2 FILES under os-verify-lock.sh, exit 0 each:
    • @objectstack/runtimesrc/http-dispatcher.actions-global-key.test.ts + the four src/action-execution-*.test.ts: Test Files 5 passed (5), Tests 133 passed (133).
    • @objectstack/lintvalidate-action-name-refs.test.ts + reference-integrity-suite.test.ts + the two re-feed candidates authoring-rule-input-tier.test.ts + validate-form-layout.test.ts: Test Files 4 passed (4), Tests 69 passed (69).
    • @objectstack/objectqlaction-governance.test.ts + plugin-action-governance-rung.test.ts: Test Files 2 passed (2), Tests 20 passed (20).
    • @objectstack/example-crm whole 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-package has no vitest suite — its typecheck (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).
    • Consumer dists were restored through turbo with OS_SKIP_DTS=1; note for the next author: turbo's ^build re-ran @objectstack/spec under that flag despite --filter='!@objectstack/spec', clobbering its .d.ts — spec was rebuilt without the flag before every declaration-reading gate.
  • Gates green on the patched head: 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 of check:spec-parsed-alias, check:keyed-text-bounds, check:page-declaration-shape, docs-audit check-affected-docs / check-drift-comment, spec check:empty-state / check:variant-docs / check:llms-txt / check:browser-reachable-entries read files this patch did not touch. check-test-completeness exits 3 locally by design (NOT MEASURED — needs a turbo test log). The derived family list (53 commands from dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at aa937c775) is unchanged from the first head; the remaining families are CI's.

Out of scope, filed

Contract review

Draft PR; needs:contract-review parked 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

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
@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@github-actions

github-actionsBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

5 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 128 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json c616c2cc20fa74428900d5bb7376dd9d506c717epackageMentionDocs.

Which tree this was computed on

This run read content/docs from 3145bc34358f244c21acc887a8da2608119fb922 — the merge of head aa937c775121bb92aaa90ebd9a31c5005ba23b3a into base c616c2cc20fa74428900d5bb7376dd9d506c717e, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@os-samClaude

Copy link
Copy Markdown
CollaboratorAuthor

Contract review (clause ②) of head d56c22ce2 by the domain:spec seat: FAIL on one item ⇒ patch round on this branch. The full seven-item judgment list, produced by an isolated claude-fable-5 reviewer and adopted verbatim by the seat, is on the card: #14124 (comment posted 2026-09-02T18:4xZ).

Blocking item: the case-(c) carve-out (a byte-identical embedded copy of a bound standalone counted as one declaration). After mergeActionsIntoObjects appends, the absorbed pair becomes two embedded entries under one key in the shipped artifact — listActions lists it twice and bare-name resolveActionByName throws exists on multiple objects (X, X) — so the runtime does not treat that pair as one. Required: option B — refuse every one-key pair, rewrite the #7397 vacuity pin to feed hand-written embedded actions rather than a re-fed build, flip the carve-out pins, re-run spec + consumer suites. Everything else (accept-set enumeration, changeset level and ADR-0087 disposition, public surface, pin quality, scope, envelope) passed.

needs:contract-review stays on both carriers; the seat re-reviews the patched head.


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
@os-sam
os-sam marked this pull request as ready for review September 2, 2026 19:50
@os-sam
os-sam enabled auto-merge September 2, 2026 19:50
@os-sam
os-sam added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 279431eSep 2, 2026
42 checks passed
@os-sam
os-sam deleted the claude/issue-14124-define-stack-duplicate-action-key branch September 2, 2026 20:56
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-sam@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(spec): defineStack refuses two actions that resolve to one scope-qualified runtime key - #14686

Merged
os-sam merged 4 commits into
mainfrom
claude/issue-14124-define-stack-duplicate-action-key
Sep 2, 2026
Merged

feat(spec): defineStack refuses two actions that resolve to one scope-qualified runtime key#14686
os-sam merged 4 commits into
mainfrom
claude/issue-14124-define-stack-duplicate-action-key

Conversation

@os-sam

@os-samos-sam commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

defineStack now 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, same name) stays accepted and is pinned as accepted; its precedence is documented on the actions collection, 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)

Mechanism measured on origin/main @ 2aa8456cf (before any edit)

One probe per row through defineStack from source:

(a) two standalone globals, one name : ACCEPTED stack.actions=["global:dup_a","global:dup_a"]
(b) two standalone bound to the same object : ACCEPTED object.actions=["dup_b/BOUND","dup_b/BOUND"]
(c) standalone bound to X + embedded on X : ACCEPTED object.actions=["dup_c/EMB","dup_c/BOUND"] -- the merge APPENDS
(d) two embedded on one object : ACCEPTED object.actions=["dup_d/EMB","dup_d/EMB"]
(z) object-less stack, two globals : ACCEPTED stack.actions=["global:dup_z","global:dup_z"]
(x) cross-scope: global + bound to X : ACCEPTED stack.actions=["global:dup_x","probe_item:dup_x"]
(y) cross-scope: global + embedded on X : ACCEPTED stack.actions=["global:dup_y"], object.actions=["dup_y/EMB"]
  • Where the walk gets its action set:validateCrossReferences runs on the parsed data BEFORE mergeActionsIntoObjects, and reads both config.actions (standalone, scoped by objectName or global) and config.objects[].actions (embedded, scoped by the owning object). Both positions are in its input today; nothing had to be widened.
  • Case (c) — replace or append?mergeActionsIntoObjects does [...base, ...objActions]: append. Both survive into object.actions under one OBJECT:NAME key, so (c) is same-scope and is refused — identical twins included (see the next section).
  • Global-scope literal: the runtime keys an object-less action under GLOBAL_ACTION_OBJECT_KEY = 'global' (packages/objectql/src/action-governance.ts:61; packages/runtime/src/action-execution.tsstandaloneActionObjectName falls back to it; collectActionDeclarations builds ${objectName}:${action.name}). The refusal uses that literal, spelled in spec as a module-private GLOBAL_ACTION_SCOPE with a lockstep note (spec sits below objectql and cannot import it).
  • Registration is a silent last-writer-wins:ObjectQL.registerAction is this.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.
  • composeStacks never routes through the walk (actions: 'concat', then mergeActionsIntoObjects directly) — left untouched per the ruling; the cross-package collision it admits is filed as composeStacks concatenates actions across packages with no same-scope duplicate-key check — two packages each declaring one global:NAME compose 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" fed defineStack's OUTPUT back into defineStack. On the way out, mergeActionsIntoObjects APPENDS every bound standalone action into its object's actions, 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 — MCP listActions (packages/runtime/src/domains/mcp.ts) lists it twice and bare-name resolveActionByName throws exists 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 doubles objects[X].actions each 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 defineStack is therefore refused when fed back in (pinned as a refusal in stack-duplicate-action-key.test.ts, one line per bound action). Re-entrancy of defineStack output as a contract is not decided here; if wanted, it pairs with an idempotent mergeActionsIntoObjects on 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 targeting probe_home and a flow action targeting probe_flow, each carrying objectName: 'probe_task' exactly as the merge would have written them, with no top-level twin — and asserts refusals(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 / resolveRouteActionDeclaration use — not by the action's own objectName, which is the registration key collectBundleActions (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 on ObjectStackDefinitionSchema.actions and on collectDuplicateActionKeyErrors.

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, plugin objectstack.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:76 and packages/lint/src/validate-form-layout.test.ts:350 (cliTierFor: defineStack(stack) on a hand-written fixture, then normalizeStackInput on the OUTPUT — the CLI tier model, not a re-feed), packages/spec/src/stack-requires.test.ts:77 (build(stack) on plain inputs), and packages/spec/src/assembled-package-body.test.ts:225 (defineStack(composed()) — a composeStacks output 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 defineStack with the refusal in place (final head, built spec)

appdeclared actionsstandalone (bound)embeddedscope keys (global / object-bound)same-scope duplicates
examples/app-crm11 (1)01 (0 / 1)0
examples/app-showcase7070 (69)070 (1 / 69)0
examples/app-todo88 (8)08 (0 / 8)0
examples/app-multi-package (core, orders, composed)0 / 0 / 00000

Loading is the measurement: each objectstack.config.ts (and each multi-package sub-stack) ran defineStack at 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': minor with a **BREAKING** banner and adr-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 a major bump and names the **BREAKING** banner plus the ADR-0087 disposition as the mandatory carriers; check-adr-0087-registration.mjs keys on that banner. no-migration-prescription because 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/spec whole 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 0 on both.
  • @objectstack/spec typecheck — check:test-typecheck: OK … 54 file(s) / 262 error(s) / 146 pinned signature(s) (ledger held, no growth); tsc -p tsconfig.test.json --listFiles lists both stack-duplicate-action-key.test.ts and stack-inline-action-crossref.test.ts. VERDICT command-exit 0.
  • Reverse verification on the committed head: with errors.push(...collectDuplicateActionKeyErrors(config)); replaced on disk (marker 1 → 0, injected 1, disk blob a208575f vs HEAD 493e9307), stack-duplicate-action-key.test.ts went 11 failed | 5 passed (16) — every refusal pin red (the built-stack re-feed and identical-copy pins included), every accept pin green; restore via git checkout HEAD -- ABSPATH proven by blob hash (493e9307… both sides), git diff HEAD empty, porcelain empty. Trap-restored, absolute paths. No build needed — the pins import ./stack.zod from source. (Its restore re-touched stack.zod.ts after the DTS build, so spec was rebuilt once more before the declaration gates below.)
  • Consumer readings, each pnpm --filter PKG exec vitest run --maxWorkers=2 FILES under os-verify-lock.sh, exit 0 each:
    • @objectstack/runtimesrc/http-dispatcher.actions-global-key.test.ts + the four src/action-execution-*.test.ts: Test Files 5 passed (5), Tests 133 passed (133).
    • @objectstack/lintvalidate-action-name-refs.test.ts + reference-integrity-suite.test.ts + the two re-feed candidates authoring-rule-input-tier.test.ts + validate-form-layout.test.ts: Test Files 4 passed (4), Tests 69 passed (69).
    • @objectstack/objectqlaction-governance.test.ts + plugin-action-governance-rung.test.ts: Test Files 2 passed (2), Tests 20 passed (20).
    • @objectstack/example-crm whole 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-package has no vitest suite — its typecheck (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).
    • Consumer dists were restored through turbo with OS_SKIP_DTS=1; note for the next author: turbo's ^build re-ran @objectstack/spec under that flag despite --filter='!@objectstack/spec', clobbering its .d.ts — spec was rebuilt without the flag before every declaration-reading gate.
  • Gates green on the patched head: 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 of check:spec-parsed-alias, check:keyed-text-bounds, check:page-declaration-shape, docs-audit check-affected-docs / check-drift-comment, spec check:empty-state / check:variant-docs / check:llms-txt / check:browser-reachable-entries read files this patch did not touch. check-test-completeness exits 3 locally by design (NOT MEASURED — needs a turbo test log). The derived family list (53 commands from dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at aa937c775) is unchanged from the first head; the remaining families are CI's.

Out of scope, filed

Contract review

Draft PR; needs:contract-review parked 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

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
@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@github-actions

github-actionsBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

5 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 128 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json c616c2cc20fa74428900d5bb7376dd9d506c717epackageMentionDocs.

Which tree this was computed on

This run read content/docs from 3145bc34358f244c21acc887a8da2608119fb922 — the merge of head aa937c775121bb92aaa90ebd9a31c5005ba23b3a into base c616c2cc20fa74428900d5bb7376dd9d506c717e, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@os-samClaude

Copy link
Copy Markdown
CollaboratorAuthor

Contract review (clause ②) of head d56c22ce2 by the domain:spec seat: FAIL on one item ⇒ patch round on this branch. The full seven-item judgment list, produced by an isolated claude-fable-5 reviewer and adopted verbatim by the seat, is on the card: #14124 (comment posted 2026-09-02T18:4xZ).

Blocking item: the case-(c) carve-out (a byte-identical embedded copy of a bound standalone counted as one declaration). After mergeActionsIntoObjects appends, the absorbed pair becomes two embedded entries under one key in the shipped artifact — listActions lists it twice and bare-name resolveActionByName throws exists on multiple objects (X, X) — so the runtime does not treat that pair as one. Required: option B — refuse every one-key pair, rewrite the #7397 vacuity pin to feed hand-written embedded actions rather than a re-fed build, flip the carve-out pins, re-run spec + consumer suites. Everything else (accept-set enumeration, changeset level and ADR-0087 disposition, public surface, pin quality, scope, envelope) passed.

needs:contract-review stays on both carriers; the seat re-reviews the patched head.


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
@os-sam
os-sam marked this pull request as ready for review September 2, 2026 19:50
@os-sam
os-sam enabled auto-merge September 2, 2026 19:50
@os-sam
os-sam added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 279431eSep 2, 2026
42 checks passed
@os-sam
os-sam deleted the claude/issue-14124-define-stack-duplicate-action-key branch September 2, 2026 20:56
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-sam@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(spec): defineStack refuses two actions that resolve to one scope-qualified runtime key - #14686

Merged
os-sam merged 4 commits into
mainfrom
claude/issue-14124-define-stack-duplicate-action-key
Sep 2, 2026
Merged

feat(spec): defineStack refuses two actions that resolve to one scope-qualified runtime key#14686
os-sam merged 4 commits into
mainfrom
claude/issue-14124-define-stack-duplicate-action-key

Conversation

@os-sam

@os-samos-sam commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

defineStack now 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, same name) stays accepted and is pinned as accepted; its precedence is documented on the actions collection, 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)

Mechanism measured on origin/main @ 2aa8456cf (before any edit)

One probe per row through defineStack from source:

(a) two standalone globals, one name : ACCEPTED stack.actions=["global:dup_a","global:dup_a"]
(b) two standalone bound to the same object : ACCEPTED object.actions=["dup_b/BOUND","dup_b/BOUND"]
(c) standalone bound to X + embedded on X : ACCEPTED object.actions=["dup_c/EMB","dup_c/BOUND"] -- the merge APPENDS
(d) two embedded on one object : ACCEPTED object.actions=["dup_d/EMB","dup_d/EMB"]
(z) object-less stack, two globals : ACCEPTED stack.actions=["global:dup_z","global:dup_z"]
(x) cross-scope: global + bound to X : ACCEPTED stack.actions=["global:dup_x","probe_item:dup_x"]
(y) cross-scope: global + embedded on X : ACCEPTED stack.actions=["global:dup_y"], object.actions=["dup_y/EMB"]
  • Where the walk gets its action set:validateCrossReferences runs on the parsed data BEFORE mergeActionsIntoObjects, and reads both config.actions (standalone, scoped by objectName or global) and config.objects[].actions (embedded, scoped by the owning object). Both positions are in its input today; nothing had to be widened.
  • Case (c) — replace or append?mergeActionsIntoObjects does [...base, ...objActions]: append. Both survive into object.actions under one OBJECT:NAME key, so (c) is same-scope and is refused — identical twins included (see the next section).
  • Global-scope literal: the runtime keys an object-less action under GLOBAL_ACTION_OBJECT_KEY = 'global' (packages/objectql/src/action-governance.ts:61; packages/runtime/src/action-execution.tsstandaloneActionObjectName falls back to it; collectActionDeclarations builds ${objectName}:${action.name}). The refusal uses that literal, spelled in spec as a module-private GLOBAL_ACTION_SCOPE with a lockstep note (spec sits below objectql and cannot import it).
  • Registration is a silent last-writer-wins:ObjectQL.registerAction is this.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.
  • composeStacks never routes through the walk (actions: 'concat', then mergeActionsIntoObjects directly) — left untouched per the ruling; the cross-package collision it admits is filed as composeStacks concatenates actions across packages with no same-scope duplicate-key check — two packages each declaring one global:NAME compose 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" fed defineStack's OUTPUT back into defineStack. On the way out, mergeActionsIntoObjects APPENDS every bound standalone action into its object's actions, 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 — MCP listActions (packages/runtime/src/domains/mcp.ts) lists it twice and bare-name resolveActionByName throws exists 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 doubles objects[X].actions each 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 defineStack is therefore refused when fed back in (pinned as a refusal in stack-duplicate-action-key.test.ts, one line per bound action). Re-entrancy of defineStack output as a contract is not decided here; if wanted, it pairs with an idempotent mergeActionsIntoObjects on 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 targeting probe_home and a flow action targeting probe_flow, each carrying objectName: 'probe_task' exactly as the merge would have written them, with no top-level twin — and asserts refusals(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 / resolveRouteActionDeclaration use — not by the action's own objectName, which is the registration key collectBundleActions (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 on ObjectStackDefinitionSchema.actions and on collectDuplicateActionKeyErrors.

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, plugin objectstack.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:76 and packages/lint/src/validate-form-layout.test.ts:350 (cliTierFor: defineStack(stack) on a hand-written fixture, then normalizeStackInput on the OUTPUT — the CLI tier model, not a re-feed), packages/spec/src/stack-requires.test.ts:77 (build(stack) on plain inputs), and packages/spec/src/assembled-package-body.test.ts:225 (defineStack(composed()) — a composeStacks output 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 defineStack with the refusal in place (final head, built spec)

appdeclared actionsstandalone (bound)embeddedscope keys (global / object-bound)same-scope duplicates
examples/app-crm11 (1)01 (0 / 1)0
examples/app-showcase7070 (69)070 (1 / 69)0
examples/app-todo88 (8)08 (0 / 8)0
examples/app-multi-package (core, orders, composed)0 / 0 / 00000

Loading is the measurement: each objectstack.config.ts (and each multi-package sub-stack) ran defineStack at 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': minor with a **BREAKING** banner and adr-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 a major bump and names the **BREAKING** banner plus the ADR-0087 disposition as the mandatory carriers; check-adr-0087-registration.mjs keys on that banner. no-migration-prescription because 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/spec whole 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 0 on both.
  • @objectstack/spec typecheck — check:test-typecheck: OK … 54 file(s) / 262 error(s) / 146 pinned signature(s) (ledger held, no growth); tsc -p tsconfig.test.json --listFiles lists both stack-duplicate-action-key.test.ts and stack-inline-action-crossref.test.ts. VERDICT command-exit 0.
  • Reverse verification on the committed head: with errors.push(...collectDuplicateActionKeyErrors(config)); replaced on disk (marker 1 → 0, injected 1, disk blob a208575f vs HEAD 493e9307), stack-duplicate-action-key.test.ts went 11 failed | 5 passed (16) — every refusal pin red (the built-stack re-feed and identical-copy pins included), every accept pin green; restore via git checkout HEAD -- ABSPATH proven by blob hash (493e9307… both sides), git diff HEAD empty, porcelain empty. Trap-restored, absolute paths. No build needed — the pins import ./stack.zod from source. (Its restore re-touched stack.zod.ts after the DTS build, so spec was rebuilt once more before the declaration gates below.)
  • Consumer readings, each pnpm --filter PKG exec vitest run --maxWorkers=2 FILES under os-verify-lock.sh, exit 0 each:
    • @objectstack/runtimesrc/http-dispatcher.actions-global-key.test.ts + the four src/action-execution-*.test.ts: Test Files 5 passed (5), Tests 133 passed (133).
    • @objectstack/lintvalidate-action-name-refs.test.ts + reference-integrity-suite.test.ts + the two re-feed candidates authoring-rule-input-tier.test.ts + validate-form-layout.test.ts: Test Files 4 passed (4), Tests 69 passed (69).
    • @objectstack/objectqlaction-governance.test.ts + plugin-action-governance-rung.test.ts: Test Files 2 passed (2), Tests 20 passed (20).
    • @objectstack/example-crm whole 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-package has no vitest suite — its typecheck (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).
    • Consumer dists were restored through turbo with OS_SKIP_DTS=1; note for the next author: turbo's ^build re-ran @objectstack/spec under that flag despite --filter='!@objectstack/spec', clobbering its .d.ts — spec was rebuilt without the flag before every declaration-reading gate.
  • Gates green on the patched head: 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 of check:spec-parsed-alias, check:keyed-text-bounds, check:page-declaration-shape, docs-audit check-affected-docs / check-drift-comment, spec check:empty-state / check:variant-docs / check:llms-txt / check:browser-reachable-entries read files this patch did not touch. check-test-completeness exits 3 locally by design (NOT MEASURED — needs a turbo test log). The derived family list (53 commands from dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at aa937c775) is unchanged from the first head; the remaining families are CI's.

Out of scope, filed

Contract review

Draft PR; needs:contract-review parked 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

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
@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@github-actions

github-actionsBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

5 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 128 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json c616c2cc20fa74428900d5bb7376dd9d506c717epackageMentionDocs.

Which tree this was computed on

This run read content/docs from 3145bc34358f244c21acc887a8da2608119fb922 — the merge of head aa937c775121bb92aaa90ebd9a31c5005ba23b3a into base c616c2cc20fa74428900d5bb7376dd9d506c717e, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@os-samClaude

Copy link
Copy Markdown
CollaboratorAuthor

Contract review (clause ②) of head d56c22ce2 by the domain:spec seat: FAIL on one item ⇒ patch round on this branch. The full seven-item judgment list, produced by an isolated claude-fable-5 reviewer and adopted verbatim by the seat, is on the card: #14124 (comment posted 2026-09-02T18:4xZ).

Blocking item: the case-(c) carve-out (a byte-identical embedded copy of a bound standalone counted as one declaration). After mergeActionsIntoObjects appends, the absorbed pair becomes two embedded entries under one key in the shipped artifact — listActions lists it twice and bare-name resolveActionByName throws exists on multiple objects (X, X) — so the runtime does not treat that pair as one. Required: option B — refuse every one-key pair, rewrite the #7397 vacuity pin to feed hand-written embedded actions rather than a re-fed build, flip the carve-out pins, re-run spec + consumer suites. Everything else (accept-set enumeration, changeset level and ADR-0087 disposition, public surface, pin quality, scope, envelope) passed.

needs:contract-review stays on both carriers; the seat re-reviews the patched head.


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
@os-sam
os-sam marked this pull request as ready for review September 2, 2026 19:50
@os-sam
os-sam enabled auto-merge September 2, 2026 19:50
@os-sam
os-sam added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 279431eSep 2, 2026
42 checks passed
@os-sam
os-sam deleted the claude/issue-14124-define-stack-duplicate-action-key branch September 2, 2026 20:56
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-sam@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

feat(spec): defineStack refuses two actions that resolve to one scope-qualified runtime key - #14686

Merged
os-sam merged 4 commits into
mainfrom
claude/issue-14124-define-stack-duplicate-action-key
Sep 2, 2026
Merged

feat(spec): defineStack refuses two actions that resolve to one scope-qualified runtime key#14686
os-sam merged 4 commits into
mainfrom
claude/issue-14124-define-stack-duplicate-action-key

Conversation

@os-sam

@os-samos-sam commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

defineStack now 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, same name) stays accepted and is pinned as accepted; its precedence is documented on the actions collection, 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)

Mechanism measured on origin/main @ 2aa8456cf (before any edit)

One probe per row through defineStack from source:

(a) two standalone globals, one name : ACCEPTED stack.actions=["global:dup_a","global:dup_a"]
(b) two standalone bound to the same object : ACCEPTED object.actions=["dup_b/BOUND","dup_b/BOUND"]
(c) standalone bound to X + embedded on X : ACCEPTED object.actions=["dup_c/EMB","dup_c/BOUND"] -- the merge APPENDS
(d) two embedded on one object : ACCEPTED object.actions=["dup_d/EMB","dup_d/EMB"]
(z) object-less stack, two globals : ACCEPTED stack.actions=["global:dup_z","global:dup_z"]
(x) cross-scope: global + bound to X : ACCEPTED stack.actions=["global:dup_x","probe_item:dup_x"]
(y) cross-scope: global + embedded on X : ACCEPTED stack.actions=["global:dup_y"], object.actions=["dup_y/EMB"]
  • Where the walk gets its action set:validateCrossReferences runs on the parsed data BEFORE mergeActionsIntoObjects, and reads both config.actions (standalone, scoped by objectName or global) and config.objects[].actions (embedded, scoped by the owning object). Both positions are in its input today; nothing had to be widened.
  • Case (c) — replace or append?mergeActionsIntoObjects does [...base, ...objActions]: append. Both survive into object.actions under one OBJECT:NAME key, so (c) is same-scope and is refused — identical twins included (see the next section).
  • Global-scope literal: the runtime keys an object-less action under GLOBAL_ACTION_OBJECT_KEY = 'global' (packages/objectql/src/action-governance.ts:61; packages/runtime/src/action-execution.tsstandaloneActionObjectName falls back to it; collectActionDeclarations builds ${objectName}:${action.name}). The refusal uses that literal, spelled in spec as a module-private GLOBAL_ACTION_SCOPE with a lockstep note (spec sits below objectql and cannot import it).
  • Registration is a silent last-writer-wins:ObjectQL.registerAction is this.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.
  • composeStacks never routes through the walk (actions: 'concat', then mergeActionsIntoObjects directly) — left untouched per the ruling; the cross-package collision it admits is filed as composeStacks concatenates actions across packages with no same-scope duplicate-key check — two packages each declaring one global:NAME compose 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" fed defineStack's OUTPUT back into defineStack. On the way out, mergeActionsIntoObjects APPENDS every bound standalone action into its object's actions, 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 — MCP listActions (packages/runtime/src/domains/mcp.ts) lists it twice and bare-name resolveActionByName throws exists 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 doubles objects[X].actions each 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 defineStack is therefore refused when fed back in (pinned as a refusal in stack-duplicate-action-key.test.ts, one line per bound action). Re-entrancy of defineStack output as a contract is not decided here; if wanted, it pairs with an idempotent mergeActionsIntoObjects on 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 targeting probe_home and a flow action targeting probe_flow, each carrying objectName: 'probe_task' exactly as the merge would have written them, with no top-level twin — and asserts refusals(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 / resolveRouteActionDeclaration use — not by the action's own objectName, which is the registration key collectBundleActions (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 on ObjectStackDefinitionSchema.actions and on collectDuplicateActionKeyErrors.

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, plugin objectstack.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:76 and packages/lint/src/validate-form-layout.test.ts:350 (cliTierFor: defineStack(stack) on a hand-written fixture, then normalizeStackInput on the OUTPUT — the CLI tier model, not a re-feed), packages/spec/src/stack-requires.test.ts:77 (build(stack) on plain inputs), and packages/spec/src/assembled-package-body.test.ts:225 (defineStack(composed()) — a composeStacks output 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 defineStack with the refusal in place (final head, built spec)

appdeclared actionsstandalone (bound)embeddedscope keys (global / object-bound)same-scope duplicates
examples/app-crm11 (1)01 (0 / 1)0
examples/app-showcase7070 (69)070 (1 / 69)0
examples/app-todo88 (8)08 (0 / 8)0
examples/app-multi-package (core, orders, composed)0 / 0 / 00000

Loading is the measurement: each objectstack.config.ts (and each multi-package sub-stack) ran defineStack at 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': minor with a **BREAKING** banner and adr-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 a major bump and names the **BREAKING** banner plus the ADR-0087 disposition as the mandatory carriers; check-adr-0087-registration.mjs keys on that banner. no-migration-prescription because 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/spec whole 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 0 on both.
  • @objectstack/spec typecheck — check:test-typecheck: OK … 54 file(s) / 262 error(s) / 146 pinned signature(s) (ledger held, no growth); tsc -p tsconfig.test.json --listFiles lists both stack-duplicate-action-key.test.ts and stack-inline-action-crossref.test.ts. VERDICT command-exit 0.
  • Reverse verification on the committed head: with errors.push(...collectDuplicateActionKeyErrors(config)); replaced on disk (marker 1 → 0, injected 1, disk blob a208575f vs HEAD 493e9307), stack-duplicate-action-key.test.ts went 11 failed | 5 passed (16) — every refusal pin red (the built-stack re-feed and identical-copy pins included), every accept pin green; restore via git checkout HEAD -- ABSPATH proven by blob hash (493e9307… both sides), git diff HEAD empty, porcelain empty. Trap-restored, absolute paths. No build needed — the pins import ./stack.zod from source. (Its restore re-touched stack.zod.ts after the DTS build, so spec was rebuilt once more before the declaration gates below.)
  • Consumer readings, each pnpm --filter PKG exec vitest run --maxWorkers=2 FILES under os-verify-lock.sh, exit 0 each:
    • @objectstack/runtimesrc/http-dispatcher.actions-global-key.test.ts + the four src/action-execution-*.test.ts: Test Files 5 passed (5), Tests 133 passed (133).
    • @objectstack/lintvalidate-action-name-refs.test.ts + reference-integrity-suite.test.ts + the two re-feed candidates authoring-rule-input-tier.test.ts + validate-form-layout.test.ts: Test Files 4 passed (4), Tests 69 passed (69).
    • @objectstack/objectqlaction-governance.test.ts + plugin-action-governance-rung.test.ts: Test Files 2 passed (2), Tests 20 passed (20).
    • @objectstack/example-crm whole 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-package has no vitest suite — its typecheck (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).
    • Consumer dists were restored through turbo with OS_SKIP_DTS=1; note for the next author: turbo's ^build re-ran @objectstack/spec under that flag despite --filter='!@objectstack/spec', clobbering its .d.ts — spec was rebuilt without the flag before every declaration-reading gate.
  • Gates green on the patched head: 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 of check:spec-parsed-alias, check:keyed-text-bounds, check:page-declaration-shape, docs-audit check-affected-docs / check-drift-comment, spec check:empty-state / check:variant-docs / check:llms-txt / check:browser-reachable-entries read files this patch did not touch. check-test-completeness exits 3 locally by design (NOT MEASURED — needs a turbo test log). The derived family list (53 commands from dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at aa937c775) is unchanged from the first head; the remaining families are CI's.

Out of scope, filed

Contract review

Draft PR; needs:contract-review parked 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

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
@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@github-actions

github-actionsBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

5 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 128 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json c616c2cc20fa74428900d5bb7376dd9d506c717epackageMentionDocs.

Which tree this was computed on

This run read content/docs from 3145bc34358f244c21acc887a8da2608119fb922 — the merge of head aa937c775121bb92aaa90ebd9a31c5005ba23b3a into base c616c2cc20fa74428900d5bb7376dd9d506c717e, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@os-samClaude

Copy link
Copy Markdown
CollaboratorAuthor

Contract review (clause ②) of head d56c22ce2 by the domain:spec seat: FAIL on one item ⇒ patch round on this branch. The full seven-item judgment list, produced by an isolated claude-fable-5 reviewer and adopted verbatim by the seat, is on the card: #14124 (comment posted 2026-09-02T18:4xZ).

Blocking item: the case-(c) carve-out (a byte-identical embedded copy of a bound standalone counted as one declaration). After mergeActionsIntoObjects appends, the absorbed pair becomes two embedded entries under one key in the shipped artifact — listActions lists it twice and bare-name resolveActionByName throws exists on multiple objects (X, X) — so the runtime does not treat that pair as one. Required: option B — refuse every one-key pair, rewrite the #7397 vacuity pin to feed hand-written embedded actions rather than a re-fed build, flip the carve-out pins, re-run spec + consumer suites. Everything else (accept-set enumeration, changeset level and ADR-0087 disposition, public surface, pin quality, scope, envelope) passed.

needs:contract-review stays on both carriers; the seat re-reviews the patched head.


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
@os-sam
os-sam marked this pull request as ready for review September 2, 2026 19:50
@os-sam
os-sam enabled auto-merge September 2, 2026 19:50
@os-sam
os-sam added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 279431eSep 2, 2026
42 checks passed
@os-sam
os-sam deleted the claude/issue-14124-define-stack-duplicate-action-key branch September 2, 2026 20:56
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-sam@claude