# 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.
