2026 03 08_sector_b_input_dispatch_plan - mark-ik/graphshell GitHub Wiki

Sector B — Input & Dispatch Registry Development Plan

Doc role: Implementation plan for the input and dispatch registry sector Status: Active / planning Date: 2026-03-08 Parent: 2026-03-08_registry_development_plan.md Registries covered: InputRegistry, ActionRegistry, RendererRegistry (new) Specs: input_registry_spec.md, action_registry_spec.md Execution note: RendererRegistry Phase B1 is landed as part of the archived servoshell debt-clear plan Phases 1–2. Sector B2/B3 remain follow-on registry work, with initial typed/context-aware keyboard resolution now landed for the current toolbar and graph-view enter path.


Purpose

Sector B owns the complete dispatch pipeline from raw user input to authoritative state mutation:

InputEvent (key / gamepad / mouse)
 └─► InputRegistry          binding × context → ActionId
      └─► ActionRegistry    ActionId × payload → Vec<GraphIntent> | WorkbenchIntent
           └─► Reducer / Workbench Authority

NodeKey + PaneId
 └─► RendererRegistry       pane attachment table → accept/reject
      └─► reconcile_webview_lifecycle()   renderer created only after acceptance

RendererRegistry is the most urgent new registry in the codebase: it is the boundary object required by debtclear Phase 1 and blocks servoshell structural inversion.

For sequencing, treat only Phase B1 as a debt-clear prerequisite. Completing InputRegistry and ActionRegistry modernization is important, but it is not a reason to pause debt-clear once the renderer boundary is landing through the debt-clear plan itself.


Current State

Registry Struct Completeness Key gaps
InputRegistry Partial Typed key bindings and context-aware resolution are landed for the current keyboard path; missing chords, runtime rebind depth, and wider surface coverage. The inherited servoshell gamepad path has been removed and is not standing debt.
ActionRegistry Partial Only 7 actions; no namespace enforcement; no capability guard; sync-only handlers
RendererRegistry Phase B1 landed Follow-through is mostly validation and broader authority-path cleanup under debtclear, not missing registry scaffolding

Phase B1 — RendererRegistry (most urgent)

Unlocks: Servoshell debtclear Phase 1 done gate; creation inversion (Phase 2).

Sequencing note: This phase is intentionally duplicated with the debt-clear plan's Phase 1B / Phase 2 work because it is the shared boundary slice. When in doubt, implement it under the debt-clear plan and treat this section as the registry-specific acceptance detail for the same work.

B1.1 — Define RendererRegistry struct

The RendererRegistry maintains a bijective map: each PaneId has at most one active RendererId; each RendererId is attached to exactly one PaneId.

pub struct PaneAttachment {
    pub pane_id: PaneId,
    pub renderer_id: RendererId,
    pub attached_at: Instant,
    pub node_key: Option<NodeKey>,  // which node this renderer is serving
}

pub struct RendererRegistry {
    by_pane: HashMap<PaneId, PaneAttachment>,
    by_renderer: HashMap<RendererId, PaneId>,
}

impl RendererRegistry {
    /// Accept a renderer for a pane. Returns Err if pane already has a renderer.
    pub fn accept(&mut self, pane: PaneId, renderer: RendererId, node: Option<NodeKey>)
        -> Result<(), RendererRegistryError>;

    /// Detach renderer from its pane. Must be called before the renderer is destroyed.
    pub fn detach(&mut self, renderer: RendererId) -> Option<PaneAttachment>;

    /// Look up which renderer is currently serving a pane.
    pub fn renderer_for_pane(&self, pane: &PaneId) -> Option<&PaneAttachment>;

    /// Look up which pane a renderer is attached to.
    pub fn pane_for_renderer(&self, renderer: &RendererId) -> Option<PaneId>;
}

Done gates:

  • RendererRegistry struct in shell/desktop/runtime/registries/renderer.rs.
  • accept(), detach(), renderer_for_pane(), pane_for_renderer() implemented.
  • Added to RegistryRuntime.
  • DIAG_RENDERER_ATTACH and DIAG_RENDERER_DETACH channels registered (Severity::Info).
  • Unit tests: accept, detach, double-accept error, lookup consistency.

B1.2 — Gate renderer creation on RendererRegistry::accept()

