From e3a688fbe2041e0bb3e36dc8034c0264701f23c3 Mon Sep 17 00:00:00 2001 From: Alex Barclay Date: Thu, 16 Jul 2026 17:54:01 -0600 Subject: [PATCH 01/24] =?UTF-8?q?feat(app-plugins):=20Lane=20A=20build-ord?= =?UTF-8?q?er=200-2=20=E2=80=94=20unstable=20feature,=20app::*=20caps,=20A?= =?UTF-8?q?udience=20manifest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cargo.toml: enable Tauri `unstable` feature (child-webview multiwebview APIs) and PIN with `=2.11.2` (unstable is not semver-stable; note for upgrade docs). - capabilities/default.json: add isolated `app-plugins` capability scoped to the `app::*` webview-label glob (spec §6 step 0) granting host visibility/position/focus webview commands only — no core:default/shell (spec §5 no host<->app bridge). - Audience proving manifest (app-plugins/audience/app-plugin.json): credential-free dev path — fake providers as BUILD args (baked), devAuth via runtime env + DEV_WORKSPACE_ID/ DEV_USER_ID; ready probe accepts 3xx for the /dashboard redirect. - manager.rs: CC_APP_PLUGINS_DEV dev-list discovery root (user dir still wins on collision) + unit tests for the shipped manifest posture and dev-root discovery. cargo test: 28 passed / 0 failed. Manifest/discovery/lifecycle scaffolds already present on main extended in place, not rewritten. --- cockpit/ui/src-tauri/Cargo.toml | 7 +- .../app-plugins/audience/app-plugin.json | 42 +++++++++++ .../ui/src-tauri/capabilities/default.json | 66 +++++++++++------ cockpit/ui/src-tauri/src/plugins/manager.rs | 73 ++++++++++++++++++- 4 files changed, 163 insertions(+), 25 deletions(-) create mode 100644 cockpit/ui/src-tauri/app-plugins/audience/app-plugin.json diff --git a/cockpit/ui/src-tauri/Cargo.toml b/cockpit/ui/src-tauri/Cargo.toml index af109e4..15eca6a 100644 --- a/cockpit/ui/src-tauri/Cargo.toml +++ b/cockpit/ui/src-tauri/Cargo.toml @@ -25,7 +25,12 @@ serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } thiserror = "2" log = "0.4" -tauri = { version = "2.11.2", features = [] } +# LANE-A (app-plugins): the child-webview embedding layer needs Tauri's `unstable` +# feature (multiwebview: create/position/show/hide child webviews). `unstable` is +# explicitly NOT semver-stable, so the version is PINNED with `=` — a patch bump can +# silently change or remove the unstable webview APIs. Note for CI/upgrade docs: bumping +# this line requires re-verifying the embedding smoke (spec §6 step 0), not a blind bump. +tauri = { version = "=2.11.2", features = ["unstable"] } tauri-plugin-log = "2" tauri-plugin-shell = "2" # LANE-P (packaging): activate the updater runtime so the `plugins.updater` diff --git a/cockpit/ui/src-tauri/app-plugins/audience/app-plugin.json b/cockpit/ui/src-tauri/app-plugins/audience/app-plugin.json new file mode 100644 index 0000000..361532e --- /dev/null +++ b/cockpit/ui/src-tauri/app-plugins/audience/app-plugin.json @@ -0,0 +1,42 @@ +{ + "id": "audience", + "name": "Audience", + "apiVersion": 1, + "icon": "icon.svg", + "url": "http://localhost:3000", + + "lifecycle": { + "managed": true, + "cwd": "D:/MajorProjects/CURRENT/audience", + + "build": { + "cmd": "docker compose -f docker-compose.prod.yml build", + "args": { + "NODE_ENV": "development", + "AI_PROVIDER": "fake", + "MEDIA_PROVIDER": "fake" + }, + "timeout": 1200000 + }, + + "start": "docker compose -f docker-compose.prod.yml up", + "stop": "docker compose -f docker-compose.prod.yml down", + + "env": { + "NODE_ENV": "development", + "AI_PROVIDER": "fake", + "MEDIA_PROVIDER": "fake", + "DEV_WORKSPACE_ID": "ws_dev_cockpit", + "DEV_USER_ID": "user_dev_cockpit" + }, + + "health": { "url": "http://localhost:8080/health", "okStatus": [200], "timeout": 180000, "interval": 1000 }, + "ready": { "url": "http://localhost:3000", "okStatus": [200, 204, 301, 302, 307, 308], "timeout": 180000, "interval": 1000 } + }, + + "webview": { + "popups": "allow", + "externalLinks": "in-app", + "title": "Audience" + } +} diff --git a/cockpit/ui/src-tauri/capabilities/default.json b/cockpit/ui/src-tauri/capabilities/default.json index 703ac70..6722c5e 100644 --- a/cockpit/ui/src-tauri/capabilities/default.json +++ b/cockpit/ui/src-tauri/capabilities/default.json @@ -1,23 +1,43 @@ -{ - "$schema": "../gen/schemas/desktop-schema.json", - "identifier": "default", - "description": "enables the default permissions", - "windows": [ - "main" - ], - "permissions": [ - "core:default", - { - "identifier": "shell:allow-execute", - "allow": [ - { - "name": "binaries/fleetd-serve", - "sidecar": true, - "args": true - } - ] - }, - "shell:allow-kill", - "updater:default" - ] -} +[ + { + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "enables the default permissions", + "windows": [ + "main" + ], + "permissions": [ + "core:default", + { + "identifier": "shell:allow-execute", + "allow": [ + { + "name": "binaries/fleetd-serve", + "sidecar": true, + "args": true + } + ] + }, + "shell:allow-kill", + "updater:default" + ] + }, + { + "identifier": "app-plugins", + "description": "Host control of trusted app-plugin child webviews. The glob MUST match the webview-label scheme `app::` (spec §6 step 0). Scoped with `webviews` and NO `windows` so it applies only to app-plugin webviews, never the main shell. Deliberately EXCLUDES core:default and the shell permissions: app content (e.g. Audience) gets no host IPC bridge — spec §5 'no host<->app data bridge'. Only the host-side visibility/position/focus commands the embedding layer (plugin_show/plugin_hide/plugin_set_rect) drives are granted.", + "webviews": [ + "app::*" + ], + "permissions": [ + "core:webview:allow-create-webview", + "core:webview:allow-set-webview-position", + "core:webview:allow-set-webview-size", + "core:webview:allow-set-webview-focus", + "core:webview:allow-webview-show", + "core:webview:allow-webview-hide", + "core:webview:allow-webview-position", + "core:webview:allow-webview-close", + "core:webview:allow-reparent" + ] + } +] diff --git a/cockpit/ui/src-tauri/src/plugins/manager.rs b/cockpit/ui/src-tauri/src/plugins/manager.rs index 10c3618..f3436e9 100644 --- a/cockpit/ui/src-tauri/src/plugins/manager.rs +++ b/cockpit/ui/src-tauri/src/plugins/manager.rs @@ -23,9 +23,17 @@ pub struct PluginManager { } impl PluginManager { - /// Discovery roots: the per-user plugins dir. (A dev-list root can be added later.) + /// Discovery roots (spec §2 seam: dev list ∪ user dir). Ordered so LATER roots + /// win on `id` collision (discovery dedupes with later-wins): the dev-list root is + /// placed first and the per-user dir last, so a user-installed plugin overrides a + /// dev-checkout one of the same id. The dev-list root is opt-in via the + /// `CC_APP_PLUGINS_DEV` env var (points at e.g. this repo's + /// `cockpit/ui/src-tauri/app-plugins/`), keeping machine paths out of the binary. pub fn roots() -> Vec { let mut v = Vec::new(); + if let Some(dev) = std::env::var_os("CC_APP_PLUGINS_DEV") { + v.push(PathBuf::from(dev)); + } if let Some(home) = home_dir() { v.push(home.join(".command-center/app-plugins")); } @@ -162,3 +170,66 @@ pub fn plugin_launch( StartOutcome::Error(e) => Err(e), } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::plugins::discovery::discover; + use crate::plugins::manifest::Popups; + use std::path::Path; + + /// The shipped Audience proving manifest lives in the repo (dev-list root) and must + /// parse, validate, and carry the credential-free dev posture (spec §2 + audience + /// digest): fake providers baked as BUILD args, and devAuth selected at runtime env. + #[test] + fn shipped_audience_manifest_is_credential_free_dev() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("app-plugins/audience/app-plugin.json"); + let text = std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("read {}: {e}", path.display())); + let m = Manifest::from_json(&text).expect("audience manifest parses"); + m.validate().expect("audience manifest validates"); + + assert_eq!(m.id, "audience"); + let lc = &m.lifecycle; + + // Fake providers are BUILD args (baked into the image — a runtime env can't flip a + // prod-built Next image to devAuth/fake; spec §2 "build vs runtime env"). + let build = lc.build.as_ref().expect("audience has a build step"); + assert_eq!(build.args.get("NODE_ENV").map(String::as_str), Some("development")); + assert_eq!(build.args.get("AI_PROVIDER").map(String::as_str), Some("fake")); + assert_eq!(build.args.get("MEDIA_PROVIDER").map(String::as_str), Some("fake")); + + // devAuth is selected at runtime (NODE_ENV) and fabricates an identity from + // DEV_WORKSPACE_ID/DEV_USER_ID (audience digest) — so no Clerk cookie is needed. + assert_eq!(lc.env.get("NODE_ENV").map(String::as_str), Some("development")); + assert!(lc.env.contains_key("DEV_WORKSPACE_ID")); + assert!(lc.env.contains_key("DEV_USER_ID")); + + // Audience root `/` 302-redirects to /dashboard → the ready probe must accept 3xx + // or a perfectly healthy Next server is marked `error` (spec §2, critique R1 #3). + assert!(lc.ready.ok_status.contains(&302)); + assert_eq!(lc.health.ok_status, vec![200]); + + // OAuth popups must share the app's session partition (spec §4) → popups allowed. + assert_eq!(m.webview.popups, Popups::Allow); + } + + /// `CC_APP_PLUGINS_DEV` adds a dev-list discovery root; discovery finds a manifest + /// placed under it. (Verifies the dev seam wired into `roots()` end-to-end.) + #[test] + fn dev_list_root_is_discovered() { + let tmp = std::env::temp_dir().join("appplugins_devroot_test"); + let _ = std::fs::remove_dir_all(&tmp); + std::fs::create_dir_all(tmp.join("audience")).unwrap(); + let text = std::fs::read_to_string( + Path::new(env!("CARGO_MANIFEST_DIR")).join("app-plugins/audience/app-plugin.json"), + ) + .unwrap(); + std::fs::write(tmp.join("audience/app-plugin.json"), text).unwrap(); + + let found = discover(&[tmp.as_path()]); + assert_eq!(found.len(), 1); + assert_eq!(found[0].manifest.id, "audience"); + } +} From dc378066ba2c12690237791f468992a4c9497189 Mon Sep 17 00:00:00 2001 From: Alex Barclay Date: Fri, 17 Jul 2026 16:11:27 -0600 Subject: [PATCH 02/24] =?UTF-8?q?feat(cockpit):=20view-plugin=20runtime=20?= =?UTF-8?q?=E2=80=94=20store=20command-sink,=20MessagePort=20bridge+policy?= =?UTF-8?q?,=20SDK,=20loader,=20reference=20plugin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lane V (Spec-A steps 2–5). Sandboxed-iframe view-plugin runtime: - store.svelte.ts: single command-sink support without breaking the live reconnect/socket path. Adds a plain dirty-set accumulator (drainDirty) the bridge drains on its tick, a seq-tagged per-unit log tail (logsSince) for log-append, and guarded socket-opening (ensureStream) + optimistic-insert guard so a bridge launch racing reconnect() yields exactly one unit + one socket. Preserves the started/seq-dedup/if-exists double-connect guards. - bridge.ts: MessagePort bridge. plugin-hello→init→ready handshake (identity bound to the transferred port, not the null origin); dirty-delta `state` pushes with per-unit lastEmitted suppression + full-snapshot baseline reset; log-append/log-reset; command policy (shape/authority/unknown-id/over-bound), token-bucket rate limit + inbound-flood→port.close() kill; command-ack with reqId correlation; real-launch staged to the host overlay (demo-only for plugins). Coarse `degraded` only — no raw health leaked. - loader.ts: manifest validation + apiVersion gate + capability negotiation + dev|packaged iframe-src resolution behind a single injected PluginSource. - cockpit/plugin-sdk: connect()/attach() plugin client (browser ESM + .d.ts). - plugins/reference: reference plugin exercising the FULL surface (dirty state, log-append, policed demo launch, command-ack rejection, awaiting-approval presence indicator). Pure model.js is unit-tested. Tests: 53 green across store(sink/fold/overlay), bridge (handshake 100×, policy, flood-kill, ack), loader, SDK↔session, reference model. The overlay is unchanged (already built); the bridge reads host-owned awaitingApproval state, it does not drive its own modal. --- cockpit/plugin-sdk/index.d.ts | 84 +++ cockpit/plugin-sdk/index.js | 155 +++++ cockpit/plugin-sdk/package.json | 16 + cockpit/ui/src/lib/bridge.test.ts | 382 ++++++++++++ cockpit/ui/src/lib/bridge.ts | 615 +++++++++++++++++++ cockpit/ui/src/lib/loader.test.ts | 79 +++ cockpit/ui/src/lib/loader.ts | 146 +++++ cockpit/ui/src/lib/plugin-sdk.test.ts | 113 ++++ cockpit/ui/src/lib/reference-plugin.test.ts | 83 +++ cockpit/ui/src/lib/store.sink.svelte.test.ts | 105 ++++ cockpit/ui/src/lib/store.svelte.ts | 93 ++- plugins/reference/app.js | 105 ++++ plugins/reference/index.html | 45 ++ plugins/reference/manifest.json | 8 + plugins/reference/model.d.ts | 61 ++ plugins/reference/model.js | 73 +++ 16 files changed, 2158 insertions(+), 5 deletions(-) create mode 100644 cockpit/plugin-sdk/index.d.ts create mode 100644 cockpit/plugin-sdk/index.js create mode 100644 cockpit/plugin-sdk/package.json create mode 100644 cockpit/ui/src/lib/bridge.test.ts create mode 100644 cockpit/ui/src/lib/bridge.ts create mode 100644 cockpit/ui/src/lib/loader.test.ts create mode 100644 cockpit/ui/src/lib/loader.ts create mode 100644 cockpit/ui/src/lib/plugin-sdk.test.ts create mode 100644 cockpit/ui/src/lib/reference-plugin.test.ts create mode 100644 cockpit/ui/src/lib/store.sink.svelte.test.ts create mode 100644 plugins/reference/app.js create mode 100644 plugins/reference/index.html create mode 100644 plugins/reference/manifest.json create mode 100644 plugins/reference/model.d.ts create mode 100644 plugins/reference/model.js diff --git a/cockpit/plugin-sdk/index.d.ts b/cockpit/plugin-sdk/index.d.ts new file mode 100644 index 0000000..c6e7fdc --- /dev/null +++ b/cockpit/plugin-sdk/index.d.ts @@ -0,0 +1,84 @@ +// Type declarations for the cockpit view-plugin SDK (hand-written; the runtime is +// plain browser ESM in index.js). Mirrors the host bridge protocol. + +export type PluginTier = 't1' | 't2' | 't3'; +export type PluginMode = 'demo' | 'real'; +export type PluginUnitAction = 'halt' | 'resume' | 'abandon' | 'ship'; + +export interface LogDelta { + seq: number; + stream: string; + line: string; +} + +/** A unit projection minus its heavy log (history capped by the host). */ +export interface UnitLite { + id: string; + task: string; + tier: string; + phase: string; + history: string[]; + cost: number; + usdCap: number; + tokensIn: number; + tokensOut: number; + iters: { build: number; check: number; review: number }; + findings: { round: number; title: string; severity: string; resolved: boolean }[]; + oracleFiles: string[]; + branch?: string; + pr?: string; + blocked?: string; + awaitingSlot: boolean; + rateLimited: boolean; + error?: string; + result?: string; + lastSeq: number; +} + +export interface StateMessage { + v: 1; + type: 'state'; + full: boolean; + changed: UnitLite[]; + removed: string[]; + order: string[]; + degraded: boolean; +} + +export interface CommandAck { + v: 1; + type: 'command-ack'; + reqId: string; + ok: boolean; + reasonClass?: string; +} + +export interface LaunchReq { + task: string; + tier: PluginTier; + mode: PluginMode; + min_review_rounds?: number; +} + +export interface PluginClient { + apiVersion?: number; + capabilities: string[]; + onState(cb: (s: StateMessage) => void): () => void; + onLog(cb: (unitId: string, lines: LogDelta[]) => void): () => void; + onReset(cb: () => void): () => void; + onAck(cb: (ack: CommandAck) => void): () => void; + launch(req: LaunchReq): Promise; + command(unitId: string, action: PluginUnitAction): Promise; +} + +export interface ConnectOpts { + scope?: unknown; + parent?: unknown; + timeoutMs?: number; +} + +export function connect(opts?: ConnectOpts): Promise; +export function attach( + port: MessagePort, + init?: { apiVersion?: number; capabilities?: string[] }, +): PluginClient; diff --git a/cockpit/plugin-sdk/index.js b/cockpit/plugin-sdk/index.js new file mode 100644 index 0000000..6bcc902 --- /dev/null +++ b/cockpit/plugin-sdk/index.js @@ -0,0 +1,155 @@ +// @ts-nocheck +// Cockpit view-plugin SDK (Lane V) — bundled convenience for UNTRUSTED plugins that run +// in a sandboxed iframe. It speaks the MessagePort protocol the host bridge expects: +// +// connect() → posts `plugin-hello` to the parent, awaits the host's `init` (which +// transfers a private MessagePort), replies `ready`, then exposes typed callbacks and +// promise-returning command verbs. All traffic after the handshake is on the port. +// +// The plugin never sees the daemon URL, host DOM, storage, or network — only this port. +// `connect()` accepts an injectable `scope`/`parent` purely so it is testable off a real +// window; in the iframe it defaults to `window` / `window.parent`. + +const PROTOCOL_VERSION = 1; + +/** + * Perform the plugin-announces-ready handshake and resolve to a connected client. + * @param {{ scope?: any, parent?: any, timeoutMs?: number }} [opts] + * @returns {Promise} + */ +export function connect(opts = {}) { + const scope = opts.scope ?? (typeof window !== 'undefined' ? window : undefined); + const parent = opts.parent ?? (scope ? scope.parent : undefined); + if (!scope || !parent) { + return Promise.reject(new Error('cockpit-sdk: no window/parent to connect through')); + } + return new Promise((resolve, reject) => { + let settled = false; + let timer = null; + + function onMessage(e) { + const d = e && e.data; + if (!d || d.v !== PROTOCOL_VERSION || d.type !== 'init') return; + const port = e.ports && e.ports[0]; + if (!port) return; + settled = true; + if (timer !== null) clearTimeout(timer); + scope.removeEventListener('message', onMessage); + resolve(attach(port, d)); + } + + scope.addEventListener('message', onMessage); + // The plugin announces readiness FIRST (avoids a host `load`-post race). `"*"` is safe: + // the hello is non-sensitive and the sandbox frame's real origin is the unusable "null". + parent.postMessage({ v: PROTOCOL_VERSION, type: 'plugin-hello' }, '*'); + + if (opts.timeoutMs) { + timer = setTimeout(() => { + if (settled) return; + scope.removeEventListener('message', onMessage); + reject(new Error('cockpit-sdk: handshake timed out')); + }, opts.timeoutMs); + } + }); +} + +/** + * Wrap an already-transferred MessagePort as a client. Exposed for tests that drive the + * port directly (the normal path is `connect()`). + * @param {MessagePort} port + * @param {{ apiVersion?: number, capabilities?: string[] }} init + * @returns {PluginClient} + */ +export function attach(port, init = {}) { + const stateCbs = []; + const logCbs = []; + const resetCbs = []; + const ackCbs = []; + const pending = new Map(); // reqId → resolve + let reqSeq = 0; + + port.onmessage = (e) => { + const m = e && e.data; + if (!m || m.v !== PROTOCOL_VERSION) return; + switch (m.type) { + case 'state': + for (const cb of stateCbs) cb(m); + break; + case 'log-append': + for (const cb of logCbs) cb(m.unitId, m.lines); + break; + case 'log-reset': + for (const cb of resetCbs) cb(); + break; + case 'command-ack': { + for (const cb of ackCbs) cb(m); + const resolve = pending.get(m.reqId); + if (resolve) { + pending.delete(m.reqId); + resolve(m); + } + break; + } + default: + // forward-compatible: ignore unknown host messages + break; + } + }; + if (port.start) port.start(); + + // Reply `ready` — the host answers with a full `state` snapshot. + port.postMessage({ v: PROTOCOL_VERSION, type: 'ready' }); + + function send(payload) { + const reqId = `p${reqSeq++}`; + return new Promise((resolve) => { + pending.set(reqId, resolve); + port.postMessage({ v: PROTOCOL_VERSION, type: 'command', reqId, ...payload }); + }); + } + + return { + apiVersion: init.apiVersion, + capabilities: init.capabilities ?? [], + /** Dirty-delta (or full) state pushes: `{ full, changed, removed, order, degraded }`. */ + onState(cb) { + stateCbs.push(cb); + return () => { + const i = stateCbs.indexOf(cb); + if (i >= 0) stateCbs.splice(i, 1); + }; + }, + /** Append-only log deltas: `(unitId, lines)` where each line is `{ seq, stream, line }`. */ + onLog(cb) { + logCbs.push(cb); + return () => { + const i = logCbs.indexOf(cb); + if (i >= 0) logCbs.splice(i, 1); + }; + }, + /** Fired on a daemon-stream reconnect: discard per-unit log cursors (a full state follows). */ + onReset(cb) { + resetCbs.push(cb); + return () => { + const i = resetCbs.indexOf(cb); + if (i >= 0) resetCbs.splice(i, 1); + }; + }, + /** Every `command-ack`, correlated by `reqId` (also resolves the originating promise). */ + onAck(cb) { + ackCbs.push(cb); + return () => { + const i = ackCbs.indexOf(cb); + if (i >= 0) ackCbs.splice(i, 1); + }; + }, + /** Request a launch. Resolves to the `command-ack` (ok, or rejected with reasonClass). */ + launch(req) { + return send({ launch: req }); + }, + /** Request a unit command (halt/resume/abandon/ship). Resolves to its `command-ack`. */ + command(unitId, action) { + return send({ unit: { id: unitId, action } }); + }, + }; +} diff --git a/cockpit/plugin-sdk/package.json b/cockpit/plugin-sdk/package.json new file mode 100644 index 0000000..28be614 --- /dev/null +++ b/cockpit/plugin-sdk/package.json @@ -0,0 +1,16 @@ +{ + "name": "@cockpit/plugin-sdk", + "version": "0.1.0", + "private": true, + "description": "Bundled convenience SDK for cockpit view plugins (sandboxed-iframe MessagePort client).", + "type": "module", + "main": "index.js", + "module": "index.js", + "types": "index.d.ts", + "exports": { + ".": { + "types": "./index.d.ts", + "import": "./index.js" + } + } +} diff --git a/cockpit/ui/src/lib/bridge.test.ts b/cockpit/ui/src/lib/bridge.test.ts new file mode 100644 index 0000000..85d3bb0 --- /dev/null +++ b/cockpit/ui/src/lib/bridge.test.ts @@ -0,0 +1,382 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + PROTOCOL_VERSION, + HOST_API_VERSION, + HOST_CAPABILITIES, + TokenBucket, + FloodMeter, + policeCommand, + toUnitLite, + PluginSession, + PluginBridge, + type BridgeHost, + type HostMessage, + type UnitLite, +} from './bridge'; +import { newUnit, type Unit } from './fleet'; +import type { CreateReq } from './api'; +import type { CommandName } from './types'; + +// ── a minimal in-memory BridgeHost fake ─────────────────────────────────────── +interface HostBag { + host: BridgeHost; + units: Record; + state: { dirty: string[]; degraded: boolean }; + logs: Record; + launches: CreateReq[]; + commands: [string, CommandName][]; + staged: CreateReq[]; +} + +function makeHost(): HostBag { + const units: Record = {}; + const state = { dirty: [] as string[], degraded: false }; + const logs: Record = {}; + const launches: CreateReq[] = []; + const commands: [string, CommandName][] = []; + const staged: CreateReq[] = []; + const host: BridgeHost = { + order: () => Object.keys(units), + unit: (id) => units[id], + drainDirty: () => { + const d = state.dirty; + state.dirty = []; + return d; + }, + logsSince: (id, since) => (logs[id] ?? []).filter((l) => l.seq > since), + degraded: () => state.degraded, + launch: async (req) => { + launches.push(req); + return 'new-unit'; + }, + command: async (id, name) => { + commands.push([id, name]); + }, + requestRealLaunch: (req) => { + staged.push(req); + }, + }; + return { host, units, state, logs, launches, commands, staged }; +} + +const wait = () => new Promise((r) => setTimeout(r, 0)); + +// A MessagePort round-trip can span more than one macrotask in jsdom, so poll for the +// condition rather than assuming a fixed number of ticks. +async function waitFor(pred: () => boolean, tries = 100): Promise { + for (let i = 0; i < tries; i++) { + if (pred()) return true; + await wait(); + } + return pred(); +} + +// Collect messages the host sends to the plugin end of a channel. +function pluginEnd(port: MessagePort): { received: HostMessage[]; port: MessagePort } { + const received: HostMessage[] = []; + port.onmessage = (e: MessageEvent) => received.push(e.data as HostMessage); + port.start?.(); + return { received, port }; +} + +const hasFull = (r: HostMessage[]) => r.some((m) => m.type === 'state' && m.full); +const findAck = (r: HostMessage[]) => r.find((m) => m.type === 'command-ack') as + | Extract + | undefined; + +describe('toUnitLite', () => { + it('drops the heavy log and caps history', () => { + const u = newUnit('u1', 'task', 'T1'); + u.log = [{ stream: 'agent', line: 'x' }]; + u.history = Array.from({ length: 100 }, () => 'building' as const); + const lite = toUnitLite(u, 10) as UnitLite & { log?: unknown }; + expect(lite.log).toBeUndefined(); + expect(lite.history).toHaveLength(10); + expect(lite.id).toBe('u1'); + }); +}); + +describe('TokenBucket', () => { + it('allows up to capacity, then denies until refill', () => { + const b = new TokenBucket(2, 1, 0); // 2 tokens, 1/sec + expect(b.take(0)).toBe(true); + expect(b.take(0)).toBe(true); + expect(b.take(0)).toBe(false); // dry + expect(b.take(1000)).toBe(true); // +1 token after 1s + expect(b.take(1000)).toBe(false); + }); +}); + +describe('FloodMeter', () => { + it('fires only once the per-second ceiling is exceeded', () => { + const f = new FloodMeter(3); + expect(f.hit(0)).toBe(false); + expect(f.hit(0)).toBe(false); + expect(f.hit(0)).toBe(false); + expect(f.hit(0)).toBe(true); // 4th within the window > ceiling 3 + }); + + it('slides the window so old hits expire', () => { + const f = new FloodMeter(2); + f.hit(0); + f.hit(0); + expect(f.hit(2000)).toBe(false); // earlier hits aged out + }); +}); + +describe('policeCommand — the trust boundary', () => { + const ctx = { hasUnit: (id: string) => id === 'known' }; + + it('accepts a well-formed demo launch', () => { + const r = policeCommand( + { v: 1, type: 'command', reqId: 'r1', launch: { task: 'do it', tier: 't1', mode: 'demo' } }, + ctx, + ); + expect(r).toEqual({ ok: true, kind: 'launch', req: { task: 'do it', tier: 't1', mode: 'demo', min_review_rounds: 2 } }); + }); + + it('accepts a valid unit command on a known id', () => { + const r = policeCommand({ v: 1, type: 'command', reqId: 'r1', unit: { id: 'known', action: 'halt' } }, ctx); + expect(r).toEqual({ ok: true, kind: 'unit', unitId: 'known', action: 'halt' }); + }); + + it('rejects a host-only verb (approve_oracle) as an authority violation', () => { + const r = policeCommand({ v: 1, type: 'command', reqId: 'r1', unit: { id: 'known', action: 'approve_oracle' } }, ctx); + expect(r).toEqual({ ok: false, reasonClass: 'authority' }); + }); + + it('rejects an unknown unit action as unknown-type', () => { + const r = policeCommand({ v: 1, type: 'command', reqId: 'r1', unit: { id: 'known', action: 'nuke' } }, ctx); + expect(r).toEqual({ ok: false, reasonClass: 'unknown-type' }); + }); + + it('rejects a command against an unknown unit id', () => { + const r = policeCommand({ v: 1, type: 'command', reqId: 'r1', unit: { id: 'ghost', action: 'halt' } }, ctx); + expect(r).toEqual({ ok: false, reasonClass: 'unknown-id' }); + }); + + it('rejects an over-bound task', () => { + const r = policeCommand( + { v: 1, type: 'command', reqId: 'r1', launch: { task: 'x'.repeat(3000), tier: 't1', mode: 'demo' } }, + ctx, + ); + expect(r).toEqual({ ok: false, reasonClass: 'over-bound' }); + }); + + it('rejects malformed shapes (bad version, both/neither payload, empty task, bad tier)', () => { + expect(policeCommand({ v: 2, type: 'command', reqId: 'r' }, ctx).ok).toBe(false); + expect(policeCommand({ v: 1, type: 'command', reqId: 'r' }, ctx)).toEqual({ ok: false, reasonClass: 'malformed' }); + expect( + policeCommand({ v: 1, type: 'command', reqId: 'r', launch: { task: 'a', tier: 't1', mode: 'demo' }, unit: { id: 'known', action: 'halt' } }, ctx).ok, + ).toBe(false); + expect(policeCommand({ v: 1, type: 'command', reqId: 'r', launch: { task: ' ', tier: 't1', mode: 'demo' } }, ctx)).toEqual({ ok: false, reasonClass: 'malformed' }); + expect(policeCommand({ v: 1, type: 'command', reqId: 'r', launch: { task: 'a', tier: 'gold', mode: 'demo' } }, ctx).ok).toBe(false); + expect(policeCommand(null, ctx)).toEqual({ ok: false, reasonClass: 'malformed' }); + }); + + it('rejects a task carrying control characters', () => { + const task = `bad${String.fromCharCode(0)}null`; // embedded NUL + const r = policeCommand({ v: 1, type: 'command', reqId: 'r1', launch: { task, tier: 't1', mode: 'demo' } }, ctx); + expect(r).toEqual({ ok: false, reasonClass: 'malformed' }); + }); +}); + +describe('PluginSession over a real MessageChannel', () => { + it('sends a full snapshot on ready and echoes host capabilities in init separately', async () => { + const { host, units } = makeHost(); + units['u1'] = newUnit('u1', 'task one', 'T1'); + const ch = new MessageChannel(); + const plugin = pluginEnd(ch.port2); + const session = new PluginSession(ch.port1, host, { autoTick: false }); + session.start(); + + ch.port2.postMessage({ v: 1, type: 'ready' }); + await waitFor(() => hasFull(plugin.received)); + + const full = plugin.received.find((m) => m.type === 'state' && m.full); + expect(full).toBeTruthy(); + expect((full as Extract).changed.map((u) => u.id)).toEqual(['u1']); + session.destroy(); + }); + + it('handshake→ready→full-snapshot succeeds 100× with zero drops', async () => { + let delivered = 0; + for (let i = 0; i < 100; i++) { + const { host, units } = makeHost(); + units['u1'] = newUnit('u1', 't', 'T1'); + const ch = new MessageChannel(); + const plugin = pluginEnd(ch.port2); + const session = new PluginSession(ch.port1, host, { autoTick: false }); + session.start(); + ch.port2.postMessage({ v: 1, type: 'ready' }); + if (await waitFor(() => hasFull(plugin.received))) delivered++; + session.destroy(); + } + expect(delivered).toBe(100); + }); + + it('pushes a dirty-delta state on tick containing only changed units', async () => { + const bag = makeHost(); + const { host, units } = bag; + units['u1'] = newUnit('u1', 't', 'T1'); + units['u2'] = newUnit('u2', 't', 'T1'); + const ch = new MessageChannel(); + const plugin = pluginEnd(ch.port2); + const session = new PluginSession(ch.port1, host, { autoTick: false }); + session.start(); + ch.port2.postMessage({ v: 1, type: 'ready' }); + await waitFor(() => hasFull(plugin.received)); + plugin.received.length = 0; + + // Only u1 changed since the snapshot. + units['u1'].phase = 'building'; + bag.state.dirty = ['u1']; + session.tick(); + await waitFor(() => plugin.received.some((m) => m.type === 'state')); + + const delta = plugin.received.find((m) => m.type === 'state') as Extract; + expect(delta.full).toBe(false); + expect(delta.changed.map((u) => u.id)).toEqual(['u1']); + session.destroy(); + }); + + it('emits log-append for new lines and log-reset on reset()', async () => { + const bag = makeHost(); + const { host, units, logs } = bag; + units['u1'] = newUnit('u1', 't', 'T1'); + logs['u1'] = [ + { seq: 1, stream: 'agent', line: 'first' }, + { seq: 2, stream: 'agent', line: 'second' }, + ]; + const ch = new MessageChannel(); + const plugin = pluginEnd(ch.port2); + const session = new PluginSession(ch.port1, host, { autoTick: false }); + session.start(); + ch.port2.postMessage({ v: 1, type: 'ready' }); + await waitFor(() => hasFull(plugin.received)); + + bag.state.dirty = ['u1']; + session.tick(); + await waitFor(() => plugin.received.some((m) => m.type === 'log-append')); + const append = plugin.received.find((m) => m.type === 'log-append') as Extract; + expect(append.lines.map((l) => l.seq)).toEqual([1, 2]); + + plugin.received.length = 0; + session.reset(); + await waitFor(() => plugin.received.some((m) => m.type === 'log-reset') && hasFull(plugin.received)); + expect(plugin.received.some((m) => m.type === 'log-reset')).toBe(true); + expect(hasFull(plugin.received)).toBe(true); + session.destroy(); + }); + + it('policed demo launch reaches the sink and is acked ok', async () => { + const bag = makeHost(); + const ch = new MessageChannel(); + const plugin = pluginEnd(ch.port2); + const session = new PluginSession(ch.port1, bag.host, { autoTick: false }); + session.start(); + ch.port2.postMessage({ v: 1, type: 'ready' }); + await waitFor(() => hasFull(plugin.received)); + + ch.port2.postMessage({ v: 1, type: 'command', reqId: 'q1', launch: { task: 'go', tier: 't1', mode: 'demo' } }); + await waitFor(() => findAck(plugin.received) !== undefined); + expect(findAck(plugin.received)).toMatchObject({ reqId: 'q1', ok: true }); + expect(bag.launches).toHaveLength(1); + session.destroy(); + }); + + it('a real launch is staged for host confirm and acked rejected (not executed)', async () => { + const bag = makeHost(); + const ch = new MessageChannel(); + const plugin = pluginEnd(ch.port2); + const session = new PluginSession(ch.port1, bag.host, { autoTick: false }); + session.start(); + ch.port2.postMessage({ v: 1, type: 'ready' }); + await waitFor(() => hasFull(plugin.received)); + + ch.port2.postMessage({ v: 1, type: 'command', reqId: 'q2', launch: { task: 'go', tier: 't1', mode: 'real' } }); + await waitFor(() => findAck(plugin.received) !== undefined); + expect(findAck(plugin.received)).toMatchObject({ reqId: 'q2', ok: false, reasonClass: 'real-requires-confirm' }); + expect(bag.launches).toHaveLength(0); // NOT executed + expect(bag.staged).toHaveLength(1); // staged for the host overlay + session.destroy(); + }); + + it('acks a rejection with the policy reasonClass (unknown-id)', async () => { + const bag = makeHost(); + const ch = new MessageChannel(); + const plugin = pluginEnd(ch.port2); + const session = new PluginSession(ch.port1, bag.host, { autoTick: false }); + session.start(); + ch.port2.postMessage({ v: 1, type: 'ready' }); + await waitFor(() => hasFull(plugin.received)); + + ch.port2.postMessage({ v: 1, type: 'command', reqId: 'q3', unit: { id: 'ghost', action: 'halt' } }); + await waitFor(() => findAck(plugin.received) !== undefined); + expect(findAck(plugin.received)).toMatchObject({ reqId: 'q3', ok: false, reasonClass: 'unknown-id' }); + expect(bag.commands).toHaveLength(0); + session.destroy(); + }); + + it('an inbound flood kills the plugin (port close + onKill)', async () => { + const bag = makeHost(); + const ch = new MessageChannel(); + pluginEnd(ch.port2); + const onKill = vi.fn(); + const session = new PluginSession(ch.port1, bag.host, { autoTick: false, floodCeiling: 5, onKill }); + session.start(); + ch.port2.postMessage({ v: 1, type: 'ready' }); + await wait(); + + for (let i = 0; i < 50; i++) { + ch.port2.postMessage({ v: 1, type: 'command', reqId: `f${i}`, unit: { id: 'x', action: 'halt' } }); + } + await waitFor(() => onKill.mock.calls.length > 0); + expect(onKill).toHaveBeenCalledWith('flood'); + expect(session.isAlive).toBe(false); + }); +}); + +describe('PluginBridge window handshake (hello → init + port)', () => { + it('replies init only to a hello from OUR frame, transferring a port', async () => { + const bag = makeHost(); + let initMsg: HostMessage | null = null; + let transferred: MessagePort | null = null; + const contentWindow = { + postMessage: (msg: unknown, _o: string, transfer?: Transferable[]) => { + initMsg = msg as HostMessage; + transferred = (transfer?.[0] as MessagePort) ?? null; + }, + }; + const bridge = new PluginBridge({ contentWindow }, bag.host, { autoTick: false }); + + // A hello from a DIFFERENT source is ignored. + bridge.onWindowMessage({ data: { v: 1, type: 'plugin-hello' }, source: {} }); + expect(bridge.active).toBeNull(); + + // A hello from our frame completes the handshake. + bridge.onWindowMessage({ data: { v: 1, type: 'plugin-hello' }, source: contentWindow }); + expect(bridge.active).not.toBeNull(); + expect(initMsg).toMatchObject({ type: 'init', apiVersion: HOST_API_VERSION, capabilities: [...HOST_CAPABILITIES] }); + expect(transferred).toBeTruthy(); + + // The transferred port is live: driving ready over it yields a snapshot. + const plugin = pluginEnd(transferred!); + transferred!.postMessage({ v: PROTOCOL_VERSION, type: 'ready' }); + await waitFor(() => hasFull(plugin.received)); + expect(hasFull(plugin.received)).toBe(true); + + bridge.destroy(); + }); + + it('ignores a second hello (handshake is once-only)', () => { + const bag = makeHost(); + let posts = 0; + const contentWindow = { postMessage: () => { posts++; } }; + const bridge = new PluginBridge({ contentWindow }, bag.host, { autoTick: false }); + bridge.onWindowMessage({ data: { v: 1, type: 'plugin-hello' }, source: contentWindow }); + bridge.onWindowMessage({ data: { v: 1, type: 'plugin-hello' }, source: contentWindow }); + expect(posts).toBe(1); + bridge.destroy(); + }); +}); diff --git a/cockpit/ui/src/lib/bridge.ts b/cockpit/ui/src/lib/bridge.ts new file mode 100644 index 0000000..81691cf --- /dev/null +++ b/cockpit/ui/src/lib/bridge.ts @@ -0,0 +1,615 @@ +// View-plugin MessagePort bridge + command policy (Lane V). +// +// A view plugin is UNTRUSTED UI in a sandboxed iframe (`allow-scripts`, never +// `allow-same-origin` → opaque "null" origin). Its only channel to the host is a +// MessagePort transferred at handshake. The host identifies the plugin by HOLDING the +// port (not by `event.origin`, which is "null" and unusable for auth), and it POLICES +// every command before it reaches the store's single command sink. +// +// Handshake (plugin-announces-ready — avoids the `load`-event post race): +// 1. Host attaches a window `message` listener, then creates the sandboxed iframe. +// 2. The plugin posts a window-level `plugin-hello` to `parent`. +// 3. The host replies `init` TRANSFERRING port2; thereafter all traffic is on the port. +// +// This module has three testable layers: +// • pure policy + rate/flood primitives (`policeCommand`, `TokenBucket`, `FloodMeter`), +// • `PluginSession` — everything that happens over an established MessagePort, +// • `PluginBridge` — the window `plugin-hello` → `init`+port dance around a `PluginSession`. + +import type { CreateReq } from './api'; +import type { Unit } from './fleet'; +import type { CommandName } from './types'; +import type { FleetStore, LogDelta } from './store.svelte'; + +export const PROTOCOL_VERSION = 1 as const; +/** The apiVersion the host implements; a manifest asking for anything else is refused. */ +export const HOST_API_VERSION = 1; +/** The named capability set the host advertises in `init` (grows without apiVersion breaks). */ +export const HOST_CAPABILITIES = ['log-append', 'real-launch-confirm'] as const; + +export type PluginTier = 't1' | 't2' | 't3'; +export type PluginMode = 'demo' | 'real'; +export type PluginUnitAction = 'halt' | 'resume' | 'abandon' | 'ship'; + +/** Host-only command verbs a plugin may NEVER issue (the overlay owns these). */ +export const HOST_ONLY_ACTIONS = ['approve_oracle', 'reject_oracle']; +/** Unit verbs a plugin MAY request (still policed for shape/id/rate). */ +export const PLUGIN_UNIT_ACTIONS: PluginUnitAction[] = ['halt', 'resume', 'abandon', 'ship']; +/** Upper bound on a plugin-supplied `task` string. */ +export const MAX_TASK_LEN = 2000; +/** How many phase entries `UnitLite.history` carries (a generous bounded phase walk). */ +export const HISTORY_CAP = 64; + +// ── wire messages ──────────────────────────────────────────────────────────── +// host → plugin +export interface InitMsg { + v: 1; + type: 'init'; + apiVersion: number; + capabilities: string[]; +} +export interface StateMsg { + v: 1; + type: 'state'; + /** true = full snapshot (resets the plugin's per-unit baseline); false = dirty delta. */ + full: boolean; + changed: UnitLite[]; + /** Reserved-always-empty this cycle (the store never deletes units). */ + removed: string[]; + order: string[]; + /** Coarse daemon health — the literal key/version stay host-side (no recon). */ + degraded: boolean; +} +export interface LogAppendMsg { + v: 1; + type: 'log-append'; + unitId: string; + lines: LogDelta[]; +} +export interface LogResetMsg { + v: 1; + type: 'log-reset'; +} +export interface CommandAckMsg { + v: 1; + type: 'command-ack'; + reqId: string; + ok: boolean; + reasonClass?: string; +} +export type HostMessage = InitMsg | StateMsg | LogAppendMsg | LogResetMsg | CommandAckMsg; + +// plugin → host +export interface HelloMsg { + v: 1; + type: 'plugin-hello'; +} +export interface ReadyMsg { + v: 1; + type: 'ready'; +} +export interface CommandMsg { + v: 1; + type: 'command'; + /** Opaque, plugin-scoped correlation id — echoed in `command-ack`. NOT the host cmd_id. */ + reqId: string; + launch?: { task: string; tier: PluginTier; mode: PluginMode; min_review_rounds?: number }; + unit?: { id: string; action: PluginUnitAction }; +} + +/** `UnitLite` = a unit minus its (heavy, untagged) `log`; `history` is capped by the bridge. */ +export type UnitLite = Omit; + +export function toUnitLite(u: Unit, historyCap = HISTORY_CAP): UnitLite { + return { + id: u.id, + task: u.task, + tier: u.tier, + phase: u.phase, + history: u.history.slice(-historyCap), + cost: u.cost, + usdCap: u.usdCap, + tokensIn: u.tokensIn, + tokensOut: u.tokensOut, + iters: u.iters, + findings: u.findings, + oracleFiles: u.oracleFiles, + branch: u.branch, + pr: u.pr, + blocked: u.blocked, + awaitingSlot: u.awaitingSlot, + rateLimited: u.rateLimited, + error: u.error, + result: u.result, + lastSeq: u.lastSeq, + }; +} + +// ── rate / flood primitives ────────────────────────────────────────────────── + +/** Token bucket for ACCEPTED commands (a second, stricter bucket guards `launch`). */ +export class TokenBucket { + private tokens: number; + private last: number; + constructor( + private readonly capacity: number, + private readonly refillPerSec: number, + now: number = Date.now(), + ) { + this.tokens = capacity; + this.last = now; + } + + /** Consume `cost` tokens; returns false (and consumes nothing) if the bucket is dry. */ + take(now: number = Date.now(), cost = 1): boolean { + const elapsed = Math.max(0, now - this.last) / 1000; + this.tokens = Math.min(this.capacity, this.tokens + elapsed * this.refillPerSec); + this.last = now; + if (this.tokens >= cost) { + this.tokens -= cost; + return true; + } + return false; + } +} + +/** + * Inbound port-message ceiling measured BEFORE policy — a flood can't be deserialized + * into a host-thread stall. Sliding one-second window; `hit()` returns true when the + * ceiling is exceeded (the session then `port.close()`s the plugin). + */ +export class FloodMeter { + private times: number[] = []; + constructor(private readonly ceilingPerSec: number) {} + + hit(now: number = Date.now()): boolean { + this.times.push(now); + const cutoff = now - 1000; + let drop = 0; + while (drop < this.times.length && this.times[drop] < cutoff) drop++; + if (drop) this.times.splice(0, drop); + return this.times.length > this.ceilingPerSec; + } +} + +// ── command policy (the trust boundary — a policy, not just a schema check) ──── + +export type PolicyResult = + | { ok: true; kind: 'launch'; req: CreateReq } + | { ok: true; kind: 'unit'; unitId: string; action: PluginUnitAction } + | { ok: false; reasonClass: string }; + +export interface PolicyContext { + /** Does this unit id exist in the host's FleetState? */ + hasUnit(id: string): boolean; +} + +function isPrintable(s: string): boolean { + // Reject control chars (except tab 0x09, newline 0x0a, carriage-return 0x0d) so a + // plugin cannot smuggle escapes/nulls into a task string. + for (let i = 0; i < s.length; i++) { + const c = s.charCodeAt(i); + if (c === 0x09 || c === 0x0a || c === 0x0d) continue; + if (c < 0x20 || c === 0x7f) return false; + } + return true; +} + +/** + * Validate a plugin `command` for SHAPE, AUTHORITY, and BOUNDS. Stateless and pure — + * cost/`real` gating and rate limiting are stateful and enforced by the session. Returns + * a discriminated result the session turns into a store call + `command-ack`. + * + * reasonClass vocabulary: `malformed` · `authority` (host-only verb) · `unknown-type` + * (unknown unit action) · `unknown-id` · `over-bound` (task too long). + */ +export function policeCommand(msg: unknown, ctx: PolicyContext): PolicyResult { + if (typeof msg !== 'object' || msg === null) return { ok: false, reasonClass: 'malformed' }; + const m = msg as Record; + if (m.v !== PROTOCOL_VERSION || m.type !== 'command') return { ok: false, reasonClass: 'malformed' }; + if (typeof m.reqId !== 'string' || m.reqId.length === 0) return { ok: false, reasonClass: 'malformed' }; + + const hasLaunch = m.launch !== undefined; + const hasUnit = m.unit !== undefined; + if (hasLaunch === hasUnit) return { ok: false, reasonClass: 'malformed' }; // exactly one + + if (hasUnit) { + const u = m.unit as Record; + if (typeof u?.id !== 'string' || typeof u?.action !== 'string') { + return { ok: false, reasonClass: 'malformed' }; + } + if (HOST_ONLY_ACTIONS.includes(u.action)) return { ok: false, reasonClass: 'authority' }; + if (!PLUGIN_UNIT_ACTIONS.includes(u.action as PluginUnitAction)) { + return { ok: false, reasonClass: 'unknown-type' }; + } + if (!ctx.hasUnit(u.id)) return { ok: false, reasonClass: 'unknown-id' }; + return { ok: true, kind: 'unit', unitId: u.id, action: u.action as PluginUnitAction }; + } + + const l = m.launch as Record; + if (typeof l?.task !== 'string') return { ok: false, reasonClass: 'malformed' }; + if (l.task.trim().length === 0 || !isPrintable(l.task)) return { ok: false, reasonClass: 'malformed' }; + if (l.task.length > MAX_TASK_LEN) return { ok: false, reasonClass: 'over-bound' }; + if (l.tier !== 't1' && l.tier !== 't2' && l.tier !== 't3') return { ok: false, reasonClass: 'malformed' }; + if (l.mode !== 'demo' && l.mode !== 'real') return { ok: false, reasonClass: 'malformed' }; + let rounds = 2; + if (l.min_review_rounds !== undefined) { + if (typeof l.min_review_rounds !== 'number' || !Number.isInteger(l.min_review_rounds) || l.min_review_rounds < 1 || l.min_review_rounds > 6) { + return { ok: false, reasonClass: 'over-bound' }; + } + rounds = l.min_review_rounds; + } + return { + ok: true, + kind: 'launch', + req: { task: l.task, tier: l.tier, mode: l.mode, min_review_rounds: rounds }, + }; +} + +// ── host adapter ───────────────────────────────────────────────────────────── + +/** + * The narrow surface the bridge needs from the host store — so the session is testable + * against a fake and the store stays the single owner of fleet state + command sink. + */ +export interface BridgeHost { + order(): string[]; + unit(id: string): Unit | undefined; + drainDirty(): string[]; + logsSince(id: string, sinceSeq: number): LogDelta[]; + /** Coarse daemon health only — never the literal key/version. */ + degraded(): boolean; + launch(req: CreateReq): Promise; + command(unitId: string, name: CommandName): Promise; + requestRealLaunch(req: CreateReq): void; +} + +/** Adapt the real `FleetStore` singleton into a `BridgeHost`. */ +export function makeFleetHost(store: FleetStore): BridgeHost { + return { + order: () => store.order, + unit: (id) => store.units[id], + drainDirty: () => store.drainDirty(), + logsSince: (id, since) => store.logsSince(id, since), + degraded: () => { + const d = store.daemon; + return !d || !d.docker || !d.anthropic_key; + }, + launch: (req) => store.launch(req), + command: (id, name) => store.cmd(name, id), + requestRealLaunch: (req) => store.requestRealLaunch(req), + }; +} + +// ── session ────────────────────────────────────────────────────────────────── + +export interface SessionOpts { + /** State/log push cadence (ms). */ + tickMs?: number; + /** Start the internal tick timer on `ready` (false = drive `tick()` manually in tests). */ + autoTick?: boolean; + /** Inbound msgs/sec ceiling before `port.close()` kill. */ + floodCeiling?: number; + /** Accepted-command token bucket size + refill/sec. */ + bucketCapacity?: number; + bucketRefillPerSec?: number; + /** Stricter bucket for `launch`. */ + launchBucketCapacity?: number; + launchBucketRefillPerSec?: number; + /** Host capabilities to echo in `init` (defaults to `HOST_CAPABILITIES`). */ + capabilities?: string[]; + /** Called when the plugin is killed (flood/destroy) so the host can revert to ops grid. */ + onKill?: (reason: string) => void; + now?: () => number; +} + +const DEFAULTS = { + tickMs: 60, + floodCeiling: 200, + bucketCapacity: 20, + bucketRefillPerSec: 10, + launchBucketCapacity: 3, + launchBucketRefillPerSec: 0.5, +}; + +/** + * Everything that happens over an established MessagePort: the `ready` handshake tail, + * dirty-delta `state` pushes, `log-append`/`log-reset`, and policed `command` → sink → + * `command-ack`. Constructed with `port1`; the plugin holds `port2`. + */ +export class PluginSession { + private ready = false; + private alive = true; + private timer: ReturnType | null = null; + private readonly flood: FloodMeter; + private readonly bucket: TokenBucket; + private readonly launchBucket: TokenBucket; + private readonly now: () => number; + private readonly tickMs: number; + private readonly autoTick: boolean; + private readonly capabilities: string[]; + // Per-unit last-emitted `UnitLite` JSON — suppresses no-op deltas (the "only changed + // units" payload AC). A full snapshot resets this baseline. + private emitted: Record = {}; + // Per-unit log seq cursor: the highest seq already delivered via `log-append`. + private logCursor: Record = {}; + + constructor( + private readonly port: MessagePort, + private readonly host: BridgeHost, + opts: SessionOpts = {}, + ) { + this.now = opts.now ?? Date.now; + this.tickMs = opts.tickMs ?? DEFAULTS.tickMs; + this.autoTick = opts.autoTick ?? true; + this.capabilities = opts.capabilities ?? [...HOST_CAPABILITIES]; + this.flood = new FloodMeter(opts.floodCeiling ?? DEFAULTS.floodCeiling); + this.bucket = new TokenBucket( + opts.bucketCapacity ?? DEFAULTS.bucketCapacity, + opts.bucketRefillPerSec ?? DEFAULTS.bucketRefillPerSec, + this.now(), + ); + this.launchBucket = new TokenBucket( + opts.launchBucketCapacity ?? DEFAULTS.launchBucketCapacity, + opts.launchBucketRefillPerSec ?? DEFAULTS.launchBucketRefillPerSec, + this.now(), + ); + this.onKill = opts.onKill; + } + + private onKill?: (reason: string) => void; + + /** Begin receiving on the port. The plugin replies `ready`; then we snapshot + tick. */ + start(): void { + this.port.onmessage = (e: MessageEvent) => this.handleMessage(e.data); + this.port.start?.(); + } + + private handleMessage(data: unknown): void { + if (!this.alive) return; + // Flood ceiling is measured BEFORE any parsing/policy — the whole point is that a + // flood can't even be deserialized into a host-thread stall. + if (this.flood.hit(this.now())) { + this.kill('flood'); + return; + } + const m = data as Record | null; + if (!m || m.v !== PROTOCOL_VERSION) return; + if (m.type === 'ready') { + this.onReady(); + } else if (m.type === 'command') { + void this.onCommand(m); + } + // unknown types are ignored (forward-compatible) + } + + private onReady(): void { + if (this.ready) return; + this.ready = true; + this.sendFullState(); + if (this.autoTick) { + this.timer = setInterval(() => this.tick(), this.tickMs); + } + } + + /** Post a FULL snapshot and reset the per-unit delta baseline. */ + sendFullState(): void { + const changed: UnitLite[] = []; + for (const id of this.host.order()) { + const u = this.host.unit(id); + if (!u) continue; + const lite = toUnitLite(u); + this.emitted[id] = JSON.stringify(lite); + changed.push(lite); + } + this.post({ + v: 1, + type: 'state', + full: true, + changed, + removed: [], + order: this.host.order(), + degraded: this.host.degraded(), + }); + } + + /** + * Drain the store's dirty set and push a per-unit `state` delta (only units whose + * `UnitLite` actually changed) plus `log-append` for any new seq-tagged log lines. + */ + tick(): void { + if (!this.ready || !this.alive) return; + const ids = this.host.drainDirty(); + const changed: UnitLite[] = []; + for (const id of ids) { + const u = this.host.unit(id); + if (!u) continue; + const lite = toUnitLite(u); + const json = JSON.stringify(lite); + if (json !== this.emitted[id]) { + this.emitted[id] = json; + changed.push(lite); + } + // Log deltas are independent of the state-delta suppression above. + const cursor = this.logCursor[id] ?? 0; + const lines = this.host.logsSince(id, cursor); + if (lines.length) { + this.logCursor[id] = lines[lines.length - 1].seq; + this.post({ v: 1, type: 'log-append', unitId: id, lines }); + } + } + if (changed.length) { + this.post({ + v: 1, + type: 'state', + full: false, + changed, + removed: [], + order: this.host.order(), + degraded: this.host.degraded(), + }); + } + } + + /** On a daemon-stream reconnect: tell the plugin to discard cursors, then full-snapshot. */ + reset(): void { + this.logCursor = {}; + this.post({ v: 1, type: 'log-reset' }); + this.emitted = {}; + this.sendFullState(); + } + + private async onCommand(m: Record): Promise { + const result = policeCommand(m, { hasUnit: (id) => this.host.unit(id) !== undefined }); + const reqId = typeof m.reqId === 'string' ? m.reqId : ''; + if (!result.ok) { + this.ack(reqId, false, result.reasonClass); + return; + } + if (result.kind === 'launch') { + // Stricter launch bucket + the general bucket. + if (!this.launchBucket.take(this.now()) || !this.bucket.take(this.now())) { + this.ack(reqId, false, 'rate-limited'); + return; + } + // Cost/`real`: a plugin `launch` is demo-only. A `real` request is STAGED for the + // host's real-launch confirm overlay (host-owned) and acked rejected so the + // plugin's promise resolves ("blocked pending confirm") instead of hanging. + if (result.req.mode === 'real') { + this.host.requestRealLaunch(result.req); + this.ack(reqId, false, 'real-requires-confirm'); + return; + } + try { + await this.host.launch(result.req); + this.ack(reqId, true); + } catch { + this.ack(reqId, false, 'sink-error'); + } + return; + } + // unit command + if (!this.bucket.take(this.now())) { + this.ack(reqId, false, 'rate-limited'); + return; + } + try { + await this.host.command(result.unitId, result.action); + this.ack(reqId, true); + } catch { + this.ack(reqId, false, 'sink-error'); + } + } + + private ack(reqId: string, ok: boolean, reasonClass?: string): void { + const msg: CommandAckMsg = { v: 1, type: 'command-ack', reqId, ok }; + if (reasonClass !== undefined) msg.reasonClass = reasonClass; + this.post(msg); + } + + private post(msg: HostMessage): void { + if (!this.alive) return; + this.port.postMessage(msg); + } + + /** Kill the plugin: stop the tick, close the port, notify the host (→ ops-grid revert). */ + kill(reason: string): void { + if (!this.alive) return; + this.alive = false; + if (this.timer !== null) { + clearInterval(this.timer); + this.timer = null; + } + try { + this.port.close(); + } catch { + /* already closed */ + } + this.onKill?.(reason); + } + + /** Clean unmount (view switch): identical teardown, reason `destroy`. */ + destroy(): void { + this.kill('destroy'); + } + + get isReady(): boolean { + return this.ready; + } + get isAlive(): boolean { + return this.alive; + } + /** The host capability set echoed to the plugin (for tests/introspection). */ + get advertisedCapabilities(): string[] { + return this.capabilities; + } +} + +// ── window-level bridge (hello → init+port → session) ───────────────────────── + +/** The subset of an ` + {:else if activeApp} + +
+ {:else if view === 'projects'} +## Active session pickup + +If the current branch is `feat/plugin-runtime` (check with `git rev-parse --abbrev-ref HEAD`), read +the **State summary** in [`docs/STATUS.md`](docs/STATUS.md) and the **"Smoke run 1"** section of +[`spikes/SPIKE-RESULTS.md`](spikes/SPIKE-RESULTS.md) before doing anything else. + +The one-line version: **PR #49's interactive smoke is ~2 of 11 items done.** Item 1.5 failed with a +UI-freezing defect (`plugin_launch` ran the whole docker build on the main event-loop thread); it is +root-caused, fixed and regression-tested in `db74a47`, **but that fix has only ever been verified by +automated gates — never in a watched window.** Re-running 1.5 and finishing the rest of the checklist +is the next action, and #49 stays draft until it's done. + +Two traps that cost time this session and will again: +- **A running dev app blocks any rebuild of the tauri crate** — `tauri-build` can't overwrite + `target/debug/fleetd-serve.exe` while it's the running sidecar, and fails `PermissionDenied`. Quit + the app before building. +- **Quit the cockpit gracefully, never `Stop-Process`,** when testing Gate 5 — a force-kill skips + `stop_all_owned` and fabricates a teardown failure. + +If the branch has changed, this section and its links are stale — delete this block. + + Project conventions live in the global `~/.claude/CLAUDE.md` and this project's memory store (`MEMORY.md` index, auto-loaded at session start). diff --git a/docs/STATUS.md b/docs/STATUS.md index f71fcea..30cc2d9 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,7 +1,7 @@ --- stage: Build readiness: "control plane publication-ready; product shell on roadmap" -updated: "2026-08-09" +updated: "2026-08-10" name: "Command Center" base_branch: "main" test_cmd: "cargo test --workspace" @@ -17,13 +17,16 @@ its own `STATUS.md`, so the Command Center appears on its own board as a `local: ## State summary **TL;DR.** The **control plane and workflow layer are feature-complete and tested**, the repo is -**public**, and `main` is now **branch-protected** with `embargo guard` + `cargo test (workspace)` as -required checks, enforced for admins. The **superseded guard digests are out of public history** — a -targeted 9-commit `filter-repo` rewrite, not the 193-commit rewrite that was correctly ruled out for -the embargoed name. The **product shell is no longer purely roadmap**: the plugin-runtime swarm's work -turned out to be **complete, not stranded**, and now sits in **draft PR #49** with all three automated -gates re-verified against current `main`, blocked only on an interactive smoke that needs a GUI -session. A design pass and remote control remain on the roadmap. +**public**, and `main` is **branch-protected** with `embargo guard` + `cargo test (workspace)` as +required checks, enforced for admins. The **superseded guard digests are out of public history** +(targeted 9-commit `filter-repo` rewrite). **The interactive smoke for PR #49 finally ran on +2026-08-10 — and it earned its keep: it caught a real UI-freezing defect on the very first +app-plugin activation.** `plugin_launch` was a *synchronous* Tauri command, so the whole +`docker compose build` + probe sequence ran on the main event-loop thread and froze the window. +Root-caused, fixed, regression-tested, committed (`db74a47`). **#49 is `MERGEABLE`/`CLEAN` and no +longer behind `main`, but it is still draft and still merge-blocked** — the smoke is only ~2 of 11 +items deep, and the fix itself has been verified *only* by automated gates, never in a watched +window. **Vision (unchanged):** the Command Center is the operator's **one-stop shop for agentic engineering** — dispatch work, see every project's stage, act without alt-tabbing, host the other @@ -38,8 +41,11 @@ launch.** 3. **Design overhaul** (needs Claude Design output). 4. **Remote Control** — brainstorm→spec after Phase-2 auth lands. -**Open PRs.** **#49 (draft)** — cockpit plugin runtime (view-plugins + app-plugins). Merge-blocked on -the interactive smoke only; see `spikes/SPIKE-RESULTS-app-plugins.md` and the Lane S human gate. +**Open PRs.** **#49 (draft)** — cockpit plugin runtime (view-plugins + app-plugins). `MERGEABLE` / +`CLEAN` (merged up to `main` on 2026-08-10, conflict-free). Merge-blocked on **finishing** the +interactive smoke: 1.5 failed and was fixed, Gate 5's container half passed, and **nine of eleven +dev items plus the entire packaged pass have never been run**. Results table in +`spikes/SPIKE-RESULTS.md` → "Smoke run 1". **Known gaps / blockers.** - **Embargoed token remains in git history** (~193 commits) — deliberately out of scope, unchanged. @@ -50,10 +56,18 @@ the interactive smoke only; see `spikes/SPIKE-RESULTS-app-plugins.md` and the La does **not** delete unreachable objects. Verified still served: commits `6016495` / `eb832bd` and blob `ee0ed06`. **Requires a GitHub Support ticket** asking them to garbage-collect unreachable objects on this repo. Until then the exposure is "attacker needs the 40-char SHA", not "gone". -- **P3's Gate 5 (app-plugin lifecycle / no orphans) was never closed**, and - `docs/SWARM-HANDOFF-plugin-runtime.md` nevertheless describes P3 as "GO" when its own record says - **LEANING GO** with packaged gates 2/4 and Gate 5 outstanding. The swarm was dispatched on the - stronger claim. Those gates are now folded into #49's smoke checklist. +- **Gate 5 is half-closed.** Container teardown **PASSES** — `docker ps` empty after a graceful quit + against a verified 0-container baseline, `fleetd-serve` exited, all ports released. But the **`app` + process survived the window close** (no window, 23 threads, still responding 15 s later). Teardown + ran; the process just didn't exit after it. **Not diagnosed** — decide next smoke whether it's a + `tauri dev` supervision artifact or a real shutdown defect. `docs/SWARM-HANDOFF-plugin-runtime.md` + still overstates P3 as "GO" when its own record says LEANING GO; unchanged. +- **The 2026-08-10 fix is unverified in a GUI.** `cargo test` / `npm test` / `npm run check` / + `clippy` all pass, and `src/App.appPlugin.test.ts` pins the contract, but nobody has watched the + AUDIENCE tab actually stay responsive. That is the single highest-value next action. +- **Audience's `video` service busy-polls at ~100% of a core while idle** (last log line is just + `video worker started, listening on …`). Different repo, not a #49 blocker, but it skews any + performance observation made while the Audience stack is up. Worth filing against Audience. - **Optional cockpit screenshot** for the README (needs a GUI session; the architecture diagram stands in). - **Roadmap remainder:** cockpit design overhaul, Local-Tracker Phase 2 dispatch, Remote Control. @@ -72,25 +86,76 @@ failure was a **stale local `main`**. Resolved 2026-08-09: branch protection, th the branch/worktree pruning below._ **Next steps.** _All open work is tracked as GitHub issues (#51–#59); this list is the ordering._ -1. **#51 — Run the interactive smoke for PR #49** (dev + packaged) and record PASS/FAIL in - `spikes/SPIKE-RESULTS.md`. Repo is parked on `feat/plugin-runtime` with the build pre-warmed. - Note: free port **8080** first (a `java` process holds it) or the Audience health probe is - inconclusive, and Docker must be up for the managed lifecycle. -2. **#52 — File the GitHub Support ticket** to GC unreachable objects. The last step of the digest - removal, and the only one that closes the residual exposure. -3. **#54 — Retire the spike branches/worktrees**, but *only after* #49 merges — they are the sole - working reproduction if the smoke fails. -4. Run `git config core.hooksPath "/.githooks"` (**absolute**) in every other clone; it is - per-clone config and does **not** travel with a merge. -5. Resume the roadmap: **#55 Local-Tracker Phase 2** (keystone + auth foundation), then **#56** the - design pass, then **#57** Remote Control. - -_Also open: **#53** (`.embargo-guard.local.json` not gitignored on `feat/plugin-runtime` — the guard -scans for plaintext, so it would not block committing its own salts+digests), **#58** (signing certs → -first signed release), **#59** (README screenshot)._ +1. **#51 — Finish the interactive smoke** (it is ~2/11 done). Start cold: `cd cockpit/ui && + npm run desktop`, or use the staged launcher (see the session log below for its path). **Run + 1.1–1.4 and 1.6–1.10, then the whole packaged Part 2.** The pivotal one is **1.5 re-run**: with + the fix, the AUDIENCE tab must show the chip walk `starting → health-probing → healthy` **with the + window responsive throughout**. A frozen window means the fix didn't take. Audience images are + already built, so activation skips the 20-minute build. Record in `spikes/SPIKE-RESULTS.md` under + "Smoke run 2". +2. **Decide the lingering-`app`-process anomaly** (Gate 5's second half) — dev artifact or real bug. +3. **#52 — File the GitHub Support ticket** to GC unreachable objects. Needs no build and no GUI; + it is the cheapest open item and the only one closing a real exposure. +4. **#54 — Retire the spike branches/worktrees**, but *only after* #49 merges — still the sole + working reproduction. +5. **Reconcile `docs/ROADMAP.md`** (last touched 2026-07-16, `cf92aec`). It still calls P3/P4 + unresolved spikes and the embedding swarms "blocked / dispatch-ready", which this file contradicts + outright, and it carries a stale "verify CI billing" note. It is now the misleading doc — the same + role it played in the three-week stranded-swarm misread. Not yet done; deliberately deferred until + #49 merges so it is written against reality. +6. Run `git config core.hooksPath "/.githooks"` (**absolute**) in every other clone; per-clone + config, does **not** travel with a merge. +7. Resume the roadmap: **#55 Local-Tracker Phase 2** (keystone + auth foundation), then **#56** the + design pass, then **#57** Remote Control. Sequence Phase 2 **after** #49 merges — it must touch + `App.svelte` and `store.svelte.ts`, the two files #49 rewrites most. + +_Also open: **#58** (signing certs → first signed release), **#59** (README screenshot). **#53 is +resolved** — merging `main` into `feat/plugin-runtime` on 2026-08-10 brought the `.gitignore` entry +across from #47; close it._ ## Session log +### 2026-08-10 — The smoke finally ran, and caught a real one + +Audit → executed the audit's own prep steps → ran the smoke → it failed on the first app-plugin +activation → root-caused and fixed it. **Branch `feat/plugin-runtime`, HEAD `db74a47`.** + +- **Prep (all of it turned out to be load-bearing).** Merged `main` into `feat/plugin-runtime` + conflict-free (`725b630`) — #49 went `BEHIND` → **`CLEAN`**, and that merge **resolved #53 for + free** by bringing #47's `.gitignore` entry across. Found `cockpit/ui/node_modules` **empty** + (collateral from the 2026-08-09 cleanup): both JS gates were failing with `'vitest' is not + recognized`, a toolchain failure that would have read as a code failure mid-smoke. `npm ci` fixed + it. The port-8080 holder turned out to be an unrelated `purposefull` Spring Boot server in an agent + worktree, which **exited on its own** — never killed anything. +- **The bug (smoke checklist 1.5).** Clicking AUDIENCE froze the entire UI. `plugin_launch` was a + **synchronous** `#[tauri::command]`, so it ran on the main event-loop thread — the *same* P3 + finding that had already forced the embedding commands to be `async` — and blocked there on + `docker compose build` plus the health/ready probe budgets. The code had predicted this in a + standing comment ("may block up to the probe timeout (~180 s) … can move to a background task"). + The Phase-6 smoke it named is exactly what came due. +- **The fix (`db74a47`).** Dispatch the start sequence to a dedicated OS thread; return immediately. + A plain thread, *not* an async-runtime worker — every seam is blocking (`Command::status`, `ureq`, + `thread::sleep`), so the runtime would just relocate the stall. The contract change matters more + than the threading: **`Ok` now means "dispatched", not "healthy"**, so `App.svelte` stopped + fabricating `pluginState[id]='healthy'` and stopped calling `plugin_show` directly. Without that + half, the early return would have pointed the child webview at a URL that isn't serving yet. The + existing compositing `$effect` already composites on the `plugin://state` `healthy` event, so the + frontend fix was mostly deletion. Pinned by **`src/App.appPlugin.test.ts`**, written red first + (it caught `plugin_show` firing **twice** before any state event) then green. +- **Gates after the fix:** `cargo test` 28 · `npm test` **135** (19 files, +2) · `npm run check` + **353 files, 0/0** · `clippy` **exit 0**. Clippy initially failed `PermissionDenied` — not a code + problem: `tauri-build` copies the sidecar every build and couldn't overwrite + `target/debug/fleetd-serve.exe` **because that file was the running sidecar**. Worth remembering: + **a running dev app blocks any rebuild of this crate.** +- **Gate 5, split.** Containers: **PASS** (0 after graceful quit, against a verified 0 baseline). + Process exit: **ANOMALY** — the `app` process outlived its window. Recorded, not diagnosed. +- **Found but not ours:** Audience's `video` container busy-polls at ~100% of a core while idle. +- **Deliberately not done:** the `docs/ROADMAP.md` reconcile (next-step 5) — it is stale and + contradicts this file, but it should be rewritten against a merged #49, not a pending one. +- Session artifacts (launcher + full 11-item checklist) were staged in the session scratchpad; the + checklist content is reproduced in `spikes/SPIKE-RESULTS.md`, so **nothing depends on the + scratchpad surviving**. + ### 2026-08-09 — Work audit, then worked the findings Ran a full work-audit after ~10 days idle and executed the results rather than just filing them. From 0d05f554beacd4c5e55a47e697e0d6d04fabddd2 Mon Sep 17 00:00:00 2001 From: Alex Barclay Date: Sat, 15 Aug 2026 11:18:47 -0600 Subject: [PATCH 09/24] test(cockpit): cover Gate 5 teardown selection and ratchet the main-thread rule Gate 5 ("quit the app, confirm `docker ps` is clean") was the one merge blocker on the plugin runtime with no automated coverage at all. It splits in two: WHICH stacks a teardown pass picks, and WHETHER `docker compose down` brings them down. Only the first half is testable without a Docker daemon, so extract it. `teardown_targets` pulls the selection half out of `stop_all_owned` and pins it with six tests: owned-only (adopted stacks survive a quit), a vanished discovery record is skipped rather than panicked on inside the shutdown handler, every owned stack is selected rather than the first, the manifest's own cwd is carried through, the running map is emptied, and a second pass is a no-op. That last one bears on the open Gate-5 process-exit anomaly: it rules out a teardown pass re-spending the 30 s ExitRequested budget. `tests/tauri_command_threading.rs` makes the main-thread-blocking defect mechanical. This project has hit it twice -- the embedding commands in P3, then `plugin_launch` in smoke item 1.5 -- and both times a human watching a window caught what every automated gate had passed. The test scans for `#[tauri::command]` and requires each to be `async` or dispatching; the four pre-existing sync commands are listed as debt with a note on each, three of them marked UNBOUNDED because they genuinely are. A companion test fails if a debt entry goes stale, so the ratchet only tightens. It also pins the db74a47 fix from the Rust side: delete the thread::spawn and this goes red. cargo test (cockpit): 34 passed, 2 passed. Execution half stays a human gate. --- cockpit/ui/src-tauri/src/plugins/manager.rs | 164 +++++++++++- .../tests/tauri_command_threading.rs | 233 ++++++++++++++++++ 2 files changed, 388 insertions(+), 9 deletions(-) create mode 100644 cockpit/ui/src-tauri/tests/tauri_command_threading.rs diff --git a/cockpit/ui/src-tauri/src/plugins/manager.rs b/cockpit/ui/src-tauri/src/plugins/manager.rs index f6b409e..857d80e 100644 --- a/cockpit/ui/src-tauri/src/plugins/manager.rs +++ b/cockpit/ui/src-tauri/src/plugins/manager.rs @@ -60,16 +60,9 @@ impl PluginManager { let discovered = self.discovered.lock().unwrap().clone(); let deadline = Instant::now() + Duration::from_millis(total_deadline_ms); - let handles: Vec<_> = running + let handles: Vec<_> = teardown_targets(&running, &discovered) .into_iter() - .filter(|(_, r)| r.owned) - .filter_map(|(id, _r)| { - let found = discovered - .iter() - .find(|d| d.manifest.id == id) - .map(|d| (d.manifest.clone(), d.dir.clone()))?; - Some(std::thread::spawn(move || stop_one(&found.0, &found.1))) - }) + .map(|(m, dir)| std::thread::spawn(move || stop_one(&m, &dir))) .collect(); for h in handles { @@ -81,6 +74,29 @@ impl PluginManager { } } +/// Which plugins a teardown pass must stop: OWNED entries resolved against discovery. +/// Adopted (not-owned) stacks are excluded — the user started those by hand — and a running +/// id with no surviving discovery record is skipped rather than panicking. +/// +/// Split out of `stop_all_owned` so the *selection* half of Gate 5 is unit-testable. The +/// *execution* half (`stop_one`, which shells out to the manifest's `docker compose down`) +/// needs a Docker daemon; CI has none, so that half stays a human smoke gate. +fn teardown_targets( + running: &HashMap, + discovered: &[DiscoveredPlugin], +) -> Vec<(Manifest, PathBuf)> { + running + .iter() + .filter(|(_, r)| r.owned) + .filter_map(|(id, _r)| { + discovered + .iter() + .find(|d| &d.manifest.id == id) + .map(|d| (d.manifest.clone(), d.dir.clone())) + }) + .collect() +} + fn home_dir() -> Option { std::env::var_os("USERPROFILE") .or_else(|| std::env::var_os("HOME")) @@ -250,4 +266,134 @@ mod tests { assert_eq!(found.len(), 1); assert_eq!(found[0].manifest.id, "audience"); } + + // ---- Gate 5: teardown selection ------------------------------------------------- + // + // Gate 5 ("quit the app, confirm `docker ps` is clean") is the one merge blocker on the + // plugin runtime that had no automated coverage at all. It splits in two: WHICH stacks a + // teardown pass picks, and WHETHER `docker compose down` actually brings them down. Only + // the first half can be tested without a Docker daemon — CI has none — so that is what + // these pin. The execution half stays a human smoke item. + + /// A manifest with no `stop` command, so `stop_one` is a no-op and these tests never + /// shell out to anything. + fn manifest(id: &str) -> Manifest { + let text = serde_json::json!({ + "id": id, "name": id, "apiVersion": 1, + "url": "http://localhost:3000", + "lifecycle": { + "cwd": "/x", "start": "up", "env": {}, + "health": { "url": "h", "okStatus": [200], "timeout": 5000, "interval": 1000 }, + "ready": { "url": "r", "okStatus": [200], "timeout": 5000, "interval": 1000 } + } + }) + .to_string(); + Manifest::from_json(&text).expect("fixture manifest parses") + } + + fn discovered(ids: &[&str]) -> Vec { + ids.iter() + .map(|id| DiscoveredPlugin { + dir: PathBuf::from(format!("/plugins/{id}")), + manifest: manifest(id), + }) + .collect() + } + + fn running(entries: &[(&str, bool)]) -> HashMap { + entries + .iter() + .map(|(id, owned)| { + ( + (*id).to_string(), + Running { child_id: Some(1), owned: *owned }, + ) + }) + .collect() + } + + /// Only OWNED stacks are torn down. An adopted stack — one that was already up when the + /// start sequence ran, so the user owns it — must be left running on quit. + #[test] + fn teardown_selects_owned_and_leaves_adopted_running() { + let targets = teardown_targets( + &running(&[("audience", true), ("adopted", false)]), + &discovered(&["audience", "adopted"]), + ); + let ids: Vec<&str> = targets.iter().map(|(m, _)| m.id.as_str()).collect(); + assert_eq!(ids, vec!["audience"]); + } + + /// A running id whose discovery record has vanished (plugin dir deleted or renamed while + /// the app was up) is skipped, not panicked on. Teardown runs inside the shutdown handler, + /// where a panic would strand every container that had not been reached yet. + #[test] + fn teardown_skips_running_id_with_no_discovery_record() { + let targets = teardown_targets( + &running(&[("audience", true), ("vanished", true)]), + &discovered(&["audience"]), + ); + let ids: Vec<&str> = targets.iter().map(|(m, _)| m.id.as_str()).collect(); + assert_eq!(ids, vec!["audience"]); + } + + /// EVERY owned stack is selected, not just the first — a partial teardown is precisely the + /// orphaned-container failure Gate 5 exists to catch. + #[test] + fn teardown_selects_every_owned_stack() { + let targets = teardown_targets( + &running(&[("a", true), ("b", true), ("c", true)]), + &discovered(&["a", "b", "c"]), + ); + let mut ids: Vec<&str> = targets.iter().map(|(m, _)| m.id.as_str()).collect(); + ids.sort_unstable(); + assert_eq!(ids, vec!["a", "b", "c"]); + } + + /// The resolved teardown cwd is the manifest's, not the process's — `docker compose down` + /// only finds the right stack if it runs where the compose file lives. + #[test] + fn teardown_target_carries_the_plugins_own_directory() { + let targets = teardown_targets(&running(&[("audience", true)]), &discovered(&["audience"])); + let (m, dir) = &targets[0]; + assert_eq!(dir, &PathBuf::from("/plugins/audience")); + assert_eq!(m.resolved_cwd(dir), PathBuf::from("/x")); // absolute cwd wins over the dir + } + + /// `stop_all_owned` empties the running map — including adopted entries, which are + /// forgotten rather than stopped. Documents the `mem::take`: after one pass the manager + /// tracks nothing. + #[test] + fn stop_all_owned_clears_the_running_map_including_adopted() { + let mgr = PluginManager::default(); + *mgr.discovered.lock().unwrap() = discovered(&["audience", "adopted"]); + *mgr.running.lock().unwrap() = running(&[("audience", true), ("adopted", false)]); + + mgr.stop_all_owned(5_000); + + assert!(mgr.running.lock().unwrap().is_empty()); + } + + /// Shutdown re-entrancy. `stop_all_owned` is called from the `ExitRequested` handler on the + /// main event-loop thread (`lib.rs`) with a 30 s budget, so a second pass must not re-spend + /// it. Bears on the open Gate-5 process-exit anomaly (the `app` process outliving the + /// window): whatever holds the process open, this test rules out a teardown pass blocking + /// the loop a second time. + #[test] + fn stop_all_owned_is_idempotent_and_the_second_pass_is_immediate() { + let mgr = PluginManager::default(); + *mgr.discovered.lock().unwrap() = discovered(&["audience"]); + *mgr.running.lock().unwrap() = running(&[("audience", true)]); + + mgr.stop_all_owned(5_000); + let t = Instant::now(); + mgr.stop_all_owned(5_000); + + assert!( + t.elapsed() < Duration::from_millis(250), + "second teardown pass took {:?}; it should find nothing to do", + t.elapsed() + ); + assert!(mgr.running.lock().unwrap().is_empty()); + } } diff --git a/cockpit/ui/src-tauri/tests/tauri_command_threading.rs b/cockpit/ui/src-tauri/tests/tauri_command_threading.rs new file mode 100644 index 0000000..dbc6f3f --- /dev/null +++ b/cockpit/ui/src-tauri/tests/tauri_command_threading.rs @@ -0,0 +1,233 @@ +//! Guard test: no new `#[tauri::command]` may block the main event-loop thread. +//! +//! A *synchronous* `#[tauri::command]` is invoked on Tauri's main event-loop thread, so any +//! blocking work inside it freezes the whole window. This project has now hit that defect +//! twice: +//! +//! 1. The embedding commands (P3) — fixed by making them `async`. +//! 2. `plugin_launch` (Phase-6 smoke item 1.5) — a `docker compose build` with a 20-minute +//! budget ran inline, freezing the UI from the tab click until the stack came up. Fixed +//! in `db74a47` by dispatching the start sequence to a dedicated OS thread. See +//! `spikes/SPIKE-RESULTS.md` → "Smoke run 1". +//! +//! Both were caught by a human watching a window, and both passed every automated gate on the +//! way in. This test makes the *class* mechanical. A command is acceptable when it is either: +//! +//! * `async fn` — Tauri runs it on a worker, off the event loop; or +//! * dispatching — it hands its work to a thread and returns. +//! +//! Anything else must be named in `MAIN_THREAD_DEBT`. +//! +//! **This is a ratchet, not a clean bill of health.** The list below is the debt that already +//! existed when the guard was written; the entries marked UNBOUNDED are genuine freeze risks +//! that simply have not been fixed yet. The value is that a *new* blocking command fails here +//! immediately, instead of waiting to be discovered as a frozen window six weeks later. +//! +//! It also pins the 1.5 fix from the Rust side: delete the `thread::spawn` from +//! `plugin_launch` and this test goes red, because the command becomes sync, non-dispatching, +//! and absent from the list. + +use std::fs; +use std::path::{Path, PathBuf}; + +/// Synchronous commands that predate this guard, and why each is tolerated. +/// +/// Do NOT add an entry here to make a red build green — make the command `async` instead, or +/// dispatch its work to a thread. An entry is a promise that someone looked and decided the +/// freeze risk was acceptable. +const MAIN_THREAD_DEBT: &[(&str, &str)] = &[ + ( + "plugins_list", + "BOUNDED: read_dir over at most two shallow roots, one small JSON parse per entry. \ + No network, no subprocess. Cost is a few milliseconds.", + ), + ( + "halyard_status", + "UNBOUNDED: spawns the halyard CLI via Command::output() with NO timeout. A hung or \ + slow CLI freezes the window for as long as it hangs — the same shape as the 1.5 \ + defect, just with a different blocking call.", + ), + ( + "halyard_queue", + "UNBOUNDED: same call path as halyard_status, same risk.", + ), + ( + "scan_local_projects", + "UNBOUNDED: recursive walkdir over operator-configured scan roots, plus a file read \ + per project found. Cost scales with the size of the operator's disk, not with \ + anything this repo controls.", + ), +]; + +#[derive(Debug)] +struct TauriCommand { + name: String, + is_async: bool, + dispatches: bool, + file: String, + line: usize, +} + +impl TauriCommand { + /// Runs off the event loop one way or the other. + fn is_safe(&self) -> bool { + self.is_async || self.dispatches + } +} + +#[test] +fn no_new_command_blocks_the_main_event_loop() { + let commands = collect_commands(); + + assert!( + !commands.is_empty(), + "found no #[tauri::command] at all — the scanner is broken, not the code" + ); + + let offenders: Vec<&TauriCommand> = commands + .iter() + .filter(|c| !c.is_safe()) + .filter(|c| !MAIN_THREAD_DEBT.iter().any(|(name, _)| *name == c.name)) + .collect(); + + assert!( + offenders.is_empty(), + "\n\ + These #[tauri::command] functions are synchronous and do not dispatch their work, so\n\ + they run on Tauri's main event-loop thread. If any of them blocks — a subprocess, an\n\ + HTTP call, a filesystem walk — the entire window freezes for that long.\n\n\ + {}\n\n\ + Fix by making the command `async fn` (Tauri then runs it on a worker), or by handing\n\ + the blocking work to a thread and returning immediately, as `plugin_launch` does.\n\ + Only add to MAIN_THREAD_DEBT in tests/tauri_command_threading.rs if you have looked\n\ + at the work it does and concluded the freeze risk is genuinely acceptable.\n", + offenders + .iter() + .map(|c| format!(" - {} ({}:{})", c.name, c.file, c.line)) + .collect::>() + .join("\n") + ); +} + +/// Keeps the debt list honest: an entry that no longer names a sync command is stale, either +/// because the command was fixed (delete the entry and keep the win) or renamed/removed. +#[test] +fn the_debt_list_has_no_stale_entries() { + let commands = collect_commands(); + + let stale: Vec<&str> = MAIN_THREAD_DEBT + .iter() + .map(|(name, _)| *name) + .filter(|name| { + !commands + .iter() + .any(|c| c.name == *name && !c.is_safe()) + }) + .collect(); + + assert!( + stale.is_empty(), + "\n\ + MAIN_THREAD_DEBT names commands that are no longer synchronous main-thread commands:\n\ + {}\n\n\ + If one was fixed, delete its entry so the ratchet tightens. If one was renamed or\n\ + removed, delete its entry too.\n", + stale + .iter() + .map(|n| format!(" - {n}")) + .collect::>() + .join("\n") + ); +} + +fn collect_commands() -> Vec { + let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut files = Vec::new(); + rs_files(&src, &mut files); + files.sort(); + + let mut out = Vec::new(); + for path in &files { + let Ok(text) = fs::read_to_string(path) else { + continue; + }; + let rel = path + .strip_prefix(&src) + .unwrap_or(path) + .to_string_lossy() + .replace('\\', "/"); + scan_source(&rel, &text, &mut out); + } + out +} + +fn rs_files(dir: &Path, out: &mut Vec) { + let Ok(entries) = fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + rs_files(&path, out); + } else if path.extension().is_some_and(|e| e == "rs") { + out.push(path); + } + } +} + +fn scan_source(file: &str, text: &str, out: &mut Vec) { + let lines: Vec<&str> = text.lines().collect(); + + for (i, line) in lines.iter().enumerate() { + if line.trim() != "#[tauri::command]" { + continue; + } + // The signature is the next line declaring a fn (attributes may sit between). + let Some(sig_idx) = (i + 1..lines.len()).find(|&j| lines[j].contains("fn ")) else { + continue; + }; + let sig = lines[sig_idx]; + let Some(name) = fn_name(sig) else { continue }; + + out.push(TauriCommand { + name, + is_async: sig.contains("async fn"), + dispatches: body_after(&lines, sig_idx).contains("thread::spawn"), + file: file.to_string(), + line: sig_idx + 1, + }); + } +} + +fn fn_name(sig: &str) -> Option { + let after = sig.split("fn ").nth(1)?; + let name: String = after + .chars() + .take_while(|c| c.is_alphanumeric() || *c == '_') + .collect(); + (!name.is_empty()).then_some(name) +} + +/// The function body: from the first `{` at or after the signature to its matching `}`. +/// Brace counting is naive about braces inside strings and comments, which is fine here — +/// the only question asked of the result is whether it contains `thread::spawn`. +fn body_after(lines: &[&str], sig_idx: usize) -> String { + let rest = lines[sig_idx..].join("\n"); + let Some(start) = rest.find('{') else { + return String::new(); + }; + let mut depth = 0i32; + for (offset, ch) in rest[start..].char_indices() { + match ch { + '{' => depth += 1, + '}' => { + depth -= 1; + if depth == 0 { + return rest[start..start + offset + 1].to_string(); + } + } + _ => {} + } + } + rest[start..].to_string() +} From f4d7f38578906a3926fe84ea2e04b9896d2717d4 Mon Sep 17 00:00:00 2001 From: Alex Barclay Date: Sat, 15 Aug 2026 11:19:00 -0600 Subject: [PATCH 10/24] docs(testing): risk-ranked testing plan, first run Bootstrap run of the testing-plan skill against a3edc78. Scores 131 gaps on likelihood x impact across seven test tiers, records a trust verdict per runner, and ranks what to close next. The load-bearing discovery is that five of the seven automated tiers did not run in CI: 135 vitest tests, 28 Tauri-host Rust tests, 52 session-state tests and 53 pytest tests were advisory, not gating. `cargo test --workspace` never reached the cockpit crate at all, because `cockpit/ui/src-tauri/Cargo.toml` opens with a bare `[workspace]` and the root manifest lists only crates/fleet-core and crates/fleetd. GAP-057, GAP-111 and GAP-113 are the entries for that; PR #60 closes all three, so those rows and the tier map's `in_ci` flags are already stale and will re-derive on the next run. The app-plugin runtime is a deliberate scope carve-out (section 2): targeted tests for the plugin_launch freeze and the Gate-5 teardown lifecycle were being written concurrently, so GAP-005/009/010 are recorded as context rather than as gaps to act on. The preceding commit is that work; R5 releases them back into the ranking. Six items await human ratification (section 3), including the `spine_weight` table -- which is the Impact axis of every score in the file and is currently a first-run proposal, not a ratified input. --- docs/testing/PLAN.md | 4398 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 4398 insertions(+) create mode 100644 docs/testing/PLAN.md diff --git a/docs/testing/PLAN.md b/docs/testing/PLAN.md new file mode 100644 index 0000000..875c222 --- /dev/null +++ b/docs/testing/PLAN.md @@ -0,0 +1,4398 @@ +--- +plan-format: 1 +next_id: 132 + +# RATIFICATION PENDING (proposed 2026-08-13, first run) — the Impact axis, 1-5 per module. +# Nothing here was derived from call-graph fan-in; these are judgments about blast radius. +spine_weight: + tauri_host: 5 # the desktop shell: a defect here freezes or kills the whole product + ui_shell: 5 # App.svelte is the only surface the operator touches + fleetd_driver: 5 # the autonomous execution spine; defects burn real money and real agent time + build_ci_gate: 5 # the gate itself: if it does not run, no other module's coverage is real + app_plugin_runtime: 5 # CARVE-OUT, see Trust verdicts + fleetd_server: 4 # the control plane every UI surface reads from + fleetd_store: 4 # persistence; loss or corruption is unrecoverable + fleetd_forge: 4 # container + GitHub lifecycle; orphaned resources and lost work + ui_plugin_bridge: 4 # the trust boundary for untrusted plugin code + fleet_core: 3 # small pure state machine, the best-covered thing in the repo + ui_dashboard: 3 # a read-only viewer today; a wrong card misleads, it does not destroy + dev_scripts: 3 # build/demo scripts; failure is loud and local + session_state: 2 # developer-experience plugin; failure loses a session note + py_tools: 2 # operator hooks; failure degrades a Claude session, not the product + +core_entry_points: # ratified traversal roots for reachability + - cockpit/ui/src/main.ts + - cockpit/ui/src-tauri/src/main.rs + - crates/fleetd/src/bin/serve.rs + - crates/fleetd/src/bin/run_once.rs + +tier_map: + rust-workspace: + runners: ["cargo test --workspace"] + globs: [crates/fleet-core/**, crates/fleetd/**] + trust: reports-dne + in_ci: true + parse_spec: + ok: '^test (?\S+) \.\.\. ok$' + summary: '^test result: (ok|FAILED)\. (\d+) passed; (\d+) failed; (\d+) ignored; (\d+) measured; (\d+) filtered out' + tauri-host: + runners: ["cargo test --manifest-path cockpit/ui/src-tauri/Cargo.toml"] + globs: [cockpit/ui/src-tauri/**] + trust: reports-dne + in_ci: false # standalone [workspace]; see GAP for the CI hole + parse_spec: + ok: '^test (?\S+) \.\.\. ok$' + summary: '^test result: (ok|FAILED)\. (\d+) passed; (\d+) failed; (\d+) ignored; (\d+) measured; (\d+) filtered out' + vitest: + runners: ["npm test (cwd cockpit/ui)"] + globs: [cockpit/ui/src/**] + trust: reports-dne + in_ci: false + parse_spec: + ok: '^ . (?src/\S+) \((?\d+) tests?\)' + summary: '^\s*Tests\s+(\d+) passed \((\d+)\)$' + node-embargo: + runners: ["node --test scripts/embargo-guard.test.mjs"] + globs: [scripts/**] + trust: reports-dne + in_ci: true + parse_spec: + ok: '^ok (?\d+) - (?.+)$' + summary: '^# pass (\d+)$' + node-session-state: + runners: ['node --test "plugins/session-state/test/*.test.mjs"'] + globs: [plugins/session-state/**] + trust: reports-dne + in_ci: false + parse_spec: + ok: '^ok (?\d+) - (?.+)$' + summary: '^# pass (\d+)$' + pytest: + runners: ["uv run pytest (cwd tools/budget-checkpoint)", "uv run pytest (cwd tools/cache-countdown)"] + globs: [tools/**] + trust: reports-dne + in_ci: false + parse_spec: + ok: '^\.+\s+\[\s*\d+%\]$' + summary: '^(\d+) passed(?:, (\d+) skipped)?(?:, (\d+) failed)? in [\d.]+s$' + manual: + runners: ["a watched GUI session on the target machine"] + globs: [spikes/SPIKE-RESULTS.md] + trust: unverified + in_ci: false + parse_spec: + ok: 'PASS' + summary: 'n/a - a human writes the result row by hand' +--- + +# Testing Plan — Command Center + +> Risk-ranked map of testing gaps, automated and human-QA in one ranking. Produced by the +> `testing-plan` skill. **This file is the register; it writes no tests and changes no code.** +> Entries are append-only and IDs are never reused. `decision` / `rationale` / `owner` are +> human-owned fields — a scan may propose, never overwrite. + +## 1. Run stamp + +| | | +|---|---| +| **Run** | 2026-08-13 (first run — bootstrap) | +| **Commit** | `a3edc78` on `feat/plugin-runtime` | +| **Working tree at scan time** | `M cockpit/ui/src-tauri/src/plugins/manager.rs`, `?? cockpit/ui/src-tauri/tests/` — **concurrent work by another agent**, see the carve-out in §2 | +| **Repo age** | 223 commits, first commit 2026-06-04 (70 days) | + +### Per-tier results this run + +| Tier | Runner | Runs in CI? | This run | +|---|---|---|---| +| `rust-workspace` | `cargo test --workspace` | **yes** | **not run** — the cargo build was held by a concurrent agent; stamped `(static-only)` | +| `tauri-host` | `cargo test` in `cockpit/ui/src-tauri` | **NO** | **not run** — same reason; stamped `(static-only)` | +| `vitest` | `npm test` in `cockpit/ui` | **NO** | **GREEN** — 19 files, 135 tests, 135 passed, 0 failed, 0 skipped, 0 todo (209 s) | +| `node-embargo` | `node --test scripts/embargo-guard.test.mjs` | **yes** | **GREEN** — 13 tests, 13 pass, 0 fail, 0 skipped, 0 todo | +| `node-session-state` | `node --test "plugins/session-state/test/*.test.mjs"` | **NO** | **GREEN** — 52 tests, 52 pass, 0 fail, 0 skipped, 0 todo | +| `pytest` (budget-checkpoint) | `uv run pytest` | **NO** | **GREEN** — 24 passed | +| `pytest` (cache-countdown) | `uv run pytest` | **NO** | **GREEN** — 29 passed | +| `manual` | watched GUI session | n/a | **1 of 12 rows PASS**; 1 FAIL-then-fixed-but-never-re-watched; 1 undiagnosed ANOMALY; 9 never run | + +### Coverage holes — read these before trusting any number above + +1. **Five of the seven automated tiers never run in CI.** `.github/workflows/ci.yml` has exactly + three jobs: `embargo`, `test` (`cargo test --workspace`), and `build` (`tauri build` ×3 OS). + Only `rust-workspace` and `node-embargo` gate a pull request. 135 vitest tests, 28 Tauri-host + Rust tests, 52 session-state tests and 53 pytest tests are **advisory**, not gating. +2. **`cargo test --workspace` does not reach the Tauri host crate.** `cockpit/ui/src-tauri/Cargo.toml` + declares a bare `[workspace]`, so the root workspace (`crates/fleet-core`, `crates/fleetd`) excludes + it. CI compiles that crate via `tauri build` and never executes one of its tests. +3. **CI has no Docker daemon.** `crates/fleetd/tests/{local_docker_it,preflight_it,swarm_smoke_it}.rs` + are `#[ignore]`d (`swarm_smoke_it` for git network access, the other two for Docker) and run + nowhere automatically. Everything whose only coverage is one of those files is **human-QA-only + today**; entries say so individually. +4. **No lint, format, or static-analysis gate exists.** `clippy`, `rustfmt`, `cargo fmt`, eslint and + prettier appear in zero workflow files. +5. **`release.yml` runs no tests at all** before signing and publishing. `releaseDraft: true` — a + human clicking publish — is the only thing between an untested build and users. +6. **`npm run check`** (svelte-check + tsc, 353 files) is not in CI either. +7. **`churn_90d` is lifetime churn on this repo.** The first commit is 70 days old, so the 90-day + window covers all 223 commits. Churn points therefore skew high across the board; treat them as a + relative ordering signal, not an absolute rate. +8. **No captured per-test inventory exists for the two Rust tiers this run.** Every Rust claim below + is static-analysis only. No `open → covered` transition would be licensed for those tiers. + +## 2. Trust verdicts + +| Tier · runner | Trust | Basis | +|---|---|---| +| `rust-workspace` · `cargo test --workspace` | `reports-dne` | libtest's summary carries a real `ignored` count and `#[ignore]` is a first-class primitive this repo actually uses. **But not run this session** — `(static-only)`. | +| `tauri-host` · `cargo test` | `reports-dne` | same runner grammar. **Not run this session**, and never run by CI at all. | +| `vitest` · `npm test` | `reports-dne` | vitest's summary distinguishes `passed` / `skipped` / `todo`; this run reported 0 of the latter two. Verified against a real captured run. | +| `node-embargo` · `node --test` | `reports-dne` | TAP emits `# skipped` and `# todo` separately from `# pass`. Captured: both 0. | +| `node-session-state` · `node --test` | `reports-dne` | same. Captured: both 0. | +| `pytest` · `uv run pytest` | `reports-dne` | pytest's summary line reports `skipped` separately. Captured: neither suite skipped. | +| `manual` · watched GUI | `unverified` | A human writes PASS/FAIL prose into `spikes/SPIKE-RESULTS.md` by hand. Nothing distinguishes "ran and passed" from "was not reached" except the author's discipline. | + +**`manual-baseline: partial.`** One of the twelve manual rows (Gate 5 container teardown) carries a +recorded pass at `2026-08-10 @ 725b630`. Ten have never carried a result. One carries a FAIL that was +fixed in `db74a47` and has never been re-run in a watched window. + +### Scope carve-out — the app-plugin runtime + +`cockpit/ui/src-tauri/src/plugins/**` (`manager.rs`, `state.rs`, `manifest.rs`, `discovery.rs`, +`seams.rs`, `seams_impl.rs`, `mod.rs`) was **deliberately not scanned** on this run. Targeted tests for +the `plugin_launch` main-thread-blocking defect and the `stop_all_owned` / container-teardown +lifecycle (Gate 5) were being written by another agent while this plan was produced — the working tree +showed `M .../plugins/manager.rs` and a new `cockpit/ui/src-tauri/tests/tauri_command_threading.rs`. +Those two areas are **in progress, covered separately** and are recorded here only as context, never +as gaps to act on: entries `GAP-005` (1.5 AUDIENCE activation) and `GAP-009`/`GAP-010` (Gate 5) carry +`ratification_pending` for that reason and are excluded from the ranked index's call to action. + +Neighbouring code that is *not* carved out was scanned normally — `embedding.rs`, `view_plugins.rs`, +`sidecar.rs`, `dashboard.rs`, `local_projects.rs`, `lib.rs` and the whole UI side all appear below. +Note that the new `tests/tauri_command_threading.rs` guard is a **signature-level** ratchet: it +inspects whether a `#[tauri::command]` is declared `async`, so blocking work one level down inside a +callee is invisible to it (see `GAP` entries for `WebviewPool::touch_and_evict` and `run_halyard`). + +## 3. Needs ratification + +Durable — carried across runs until a human answers. Nothing below has been applied. + +| # | Awaiting | Detail | +|---|---|---| +| R1 | `spine_weight` | The whole table in the front matter is a first-run **proposal**. It is the Impact axis of every score in this file. Confirm or amend before the next run treats it as ratified. | +| R2 | `core_entry_points` | Four roots proposed. `plugins/session-state/**` and `tools/**` are reachable from **none** of them — their real entry points are the Claude Code hook contract (`hooks.json`, the `.ps1` wrappers) and the `/save-state` skill. Ratify whether those count as additional roots. | +| R3 | Phase-3 dispatch shape | On this bootstrap run the scanners returned ~200 candidates. Risk-tiered solo refutation of every one was not affordable, so refuters were dispatched **grouped by module** over the highest-risk and live-defect-asserting claims; the remainder are written `(unverified)`. Ratify this as the standing policy for large bootstrap runs, or require a second pass. | +| R4 | Manual checklist home | `spikes/SPIKE-RESULTS.md` was seeded as the single manual-QA source. `docs/handoff/2026-06-24-human-gated-spikes-runbook.md` and `2026-06-25-spikes-handoff.md` also contain human-gated procedures but are marked SUPERSEDED. Confirm SPIKE-RESULTS is canonical, or name a real checklist file. | +| R5 | Carve-out release | `GAP-005`, `GAP-009`, `GAP-010` are parked as "covered separately". Release them back into the ranking once the concurrent app-plugin-runtime test work lands, or mark them `covered` with the covering test. | +| R6 | Tier `in_ci` key | `tier_map` here carries a non-standard `in_ci` boolean per tier. It is the single most load-bearing fact this plan discovered and there was nowhere else in the schema to put it. Ratify the key or move it. | + +## 4. Index + +Machine-owned and fully regenerated each run. Ranks are re-densified `1..N` every time; +tie-break is `(impact desc, likelihood desc, GAP id asc)`. + +### 4.1 Ranked open gaps + +| rank | id | risk | title | +|---:|---|---:|---| +| 1 | `GAP-006` | **25** (L5×I5) | Smoke 1.6: native webview stays glued to its rect on resize (manual) | +| 2 | `GAP-008` | **25** (L5×I5) | Smoke 1.8: no leak or orphaned webview when switching away and back (manual) | +| 3 | `GAP-010` | **25** (L5×I5) | Smoke 1.9b: the app process survives window close (manual) — PARKED, undiagnosed | +| 4 | `GAP-013` | **25** (L5×I5) | Overlay input-block over a LIVE view-plugin iframe is unverified (new manual row) | +| 5 | `GAP-014` | **25** (L5×I5) | Rect glue under DPI, monitor, and window-move changes (new manual row) | +| 6 | `GAP-015` | **25** (L5×I5) | Cockpit behaviour after fleetd restarts or the socket drops (new manual row) | +| 7 | `GAP-017` | **25** (L5×I5) | `agent_exec` awaits the agent with no timeout and no cancellation | +| 8 | `GAP-033` | **25** (L5×I5) | Driver-plus-real-Docker resume has never been verified by machine or human | +| 9 | `GAP-002` | **20** (L4×I5) | Smoke 1.2: Fleet ops-grid regression canary (manual) | +| 10 | `GAP-007` | **20** (L4×I5) | Smoke 1.7: native webview parks off-screen while a host overlay is open (manual) | +| 11 | `GAP-011` | **20** (L4×I5) | Smoke 1.10: Vite HMR still works under the host CSP (manual) | +| 12 | `GAP-012` | **20** (L4×I5) | Smoke Part 2: the packaged build has never been launched (manual) | +| 13 | `GAP-018` | **20** (L4×I5) | `Runner::health` is implemented twice and called from nowhere, so `Trigger::Stall` is unreachable | +| 14 | `GAP-019` | **20** (L4×I5) | Resumed T2/T3: rejecting the oracle is a silent no-op | +| 15 | `GAP-021` | **20** (L4×I5) | A successful T3 ship orphans the unit's named volume | +| 16 | `GAP-022` | **20** (L4×I5) | `poll_mergeability` fires ten `gh` calls back to back with no delay | +| 17 | `GAP-023` | **20** (L4×I5) | The whole host-side git/GitHub failure surface is unexecuted because `FakeForge` cannot fail | +| 18 | `GAP-024` | **20** (L4×I5) | Every command-validity decision is written out four times | +| 19 | `GAP-057` | **20** (L4×I5) | The Tauri host crate is a standalone workspace, so CI never runs one of its tests | +| 20 | `GAP-058` | **20** (L4×I5) | The sidecar supervisor's restart loop has no test, no attempt cap, and no deadline | +| 21 | `GAP-059` | **20** (L4×I5) | `health_gate` does not restart on timeout, contradicting its own doc, and wedges the app in Starting | +| 22 | `GAP-062` | **20** (L4×I5) | `view_plugins::respond` is the only guard between plugin URLs and `fs::read`, with 14 untested branches | +| 23 | `GAP-063` | **20** (L4×I5) | Dev/packaged plugin-root precedence is the seam every remaining smoke row stands on, untested | +| 24 | `GAP-064` | **20** (L4×I5) | `WebviewPool::touch_and_evict` is the whole "no leak on switch" guarantee and is pure arithmetic nobody tests | +| 25 | `GAP-065` | **20** (L4×I5) | The `app::` webview-label scheme is encoded in three places with a "MUST" nobody enforces | +| 26 | `GAP-066` | **20** (L4×I5) | The `ccplugin://` origin is written three ways, and the CSP form is Windows-only | +| 27 | `GAP-067` | **20** (L4×I5) | `127.0.0.1:8787` is hand-mirrored in four places and only one of them honours `CC_ADDR` | +| 28 | `GAP-068` | **20** (L4×I5) | The updater is registered against an empty pubkey and a `.example` endpoint | +| 29 | `GAP-069` | **20** (L4×I5) | `lib.rs:run`'s ExitRequested ordering is load-bearing and enforced only by statement order | +| 30 | `GAP-078` | **20** (L4×I5) | `api.ts` has no test file at all, and `openStream` wires no close or error handler | +| 31 | `GAP-081` | **20** (L4×I5) | `phaseClass` and `progress` drive every tile's colour and rail and have no direct assertion | +| 32 | `GAP-111` | **20** (L4×I5) | `npm run check` (353 files) is not in CI, and two source files are typechecked by nothing | +| 33 | `GAP-113` | **20** (L4×I5) | No lint, format, or static-analysis gate exists anywhere | +| 34 | `GAP-122` | **20** (L4×I5) | The embargo guard's `--all` mode, its skip paths, and its only write path are untested | +| 35 | `GAP-123` | **20** (L4×I5) | The git hooks and CI's inline commit-message range are shell nothing executes | +| 36 | `GAP-016` | **20** (L5×I4) | Starting a real mission with Docker stopped or the agent image absent (new manual row) | +| 37 | `GAP-043` | **20** (L5×I4) | No `busy_timeout` anywhere: a second writer loses events silently | +| 38 | `GAP-044` | **20** (L5×I4) | Every driver event does two synchronous SQLite writes inside a global mutex on a tokio worker | +| 39 | `GAP-045` | **20** (L5×I4) | `events_since` replays from 0 with no retention, pagination, or bound | +| 40 | `GAP-049` | **20** (L5×I4) | `router()` mounts nine routes with no auth, no origin check, and no CORS layer | +| 41 | `GAP-092` | **20** (L5×I4) | The hostile-plugin kill-and-revert path is covered by neither a test nor a checklist row | +| 42 | `GAP-119` | **20** (L5×I4) | Failed and halted units keep their volumes forever, and nothing has ever looked | +| 43 | `GAP-047` | **16** (L4×I4) | `env_f64` accepts a zero cap, bricking every mission with a 429 | +| 44 | `GAP-048` | **16** (L4×I4) | `stream_to_socket` drops events permanently on broadcast lag and never notices a dead peer | +| 45 | `GAP-050` | **16** (L4×I4) | `post_command` is the entire inbound control surface and no test drives it over HTTP | +| 46 | `GAP-053` | **16** (L4×I4) | `spawn_driver_for` and `rehydrate` duplicate the real-mode construction and both dispatch on `_` | +| 47 | `GAP-055` | **16** (L4×I4) | `bin/serve.rs:main` has no tests, no graceful shutdown, and panics on a held port | +| 48 | `GAP-056` | **16** (L4×I4) | `get_swarm` computes the swarm "done" verdict at read time with no test and no consumer | +| 49 | `GAP-087` | **16** (L4×I4) | `policeCommand`'s only numeric bound, `min_review_rounds`, is untested | +| 50 | `GAP-088` | **16** (L4×I4) | `loader.ts`'s traversal guard has one test case and disagrees with the Rust guard | +| 51 | `GAP-089` | **16** (L4×I4) | The shipped SDK has no lifetime story: unsubscribes untested, no `close()`, and a killed session hangs every pending promise | +| 52 | `GAP-090` | **16** (L4×I4) | The reference plugin's `esc`/`render` are structurally untestable, and `esc` guards `innerHTML` | +| 53 | `GAP-116` | **16** (L4×I4) | `FakeRunner` cannot fail, so the Docker error arms are unreachable from CI | +| 54 | `GAP-117` | **16** (L4×I4) | `trial_merge`'s Conflict half and cleanup are testable with git alone and are tested nowhere | +| 55 | `GAP-118` | **16** (L4×I4) | `local_docker`'s pure validators and exit-code mappings are untested, and two write paths swallow failure | +| 56 | `GAP-001` | **15** (L3×I5) | Smoke 1.1: switcher shows all four destinations (manual) | +| 57 | `GAP-005` | **15** (L3×I5) | Smoke 1.5: AUDIENCE app-plugin activation stays responsive (manual) — PARKED, covered separately | +| 58 | `GAP-009` | **15** (L3×I5) | Smoke 1.9a: Gate 5 container teardown on quit (manual) — PARKED, covered separately | +| 59 | `GAP-020` | **15** (L3×I5) | A `Ship` delivered to a `Halted` unit destroys it | +| 60 | `GAP-025` | **15** (L3×I5) | The red-checks feedback loop has no test and no iteration ceiling | +| 61 | `GAP-026` | **15** (L3×I5) | The wall-clock cap's driver-side use is dead code in the entire suite | +| 62 | `GAP-027` | **15** (L3×I5) | `fail_closed` bypasses the state machine and is never executed | +| 63 | `GAP-028` | **15** (L3×I5) | `ClaudePlanner::plan` spawns a real `claude` process with no timeout and no test | +| 64 | `GAP-029` | **15** (L3×I5) | The USD-cap check is copy-pasted at five call sites, three of them unexercised | +| 65 | `GAP-030` | **15** (L3×I5) | `steps::build` and `steps::review` are untested, and `review`'s prompt is half a contract | +| 66 | `GAP-032` | **15** (L3×I5) | `retry.rs:env_secs` and its three wrappers are untested | +| 67 | `GAP-060` | **15** (L3×I5) | `fleetd://status` is emitted to nobody | +| 68 | `GAP-061` | **15** (L3×I5) | The `ccplugin://` response headers are three load-bearing security invariants with no assertion | +| 69 | `GAP-070` | **15** (L3×I5) | `run_halyard` shells out with no timeout from a synchronous Tauri command | +| 70 | `GAP-071` | **15** (L3×I5) | The Audience HTTP commands have no client timeout and an unasserted error-policy asymmetry | +| 71 | `GAP-073` | **15** (L3×I5) | `App.svelte`'s app-plugin compositing effect never exercises the overlay park/restore pair | +| 72 | `GAP-074` | **15** (L3×I5) | The ResizeObserver rect-glue effect is asserted nowhere, teardown included | +| 73 | `GAP-075` | **15** (L3×I5) | No test in the repo ever mounts a view-plugin iframe from `App.svelte` | +| 74 | `GAP-076` | **15** (L3×I5) | `onKill` — the plugin-misbehaviour escape hatch — has no App-level test | +| 75 | `GAP-077` | **15** (L3×I5) | `selectApp` has no in-flight guard, so a double-click starts two docker builds | +| 76 | `GAP-079` | **15** (L3×I5) | `FleetStore.dispose` leaves the store un-restartable | +| 77 | `GAP-080` | **15** (L3×I5) | `fleet.ts:fold` matches Rust-side reason strings by exact equality, with no test on either side | +| 78 | `GAP-082` | **15** (L3×I5) | Phase-eligibility policy for the action buttons lives in three places with no assertion on any | +| 79 | `GAP-110` | **15** (L3×I5) | `npm test` — 135 tests over the whole cockpit UI — is not in CI | +| 80 | `GAP-112` | **15** (L3×I5) | Three whole test suites outside the Rust workspace are invoked by no gate | +| 81 | `GAP-114` | **15** (L3×I5) | `release.yml` signs and publishes without running a single test | +| 82 | `GAP-115` | **15** (L3×I5) | The Docker integration tests run nowhere, on any schedule | +| 83 | `GAP-121` | **15** (L3×I5) | The load-bearing sidecar-before-bundle order is written four times and CI does not reuse it | +| 84 | `GAP-093` | **15** (L5×I3) | The dashboard's local scan root is a hardcoded developer drive letter | +| 85 | `GAP-041` | **12** (L3×I4) | `Store::open` is the only constructor any real process uses and no test calls it | +| 86 | `GAP-042` | **12** (L3×I4) | `Store::init`'s migration ALTERs swallow every error, so a failed upgrade reads as an empty fleet | +| 87 | `GAP-046` | **12** (L3×I4) | `docker_ok` has no timeout and no single-flight guard, and `create_swarm` awaits it in-handler | +| 88 | `GAP-051` | **12** (L3×I4) | The `/units`, `/health` and `/units/:id` JSON shapes are a hand-mirrored contract nothing gates | +| 89 | `GAP-052` | **12** (L3×I4) | `create_mission` and `create_swarm`'s real-mode money guards are unexecuted | +| 90 | `GAP-054` | **12** (L3×I4) | `spawn_forwarder` discards store write errors, then broadcasts the event as if durable | +| 91 | `GAP-083` | **12** (L3×I4) | Capabilities are negotiated, thrown away, and never enforced | +| 92 | `GAP-084` | **12** (L3×I4) | The bridge's rate/flood buckets are never driven end to end | +| 93 | `GAP-085` | **12** (L3×I4) | The shipped `autoTick: true` default path executes in zero tests | +| 94 | `GAP-086` | **12** (L3×I4) | Hostile-input handling on the port is exercised only through pure-function tests | +| 95 | `GAP-091` | **12** (L3×I4) | The host duplicates the reference manifest inline, so the loader's tested code paths are unreachable in the app | +| 96 | `GAP-120` | **12** (L3×I4) | The fake and the real runner disagree on what a valid `UnitSpec` is | +| 97 | `GAP-039` | **12** (L4×I3) | `Provisioning` is excluded from `is_agent_active`, so nothing bounds a hung provision | +| 98 | `GAP-097` | **12** (L4×I3) | Both dashboard adapters map an unrecognised upstream state to a confident "Idle" | +| 99 | `GAP-101` | **12** (L4×I3) | `model.ts:isOffPipeline` is exported, unreferenced, untested — and `sortedCards` re-derives it inline | +| 100 | `GAP-124` | **12** (L4×I3) | `demo-restart-recovery.mjs` is the only end-to-end durability check and nothing runs it | +| 101 | `GAP-125` | **12** (L4×I3) | `index.html` loads Google Fonts against a CSP that has no `font-src` and no such origin | +| 102 | `GAP-031` | **10** (L2×I5) | `reconcile` and `reconcile_live` re-derive the same decision independently | +| 103 | `GAP-072` | **10** (L2×I5) | `local_projects`' exclusion list and depth bound are the only brakes on a whole-disk walk, and neither is tested | +| 104 | `GAP-040` | **9** (L3×I3) | `Phase::is_interruptible` is exported, uncalled, untested, and duplicated inline | +| 105 | `GAP-094` | **9** (L3×I3) | `Dashboard.svelte`'s entire live-wiring path is unexecuted while looking well tested | +| 106 | `GAP-095` | **9** (L3×I3) | Every dashboard adapter's degradation contract is unenforced at its edges | +| 107 | `GAP-096` | **9** (L3×I3) | App-scoped Halyard proposals are written into the map under a key nothing reads | +| 108 | `GAP-098` | **9** (L3×I3) | `dashboard/api.ts` has no test file and is the only place the four IPC command names appear | +| 109 | `GAP-099` | **9** (L3×I3) | The dashboard's user-facing affordances — deep links, chips, footers, empty state — are asserted nowhere | +| 110 | `GAP-100` | **9** (L3×I3) | App-plugin cards can never reach the dashboard board because the prop is never passed | +| 111 | `GAP-102` | **9** (L3×I3) | The dashboard's "source unreachable" card is hand-copied three times with divergent fields | +| 112 | `GAP-003` | **8** (L2×I4) | Smoke 1.3: REFERENCE view-plugin renders, handshakes, and cannot reach the network (manual) | +| 113 | `GAP-004` | **8** (L2×I4) | Smoke 1.4: command policy round-trip and command-ack rejection (manual) | +| 114 | `GAP-103` | **8** (L4×I2) | The session-state SessionEnd hook has never been observed firing | +| 115 | `GAP-104` | **8** (L4×I2) | The Stop hook spawns eight sequential git subprocesses against a 5-second budget | +| 116 | `GAP-106` | **8** (L4×I2) | `withLock` steals a lock from a demonstrably live holder, and `sleep` busy-spins | +| 117 | `GAP-126` | **8** (L4×I2) | The PowerShell hooks Claude Code actually executes are tested by nothing, in any repo language | +| 118 | `GAP-127` | **8** (L4×I2) | `deploy_globals.py` mutates the user's real `settings.json` and `CLAUDE.md` with no tests at all | +| 119 | `GAP-128` | **8** (L4×I2) | `context-offload` has no test infrastructure, and its update path corrupts on any Windows path | +| 120 | `GAP-129` | **8** (L4×I2) | cache-countdown's headline feature is inert, and its self-test cannot fail | +| 121 | `GAP-131` | **8** (L4×I2) | Both `install.ps1` scripts are the same 85 lines twice, with an untested `Copy-Item` nesting hazard | +| 122 | `GAP-034` | **6** (L2×I3) | `gate_met`'s anti-oscillation conjunct is unreachable dead logic | +| 123 | `GAP-035` | **6** (L2×I3) | `Event`'s wire shape is the cockpit's contract and seven of ten variants are unasserted | +| 124 | `GAP-036` | **6** (L2×I3) | The snake_case phase vocabulary is hand-duplicated across Rust, SQL and TypeScript | +| 125 | `GAP-037` | **6** (L2×I3) | `Command::to_trigger` has no production caller while the driver re-implements it twice | +| 126 | `GAP-038` | **6** (L2×I3) | `OracleTampering`'s transition arm is the trust gate and has no direct test | +| 127 | `GAP-105` | **6** (L3×I2) | `capture_end` drops a timeline record and deletes the only backup in the same breath | +| 128 | `GAP-107` | **6** (L3×I2) | Two `repoRoot` spawns per hook, and a torn `git status` renders as a real branch called `null` | +| 129 | `GAP-108` | **6** (L3×I2) | The session-state hook contract is validated only for file existence | +| 130 | `GAP-109` | **6** (L3×I2) | `capture_rich`'s four failure arms are the plugin's only user-visible errors and none is tested | +| 131 | `GAP-130` | **6** (L3×I2) | Both Python tools' console-script entry points are the untested side of the process boundary | + +### 4.2 By status + +| status | count | ids | +|---|---:|---| +| `open` | 131 | all entries | + +_Three entries are parked as **covered separately** pending the concurrent app-plugin-runtime +test work and are excluded from the call to action even though they appear in the ranking above: +`GAP-005`, `GAP-009`, `GAP-010` (see §2 and R5)._ + +## 5. Entries + +One flat list, `GAP-###` ascending, append-only. **Status is a field, not a section.** Ordering and +grouping live in the Index (§4), never here. + +### GAP-001 — Smoke 1.1: switcher shows all four destinations (manual) + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `manual-unverified` | +| **layer** | manual | +| **verified_by** | manual | +| **anchors** | `spikes/SPIKE-RESULTS.md#smoke-run-1--2026-08-10`, `1.1` | +| **governs** | `cockpit/ui/src/App.svelte`, `cockpit/ui/src/lib/Switcher.svelte` | +| **last_manual_pass** | — (never_verified) | +| **risk** | L3 × I5 = **15** | +| **observations** | manual_coverage_pts=3, churn_90d=8 (churn_pts=4), never_verified=true | +| **anchor_sites** | 1 | +| **first_seen** | 2026-08-10 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** The row asks a human to eyeball that FLEET + PROJECTS + REFERENCE + AUDIENCE all +appear. `Switcher.test.ts` tests the presentational component against hand-written props; App's +*composition* of host views + discovered view-plugins + `plugins_list` app-plugins into that prop is +asserted nowhere, and neither is `activeSwitcherId`'s nested-ternary precedence. Scored 3 rather than +5 because the leaf component is genuinely covered. + +**Concrete test.** **Automatable.** A jsdom render of `App` asserting the four +`data-testid` tabs in order with their labels, plus the aria-pressed precedence, replaces everything +this row asks a human to look at except "the segmented control looks right". + +### GAP-002 — Smoke 1.2: Fleet ops-grid regression canary (manual) + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `manual-unverified` | +| **layer** | manual | +| **verified_by** | manual | +| **anchors** | `spikes/SPIKE-RESULTS.md#smoke-run-1--2026-08-10`, `1.2` | +| **governs** | `cockpit/ui/src/App.svelte`, `cockpit/ui/src/lib/fleet.ts`, `cockpit/ui/src/lib/store.svelte.ts` | +| **last_manual_pass** | — (never_verified) | +| **risk** | L4 × I5 = **20** | +| **observations** | manual_coverage_pts=4, churn_90d=8 (churn_pts=4), never_verified=true | +| **anchor_sites** | 1 | +| **first_seen** | 2026-08-10 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** This is the "did bolting the plugin runtime on break the thing the product is +for" canary, and it is the largest unasserted surface in the UI: not one test in `src/` renders or +inspects the ops grid. `App.overlay.test.ts` seeds a unit only to pop a modal and asserts nothing +about tiles; the grid markup carries no `data-testid` at all. + +**Concrete test.** **Automatable.** Seed three units with distinct phases, assert one +tile each plus the ACTIVE/UNITS/BURN stat values, then switch away and back and assert the same tiles +return with selection intact — which also covers the automatable core of row 1.8. + +### GAP-003 — Smoke 1.3: REFERENCE view-plugin renders, handshakes, and cannot reach the network (manual) + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `manual-unverified` | +| **layer** | manual | +| **verified_by** | manual | +| **anchors** | `spikes/SPIKE-RESULTS.md#smoke-run-1--2026-08-10`, `1.3` | +| **governs** | `cockpit/ui/src/lib/bridge.ts`, `cockpit/ui/src/lib/loader.ts`, `cockpit/plugin-sdk/**`, `plugins/reference/**`, `cockpit/ui/src-tauri/src/view_plugins.rs` | +| **last_manual_pass** | — (never_verified) | +| **risk** | L2 × I4 = **8** | +| **observations** | manual_coverage_pts=2, churn_90d=1 (churn_pts=2), never_verified=true | +| **anchor_sites** | 1 | +| **first_seen** | 2026-08-10 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** Scores low *because it is the best-defended manual row*: `bridge.test.ts` drives +the `plugin-hello`→`ready`→full-snapshot handshake 100× over a real `MessageChannel` with zero drops. +What stays genuinely human is the part jsdom cannot model — that a `sandbox="allow-scripts"` iframe +yields an opaque origin, that `connect-src 'none'` really blocks network, and that the CORS/module +fetch of `sdk.js` succeeds under the real `ccplugin://` handler. + +**Concrete test.** **Partially automatable — see `GAP-075` and `GAP-086`.** The iframe-mount, +`sandbox` attribute, bridge construction and destroy-on-switch are jsdom-assertable; the opaque-origin +and CSP halves are not and must stay in this row. + +### GAP-004 — Smoke 1.4: command policy round-trip and command-ack rejection (manual) + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `manual-unverified` | +| **layer** | manual | +| **verified_by** | manual | +| **anchors** | `spikes/SPIKE-RESULTS.md#smoke-run-1--2026-08-10`, `1.4` | +| **governs** | `cockpit/ui/src/lib/bridge.ts`, `cockpit/plugin-sdk/index.js` | +| **last_manual_pass** | — (never_verified) | +| **risk** | L2 × I4 = **8** | +| **observations** | manual_coverage_pts=2, churn_90d=1 (churn_pts=2), never_verified=true | +| **anchor_sites** | 1 | +| **first_seen** | 2026-08-10 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** `policeCommand` has eight direct tests, so the happy round-trip and the +`real-requires-confirm` rejection are already pinned. The residual holes are the ones no human step +would find either: the rate/flood buckets are never driven end to end, the `sink-error` ack arms are +dead in the suite, and a version-skewed message is dropped silently with no ack. + +**Concrete test.** **Fully automatable — see `GAP-084`, `GAP-086`, `GAP-087`, `GAP-089`.** Everything +this row checks happens over a `MessageChannel`, which jsdom provides natively. This row can be +retired from the human gate once those land. + +### GAP-005 — Smoke 1.5: AUDIENCE app-plugin activation stays responsive (manual) — PARKED, covered separately + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `manual-unverified` | +| **layer** | manual | +| **verified_by** | manual | +| **anchors** | `spikes/SPIKE-RESULTS.md#smoke-run-1--2026-08-10`, `1.5` | +| **governs** | `cockpit/ui/src-tauri/src/plugins/**`, `cockpit/ui/src/App.svelte` | +| **last_manual_pass** | — (FAIL 2026-08-10 @ `725b630`; fixed in `db74a47`, never re-run) | +| **risk** | L3 × I5 = **15** | +| **observations** | manual_coverage_pts=2, churn_90d=8 (churn_pts=4), never_verified=true | +| **anchor_sites** | 1 | +| **first_seen** | 2026-08-10 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **ratification_pending** | `accepted` — in progress, covered separately (see §2 carve-out and R5) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** The row that earned the whole smoke its keep: clicking AUDIENCE froze the UI +because `plugin_launch` was a synchronous `#[tauri::command]` running `docker compose build` on the +main event-loop thread. Root-caused and fixed in `db74a47`, pinned by two tests in +`src/App.appPlugin.test.ts` — but **verified only by automated gates, never in a watched window**. +Excluded from this plan's call to action: another agent is writing targeted tests for exactly this +defect concurrently. + +**Concrete test.** Owned elsewhere. The residual human step after that work is narrow: watch the chip +walk `starting → health-probing → healthy` with the window responsive throughout. + +### GAP-006 — Smoke 1.6: native webview stays glued to its rect on resize (manual) + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `manual-unverified` | +| **layer** | manual | +| **verified_by** | manual | +| **anchors** | `spikes/SPIKE-RESULTS.md#smoke-run-1--2026-08-10`, `1.6` | +| **governs** | `cockpit/ui/src/App.svelte`, `cockpit/ui/src-tauri/src/embedding.rs` | +| **last_manual_pass** | — (never_verified) | +| **risk** | L5 × I5 = **25** | +| **observations** | manual_coverage_pts=5, churn_90d=8 (churn_pts=4), never_verified=true | +| **anchor_sites** | 1 | +| **first_seen** | 2026-08-10 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** Nothing in the suite asserts one byte of the rect-glue effect — +`App.appPlugin.test.ts` explicitly stubs `ResizeObserver` to a no-op and its own comment defers this +to "smoke checklist 1.6". `embedding.rs` has zero tests. So the highest-churn interactive surface in +the app is defended by a checklist row that has never been run. + +**Concrete test.** **Two thirds automatable — see `GAP-074`.** That an observer is attached to the +reserved rect, that the callback marshals a well-formed four-key rect through `toRect`, and that the +observer is disconnected on switch-away are all jsdom-assertable. Only the geometric truth needs a +real window — and see `GAP-014` for the DPI/multi-monitor case this row does *not* cover. + +### GAP-007 — Smoke 1.7: native webview parks off-screen while a host overlay is open (manual) + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `manual-unverified` | +| **layer** | manual | +| **verified_by** | manual | +| **anchors** | `spikes/SPIKE-RESULTS.md#smoke-run-1--2026-08-10`, `1.7` | +| **governs** | `cockpit/ui/src/App.svelte`, `cockpit/ui/src-tauri/src/embedding.rs` | +| **last_manual_pass** | — (never_verified) | +| **risk** | L4 × I5 = **20** | +| **observations** | manual_coverage_pts=4, churn_90d=8 (churn_pts=4), never_verified=true | +| **anchor_sites** | 1 | +| **first_seen** | 2026-08-10 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** `z-index` and `inert` cannot cross the native-webview boundary, so parking is the +*only* mechanism keeping an app-plugin from painting over a REAL-launch confirm dialog. The two +existing app-plugin tests cover only the healthy/not-healthy compositing gate; neither ever opens an +overlay, so the entire park/restore branch pair is dead in the suite. + +**Concrete test.** **Signal half automatable — see `GAP-073`.** Assert `plugin_hide` fires exactly +once on overlay open and `plugin_show` on close, with no re-issue on an unrelated state write. The +residual human step shrinks to confirming the pixels once. See also `GAP-013`, an input-block case +over a *view*-plugin that this row does not cover at all. + +### GAP-008 — Smoke 1.8: no leak or orphaned webview when switching away and back (manual) + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `manual-unverified` | +| **layer** | manual | +| **verified_by** | manual | +| **anchors** | `spikes/SPIKE-RESULTS.md#smoke-run-1--2026-08-10`, `1.8` | +| **governs** | `cockpit/ui/src/App.svelte`, `cockpit/ui/src/lib/bridge.ts`, `cockpit/ui/src-tauri/src/embedding.rs` | +| **last_manual_pass** | — (never_verified) | +| **risk** | L5 × I5 = **25** | +| **observations** | manual_coverage_pts=5, churn_90d=8 (churn_pts=4), never_verified=true | +| **anchor_sites** | 1 | +| **first_seen** | 2026-08-10 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** A leak is the one defect class a human watching a window *cannot* see. Every test +calls `bridge.destroy()`/`session.destroy()` as cleanup and none asserts anything about it; the +window `message` listener, the `setInterval` tick, the transferred port, the `ResizeObserver`, and +the `WebviewPool` LRU entry are all released only by teardown paths nothing checks. + +**Concrete test.** **Largely automatable — see `GAP-074`, `GAP-075`, `GAP-064`, `GAP-085`.** Construct +and destroy fifty times and assert the live window-listener count, timer count, and LRU length are +unchanged. Only "Task Manager shows no orphaned webview process" stays human. + +### GAP-009 — Smoke 1.9a: Gate 5 container teardown on quit (manual) — PARKED, covered separately + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `manual-unverified` | +| **layer** | manual | +| **verified_by** | manual | +| **anchors** | `spikes/SPIKE-RESULTS.md#smoke-run-1--2026-08-10`, `1.9a` | +| **governs** | `cockpit/ui/src-tauri/src/plugins/manager.rs`, `crates/fleetd/src/local_docker.rs` | +| **last_manual_pass** | 2026-08-10 @ `725b630` (PASS — `docker ps` empty against a verified 0-container baseline) | +| **risk** | L3 × I5 = **15** | +| **observations** | manual_coverage_pts=2, churn_90d=9 (churn_pts=4), never_verified=false | +| **anchor_sites** | 1 | +| **first_seen** | 2026-08-10 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **ratification_pending** | `accepted` — in progress, covered separately (see §2 carve-out and R5) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** The only manual row in the register carrying a real PASS. Excluded from this +plan's call to action — `stop_all_owned` / container teardown is being covered by targeted tests +concurrently. Recorded here so the ranking is honest about what is and is not already defended. + +**Concrete test.** Owned elsewhere. One gap this row does *not* close, filed separately as `GAP-119`: +it checks `docker ps` only, never `docker volume ls`, and volumes are deliberately kept on teardown. + +### GAP-010 — Smoke 1.9b: the app process survives window close (manual) — PARKED, undiagnosed + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `manual-unverified` | +| **layer** | manual | +| **verified_by** | manual | +| **anchors** | `spikes/SPIKE-RESULTS.md#smoke-run-1--2026-08-10`, `1.9b` | +| **governs** | `cockpit/ui/src-tauri/src/lib.rs`, `cockpit/ui/src-tauri/src/sidecar.rs` | +| **last_manual_pass** | — (ANOMALY 2026-08-10 @ `725b630`) | +| **risk** | L5 × I5 = **25** | +| **observations** | manual_coverage_pts=5, churn_90d=10 (churn_pts=4), never_verified=true | +| **anchor_sites** | 1 | +| **first_seen** | 2026-08-10 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **ratification_pending** | `accepted` — adjacent to the carve-out (see §2 and R5) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** Teardown demonstrably ran — the containers came down — but the `app` process +survived the window close (pid 13396, no window, 23 threads, still responding 15 s later, 41 MB). +Undiagnosed: dev-only artifact of `tauri dev` supervision, or a real shutdown defect. `lib.rs` has +zero tests and its `ExitRequested` handler's ordering (reap the sidecar *before* `stop_all_owned`, so +the supervisor cannot respawn it mid-teardown) is enforced by nothing but statement order. + +**Concrete test.** **The ordering half is automatable — see `GAP-069`.** A source-structure guard +asserting `SidecarSupervisor::shutdown` precedes `stop_all_owned`, which precedes `app_handle.exit(0)`. +Whether the process actually exits stays human until someone diagnoses the anomaly. + +### GAP-011 — Smoke 1.10: Vite HMR still works under the host CSP (manual) + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `manual-unverified` | +| **layer** | manual | +| **verified_by** | manual | +| **anchors** | `spikes/SPIKE-RESULTS.md#smoke-run-1--2026-08-10`, `1.10` | +| **governs** | `cockpit/ui/src-tauri/tauri.conf.json`, `cockpit/ui/vite.config.ts` | +| **last_manual_pass** | — (never_verified) | +| **risk** | L4 × I5 = **20** | +| **observations** | manual_coverage_pts=5, churn_90d=3 (churn_pts=3), never_verified=true | +| **anchor_sites** | 1 | +| **first_seen** | 2026-08-10 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** Nothing in the repo reads `tauri.conf.json`'s CSP and checks it against what the +app actually loads — not the 19-file vitest suite, not either workflow. Two concrete drifts are +already visible and unguarded (`GAP-066` frame-src origin form, `GAP-125` index.html loading Google +Fonts against a `style-src 'self'` policy). HMR is only the most visible symptom of that class. + +**Concrete test.** **The static half is automatable — see `GAP-066`.** Parse `tauri.conf.json` and +assert every origin any code path can request is admitted by the directive that governs it. Whether +the WebView2 HMR socket actually connects stays human. + +### GAP-012 — Smoke Part 2: the packaged build has never been launched (manual) + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `manual-unverified` | +| **layer** | manual | +| **verified_by** | manual | +| **anchors** | `spikes/SPIKE-RESULTS.md#remaining-human-gate--interactive-dev--packaged-smoke-not-run-headlessly`, `Part 2` | +| **governs** | `cockpit/ui/src-tauri/tauri.conf.json`, `.github/workflows/ci.yml`, `.github/workflows/release.yml`, `cockpit/ui/scripts/build-sidecar.mjs` | +| **last_manual_pass** | — (never_verified) | +| **risk** | L4 × I5 = **20** | +| **observations** | manual_coverage_pts=5, churn_90d=4 (churn_pts=3), never_verified=true | +| **anchor_sites** | 1 | +| **first_seen** | 2026-07-17 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** CI produces `.msi`/`.exe`/`.dmg`/`.app`/`.deb`/`.AppImage` on three OSes with +`if-no-files-found: warn` — so a bundle producing zero files does not even fail the job — and never +launches one. `release.yml` signs and publishes without launching one either. **No packaged build of +this app has ever been confirmed to start.** Every packaged-only concern is unexercised by every +automated gate: the `ccplugin://` scheme without a dev server, `externalBin` resolution, the updater. + +**Concrete test.** **Partially automatable — see `GAP-121`.** A CI step that unpacks the produced +bundle and asserts `binaries/fleetd-serve*` is inside it and answers `--version` would catch the +build-order class cheaply. Launching the GUI stays human, but it should become a per-release row that +must be PASS before a draft release is published. + +### GAP-013 — Overlay input-block over a LIVE view-plugin iframe is unverified (new manual row) + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `manual-uncovered` | +| **layer** | manual | +| **verified_by** | manual | +| **anchors** | `cockpit/ui/src/App.svelte:overlayOpen` | +| **governs** | `cockpit/ui/src/App.svelte`, `cockpit/ui/src/lib/ApprovalOverlay.svelte` | +| **last_manual_pass** | — (never_verified) | +| **risk** | L5 × I5 = **25** | +| **observations** | manual_coverage_pts=5, churn_90d=8 (churn_pts=4), never_verified=true | +| **anchor_sites** | 1 | +| **first_seen** | 2026-08-13 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** `App.svelte`'s own comment asserts that `inert` on the content subtree is +"honored by WebView2/Chromium" and is "the real input block, not the backdrop" — a claim about a +native runtime that nobody has verified. jsdom implements no `inert` semantics at all: it reports the +property as `true` while delivering every event, so `App.overlay.test.ts`'s assertion is a spelling +check on an attribute, not evidence of a block. No existing row covers it — 1.7 is about parking a +*native* webview, an entirely different mechanism. + +**Concrete test.** Not automatable in this harness. With REFERENCE live and focused, stage a REAL +launch and confirm keystrokes and clicks no longer reach the iframe, Tab cannot move focus into it, +the plugin cannot `.focus()` its way back, and Enter/Escape still hit the modal. Repeat packaged. + +### GAP-014 — Rect glue under DPI, monitor, and window-move changes (new manual row) + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `manual-uncovered` | +| **layer** | manual | +| **verified_by** | manual | +| **anchors** | `cockpit/ui/src/App.svelte:$effect#rect-glue-resizeobserver` | +| **governs** | `cockpit/ui/src/App.svelte`, `cockpit/ui/src-tauri/src/embedding.rs` | +| **last_manual_pass** | — (never_verified) | +| **risk** | L5 × I5 = **25** | +| **observations** | manual_coverage_pts=5, churn_90d=8 (churn_pts=4), never_verified=true | +| **anchor_sites** | 1 | +| **first_seen** | 2026-08-13 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** The glue is driven solely by a `ResizeObserver` on the reserved div, which fires +on element *size* changes. A window **move** produces no size change, and `plugin_show`/ +`plugin_set_rect` take `LogicalPosition` — so a scale-factor change is the most likely break. Row 1.6 +says only "resize the window" on a single display; mixed-DPI multi-monitor is the ordinary case on +the target machine and the failure is a plugin rendered halfway off the window with no error. + +**Concrete test.** Not automatable — jsdom has no layout engine and `getBoundingClientRect` returns +zeros. Drag the window between monitors with different scale factors, change display scaling while +running, move without resizing, minimise/restore, maximise/unmaximise; after each confirm the child +webview is still exactly over the reserved rect. + +### GAP-015 — Cockpit behaviour after fleetd restarts or the socket drops (new manual row) + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `manual-uncovered` | +| **layer** | manual | +| **verified_by** | manual | +| **anchors** | `cockpit/ui/src/lib/store.svelte.ts:start`, `crates/fleetd/src/server.rs:stream_to_socket` | +| **governs** | `cockpit/ui/src/lib/api.ts`, `cockpit/ui/src/lib/store.svelte.ts`, `crates/fleetd/src/server.rs`, `cockpit/ui/src-tauri/src/sidecar.rs` | +| **last_manual_pass** | — (never_verified) | +| **risk** | L5 × I5 = **25** | +| **observations** | manual_coverage_pts=5, churn_90d=26 (churn_pts=5), never_verified=true | +| **anchor_sites** | 2 | +| **first_seen** | 2026-08-13 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** Nothing — automated or manual — observes what the cockpit does after the daemon +goes away, and the sidecar supervisor restarts fleetd on every crash, so this is a normal path, not an +exotic one. `openStream` wires no `onclose`/`onerror`, `start()` latches `started = true`, and daemon +health is fetched only inside that single `reconnect()`. The result is a cockpit that looks completely +healthy and is completely dead: frozen phases, a **green** DOCKER badge from minutes ago, launch +buttons that 404. The stale-green header is the dangerous part — an affirmative false signal, not +merely a missing one. All twelve existing rows assume a live daemon for the whole run. + +**Concrete test.** Launch a DEMO unit, confirm tiles are streaming, then kill and restart the fleetd +sidecar. Record whether tiles keep updating, whether the header badge goes stale-but-green, whether +there is any user-visible indication, and whether anything recovers without an app restart. The +jsdom-testable half is filed as `GAP-078`. + +### GAP-016 — Starting a real mission with Docker stopped or the agent image absent (new manual row) + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `manual-uncovered` | +| **layer** | manual | +| **verified_by** | manual | +| **anchors** | `crates/fleetd/src/local_docker.rs:provision`, `crates/fleetd/src/server.rs:docker_ok` | +| **governs** | `crates/fleetd/src/local_docker.rs`, `crates/fleetd/src/server.rs` | +| **last_manual_pass** | — (never_verified) | +| **risk** | L5 × I4 = **20** | +| **observations** | manual_coverage_pts=5, churn_90d=26 (churn_pts=5), never_verified=true | +| **anchor_sites** | 2 | +| **first_seen** | 2026-08-13 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** The most common first-run user error has never been performed by anyone. Both +`#[ignore]`d Docker ITs assume a healthy daemon *and* a prebuilt `cc-agent:dev` image, CI has no +daemon at all, and no smoke row starts a mission. Worse, `docker_ok` has no timeout and no +single-flight guard, so a wedged Docker Desktop makes every `/health` poll spawn its own `docker +version` — a subprocess pileup under exactly the condition the probe exists to detect — while +`create_swarm` awaits it *inside* the request handler. + +**Concrete test.** With Docker stopped, and separately with the image absent, dispatch a real mission +from the cockpit and record what the operator sees and how long it takes. Much of this is +reclassifiable to CI — see `GAP-116`, `GAP-046` and `GAP-118`. + +### GAP-017 — `agent_exec` awaits the agent with no timeout and no cancellation + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `manual-uncovered` | +| **layer** | manual | +| **verified_by** | manual | +| **anchors** | `crates/fleetd/src/driver.rs:Run::agent_exec`, `crates/fleetd/src/steps.rs:check` | +| **governs** | `crates/fleetd/src/driver.rs`, `crates/fleetd/src/steps.rs`, `crates/fleetd/src/local_docker.rs` | +| **last_manual_pass** | — (never_verified) | +| **risk** | L5 × I5 = **25** | +| **observations** | manual_coverage_pts=5, churn_90d=19 (churn_pts=5), never_verified=true | +| **anchor_sites** | 2 | +| **first_seen** | 2026-08-13 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** `agent_exec` awaits `runner.exec(...)` with no timeout; `local_docker.rs:exec` is a +bare `docker exec` with no bound either. The only wall-clock guard runs at the **top of the `drive` +loop**, i.e. between phases, so it can never interrupt an exec already in flight. `steps::check` — the +project's own test command — gets no in-container `timeout` prefix at all. A hung agent or a test +suite that never returns pins the driver task forever while holding an `OwnedSemaphorePermit`, +silently consuming a fleet concurrency slot. `Runner::health` exists and would notice, but nothing +calls it (`GAP-018`). No checklist row covers a stalled exec. + +**Concrete test.** `crates/fleetd/tests/exec_watchdog_it.rs`, fakes only so it runs in CI: a +`HangingRunner` whose `exec` sleeps 86 400 s, driven under `#[tokio::test(start_paused = true)]` with +`wall_clock_secs: 60`; assert `Blocked{cap: Some("wall_clock")}`, `NeedsHuman`, and that the semaphore +returns to full `available_permits()`. + +### GAP-018 — `Runner::health` is implemented twice and called from nowhere, so `Trigger::Stall` is unreachable + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `untested-symbol` | +| **layer** | rust-workspace | +| **verified_by** | automated | +| **anchors** | `crates/fleetd/src/runner.rs:Runner::health`, `crates/fleetd/src/local_docker.rs:health`, `crates/fleetd/src/fake.rs:health` | +| **risk** | L4 × I5 = **20** | +| **observations** | coverage_pts=5 (no test and no caller), branches=0 (branch_pts=1), churn_90d=9 (churn_pts=4) | +| **anchor_sites** | 3 | +| **first_seen** | 2026-08-13 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** **Phase-3 confirmed.** An exhaustive workspace search for `.health(` returns one +hit and it is unrelated TypeScript. `Liveness::Stalled` is constructed only inside +`LocalDockerRunner::health` and consumed nowhere; `Trigger::Stall` appears only at its declaration, in +the universal-interrupt match pattern, and in one `fleet-core` unit test — the daemon never constructs +it. So container liveness detection is **built but not wired**: a container that dies, OOMs, or is +`docker kill`ed mid-run is never noticed, and the `Stall → NeedsHuman` path is dead. The dead wiring +is itself the defect; the missing test is secondary. + +**Concrete test.** Add `FakeRunner::stalled()` (the `health` field exists but has no builder, so +`Liveness::Stalled` is currently unconstructible from the fake), then a `driver.rs` test asserting a +unit whose container reports `Stalled` is routed off the agent-active phases. It will fail to observe +anything today, which is the point. + +### GAP-019 — Resumed T2/T3: rejecting the oracle is a silent no-op + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `untested-branch` | +| **layer** | rust-workspace | +| **verified_by** | automated | +| **anchors** | `crates/fleetd/src/driver.rs:Run::drive#awaiting-oracle-reject-on-resume`, `crates/fleetd/src/driver.rs:Run::drive#spec-oracle-frozen-guard` | +| **risk** | L4 × I5 = **20** | +| **observations** | coverage_pts=4, branches=5 (branch_pts=2), churn_90d=19 (churn_pts=5) | +| **anchor_sites** | 2 | +| **first_seen** | 2026-08-13 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** **Phase-3 confirmed.** `Phase::Spec` is guarded by `if self.resume && +self.spec.oracle_frozen { goto(OracleFrozen); continue; }`, and neither flag is ever cleared — `resume` +appears once outside construction, and `oracle_frozen` is only ever latched *on* (the store even has a +test asserting `None` must not un-freeze it). `server.rs:rehydrate` sets `resume: true` for every +restart-recovered unit. So a human clicking REJECT on a resumed T2/T3 unit drives +`Spec → OracleFrozen → AwaitingOracleApproval` with no oracle re-run and is asked the identical +question forever. A human-in-the-loop gate that silently does nothing, catchable only by a person +clicking the button, and no checklist row covers it. + +**Concrete test.** In `driver.rs`'s inline `mod tests`, a `RunCtx{resume: true}` T2 unit with +`oracle_frozen: true`: send `RejectOracle` and assert a **second** oracle exec runs and a second +`OracleProposed` with a different hash is emitted. Red today. + +### GAP-020 — A `Ship` delivered to a `Halted` unit destroys it + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `untested-branch` | +| **layer** | rust-workspace | +| **verified_by** | automated | +| **anchors** | `crates/fleet-core/src/transition.rs:transition#ship-from-halted`, `crates/fleetd/src/driver.rs:Run::goto#none-arm`, `crates/fleetd/src/server.rs:post_command` | +| **risk** | L3 × I5 = **15** | +| **observations** | coverage_pts=2, branches=2 (branch_pts=1), churn_90d=26 (churn_pts=5) | +| **anchor_sites** | 3 | +| **first_seen** | 2026-08-13 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** **Phase-3 confirmed, every link.** `(NeedsHuman, Ship)` is defined; +`(Halted, Ship)` falls to `_ => None`. `Run::goto`'s `None` arm does not ignore the trigger — it +emits an `Event::Error` and then unconditionally sets `Phase::Failed` with reason +`invalid transition`. The driver's `Phase::NeedsHuman | Phase::Halted` arm routes `Command::Ship` for +**both** phases with no discrimination. And it is reachable from real input: `post_command` forwards +any deserialized `Command` with zero phase validation (returning 202), and `bridge.ts` exposes `ship` +to plugins with only a shape/`hasUnit` check. Only the host's own button gates on +`phase === 'needs_human'` — precisely the guard the HTTP and plugin paths lack. A Ship that lands one +tick after a Halt permanently `Failed`s a unit that had already produced a clean trial merge. + +**Concrete test.** `driver.rs` test `ship_while_halted_must_not_destroy_the_unit`: pre-queue `Halt`, +let it park, send `Ship`, assert the unit stays `Halted` with a rejection `Event::Error` rather than +`Failed`. Write it as the desired behaviour so it fails until `goto`'s `None` arm stops force-failing +on human-supplied triggers. + +### GAP-021 — A successful T3 ship orphans the unit's named volume + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `untested-branch` | +| **layer** | rust-workspace | +| **verified_by** | automated | +| **anchors** | `crates/fleetd/src/driver.rs:Run::drive#pause-cleanup-takes-handle`, `crates/fleetd/src/driver.rs:Run::drive#done-arm-discard` | +| **risk** | L4 × I5 = **20** | +| **observations** | coverage_pts=4, branches=3 (branch_pts=2), churn_90d=19 (churn_pts=5) | +| **anchor_sites** | 2 | +| **first_seen** | 2026-08-13 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** **Phase-3 confirmed.** Entering `NeedsHuman`/`Halted` runs a pause cleanup that +does `self.handle.take()`. T3 lands at `NeedsHuman` by design, so by the time a human Ships it to +`Done` the terminal arm's `if let Some(h) = self.handle.clone()` sees `None` and **skips `discard`** — +and `discard` is the only thing that runs `docker volume rm`. `handle_id` survives but is consumed +only by the `Abandon` arm. No sweeper exists: `reap_unit` and reconcile only remove containers. So +every successful T3 ship, and any Resume→…→Done that passed through a pause, permanently orphans a +`ccvol_` volume holding a full repo clone. Gate 5 (`GAP-009`) checks `docker ps`, never +`docker volume ls`, so no human would see it either. + +**Concrete test.** `driver.rs` test `t3_human_ships_from_needs_human_to_done`: drive a `Tier::T3` spec +(no test in the crate constructs T3) to `NeedsHuman`, send `Command::Ship`, assert `Done` **and** +`runner.discards == 1`. It is 0 today while `teardowns == 1`. + +### GAP-022 — `poll_mergeability` fires ten `gh` calls back to back with no delay + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `untested-branch` | +| **layer** | rust-workspace | +| **verified_by** | automated | +| **anchors** | `crates/fleetd/src/driver.rs:Run::poll_mergeability#pending-dirty-and-error-arms`, `crates/fleetd/src/gh_forge.rs:poll_mergeable` | +| **risk** | L4 × I5 = **20** | +| **observations** | coverage_pts=4, branches=4 (branch_pts=2), churn_90d=19 (churn_pts=5) | +| **anchor_sites** | 2 | +| **first_seen** | 2026-08-13 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** `Ok(Mergeability::Pending) => continue` loops `for _ in 0..MAX_MERGEABLE_POLLS` +with **no sleep**, and `gh_forge` maps GitHub's `"UNKNOWN"` to `Pending`. GitHub returns UNKNOWN for +several seconds after every fresh PR, so on a real run the happy path plausibly *always* declares +"mergeability poll timed out" within milliseconds and routes to `NeedsHuman` — and a `gh` rate limit +is amplified tenfold. Tellingly, `preflight_it.rs` compensates with its own 15-iteration loop and a 2 s +sleep: the IT proves the daemon's loop is wrong and hides it at the same time, and it is `#[ignore]`d +so CI never sees any of it. Every driver test uses `FakeForge::default()` (`Mergeable`), so the +Pending, Dirty and Err arms are unexecuted. + +**Concrete test.** With `FakeForge { mergeable: Pending }` under `tokio::time::pause()`, assert exactly +`MAX_MERGEABLE_POLLS` polls, that **virtual elapsed time is greater than zero** (i.e. a backoff +exists), and that it ends in `Blocked` + `PrDirty`. + +### GAP-023 — The whole host-side git/GitHub failure surface is unexecuted because `FakeForge` cannot fail + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `untested-branch` | +| **layer** | rust-workspace | +| **verified_by** | automated | +| **anchors** | `crates/fleetd/src/fake.rs:FakeForge#no-failure-knobs`, `crates/fleetd/src/driver.rs:Run::drive#forge-and-export-failure-arms` | +| **risk** | L4 × I5 = **20** | +| **observations** | coverage_pts=4, branches=5 (branch_pts=2), churn_90d=19 (churn_pts=5) | +| **anchor_sites** | 2 | +| **first_seen** | 2026-08-13 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** `FakeForge` is only ever built via `::default()` (Clean + Mergeable) — grep finds +no struct-literal construction anywhere — and its three methods return `Ok(..)` unconditionally. So +five driver arms can never fire in an automated test: `export_bundle` Err, `MergeResult::Conflict`, +`trial_merge` Err, `open_pr` Err, and the poll Err arm. These are the routine real-world failures — a +base that moved, an expired `gh` token, a full temp dir, a secondary rate limit. Two contract +mismatches are visible and unasserted: `trial_merge` Err is emitted `retryable: true` but routed to a +non-retryable `Failed`, and "a PR already exists for this branch" (the normal outcome of any resume) is +treated as fatal. **This is the single cheapest reclassification in the repo** — the struct's fields +are already `pub` and already model Conflict/Dirty/Pending. + +**Concrete test.** Add `trial_merge_fails`/`open_pr_fails`/`poll_fails` builders plus struct-literal +construction in driver tests, and assert each arm's scope, retryability, terminal phase, and whether +the container was torn down versus discarded. + +### GAP-024 — Every command-validity decision is written out four times + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `duplicated-logic` | +| **layer** | rust-workspace | +| **verified_by** | automated | +| **anchors** | `crates/fleetd/src/driver.rs:Run::poll_halt`, `crates/fleetd/src/driver.rs:Run::drive#awaiting-oracle-recv`, `crates/fleetd/src/driver.rs:Run::drive#paused-recv`, `crates/fleetd/src/driver.rs:Run::agent_exec#backoff-select` | +| **risk** | L4 × I5 = **20** | +| **observations** | coverage_pts=3, branches=21 (branch_pts=5), churn_90d=19 (churn_pts=5) | +| **anchor_sites** | 4 | +| **first_seen** | 2026-08-13 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** Four hand-written copies of "which commands are valid here", each with its own +reject message. Adding a `Command` variant means editing four places and nothing enforces it. The +drift is already load-bearing: during a rate-limit backoff only `Halt` is honoured, so a user hitting +ABANDON on a throttled unit gets "not valid" and the unit keeps waiting — up to the full +`CC_RL_MAX_WAIT_SECS` (3600 s default) — holding its permit and its container. +`non_halt_command_during_backoff_errors_and_keeps_retrying` actually *pins* that behaviour with +`Resume`, and nothing flags that `Abandon` falls in the same bucket. + +**Concrete test.** `crates/fleetd/tests/command_dispatch_matrix_it.rs`, fakes only: a table over every +`Command` × every waiting state, asserting each cell is either accepted-and-transitions or +rejected-with-an-Error-and-stays-parked — never silently dropped, never a hard `Failed`. + +### GAP-025 — The red-checks feedback loop has no test and no iteration ceiling + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `untested-branch` | +| **layer** | rust-workspace | +| **verified_by** | automated | +| **anchors** | `crates/fleetd/src/driver.rs:Run::drive#checking-red-checks`, `crates/fleetd/src/driver.rs:Run::drive#has-diff-error` | +| **risk** | L3 × I5 = **15** | +| **observations** | coverage_pts=4, branches=2 (branch_pts=1), churn_90d=19 (churn_pts=5) | +| **anchor_sites** | 2 | +| **first_seen** | 2026-08-13 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** `if out.exit_code != 0 { goto(ChecksFailed) }` is the entire red-test feedback +loop and no test anywhere supplies a non-zero check exit — `demo_script` in both `server.rs` and the +mirrored `demo_mode_it.rs` always scripts passing checks. The `Checking → Building → Checking` loop has +no iteration ceiling of its own; its only bounds are the USD cap and the between-phase wall-clock +check, itself untested (`GAP-026`). An agent that can never go green burns the full budget in a loop +nothing asserts. Separately, the `has_diff` Err arm fails **open** (proceeds to `ChecksPassed`), +opening a PR for a possibly-empty branch — a real decision no test pins. + +**Concrete test.** Script oracle → build → check with `exit_code: 1` → build/check/review passing; +assert the phase sequence contains `Checking → Building`, that two `Iteration{Build}` events fired, +and that a tiny USD cap bounds the loop at `NeedsHuman`. + +### GAP-026 — The wall-clock cap's driver-side use is dead code in the entire suite + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `untested-branch` | +| **layer** | rust-workspace | +| **verified_by** | automated | +| **anchors** | `crates/fleetd/src/driver.rs:Run::drive#wall-clock-backstop`, `crates/fleetd/src/driver.rs:Run::over_wall_clock` | +| **risk** | L3 × I5 = **15** | +| **observations** | coverage_pts=4, branches=2 (branch_pts=1), churn_90d=19 (churn_pts=5) | +| **anchor_sites** | 2 | +| **first_seen** | 2026-08-13 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** Every driver test — and both `demo_mode_it.rs` scenarios, which set +`wall_clock_secs: 1800` — finishes long before `over_wall_clock()` can return true, so the +`is_agent_active() && over_wall_clock()` guard never executes. `retry.rs:wall_clock_exceeded` is +tested as a pure function, but the driver's *use* of it — the `Blocked` event the cockpit renders, the +`CapBreach` routing, and the rate-limit exemption fed by `self.rl_elapsed` — is not. This is the +daemon's only defence against an agent that loops without tripping the per-step USD check, so a +regression is a silent budget hole. + +**Concrete test.** `#[tokio::test(start_paused = true)]` with `wall_clock_secs: 5` and a `FakeRunner` +whose exec sleeps 10 virtual seconds: assert `Blocked{cap: Some("wall_clock")}`, `NeedsHuman`, and +permit release. Companion: a rate-limited run whose `elapsed - rl_elapsed` stays under the cap must +**not** trip it. + +### GAP-027 — `fail_closed` bypasses the state machine and is never executed + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `untested-symbol` | +| **layer** | rust-workspace | +| **verified_by** | automated | +| **anchors** | `crates/fleetd/src/driver.rs:Run::fail_closed` | +| **risk** | L3 × I5 = **15** | +| **observations** | coverage_pts=4, branches=0 (branch_pts=1), churn_90d=19 (churn_pts=5) | +| **anchor_sites** | 1 | +| **first_seen** | 2026-08-13 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** The only path in the driver that sets `self.phase` directly, bypassing +`fleet_core::transition` — so it can move a unit to `Failed` from a state the machine might not allow. +It fires whenever the last `cmd_tx` is dropped while a unit is parked, and +`crates/fleetd/src/bin/run_once.rs` (a ratified entry point) drops `cmd_tx` immediately. So in a live +`run_once`, **any** cap breach, oracle-tamper detection, retries-exhausted park, merge conflict, or +PR-dirty verdict converts instantly to a hard `Failed` where the design intends a recoverable human +gate. No test closes the channel while a unit is parked. + +**Concrete test.** `closed_command_channel_at_needs_human_fails_closed`: park via a cap breach with the +sender already dropped; assert the error + `Failed{reason: "control channel closed"}`, that the +container was torn down but the volume kept, and that the permit is released. + +### GAP-028 — `ClaudePlanner::plan` spawns a real `claude` process with no timeout and no test + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `untested-symbol` | +| **layer** | rust-workspace | +| **verified_by** | automated | +| **anchors** | `crates/fleetd/src/planner.rs:ClaudePlanner::plan` | +| **risk** | L3 × I5 = **15** | +| **observations** | coverage_pts=4, branches=0 (branch_pts=1), churn_90d=2 (churn_pts=2) | +| **anchor_sites** | 1 | +| **first_seen** | 2026-08-13 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** Reached from `serve.rs → run_swarm(ClaudePlanner::new())` for any non-demo swarm, +it awaits `Command::output()` with no timeout, no cancellation and no kill-on-drop. If the CLI hangs +or waits on stdin the swarm sits in status `planning` forever; `reconcile_on_startup` only rescues that +state on a *daemon restart*, so nothing recovers it while the process lives. Two further unpinned +contracts: `--max-budget-usd 1.0` is hardcoded rather than derived from the swarm's `usd_budget`, and +`parse_usage(..).unwrap_or(0.0)` means a format change silently bills planning at $0. Every test uses +`FakePlanner`, so not one line executes in CI. + +**Concrete test.** Split out a pure `parse_plan_output(stdout, lane_cap)` and table-test it against +captured `stream-json` fixtures (mixed narration, a `result` record, malformed JSON, no array, an +over-cap array). Then assert `plan()` is wrapped in a bounded `tokio::time::timeout`. + +### GAP-029 — The USD-cap check is copy-pasted at five call sites, three of them unexercised + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `duplicated-logic` | +| **layer** | rust-workspace | +| **verified_by** | automated | +| **anchors** | `crates/fleetd/src/driver.rs:Run::account`, `crates/fleetd/src/driver.rs:Run::remaining` | +| **risk** | L3 × I5 = **15** | +| **observations** | coverage_pts=2, branches=6 (branch_pts=3), churn_90d=19 (churn_pts=5) | +| **anchor_sites** | 2 | +| **first_seen** | 2026-08-13 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** `if self.account(&out) { goto(CapBreach, "usd cap") ; continue; }` is hand-copied +at `Spec`, `Building`, `Checking`, `Reviewing`, and inside `agent_exec`'s rate-limit arm. There is no +choke point, so a new billable step added without the copy spends money with no ceiling and emits no +`Metric` for the cockpit's cost chip. Enforcement is *post-hoc* (`cost_usd > usd_cap` after the spend), +so the only pre-hoc bound is the `--max-budget-usd` value `remaining()` feeds to `claude_argv` — +making the correctness of the copies the difference between a bounded and an unbounded overrun. Two of +the five copies have direct assertions; the Spec, Checking and Reviewing breach sub-paths do not. + +**Concrete test.** A table over the four billable phases scripting a cheap run to the target phase then +one exec that blows the cap; assert each lands at `NeedsHuman{reason: "usd cap"}` and that no further +exec is issued. Plus `remaining_never_goes_negative_and_is_passed_to_claude`. + +### GAP-030 — `steps::build` and `steps::review` are untested, and `review`'s prompt is half a contract + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `untested-symbol` | +| **layer** | rust-workspace | +| **verified_by** | automated | +| **anchors** | `crates/fleetd/src/steps.rs:build`, `crates/fleetd/src/steps.rs:review`, `crates/fleetd/src/driver.rs:parse_blockers` | +| **risk** | L3 × I5 = **15** | +| **observations** | coverage_pts=4, branches=0 (branch_pts=1), churn_90d=4 (churn_pts=3) | +| **anchor_sites** | 3 | +| **first_seen** | 2026-08-13 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** `steps.rs` has three tests and they only call `oracle` and `check`. `review` +matters disproportionately: its prompt asks the agent to emit `BLOCKERS=N`, and `parse_blockers` +**defaults to 0 when the marker is absent**. So any drift in the prompt wording — or a model that +stops complying — silently produces zero blockers, `gate_met` opens on the round floor, and a unit with +unresolved must-fix findings sails into an auto-opened PR. `parse_blockers` is tested against a literal +string, which is exactly the kind of test that cannot catch producer/consumer drift. + +**Concrete test.** `review_prompt_demands_the_blockers_marker`: assert the prompt contains +`BLOCKERS=N` and round-trip it through `parse_blockers` in the same assertion, so producer and consumer +are pinned together. Plus `build_prompt_carries_task_and_findings`. + +### GAP-031 — `reconcile` and `reconcile_live` re-derive the same decision independently + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `duplicated-logic` | +| **layer** | rust-workspace | +| **verified_by** | automated | +| **anchors** | `crates/fleetd/src/reconcile.rs:reconcile`, `crates/fleetd/src/reconcile.rs:reconcile_live` | +| **risk** | L2 × I5 = **10** | +| **observations** | coverage_pts=2, branches=9 (branch_pts=3), churn_90d=2 (churn_pts=2) | +| **anchor_sites** | 2 | +| **first_seen** | 2026-08-13 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** `reconcile_live` is `reconcile` plus a `live` filter; both independently re-derive +`HaltWithContainer` vs `HaltNoContainer` and both re-derive stray detection from a second pass. They +run in different lifecycles (startup vs a 30 s timer) and both drive destructive `reap_unit` calls plus +store writes that force units to `Halted`. Divergence means the timer pass could start halting healthy +in-flight work, or stop reaping genuine orphans. Both functions are individually well tested — the only +untested sub-path is their **agreement**, which is the whole risk of the duplication. + +**Concrete test.** `startup_is_steady_state_with_no_live_drivers`: assert +`reconcile(&p, &r) == reconcile_live(&p, &[], &r)` over an exhaustive enumeration on a 3-id universe. + +### GAP-032 — `retry.rs:env_secs` and its three wrappers are untested + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `untested-symbol` | +| **layer** | rust-workspace | +| **verified_by** | automated | +| **anchors** | `crates/fleetd/src/retry.rs:env_secs`, `crates/fleetd/src/retry.rs:rl_max_wait_secs` | +| **risk** | L3 × I5 = **15** | +| **observations** | coverage_pts=4, branches=0 (branch_pts=1), churn_90d=3 (churn_pts=3) | +| **anchor_sites** | 2 | +| **first_seen** | 2026-08-13 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** These govern the rate-limit retry budget, including the ~1 h envelope after which +`agent_exec` gives up and parks. `.parse().ok().unwrap_or(default)` swallows every malformed value +silently, so `CC_RL_MAX_WAIT_SECS=1h` yields 3600 by luck of the default and a typo'd variable name is +equally silent. During that envelope the unit holds its container and its concurrency permit, so this +is a real fleet-throughput knob. `Backoff::next_delay` is well tested but always with hand-passed +literals; the env plumbing that supplies them in production has no test. + +**Concrete test.** One serialized test asserting the three documented defaults (2 / 300 / 3600) with the +vars unset, that a valid value is honoured, and that a malformed value falls back to the default rather +than to 0. + +### GAP-033 — Driver-plus-real-Docker resume has never been verified by machine or human + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `manual-uncovered` | +| **layer** | manual | +| **verified_by** | manual | +| **anchors** | `crates/fleetd/src/driver.rs:run#resume-contract`, `crates/fleetd/src/local_docker.rs:provision#reused-volume-resume-path` | +| **governs** | `crates/fleetd/src/driver.rs`, `crates/fleetd/src/local_docker.rs`, `crates/fleetd/src/server.rs` | +| **last_manual_pass** | — (never_verified) | +| **risk** | L5 × I5 = **25** | +| **observations** | manual_coverage_pts=5, churn_90d=19 (churn_pts=5), never_verified=true | +| **anchor_sites** | 2 | +| **first_seen** | 2026-08-13 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** **No automated test anywhere runs `driver::run` against `LocalDockerRunner`** — +`demo_mode_it.rs` is fakes-only by design, and `preflight_it.rs`/`local_docker_it.rs` are `#[ignore]`d +and drive `Runner` methods by hand, deliberately bypassing the driver. So the crash-restart recovery +contract (reuse the persisted volume, skip the frozen oracle, re-checkout the agent branch over a +possibly dirty tree, re-arm the tamper gate from the reloaded hash) is only ever verified against +`FakeRunner`, whose `provision` returns a constant handle. The riskiest half lives in +`provision`'s `reused` branch (`rm -f .git/index.lock`, `git checkout -B`), which the fake cannot model +at all — and `reused` is inferred from an `exec` that can fail for unrelated reasons, in which case a +genuinely resumable volume is silently re-cloned over, losing all agent work. No checklist row covers +daemon-restart resume. + +**Concrete test.** CI-runnable half: a `ResumingFakeRunner` recording the ordered `Runner` call +sequence, asserting a `RunCtx{resume: true, start_phase: Halted}` run issues `provision` → *no oracle +exec* → `read_files` → build/check, and never `discard`. Real half `#[ignore]`d: provision, commit, +teardown, re-provision the same id, assert no re-clone and that a planted `.git/index.lock` is removed. + +### GAP-034 — `gate_met`'s anti-oscillation conjunct is unreachable dead logic + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `untested-branch` | +| **layer** | rust-workspace | +| **verified_by** | automated | +| **anchors** | `crates/fleet-core/src/gate.rs:gate_met#non-increasing-conjunct` | +| **risk** | L2 × I3 = **6** | +| **observations** | coverage_pts=2, branches=3 (branch_pts=2), churn_90d=1 (churn_pts=2) | +| **anchor_sites** | 1 | +| **first_seen** | 2026-08-13 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** **Phase-3 confirmed.** `unresolved_blockers` and `prev_unresolved_blockers` are +both `u32`, and the verdict already requires `unresolved_blockers == 0`, so `0 <= prev` holds for every +possible prev — `non_increasing` can never change the answer. The documented intent ("no oscillation +in this round", restated in `transition.rs`) is unenforced: a unit that oscillates 0 → 3 → 0 across +rounds auto-advances identically to one that converged. This is the gate that lets the driver fire +`ReviewFinished{gate_met: true}` and push toward MergeCheck without a human. Low risk score only +because `fleet_core` is a spine-weight-3 module; the finding is structural, not cosmetic. + +**Concrete test.** `non_increasing_is_subsumed_by_zero_blockers` documenting the unreachability, then a +decision: either delete the conjunct or move the anti-oscillation check somewhere it can bind (e.g. on +`round`-over-`round` blocker history rather than the current count). + +### GAP-035 — `Event`'s wire shape is the cockpit's contract and seven of ten variants are unasserted + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `untested-symbol` | +| **layer** | rust-workspace | +| **verified_by** | automated | +| **anchors** | `crates/fleet-core/src/event.rs:Event`, `cockpit/ui/src/lib/types.ts:FleetEvent` | +| **risk** | L2 × I3 = **6** | +| **observations** | coverage_pts=2, branches=0 (branch_pts=1), churn_90d=2 (churn_pts=2) | +| **anchor_sites** | 2 | +| **first_seen** | 2026-08-13 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** `Event` is the outbound `/stream` contract consumed across a process boundary by a +**hand-maintained** TypeScript mirror with nothing enforcing agreement. Only 3 of 10 variants have any +serialization assertion. The untested ones are the ones the operator acts on: `Blocked` (drives the +"why is this stuck" surface and the rate-limit notice), `Finding` (severity/round feed the review gate +display), `Error`, `Done`. The `skip_serializing_if` behaviour on `Finding.file` and `Blocked.cap` — +which `types.ts` encodes as optional properties — is asserted for zero of them, so a dropped attribute +compiles, passes `cargo test --workspace`, and shows up as a blank cell in a running cockpit. + +**Concrete test.** `every_event_variant_wire_shape`: serialize one instance of each variant, assert the +`type` tag and every field key including the `None`-omission and `Some`-presence cases, then round-trip +each back through `from_str`. + +### GAP-036 — The snake_case phase vocabulary is hand-duplicated across Rust, SQL and TypeScript + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `duplicated-logic` | +| **layer** | rust-workspace | +| **verified_by** | automated | +| **anchors** | `crates/fleet-core/src/phase.rs:Phase`, `crates/fleet-core/src/phase.rs:TERMINAL_PHASE_STRS`, `crates/fleetd/src/store.rs:swarm_rollup`, `cockpit/ui/src/lib/types.ts:Phase` | +| **risk** | L2 × I3 = **6** | +| **observations** | coverage_pts=2, branches=0 (branch_pts=1), churn_90d=14 (churn_pts=5) | +| **anchor_sites** | 4 | +| **first_seen** | 2026-08-13 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** Four independent copies, only the terminal subset pinned: the serde derive, the +`TERMINAL_PHASE_STRS` const (one test), raw SQL literals in `swarm_rollup` (`phase IN +('needs_human','halted')`) plus the interpolated queries in `committed_spend` and the server's +admission filters, and the TS union consumed by `store.svelte.ts`, `App.svelte` (`canShip`/`canResume`/ +`PHASE_LABEL`) and `adapters/fleet.ts`. Phase is persisted as a bare `String` column, so nothing +type-checks the SQL against the enum and nothing at all type-checks TS against Rust. Renaming or adding +a phase silently breaks the parked-unit rollup, the spend partition, and the cockpit's attention +highlighting — all across boundaries `cargo test --workspace` cannot see. Widest drift surface in the +repo. See also `GAP-081` and `GAP-082` (the UI-side half). + +**Concrete test.** Extend `terminal_strs_match_is_terminal` into +`phase_wire_strings_are_exhaustive_and_stable`: serialize all 14 variants and assert the set equals a +literal list, so adding or renaming one fails CI and forces the mirrors to be updated. + +### GAP-037 — `Command::to_trigger` has no production caller while the driver re-implements it twice + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `duplicated-logic` | +| **layer** | rust-workspace | +| **verified_by** | automated | +| **anchors** | `crates/fleet-core/src/event.rs:Command::to_trigger`, `crates/fleetd/src/driver.rs:Run::drive#awaiting-oracle-recv`, `crates/fleetd/src/driver.rs:Run::drive#paused-recv` | +| **risk** | L2 × I3 = **6** | +| **observations** | coverage_pts=2, branches=6 (branch_pts=3), churn_90d=19 (churn_pts=5) | +| **anchor_sites** | 3 | +| **first_seen** | 2026-08-13 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** Three copies of one mapping, and **only the unused copy is under test** — and only +2 of its 6 arms at that. A new command verb ships correct in `fleet-core` and wrong in the daemon; the +`_ => other` catch-alls in both driver arms mean a mismatch degrades to a silent "not valid" +`Event::Error` rather than a compile failure. Overlaps `GAP-024`, which covers the four-way duplication +of the *validity* decision; this entry is specifically the command→trigger mapping. + +**Concrete test.** `every_command_maps_to_its_trigger` covering all six arms, then replace the two +inline mappings with `cmd.to_trigger()` (or assert the inline copies agree with it for every variant). + +### GAP-038 — `OracleTampering`'s transition arm is the trust gate and has no direct test + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `untested-branch` | +| **layer** | rust-workspace | +| **verified_by** | automated | +| **anchors** | `crates/fleet-core/src/transition.rs:transition#oracle-tampering-arm` | +| **risk** | L2 × I3 = **6** | +| **observations** | coverage_pts=3, branches=2 (branch_pts=1), churn_90d=3 (churn_pts=3) | +| **anchor_sites** | 1 | +| **first_seen** | 2026-08-13 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** `OracleTampering if phase.is_agent_active() => NeedsHuman` is its own arm and the +trust gate the whole T1-autonomy story rests on; the driver fires it from three sites (unreadable +frozen files, an empty frozen set, a hash mismatch). If the guard or target ever changes, tamper +detection degrades to `None`, which `goto` turns into a hard `Failed` rather than the intended human +review — a silent downgrade of a security-shaped gate into a crash. Exercised only indirectly by two +fleetd driver tests; the guard-false side is never exercised for this trigger at all. + +**Concrete test.** `oracle_tampering_parks_agent_phases_at_needs_human` mirroring the existing +`retries_exhausted_*` test over `[Spec, Building, Checking, Reviewing]`, plus `MergeCheck` and +`AwaitingOracleApproval` asserting `None`. + +### GAP-039 — `Provisioning` is excluded from `is_agent_active`, so nothing bounds a hung provision + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `manual-uncovered` | +| **layer** | manual | +| **verified_by** | manual | +| **anchors** | `crates/fleet-core/src/phase.rs:Phase::is_agent_active` | +| **governs** | `crates/fleet-core/src/phase.rs`, `crates/fleetd/src/driver.rs`, `crates/fleetd/src/local_docker.rs` | +| **last_manual_pass** | — (never_verified) | +| **risk** | L4 × I3 = **12** | +| **observations** | manual_coverage_pts=4, churn_90d=3 (churn_pts=3), never_verified=true | +| **anchor_sites** | 1 | +| **first_seen** | 2026-08-13 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** `is_agent_active` returns true only for Spec/Building/Checking/Reviewing, and the +driver's only stall backstop gates on exactly that predicate. But `Provisioning` is where the long +blocking work happens: an unbounded `acquire_owned().await` on the concurrency semaphore, then +`local_docker.rs:provision`, which shells out to docker with no timeout. So a hung image build or a +never-freed slot leaves a unit pinned in `Provisioning` with no cap, no stall trigger, and no escape +hatch — the cockpit just shows PROVISIONING and a human is the only detector. Same failure shape as the +1.5 freeze. + +**Concrete test.** A driver-level test with a fake `provision()` that sleeps past `wall_clock_secs`, +asserting the unit leaves `Provisioning`; plus a `fleet-core` unit test pinning the exclusion as a +deliberate decision rather than an accident. + +### GAP-040 — `Phase::is_interruptible` is exported, uncalled, untested, and duplicated inline + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `untested-symbol` | +| **layer** | rust-workspace | +| **verified_by** | automated | +| **anchors** | `crates/fleet-core/src/phase.rs:Phase::is_interruptible` | +| **risk** | L3 × I3 = **9** | +| **observations** | coverage_pts=4, branches=0 (branch_pts=1), churn_90d=3 (churn_pts=3) | +| **anchor_sites** | 1 | +| **first_seen** | 2026-08-13 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** Zero callers repo-wide and zero tests, while `transition` open-codes the identical +condition twice as `!phase.is_terminal()` on the `FatalError` and `Halt` arms. The crate ships a named +concept the state machine does not use, and an edit to one will not track the other. Drift/dead-API +rather than a live defect — ranked accordingly. + +**Concrete test.** Either delete it, or add `interruptible_is_the_complement_of_terminal` over all 14 +variants and wire it into the two arms that currently open-code it. + +### GAP-041 — `Store::open` is the only constructor any real process uses and no test calls it + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `untested-symbol` | +| **layer** | rust-workspace | +| **verified_by** | automated | +| **anchors** | `crates/fleetd/src/store.rs:Store::open` | +| **risk** | L3 × I4 = **12** | +| **observations** | coverage_pts=4, branches=0 (branch_pts=1), churn_90d=14 (churn_pts=5) | +| **anchor_sites** | 1 | +| **first_seen** | 2026-08-13 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** All 13 inline store tests and every `server.rs` test call `Store::open_memory()`, +which dies with the process and cannot exercise WAL, the `-wal`/`-shm` sidecars, or reopen. +`Store::open` is what `bin/serve.rs` actually uses. The whole reason the schema carries set-once +columns is resume-after-restart, and the cockpit sidecar auto-restarts fleetd on every exit — so +restart is the normal path, not the exceptional one. A durability regression (rows not committed, the +seq allocator re-seeding to 0 and re-minting a live unit id) would be invisible to CI and would surface +only as a human noticing their fleet history vanished. + +**Concrete test.** Open a `Store` on a real file in a temp dir, write a unit + swarm + lanes + events, +drop it, reopen with `Store::open`, and assert every row and that `max_unit_seq`/`max_swarm_seq` +re-seed to the persisted maxima rather than 0. + +### GAP-042 — `Store::init`'s migration ALTERs swallow every error, so a failed upgrade reads as an empty fleet + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `untested-branch` | +| **layer** | rust-workspace | +| **verified_by** | automated | +| **anchors** | `crates/fleetd/src/store.rs:Store::init#alter-migration-on-preexisting-db` | +| **risk** | L3 × I4 = **12** | +| **observations** | coverage_pts=4, branches=1 (branch_pts=1), churn_90d=14 (churn_pts=5) | +| **anchor_sites** | 1 | +| **first_seen** | 2026-08-13 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** The migration loop is `let _ = conn.execute(stmt, [])` and the comment concedes it +relies on the ALTERs being "a no-op failure we ignore". On `open_memory` the CREATE TABLE always wins, +so the ALTER path never supplies the columns in any test — the sole migration test proves only the +fresh-db case. The fresh CREATE for `units` does **not** declare `swarm_id`, so even a new db depends +on an ALTER that cannot fail loudly. If any ALTER fails for a real reason on a user's existing +`fleet.db`, `init` still returns `Ok`, and every downstream query dies on "no such column" — which +`server.rs` converts to silence via `unwrap_or_default()`/`.ok()`. Silent total history loss on +upgrade, caught by no gate and no checklist row. + +**Concrete test.** Hand-build a temp db with an OLD `units` table plus one legacy row, then +`Store::open` it and assert the row is readable with the new columns defaulted. Add a negative case +where an ALTER genuinely fails and assert `open()` reports it rather than returning `Ok`. + +### GAP-043 — No `busy_timeout` anywhere: a second writer loses events silently + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `manual-uncovered` | +| **layer** | manual | +| **verified_by** | manual | +| **anchors** | `crates/fleetd/src/store.rs:Store::init#pragma-set` | +| **governs** | `crates/fleetd/src/store.rs`, `crates/fleetd/src/bin/serve.rs`, `cockpit/ui/src-tauri/src/sidecar.rs` | +| **last_manual_pass** | — (never_verified) | +| **risk** | L5 × I4 = **20** | +| **observations** | manual_coverage_pts=5, churn_90d=14 (churn_pts=5), never_verified=true | +| **anchor_sites** | 1 | +| **first_seen** | 2026-08-13 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** `init` sets `journal_mode=WAL` and `synchronous=NORMAL` but no `busy_timeout` — +grep finds no `busy_timeout`/`busy_handler` call in the whole repo — so a second writer gets +`SQLITE_BUSY` immediately with no retry. Two writers are reachable in normal use: the cockpit +supervisor respawns `fleetd-serve` the instant the old one exits, with no wait for the WAL lock; and a +developer running `cargo run --bin serve` alongside the packaged app opens the same default +`./fleet.db`. Every store write on the event hot path is `let _ = ...`, so a BUSY loses the event +silently; and if `Store::open` itself fails, `serve.rs` panics and the supervisor restart-loops. No +db/persistence/restart row exists in the manual checklist either. + +**Concrete test.** Hold a write transaction from a second connection, then drive a unit through the +serve binary against the same `CC_DB`: assert writes either block-and-succeed or surface a visible +error, never silently drop. Pair with a supervisor check that a `Store::open` failure does not produce +an unbounded respawn loop. + +### GAP-044 — Every driver event does two synchronous SQLite writes inside a global mutex on a tokio worker + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `manual-uncovered` | +| **layer** | manual | +| **verified_by** | manual | +| **anchors** | `crates/fleetd/src/server.rs:spawn_forwarder`, `crates/fleetd/src/store.rs:Store::append_event`, `crates/fleetd/src/store.rs:Store::update_unit` | +| **governs** | `crates/fleetd/src/server.rs`, `crates/fleetd/src/store.rs` | +| **last_manual_pass** | — (never_verified) | +| **risk** | L5 × I4 = **20** | +| **observations** | manual_coverage_pts=5, churn_90d=26 (churn_pts=5), never_verified=true | +| **anchor_sites** | 3 | +| **first_seen** | 2026-08-13 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** Two blocking `rusqlite` writes per event, inside a `std::sync::Mutex` critical +section, on a tokio worker thread — against a WAL file on disk in production but only against +`:memory:` in every existing test, so the tests **structurally cannot observe the real IO cost**. This +is the exact defect class the repo has already been burned by: the 1.5 FAIL was blocking work on the +wrong thread, passed every automated gate, and was caught only by a human watching a frozen window. +Here the cost scales with event volume and fleet size (one global lock shared by all drivers and every +HTTP handler) and the failure is a laggy cockpit rather than a red test. `evt_rx` is also an unbounded +mpsc, so a chatty driver grows memory with no backpressure. + +**Concrete test.** Drive a few thousand events through `spawn_forwarder` against a **file-backed** +store and assert per-event forwarder latency stays under a budget, that a concurrent `/health` is +served within a budget while the burst is in flight, and that no events are dropped. + +### GAP-045 — `events_since` replays from 0 with no retention, pagination, or bound + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `manual-uncovered` | +| **layer** | manual | +| **verified_by** | manual | +| **anchors** | `crates/fleetd/src/store.rs:Store::events_since`, `crates/fleetd/src/server.rs:get_unit` | +| **governs** | `crates/fleetd/src/store.rs`, `crates/fleetd/src/server.rs`, `cockpit/ui/src/lib/store.svelte.ts` | +| **last_manual_pass** | — (never_verified) | +| **risk** | L5 × I4 = **20** | +| **observations** | manual_coverage_pts=5, churn_90d=26 (churn_pts=5), never_verified=true | +| **anchor_sites** | 2 | +| **first_seen** | 2026-08-13 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** No `DELETE`, no `VACUUM`, no retention policy and no `LIMIT` anywhere in +`crates/fleetd/src` — units, swarms, lanes and events grow forever in the long-lived `./fleet.db` the +sidecar keeps reopening. `events_since` collects the entire matching set into a `Vec` in +memory, and both the snapshot and WS-attach replay paths call it with `since = 0`, on the same global +store mutex. `FleetStore.ensureStream` also always passes `sinceSeq = 0`, so a cockpit reload makes the +daemon re-serialize every unit's entire log — a startup thundering herd. Unbounded growth plus full +replay is a slow-motion UI hang no unit test will ever see (existing tests replay one or two events). + +**Concrete test.** Append 100 k events for one unit and assert the `/events` and WS-attach replay paths +stay under latency and memory budgets; assert some retention or pagination bound exists; assert +repeated daemon restarts do not monotonically grow the db. + +### GAP-046 — `docker_ok` has no timeout and no single-flight guard, and `create_swarm` awaits it in-handler + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `untested-symbol` | +| **layer** | rust-workspace | +| **verified_by** | automated | +| **anchors** | `crates/fleetd/src/server.rs:docker_ok`, `crates/fleetd/src/server.rs:create_swarm#docker-preflight` | +| **risk** | L3 × I4 = **12** | +| **observations** | coverage_pts=4, branches=1 (branch_pts=1), churn_90d=26 (churn_pts=5) | +| **anchor_sites** | 2 | +| **first_seen** | 2026-08-13 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** `Command::new("docker").args(["version",...]).output().await` with no timeout, and +the 5 s TTL cache is written only *after* the await returns — so a Docker Desktop that is starting or +wedged makes every unserved `/health` poll spawn its own docker process. The Tauri supervisor polls +every 300 ms for 20 s and `App.svelte` polls on an interval, so this is a subprocess pileup under +exactly the condition the probe exists to detect. Worse, `create_swarm` awaits `docker_ok` **inside** +the request handler for `mode: "real"`, so `POST /swarms` hangs unboundedly with the human staring at a +spinner — the same "slow work on the interactive path" shape as the 1.5 freeze. CI has no Docker, so +the false branch is all a CI machine could ever see; the true and hung branches are human-QA-only. + +**Concrete test.** Behind a probe seam: assert the 5 s TTL is honoured (two calls inside the window +spawn one subprocess), that an injected 60 s probe returns `false` within a stated deadline, and that +`/health` answers within that deadline while the probe is stuck. + +### GAP-047 — `env_f64` accepts a zero cap, bricking every mission with a 429 + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `untested-symbol` | +| **layer** | rust-workspace | +| **verified_by** | automated | +| **anchors** | `crates/fleetd/src/server.rs:env_f64`, `crates/fleetd/src/server.rs:env_usize` | +| **risk** | L4 × I4 = **16** | +| **observations** | coverage_pts=5, branches=0 (branch_pts=1), churn_90d=26 (churn_pts=5) | +| **anchor_sites** | 2 | +| **first_seen** | 2026-08-13 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** **Phase-3 confirmed.** `env_usize` guards with `.filter(|&n| n > 0)`; `env_f64` +does not. So `CC_GLOBAL_USD_CAP=0` (or a negative) is accepted, and the admission check +`committed_spend(...).unwrap_or(0.0) >= st.global_cap` evaluates `0.0 >= 0.0` — every `POST /missions` +**and** `POST /swarms` is refused forever on a fresh daemon with no spend. `api.ts:createMission` +surfaces that as "global daily cost cap reached", so the human sees a cap error on an empty fleet with +no way to tell it is a config typo. The asymmetry between the two helpers is the tell that the missing +filter is an oversight. `.env.example` and `docs/quickstart.md` document the knob as user-settable. +(Refuter notes `NaN` produces the opposite failure — the cap never binds.) + +**Concrete test.** Assert `env_f64` rejects `"0"`, `"-5"` and `NaN` the way `env_usize` rejects `"0"`, +plus a companion asserting an `AppState` built with `CC_GLOBAL_USD_CAP=0` does not turn +`create_mission` into a permanent 429. + +### GAP-048 — `stream_to_socket` drops events permanently on broadcast lag and never notices a dead peer + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `untested-branch` | +| **layer** | rust-workspace | +| **verified_by** | automated | +| **anchors** | `crates/fleetd/src/server.rs:stream_to_socket#lagged-recv-arm`, `crates/fleetd/src/server.rs:stream_to_socket#no-recv-loop` | +| **risk** | L4 × I4 = **16** | +| **observations** | coverage_pts=4, branches=11 (branch_pts=4), churn_90d=26 (churn_pts=5) | +| **anchor_sites** | 2 | +| **first_seen** | 2026-08-13 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** `broadcast::channel(1024)` plus `Err(Lagged(_)) => continue` is silent, permanent +event loss on the seam the cockpit renders from — and the UI dedups on `seq` but never re-fetches, so a +burst of log lines leaves a tile stuck on a stale phase with no error anywhere. Separately the function +is write-only: it never calls `socket.recv()`, so it never processes Close frames and never learns the +peer is gone while the unit is live-but-quiet — the task and its receiver leak for the daemon's +lifetime (nothing ever removes a `UnitHandle`). The one existing WS test connects *after* the unit is +terminal, so it exercises only the replay half. + +**Concrete test.** Flood a unit's sender past 1024 envelopes faster than the socket drains and assert +the client's received `seq` set has **no gap** — i.e. after a lag the server backfills from +`events_since(last_seq)` instead of `continue`-ing. Plus a peer-disappears test asserting the task +terminates. + +### GAP-049 — `router()` mounts nine routes with no auth, no origin check, and no CORS layer + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `manual-uncovered` | +| **layer** | manual | +| **verified_by** | manual | +| **anchors** | `crates/fleetd/src/server.rs:router` | +| **governs** | `crates/fleetd/src/server.rs`, `crates/fleetd/src/bin/serve.rs`, `cockpit/ui/src-tauri/tauri.conf.json` | +| **last_manual_pass** | — (never_verified) | +| **risk** | L5 × I4 = **20** | +| **observations** | manual_coverage_pts=5, churn_90d=26 (churn_pts=5), never_verified=true | +| **anchor_sites** | 1 | +| **first_seen** | 2026-08-13 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** No auth layer, no origin check, no CORS layer — `tower-http` is not even a +dependency. Two unverified consequences pull in opposite directions and **neither is gated**. +*Security:* `POST /missions` and `POST /swarms` spend real money and start containers; any local +process can drive them, and `CC_ADDR` accepts any bind address with no loopback assertion, so one env +var exposes the fleet to the LAN. *Function:* the Tauri webview origin is `tauri://localhost`, not +`127.0.0.1:8787`; `tauri.conf.json` grants `connect-src` (CSP), but **CSP is not CORS**, and `api.ts` +uses plain `fetch`. Whether the read succeeds depends entirely on WebView2/WKWebView behaviour for the +custom scheme — unverified. Every vitest suite stubs `fetch`, so no test at any layer crosses the real +origin boundary. The only evidence this works is a human having watched a window. + +**Concrete test.** A `crates/fleetd/tests/http_contract_it.rs` case that issues requests carrying +`Origin: http://tauri.localhost` and `Origin: https://evil.example` and asserts the response's +`Access-Control-Allow-Origin` against a **decided** policy, plus a preflight `OPTIONS`. Whichever policy +is chosen, the test freezes it. Add the corresponding human row. + +### GAP-050 — `post_command` is the entire inbound control surface and no test drives it over HTTP + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `untested-symbol` | +| **layer** | rust-workspace | +| **verified_by** | automated | +| **anchors** | `crates/fleetd/src/server.rs:post_command` | +| **risk** | L4 × I4 = **16** | +| **observations** | coverage_pts=4, branches=5 (branch_pts=2), churn_90d=26 (churn_pts=5) | +| **anchor_sites** | 1 | +| **first_seen** | 2026-08-13 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** Halt/Resume/Abandon/Ship/ApproveOracle all arrive here, and every existing test +pokes `h.cmd_tx` directly — bypassing the handler, the `Json` deserializer, and the +rehydrate-then-dispatch ordering. All three status outcomes (202/404/410) are unasserted, and axum's +422 on a deserialize failure never appears in the handler at all. The failure is invisible by +construction: `api.ts:sendCommand` returns the status but `FleetStore.cmd` discards it, so a +404/410/422 renders as a button click that does nothing. This handler also carries the `rehydrate` side +effect, so an untested path can **start a Docker container as a side effect of an HTTP POST**. See +`GAP-020` for the destructive case this lack of validation enables. + +**Concrete test.** `crates/fleetd/tests/http_contract_it.rs` (demo mode, no Docker): POST to a ghost id +→ 404; to a unit whose driver exited → 410; a valid Halt → 202 **and** a `phase_changed` carrying the +same `cmd_id` on that unit's stream. + +### GAP-051 — The `/units`, `/health` and `/units/:id` JSON shapes are a hand-mirrored contract nothing gates + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `untested-symbol` | +| **layer** | rust-workspace | +| **verified_by** | automated | +| **anchors** | `crates/fleetd/src/server.rs:list_units`, `crates/fleetd/src/server.rs:health`, `crates/fleetd/src/server.rs:get_unit`, `cockpit/ui/src/lib/types.ts:Snapshot` | +| **risk** | L3 × I4 = **12** | +| **observations** | coverage_pts=4, branches=0 (branch_pts=1), churn_90d=26 (churn_pts=5) | +| **anchor_sites** | 4 | +| **first_seen** | 2026-08-13 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** These produce the only JSON the fleet view is built from, and no test anywhere +serializes them. `types.ts` is hand-maintained with no generator, so renaming `usd_cap` or dropping +`task` compiles clean, passes `cargo test --workspace`, passes `npm run check` (TS sees only its own +stale interface), passes `tauri build` — and the cockpit renders `undefined`, or throws outright in +`fromSnapshot`, which dereferences `s.tier.toUpperCase()`. **Drift is already present and +unverified:** the Rust `Snapshot` returned by `GET /units/:id` has no `tier` or `task`, while the TS +interface of the same name requires both. Discovered today only by a human seeing a blank tile. + +**Concrete test.** `crates/fleetd/tests/ui_contract_it.rs`: assert the exact top-level key set of each +payload against a literal list, failing on missing **and** extra keys, and that `phase` is one of the +14 strings in `types.ts`. Emit the same payloads as golden fixtures under +`cockpit/ui/src/lib/__fixtures__/` and type-assert them in vitest so both sides fail together. + +### GAP-052 — `create_mission` and `create_swarm`'s real-mode money guards are unexecuted + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `untested-branch` | +| **layer** | rust-workspace | +| **verified_by** | automated | +| **anchors** | `crates/fleetd/src/server.rs:create_mission#real-mode-preflight`, `crates/fleetd/src/server.rs:create_swarm#real-mode-preflight` | +| **risk** | L3 × I4 = **12** | +| **observations** | coverage_pts=2, branches=9 (branch_pts=3), churn_90d=26 (churn_pts=5) | +| **anchor_sites** | 2 | +| **first_seen** | 2026-08-13 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** All existing tests pass `mode: "demo"`. The `ANTHROPIC_API_KEY` check is the only +thing between a cockpit click and a real Docker + GitHub + paid-Anthropic run, and no test proves it +fires — nor that it fires **before** the row insert and driver spawn, an ordering the code comment +claims is load-bearing precisely so a bad request "never leaves a driverless unit in the map or a junk +row in the store". The 409 path from `spawn_driver_for` returning `AlreadyRegistered` is likewise never +executed. For swarms the blast radius multiplies by `CC_MAX_LANES`. The 503 docker path cannot run in +CI at all, but the 400-before-any-side-effect assertion needs no Docker. + +**Concrete test.** With `ANTHROPIC_API_KEY` removed, call with `mode: "real"` and assert 400 **and** +that `list_units()` is still empty and no handle was registered. Repeat for `create_swarm`, and assert +a rejected request does not burn a swarm id. + +### GAP-053 — `spawn_driver_for` and `rehydrate` duplicate the real-mode construction and both dispatch on `_` + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `duplicated-logic` | +| **layer** | rust-workspace | +| **verified_by** | automated | +| **anchors** | `crates/fleetd/src/server.rs:spawn_driver_for#real-arm`, `crates/fleetd/src/server.rs:rehydrate#real-arm`, `crates/fleetd/src/server.rs:resume_fan_out` | +| **risk** | L4 × I4 = **16** | +| **observations** | coverage_pts=4, branches=8 (branch_pts=3), churn_90d=26 (churn_pts=5) | +| **anchor_sites** | 3 | +| **first_seen** | 2026-08-13 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** The runner/forge construction is copy-pasted verbatim in two `_` arms, each +independently building the temp host-clone path, the `GhForge` title, and a **hardcoded** +`cc-agent:dev` image — note `bin/serve.rs` honours `CC_IMAGE` for reconciliation but these two sites do +not, so the drift already exists. Neither copy is executed by any test. The dangerous shared property: +both dispatch on `_`, not on `"real"`. `create_mission` validates the mode string, but `rehydrate` +(`row.mode`) and `resume_fan_out` (`sw.mode`) feed **persisted** values straight through — so any +unexpected mode value in SQLite becomes a real, billable Docker run at daemon startup. + +**Concrete test.** Extract one `real_driver(spec, unit_id)` and assert mode dispatch is a whitelist, not +a fallthrough: persist a unit row with `mode: ""` or `"legacy"`, call `rehydrate`, and assert no driver +is spawned rather than a silent promotion to a real run. + +### GAP-054 — `spawn_forwarder` discards store write errors, then broadcasts the event as if durable + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `untested-branch` | +| **layer** | rust-workspace | +| **verified_by** | automated | +| **anchors** | `crates/fleetd/src/server.rs:spawn_forwarder#ignored-store-write-error` | +| **risk** | L3 × I4 = **12** | +| **observations** | coverage_pts=2, branches=6 (branch_pts=3), churn_90d=26 (churn_pts=5) | +| **anchor_sites** | 1 | +| **first_seen** | 2026-08-13 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** `let _ = s.append_event(...)` and `let _ = s.update_unit(...)`, then +`bcast.send(env)` regardless. The result is a live UI that has folded events which do not exist in the +store: refresh the cockpit or restart the daemon and they vanish, `last_seq` regresses, and the WS +replay disagrees with what the human just watched — with no log line, no error event, and no health +signal. The existing test covers only the happy-path projection fold, so the enclosing symbol looks +covered while the durability guarantee it exists to provide is unasserted. + +**Concrete test.** Drive `spawn_forwarder` with a store whose `append_event` fails and assert the +failure is **observable** — the event is not broadcast as durable, or an `Event::Error{scope: System}` +is emitted. + +### GAP-055 — `bin/serve.rs:main` has no tests, no graceful shutdown, and panics on a held port + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `untested-symbol` | +| **layer** | rust-workspace | +| **verified_by** | automated | +| **anchors** | `crates/fleetd/src/bin/serve.rs:main` | +| **risk** | L4 × I4 = **16** | +| **observations** | coverage_pts=5, branches=1 (branch_pts=1), churn_90d=5 (churn_pts=3) | +| **anchor_sites** | 1 | +| **first_seen** | 2026-08-13 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** No inline tests, no integration file, and `cargo test --workspace` never links its +`main`. Two behaviours bite in the packaged product. **Port-in-use:** `TcpListener::bind(...).unwrap_or_else(|e| panic!(...))` +aborts, and `sidecar.rs:supervise` respawns every 2 s forever — while `health_gate` polls +`127.0.0.1:8787/health`, which the *other* already-running fleetd cheerfully answers, so the supervisor +emits `Ready` and the cockpit believes it is talking to its own sidecar while that sidecar crash-loops +against a different `CC_DB`. **Shutdown:** there is no `with_graceful_shutdown` and no signal handler +at all; the process is torn down by `CommandChild::kill()`, severing in-flight WS clients mid-write — +and Gate 5 already recorded an undiagnosed "process did not exit" anomaly (`GAP-010`). Also +unasserted: `reconcile_on_startup` runs against a real `LocalDockerRunner` **before** the bind, so a +hung `docker ps` delays listening past the supervisor's 20 s health gate. + +**Concrete test.** `crates/fleetd/tests/serve_bootstrap_it.rs`: hold the port, start `serve`, assert a +non-zero exit with a diagnosable message rather than a bare panic; and assert a terminate signal +mid-mission leaves a coherent `last_seq` when a second process reads the same db. + +### GAP-056 — `get_swarm` computes the swarm "done" verdict at read time with no test and no consumer + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `untested-symbol` | +| **layer** | rust-workspace | +| **verified_by** | automated | +| **anchors** | `crates/fleetd/src/server.rs:get_swarm` | +| **risk** | L4 × I4 = **16** | +| **observations** | coverage_pts=4, branches=6 (branch_pts=3), churn_90d=26 (churn_pts=5) | +| **anchor_sites** | 1 | +| **first_seen** | 2026-08-13 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** The only place the swarm "done" verdict exists, computed rather than persisted, as +a three-way conjunction with zero direct coverage — the `run_swarm` tests assert the *stored* status and +call `swarm_rollup` directly, never this handler. A wrong verdict is how a human ends up believing a +swarm finished while lanes are still burning budget, or the reverse. `spent_so_far` — the number a +human would use to decide whether to keep going — is likewise computed only here, by iterating +`get_unit` per lane inside the held store lock, and asserted nowhere. And `grep -rn 'swarm' +cockpit/ui/src` returns nothing: the entire `/swarms` surface is a fully-implemented, +contract-unpinned API with no client to notice drift. + +**Concrete test.** Build a swarm with two child units; assert `running` while one child is +non-terminal, `done` only when both are terminal, never `done` when `total == 0`, and +`spent_so_far == planner_cost + sum(child.cost)`. Pin the exact `SwarmDetail`/`LaneView` key sets. + +### GAP-057 — The Tauri host crate is a standalone workspace, so CI never runs one of its tests + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `untested-symbol` | +| **layer** | build_ci_gate | +| **verified_by** | automated | +| **anchors** | `cockpit/ui/src-tauri/Cargo.toml:[workspace]`, `Cargo.toml:workspace.members`, `.github/workflows/ci.yml:jobs.test` | +| **risk** | L4 × I5 = **20** | +| **observations** | coverage_pts=5, branches=0 (branch_pts=1), churn_90d=10 (churn_pts=4) | +| **anchor_sites** | 3 | +| **first_seen** | 2026-08-13 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** Verified from source: line 2 of `cockpit/ui/src-tauri/Cargo.toml` is a bare +`[workspace]` with the comment "Standalone workspace so the parent Cargo workspace doesn't claim this +crate", and the root members are only `crates/fleet-core` and `crates/fleetd`. So `cargo test +--workspace` in CI's `test` job never reaches the crate that contains the sidecar supervisor, the +`ccplugin://` handler, the webview embedding layer, the dashboard commands and the app-plugin runtime. +CI's only contact with it is compilation inside `tauri build`. **This entry is the multiplier on every +other `tauri_host` and `app_plugin_runtime` finding in this plan** — including the tests the concurrent +carve-out work is writing right now, which will not gate a PR either. + +**Concrete test.** Add `cargo test --manifest-path cockpit/ui/src-tauri/Cargo.toml` to the `test` job. +Pair it with a repo-root guard that walks every `Cargo.toml`, collects those declaring their own +`[workspace]`, and asserts each is named in an allowlist CI is known to invoke — so the next standalone +crate cannot silently drop out. + +### GAP-058 — The sidecar supervisor's restart loop has no test, no attempt cap, and no deadline + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `manual-uncovered` | +| **layer** | manual | +| **verified_by** | manual | +| **anchors** | `cockpit/ui/src-tauri/src/sidecar.rs:supervise` | +| **governs** | `cockpit/ui/src-tauri/src/sidecar.rs` | +| **last_manual_pass** | — (never_verified) | +| **risk** | L4 × I5 = **20** | +| **observations** | manual_coverage_pts=5, churn_90d=1 (churn_pts=2), never_verified=true | +| **anchor_sites** | 1 | +| **first_seen** | 2026-08-13 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** `sidecar.rs` contains no `#[cfg(test)]` module at all, and no smoke row exercises a +mid-session sidecar crash — the twelve rows cover teardown at quit and nothing else. The restart loop +has no attempt cap and no cumulative deadline: on a hard-crashing or unspawnable binary it cycles +`emit_status(Down)` → 2 s backoff → respawn forever, and the two `Err` arms (sidecar resolve failure, +spawn failure) each `continue` into that same uncapped loop. Combined with `GAP-055`, a port already +held by a stale fleetd produces a permanent crash-loop the operator cannot see. + +**Concrete test.** Extract the decision as a pure `should_restart(shutting_down, exit_code, attempt) -> +Restart` and unit-test crash→restart, shutdown→stop, and attempt-cap behaviour with no real process. +Manual row: kill `fleetd-serve` from outside the app and confirm exactly one listener on 8787 afterwards. + +### GAP-059 — `health_gate` does not restart on timeout, contradicting its own doc, and wedges the app in Starting + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `untested-branch` | +| **layer** | tauri-host | +| **verified_by** | automated | +| **anchors** | `cockpit/ui/src-tauri/src/sidecar.rs:health_gate`, `cockpit/ui/src-tauri/src/sidecar.rs:pump_events#gate-spawn` | +| **risk** | L4 × I5 = **20** | +| **observations** | coverage_pts=5, branches=5 (branch_pts=2), churn_90d=1 (churn_pts=2) | +| **anchor_sites** | 2 | +| **first_seen** | 2026-08-13 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** **Phase-3 confirmed.** `HEALTH_GATE_TIMEOUT`'s doc says the gate waits "before +giving up on this attempt and restarting". The code does not restart: the gate runs on a detached +`spawn` whose failure arm is a lone `log::warn!` and whose result is never joined. `supervise` advances +only when the child's event stream closes, so a fleetd that starts but never binds — port 8787 already +held by a stale sidecar, a real scenario — leaves the app in `Starting` indefinitely with no `Down` +event and no restart. The success arm, the `_` fallthrough and the deadline arm are all unexercised. + +**Concrete test.** Thread the timeout, poll interval and base URL through as parameters, then run +`health_gate` against a stub that never binds: assert it returns false at the deadline rather than +hanging, and assert the supervisor reacts with a Down/restart transition — which is what the constant's +doc promises. + +### GAP-060 — `fleetd://status` is emitted to nobody + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `untested-symbol` | +| **layer** | tauri-host | +| **verified_by** | automated | +| **anchors** | `cockpit/ui/src-tauri/src/sidecar.rs:emit_status`, `cockpit/ui/src-tauri/src/sidecar.rs:STATUS_EVENT` | +| **risk** | L3 × I5 = **15** | +| **observations** | coverage_pts=5, branches=0 (branch_pts=1), churn_90d=1 (churn_pts=2) | +| **anchor_sites** | 2 | +| **first_seen** | 2026-08-13 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** **Phase-3 confirmed.** Grepping the whole `cockpit/` tree for `fleetd://` returns +exactly two hits, both inside `sidecar.rs` itself. The only `listen(` subscriber in the frontend is +`plugin://state`. So the supervisor emits Starting/Ready/Down on a channel with zero subscribers, and +the only live liveness path is `api.ts:health` polling `/health`. In fairness the module docstring +hedges ("the event is an optimisation, not a hard dependency") — but nothing would notice if the event +name, the `rename_all = "lowercase"` serialisation, or the `skip_serializing_if` on `code` changed. + +**Concrete test.** A serde round-trip on `StatusPayload`, plus a source-level contract test asserting +the `"fleetd://status"` literal appears in at least one frontend listener — which fails today and +documents the dead channel rather than leaving it to be rediscovered. + +### GAP-061 — The `ccplugin://` response headers are three load-bearing security invariants with no assertion + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `untested-symbol` | +| **layer** | tauri-host | +| **verified_by** | automated | +| **anchors** | `cockpit/ui/src-tauri/src/view_plugins.rs:built`, `cockpit/ui/src-tauri/src/view_plugins.rs:PLUGIN_CSP` | +| **risk** | L3 × I5 = **15** | +| **observations** | coverage_pts=5, branches=0 (branch_pts=1), churn_90d=1 (churn_pts=2) | +| **anchor_sites** | 2 | +| **first_seen** | 2026-08-13 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** `view_plugins.rs` has zero tests. Its module header names three findings that each +"cost a dropped handshake run to discover": the concrete-origin `script-src` (a +`sandbox="allow-scripts"` iframe has an opaque origin, so `'self'` matches nothing), the +`Access-Control-Allow-Origin: *` (opaque-origin module scripts are always fetched CORS-mode, so without +it `sdk.js` never runs and every handshake times out), and CSP as a **response header** rather than a +``. All three live in two string literals with no assertion anywhere, and the failure mode is a +silent handshake timeout only a human in a watched window sees. **Cheapest high-value test in the +module** — `built` takes no `AppHandle`, so it is a plain `#[test]`. + +**Concrete test.** Assert `built(200, "text/javascript", vec![])` and `not_found()` both carry +`Content-Security-Policy == PLUGIN_CSP`, that it contains `script-src 'self' http://ccplugin.localhost` +(not bare `'self'`) and `connect-src 'none'`, and that ACAO is `*` on **both** the 200 and 404 paths. + +### GAP-062 — `view_plugins::respond` is the only guard between plugin URLs and `fs::read`, with 14 untested branches + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `untested-branch` | +| **layer** | tauri-host | +| **verified_by** | automated | +| **anchors** | `cockpit/ui/src-tauri/src/view_plugins.rs:respond#path-traversal-and-id-validation` | +| **risk** | L4 × I5 = **20** | +| **observations** | coverage_pts=5, branches=14 (branch_pts=4), churn_90d=1 (churn_pts=2) | +| **anchor_sites** | 1 | +| **first_seen** | 2026-08-13 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** Fourteen branches of hand-rolled string validation guard `root.join(id).join(sub)` +before a filesystem read, and none is tested. Whether `request.uri().path()` arrives percent-decoded +(making `%2e%2e` a bypass) is **unverified** — precisely why a test should pin it. A Windows `id` such +as a drive prefix or a device name also reaches `Path::join` unchecked. And this Rust guard **disagrees +with the JavaScript one** (`GAP-088`): the JS side rejects any `..` substring while Rust rejects only +`..` *components*, and the JS side accepts a bare backslash the Rust side 404s. A manifest is +attacker-supplied in the `~/.command-center/plugins` drop-in case. + +**Concrete test.** Extract `resolve(path) -> Option<(String, String)>` and table-drive it over `/`, +`/../etc/passwd`, `/id/../../secret`, `/id/a//b`, `/id/.`, `/id/a\b`, and percent-encoded `%2e%2e`; +assert `/id` defaults to `index.html` and `/id/sdk.js` takes the SDK branch. + +### GAP-063 — Dev/packaged plugin-root precedence is the seam every remaining smoke row stands on, untested + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `untested-branch` | +| **layer** | tauri-host | +| **verified_by** | automated | +| **anchors** | `cockpit/ui/src-tauri/src/view_plugins.rs:plugin_roots#dev-env-precedence`, `cockpit/ui/src-tauri/src/view_plugins.rs:sdk_bytes` | +| **risk** | L4 × I5 = **20** | +| **observations** | coverage_pts=5, branches=3 (branch_pts=2), churn_90d=1 (churn_pts=2) | +| **anchor_sites** | 2 | +| **first_seen** | 2026-08-13 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** `SPIKE-RESULTS.md` step 1 instructs the operator to set `CC_VIEW_PLUGINS_DEV` and +`CC_PLUGIN_SDK` before running the smoke, and rows 1.3, 1.4 and 1.10 are **unmeasurable** if resolution +silently falls through to the wrong root. First-hit-wins ordering across three optional sources is +exactly the logic that rots when a fourth root is added, and a wrong-root hit fails as "the plugin +rendered stale content", not as an error. Zero tests; the `USERPROFILE`/`HOME` fallback in particular is +only ever exercised on one OS at a time. + +**Concrete test.** Refactor to `roots_from(dev, resource, home) -> Vec` and assert the exact +ordering dev → `/plugins` → `/.command-center/plugins`, that an absent env var drops +only its own entry, and that `USERPROFILE` wins over `HOME`. Same shape for `sdk_bytes`. + +### GAP-064 — `WebviewPool::touch_and_evict` is the whole "no leak on switch" guarantee and is pure arithmetic nobody tests + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `untested-symbol` | +| **layer** | tauri-host | +| **verified_by** | automated | +| **anchors** | `cockpit/ui/src-tauri/src/embedding.rs:WebviewPool::touch_and_evict` | +| **risk** | L4 × I5 = **20** | +| **observations** | coverage_pts=5, branches=3 (branch_pts=2), churn_90d=1 (churn_pts=2) | +| **anchor_sites** | 1 | +| **first_seen** | 2026-08-13 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** `embedding.rs` has zero tests. This function is the only thing bounding +native-webview memory and it is list arithmetic that unit-tests trivially — yet today the only way to +learn it is wrong is Task Manager. Two specific unasserted behaviours: `plugin_hide` parks a webview +**without touching the LRU**, so a parked-but-still-MRU plugin can be destroyed by three subsequent +shows and the user's next switch-back gets a cold reload instead of the promised warm render tree; and +nothing removes a label from `lru`/`last_rect` when a plugin is stopped, so stale labels occupy +warm-cap slots and evict live webviews. +*(A related claim — that the `lru` mutex is held across a blocking main-thread `close()` — was **Phase-3 +refuted**: `close()` compiles to a non-blocking `send_event`, so the critical section is two channel +sends. Recorded so it is not re-flagged.)* + +**Concrete test.** Split bookkeeping from destruction — `touch(label, rect) -> Vec` returning +evicted labels — then assert with no Tauri runtime: A,B,C,D evicts [A]; re-showing A before D evicts +[B]; re-showing the MRU evicts nothing and does not duplicate; `last_rect` has no entry for an evicted +label; `lru.len()` never exceeds `WARM_CAP`. + +### GAP-065 — The `app::` webview-label scheme is encoded in three places with a "MUST" nobody enforces + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `duplicated-logic` | +| **layer** | tauri-host | +| **verified_by** | automated | +| **anchors** | `cockpit/ui/src-tauri/src/embedding.rs:app_label`, `cockpit/ui/src-tauri/capabilities/default.json:app-plugins`, `cockpit/ui/src-tauri/src/embedding.rs:HOST_WINDOW_LABEL` | +| **risk** | L4 × I5 = **20** | +| **observations** | coverage_pts=5, branches=0 (branch_pts=1), churn_90d=4 (churn_pts=3) | +| **anchor_sites** | 3 | +| **first_seen** | 2026-08-13 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** `format!("app::{id}")`, the `"app::*"` glob in `capabilities/default.json`, and the +module header's contract are three independent copies, and the capability comment literally says the +glob "MUST match the webview-label scheme" — a MUST with no enforcement. Renaming the prefix compiles, +bundles, and passes every gate; it surfaces only as an app-plugin webview silently losing permissions +at runtime. `HOST_WINDOW_LABEL = "main"` has the same shape: a guess about a label `tauri.conf.json` +never states, degrading to a user-visible error string when `get_window` returns `None`. +*(A related claim — that the `app-plugins` capability over-grants webview-mutating permissions to +third-party app content — was **Phase-3 refuted**: the capability omits `remote`, and the child webview +loads an external URL, so Tauri classifies it `Origin::Remote` and the Local-only grants never resolve. +The capability is inert rather than dangerous. Recorded so it is not re-flagged.)* + +**Concrete test.** `include_str!` the capability file and assert every `webviews` glob, with its +trailing `*` stripped, is a prefix of `app_label("x")`; assert `HOST_WINDOW_LABEL` appears in the +`default` capability's `windows` array and that `tauri.conf.json` declares exactly one window. + +### GAP-066 — The `ccplugin://` origin is written three ways, and the CSP form is Windows-only + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `duplicated-logic` | +| **layer** | tauri-host | +| **verified_by** | automated | +| **anchors** | `cockpit/ui/src-tauri/tauri.conf.json:app.security.csp`, `cockpit/ui/src-tauri/src/view_plugins.rs:PLUGIN_CSP`, `cockpit/ui/src/lib/loader.ts:pluginSrc` | +| **risk** | L4 × I5 = **20** | +| **observations** | coverage_pts=5, branches=0 (branch_pts=1), churn_90d=3 (churn_pts=3) | +| **anchor_sites** | 3 | +| **first_seen** | 2026-08-13 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** **Phase-3 confirmed against the vendored Tauri source.** `pluginSrc` emits +`ccplugin://localhost/${id}/${entry}` and `App.svelte` uses that literal as the iframe `src` in both +dev and packaged; the host CSP is a single static string ending `frame-src http://ccplugin.localhost` +with no platform variants and no rewrite anywhere; and Tauri's own docs state the URL form is +`http://.localhost` on **Windows/Android** but `://localhost` on **macOS/iOS/Linux**. +Tauri's runtime CSP augmentation only injects nonces into `script-src`/`style-src`, never adds custom +schemes to `frame-src`. `release.yml` builds all three OSes. So view-plugins would be CSP-blocked on +macOS and Linux, discovered by a user. CI's three-OS matrix only runs `tauri build` and never launches +the app; smoke rows 1.3/1.10 were Windows-only and are recorded "not run". + +**Concrete test.** Parse `tauri.conf.json` and assert its `frame-src` admits **every** origin form +`pluginSrc` can emit — including the raw `ccplugin://localhost` form — and that the origin token in +`PLUGIN_CSP`'s `script-src` is byte-identical to the one in `frame-src`. Mirror it in `loader.test.ts`. + +### GAP-067 — `127.0.0.1:8787` is hand-mirrored in four places and only one of them honours `CC_ADDR` + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `duplicated-logic` | +| **layer** | tauri-host | +| **verified_by** | automated | +| **anchors** | `cockpit/ui/src-tauri/src/sidecar.rs:FLEETD_ADDR`, `cockpit/ui/src-tauri/tauri.conf.json:app.security.csp`, `cockpit/ui/src/lib/api.ts:BASE`, `crates/fleetd/src/bin/serve.rs:main#cc-addr` | +| **risk** | L4 × I5 = **20** | +| **observations** | coverage_pts=5, branches=0 (branch_pts=1), churn_90d=5 (churn_pts=3) | +| **anchor_sites** | 4 | +| **first_seen** | 2026-08-13 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** The `sidecar.rs` comment says it "mirrors serve.rs" — a mirror maintained by hand. +`serve.rs` honours `CC_ADDR`; the host does not. So setting `CC_ADDR` produces a running daemon the +supervisor health-gates against forever on the old port (never `Ready`, and per `GAP-059` never +restarted) while the CSP simultaneously blocks the frontend from the new one. Silent, three-symptom, +asserted nowhere. + +**Concrete test.** Read `tauri.conf.json` and assert its `connect-src` contains both +`http://{FLEETD_ADDR}` and `ws://{FLEETD_ADDR}` built from the sidecar constant. Better: make +`FLEETD_ADDR` read `CC_ADDR` with the same default as `serve.rs` and test that override end to end. + +### GAP-068 — The updater is registered against an empty pubkey and a `.example` endpoint + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `untested-symbol` | +| **layer** | tauri-host | +| **verified_by** | automated | +| **anchors** | `cockpit/ui/src-tauri/tauri.conf.json:plugins.updater`, `cockpit/ui/src-tauri/src/lib.rs:run#updater-registration` | +| **risk** | L4 × I5 = **20** | +| **observations** | coverage_pts=5, branches=2 (branch_pts=1), churn_90d=10 (churn_pts=4) | +| **anchor_sites** | 2 | +| **first_seen** | 2026-08-13 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** **Phase-3 confirmed.** `pubkey` is the empty string and the single endpoint host is +a reserved `.example` name; `lib.rs` registers the plugin unconditionally with no `Builder::pubkey` +override, and no build step patches the config. Meanwhile `release.yml` injects +`TAURI_SIGNING_PRIVATE_KEY` for all three OSes — so a tagged build produces **signed artifacts whose +signature can never verify**, pointed at a nonexistent host. `tauri build` validates bundling, not +updater semantics, and no smoke row mentions the updater. `lib.rs` has zero tests. + +**Concrete test.** A config-coherence test asserting `plugins.updater.pubkey` is non-empty, that no +endpoint host ends in `.example`/`.invalid`, and that every endpoint retains its +`{{target}}/{{arch}}/{{current_version}}` placeholders. Pair with a `release.yml` assertion that signing +key and verifying pubkey cannot be configured one-sidedly. + +### GAP-069 — `lib.rs:run`'s ExitRequested ordering is load-bearing and enforced only by statement order + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `untested-symbol` | +| **layer** | tauri-host | +| **verified_by** | automated | +| **anchors** | `cockpit/ui/src-tauri/src/lib.rs:run#exit-requested`, `cockpit/ui/src-tauri/src/lib.rs:run#generate-handler` | +| **risk** | L4 × I5 = **20** | +| **observations** | coverage_pts=5, branches=2 (branch_pts=1), churn_90d=10 (churn_pts=4) | +| **anchor_sites** | 2 | +| **first_seen** | 2026-08-13 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** `lib.rs` has zero tests and is a ratified core entry point. The handler's comment +states the sidecar must be reaped first "so the supervisor doesn't respawn it as we tear down" — an +ordering nothing enforces. Smoke row 1.9b (`GAP-010`) recorded a live, undiagnosed anomaly here, which +is precisely the signal this contract deserves a mechanical guard. The registration block is the other +silent-drift surface: adding a command and forgetting the `generate_handler!` line compiles clean and +fails only as a runtime "command not found" in the webview. + +**Concrete test.** A source-structure guard (same technique the concurrent +`tests/tauri_command_threading.rs` uses): assert `SidecarSupervisor::shutdown` appears at an earlier +byte offset than `stop_all_owned`, which precedes `app_handle.exit(0)`; and assert every name in +`generate_handler!` resolves to a real `#[tauri::command]` in the tree. + +### GAP-070 — `run_halyard` shells out with no timeout from a synchronous Tauri command + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `untested-symbol` | +| **layer** | tauri-host | +| **verified_by** | automated | +| **anchors** | `cockpit/ui/src-tauri/src/dashboard.rs:run_halyard`, `cockpit/ui/src-tauri/src/dashboard.rs:halyard_status`, `cockpit/ui/src-tauri/src/local_projects.rs:scan_local_projects` | +| **risk** | L3 × I5 = **15** | +| **observations** | coverage_pts=4, branches=1 (branch_pts=1), churn_90d=2 (churn_pts=2) | +| **anchor_sites** | 3 | +| **first_seen** | 2026-08-13 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** **This is the same defect class as the 1.5 freeze, in commands nobody has looked +at.** `halyard_status`/`halyard_queue` are synchronous `#[tauri::command] pub fn` calling +`Command::output()` with no timeout; `scan_local_projects` is synchronous and does a bounded-recursive +`walkdir` over whole scan roots plus a full read of every STATUS.md/ROADMAP.md. Sync Tauri commands run +on the main event-loop thread. `Dashboard.svelte` fires all three on mount **and again every 15 s**, so +the freeze recurs. The concurrent `tests/tauri_command_threading.rs` guard already names this as +tolerated debt — it asserts signatures, so it can never go red when the hang happens. Nothing else +gates it: this crate's tests do not run in CI (`GAP-057`). + +**Concrete test.** Point `HALYARD_BIN` at a stub and assert the three failure shapes (binary absent, +non-zero exit with stderr, non-JSON stdout); then the one that matters — a `sleep 30` stub must return +within a bounded time, which fails today and forces a timeout to exist. + +### GAP-071 — The Audience HTTP commands have no client timeout and an unasserted error-policy asymmetry + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `untested-symbol` | +| **layer** | tauri-host | +| **verified_by** | automated | +| **anchors** | `cockpit/ui/src-tauri/src/dashboard.rs:audience_health`, `cockpit/ui/src-tauri/src/dashboard.rs:audience_posts` | +| **risk** | L3 × I5 = **15** | +| **observations** | coverage_pts=4, branches=3 (branch_pts=2), churn_90d=1 (churn_pts=2) | +| **anchor_sites** | 2 | +| **first_seen** | 2026-08-13 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** `reqwest::Client::new()` with no `.timeout()`, so a backend that accepts the socket +and stalls leaves the invoke promise pending forever — and `Dashboard.svelte:refresh()` awaits +sequentially, so `pulling` never clears, REFRESH stays disabled, and the unrelated LOCAL lane never +polls. Only `unwrap_posts` is tested; neither transport is. The two commands also take **opposite** +error policies (`audience_health` swallows every transport error into `Ok(false)`, `audience_posts` +propagates) and nothing pins that as deliberate, so a refactor could flip a down backend into a hard +failure the adapter is not written to handle. + +**Concrete test.** Stand a `tokio` test HTTP server and assert: `/health` 200 → `Ok(true)`; 500 → +`Ok(false)`; connection refused → `Ok(false)` not `Err`; `/posts` 503 → `Err` with the status; HTML body +→ `Err` "not JSON"; `{"posts":[…]}` → the unwrapped array. + +### GAP-072 — `local_projects`' exclusion list and depth bound are the only brakes on a whole-disk walk, and neither is tested + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `untested-branch` | +| **layer** | tauri-host | +| **verified_by** | automated | +| **anchors** | `cockpit/ui/src-tauri/src/local_projects.rs:discover#excludes-and-max-depth`, `cockpit/ui/src-tauri/src/local_projects.rs:scan_local_projects#pin-dedup` | +| **risk** | L2 × I5 = **10** | +| **observations** | coverage_pts=2, branches=5 (branch_pts=2), churn_90d=2 (churn_pts=2) | +| **anchor_sites** | 2 | +| **first_seen** | 2026-08-13 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** All four tests pass `max_depth: 5, excludes: vec![]`, so `is_excluded` is never +exercised with a non-empty list and the depth bound is never observed truncating anything — while +`scan_local_projects` is itself flagged unbounded main-thread debt whose cost scales with the +operator's disk. Separately the pin dedup arm (`if discovered.contains(&norm) { continue }`) is never +taken, and its correctness rests on a case-**sensitive** comparison on Windows, where `D:/proj` and +`d:/proj` are the same directory — so the same project can appear twice on the board, and a pinned +directory that is also discovered silently keeps `is_pinned: false`. + +**Concrete test.** Build `root/deep/a/b/c/d/docs/STATUS.md` and assert it is found at depth 8 and absent +at 3; build `root/vendor/thing/docs/STATUS.md` and assert `excludes: ["vendor"]` drops it; pass a pin +with backslashes against forward-slash discovery and assert dedup still holds. + +### GAP-073 — `App.svelte`'s app-plugin compositing effect never exercises the overlay park/restore pair + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `untested-branch` | +| **layer** | vitest | +| **verified_by** | automated | +| **anchors** | `cockpit/ui/src/App.svelte:$effect#app-plugin-compositing` | +| **risk** | L3 × I5 = **15** | +| **observations** | coverage_pts=4, branches=4 (branch_pts=2), churn_90d=8 (churn_pts=4) | +| **anchor_sites** | 1 | +| **first_seen** | 2026-08-13 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** The jsdom half of smoke row 1.7 (`GAP-007`) and it is fully automatable: the +overlay-open branch (`if (overlayOpen) void invoke('plugin_hide')`) and the restore branch are pure +signal emission across the Tauri boundary, observable through the invoke mock this test file already +installs. The two existing tests cover only the healthy/not-healthy gate; neither ever opens an +overlay. Same class as the defect that froze the UI — a native-boundary contract the UI half must +honour. Note the pin would still be **local-only** until `npm test` reaches CI (`GAP-110`). + +**Concrete test.** Activate audience, emit `healthy`, assert one `plugin_show`; call +`fleet.requestRealLaunch(...)`, assert one `plugin_hide` and no `plugin_show` while open; cancel and +assert `plugin_show` count is 2. Plus: no re-issue of `plugin_hide` on an unrelated `pluginState` write. + +### GAP-074 — The ResizeObserver rect-glue effect is asserted nowhere, teardown included + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `untested-symbol` | +| **layer** | vitest | +| **verified_by** | automated | +| **anchors** | `cockpit/ui/src/App.svelte:$effect#rect-glue-resizeobserver` | +| **risk** | L3 × I5 = **15** | +| **observations** | coverage_pts=4, branches=2 (branch_pts=1), churn_90d=8 (churn_pts=4) | +| **anchor_sites** | 1 | +| **first_seen** | 2026-08-13 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** Two thirds of smoke row 1.6 and the leak half of 1.8. `App.appPlugin.test.ts` +explicitly stubs `ResizeObserver` to a no-op and defers this to the checklist, so nothing asserts that +an observer is attached, that the callback marshals a well-formed rect, or — the high-value one — that +the effect's teardown **disconnects** when `activeApp` goes null. An undisconnected observer holding a +detached rect element is exactly the leak 1.8 is looking for, and a human eyeballing a window will +never see it. + +**Concrete test.** Stub `ResizeObserver` with a class capturing the callback and recording +`observe`/`disconnect`; assert `observe` on the rect element, that firing the callback emits +`plugin_set_rect` with all four keys, and that switching to FLEET calls `disconnect` exactly once and +stops further emissions. + +### GAP-075 — No test in the repo ever mounts a view-plugin iframe from `App.svelte` + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `untested-symbol` | +| **layer** | vitest | +| **verified_by** | automated | +| **anchors** | `cockpit/ui/src/App.svelte:$effect#view-plugin-bridge`, `cockpit/ui/src/App.svelte:onSwitch#view-prefix-arm` | +| **risk** | L3 × I5 = **15** | +| **observations** | coverage_pts=4, branches=2 (branch_pts=1), churn_90d=8 (churn_pts=4) | +| **anchor_sites** | 2 | +| **first_seen** | 2026-08-13 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** `bridge.test.ts` unit-tests `PluginBridge` in isolation and no App-level test ever +activates a view-plugin — no test in `src/` references `view-plugin-frame` or `switch-view:` at all. So +the `view:` arm of `onSwitch`, the entry point to the entire sandboxed-plugin runtime, is unexecuted. +The bridge registers a window `message` listener and a 60 ms interval released only via `destroy()`, so +if the effect teardown ever stops firing, every FLEET↔REFERENCE round trip leaks a listener plus a +timer draining a store for an unmounted frame. The `sandbox="allow-scripts"` assertion is cheap +insurance on a security-shaped invariant with zero automated protection today. + +**Concrete test.** `vi.mock('./lib/bridge')` so `PluginBridge` records constructor args and a `destroy` +spy; assert the iframe mounts with `sandbox === 'allow-scripts'` (and **not** `allow-same-origin`) and a +`ccplugin://` src, that exactly one bridge is constructed, and that switching away calls `destroy` once +and unmounts. Re-enter and assert no accumulation. + +### GAP-076 — `onKill` — the plugin-misbehaviour escape hatch — has no App-level test + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `untested-branch` | +| **layer** | vitest | +| **verified_by** | automated | +| **anchors** | `cockpit/ui/src/App.svelte:$effect#view-plugin-bridge#onkill-fallback` | +| **risk** | L3 × I5 = **15** | +| **observations** | coverage_pts=4, branches=2 (branch_pts=1), churn_90d=8 (churn_pts=4) | +| **anchor_sites** | 1 | +| **first_seen** | 2026-08-13 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** An inbound message flood must dump the untrusted iframe and fall back to the +trusted ops grid. `onKill` sets two pieces of state from inside an effect keyed on the first of them — +a self-invalidating write whose correctness depends on Svelte 5 effect re-entrancy doing the teardown +before the re-run. `bridge.test.ts` can only prove the callback fires, not what the shell does with it, +and the hostile-plugin kill path falls between every manual row (1.3/1.4 are the happy path, 1.7 is +app-plugin parking, 1.8 is leak-on-switch). A human smoke run would essentially never trigger it. + +**Concrete test.** Capture the options passed to the mocked `PluginBridge`, invoke `options.onKill()`, +`await tick()`, then assert the iframe is gone, the Fleet ops grid is rendered, and +`switch-fleet` has `aria-pressed === "true"`. + +### GAP-077 — `selectApp` has no in-flight guard, so a double-click starts two docker builds + +| field | value | +|---|---| +| **status** | `open` | +| **claim_type** | `untested-branch` | +| **layer** | vitest | +| **verified_by** | automated | +| **anchors** | `cockpit/ui/src/App.svelte:selectApp#no-in-flight-guard` | +| **risk** | L3 × I5 = **15** | +| **observations** | coverage_pts=4, branches=3 (branch_pts=2), churn_90d=8 (churn_pts=4) | +| **anchor_sites** | 1 | +| **first_seen** | 2026-08-13 | +| **last_verified** | 2026-08-13 @ `a3edc78` (static-only) | +| **decision** | — | +| **rationale** | — | + +**Risk rationale.** **Phase-3 confirmed on both sides.** UI: `activeApp` is set synchronously, then +`await tick()`, then a `pluginState[id] === 'healthy'` check — and `pluginState` is only ever written by +the async `plugin://state` listener, so a second click during `starting`/`building` fails the check and +dispatches again. The tab is a plain `