` gains the `scroll` modifier (`class="data scroll"`), or is wrapped in `` — check which form `dash.css`'s selector actually matches (grep `\.scroll` in the file first; use the one it styles, do not invent a third).
+2. `src/dashboards/web/static/dash.css` — if the `.scroll` selector matches `table.data.scroll`, nothing to add; if it matches a `div.scroll > table.data` wrapper, verify the wrapper's max-height/overflow behaviour against the card's padding so the scroll container does not clip the row's own bottom border (measure in a browser, do not deduce). No new token, no new palette.
+3. `tests/test_api.py` — the served-HTML pin: `#agents-table` (or its wrapper, whichever shipped) carries the `.scroll` mechanism in the served bytes; the `COLUMNS`-resolution test (the one that reads the served `COLUMNS` table against a live `GET /api/certs` row in both directions) stays green untouched.
+4. `src/dashboards/web/static/page-certs.js` — lines 55–56 (`table`/`body` lookups): no change expected — the id and the `tbody` selector are untouched by the wrapper/class; verify the page still renders its rows (a wrapper that breaks `getElementById('agents-table')` would be caught by the existing render path).
+
+## Testing & Quality
+- Unit/integration: `tests/test_api.py` — the pin above; the unchanged `COLUMNS` contract test
+- Coverage: **>90%** on this task's new/modified code (one class / one wrapper; the gate applies to the changed test file)
+- Browser verification (invariant 10): at 360 px on `/certs`, the table's scroll container is reachable — a CDP check that the container's `scrollWidth > clientWidth` and that scrolling to the end exposes the last column (the controls column), plus a screenshot mid-scroll in `data/logs/`; at 1280 px the table renders as before (no scrollbar on a wide viewport with three short rows)
+
+## Completion Criteria
+- [ ] at 360 px the `#agents-table` scrolls horizontally and its last column is reachable (screenshot in `data/logs/`)
+- [ ] at 1280 px the table is visually unchanged
+- [ ] `tests/test_api.py` green including the new pin and the unchanged `COLUMNS` test
+- [ ] full test suite green
+- [ ] no behavior change in completed work: the row contents, the status chips, the Rotate / Revoke / bundle controls and the `restart_required` outcome line behave exactly as before
diff --git a/.agents/phases/todo/22_disks_hide_unconfigured/00_phase.md b/.agents/phases/todo/22_disks_hide_unconfigured/00_phase.md
new file mode 100644
index 0000000..f0c344d
--- /dev/null
+++ b/.agents/phases/todo/22_disks_hide_unconfigured/00_phase.md
@@ -0,0 +1,32 @@
+# Phase 22 — /disks hides hosts without smartctl
+
+**Source:** `TODO.md` L13 — "VMs that don't have smartctl enabled disks should not show up. Gateway shouldn't even be a host on the disks page."
+**Story:** n/a
+**Context:** `src/dashboards/api.py` (`disks_router()` at line 791 — `/api/disks/snapshot` answering one entry per configured host; the `/api/summary` disks block at lines 640–704 — `disks_hosts` built from `job.state.payload or _disks_stub(...)` at line 640, `fleet_disk_summary(disks_hosts)` at line 693; `_disks_stub()` at line 771 — `smartctl_available: None` for a never-answered host), `src/dashboards/sources/disks.py` (`smartctl_available()` at line 142 — the three-way flag: `True` a disk answered, `False` the agent said the binary is not installed, `None` an agent predating the pass or `--no-smart`), `src/dashboards/web/static/page-disks.js` (the per-host cards + the `fleet_disk_summary` header, hidden below two hosts), `tests/test_api.py` (the `/api/disks/*` pins — "one entry per configured host", the `smartctl_available` three-way, "with and without targets configured", the `fleet_disk_summary` key paths)
+
+## Objective
+
+A host whose agent answered `smartctl_available: false` (the binary is not installed — gateway's VM, no smartmontools behind `vda`) is not a host on the `/disks` page at all: it is absent from `/api/disks/snapshot`, from the fleet block and from `/api/summary`'s disks rows, and the fleet header counts only the hosts that are shown. The **three-way** flag itself is unchanged in `sources/disks.py` (the agent still reports it, the `/disks` page's `null`-case why-line still exists for `None` hosts, and an **unreachable** host keeps its dimmed stub row — a dead agent is a different fact from a missing binary, invariant 3). The filter is one API-level decision, so the source, the store and the health records see nothing new.
+
+## Dependencies
+- `21_page_cleanups` (todo) — both phases touch the `/disks` page markup (task 21.01 removes the page's `#pause` button); landing 21 first keeps this phase's diff confined to the host list.
+
+## Tasks
+1. `01_hide_smartctl_false_hosts.md` — the one API-level visibility gate (explicit `False` only) over `/api/disks/snapshot`, `/api/summary`'s disks block and the fleet summary; the pins rewritten
+2. `02_verification.md` — browser + API verification against the live fleet: gateway absent, deskwork/driveripper intact, fleet header consistent, screenshots
+
+## Testing & Quality
+- Unit/integration: `tests/test_api.py` — the rewritten `/api/disks/snapshot` pin (one entry per configured host **whose agent can read its drives** — a `smartctl_available: false` payload is absent, a `None` payload is present, an `unreachable` stub is present), the `/api/summary` disks-block pin (fleet counts over the visible hosts, per-host rows for the visible hosts only), the unchanged `smartctl_available` three-way pin in `sources/disks.py`'s own tests (`tests/test_disks.py` or wherever the flag is pinned — grep `smartctl_available` in `tests/` first)
+- Coverage: **>90%** on new/modified code
+- The phase is "Complete" only when the **entire** project test suite passes 100% (every test in the project, not just this phase's)
+
+## Documentation & Consistency
+
+- The final pass reviews `README.md` and `AGENTS.md` in full against the project as it stands after this phase — fixing every inconsistency found, **including pre-existing ones not caused by this phase**. AGENTS.md's `sources/disks.py` bullet (the three-way flag's `False` arm — "so the page can say *install smartmontools*") and the `/disks` UI paragraph (the `.banner.info` why-line for `false`) are rewritten: the `false` arm's page-side consequence is now *absence*, and the three-way flag keeps its agent-side meaning; the "gateway answers `smartctl_available: false`" fleet fact gains the "and is therefore not a host on the disks page" clause.
+
+## Completion Criteria
+- [ ] `/api/disks/snapshot` and `/api/summary`'s disks block carry no entry for a host whose agent answered `smartctl_available: false`; a `None` host and an `unreachable` stub both remain (pinned both ways in `tests/test_api.py`)
+- [ ] the `/disks` page on the live fleet shows deskwork + driveripper only, the fleet header counts 2 hosts, and the per-host cards are unchanged (screenshot in `data/logs/`)
+- [ ] `uv run pytest` green, coverage not down, `ruff` + `pyright` clean
+- [ ] `bash .agents/validate.sh` green
+- [ ] no behavior change in completed work: the agent's SMART pass, the `disks:` job's health record, the stored `disk.*` series, the `None`-case why-line and the unreachable stub are all untouched
diff --git a/.agents/phases/todo/22_disks_hide_unconfigured/01_hide_smartctl_false_hosts.md b/.agents/phases/todo/22_disks_hide_unconfigured/01_hide_smartctl_false_hosts.md
new file mode 100644
index 0000000..2fb8863
--- /dev/null
+++ b/.agents/phases/todo/22_disks_hide_unconfigured/01_hide_smartctl_false_hosts.md
@@ -0,0 +1,30 @@
+# Task 01 — The one visibility gate: `smartctl_available` is `False` only
+
+**Phase:** `22_disks_hide_unconfigured` · **Source:** `TODO.md:13` — "VMs that don't have smartctl enabled disks should not show up. Gateway shouldn't even be a host on the disks page."
+**Story:** n/a
+
+## Objective
+
+One API-level helper decides disk-page visibility: a host entry is shown when its payload's `smartctl_available` is **not explicitly `False`** — `True` (a disk answered) and `None` (nobody knows: an agent predating the pass, `--no-smart`, or a never-answered stub) stay, `False` (the agent said the binary is not installed) does not. The gate runs over the assembled per-host list in both endpoints, so `fleet_disk_summary()` (which sums whatever it is handed) sees only the visible hosts and its `hosts_total`/`hosts_live` count exactly the page's rows. `sources/disks.py` keeps the three-way flag verbatim — the agent still reports it and the `None`-case why-line still renders.
+
+- ASSUMPTION: **explicit `False` only** is the hide condition. `None` stays visible (an agent that cannot say is not an agent that said *no* — the three-way flag's whole point), and an `unreachable` stub stays visible (invariant 3's rule: a dead agent must not read as "never configured"; its `smartctl_available` is `None` by `_disks_stub`'s shape anyway). The operator's words — "don't have smartctl **enabled**" / "shouldn't **even be a host**" — are the `false` arm; if a `None` host should also vanish, that is a one-clause change to the helper, not a redesign.
+
+## Work
+
+1. `src/dashboards/api.py` — add one small helper next to `_disks_stub` (line 771): `_disk_visible(host: Mapping) -> bool` — `host.get("smartctl_available") is not False` (the explicit-`False` check, spelled out so a `None` payload passes; a docstring naming the three arms).
+2. `src/dashboards/api.py` — `/api/disks/snapshot` (in `disks_router()`, line 791): filter the assembled per-host list through `_disk_visible` before the response is built (the response shape is unchanged — a shorter list, same entry shape).
+3. `src/dashboards/api.py` — the `/api/summary` disks block (lines 640–704): `disks_hosts` (line 640) is filtered through the same helper **before** `fleet_disk_summary(disks_hosts)` (line 693) and before the per-host row loop (lines 695–704), so the fleet counts and the rows agree with `/api/disks/snapshot` by construction — one helper, two call sites, no second copy of the predicate.
+4. `tests/test_api.py` — rewrite the affected pins: the `/api/disks/snapshot` "one entry per configured host" test gains a `smartctl_available: false` host (a `disks:` job whose payload the test harness holds, the `self_test`-style swap the `nut` job's pin uses) and asserts it is **absent** while a `None` host and an `unreachable` stub are **present**; the `/api/summary` disks-block pin asserts the fleet counts over the visible hosts only (e.g. three configured hosts, one `false` → `hosts_total: 2`); the "without targets configured" leg stays green untouched.
+5. `tests/` — locate the `smartctl_available` three-way pin (grep `smartctl_available` in `tests/`; `tests/test_disks.py` or the agent/disks source tests) and confirm it is **untouched** by this change (the flag's computation is unchanged; only the API's reading of it gained a gate).
+
+## Testing & Quality
+- Unit/integration: `tests/test_api.py` — the pins above, both directions (a `false` host absent from **both** endpoints; a `None`/`unreachable` host present in both); the unchanged `fleet_disk_summary` unit tests in `tests/` (the function is untouched — it sums what it is handed)
+- Coverage: **>90%** on this task's new/modified code
+
+## Completion Criteria
+- [ ] `/api/disks/snapshot` and `/api/summary` agree on the visible host set, and neither lists a `smartctl_available: false` host (pinned)
+- [ ] a `None` host and an `unreachable` stub both render in both endpoints (pinned)
+- [ ] `fleet_disk_summary` counts exactly the visible hosts (pinned)
+- [ ] `uv run pytest` green, `ruff` + `pyright` clean
+- [ ] full test suite green
+- [ ] no behavior change in completed work: `sources/disks.py`, the `disks:` job, the stored `disk.*` series and the health records are byte-identical in behaviour
diff --git a/.agents/phases/todo/22_disks_hide_unconfigured/02_verification.md b/.agents/phases/todo/22_disks_hide_unconfigured/02_verification.md
new file mode 100644
index 0000000..fecc4f9
--- /dev/null
+++ b/.agents/phases/todo/22_disks_hide_unconfigured/02_verification.md
@@ -0,0 +1,25 @@
+# Task 02 — Live verification: gateway gone, the rest intact
+
+**Phase:** `22_disks_hide_unconfigured` · **Source:** `TODO.md:13` — "VMs that don't have smartctl enabled disks should not show up. Gateway shouldn't even be a host on the disks page."
+**Story:** n/a
+
+## Objective
+
+The gate is proven on the live fleet, read-only: the dev server (invariant 10: `./scripts/dev-server.sh restart`, sign in with `testpass`) answers `/api/health` with all nine sources `ok`, the `/disks` page shows deskwork and driveripper only, and every number that used to render still renders. No fleet change is made — this task dials the fleet and screenshots it.
+
+## Work
+
+1. `./scripts/dev-server.sh restart` — the dev server up on http://127.0.0.1:8090; `./scripts/dev-server.sh status` — the nine-source table: `host:driveripper`, `host:deskwork`, `host:gateway`, `disks:driveripper`, `disks:deskwork`, `disks:gateway`, `nut`, `llm:gufo`, `llm:vllm` all `ok` (gateway's **agent** still answers — the job is `ok`; only the *page* hides it, which is the point: the `disks:gateway` health record must not be touched by the API filter, and `status` is the proof).
+2. Signed-in `GET /api/disks/snapshot`: no `gateway` entry, `deskwork` and `driveripper` present with their `smartctl_available: true` payloads; `GET /api/summary`: the disks block's `hosts_total` is 2, the per-host rows are the two, and the `system` block still lists all three hosts (the filter is `/disks`-scoped only — a `system` host list that lost gateway would be the over-reach the pin must catch).
+3. Browser on `/disks` (invariant 10): the fleet header reads the two-host sum; gateway has no card and no why-line; deskwork's `nvme0n1` and driveripper's 16 drives render exactly as before the phase (the live SMART numbers from the fleet record); a screenshot in `data/logs/`.
+4. The `None`-arm check: no live host is currently `None` (all three agents post-date the SMART pass), so the `None` arm is proven by the task-01 pins only — state that in the notes rather than fabricating a live case.
+
+## Testing & Quality
+- Coverage: n/a (verification task; the gate's coverage is task 01's)
+- The read-only rule: no `POST` to the fleet, no `upsd` command, no unit change — a live test that writes is not a test (AGENTS.md testing policy)
+
+## Completion Criteria
+- [ ] `./scripts/dev-server.sh status` — nine sources `ok`, including `disks:gateway` (the job is healthy; only the page hides it)
+- [ ] `/api/disks/snapshot` has no gateway entry; `/api/summary`'s system block still lists gateway (screenshot or transcript in `data/logs/`)
+- [ ] the `/disks` page shows the two live hosts with their pre-phase numbers (screenshot in `data/logs/`)
+- [ ] the full suite + `bash .agents/validate.sh` green
diff --git a/.agents/phases/todo/23_llm_rate_responsiveness/00_phase.md b/.agents/phases/todo/23_llm_rate_responsiveness/00_phase.md
new file mode 100644
index 0000000..fcbe69a
--- /dev/null
+++ b/.agents/phases/todo/23_llm_rate_responsiveness/00_phase.md
@@ -0,0 +1,34 @@
+# Phase 23 — LLM rate responsiveness
+
+**Source:** `TODO.md` L8 — "The LLM monitor has a strange delay and a 'ramp up/ramp down' effect that makes it look innacurate. Data reporting should be accurate to as small a delta as possible."
+**Story:** n/a
+**Context:** `src/dashboards/app.py` line 122 (`build_hub` builds **one** `RateEstimator(span=settings.rate_window)` and hands it to both `host_jobs` (line 124) and `llm_jobs` (line 130)), `src/dashboards/config.py` lines 197–199 (`llm_interval` 2 s, `llm_timeout` 3 s, `rate_window` 10 s), `src/dashboards/collectors.py` line 477 (`llm_jobs(settings, client, rates)` — the estimator is a parameter, so a second one is a wiring change, not a signature redesign), `src/dashboards/rates.py` (line 55 `MIN_WINDOW_SECONDS = 0.5`, line 61 `MAX_WINDOW_RATIO = 2.0`, the phase-13 jump gate — all untouched by this phase), `src/dashboards/web/static/page-llm.js` (line 39 `pollMs: 2000`, the `Δ` labels printed from the payload's `window`), `src/dashboards/sources/llm.py` (the rate cells carry `computed:Δ/Δt` + the honest `Δ`; the window the estimator *used* travels with the value — rule 2), the mechanism the TODO describes (gap 9 / phase 13 in AGENTS.md: a 10 s window over a 2 s poll ramped the hero up over ~5 polls when a slot started and decayed over ~5 polls when it ended — the "ramp up/ramp down" the operator sees; the sub-8× skew family and the stall are **already** closed by phases 13/14 and stay closed: this phase changes the window size, nothing else)
+
+## Objective
+
+The LLM throughput rates are measured over a window as small as the app's own cadence allows — the LLM source gets its **own** `RateEstimator` whose span is the LLM poll interval (2 s), so a slot start reads steady within two polls instead of ramping over ten, while the `host` source keeps the 10 s `RATE_WINDOW_SECONDS` window its 10 s cadence needs (a 2 s window over a 10 s poll would refuse every window — the estimator's floor and ceiling would answer `n/a` forever). The phase-13 protections (the 0.5 s floor, the `2 × span` ceiling, the 8× jump gate with its independent-anchor confirmation) apply to the smaller window unchanged; the `Δ` label prints the smaller window it actually used, so the number always says what it is.
+
+## Dependencies
+- — (none) — the phase touches `app.py`/`collectors.py` wiring and the LLM page; it is scheduled after `22_disks_hide_unconfigured` only to keep the fleet-changing phase 24 last.
+
+## Tasks
+1. `01_llm_window_per_poll.md` — the second estimator: `build_hub` hands `llm_jobs` a `RateEstimator(span=settings.llm_interval)`, the host keeps the 10 s one
+2. `02_window_pins.md` — the pins: LLM rate cells label the 2 s window, host cells keep 10 s, the phase-13 gates still hold over the smaller window
+3. `03_live_verification.md` — the live proof: a slot start reads steady within two polls (no 10 s ramp), the `Δ2.0s` label travels with the value, screenshots
+
+## Testing & Quality
+- Unit/integration: `tests/test_llm.py` (or wherever the `parse_gufo_metrics`/`parse_vllm_metrics` tests live — grep `rate_window`/`RateEstimator` in `tests/` first) — the window-value pins for both sources, the phase-13 tests (floor refusal, ceiling, jump gate, the stale-anchor tests) run **unchanged** and green; `tests/test_api.py`'s `/api/llm/snapshot` shape pins stay green (the shape is unchanged — only the `window` values move)
+- Coverage: **>90%** on new/modified code
+- The phase is "Complete" only when the **entire** project test suite passes 100% (every test in the project, not just this phase's)
+
+## Documentation & Consistency
+
+- The final pass reviews `README.md` and `AGENTS.md` in full against the project as it stands after this phase — fixing every inconsistency found, **including pre-existing ones not caused by this phase**. AGENTS.md's `app.py`/`poller.py` bullets (the "one `RateEstimator`" sentence) and `rates.py`'s docstring context gain the two-estimator fact; gap 9's "the estimator no longer turns a slow fetch into 0.2 s arithmetic" history stays — this phase is the window-size decision on top of it, and the `RATE_WINDOW_SECONDS=10` line in `.env.example` stays (it is the host's knob now, and gains a comment saying so if it has none).
+
+## Completion Criteria
+- [ ] `/api/llm/snapshot` rate cells carry a `window` ≈ 2 s (the label prints `Δ2.0s` on the page) and `/api/system/snapshot` rate cells keep ≈ 10 s (pinned both ways)
+- [ ] the phase-13 tests (floor, ceiling, jump gate, stale anchor) pass unchanged
+- [ ] live: a slot start reads steady within two polls and a slot end decays within two — the 10 s ramp is gone (screenshots + the stored 1 m rollup in `data/logs/`)
+- [ ] `uv run pytest` green, coverage not down, `ruff` + `pyright` clean
+- [ ] `bash .agents/validate.sh` green
+- [ ] no behavior change in completed work: the host rates, the estimator's gates, the rollups and the `Δ` labelling are all unchanged in kind
diff --git a/.agents/phases/todo/23_llm_rate_responsiveness/01_llm_window_per_poll.md b/.agents/phases/todo/23_llm_rate_responsiveness/01_llm_window_per_poll.md
new file mode 100644
index 0000000..172bf5a
--- /dev/null
+++ b/.agents/phases/todo/23_llm_rate_responsiveness/01_llm_window_per_poll.md
@@ -0,0 +1,28 @@
+# Task 01 — The LLM source gets its own 2 s-window estimator
+
+**Phase:** `23_llm_rate_responsiveness` · **Source:** `TODO.md:8` — "The LLM monitor has a strange delay and a 'ramp up/ramp down' effect that makes it look innacurate. Data reporting should be accurate to as small a delta as possible."
+**Story:** n/a
+
+## Objective
+
+`build_hub` builds **two** estimators: the host's keeps `RateEstimator(span=settings.rate_window)` (10 s — its 10 s cadence needs the wide window; a 2 s span over a 10 s poll would sit the window-empty fallback against the `2 × span` ceiling and answer `n/a` every poll), and the LLM's is `RateEstimator(span=settings.llm_interval)` (2 s — one poll interval: the smallest window the 2 s cadence can honestly fill). `llm_jobs` (which already takes the estimator as a parameter, `collectors.py` line 477) receives the LLM one; `HostCollector` keeps the 10 s one. No new env var: the LLM window **tracks** `LLM_INTERVAL_SECONDS`, so the one existing knob stays the single source of truth for the LLM cadence-and-window pair.
+
+- ASSUMPTION: **no new env var** — `RATE_WINDOW_SECONDS` (10, `.env.example` line 85) becomes the host's knob and the LLM window is `llm_interval` by construction. A 2 s window is 4× above the 0.5 s `MIN_WINDOW_SECONDS` floor, so the floor never engages on a healthy cadence; the 8× jump gate stays engaged (a real burst from idle still holds one poll for confirmation), and the poll cadence itself is **unchanged** — gufo and vLLM see exactly the same request rate as before (the TODO asks for a smaller *delta*, not more load on the engines).
+
+## Work
+
+1. `src/dashboards/app.py` — `build_hub` (lines 122–130): `rates` becomes `host_rates = RateEstimator(span=settings.rate_window)`; new `llm_rates = RateEstimator(span=settings.llm_interval)`; `host_jobs(..., HostCollector(host_rates), ...)` unchanged in shape; `llm_jobs(settings, client, llm_rates)` takes the new one. The two estimators are keyed on disjoint series names (rule 2's key is the resolved name + label set), so one shared object was never load-bearing — but the docstring/comment at line 122 (if it says "one estimator for all sources") is rewritten to the two-estimator shape.
+2. `src/dashboards/collectors.py` — line 477: no signature change expected (the parameter is already there); verify the LLM jobs thread `rates` through to `parse_gufo_metrics`/`parse_vllm_metrics` (they already take it, `sources/llm.py` lines 189/243) — if any LLM path constructs its own estimator, it takes the injected one (one estimator per source, the phase's invariant).
+3. `src/dashboards/config.py` — no change expected (the knobs exist); if a comment on `rate_window` (line 199) implies it is the one global knob, it gains the "host's knob; the LLM window is `llm_interval`" clause.
+4. `tests/` — the `build_hub`-level test (grep `build_hub` in `tests/` — the `tests/test_api.py` wiring pins) gains one assertion pair: the estimator the LLM jobs receive has `span == settings.llm_interval` and the host collector's has `span == settings.rate_window` (spy the two objects, the way the `agent_tls_map` pin spies its entry).
+
+## Testing & Quality
+- Unit/integration: the wiring pin above; the existing LLM parse tests (fixture-driven, `tests/fixtures/`) run with the injected estimator — their assertions on the `window` value, if any hard-code 10.0, are updated to the new 2.0 (grep `10.0`/`window` in `tests/test_llm.py` first); the host parse tests keep 10.0
+- Coverage: **>90%** on this task's new/modified code
+
+## Completion Criteria
+- [ ] `build_hub` hands the LLM jobs a 2 s-span estimator and the host a 10 s-span one (pinned by the spy)
+- [ ] the fixture-driven LLM tests pass with the 2 s window; the host tests keep 10 s
+- [ ] `uv run pytest` green, `ruff` + `pyright` clean
+- [ ] full test suite green
+- [ ] no behavior change in completed work: the host's measured rates, the estimator's gates and the engines' poll cadence are untouched
diff --git a/.agents/phases/todo/23_llm_rate_responsiveness/02_window_pins.md b/.agents/phases/todo/23_llm_rate_responsiveness/02_window_pins.md
new file mode 100644
index 0000000..057bdc3
--- /dev/null
+++ b/.agents/phases/todo/23_llm_rate_responsiveness/02_window_pins.md
@@ -0,0 +1,27 @@
+# Task 02 — The pins: 2 s LLM window, 10 s host window, gates intact
+
+**Phase:** `23_llm_rate_responsiveness` · **Source:** `TODO.md:8` — "…Data reporting should be accurate to as small a delta as possible."
+**Story:** n/a
+
+## Objective
+
+The window decision is pinned on the surface that shows it, in both directions (the repo's testing-policy rule): a LLM rate cell answers its `window` ≈ the LLM interval with the `Δ` label traveling with the value, a host rate cell keeps ≈ 10 s, and the phase-13 gate tests — the 0.5 s floor refusal, the `2 × span` ceiling, the 8× jump hold with its independent-anchor confirmation, and the stale-scrape-body regression — run **unchanged** and green, because they are estimator properties, not window-size properties.
+
+## Work
+
+1. `tests/test_llm.py` (or the file the LLM parse tests live in — grep first) — the two window pins: (a) a gufo metrics scrape driven through `parse_gufo_metrics` with a `RateEstimator(span=2.0)` (the `build_hub` shape, task 01's) answers the rate cell with a `window` in `(0.5, 2.0]` on a healthy 2 s cadence and the cell's `source` string still reads `computed:Δ/Δt`; (b) the same test shape with `span=10.0` on the host's series (or the existing host fixture tests) keeps its `window` ≈ 10 s — the two sources' windows are **different numbers in the same payload shape**, and a pin that asserts only one of them is the gap this task closes.
+2. `tests/test_rates.py` (or wherever the phase-13 tests live) — run them unchanged: the floor test (a sub-0.5 s window answers `(None, dt)`), the ceiling test (`2 × span`), the jump-gate test (≥8× holds one poll, publishes on a *different-anchor* confirmation) and the two stale-body regression tests. If a test constructs its estimator with an explicit `span` that now disagrees with the app's shape, it is **not** edited to match — the estimator's constants are app-independent and the tests' own spans are theirs (the app's shape is task 01's pin's job).
+3. `tests/test_api.py` — the `/api/llm/snapshot` shape pins stay green (the shape is unchanged; only `window` values move). If a pin hard-codes a `window` number in the LLM payload, it is the one place it is updated — to a range assertion (`0.5 < window <= 2.0 × llm_interval`), not a literal, so a future cadence change does not rot it.
+4. `tests/test_config.py` — confirm `rate_window`'s pin (the 10 s default) stays; no new setting was added, so no new pin is needed (the "no new env var" ASSUMPTION is what the absence of a new setting is).
+
+## Testing & Quality
+- Unit/integration: the four pin groups above
+- Coverage: **>90%** on this task's new/modified code
+
+## Completion Criteria
+- [ ] a LLM rate cell's `window` is pinned to the 2 s shape and its label prints `Δ2.0s` (or the honest sub-window when the floor engages)
+- [ ] a host rate cell's `window` is pinned to the 10 s shape
+- [ ] the phase-13 gate tests pass unchanged
+- [ ] `uv run pytest` green, `ruff` + `pyright` clean
+- [ ] full test suite green
+- [ ] no behavior change in completed work: the payload shapes, the `source` strings and the estimator constants are untouched
diff --git a/.agents/phases/todo/23_llm_rate_responsiveness/03_live_verification.md b/.agents/phases/todo/23_llm_rate_responsiveness/03_live_verification.md
new file mode 100644
index 0000000..2b88192
--- /dev/null
+++ b/.agents/phases/todo/23_llm_rate_responsiveness/03_live_verification.md
@@ -0,0 +1,27 @@
+# Task 03 — Live verification: the ramp is gone
+
+**Phase:** `23_llm_rate_responsiveness` · **Source:** `TODO.md:8` — "The LLM monitor has a strange delay and a 'ramp up/ramp down' effect that makes it look innacurate."
+**Story:** n/a
+
+## Objective
+
+The smaller window is proven on the live engines, read-only: with the dev server up (invariant 10) and a decode workload running on the engines, the `/llm` hero reaches steady state within **two** polls of the slot starting (pre-phase it ramped over ~five) and decays within two of it ending, every value carrying its `Δ2.0s` label, and the stored 1 m rollups staying in the fleet's measured band (gap 9's pass line: anything above ~300 tok/s is arithmetic, not work). No engine is written to — the workload is whatever the operator's engines are doing, or a deliberately short local decode the operator starts; a live test that loads a shared engine is not a test.
+
+## Work
+
+1. `./scripts/dev-server.sh restart`, sign in with `testpass`; `./scripts/dev-server.sh status` — the nine-source table `ok` (a restart also proves the two-estimator wiring survives a real boot, not just the in-memory test store).
+2. Signed-in polling of `/api/llm/snapshot` at 2 s (a short script, the `scripts/watch-store-perf.py` pattern): capture the decode tok/s cell's `value` + `window` for ~60 s while a slot is busy, then again for ~30 s after it frees. The pass shape: within two polls of the transition the value is within 8× of steady (the jump gate's own ratio — a held value is the designed behaviour, and a *confirmation* within the next poll is the honest publish; a value still ramping at poll 5 is the pre-phase defect returning).
+3. The `Δ` label check (gap 9's rule, restated): **zero** values printed without their `Δ` label; the label reads the window actually used (`Δ2.0s` on a healthy cadence, the honest sub-window — never a bare number — when the floor or the stall engages).
+4. The stored-rollup check (the phase-14 rule): the 1 m rollups of `llm.decode_tokens_per_second` for the window under test top out in the fleet's measured band (gap 9: 119.9 gufo / 171.7 vLLM were the real maxima; the 300 tok/s pass line stands) — a 1 m average set by a ramp would be the arithmetic this phase exists to remove.
+5. Screenshots of `/llm` mid-burst and post-free in `data/logs/` (invariant 10), plus the captured series as a TSV beside them (the `data/logs/llm-decode-*.tsv` precedent).
+
+## Testing & Quality
+- Coverage: n/a (verification task; the pins are task 02's)
+- The read-only rule: no `POST` to the fleet, no `upsd` command, no engine write — observing a workload the engines are already doing is the test
+
+## Completion Criteria
+- [ ] a slot start reads steady (within 8× of steady) within two polls, and a slot end decays within two — the 10 s ramp is gone (captured series in `data/logs/`)
+- [ ] zero values printed without their `Δ` label, the label reading the window actually used
+- [ ] the 1 m rollups stay in the measured band (nothing above the 300 tok/s pass line)
+- [ ] screenshots in `data/logs/`
+- [ ] the full suite + `bash .agents/validate.sh` green
diff --git a/.agents/phases/todo/24_dev_mtls_rollout/00_phase.md b/.agents/phases/todo/24_dev_mtls_rollout/00_phase.md
new file mode 100644
index 0000000..54a677a
--- /dev/null
+++ b/.agents/phases/todo/24_dev_mtls_rollout/00_phase.md
@@ -0,0 +1,36 @@
+# Phase 24 — Dev mTLS: no non-mTLS agent, ever
+
+**Source:** `TODO.md` L3 — "The client should never allow non-mtls communication. deskwork and gateway are still non-mtls according to the dashboard. During dev, the dev server should generate certs in a non-committed directory and then distribute them to clients during registration so we never need non-mtls servers."
+**Story:** n/a
+**Context:** phase 15 (complete) — the durable CA in `meta` (`certs.py`: five `certs.*` rows, the bundle stored verbatim), the agent's TLS mode (`agent/host_agent.py` 1.1.0: `--cert-bundle`, `CERT_OPTIONAL`, the live `# REVOKED` denylist, `agent.tls`/`agent.bundle_rev` in the snapshot), the collector's certificate dial (`collectors.py` `AgentTls` — an `https://` `HOST_TARGETS` entry dials with this app's client certificate), the bundle download route (`GET /api/certs//bundle`, session-gated, the stored bytes verbatim), the installer's `--cert-bundle` shape check (`deploy/install-agent.sh` lines 270–331 — the python3 validator runs **before** `need_root`, the bundle installs `0640 root:dashboards-agent` at line 385, the `--status` https probe), and the fleet record (AGENTS.md gap 11: no bundle is installed on any host, `HOST_TARGETS` is `http://` for every host, the installed agents run 1.0.0 so **1.1.0 ships together with the bundle, never the bundle alone**; gap 10: driveripper's unit is reproducible only with the repo script's `--as-root --caps CAP_SYS_RAWIO CAP_SYS_ADMIN --force` line — the Oct-18 sudo-granted snapshot must not be run; deskwork's `ops` has no installer grant, so its install goes through the operator's root). `scripts/dev-server.sh` (the dev server: reads `.env.dev`, nohup `dashboards`, `/api/health` readiness probe, `status` subcommand) and `data/` (gitignored — the non-committed directory the TODO names)
+
+## Objective
+
+The dev loop stops producing `http://` agents. On start, the dev server exports every configured host's stored bundle to non-committed `data/certs/` (the CA is already durable in `meta` — "generate" means *materialise what the first boot minted*, never a second authority), and the installer gains `--fetch-bundle` so a client's **registration** — its install — pulls its bundle from the dashboard and installs it in the same run (the operator no longer curls a file across the network by hand). Then the fleet itself moves: agent 1.1.0 + bundle on deskwork, driveripper and gateway, `HOST_TARGETS` flipped to `https://` host by host (a URL flipped before its bundle is installed turns that host red by construction — the order is the safety), leaving the dashboard's nine sources `ok` with three `agent.tls: true` hosts and no `http://` agent URL anywhere.
+
+## Dependencies
+- — (none) — the phase builds on complete phase 15 and phase 17; it is scheduled last on purpose, because it is the fleet-changing one.
+
+## Tasks
+1. `01_dev_bundle_export.md` — `scripts/dev-certs-export.py` + `dev-server.sh`: the dev server writes every configured host's bundle to `data/certs/.bundle` (`0600`) on start; an unissued host says so and writes nothing
+2. `02_installer_fetch_bundle.md` — `install-agent.sh --fetch-bundle --cert-name --session `: registration pulls the bundle from the dashboard, the shape check runs on the fetched bytes before `need_root`, the token never reaches the token file or the unit
+3. `03_fleet_rollout.md` — agent 1.1.0 + bundle on the three hosts (deskwork via the operator's root, driveripper with the gap-10 `--force` line, gateway with its sudoers grant), `HOST_TARGETS` flipped per host, the URL never flipped before the bundle
+4. `04_verification.md` — the fleet is mTLS: `agent.tls`/`deployed_bundle_rev` agree for all three hosts, nine sources `ok`, no `http://` agent URL, the 16-driver SMART reads still live, screenshots
+
+## Testing & Quality
+- Unit/integration: `tests/test_deploy_scripts.py` — the export helper against a `create_app(...)` hosted on a socket (the sandbox pattern the installer tests already use: fake `install`/`chown` where the installer runs); the `--fetch-bundle` legs (a good bundle fetched, installed and byte-identical; a malformed one refused **before any write**; the token absent from every written artefact)
+- Coverage: **>90%** on new/modified code
+- The phase is "Complete" only when the **entire** project test suite passes 100% (every test in the project, not just this phase's)
+
+## Documentation & Consistency
+
+- The final pass reviews `README.md` and `AGENTS.md` in full against the project as it stands after this phase — fixing every inconsistency found, **including pre-existing ones not caused by this phase**. The README's *Protecting an agent, host by host* rollout becomes the `--fetch-bundle` recipe (the manual curl is demoted to the fallback), the dev section gains the `data/certs` export, AGENTS.md's fleet record and gap 11 resolve to "all three hosts mTLS since " with the exact installed units, and the "bearer-token transport is the compatibility path, not the designed one" sentence becomes history. **No token, no PEM and no session value go into any tracked file** — the exported bundles live in gitignored `data/`, the fleet's `HOST_TARGETS` URLs carry no userinfo once flipped (invariant 6), and the task transcript names hosts, not credentials.
+
+## Completion Criteria
+- [ ] `./scripts/dev-server.sh restart` leaves `data/certs/.bundle` for every configured host that has an issued bundle, `0600`, byte-identical to the stored row, and a one-line note for any unissued host (pinned + verified)
+- [ ] `sudo deploy/install-agent.sh --fetch-bundle --cert-name --session ...` installs a byte-identical bundle in the sandbox, refuses a malformed one before `need_root`, and leaves no token in the token file, the unit or the log (pinned)
+- [ ] all three hosts run agent 1.1.0 from an installed bundle, `HOST_TARGETS` is `https://` for every host with no userinfo, and `./scripts/dev-server.sh status` reads nine sources `ok` with `agent.tls: true` and `deployed_bundle_rev == bundle_rev` on all three (screenshots of `/certs` in `data/logs/`)
+- [ ] driveripper's 16 drives still read live on `/disks` after the `--force` unit rewrite (the gap-10 unit shape is reproduced exactly)
+- [ ] `uv run pytest` green, coverage not down, `ruff` + `pyright` clean
+- [ ] `bash .agents/validate.sh` green
+- [ ] no behavior change in completed work: the token-mode path, the revocation wiring, the bundle route and the agent's token-mode bytes are all unchanged
diff --git a/.agents/phases/todo/24_dev_mtls_rollout/01_dev_bundle_export.md b/.agents/phases/todo/24_dev_mtls_rollout/01_dev_bundle_export.md
new file mode 100644
index 0000000..a147755
--- /dev/null
+++ b/.agents/phases/todo/24_dev_mtls_rollout/01_dev_bundle_export.md
@@ -0,0 +1,27 @@
+# Task 01 — The dev server exports the bundles to `data/certs/`
+
+**Phase:** `24_dev_mtls_rollout` · **Source:** `TODO.md:3` — "During dev, the dev server should generate certs in a non-committed directory and then distribute them to clients during registration…"
+**Story:** n/a
+
+## Objective
+
+On start, the dev server materialises the **stored** CA's per-host bundles in non-committed `data/certs/`: one `0600` file per configured host with an issued bundle (`data/certs/.bundle`, byte-identical to the `meta` row the bundle route serves — the CA is durable in `meta`, so "generate" is an export of what the first boot already minted, never a second authority; `data/` is gitignored, so nothing committed ever carries a key). A configured host with **no** issued bundle (the invariant-3 shape: `issued: false`) writes no file and prints one line naming the host and the `/certs` route that issues it. The export is a small standalone script the dev server calls, so the test suite drives it against a `create_app(...)` hosted on a socket without a nohup'd process.
+
+## Work
+
+1. `scripts/dev-certs-export.py` — a standalone helper (stdlib `urllib`, the way `install-agent.sh`'s validator uses `python3`): arguments `--url` (the dev server's base URL), `--password` (the sign-in password — the dev server reads it from the same `.env.dev` it already loads, so the value comes from the environment, never a tracked file), `--names` (the configured host names, space-separated — read from `HOST_TARGETS`' names, the same parsing `parse_targets` does, passed in by the caller rather than re-parsed inside), `--out-dir` (defaults to `/data/certs`). Behaviour: sign in via `POST /api/auth/login` (the public route; the token travels in `Authorization` for the bundle GETs), then per name `GET /api/certs//bundle` — a `{"configured": true, "issued": false}` answer writes **no file** and prints the one-line note; a configured host that 404s (`{"configured": false}`) is impossible by construction (names come from `HOST_TARGETS`) but is handled as the same no-file line, not a crash; a good bundle is written `/.bundle` at `0600` (created via `os.open`, not `open()`, so the mode is the file's birth mode). The script exits 0 when every name resolved to either a file or the no-issued note, and non-zero on a sign-in failure — a dev server that cannot sign in says so in `dev-server.sh`'s start output, never silently.
+2. `scripts/dev-server.sh` — in `start()`, after the readiness probe: call the export (`.venv/bin/python scripts/dev-certs-export.py --url http://127.0.0.1:$PORT --password "$DASH_ADMIN_PASSWORD" --names --out-dir $ROOT/data/certs`), printing its per-host lines; in `status`, print the `data/certs/` listing (names + sizes) as the "bundles exported" block. The export failure is a **warning line**, not a `start` failure — a bundle export that cannot sign in is a dev-convenience failure, and the server itself is up (the readiness probe already proved it); the one line says what to check.
+3. `tests/test_deploy_scripts.py` — the pin: host `create_app(settings with admin_password="testpass", pbkdf2_iterations=1000, one or two configured targets, an in-memory store with an issued bundle for one host and none for the other)` on `127.0.0.1:0` via `uvicorn` (the project's own dependency — `__main__.py`'s pattern), run the script against it, and assert: the issued host's file exists at `0600` and is **byte-identical** to `GET /api/certs//bundle` from the same app; the unissued host wrote **no file** and the output carries its one-line note; a wrong `--password` exits non-zero and writes nothing.
+
+## Testing & Quality
+- Unit/integration: the pin above (the sandbox pattern `tests/test_deploy_scripts.py` already uses for the installer)
+- Coverage: **>90%** on this task's new/modified code
+- Real-boot check: `./scripts/dev-server.sh restart` on the dev box prints the per-host export lines and `data/certs/` holds the files (the dev box is the test bed — invariant 10)
+
+## Completion Criteria
+- [ ] `./scripts/dev-server.sh restart` leaves `data/certs/.bundle` per issued host, `0600`, byte-identical to the stored row; an unissued host is a one-line note and no file (pinned + verified on the dev box)
+- [ ] `status` prints the exported-bundles block
+- [ ] no bundle, key or PEM is ever written outside `data/` (the gitignored tree) — a grep of the tracked files for the export's output path confirms it lands only under `data/`
+- [ ] `uv run pytest` green, `ruff` + `pyright` clean
+- [ ] full test suite green
+- [ ] no behavior change in completed work: the dev server's start/stop/status semantics, the readiness probe and the CA rows in `meta` are untouched (an export never mints — `certs.read_state` is the request-time read the bundle route uses, and the export dials that route, not `ensure_certs`)
diff --git a/.agents/phases/todo/24_dev_mtls_rollout/02_installer_fetch_bundle.md b/.agents/phases/todo/24_dev_mtls_rollout/02_installer_fetch_bundle.md
new file mode 100644
index 0000000..907a4d2
--- /dev/null
+++ b/.agents/phases/todo/24_dev_mtls_rollout/02_installer_fetch_bundle.md
@@ -0,0 +1,29 @@
+# Task 02 — `install-agent.sh --fetch-bundle`: registration pulls the bundle
+
+**Phase:** `24_dev_mtls_rollout` · **Source:** `TODO.md:3` — "…then distribute them to clients during registration so we never need non-mtls servers."
+**Story:** n/a
+
+## Objective
+
+The installer's registration moment — the install — is where the client gets its bundle: `--fetch-bundle --cert-name --session ` fetches `GET /api/certs//bundle` from the dashboard **in the install run**, the existing python3 shape check (lines 270–331, which runs before `need_root`) validates the **fetched bytes**, and the rest of the `--cert-bundle` path (the `0640 root:dashboards-agent` install at line 385, the unit's `--cert-bundle` + `RuntimeDirectory` lines, the `--status` https probe) runs on them unchanged. The session token is a **fetch credential only**: it never reaches the agent's token file, the unit, the log or the installed bundle, and the fetch happens before `need_root` (a host whose dashboard refuses the fetch never gets a partial install — the same order the bundle validator already has).
+
+- ASSUMPTION: the session travels on **argv** (`--session `), matching how the operator already signs in for the manual curl the README's rollout documents; the token is visible in the `sudo` process list for the fetch's duration, which is the accepted cost on a single-admin box (the alternative — a `0600` session file — is a second artefact to clean and the operator's muscle memory is the curl recipe). The three flags are **all-or-nothing**: any one without the others is refused with a usage line before `need_root` and before any write.
+
+## Work
+
+1. `deploy/install-agent.sh` — argument parsing (near line 136): `--fetch-bundle) FETCH_URL="$2"; shift 2 ;;`, `--cert-name) CERT_NAME="$2"; shift 2 ;;`, `--session) SESSION_TOKEN="$2"; shift 2 ;;`; the all-or-nothing check (the three set together or not at all; `--fetch-bundle` and a local `--cert-bundle ` together is refused — two bundle sources are a contradiction, pick one) in the same pre-`need_root` region the bundle validator occupies (lines 160–331).
+2. `deploy/install-agent.sh` — the fetch: a `python3` `urllib` block (the script already requires `python3` for the shape check — no new dependency, no `curl` assumption): `GET $FETCH_URL/api/certs/$CERT_NAME/bundle` with `Authorization: Bearer $SESSION_TOKEN` written to a `mktemp` file; the response body's shape is checked in the same block — a `{"configured": true, "issued": false}` body is refused with its own one-line reason (*"no bundle issued for — issue it from /certs first"*), a `404`/`401`/`403` is refused naming the HTTP status, and only a 200 with a non-empty body falls through to the **existing** shape check (lines 270–331) run on the temp file; on any refusal the temp file is deleted (`trap`-covered, the way the script already cleans its unit temp file) and nothing is written. On success the temp file is handed to the existing `BUNDLE_SRC` path — the `0640` install, the unit lines and the `--status` probe all run unchanged on the fetched bytes.
+3. `deploy/install-agent.sh` — token hygiene: `SESSION_TOKEN` is used only in the fetch block; it is never `echo`d (the script's existing log lines name the URL, never the header), never written to `$TOKEN_FILE`/`$CERT_FILE`/the unit, and is `unset` after the fetch; the `--help` block gains the three flags with the one-line recipe (the URL is the dashboard's, the name is the `HOST_TARGETS` name, the session is the signed-in token).
+4. `tests/test_deploy_scripts.py` — the pins (the sandbox pattern: fake `install`/`chown`, a local `http.server` thread serving the bundle route's exact bytes — the `application/x-pem-file` attachment body a `create_app` bundle route would serve): (a) a good fetch → the installed `CERT_FILE` is **byte-identical** to the served bytes, `0640`, and the unit carries the `--cert-bundle` line; (b) a served `{"configured": true, "issued": false}` body → refusal with the one-line reason, **no** `install` call (the fake records it), temp file gone; (c) a served 401 → refusal naming the status, no write; (d) the **token absence** pin: the token string appears in no written artefact — not the token file, not the unit, not the script's captured stdout (the test serves the bundle with a token it chose and greps the sandbox for it); (e) the all-or-nothing and the two-bundle-source refusals, both before `need_root` (the sandbox's root check records the call order).
+
+## Testing & Quality
+- Unit/integration: the five pin legs above
+- Coverage: **>90%** on this task's new/modified code (the gate applies to the changed test file)
+
+## Completion Criteria
+- [ ] `sudo deploy/install-agent.sh --fetch-bundle --cert-name --session --bind … --port …` installs a byte-identical bundle in the sandbox, `0640`, with the unit's TLS lines (pinned)
+- [ ] a malformed/missing/401 bundle is refused before `need_root` with its own one-line reason and no partial write (pinned)
+- [ ] the session token appears in no written artefact — token file, unit, or captured stdout (pinned)
+- [ ] `uv run pytest` green, `bash .agents/validate.sh` green
+- [ ] full test suite green
+- [ ] no behavior change in completed work: the local `--cert-bundle ` path, the `--status` probe, the re-run guard (task-17's diff-and-refuse) and the token file's mode are byte-identical in behaviour to before
diff --git a/.agents/phases/todo/24_dev_mtls_rollout/03_fleet_rollout.md b/.agents/phases/todo/24_dev_mtls_rollout/03_fleet_rollout.md
new file mode 100644
index 0000000..eececfe
--- /dev/null
+++ b/.agents/phases/todo/24_dev_mtls_rollout/03_fleet_rollout.md
@@ -0,0 +1,30 @@
+# Task 03 — The fleet goes mTLS, host by host
+
+**Phase:** `24_dev_mtls_rollout` · **Source:** `TODO.md:3` — "The client should never allow non-mtls communication. deskwork and gateway are still non-mtls according to the dashboard."
+**Story:** n/a
+
+## Objective
+
+All three agents run 1.1.0 from an installed bundle, and `HOST_TARGETS` is `https://` for every host. The order per host is the safety (gap 11's rule: **a URL flipped before its bundle is installed turns that host red by construction**): install agent 1.1.0 + bundle → restart the unit → the `--status` CA-only https probe answers → **then** the `HOST_TARGETS` line flips → the dev server restarts → `agent.tls` shows. 1.1.0 ships **together with** the bundle, never the bundle alone (the installed agents are 1.0.0 — gap 11's re-check). Hosts in order: gateway (the trivial one: `(ALL) NOPASSWD: ALL`, a VM with the standard unit), deskwork (no `ops` installer grant — its root half is the operator's action), driveripper (the gap-10 line, the one that must not be the Oct-18 snapshot).
+
+- ASSUMPTION: the executor has **ssh as `ops`** from the dev box (the fleet record's probes ran from this laptop; the repo's dev workflow dials the fleet from here). If ssh is unavailable in the run environment, the task degrades to its written form: the exact command blocks are printed for the operator, the operator applies them, and task 04's verification runs against the applied fleet — the pass criteria are the same either way.
+- ASSUMPTION: the flipped URL is **clean `https://:9120` with no userinfo** — the TLS handshake is the auth, the token file line stays in the unit (the rollback path), and invariant 6's "no token in a tracked file" is satisfied by `.env.dev` itself being gitignored with the token now out of the URL. **Rollback** per host = restore the pre-flip URL line from the timestamped backup copy (made in `data/env-backup/` before any flip) and restart the dev server — the bundle stays installed, the unit keeps both its token file and the bundle line.
+
+## Work
+
+1. **Prep (dev box):** `./scripts/dev-server.sh restart` (task 01's export leaves `data/certs/` populated); sign in, note the session token (it lives only in the operator's shell and the task transcript's redaction, never in a file); `cp .env.dev data/env-backup/.env.dev-` (gitignored).
+2. **gateway** (`10.1.1.27:9120`, the standard unit): over ssh as `ops` — copy the repo's `agent/host_agent.py` (1.1.0) to `/usr/local/lib/dashboards-agent/host_agent.py`, then `sudo deploy/install-agent.sh --bind 10.1.1.27 --port 9120 --fetch-bundle http://127.0.0.1:8090 --cert-name gateway --session ` (the fetch dials the **dev server on the dev box** — the dashboard's bundle route; on a host that cannot reach the dev box, the same flag with the dashboard's LAN URL, or task 01's exported file, is the fallback; the unit gains the `--cert-bundle` + `RuntimeDirectory` lines and keeps its `--token-file` line), `systemctl restart dashboards-agent`, `./deploy/install-agent.sh --status` (the CA-only https probe answers). **Then** flip gateway's `.env.dev` line to `https://10.1.1.27:9120`, `./scripts/dev-server.sh restart`, confirm `host:gateway`/`disks:gateway` `ok` with `agent.tls: true`.
+3. **deskwork** (`10.1.0.51:9120`, the operator's root half): `ops` has no installer grant on deskwork (the fleet record), so the executor prints the exact block — the agent-file copy, `sudo deploy/install-agent.sh --bind 10.1.0.51 --port 9120 --fetch-bundle --cert-name deskwork --session `, the restart, the `--status` probe — for the operator to run as root; the executor then verifies the unit state over ssh (`systemctl is-active`, `systemctl cat`), flips the URL line and confirms `host:deskwork`/`disks:deskwork` `ok` with `agent.tls: true` (if the operator's run and the flip land before the executor's verify, the verify is the check; the command block is the record).
+4. **driveripper** (`10.1.0.126:9120`, the gap-10 line): over ssh as `ops` — copy the repo's `agent/host_agent.py` (1.1.0) to `/usr/local/lib/dashboards-agent/host_agent.py`, then the **repo's** script with the gap-10 line exactly: `sudo deploy/install-agent.sh --bind 10.1.0.126 --port 9120 --as-root --caps CAP_SYS_RAWIO,CAP_SYS_ADMIN --force --fetch-bundle --cert-name driveripper --session ` (the `--force` is what lands the unit change — task 17's guard refuses a differing re-run without it, and the line reproduces the `User=root` + `CapabilityBoundingSet` unit that keeps all 16 SMART reads live; **never** the Oct-18 sudo-granted snapshot — it predates the flags and the guard), `systemctl restart dashboards-agent`, the `--status` probe, **then** flip the URL line and confirm `host:driveripper`/`disks:driveripper` `ok` with `agent.tls: true` and the 16 drives still reading (a `smart: null` row here is the `--force` rewrite having changed the unit — stop and diff the unit against the fleet record's shape before anything else).
+5. **Fleet state:** after all three flips, `.env.dev`'s `HOST_TARGETS` has no `http://` agent entry and no userinfo token; the three units still carry their `--token-file` lines (the rollback path); the transcript in `data/logs/` names hosts, ports and revs — no token, no PEM.
+
+## Testing & Quality
+- Coverage: n/a (fleet task; the pins are tasks 01–02's)
+- The read-only rule applies to the **verification** half: no `upsd` command, no engine write, no `POST /api/ups/command`; the installer's own writes are the change this task exists to make
+
+## Completion Criteria
+- [ ] all three units run the 1.1.0 agent file from an installed bundle (`systemctl cat` + `--status` CA-only probe per host, transcript in `data/logs/`)
+- [ ] `.env.dev`'s `HOST_TARGETS` is `https://` for all three hosts, no userinfo; the pre-flip backup exists in `data/env-backup/`
+- [ ] `./scripts/dev-server.sh status` — nine sources `ok` with `agent.tls: true` on all three (the per-host flips never left the fleet red longer than the host's own restart — the per-host order is the proof)
+- [ ] driveripper's 16 drives still read live on `/disks` after the `--force` rewrite
+- [ ] the full suite + `bash .agents/validate.sh` green
diff --git a/.agents/phases/todo/24_dev_mtls_rollout/04_verification.md b/.agents/phases/todo/24_dev_mtls_rollout/04_verification.md
new file mode 100644
index 0000000..850747a
--- /dev/null
+++ b/.agents/phases/todo/24_dev_mtls_rollout/04_verification.md
@@ -0,0 +1,29 @@
+# Task 04 — The fleet is mTLS: the end state, verified
+
+**Phase:** `24_dev_mtls_rollout` · **Source:** `TODO.md:3` — "…so we never need non-mtls servers."
+**Story:** n/a
+
+## Objective
+
+The end state is proven read-only, the way phase 15's task-05 final pass proved its composition: the dashboard dials all three agents with its client certificate, the issued-vs-deployed revisions agree, nothing non-mTLS is left in the config, and the things the bundle install must not have broken (driveripper's 16 SMART reads, deskwork's GPU row) still work. No live revocation is performed — the revocation wiring is phase 15's e2e proof (`data/logs/verify-15-tls-e2e.py`), and a live revoke of a working fleet is a change, not a check.
+
+## Work
+
+1. `./scripts/dev-server.sh status` — the nine-source table `ok` (`host:driveripper`, `host:deskwork`, `host:gateway`, `disks:driveripper`, `disks:deskwork`, `disks:gateway`, `nut`, `llm:gufo`, `llm:vllm`); `disks:gateway` is `ok` with the page hiding gateway (phase 22's gate) — the job healthy, the page scoped.
+2. Signed-in `GET /api/certs`: all three rows `status: "active"`, `deployed_bundle_rev == bundle_rev` (the "install the new bundle" sentence is gone for every host), `agent` block from the `system` payload: `agent.tls: true` + `agent.bundle_rev` present on all three (the task-03 passthrough — a token-mode snapshot grows nothing, so the fields' **presence** is the mTLS proof, rule 2's shape).
+3. Signed-in `GET /api/system/snapshot` + `GET /api/disks/snapshot`: the `agent` blocks on all three hosts; gateway's row absent from `/disks` (phase 22); driveripper's 16 drives with live SMART numbers (the `--force` unit rewrite is the gap-10 shape — a `smart: null` row here fails the task); deskwork's `nvme0n1` row and its GPU card on `/system` (the 1.1.0 agent file's reads intact).
+4. The config: `grep` of `.env.dev` — no `http://` in `HOST_TARGETS`, no `@` userinfo in any target URL; `data/env-backup/` holds the pre-flip copy (the rollback path exists on disk).
+5. The negative control, **without touching the fleet**: `data/logs/verify-15-tls-e2e.py`'s transcript (phase 15) is the standing proof that a revoked serial is refused on `/health` too; this task re-runs **none** of it live — it names the transcript as the control and checks only that `tests/test_collectors.py`'s revocation-before-dial pin and `tests/test_api.py`'s `build_hub` wiring pin are green (they run in the suite).
+6. Screenshots (invariant 10) in `data/logs/`: `/certs` (the three active rows with matching revs), `/disks` (the two visible hosts, driveripper's 16 live rows), `/system` (deskwork's GPU card) — the "the dashboard says all three are mTLS" proof is a screenshot, not a curl.
+
+## Testing & Quality
+- Coverage: n/a (verification task)
+- The read-only rule: no `POST` to the fleet, no `upsd` command, no revocation, no unit change — the task reads the fleet and screenshots it
+
+## Completion Criteria
+- [ ] nine sources `ok`; `deployed_bundle_rev == bundle_rev` and `agent.tls: true` for all three hosts (pinned by the API checks, shown by the screenshots)
+- [ ] `.env.dev` has no `http://` agent URL and no userinfo; the pre-flip backup exists
+- [ ] driveripper's 16 SMART reads and deskwork's GPU card are live in the served payloads
+- [ ] screenshots of `/certs`, `/disks`, `/system` in `data/logs/`
+- [ ] the revocation pins (`tests/test_collectors.py`, `tests/test_api.py`) green in the suite
+- [ ] the full suite + `bash .agents/validate.sh` green; AGENTS.md's fleet record and gap 11 updated by the phase's Documentation & Consistency pass to the mTLS end state