Per debtclear plan Phase 2A, no renderer may be created in reconcile_webview_lifecycle() unless the registry has accepted the pane attachment first. The acceptance must come from the workbench authority's handling of an open request, not from shell code.

Flow:

  1. Workbench authority receives WorkbenchIntent::OpenPane { node_key }.
  2. It calls registries.renderer.accept(pane_id, renderer_id, Some(node_key)).
  3. Only after accept() succeeds does reconcile_webview_lifecycle() create the renderer.
  4. If accept() fails (duplicate), reconcile_webview_lifecycle() does not create and emits DIAG_RENDERER_ATTACH at Warn severity.

Done gates:

  • reconcile_webview_lifecycle() checks RendererRegistry::renderer_for_pane() before any renderer creation.
  • Debtclear Phase 1 acceptance criterion: no shell code creates renderers outside reconcile_webview_lifecycle().
  • log::warn! emitted on any attempt to create without prior acceptance.
  • Scenario test: Ctrl+T open flow creates renderer only after registry accepts.

B1.3 — Detach on close and wire diagnostics

When a pane is closed:

  1. WorkbenchIntent::ClosePane triggers RendererRegistry::detach().
  2. reconcile_webview_lifecycle() destroys the renderer.
  3. DIAG_RENDERER_DETACH emits.

Done gates:

  • Detach called from workbench close path before reconcile destroys renderer.
  • No orphaned PaneAttachment entries remain after all panes are closed.
  • Test: open + close round-trip leaves registry empty.

Phase B2 — InputRegistry: Context and Chords

Unlocks: full typed/context-aware input parity for Graphshell-owned keyboard and pointer paths.

B2.1 — Typed InputBinding and modifier representation

Execution note (2026-03-09): the current keyboard path now uses typed InputBinding values plus InputContext-aware resolution for the existing toolbar submit/navigation bindings and a graph-view Enter mapping. Chord variants remain scaffolded-but-unwired follow-on work. The inherited gamepad route has since been removed; reintroduce gamepad only through a fresh Graphshell-native input design.

Replace the flat string key with a typed binding:

pub enum InputBinding {
    Key { modifiers: ModifierMask, keycode: Keycode },
    Chord(Vec<InputBinding>),       // sequential input sequence
}

pub struct ModifierMask(u8);  // bit flags for Ctrl/Shift/Alt/Super

pub enum Keycode {
    Named(NamedKey),    // Enter, Escape, Arrow*, F1–F12, etc.
    Char(char),
}

Existing 4 bindings (TOOLBAR_SUBMIT, NAV_BACK, NAV_FORWARD, NAV_RELOAD) are re-expressed as InputBinding::Key values with appropriate modifier masks.

Done gates:

  • InputBinding enum defined.
  • Existing bindings migrated; no regressions in unit tests.
  • register_binding(binding: InputBinding, action_id: ActionId, context: InputContext).

B2.2 — Context-aware resolution

Execution note (2026-03-09): deterministic context-aware resolution is now present in the registry for the landed keyboard bindings, including Enter resolving differently in OmnibarOpen and GraphView. Conflict diagnostics are emitted when the same (binding, context) pair is registered to multiple actions.

The input_registry_spec.md's context-resolution policy requires that the same physical input can resolve to different actions depending on active context.

pub enum InputContext {
    GraphView,
    DetailView,
    OmnibarOpen,
    RadialMenuOpen,
    DialogOpen,
}

pub fn resolve(&self, binding: &InputBinding, context: InputContext)
    -> InputBindingResolution

Done gates:

  • resolve() signature updated to include InputContext.
  • Enter in OmnibarOpen → omnibar submit; Enter in GraphView → graph node confirm.
  • Conflict detection: two actions bound to same (binding, context) pair emits Warn diagnostic.
  • Unit tests for each context variant.

B2.3 — Retired inherited gamepad path

The servoshell-inherited gamepad/radial-menu control path has been removed from the active implementation track. It should not be treated as missing work for Sector B completion.

Future gamepad support is allowed only as a deliberate Graphshell-native input design with its own capability model, user-visible settings, and tests. It must not resurrect the old path where controller buttons implicitly drove browser or workbench commands because servoshell did.

Done gate: no active Sector B acceptance criterion depends on gamepad.

B2.4 — Runtime rebinding

The spec requires that bindings can be remapped at runtime (user preferences, mod-provided profiles).

