# ADR 0003: device plugin UI and runtime boundary - Status: accepted - Date: 2026-07-16 ## Context The verified XGRIDS K1 workflow originally lived directly in the Mission Core React shell and local FastAPI service. That preserved experimental speed, but it made BLE, Wi-Fi, K1 state fields, and stream actions look like platform concepts. Adding a second device that way would spread vendor branches through navigation, runtime state, and visualization. ## Decision Mission Core uses an allowlisted device-plugin registry. Each installed plugin provides a versioned manifest, exactly one device model in v1alpha1, a runtime provider, and UI contributions for declared host slots. The one-model limit matches the current one-runtime-adapter lifecycle; multi-model plugins require explicit device-session and model routing in the next SDK version. The only import of a concrete device plugin from platform code is the composition root `apps/control-station/src/composition/devicePlugins.ts`. Shipping another frontend plugin therefore requires one reviewed composition-root import; `App`, navigation, workspaces, and generic runtime code do not change. Runtime discovery of arbitrary JavaScript is deliberately not supported. Backend factories are loaded only from validated, repository-local manifests. Startup fails unless every catalog manifest produces exactly one runtime with the same plugin ID, version, supported host API and action set. ADR 0011 adds a versioned descriptor/handshake before the runtime becomes ready. The catalog is then frozen for the life of the process, so a backend-catalog model cannot appear without an executable runtime. Frontend availability remains the separate reviewed static-composition step described below. The local-device UX is: ```text no model selected -> model catalog from registered manifests -> operator selects a model -> host mounts that plugin's device.connection contribution -> plugin performs its own setup and stream workflow -> plugin normalizes status, source, and metrics into MissionRuntimeState ``` Provider shells remain structurally mounted to keep the Mission Core React tree stable, but their contract requires them to be inert while `activeModel` is `null`. Before a model is selected, no device API is polled and no discovery, credentials, stream controls, or metrics are shown. The selected custom view is mounted only for its manifest `componentKey`. Changing the model is an asynchronous lifecycle transition. The active plugin must confirm deactivation; the XGRIDS implementation always issues `stream.stop` before its UI is removed. A failed or still-running teardown prevents the switch. The shared runtime envelope contains only: - generic lifecycle phase and message; - active device identity and an opaque endpoint label; - selected spatial source; - generic stream mode and metrics; - viewer settings. Vendor fields such as `k1_ip`, `likely_k1`, BLE candidates, MQTT topics, and firmware-specific phases remain inside the XGRIDS plugin adapter. ## Current implementation - Canonical manifest: `plugins/xgrids-k1/plugin.manifest.json`. - Host contracts and registry: `apps/control-station/src/core/device-plugins/`. - Generic runtime envelope: `apps/control-station/src/core/runtime/`. - Public frontend host surface: `apps/control-station/src/core/device-plugins/frontendSdk.ts`. - XGRIDS UI/runtime adapter: `plugins/xgrids-k1/frontend/src/`. - Read-only backend catalog: `GET /api/v1/device-plugins` and `GET /api/v1/device-models`. - Host-owned action dispatcher: `POST /api/v1/device-plugins/{pluginId}/actions/{actionId}`. - Runtime lifecycle health: `GET /api/v1/device-plugin-runtimes`. - Plugin-scoped event stream: `WS /api/v1/device-plugins/{pluginId}/events`, with plugin ID and sequence. - Manifest factory composition: `src/k1link/web/device_plugin_composition.py`. - Existing K1 action endpoints are compatibility shims over the same admitted runtime transport, so the proven physical implementation is unchanged. The XGRIDS frontend code is physically plugin-owned and statically linked into the Control Station during this v1alpha milestone. Its provisioning, acquisition/replay and diagnostics blocks are separate components, and it may import the host only through `@mission-core/plugin-sdk`. It is not yet a separately built or signed npm package. Backend composition is manifest-driven and performs its control-plane handshake; frontend/backend installed-set parity is still a reviewed build-time responsibility until the host gains signed plugin bundles and frontend loading policy. ## Required dependency rules - Core cannot import a concrete plugin outside the composition root. - Core cannot inspect opaque plugin state or branch on a model ID. - Plugins render only inside declared slots, cannot own global navigation, and may ship only plugin-root-scoped CSS. - Plugins publish canonical spatial sources; they do not control the scene engine or route names. - Passwords and other secrets cannot enter manifests, runtime snapshots, event payloads, or browser persistence. - Invalid or duplicate plugin/model IDs fail registry construction. - Manifest/runtime ID, version, host API or action drift fails backend startup. - Every v1alpha1 plugin declares exactly one model and a non-mutating, secret-free `state.read` action. - A plugin must stop or cancel active work before model selection can change. Manifest `permissions`, action `mutating`, and `secretFields` are validated and exposed as declarative contract metadata in v1alpha1. They do not yet implement operator authorization, policy enforcement, or secret-vault substitution; the dispatcher enforces only installed plugin IDs and declared action IDs. Until the next SDK milestone, plugin adapters remain responsible for request validation and for keeping secret values out of state, events, logs, and browser storage. ## Lifecycle model Enrollment, connectivity, and streaming are the target independent state machines. The v1alpha shared runtime currently exposes an aggregate `phase` plus `sourceMode`; plugins must keep their detailed state private until the three fields are added to the stable SDK: ```text Enrollment: empty -> selecting_model -> setup_in_progress -> enrolled Connectivity: unknown/offline -> connecting -> connected -> degraded/offline Stream: idle -> starting -> streaming -> stopping -> idle \-> failed -> idle ``` The current XGRIDS contribution maps its proven internal workflow into those platform states without changing the wire protocol: ```text confirm power -> scan BLE -> select candidate -> enter Wi-Fi -> provision once -> receive LAN address -> start source -> wait for first point frame -> streaming ``` ## Consequences and next extraction The shell can now boot with no selected device and contains no K1 wire fields. Adding another statically reviewed UI plugin does not require changing `App` or the generic local-device workspace. Backend execution still supports only reviewed `transitional-in-process` plugins, but enters through the manifest factory, immutable SDK runtime-action envelopes, the allowlisted facade and host-owned dispatcher. Synchronous capture/runtime work is moved off the API event loop. ADR 0009 completes the physical BLE/MQTT/codec/evidence extraction and plugin-owned observation contribution after replay parity and physical regression. The remaining milestone is process isolation, durable operation supervision and portable SDK stream/evidence transport; the Rerun consumer already contains no K1 topics. This ADR supersedes the compatibility names listed in ADR 0002: `K1Metrics`, `useK1Console`, and `ConsoleService` were renamed and isolated inside the XGRIDS adapter without changing the proven transport algorithms.