Design: Scope-aware tier-3 command dispatch (RIG-2529)
Status: Draft
Owner lane: compass-ux (design) → compass-ui (execution)
Refs: RIG-2529. Closes the tier-3 focus-exclusivity hole RIG-2130 ratified as
DEFER (RD-4, was OQ-6 — compass-keyboard-spine-app-root/design.md:451-462).
Shared substrate for the RIG-2482 shortcuts overlay and the RIG-2483 command
palette (see Cross-record note).
Problem / Intent
Section titled “Problem / Intent”The keyboard dispatcher’s tier 3 (window-global) runs any registered command
whose keymap entry has no when, regardless of the command’s declared
scope and regardless of where focus is. dispatch.ts:120-129 finds the
unscoped entry and unconditionally calls command.run() +
event.preventDefault():
// Tier 3 — global. A window-global unscoped entry fires anywhere. An// unregistered global command does not swallow the event.const globalEntry = matching.find((entry) => entry.when === undefined);if (globalEntry) { const command = registry.get(globalEntry.commandId); if (command) { command.run(); event.preventDefault(); }}The concrete leak, walked through: the board registers
board.openAssignedAgent with scope: "main" (Bridge.tsx:433-439), and the
keymap binds Shift+Enter → board.openAssignedAgent with no when
(keymap.ts:98). With the board mounted but unfocused — say a topbar
segmented button holds focus (a non-board, non-editable element) — pressing
Shift+Enter:
- Tier 1 skips: no roving group is focused, so
active()isnull(dispatch.ts:93-94; the spine’sactiveGroup()scansisFocused(),spine.ts:81-84). - Tier 2 skips:
activeZone()derives from the active group (spine.ts:95-97), so it isnulltoo (dispatch.ts:107-108). - Tier 3 fires: the entry is unscoped, the command is registered →
board.openAssignedAgentruns (Bridge.tsx:393-401— every branch returnstrue). The board’s cursor persists while the board is unfocused, so the run acts on the cursor card (store.openAgent/store.selectIssue); cursorless, the branch is a no-op (:396if (!action) return true) butpreventDefault()still fires — a silent swallow of the button’s native activation either way.
A scope: "main" command fires from anywhere in the app. RIG-2130’s tier-1
focus gate is structurally sound at tier 1 — RD-4 ratified exactly this
qualification:
RD-4 — the tier-3 escape: DEFER with eyes open (was OQ-6; option B).
dispatch.tsstays frozen (unmodified). The tier-1 focus gate does not cover tier 3, so a registeredboard.*command (Shift+Enter → board.openAssignedAgent, keymap.ts:98, nowhen) is reachable from a non-editable target while the board is mounted-but-unfocused — a pre-existing hole onmainthis record neither worsens nor fixes. […] a follow-up issue to the palette/zone lane (which owns cross-tier focus semantics) tracks the cross-tier gate. —compass-keyboard-spine-app-root/design.md:451-462
The hole is pinned, not silent: Bridge.test.tsx:537-572 presses
Shift+Enter from a focused toolbar button and asserts the leak fires
(:568-571: defaultPrevented === true, store.view() === "agent"), with a
comment (:559-567) naming the follow-up lane that flips the assertion. This
record is that lane. This record closes RD-4/OQ-6.
Why now: the discoverability net (RIG-2482 keyboard-shortcuts overlay,
RIG-2483 command palette) needs the eight group-relative list.* commands
registered in the shared CommandRegistry so they earn overlay/palette rows.
Under today’s tier 3, registering them would open eight NEW copies of the same
leak — every list.* chord (Enter, Space, arrows, Home/End,
keymap.ts:79-86) is unscoped, so a registered list.openOrSelect would fire
window-globally from any non-editable target. The scope gate is what makes
register = safe: after it, a scope: "main" registration is inert unless
the main zone actually holds focus.
Global Constraints
Section titled “Global Constraints”- SolidJS ^1.9.13 (v1 on main); Bun test toolchain,
cd apps/ui && bun test --conditions browser; single root/biome.json(2.5.4, relative imports sorted alphabetically). - Styling (if any surface work were touched — none is here):
--cx-*tokens only; the mono face is--cx-font-ui: var(--rigel-mono)(tokens.css:195 — there is NO--cx-font-mono). - Command ids follow
noun.verbCamel. - ONE window keydown path:
App.tsx:48-54installs the single production listener overstore.keyboard; this record changes the dispatcher’s tier-3 resolution, never adds a listener. - Every new global-chord contract needs a production-wiring test: mount
the real App via
mountApp, press the chord, observe the store — not a unit stub alone. - This modifies a frozen contract.
dispatch.tswas frozen by RIG-2130 RD-2 and explicitly left unmodified by RIG-2456 (“apps/ui/src/keyboard/ dispatch.tsis not modified”,compass-keyboard-spine-app-root/ design.md:38-42) and by RD-4 (“dispatch.tsstays frozen”). Amending a frozen contract requires its own record + PR + review — this record is that amendment, and the flipped RD-4 pin test (T2) is the visible, reviewable proof that the contract changed deliberately. - Ledger: new decisions are record-local (Decisions below); the driver
assembles DL rows into
DECISIONS.mdat PR-assembly time (DL-max is 225 on main,DECISIONS.md:287). Do not editDECISIONS.mdfrom this record.
Approach
Section titled “Approach”One sentence: tier 3 runs a matched global entry’s command only if the
command’s declared scope permits it from the current focus context —
command.scope === "global" || command.scope === zone — turning the
Command.scope field (commands.ts:18,40, present since D5 but never read by
the dispatcher) into the load-bearing tier-3 gate.
The gate — exact before/after of dispatch.ts:120-129
Section titled “The gate — exact before/after of dispatch.ts:120-129”Before (current main, cf5d3e69):
// Tier 3 — global. A window-global unscoped entry fires anywhere. An // unregistered global command does not swallow the event. const globalEntry = matching.find((entry) => entry.when === undefined); if (globalEntry) { const command = registry.get(globalEntry.commandId); if (command) { command.run(); event.preventDefault(); } }After:
// Tier 3 — global, scope-gated (RIG-2529). A window-global unscoped // entry fires anywhere its COMMAND's scope allows: a `scope:'global'` // command runs from any target; a zone-scoped command runs only while // its zone is active. An unregistered global command does not swallow // the event, and neither does a scoped command whose zone is inactive — // both fall out without preventDefault, so native activation survives. const globalEntry = matching.find((entry) => entry.when === undefined); if (globalEntry) { const command = registry.get(globalEntry.commandId); if (command && (command.scope === "global" || command.scope === zone)) { command.run(); event.preventDefault(); } }zone is already in scope: tier 2 computes const zone = activeZone() at
handler top level (dispatch.ts:107), so tier 3 reuses it — no second
accessor call, no allocation. The gate is one added conjunct on the existing
if (command) check.
The installKeymap signature (dispatch.ts:65-69) is unchanged; activeZone
already defaults to () => null, under which the gate degrades exactly right:
only scope: "global" commands pass — the conservative direction.
(a) Unregistered global entries: unchanged
Section titled “(a) Unregistered global entries: unchanged”The matching.find(e => e.when === undefined) selection and the
no-swallow-on-unregistered behavior (dispatch.test.ts:233-240) are
untouched: an unregistered command still falls out without preventDefault.
The gate adds a second way to fall out — registered but out-of-scope — with
the same no-swallow property (the toolbar button keeps its native Enter).
(b) scope:'main' with NO active zone: does not run
Section titled “(b) scope:'main' with NO active zone: does not run”activeZone() is null whenever no registered roving group holds DOM focus
(spine.ts:95-97 derives it from activeGroup()). Then
command.scope === zone is "main" === null → false → the command does not
run. This is precisely the leak scenario (toolbar button focused ⇒ no group ⇒
no zone), so the correct outcome falls out of the predicate with no special
case: null zone means only global-scoped commands dispatch at tier 3.
(c) Tiers 1 and 2: untouched
Section titled “(c) Tiers 1 and 2: untouched”- Tier 1 (
dispatch.ts:90-103): group-relative routing viagroup.handleCommand— no registry lookup, no scope involved. Unchanged. - Tier 2 (
dispatch.ts:105-118): already zone-gated by construction (entry .when === zone). Unchanged. (See Open Questions for why tier 2 needs no symmetric command-scope check.) - Precedence is protected structurally by tier order, not by the scenario a
reviewer might first read. While the board is focused, tier 1 claims
Enteras the group-relativelist.openOrSelect(dispatch.ts:93-98; the board never declines it,Bridge.tsx:385-392). Only on a decline — or under a future decoupled zone controller — doesEnterreach tier 2, where thewhen:'main'comms.send(keymap.ts:110) outranks the unscoped tier-3list.openOrSelect(keymap.ts:83). Registeringlist.*adds tier-3 candidates only and cannot shadow the comms rows in either path. - Fall-through composition: if a focused group declines a group-relative
chord (returns
false,dispatch.ts:98), the chord now reaches a scope-gated tier 3 where the group’s zone IS active — so a registeredscope:'main'list.*command may run. For the board this is benign by construction: its registeredrunmirrorsonCommand(Bridge.tsx:438,445), the same function tier 1 just consulted — a decline means “this id does nothing here,” and calling it again does nothing again. Recorded as part of D3’s contract: a group-relative command’srunMUST be the same behavior the group handler exposes, never a divergent second path.
(d) The list.* registration story
Section titled “(d) The list.* registration story”With the gate in place, Bridge registers the eight group-relative Lists-block
ids — enumerated from keymap.ts:79-86:
list.movePrev(ArrowUp)list.moveNext(ArrowDown)list.moveLeft(ArrowLeft)list.moveRight(ArrowRight)list.openOrSelect(Enter)list.expandOrToggle(Space)list.moveFirst(Home)list.moveLast(End)
Each registers beside the existing board.* registrations
(Bridge.tsx:433-446), scope: "main", run: () => onCommand(id) (the same
mirror pattern board.* uses, Bridge.tsx:438), and is retracted in the same
onCleanup block (Bridge.tsx:447-450 grows to unregister all ten ids).
Registration is what earns the overlay/palette rows (the discoverability net
reads the registry); the gate is what makes it safe (an unfocused board’s
list.* registrations are tier-3 inert). Titles/keywords follow the existing
board.* style ("Open assigned agent" / lowercase keyword arrays,
Bridge.tsx:434-436).
T1 — the scope gate in dispatch.ts + unit cases
Section titled “T1 — the scope gate in dispatch.ts + unit cases”Interfaces:
installKeymap(registry: CommandRegistry, active: () => RovingGroupHandle | null, activeZone: () => FocusZone | null = () => null): () => void— signature unchanged (dispatch.ts:65-69).- The only production diff is the tier-3 block,
dispatch.ts:120-129, exactly as shown in Approach (one added conjunct + comment update).
Red-first test cycle (in dispatch.test.ts, slotting into the
tier-resolution suite at :150-240, reusing makeCommand/stubGroup/
keydown from :14-63; makeCommand currently hardcodes scope: "global"
(:21) — add a scope parameter defaulting to "global"):
- (green before AND after — regression guard) a
scope:'global'command fires from tier 3 with no group and no zone (Ctrl+B → view.bridge, mirrors:219-231). - (RED first) a
scope:'main'command bound unscoped in the keymap does NOT run and does NOTpreventDefaultwhenactiveZoneyieldsnull— the leak closure. UseShift+Enter → board.openAssignedAgentregisteredscope:'main',installKeymap(registry, () => null)(default zone stub). - (RED first) same registration,
activeZone: () => "main"→ the command RUNS andpreventDefaults — scoped commands still work from their zone. - (RED first — the decline→tier-3 composition) a declining group
(
stubGroup(() => false), group zone"main") +activeZone: () => "main"+ ascope:'main'list.openOrSelectregistered → the command RUNS andpreventDefaults. This pins the one genuinely new reachable path the gate creates (a group-relative command re-invoked at tier 3 after its group declined) and matches production, where the zone is derived from the very group that declined (spine.ts:95-97). Contrast case 3, which reaches the same run through anactiveZonestub with no declining group. - (green before and after) unregistered global entry still does not
swallow (
:233-240stays green untouched).
T2 — flip the RD-4 pin in Bridge.test.tsx
Section titled “T2 — flip the RD-4 pin in Bridge.test.tsx”Interfaces: Bridge.test.tsx:537-572, the focus-exclusivity test. The
first half (:546-557, toolbar button keeps Enter/Space/arrows) is unchanged.
The RD-4 pin (:559-571) flips:
- Comment: replace the DEFER narrative (
:559-567) with the closure narrative (RIG-2529: tier 3 is scope-gated;board.openAssignedAgentisscope:'main'and no zone is active from the toolbar button, so the chord falls out of tier 3 un-consumed). - Assertions:
press({ key: "Enter", shiftKey: true })→expect(escapeEvent.defaultPrevented).toBe(false)(native activation survives) andexpect(store.view()).toBe(<view before the press>)(the assigned-agent escape did NOT fire). This IS the production-wiring test for the changed global-chord contract: real App viamountApp("/"), real chord, real store observation. - Non-regression companion in the same suite: focus a board stop, press
Shift+Enter, assert the assigned-agent action still fires (tier 1 claims it —Bridge.tsx:393-401; today this path is covered by the tier-1 suite, keep or add one explicit case so the flip demonstrably narrows tier 3 without breaking the focused-board behavior).
Red-first: the flipped assertions fail on unmodified dispatch.ts (the
current :568-571 proves it — they assert the opposite), and pass with T1.
Land T1+T2 in one commit ordering: red test → gate → green.
T3 — register the eight list.* commands in Bridge
Section titled “T3 — register the eight list.* commands in Bridge”Interfaces:
Bridge.tsx:433-450: after the twoboard.*registrations, register the eight ids from Approach (d), each{ id, title, keywords, scope: "main", run: () => onCommand(id as CommandId) }; extend theonCleanup(:447-450) to unregister all ten. Prefer aconst LIST_COMMANDS: ReadonlyArray<{id: string; title: string; keywords: string[]}>table + a loop over ten literal register/unregister pairs (Biome-clean, one place to read the inventory).Bridge.test.tsx:582-618(mount→unmount→remount lifecycle): widenboardCommandIds()(:584-588) to also collectlist.*ids and assert all ten present after mount,[]after unmount, all ten after remount — the no-leak proof that a stalescope:'main'registration can never outlive its surface.
Red-first: the widened lifecycle assertions fail before the registrations exist. Depends on T1 (the gate is what makes these registrations safe); enforce commit order T1 → T3.
- T1 — scope gate in
dispatch.ts:120-129; unit cases indispatch.test.ts(global fires anywhere;scope:'main'blocked with null zone, red-first;scope:'main'fires with"main"zone, red-first; decline→tier-3 with the zone active runs, red-first; unregistered no-swallow stays green). - T2 — flip the RD-4 pin (
Bridge.test.tsx:559-571) to assertdefaultPrevented === false+ view unchanged; keep the focused-boardShift+Enternon-regression case; production wiring viamountApp. - T3 — register the eight
list.*ids inBridge.tsx:433-450besideboard.*(scope:'main',runmirrorsonCommand), retract inonCleanup; widen the lifecycle no-leak test to all ten ids.
Decisions
Section titled “Decisions”- D1 — the scope-gate predicate:
command.scope === "global" || command.scope === zone, evaluated at tier 3 on the registered command. Chosen because it makes the existing, already-authoredCommand.scopefield (commands.ts:18,40) load-bearing with a one-conjunct diff, reuses tier 2’s already-computedzone(dispatch.ts:107), and degrades conservatively whenactiveZoneis the() => nulldefault. Rejected alternative: a tier-3isGroupRelative(entry.commandId)skip (the “close-here shape” RIG-2456 named and rejected,compass-keyboard-spine- app-root/design.md:293-296) — it special-cases two id prefixes (dispatch.ts:45-47) instead of honoring the declared scope, would NOT protect a future zone-scoped non-group command (e.g. ascope:'right'sidebar command bound unscoped), and leavesCommand.scopedecorative. - D2 — close RD-4/OQ-6: an explicit reversal of a ratified DEFER.
RIG-2456 RD-4 chose defer-with-eyes-open and routed the cross-tier gate to
“the palette/zone lane (which owns cross-tier focus semantics)”
(
design.md:459-461); this record is that lane arriving. The reversal is deliberate and Matt-ratified (2026-08-22): the discoverability net turns the hole from one pinned leak into eight new ones the momentlist.*registers, so the defer’s cost basis flipped. The pinned test flips with it — the hole was never silent, and its closure is not either. Rejected alternative: keep deferring and have the overlay/palette render group-relative rows WITHOUT registering the commands (a display-only workaround) — rejected by Matt’s R2 ruling because it forks the inventory (palette dispatch path diverges from the keymap/overlay display path) and leaves the RD-4 hole open besides. - D3 —
list.*registration-beside-behavior. The eightlist.*ids register inBridge.tsxbeside the existingboard.*block (:433-450),scope:'main',runmirroringonCommand, retractedonCleanup— never in some central manifest. A command is registered where its behavior lives (the spine did the same withview.bridgebesideshowBridge,spine.ts:60-77), so registration lifetime equals behavior lifetime and the unmount retraction is structural (the lifecycle test proves it). Contract rider from Approach (c): a group-relative command’srunMUST be the same behavior the group handler exposes — the decline/ fall-through path re-invokes it harmlessly; this rider codifies the existingboard.*mirror pattern (Bridge.tsx:438,445already setruntoonCommand), it does not add a new constraint. Rejected alternative: registerlist.*app-wide at spine creation — orphans the commands from any behavior when no list surface is mounted and breaks the retraction-on-unmount invariant DL-225 exists for. - D4 — the commands-as-inventory rule (shared with RIG-2482/RIG-2483).
Register = actionable-from-anywhere; a dispatching surface (the palette’s
action mode) reads the registry; a display surface (the overlay) reads the
keymap; a command is registered beside its behavior, never purely for
discoverability — and tier 3 only runs a command whose scope is global or
matches the active zone, so registering a
scope:'main'command is safe. This gate is what reconciles the first clause with zone-scoped commands: “actionable from anywhere” is bounded by declared scope, so the registry can be the palette’s single honest inventory without any surface leaking global dispatch. Rejected alternative: a registrydiscoverable: trueflag for display-only rows — a second registration semantic that lets an entry lie about being actionable, exactly the fork D2’s rejected workaround creates. - D5 — the safety proof’s load-bearing invariant, named as a contract.
The gate is safe today because
activeZoneis derived fromactiveGroup(spine.ts:95-97):zone === "main"implies a main-zone roving group holds focus, which implies tier 1 already saw any group-relative chord — so the only way ascope:'main'command reaches the scope-gated tier 3 with the zone live is a tier-1 decline, and D3’s mirror rider makes that harmless. The codebase declares this derivation temporary, though:dispatch.ts:61-63notesactiveZone“defaults to() => null(no live zone controller in this wave)”, andkeymap.ts:72-75already reserveszone.focus*/zone.cyclerows for one. Contract: any lane that introduces an independent zone controller — one that can makeactiveZone()non-null while no roving group is focused (e.g. focus on a plain main-zone button) — inherits the obligation to re-establish tier-3 safety for zone-scoped commands, because the decline chain no longer holds: a registeredscope:'main'list.*/board.*command would again become dispatchable from a non-group main-zone target (an in-zone sibling of the RD-4 hole this record closes). This obligation is routed to that future zone lane exactly as RD-4 routed the cross-tier gate here, and T1 mirrors the note in theactiveZonedoc comment so it is unmissable at the impl site.
Cross-record note
Section titled “Cross-record note”- RIG-2482 (keyboard-shortcuts overlay) and RIG-2483 (command palette) both cite this record as external frozen substrate, the same way they cite the merged RIG-2456 spine — they consume the D4 rule, they do not redefine it.
- The two siblings depend on this record differently:
- RIG-2482 (overlay) depends code-level: its
list.*completeness is load-bearing on the gate + the eight registrations, so the overlay impl PR stacks on this record’s impl MERGE (a design freeze is not a stackable base when the dependency is running code — the overlay’slist.*-backed rows are unsafe until the merged gate exists). - RIG-2483 (palette) depends design-level only: its seed commands are
scope: "global"view commands, so the palette is correct onmainwithout this record — it cites the D4 rule so the two records never diverge, and it inherits thelist.*rows for free once this record ships, but its impl PR is based onmain, not stacked on this merge.
- RIG-2482 (overlay) depends code-level: its
- This record’s own impl PR is independent, based on
main— files disjoint from the two sibling records’ PRs. - Reminder for the impl wave (owned by the sibling records, noted here for
the driver): overlay + palette impls still share the four App-root wiring
touchpoints (spine.ts deps+signature, store.ts:1896 spine creation,
App.tsx imports+
<Show>, spine.test.ts:37-43) — resolved by set-union; whoever merges second rebases. This record touches none of those four.
Open Questions
Section titled “Open Questions”- Should tier 2 gain a symmetric command-scope check? Likely NO, and
none is specified. Tier 2 is already zone-gated by construction — it only
consults entries whose
whenequals the liveactiveZone()(dispatch.ts:105-118), so a scoped entry can never fire outside its zone. A mismatch between an entry’swhenand its command’sscope(e.g.when:'main'entry naming ascope:'right'command) would be an authoring bug in the keymap table, not a focus leak — the command still only fires whilemainis active. Adding the check would double-encode the same fact and mask the authoring bug instead of surfacing it in review. Revisit only if keymap entries ever become user-authored.