pub fn remap_binding(
    &mut self,
    old: InputBinding,
    new: InputBinding,
    context: InputContext,
) -> Result<(), InputConflict>

Persisted to user preferences via a GraphIntent carrier after rebind.

Current status (2026-03-10): the runtime now supports conflict-aware remap_binding() replayed from defaults through the singleton RegistryRuntime, emits an Info diagnostic on successful rebind application, and persists serialized remap specs through workspace-layout settings so rebinds restore deterministically on restart.

Done gates:

  • remap_binding() implemented with conflict detection.
  • Rebind emits DIAG_INPUT_BINDING at Info severity.
  • User preferences round-trip: rebind → persist → restore on restart.

Phase B3 — ActionRegistry: Namespace, capability, and action families

Unlocks: Correct namespace:name key discipline (CLAUDE.md); undoable actions; graph and workbench action families.

B3.1 — Enforce namespace:name key format

Per CLAUDE.md: "New registry keys must follow the namespace:name pattern."

pub fn register_action(&mut self, id: &str, handler: ActionHandler) {
    if !id.contains(':') {
        log::warn!("action_registry: key {:?} does not follow namespace:name format", id);
    }
    // ...
}

Existing action IDs are already namespace:name format but should be validated consistently.

Current status (2026-03-09): the current enum-backed ActionRegistry now exposes canonical namespace:name keys for every ActionId, audits the catalog once at runtime with log::warn! for any non-conforming key, and has a test gate that fails if an action key drifts off-format.

Done gates:

  • register_action() emits log::warn! for non-conforming keys.
  • All existing action ID constants conform (audit + fix).

B3.2 — Actions emit intents, not direct state mutation

The action_registry_spec.md's intent-emission policy: actions return Vec<GraphIntent> or WorkbenchIntent, they do not mutate state directly.

Current execute_graph_view_submit_action() and similar handlers call into graph_app state directly in some paths. Refactor all handlers to return intents:

pub fn execute(&self, action_id: &str, payload: ActionPayload, context: &ActionContext)
    -> ActionOutcome

pub enum ActionOutcome {
    Intents(Vec<GraphIntent>),
    WorkbenchIntent(WorkbenchIntent),
    SignalEmit(SignalEnvelope),
    Failure(ActionFailure),    // never silent noop
}

Current status (2026-03-10): the runtime ActionRegistry now returns explicit ActionOutcome values, existing handlers fail explicitly instead of silently no-oping on invalid or rejected input, and Verse pair/share actions now emit reducer-handled intents instead of mutating Verse state directly inside the action layer.

Done gates:

  • All 7 existing handlers refactored to return ActionOutcome.
  • No handler directly mutates GraphBrowserApp fields.
  • ActionOutcome::Failure emits DIAG_ACTION_EXECUTE at Error severity.
  • Unit tests confirm handler return shapes.

B3.3 — Graph action family

Dependency note (2026-03-10): graph:edge_create payload semantics depend on the subsystem_history/2026-02-20_edge_traversal_impl_plan.md carrier model. If label-bearing edge metadata is not available on the active GraphIntent / GraphMutation / GraphDelta / persistence WAL path, this step is not complete and must remain blocked until the carrier path is upgraded.

Register canonical graph actions:

Action ID Payload Emits
graph:node_open { node_key, pane_id? } GraphIntent::ActivateNode
graph:node_close { node_key } GraphIntent::DeactivateNode
graph:edge_create { from, to, label? } GraphIntent::AddEdge
graph:navigate_back GraphIntent::TraverseBack
graph:navigate_forward GraphIntent::TraverseForward
graph:select_node { node_key } GraphIntent::SelectNode
graph:deselect_all GraphIntent::DeselectAll

Done gates:

  • All 7 graph actions registered with intent-returning handlers.
  • graph:navigate_back / forward replace any hardcoded navigation calls.
  • graph:edge_create supports { from, to, label? } end-to-end through the reducer and persistence carrier path (no label drop or silent rejection).

B3.4 — Workbench action family

Register canonical workbench actions:

