diff --git a/.gitignore b/.gitignore index 46a7e87..628b4dd 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,6 @@ # embargo guard denylist — digests of low-entropy tokens are crackable, so the # denylist is never committed (see scripts/embargo-guard.mjs) /.embargo-guard.local.json + +# Root-level tooling artifacts (cockpit/ui has its own ignore for its tree). +node_modules/ diff --git a/CLAUDE.md b/CLAUDE.md index d1d3b6d..d293684 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,43 @@ # CLAUDE.md + +## 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 3"** section of +[`spikes/SPIKE-RESULTS.md`](spikes/SPIKE-RESULTS.md) before doing anything else. + +The one-line version: **the smoke is finished. Part 1 and the packaged Part 2 have both been run, +and #49 is READY FOR REVIEW with all 18 CI checks green** — it is waiting on a human merge decision, +nothing else. Run 3 scored **9 PASS / 2 BLOCKED / 2 NOT RUN / 0 FAIL** and closed five defects: +**D-7** (view-plugins got no state — `DataCloneError` posting Svelte `$state` proxies), **D-8** (the +packaged bundle shipped **no plugin root at all**, so no shipped build could load a view-plugin), +**D-2** (every plugin was granted every capability; now fails closed), **D-4** (re-verified packaged: +exit in 0.23 s cold, 5.27 s with 10 containers), and **D-5** (investigated, **did not reproduce**). + +Traps, updated: +- **Items 1.2 / 1.4a / 1.7 are BLOCKED, not broken**, behind **D-3**: fleetd serves no CORS headers, + so no browser `fetch` from the cockpit reaches the daemon. **Pre-existing on `main`** — the FLEET + ops grid renders nothing because of this. Don't chase it as a #49 regression; it needs its own issue. +- **Assert Gate 5 with `docker ps -a`, not `docker ps`.** `docker ps` cannot see the `Created` / + `Exited` residue teardown leaves, and that residue breaks the *next* launch with a name conflict. +- **Quit the cockpit gracefully, never `Stop-Process`,** when testing Gate 5 — a force-kill skips + `stop_all_owned` and fabricates a teardown failure. +- **A clean packaged run shows no AUDIENCE tab, and that is correct.** `PluginManager::roots()` has + no packaged resource root by design; app-plugins come from `CC_APP_PLUGINS_DEV` or + `~/.command-center/app-plugins`. Set `CC_APP_PLUGINS_DEV` to drive AUDIENCE, and record it as + "packaged binary, dev discovery seam". +- **A release build has no devtools.** F12 does nothing, so any check whose criterion is a console + reading is NOT RUN when packaged — decide that up front rather than mid-session. +- **"CI never builds the app" is FALSE.** `ci.yml:311` runs `tauri build` on all three OSes. What no + gate does is look *inside* the bundle — which is exactly how D-8 passed a *successful* build. This + is `GAP-132` in [`docs/testing/PLAN.md`](docs/testing/PLAN.md). +- **"Images are prebuilt so there's no build" is false** — `compose build` runs regardless. Though in + run 3 the images already existed and the ramp took ~15 s, not 20 min. + +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/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-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/embedding.rs b/cockpit/ui/src-tauri/src/embedding.rs new file mode 100644 index 0000000..22f0bae --- /dev/null +++ b/cockpit/ui/src-tauri/src/embedding.rs @@ -0,0 +1,153 @@ +// App-plugin child-webview embedding (Lane S integration of the Lane A contract bundle; +// P3 spike `spike/app-plugins-webview-v2` carry-forward). +// +// These three commands are the trusted Rust surface the Svelte shell drives to composite a +// child webview (a whole first-party web app) under a Svelte-owned content rect. +// +// TWO load-bearing findings from the P3 spike are baked in here: +// 1. The commands are `async`. A *synchronous* command runs on the main (event-loop) +// thread, but `add_child` / `set_position` post a message to that same loop and block +// on it — a sync command deadlocks itself (the `spike_show` hang). As `async` commands +// they run on an async-runtime worker, leaving the loop free to pump webview creation. +// 2. Inactive webviews are PARKED off-screen, never `hide()`d. WebView2 `hide()`→`show()` +// forces a repaint/reload (loses scroll/app state); moving the view off-screen keeps its +// render tree warm. A small warm-pool LRU bounds how many kept-warm webviews live. +// +// Webview label scheme is `app::` — stable across relaunch/adopt, and matched by the +// `webviews: ["app::*"]` capability glob in `capabilities/default.json`. + +use crate::plugins::manager::PluginManager; +use std::collections::HashMap; +use std::sync::Mutex; +use tauri::{ + webview::WebviewBuilder, AppHandle, LogicalPosition, LogicalSize, Manager, State, WebviewUrl, +}; + +/// The CSS/logical rect the Svelte placeholder reports (device-independent px, relative to +/// the host window's content area). Mirrors the `DOMRect` fields the shell emits. +#[derive(Debug, Clone, Copy, serde::Deserialize)] +pub struct Rect { + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, +} + +/// How many kept-warm (parked) webviews we keep before destroying the least-recently-used. +const WARM_CAP: usize = 3; +/// Park origin: far enough off-screen that no sliver paints, near enough to stay a valid +/// logical coordinate. The render tree stays ALIVE here (no hide()/show() repaint-reload). +const PARK_X: f64 = -32000.0; +const PARK_Y: f64 = -32000.0; +/// The host window created by tauri.conf.json (its only `windows[]` entry) gets the +/// default label "main". +const HOST_WINDOW_LABEL: &str = "main"; + +/// Webview label for an app plugin. Stable across relaunch/adopt; matches the `app::*` +/// capability glob. +pub fn app_label(id: &str) -> String { + format!("app::{id}") +} + +/// Warm-pool bookkeeping: MRU-ordered labels + each label's last on-screen rect. Kept as +/// host state via `.manage(WebviewPool::default())`. +#[derive(Default)] +pub struct WebviewPool { + lru: Mutex>, + last_rect: Mutex>, +} + +impl WebviewPool { + /// Mark `label` most-recently-used and evict past the warm cap (destroying the LRU + /// victim's webview so memory is bounded — the next show recreates it). + fn touch_and_evict(&self, app: &AppHandle, label: &str, rect: Rect) { + self.last_rect + .lock() + .unwrap() + .insert(label.to_string(), rect); + let mut lru = self.lru.lock().unwrap(); + lru.retain(|l| l != label); + lru.insert(0, label.to_string()); + while lru.len() > WARM_CAP { + if let Some(victim) = lru.pop() { + if let Some(wv) = app.get_webview(&victim) { + let _ = wv.close(); + } + self.last_rect.lock().unwrap().remove(&victim); + } + } + } +} + +/// First show → create the child webview over the reserved rect (call only once the plugin +/// is `healthy`); later shows → reposition + bring on-screen + focus. Async: webview +/// create/show MUST run off the main thread (see module docs — sync deadlocks). +#[tauri::command] +pub async fn plugin_show( + app: AppHandle, + mgr: State<'_, PluginManager>, + pool: State<'_, WebviewPool>, + id: String, + rect: Rect, +) -> Result<(), String> { + let label = app_label(&id); + if let Some(wv) = app.get_webview(&label) { + wv.set_position(LogicalPosition::new(rect.x, rect.y)) + .map_err(|e| format!("set_position: {e}"))?; + wv.set_size(LogicalSize::new(rect.width, rect.height)) + .map_err(|e| format!("set_size: {e}"))?; + wv.set_focus().map_err(|e| format!("set_focus: {e}"))?; + } else { + let url_str = mgr + .url_for(&id) + .ok_or_else(|| format!("no url for plugin {id} (launch it first?)"))?; + let url: tauri::Url = url_str + .parse() + .map_err(|e| format!("bad plugin url {url_str}: {e}"))?; + let window = app + .get_window(HOST_WINDOW_LABEL) + .ok_or_else(|| format!("host window '{HOST_WINDOW_LABEL}' not found"))?; + // THE `unstable` call: attach a child webview to the existing host window. + let wv = window + .add_child( + WebviewBuilder::new(&label, WebviewUrl::External(url)), + LogicalPosition::new(rect.x, rect.y), + LogicalSize::new(rect.width, rect.height), + ) + .map_err(|e| format!("add_child: {e}"))?; + wv.set_focus().map_err(|e| format!("set_focus: {e}"))?; + } + pool.touch_and_evict(&app, &label, rect); + Ok(()) +} + +/// Switch-away / host-overlay-open → PARK off-screen (keep the render tree warm), do NOT +/// destroy and do NOT `hide()` (which would force a repaint/reload on the next show). +#[tauri::command] +pub async fn plugin_hide(app: AppHandle, id: String) -> Result<(), String> { + if let Some(wv) = app.get_webview(&app_label(&id)) { + wv.set_position(LogicalPosition::new(PARK_X, PARK_Y)) + .map_err(|e| format!("park: {e}"))?; + } + Ok(()) +} + +/// ResizeObserver-driven: keep the native webview glued to the reserved box on window +/// resize / layout change. Async for the same main-thread reason as `plugin_show`. +#[tauri::command] +pub async fn plugin_set_rect( + app: AppHandle, + pool: State<'_, WebviewPool>, + id: String, + rect: Rect, +) -> Result<(), String> { + let label = app_label(&id); + if let Some(wv) = app.get_webview(&label) { + wv.set_position(LogicalPosition::new(rect.x, rect.y)) + .map_err(|e| format!("set_position: {e}"))?; + wv.set_size(LogicalSize::new(rect.width, rect.height)) + .map_err(|e| format!("set_size: {e}"))?; + pool.last_rect.lock().unwrap().insert(label, rect); + } + Ok(()) +} diff --git a/cockpit/ui/src-tauri/src/lib.rs b/cockpit/ui/src-tauri/src/lib.rs index c5c21aa..11cecdb 100644 --- a/cockpit/ui/src-tauri/src/lib.rs +++ b/cockpit/ui/src-tauri/src/lib.rs @@ -1,5 +1,28 @@ +use std::sync::atomic::{AtomicBool, Ordering}; use tauri::Manager; +/// Gate-5 shutdown re-entrancy guard (smoke item 1.9b). +/// +/// `AppHandle::exit` re-emits `RunEvent::ExitRequested`. If the handler calls +/// `api.prevent_exit()` unconditionally, the exit it requests re-enters the handler, which +/// prevents it again — so the process never exits and spins the event loop at ~100% of one +/// core with no window. Measured in Smoke run 2: 309 s of CPU burned after a graceful close. +/// +/// The FIRST exit request must be prevented, so teardown gets to run. Every later one must be +/// allowed through. +#[derive(Default)] +pub struct ShutdownGuard(AtomicBool); + +impl ShutdownGuard { + /// True exactly once — for the first exit request only. + pub fn should_prevent_exit(&self) -> bool { + !self.0.swap(true, Ordering::SeqCst) + } +} + +/// One guard per process; there is exactly one `run()` per process. +static SHUTDOWN_GUARD: ShutdownGuard = ShutdownGuard(AtomicBool::new(false)); + mod plugins; // LANE-A → SHELL contract: the dashboard's read-seam Tauri commands (§6.1/§6.2). mod dashboard; @@ -7,6 +30,11 @@ mod dashboard; mod sidecar; // U4 (spec §4, §6): filesystem discovery + raw reads for the `local` dashboard source. mod local_projects; +// PLUGIN RUNTIME (Lane S integration): +// - `view_plugins`: the `ccplugin://` scheme serving sandboxed view-plugin assets (Lane V). +// - `embedding`: the app-plugin child-webview show/hide/set_rect commands (Lane A). +mod embedding; +mod view_plugins; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { @@ -16,12 +44,24 @@ pub fn run() { // LANE-B → HOST: holds the live fleetd-serve child so the shutdown hook // can kill it (no orphaned sidecar) and the supervisor can restart it. .manage(sidecar::SidecarSupervisor::default()) + // PLUGIN RUNTIME (Lane S): warm-pool bookkeeping for app-plugin child webviews. + .manage(embedding::WebviewPool::default()) + // PLUGIN RUNTIME (Lane S): serve sandboxed view-plugin assets over `ccplugin://` + // with the plugin-doc CSP + ACAO headers (P4 spike findings). Registered on the + // builder so it works in BOTH `tauri dev` and a packaged build. + .register_uri_scheme_protocol(view_plugins::SCHEME, |ctx, req| { + view_plugins::respond(ctx.app_handle(), req) + }) // LANE-A → SHELL contract: register the dashboard read-seam commands so the // frontend's `invoke('halyard_status'|'halyard_queue'|'audience_health'| // 'audience_posts')` resolve. Additive only — remove nothing here. .invoke_handler(tauri::generate_handler![ plugins::manager::plugins_list, plugins::manager::plugin_launch, + // PLUGIN RUNTIME (Lane S): app-plugin child-webview embedding (Lane A bundle). + embedding::plugin_show, + embedding::plugin_hide, + embedding::plugin_set_rect, dashboard::halyard_status, dashboard::halyard_queue, dashboard::audience_health, @@ -59,6 +99,13 @@ pub fn run() { .expect("error while building tauri application") .run(|app_handle, event| { if let tauri::RunEvent::ExitRequested { api, .. } = event { + // `app_handle.exit(0)` below re-emits `ExitRequested`. Preventing that + // re-entry too would call this handler forever: the process never exits and + // spins the event loop at ~100% of a core with no window (Gate-5 item 1.9b, + // measured at 309 s of CPU in Smoke run 2). Let every later request through. + if !SHUTDOWN_GUARD.should_prevent_exit() { + return; + } api.prevent_exit(); // LANE-B → HOST: stop the fleetd-serve sidecar first so the // supervisor doesn't respawn it as we tear down, and no @@ -87,3 +134,29 @@ pub fn run() { } }); } + +#[cfg(test)] +mod shutdown_guard_tests { + use super::ShutdownGuard; + + /// Pins Gate-5 item 1.9b. `AppHandle::exit` re-emits `ExitRequested`; if that re-entry is + /// prevented too, the handler calls itself forever and the process spins instead of + /// exiting. Delete the `swap` in `should_prevent_exit` and this goes red. + #[test] + fn only_the_first_exit_request_is_prevented() { + let guard = ShutdownGuard::default(); + + assert!( + guard.should_prevent_exit(), + "the first ExitRequested must be prevented so teardown can run" + ); + assert!( + !guard.should_prevent_exit(), + "the exit(0) re-entry must NOT be prevented, or the app never exits" + ); + assert!( + !guard.should_prevent_exit(), + "every later exit request must also be allowed through" + ); + } +} diff --git a/cockpit/ui/src-tauri/src/plugins/manager.rs b/cockpit/ui/src-tauri/src/plugins/manager.rs index c5ab8f2..8079a55 100644 --- a/cockpit/ui/src-tauri/src/plugins/manager.rs +++ b/cockpit/ui/src-tauri/src/plugins/manager.rs @@ -7,7 +7,7 @@ use std::collections::HashMap; use std::path::PathBuf; use std::sync::Mutex; use std::time::{Duration, Instant}; -use tauri::{AppHandle, State}; +use tauri::{AppHandle, Manager, State}; /// One launched plugin's runtime record. pub struct Running { @@ -26,9 +26,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")); } @@ -57,16 +65,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 { @@ -78,6 +79,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")) @@ -121,6 +145,22 @@ pub fn plugins_list(mgr: State<'_, PluginManager>) -> Vec { out } +/// Dispatch a plugin's start sequence onto a background thread and return immediately. +/// +/// PHASE-6 SMOKE FINDING (checklist 1.5). This used to call `run_start_sequence` inline, with +/// a standing note that it "may block up to the probe timeout (~180 s)". The smoke proved it +/// out: a *synchronous* Tauri command runs on the main event-loop thread — the same P3 finding +/// that forced the embedding commands to be `async` (see `embedding.rs`) — and the sequence +/// blocks on `docker compose build` (a 20-minute budget for Audience) plus the health and +/// ready probe budgets. The entire UI froze from the tab click until the stack came up. +/// +/// A dedicated OS thread, deliberately not an async-runtime worker: every seam in the sequence +/// is blocking (`Command::status`, `ureq`, `thread::sleep`), so handing it to the runtime would +/// starve a worker and merely relocate the stall. +/// +/// **Ok means "dispatched", not "healthy".** Lifecycle truth reaches the shell only through the +/// `plugin://state` events the sink emits, which `App.svelte` already subscribes to; it must not +/// treat this returning as readiness. Pinned by `src/App.appPlugin.test.ts`. #[tauri::command] pub fn plugin_launch( app: AppHandle, @@ -139,31 +179,241 @@ pub fn plugin_launch( return Err(format!("unknown plugin {id}")); }; - let probe = HttpProbe; - let clock = RealClock::new(); - let sink = TauriEventSink { app: app.clone() }; - let images_present = probe.probe(&disc.manifest.lifecycle.health.url).is_some(); - - // NOTE: this call is synchronous and may block up to the probe timeout (~180 s). - // If that proves problematic in the Phase-6 smoke it can move to a background task. - let outcome = run_start_sequence( - &disc.manifest, - &disc.dir, - &probe, - &mgr.spawner, - &clock, - &sink, - images_present, - ); - match outcome { - StartOutcome::Healthy { owned, child_id } => { + // `mgr` is borrowed from this invocation and cannot cross the thread boundary; the handle + // can, so the worker re-acquires the manager from it. + std::thread::spawn(move || { + let mgr = app.state::(); + let probe = HttpProbe; + let clock = RealClock::new(); + let sink = TauriEventSink { app: app.clone() }; + let images_present = probe.probe(&disc.manifest.lifecycle.health.url).is_some(); + + let outcome = run_start_sequence( + &disc.manifest, + &disc.dir, + &probe, + &mgr.spawner, + &clock, + &sink, + images_present, + ); + if let StartOutcome::Healthy { owned, child_id } = outcome { mgr.running .lock() .unwrap() - .insert(id, Running { child_id, owned }); - // Phase 6 shows the webview here (once healthy). - Ok(()) + .insert(disc.manifest.id.clone(), Running { child_id, owned }); } - StartOutcome::Error(e) => Err(e), + // On Error the sink has already emitted `error`; the shell reacts to that event. There + // is no caller left to return it to. + }); + + Ok(()) +} + +#[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"); + } + + // ---- 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/src/view_plugins.rs b/cockpit/ui/src-tauri/src/view_plugins.rs new file mode 100644 index 0000000..1ee6a2e --- /dev/null +++ b/cockpit/ui/src-tauri/src/view_plugins.rs @@ -0,0 +1,140 @@ +// View-plugin asset scheme `ccplugin://` (Lane S integration of the Lane V contract bundle; +// P4 spike `spike/view-plugins-handshake` carry-forward). +// +// A sandboxed view-plugin runs in an ` + {:else if activeApp} + +
+ {:else if view === 'projects'} | +| **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 `