Action ID Payload Emits
workbench:split_horizontal { pane_id } WorkbenchIntent::SplitPane(Horizontal)
workbench:split_vertical { pane_id } WorkbenchIntent::SplitPane(Vertical)
workbench:close_pane { pane_id } WorkbenchIntent::ClosePane { pane, restore_previous_focus: true }
workbench:command_palette_open WorkbenchIntent::OpenCommandPalette
workbench:settings_pane_open WorkbenchIntent::OpenToolPane(Settings)
workbench:settings_overlay_open WorkbenchIntent::OpenSettingsUrl(verso://settings/general)
workbench:settings_open Legacy alias for workbench:settings_pane_open

Done gates:

  • All 7 workbench actions registered.
  • Workbench intents are routed to the workbench authority, not the graph reducer.
  • log::warn! emitted if a workbench intent is mistakenly sent to apply_reducer_intents(). (This is the SYSTEM_REGISTER "silent no-op" gap fix.)

Implementation note (2026-03-10):

  • In the current codebase, WorkbenchIntent itself does not type-flow into apply_reducer_intents(). The practical misroute seam is GraphIntent variants that are actually workbench-authority bridge carriers (currently RouteGraphViewToWorkbench).
  • Therefore B3.4 must be read as requiring:
    1. a central classifier for reducer-received workbench-authority bridge intents,
    2. a reducer-ingress log::warn! when such a bridge intent reaches apply_reducer_intents(), and
    3. forwarding from that bridge seam into the pending workbench-intent queue, not direct tile-tree mutation in reducer logic.
  • This is sufficient to make the routing boundary explicit before WorkbenchSurfaceRegistry exists, but it is not yet the full Sector E authority object.

Implementation note (2026-03-10, pane-id refactor):

  • B3.4's generic pane actions were previously blocked by graph/tool panes lacking stable PaneId. That prerequisite is now part of the lane's architectural groundwork: graph, node, and tool panes all carry PaneId, and generic split/close operations must target those stable pane identities rather than graph-view IDs or tool kinds.
  • Command palette is not modeled as ToolPaneState::CommandPalette in the current architecture. The honest workbench-authority action is WorkbenchIntent::OpenCommandPalette; do not reintroduce a fake tool-pane variant just to satisfy the registry table.

Preconditions / non-blocking groundwork:

  • apply_reducer_intents() and apply_reducer_intent_internal() remain owned by graph_app.rs after the decomposition push; B3.4 boundary hardening belongs there unless/until reducer ingress is extracted.
  • app/workbench_commands.rs queue helpers and the desktop frame-loop drain in gui_orchestration.rs are prerequisite seams for B3.4 and should be treated as intentional groundwork, not temporary accidents.
  • If additional graph-carrier variants begin acting as workbench bridges, they must be added to the central classifier immediately; do not let new bridge variants silently piggyback on reducer traffic.

B3.5 — Capability guard

Each action descriptor carries a capability requirement. execute() checks the caller's capability token before dispatching.

pub struct ActionDescriptor {
    pub id: String,
    pub required_capability: Option<ActionCapability>,
    pub handler: ActionHandler,
}

pub enum ActionCapability {
    AlwaysAvailable,
    RequiresActiveNode,
    RequiresSelection,
    RequiresWritableWorkspace,
}

describe_action(id) -> ActionCapability is exposed through RegistryRuntime.

Implementation note (2026-03-10):

  • RequiresWritableWorkspace currently exists as an action-contract boundary and UI-availability descriptor, but the codebase does not yet have a first-class read-only workspace mode. Until that lands, writable capability checks can only reject on richer predicates (selection, active-node, etc.) and must not pretend a persistence lock bit exists if it does not.

Done gates:

  • ActionDescriptor defined; all existing actions annotated with capability.
  • execute() checks capability; unavailable action returns ActionOutcome::Failure.
  • describe_action() exposed on RegistryRuntime.

Acceptance Criteria (Sector B complete)

  • RendererRegistry enforces the creation boundary: debtclear Phase 1 acceptance criterion met.
  • InputRegistry resolves active keyboard/pointer bindings through the typed context-aware contract.
  • Any future gamepad/radial-sector path is covered by a new Graphshell-native input design before implementation.
  • All action IDs follow namespace:name format.
  • Actions return intents, not direct mutations; workbench intents route to workbench authority.
  • log::warn! fires when workbench-authority intents reach apply_reducer_intents().
  • Graph and workbench action families are registered and tested.
  • Runtime rebinding works and persists through user preferences.

Related Documents

⚠️ **GitHub.com Fallback** ⚠️