From 86c8bf49921e91a375fe5992db4bee7ba1eeecd1 Mon Sep 17 00:00:00 2001
From: ducoterra
Date: Sun, 1 Nov 2026 11:50:51 -0500
Subject: [PATCH] phase: 33_gateway_route_switching
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Phase 33 completion pass is done. Final report:
## Phase 33 — completion report
**All 8 task files complete** (`.agents/phases/complete/33_gateway_route_switching/`), full gate green, docs verified.
### Work in this pass
1. **`tests/test_routectl.py` added** (17 tests, all green) — exercises the *real* helper binary unprivileged against a fake tree (`--root`), the real agent parser, the captured gateway fixtures, and stubbed `nginx`/`haproxy`/`systemctl` on `PATH`. Pins the full protocol (locate → state → dry-run → temp-file/fsync/`os.replace` write → checker → restore on checker failure → reload-never-restart → re-read proof → sidecar record), all three route shapes (haproxy `server`, nginx `proxy_pass`, nginx stream `map` via its sink-value id), `drift` / unknown-id / `map-default` refusals, `list`/`verify`, and the `--serve` peer gate (`SO_PEERCRED` uid check, one JSON line per request, exact body shape).
2. **Real helper defect fixed** — `deploy/dashboards-routectl.py`: `main()`'s early-return paths returned `({"ok": False, …}, EXIT_*)` tuples, so `sys.exit()` printed a Python repr instead of a JSON line (usage errors, agent-import refusal). Now prints `json.dumps(…)` and returns only the code, keeping the one-JSON-line contract.
3. **Documentation consistency fixes** (task 08 criterion 6):
- `README.md`: garbled verdict-colour sentence on the switch ("green and to the position") → "in the **on** position and tinted `--gate-on`" (the existence-pair contract).
- `README.md`: audit sentence now names all three event kinds — `route-disabled` / `route-enabled` / **`route-denied`** (the code stores one per refused call; dry runs store none).
- `page-gateway.js`: replaced the stale pre-33 module docstring ("the switch is not wired") with the two-arm `GATEWAY_ALLOW_WRITES` contract.
- `AGENTS.md`: every phase-33 claim re-verified against code (the ten gates, the 35 s/30 s budget pair, `gateway.writes_enabled`, `/api/summary`'s `disabled`, the 1.4.0 agent bullet, the gap list) — no changes needed.
### Verification
- `uv run pytest --cov`: **1970 passed**, 18 skipped — coverage **98.74%** (floor 94%)
- `ruff` clean · `pyright` 0 errors · `bash .agents/validate.sh` **exit 0** (incl. `node --check` on every page script)
- Dev server running; all 10 sources `ok`; bundles exported
### Live infrastructure
**No live changes made.** The live drill remains unexecuted per `data/logs/NOTES-33-switching.md` (no operator approval; checker binaries unavailable in the podman proxy units). All routes stay enabled, config hashes unchanged, and the sandbox rehearsal + backup tree are in place for the operator's own `DANGER` call. All changes are in the working tree only.
---
.../33_gateway_route_switching/00_phase.md | 0
...the_root_helper_edits_exactly_one_route.md | 0
...ileged_peer_is_a_socket_not_a_sudo_line.md | 0
.../03_the_agent_accepts_one_write.md | 0
...abled_and_the_warnings_learn_the_marker.md | 0
.../05_the_api_gates_the_write_ten_times.md | 0
.../06_the_switch_obeys.md | 0
...rehearse_then_drill_on_the_live_ingress.md | 0
.../08_docs_and_live_verify.md | 0
.../todo/34_radiance_llm_engine/00_phase.md | 54 +
.../01_capture_radiance_ground_truth.md | 34 +
.../02_config_and_the_engine_registry.md | 38 +
.../03_the_radiance_field_map.md | 43 +
.../04_the_radiance_job.md | 33 +
.../05_the_llm_page_learns_radiance.md | 39 +
.../06_docs_and_live_verify.md | 35 +
...3_gateway_route_switching__00_phase.a1.err | 0
...33_gateway_route_switching__00_phase.a1.md | 25 +
...way_route_switching__00_phase.a1.md.status | 1 +
...eway_route_switching__00_phase.a1.validate | 71 ++
...root_helper_edits_exactly_one_route.a1.err | 0
..._root_helper_edits_exactly_one_route.a1.md | 1 +
...elper_edits_exactly_one_route.a1.md.status | 1 +
...helper_edits_exactly_one_route.a1.validate | 69 +
...ed_peer_is_a_socket_not_a_sudo_line.a1.err | 0
...ged_peer_is_a_socket_not_a_sudo_line.a1.md | 1 +
...r_is_a_socket_not_a_sudo_line.a1.md.status | 1 +
...er_is_a_socket_not_a_sudo_line.a1.validate | 69 +
...ing__03_the_agent_accepts_one_write.a1.err | 0
...hing__03_the_agent_accepts_one_write.a1.md | 1 +
...3_the_agent_accepts_one_write.a1.md.status | 1 +
...03_the_agent_accepts_one_write.a1.validate | 71 ++
...d_and_the_warnings_learn_the_marker.a1.err | 0
...ed_and_the_warnings_learn_the_marker.a1.md | 28 +
...the_warnings_learn_the_marker.a1.md.status | 1 +
..._the_warnings_learn_the_marker.a1.validate | 70 ++
...ed_and_the_warnings_learn_the_marker.a2.md | 110 ++
...5_the_api_gates_the_write_ten_times.a1.err | 0
...05_the_api_gates_the_write_ten_times.a1.md | 10 +
...api_gates_the_write_ten_times.a1.md.status | 1 +
..._api_gates_the_write_ten_times.a1.validate | 70 ++
...oute_switching__06_the_switch_obeys.a1.err | 0
...route_switching__06_the_switch_obeys.a1.md | 10 +
...witching__06_the_switch_obeys.a1.md.status | 1 +
...switching__06_the_switch_obeys.a1.validate | 70 ++
...arse_then_drill_on_the_live_ingress.a1.err | 0
...earse_then_drill_on_the_live_ingress.a1.md | 13 +
...hen_drill_on_the_live_ingress.a1.md.status | 1 +
...then_drill_on_the_live_ingress.a1.validate | 73 ++
...arse_then_drill_on_the_live_ingress.a2.err | 0
...earse_then_drill_on_the_live_ingress.a2.md | 10 +
...hen_drill_on_the_live_ingress.a2.md.status | 1 +
...then_drill_on_the_live_ingress.a2.validate | 71 ++
..._switching__08_docs_and_live_verify.a1.err | 0
...e_switching__08_docs_and_live_verify.a1.md | 10 +
...hing__08_docs_and_live_verify.a1.md.status | 1 +
...ching__08_docs_and_live_verify.a1.validate | 71 ++
.env.dev.example | 6 +
.env.example | 10 +-
AGENTS.md | 143 ++-
README.md | 139 +-
agent/host_agent.py | 328 ++++-
deploy/dashboards-routectl.py | 1076 ++++++++++++++++
deploy/dashboards-routectl.socket | 25 +
deploy/dashboards-routectl@.service | 42 +
deploy/gateway-routectl-README.md | 66 +
deploy/install-agent.sh | 156 ++-
pyproject.toml | 4 +
src/dashboards/api.py | 325 ++++-
src/dashboards/app.py | 4 +-
src/dashboards/collectors.py | 62 +-
src/dashboards/config.py | 22 +-
src/dashboards/sources/gateway.py | 170 ++-
src/dashboards/sources/nut.py | 3 +-
src/dashboards/web/pages/gateway.html | 31 +-
src/dashboards/web/static/dash.css | 39 +-
src/dashboards/web/static/page-gateway.js | 238 +++-
src/dashboards/web/static/page-index.js | 15 +-
tests/auth_client.py | 4 +-
tests/conftest.py | 63 +-
tests/test_agent.py | 634 +++++++++-
tests/test_api.py | 1118 +++++++++++++++--
tests/test_auth_api.py | 15 +-
tests/test_auth_docs.py | 33 +
tests/test_collectors.py | 201 +++
tests/test_config.py | 39 +
tests/test_deploy_scripts.py | 384 +++++-
tests/test_gateway_source.py | 369 +++++-
tests/test_live_fleet.py | 128 +-
tests/test_routectl.py | 625 +++++++++
uv.lock | 360 +++---
91 files changed, 7568 insertions(+), 519 deletions(-)
rename .agents/phases/{todo => complete}/33_gateway_route_switching/00_phase.md (100%)
rename .agents/phases/{todo => complete}/33_gateway_route_switching/01_the_root_helper_edits_exactly_one_route.md (100%)
rename .agents/phases/{todo => complete}/33_gateway_route_switching/02_the_privileged_peer_is_a_socket_not_a_sudo_line.md (100%)
rename .agents/phases/{todo => complete}/33_gateway_route_switching/03_the_agent_accepts_one_write.md (100%)
rename .agents/phases/{todo => complete}/33_gateway_route_switching/04_the_source_publishes_enabled_and_the_warnings_learn_the_marker.md (100%)
rename .agents/phases/{todo => complete}/33_gateway_route_switching/05_the_api_gates_the_write_ten_times.md (100%)
rename .agents/phases/{todo => complete}/33_gateway_route_switching/06_the_switch_obeys.md (100%)
rename .agents/phases/{todo => complete}/33_gateway_route_switching/07_rehearse_then_drill_on_the_live_ingress.md (100%)
rename .agents/phases/{todo => complete}/33_gateway_route_switching/08_docs_and_live_verify.md (100%)
create mode 100644 .agents/phases/todo/34_radiance_llm_engine/00_phase.md
create mode 100644 .agents/phases/todo/34_radiance_llm_engine/01_capture_radiance_ground_truth.md
create mode 100644 .agents/phases/todo/34_radiance_llm_engine/02_config_and_the_engine_registry.md
create mode 100644 .agents/phases/todo/34_radiance_llm_engine/03_the_radiance_field_map.md
create mode 100644 .agents/phases/todo/34_radiance_llm_engine/04_the_radiance_job.md
create mode 100644 .agents/phases/todo/34_radiance_llm_engine/05_the_llm_page_learns_radiance.md
create mode 100644 .agents/phases/todo/34_radiance_llm_engine/06_docs_and_live_verify.md
create mode 100644 .agents/reports/33_gateway_route_switching/33_gateway_route_switching__00_phase.a1.err
create mode 100644 .agents/reports/33_gateway_route_switching/33_gateway_route_switching__00_phase.a1.md
create mode 100644 .agents/reports/33_gateway_route_switching/33_gateway_route_switching__00_phase.a1.md.status
create mode 100644 .agents/reports/33_gateway_route_switching/33_gateway_route_switching__00_phase.a1.validate
create mode 100644 .agents/reports/33_gateway_route_switching/33_gateway_route_switching__01_the_root_helper_edits_exactly_one_route.a1.err
create mode 100644 .agents/reports/33_gateway_route_switching/33_gateway_route_switching__01_the_root_helper_edits_exactly_one_route.a1.md
create mode 100644 .agents/reports/33_gateway_route_switching/33_gateway_route_switching__01_the_root_helper_edits_exactly_one_route.a1.md.status
create mode 100644 .agents/reports/33_gateway_route_switching/33_gateway_route_switching__01_the_root_helper_edits_exactly_one_route.a1.validate
create mode 100644 .agents/reports/33_gateway_route_switching/33_gateway_route_switching__02_the_privileged_peer_is_a_socket_not_a_sudo_line.a1.err
create mode 100644 .agents/reports/33_gateway_route_switching/33_gateway_route_switching__02_the_privileged_peer_is_a_socket_not_a_sudo_line.a1.md
create mode 100644 .agents/reports/33_gateway_route_switching/33_gateway_route_switching__02_the_privileged_peer_is_a_socket_not_a_sudo_line.a1.md.status
create mode 100644 .agents/reports/33_gateway_route_switching/33_gateway_route_switching__02_the_privileged_peer_is_a_socket_not_a_sudo_line.a1.validate
create mode 100644 .agents/reports/33_gateway_route_switching/33_gateway_route_switching__03_the_agent_accepts_one_write.a1.err
create mode 100644 .agents/reports/33_gateway_route_switching/33_gateway_route_switching__03_the_agent_accepts_one_write.a1.md
create mode 100644 .agents/reports/33_gateway_route_switching/33_gateway_route_switching__03_the_agent_accepts_one_write.a1.md.status
create mode 100644 .agents/reports/33_gateway_route_switching/33_gateway_route_switching__03_the_agent_accepts_one_write.a1.validate
create mode 100644 .agents/reports/33_gateway_route_switching/33_gateway_route_switching__04_the_source_publishes_enabled_and_the_warnings_learn_the_marker.a1.err
create mode 100644 .agents/reports/33_gateway_route_switching/33_gateway_route_switching__04_the_source_publishes_enabled_and_the_warnings_learn_the_marker.a1.md
create mode 100644 .agents/reports/33_gateway_route_switching/33_gateway_route_switching__04_the_source_publishes_enabled_and_the_warnings_learn_the_marker.a1.md.status
create mode 100644 .agents/reports/33_gateway_route_switching/33_gateway_route_switching__04_the_source_publishes_enabled_and_the_warnings_learn_the_marker.a1.validate
create mode 100644 .agents/reports/33_gateway_route_switching/33_gateway_route_switching__04_the_source_publishes_enabled_and_the_warnings_learn_the_marker.a2.md
create mode 100644 .agents/reports/33_gateway_route_switching/33_gateway_route_switching__05_the_api_gates_the_write_ten_times.a1.err
create mode 100644 .agents/reports/33_gateway_route_switching/33_gateway_route_switching__05_the_api_gates_the_write_ten_times.a1.md
create mode 100644 .agents/reports/33_gateway_route_switching/33_gateway_route_switching__05_the_api_gates_the_write_ten_times.a1.md.status
create mode 100644 .agents/reports/33_gateway_route_switching/33_gateway_route_switching__05_the_api_gates_the_write_ten_times.a1.validate
create mode 100644 .agents/reports/33_gateway_route_switching/33_gateway_route_switching__06_the_switch_obeys.a1.err
create mode 100644 .agents/reports/33_gateway_route_switching/33_gateway_route_switching__06_the_switch_obeys.a1.md
create mode 100644 .agents/reports/33_gateway_route_switching/33_gateway_route_switching__06_the_switch_obeys.a1.md.status
create mode 100644 .agents/reports/33_gateway_route_switching/33_gateway_route_switching__06_the_switch_obeys.a1.validate
create mode 100644 .agents/reports/33_gateway_route_switching/33_gateway_route_switching__07_rehearse_then_drill_on_the_live_ingress.a1.err
create mode 100644 .agents/reports/33_gateway_route_switching/33_gateway_route_switching__07_rehearse_then_drill_on_the_live_ingress.a1.md
create mode 100644 .agents/reports/33_gateway_route_switching/33_gateway_route_switching__07_rehearse_then_drill_on_the_live_ingress.a1.md.status
create mode 100644 .agents/reports/33_gateway_route_switching/33_gateway_route_switching__07_rehearse_then_drill_on_the_live_ingress.a1.validate
create mode 100644 .agents/reports/33_gateway_route_switching/33_gateway_route_switching__07_rehearse_then_drill_on_the_live_ingress.a2.err
create mode 100644 .agents/reports/33_gateway_route_switching/33_gateway_route_switching__07_rehearse_then_drill_on_the_live_ingress.a2.md
create mode 100644 .agents/reports/33_gateway_route_switching/33_gateway_route_switching__07_rehearse_then_drill_on_the_live_ingress.a2.md.status
create mode 100644 .agents/reports/33_gateway_route_switching/33_gateway_route_switching__07_rehearse_then_drill_on_the_live_ingress.a2.validate
create mode 100644 .agents/reports/33_gateway_route_switching/33_gateway_route_switching__08_docs_and_live_verify.a1.err
create mode 100644 .agents/reports/33_gateway_route_switching/33_gateway_route_switching__08_docs_and_live_verify.a1.md
create mode 100644 .agents/reports/33_gateway_route_switching/33_gateway_route_switching__08_docs_and_live_verify.a1.md.status
create mode 100644 .agents/reports/33_gateway_route_switching/33_gateway_route_switching__08_docs_and_live_verify.a1.validate
create mode 100644 deploy/dashboards-routectl.py
create mode 100644 deploy/dashboards-routectl.socket
create mode 100644 deploy/dashboards-routectl@.service
create mode 100644 deploy/gateway-routectl-README.md
create mode 100644 tests/test_routectl.py
diff --git a/.agents/phases/todo/33_gateway_route_switching/00_phase.md b/.agents/phases/complete/33_gateway_route_switching/00_phase.md
similarity index 100%
rename from .agents/phases/todo/33_gateway_route_switching/00_phase.md
rename to .agents/phases/complete/33_gateway_route_switching/00_phase.md
diff --git a/.agents/phases/todo/33_gateway_route_switching/01_the_root_helper_edits_exactly_one_route.md b/.agents/phases/complete/33_gateway_route_switching/01_the_root_helper_edits_exactly_one_route.md
similarity index 100%
rename from .agents/phases/todo/33_gateway_route_switching/01_the_root_helper_edits_exactly_one_route.md
rename to .agents/phases/complete/33_gateway_route_switching/01_the_root_helper_edits_exactly_one_route.md
diff --git a/.agents/phases/todo/33_gateway_route_switching/02_the_privileged_peer_is_a_socket_not_a_sudo_line.md b/.agents/phases/complete/33_gateway_route_switching/02_the_privileged_peer_is_a_socket_not_a_sudo_line.md
similarity index 100%
rename from .agents/phases/todo/33_gateway_route_switching/02_the_privileged_peer_is_a_socket_not_a_sudo_line.md
rename to .agents/phases/complete/33_gateway_route_switching/02_the_privileged_peer_is_a_socket_not_a_sudo_line.md
diff --git a/.agents/phases/todo/33_gateway_route_switching/03_the_agent_accepts_one_write.md b/.agents/phases/complete/33_gateway_route_switching/03_the_agent_accepts_one_write.md
similarity index 100%
rename from .agents/phases/todo/33_gateway_route_switching/03_the_agent_accepts_one_write.md
rename to .agents/phases/complete/33_gateway_route_switching/03_the_agent_accepts_one_write.md
diff --git a/.agents/phases/todo/33_gateway_route_switching/04_the_source_publishes_enabled_and_the_warnings_learn_the_marker.md b/.agents/phases/complete/33_gateway_route_switching/04_the_source_publishes_enabled_and_the_warnings_learn_the_marker.md
similarity index 100%
rename from .agents/phases/todo/33_gateway_route_switching/04_the_source_publishes_enabled_and_the_warnings_learn_the_marker.md
rename to .agents/phases/complete/33_gateway_route_switching/04_the_source_publishes_enabled_and_the_warnings_learn_the_marker.md
diff --git a/.agents/phases/todo/33_gateway_route_switching/05_the_api_gates_the_write_ten_times.md b/.agents/phases/complete/33_gateway_route_switching/05_the_api_gates_the_write_ten_times.md
similarity index 100%
rename from .agents/phases/todo/33_gateway_route_switching/05_the_api_gates_the_write_ten_times.md
rename to .agents/phases/complete/33_gateway_route_switching/05_the_api_gates_the_write_ten_times.md
diff --git a/.agents/phases/todo/33_gateway_route_switching/06_the_switch_obeys.md b/.agents/phases/complete/33_gateway_route_switching/06_the_switch_obeys.md
similarity index 100%
rename from .agents/phases/todo/33_gateway_route_switching/06_the_switch_obeys.md
rename to .agents/phases/complete/33_gateway_route_switching/06_the_switch_obeys.md
diff --git a/.agents/phases/todo/33_gateway_route_switching/07_rehearse_then_drill_on_the_live_ingress.md b/.agents/phases/complete/33_gateway_route_switching/07_rehearse_then_drill_on_the_live_ingress.md
similarity index 100%
rename from .agents/phases/todo/33_gateway_route_switching/07_rehearse_then_drill_on_the_live_ingress.md
rename to .agents/phases/complete/33_gateway_route_switching/07_rehearse_then_drill_on_the_live_ingress.md
diff --git a/.agents/phases/todo/33_gateway_route_switching/08_docs_and_live_verify.md b/.agents/phases/complete/33_gateway_route_switching/08_docs_and_live_verify.md
similarity index 100%
rename from .agents/phases/todo/33_gateway_route_switching/08_docs_and_live_verify.md
rename to .agents/phases/complete/33_gateway_route_switching/08_docs_and_live_verify.md
diff --git a/.agents/phases/todo/34_radiance_llm_engine/00_phase.md b/.agents/phases/todo/34_radiance_llm_engine/00_phase.md
new file mode 100644
index 0000000..1f5d896
--- /dev/null
+++ b/.agents/phases/todo/34_radiance_llm_engine/00_phase.md
@@ -0,0 +1,54 @@
+# Phase 34 — Radiance is a first-class engine: a third `/llm` panel that says what its /metrics and /stats publish, nothing invented
+
+**Source:** operator request, 2026-10-24 — "add support for a new llm engine 'radiance' which is running on driveripper now. Check it out at http://driveripper.reeselink.com:8000/ and come up with a plan." + the two decisions taken with them in that conversation: **radiance replaced vLLM at driveripper:8000** (the dev config's `VLLM_BASE_URL` points at the engine that is now radiance, so the dev config moves to `RADIANCE_BASE_URL`; vLLM *code* stays — the API shape is additive-only and other deployments may run vLLM), and **the per-position spec-acceptance curve is in** (seven positions, each its own acceptance ratio).
+**Story:** n/a
+**Context:** `src/dashboards/sources/llm.py` (the gufo/vLLM field maps, the rule-2 throughput precedence, `HISTORY_METRICS`, `_sessions()`, `fleet_totals()`, `SEVERITY_CELLS`), `src/dashboards/config.py` (the `*_BASE_URL`/`*_API_KEY` engine settings, `any_source_enabled`), `src/dashboards/collectors.py` (`fetch_engine`, the best-effort `/slots` fetch, `llm_jobs`), `src/dashboards/api.py` (the `llm_router` 404 gate, `/api/config`'s llm block, the summary's llm block, the history defaults), `src/dashboards/alerts.py` (the cell-driven `_engine_alerts`), `src/dashboards/severity.py` (the only threshold table), `src/dashboards/web/static/page-llm.js` + `pages/llm.html` (the `only:` scoping, strip cells, slot grid), `tests/test_llm_source.py` / `tests/test_collectors.py` / `tests/test_api.py` / `tests/test_config.py` / `tests/test_live_fleet.py`, `scripts/capture-fixtures.sh`, `.env.dev` / `.env.dev.example`, `README.md`.
+
+## Objective
+
+The dashboard treats radiance (a vLLM-derived engine, v1.15.1, model `juggernaut`, on `driveripper:8000`) as a **first-class third engine** — `llm:radiance` — with its own field map pinned by a verbatim capture, its native windowed throughput gauges winning per rule 2, its `/stats` endpoint as the session source (radiance's answer to gufo's `/slots`), and a radiance strip on `/llm` (queue cap, version/uptime, per-card VRAM, the 7-bar spec-acceptance curve) — while gufo and vLLM keep their exact payloads, history names, and behaviour.
+
+## Scope decisions taken with the operator (binding for this phase)
+
+| # | Decision | Consequence |
+|---|----------|-------------|
+| 1 | **Radiance replaced vLLM at driveripper:8000.** | `.env.dev`'s `VLLM_BASE_URL` line becomes `RADIANCE_BASE_URL` (same address). The vLLM engine **code and env var stay** — `VLLM_BASE_URL` still configures a vLLM engine for other deployments; only the dev fleet's config moves. `AGENTS.md`'s fleet table says `vLLM :8000` for driveripper; that is the one stale fact the final pass fixes. |
+| 2 | **The per-position spec-acceptance curve is in.** | `radiance:draft_position_acceptance_ratio{position="0".."6"}` renders as a 7-bar hand-rolled SVG on the radiance strip, `role="img"` + `aria-label`, pinned in `tests/test_api.py` beside every other chart. Snapshot-only: the seven per-position ratios do **not** get history series (a bounded store, rule 9); the overall `draft_acceptance_ratio` does. |
+| 3 | **Engine internals the operator cannot act on are out.** | `link`/`passes`/`steps*`/`ckpt*`/`hit_cuts`/`released`/`kv_stream*`/`lookup*`/`linear*`/`attn_cached`/`client_disconnect`/`stream_overflow` stay out of the payload (flavour text is not helpful information). What the payload carries: native + measured throughputs, KV usage + max tokens/bytes, queue depth/cap/admission-rejected, preemptions, the prefix hit-rate gauge, overall + per-position spec acceptance, the decode-step gauges, `requests[]` sessions, per-card VRAM, `model`/`version`/`uptime_s`/`startup_s`, lifetime `tokens_total`. |
+| 4 | **No new thresholds, no new write path.** | The only rated radiance cell is KV pressure, via the **existing** `llm.kv_cache_usage_pct` row (radiance publishes the same 0-1 ratio the vLLM map already feeds it). Spec acceptance is an efficiency number, not a verdict — uncoloured. No radiance endpoint accepts writes; the app's only upstream write stays `POST /api/ups/command` (invariant 1 unchanged). No new dependency, no new `/api` route — the served-route walk and the public-surface pin are untouched. |
+
+## Dependencies
+
+- `23_llm_rate_responsiveness` (complete) — the per-cadence `RateEstimator` and the refused-window → `n/a` + `Δ` semantics this phase reuses for the measured Δ of radiance's vLLM-shaped counters.
+- `13_llm_page_and_decode_rate` (complete) — the engine-panel/strip shape, the `only:` scoping, the served-byte pins this phase extends.
+- `24_dev_mtls_rollout` (complete) — the direct-dial fleet; radiance is dialled the same plain way the other engines are (no token, no certificate — the operator's address answers unauthenticated).
+- Independent of `33_gateway_route_switching` (todo, in progress) — orthogonal module; no shared file besides `api.py`'s already-present llm routes, which this phase does not touch.
+
+## Tasks
+
+1. `01_capture_radiance_ground_truth.md` — `capture-fixtures.sh` records radiance's `/metrics`, `/stats` and `/v1/models` verbatim into `tests/fixtures/`, and the fixture name-set is pinned hermetically.
+2. `02_config_and_the_engine_registry.md` — `RADIANCE_BASE_URL` / `RADIANCE_API_KEY`, one `llm_engines()` registry replacing the five hardcoded gufo/vllm enumeration sites, the dev config moves to radiance.
+3. `03_the_radiance_field_map.md` — `RADIANCE_SERIES`/`RADIANCE_HISTOGRAMS`, `parse_radiance_metrics()`, the radiance `_sessions()` arm, the additive history series, `fleet_totals`' seams copy.
+4. `04_the_radiance_job.md` — `fetch_engine` learns the best-effort `/stats` fetch, the `llm:radiance` job.
+5. `05_the_llm_page_learns_radiance.md` — the radiance strip (queue cap, version/uptime, per-card VRAM, the 7-bar spec curve), the `only:` scopes, `requests[]` rows, the empty-state text.
+6. `06_docs_and_live_verify.md` — the live read-only test, the fixture re-capture, README/AGENTS.md pass, the live-data screenshots.
+
+## Testing & Quality
+- Unit/integration: the radiance parse against the verbatim fixtures (name-set drift pin, rule-2 precedence in both directions, absent → `n/a`, the refused-window arm, severity pass-through, the per-position shape, both session arms, `fleet_totals` with a radiance payload), the collector's best-effort `/stats` (a dead `/stats` keeps the `/metrics` poll healthy — invariant 4), the registry (one place lists the engines), the API surface (`/api/config.llm.radiance`, the 404 gate, the history defaults, the summary), and the live read-only test.
+- Coverage: **>90%** on new/modified code; the whole suite 100%; `ruff` + `pyright` clean.
+- The phase is "Complete" only when the **entire** project test suite passes 100% (every test in the project, not just this phase's — pre-existing failures included) and achieves **>90%** code coverage on new/modified code.
+- Every UI change is verified in a real browser against live data, screenshot in `data/logs/` (AGENTS.md rule 10) — with gufo and radiance both answering, the cross-engine fleet block finally renders on the dev fleet, which is the phase's own proof that the sum is honest.
+
+## Documentation & Consistency
+- The final pass reviews `README.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**. For `AGENTS.md` it fixes only *stale statements* (a dead command, a changed rule, an outdated fact) and **never adds or expands its content** (a file that states its own update policy, e.g. "updated rarely, if ever", wins).
+- Tasks update `README.md` immediately when their own changes are user-visible (setup, commands, configuration, features, conventions), and `AGENTS.md` only when a statement in it became stale.
+
+## Completion Criteria
+- [ ] `RADIANCE_BASE_URL` set and `GUFO_BASE_URL` set: `curl -s http://127.0.0.1:8090/api/llm/snapshot | jq .engines | jq 'map(.engine)'` lists `gufo`, `radiance` (and `vllm` only if configured), and `fleet.engines_live` is 2; `/api/config.llm.radiance.enabled` is `true`.
+- [ ] `RADIANCE_BASE_URL` unset: `/api/llm/snapshot` answers `404 {"configured": false}` with no dial (spy-pinned), and no `llm:radiance` job exists in the hub.
+- [ ] `tests/fixtures/metrics_radiance.txt` and `tests/fixtures/stats_radiance.json` exist, are verbatim captures, and a hermetic test fails if the fixture's `radiance:*`/`vllm:*` name set drifts from `RADIANCE_SERIES`/`RADIANCE_HISTOGRAMS`.
+- [ ] The radiance panel on `/llm` shows the native `Δ`-labelled throughputs when the gauge is absent, the KV bar with its severity colour, the 7-bar spec curve with its `aria-label`, the `requests[]` rows when a request is live and the counts-otherwise arm at rest — screenshot in `data/logs/`.
+- [ ] `uv run pytest -m live --live` includes a radiance test that passes read-only against the running engine.
+- [ ] full test suite **100% passing** (every test in the project, not just this phase's), coverage >90%; `ruff` + `pyright` clean; `bash .agents/validate.sh` green.
+- [ ] README.md is accurate and consistent with the whole project; AGENTS.md has no stale statements (verified in the final pass — the `vLLM :8000` fleet fact included).
+- [ ] no behaviour change in completed work: the gufo and vLLM payloads, history names, alerts, and served pages are byte-identical where this phase does not touch them (the suite's existing pins untouched).
diff --git a/.agents/phases/todo/34_radiance_llm_engine/01_capture_radiance_ground_truth.md b/.agents/phases/todo/34_radiance_llm_engine/01_capture_radiance_ground_truth.md
new file mode 100644
index 0000000..77b7bd3
--- /dev/null
+++ b/.agents/phases/todo/34_radiance_llm_engine/01_capture_radiance_ground_truth.md
@@ -0,0 +1,34 @@
+# Task 01 — Ground truth first: radiance's /metrics, /stats and /v1/models recorded verbatim
+
+**Phase:** `34_radiance_llm_engine` · **Story:** n/a
+
+## Objective
+
+`scripts/capture-fixtures.sh` records radiance's three endpoints verbatim into `tests/fixtures/`, driven by the same env the app reads (AGENTS.md rule 7: field maps are captured from the running engine, never trusted to a README or a memory of an upstream), and a hermetic test pins the recorded `radiance:*`/`vLLM-compat` name set so a metric rename fails the suite before the dashboard does.
+
+## Work
+
+1. `.env.dev` — the engine block: `VLLM_BASE_URL=http://driveripper.reeselink.com:8000` becomes `RADIANCE_BASE_URL=http://driveripper.reeselink.com:8000` with a comment line (the operator's decision of 2026-10-24: radiance replaced vLLM at that address; the vLLM engine code stays for other deployments). `.env.dev` is gitignored — this edit is what makes the live capture below dial the right thing.
+2. `scripts/capture-fixtures.sh` — the engine section learns radiance, the same way it reads the engine URLs (nothing hard-coded, so a capture can never come from a different topology than the dashboard polls):
+ - `[ -n "${RADIANCE_BASE_URL:-}" ] && ENGINES+=("radiance:${RADIANCE_BASE_URL%/}/metrics")` (the existing `--with-llm` gate stays the only switch).
+ - The radiance entry captures **three** files, not one: `metrics_radiance.txt` (the `/metrics` body, verbatim), `stats_radiance.json` (the `/stats` body, verbatim JSON), `models_radiance.json` (the `/v1/models` body — small, and the model's `capabilities`/`max_model_len` are the only place the engine says what it can do). Each gets the same verbatim-header comment the other fixtures carry: recorded by the script, volatile values move between captures, the contract asserts **shape and name set, never the numbers** (the `nut_*.vars` precedent).
+ - The `requests[]` shape: the capture at rest records `requests: []` (the engine was idle at first contact). The header comment says so, and the script prints a hint line (`requests[] was empty — re-run with a request in flight to record its shape`) when it sees `in_flight == 0` in the stats body. Task 03's session arm must handle the empty shape; if the operator runs a re-capture with live traffic before task 03 lands, the fixture carries the real rows and task 03's defensive arm is tested against both.
+ - `--with-llm`'s `ENGINES` loop currently appends `:/metrics` only; restructure it so each engine entry is `` and the loop writes the `.txt` metrics file plus, for radiance only, the two JSON sidecars (gufo keeps its separate `/slots` pass exactly as today).
+3. `tests/test_llm_source.py` — a fixture-loader following the existing skip pattern (`tests/conftest.py` skips a contract test when its fixture is missing, naming the capture command in the skip message — the new test skips the same way). With the fixtures present it asserts, against `metrics_radiance.txt`:
+ - the sorted set of series names in the file equals the sorted set `RADIANCE_SERIES` names + `RADIANCE_HISTOGRAMS` names that the fixture actually carries (the drift pin; a renamed `radiance:*` series fails this test, the way the gufo/vLLM captures are pinned);
+ - every name in the map that the fixture does not carry is listed in a module-level `RADIANCE_OPTIONAL` set (names that exist on other radiance builds), so "absent here" and "absent everywhere" are different things in the test (rule 2's absence arms need to be tested against real absence, not a typo).
+ - a second loader for `stats_radiance.json`: the top-level key set is pinned as a frozenset (shape, not values), `cards[]` is a non-empty list of dicts with `index`/`name`/`vram_used`/`vram_total`, `requests` is a list, and `draft_positions` is a list of dicts with `reached`/`accepted`/`rate`.
+
+## Testing & Quality
+- Unit: the fixture-loader + name-set + shape tests above (they are the unit — the fixtures are the input contract).
+- Coverage: **>90%** on this task's new/modified code (the script is shell; its behaviour is pinned by `tests/test_deploy_scripts.py`'s existing capture-script tests — extend one of them to assert the radiance branch exists and writes the three files when `RADIANCE_BASE_URL` is set, using the script's existing sandbox/fake-curl pattern).
+- The live run itself (operator's machine, this dev fleet): `set -a; . ./.env.dev; set +a && ./scripts/capture-fixtures.sh --with-llm` produces the three files with the recorded headers.
+
+## Completion Criteria
+- [ ] `./scripts/capture-fixtures.sh --with-llm` (with `.env.dev` sourced) writes `tests/fixtures/metrics_radiance.txt`, `stats_radiance.json`, `models_radiance.json`, each with a verbatim header; the `requests[]`-empty hint printed at rest.
+- [ ] `tests/fixtures/metrics_radiance.txt` carries both families on one file: `grep -c '^radiance:' tests/fixtures/metrics_radiance.txt` > 0 and `grep -c '^vllm:' tests/fixtures/metrics_radiance.txt` > 0 (the vLLM-derived shape is part of the contract).
+- [ ] The hermetic name-set/shape tests pass (and skip with the capture command named when the fixtures are absent).
+- [ ] `tests/test_deploy_scripts.py`'s capture-script pin covers the radiance branch.
+- [ ] full test suite **100% passing** (every test in the project, not just this task's).
+- [ ] README.md updated for anything user-visible this task changes (the capture script's `--with-llm` line now records three engine endpoints); AGENTS.md touched only if a statement in it became stale (the phase's final pass re-checks both)
+- [ ] no behavior change in completed work (gufo's `/slots` pass and the agent/UPS captures behave exactly as before)
diff --git a/.agents/phases/todo/34_radiance_llm_engine/02_config_and_the_engine_registry.md b/.agents/phases/todo/34_radiance_llm_engine/02_config_and_the_engine_registry.md
new file mode 100644
index 0000000..5c59498
--- /dev/null
+++ b/.agents/phases/todo/34_radiance_llm_engine/02_config_and_the_engine_registry.md
@@ -0,0 +1,38 @@
+# Task 02 — One registry: `RADIANCE_BASE_URL` and the five hardcoded engine lists become one function
+
+**Phase:** `34_radiance_llm_engine` · **Story:** n/a
+
+## Objective
+
+Radiance is configured the way the other two engines are (a base URL, an optional API key, unset by default → "not configured", rule 3), and the **five** places that enumerate the gufo/vllm pair by hand — `collectors.llm_jobs`, the `llm_router` 404 gate, the history-route defaults, `/api/config`'s llm block, and the summary's `llm.enabled` — all read one `llm_engines()` registry, so the next engine is a one-line addition and no enumeration site can silently keep a two-engine world.
+
+## Work
+
+1. `src/dashboards/config.py`
+ - Beside the existing engine settings: `radiance_url: str = _base_url("RADIANCE_BASE_URL")` and `radiance_api_key: str = _env("RADIANCE_API_KEY")` — the same shapes, the same comment idiom (unset = "this deployment does not run it", never a red panel).
+ - `any_source_enabled` gains `or bool(self.radiance_url)`.
+ - `def llm_engines(self) -> list[tuple[str, str, str, str]]` — ordered `(key, display, url, api_key)` for the three engines, in **stable display order** `gufo, vllm, radiance` (the UI's card order and the history defaults follow it; the order is part of the contract, pinned in a test), with a comment that the list is the single place an engine's presence is decided. A `bool(self.radiance_url)`-style `configured` test lives at the call sites (`url` non-empty), matching how `nut_enabled`/`gateway_enabled` work — no new property for nothing.
+2. `src/dashboards/collectors.py` — `llm_jobs` rewrites its two `if` blocks over `settings.llm_engines()`: one `Job(f"llm:{key}", "llm", display, settings.llm_interval, …)` per **configured** engine; `with_slots=True` when `key == "gufo"`, `with_stats=True` when `key == "radiance"` (the flag plumbing lands in task 04; this task introduces the flag on `fetch_engine` as a no-op parameter so `llm_jobs` reads its final shape now).
+3. `src/dashboards/api.py`
+ - The `llm_router` 404 gate (today `if not (ctx.settings.gufo_url or ctx.settings.vllm_url)`) becomes "no configured engine" over the registry — same `404 {"configured": false, "detail": …}` shape, and the `detail` sentence lists all three variable names (`GUFO_BASE_URL` / `VLLM_BASE_URL` / `RADIANCE_BASE_URL`) so the not-configured arm names the fix.
+ - The `/api/llm/history` defaults (`(("gufo", ctx.settings.gufo_url), ("vllm", ctx.settings.vllm_url))`) become the registry's configured `(display, url)` pairs — the `engines` query-param filtering behaves exactly as today.
+ - The summary's `llm.enabled` (today `bool(ctx.settings.gufo_url or ctx.settings.vllm_url)`) becomes "any engine configured" over the registry.
+ - `/api/config`'s llm block gains a third key, **additively**: `"radiance": {"enabled": bool(settings.radiance_url), "base_url": strip_userinfo(settings.radiance_url), "auth": bool(settings.radiance_api_key)}` — the existing `gufo`/`vllm` keys keep their exact names and shape (the API shape is additive-only, LOCKED).
+4. `.env.dev.example` — the engines block keeps the gufo line, keeps the vllm line (vLLM is still a supported engine for other deployments), and adds `export RADIANCE_BASE_URL=http://driveripper.example.net:8000` with the same documentation idiom. `README.md`'s env-var table row (`GUFO_BASE_URL / VLLM_BASE_URL …`) gains radiance in the same sentence shape (the "unset means *not deployed here*" clause applies unchanged).
+5. `tests/test_config.py`
+ - `RADIANCE_BASE_URL` unset → `radiance_url == ""`, no job, `any_source_enabled` unchanged by it; set → job present, `any_source_enabled` true.
+ - `llm_engines()` pins: the exact ordered triple, the display names (`gufo`, `vLLM`, `radiance`), the api-key passthrough (a set `RADIANCE_API_KEY` travels into the tuple), and that an unset engine is **still listed** with an empty url (the registry is the full fleet, configured-ness is a url test — the `parse_targets` idiom).
+6. `tests/test_api.py` — `/api/config.llm.radiance` absent-by-default pin (additive key, `enabled: false`); the 404 gate with only `RADIANCE_BASE_URL` set (no 404) and none set (404 + the three-variable detail sentence); the history defaults listing a configured radiance.
+
+## Testing & Quality
+- Unit/integration: the registry pins above; the 404 gate in all three single-engine configurations; the config block's additivity (a diff-pin: the gufo/vllm keys' bytes are unchanged when radiance is added).
+- Coverage: **>90%** on `config.py` and the touched `api.py`/`collectors.py` paths; the whole suite 100%; `ruff` + `pyright` clean.
+
+## Completion Criteria
+- [ ] `RADIANCE_BASE_URL=… uv run python -c "from dashboards.config import load; e=[x for x in load().llm_engines() if x[0]=='radiance']; print(e)"` prints the one configured entry; unset, the entry is present with an empty url.
+- [ ] `grep -n "gufo_url\|vllm_url" src/dashboards/collectors.py src/dashboards/api.py` shows no engine enumeration outside the registry (the settings fields themselves remain — they are the env surface).
+- [ ] The 404 gate and the history defaults answer from the registry in all configurations (spy-pinned: an unconfigured radiance is never dialled — no client, no fetch).
+- [ ] `/api/config` serves `llm.radiance` beside byte-identical `llm.gufo`/`llm.vllm`.
+- [ ] full test suite **100% passing** (every test in the project, not just this task's).
+- [ ] README.md updated for anything user-visible this task changes (the env-var table row); AGENTS.md touched only if a statement in it became stale (the phase's final pass re-checks both)
+- [ ] no behavior change in completed work (a gufo-only or vLLM-only deployment's `/api/config`, 404, history and summary bodies are byte-identical to before, modulo the new additive `radiance` key in the config block)
diff --git a/.agents/phases/todo/34_radiance_llm_engine/03_the_radiance_field_map.md b/.agents/phases/todo/34_radiance_llm_engine/03_the_radiance_field_map.md
new file mode 100644
index 0000000..3418048
--- /dev/null
+++ b/.agents/phases/todo/34_radiance_llm_engine/03_the_radiance_field_map.md
@@ -0,0 +1,43 @@
+# Task 03 — The radiance field map: rule-2 precedence, `/stats` as the session source, no invented number
+
+**Phase:** `34_radiance_llm_engine` · **Story:** n/a
+
+## Objective
+
+`src/dashboards/sources/llm.py` parses a radiance poll the way it parses gufo and vLLM — a field map **built from the task-01 fixture** (rule 7), the native windowed throughput gauges winning over a measured Δ (rule 2's precedence, the vLLM-native-gauge case), `n/a` where the engine is silent (never `0`), the KV cell rated through the **existing** `llm.kv_cache_usage_pct` severity row, and `/stats` as the session source beside gufo's `/slots` and vLLM's counts-only arm.
+
+## Work
+
+1. `src/dashboards/sources/llm.py` — the map, **names taken from `tests/fixtures/metrics_radiance.txt`, not from memory** (the module docstring's capture ritual already says how; the vLLM-compat subset is part of the same file, so one `Prom` parse serves both families):
+ - `RADIANCE_SERIES` (UI key → (names, agg)), the radiance gauges plus the vLLM-shaped series this build carries: `decode_tokens_per_second_native` (`radiance:decode_tokens_per_second`, avg — the scheduler's recent-window aggregate, the thing that wins), `prompt_tokens_per_second_native` (`radiance:prefill_tokens_per_second`, avg), `num_requests_running` / `num_requests_waiting` (`vllm:…`, sum), `gpu_cache_usage` (`vllm:kv_cache_usage_perc`, max — the same 0-1 ratio the vLLM map feeds `SEVERITY_CELLS`, so `attach_cell_severity` rates it with **no new row**), `kv_cache_max_tokens` / `kv_cache_max_bytes` (`radiance:…`, max), `queue_depth` (`radiance:http_queue_depth`), `admission_rejected_total` (`radiance:http_admission_rejected_total`), `num_preemptions_total` (`vllm:num_preemptions_total`), `prompt_tokens_total` / `generation_tokens_total` (`vllm:…`, sum — the measured-Δ source), `gpu_prefix_cache_hit_rate` (`vllm:gpu_prefix_cache_hit_rate`, avg — a 0-1 gauge; **this build publishes no prefix counters**, so a hits/queries ratio would be an invented number: the gauge is the answer, source named, not derived — the documented asymmetry, the gufo `*_native` note's sibling), `spec_accept_rate` (`radiance:draft_acceptance_ratio`, avg — a 0-1 gauge, **reusing gufo's UI key** so the history name, the fleet block and the UI row all work without a new vocabulary), `decode_step_seconds_recent` / `decode_step_seconds_best` (`radiance:…`, max).
+ - `RADIANCE_HISTOGRAMS`: `time_to_first_token_seconds` (`vllm:time_to_first_token_seconds`), `time_per_output_token_seconds` (`vllm:time_per_output_token_seconds`, `vllm:inter_token_latency_seconds`), `e2e_request_latency_seconds` (`vllm:e2e_request_latency_seconds`) — the vLLM-shaped histograms the build carries. This build has **no** `request_queue_time_seconds`, so `queue_seconds` is `n/a` for radiance by absence, never back-filled.
+ - `RADIANCE_OPTIONAL` (task 01's set of map names absent from the fixture — e.g. the `vllm:num_requests_swapped`-class names) so the hermetic test distinguishes "not on this build" from "not anywhere".
+2. `parse_radiance_metrics(text, rates, now, stats=None)` — the gufo/vLLM parse functions' idiom, with the **vLLM-side** throughput precedence (native gauge is the aggregate, so it wins):
+ - `decode_tokens_per_second` / `prompt_tokens_per_second`: the native gauge if present (source = the gauge's name, `derived=False`); else the measured Δ of the token counter **per label set** via the existing `_series_delta` (keyed by resolved name + labels — phase 13's carried-counter rule; a refused window is still an answer: `n/a` beside its honest `Δ`); else `field(None, None)`. **No third estimate** (no `running × 1/tpot`): a gauge + a counter + a guess stacked on one cell is exactly the aggregation rule 2 exists to catch, and the engine's own page shows only the first two.
+ - `tokens_total` from the two `vllm:` token counters (prompt + generated, as the vLLM path does); `prefix_cache_hit_ratio` from the gauge (0-1, source named); `spec_positions` from the labelled series `radiance:draft_position_acceptance_ratio` via `parsed.by_label(…, "position")` — an **ordered** list of 7 `{position, ratio}` entries (0-1, in the order 0..6 the engine publishes; absent positions stay absent, not zeroed).
+ - `attach_cell_severity(out)` at the end, unchanged (the `gpu_cache_usage` key carries the verdict; **no** new severity row for spec acceptance — an efficiency number, decision 4).
+3. `_sessions()` — the third arm, keyed on the `stats` dict task 04's fetch supplies:
+ - `stats` present: `running` / `waiting` / `queue_cap` / `requests` come from it (the engine's own summary — the numbers its own page prints). `requests` empty → `detail` = counts + cap, one honest sentence (the vLLM arm's idiom: "radiance reports counts, not sessions: N running of cap C. Per-request detail appears here while requests are in flight."). Non-empty → `list` = the rows verbatim (the fixture's shape is the contract; a row's fields render as-is, task 05), `source` = "radiance /stats".
+ - `stats` missing (the fetch failed — invariant 4: the poll itself is healthy) → fall back to the `/metrics` gauges `num_requests_running`/`num_requests_waiting` for the counts (they are real numbers, different provenance — the detail says `/stats` did not answer this poll), `queue_cap` `null` (never `0` — rule 2).
+ - `active` stays `metrics["num_requests_running"]["value"]` in every arm (one definition, the `/metrics` gauge).
+4. `engine_result(engine, base_url, text, rates, *, slots=None, stats=None, now=None)` — the parser dispatch becomes the three-way map (gufo / vllm / radiance); a radiance poll attaches `payload["stats"]` = the small additive subset `{model, version, uptime_s, startup_s, total_requests, cards[]}` (each card `+ vram_used_pct` computed `used/total×100`, `derived: true` — the only derived number in the block; the raw bytes travel as the engine published them). The subset is **whitelisted from the fixture's shape**, so a radiance upgrade that renames a `/stats` key degrades to absent, not a crash (invariant 4's poll-body rule).
+5. `HISTORY_METRICS` — **additive candidates only** (the existing keys and their candidate order are untouched): `queue_depth` → `("queue_depth",)`, `queue_cap` → `("queue_cap",)` (the `queue_cap` metrics key is the `/stats` passthrough), `decode_step_seconds_recent` → `("decode_step_seconds_recent",)`, `vram_used_pct` → `("vram_used_pct",)` (card 0, the engine's own view of the same card `/system` reads through the agent — different source, different host key, both honest). The existing keys already cover radiance: `decode/prompt_tokens_per_second` (the UI keys the precedence logic fills), `kv_cache_usage` (the `gpu_cache_usage` candidate), `requests_running`/`requests_waiting` (the `num_requests_*` candidates), `tokens_total`, `spec_accept_rate` (the gauge reuses gufo's key). The seven per-position ratios are **snapshot-only** (decision 2: a bounded store, rule 9).
+6. `series_names(engine)` — the three-way branch; `fleet_totals(engines)` — the code needs **no change** (the aliases `num_requests_running`/`num_requests_waiting`, the `decode/prompt_tokens_per_second` keys, `tokens_total` and the `gpu_cache_usage` max all already cover a radiance payload); **only** the `seams` sentence is rewritten to state, briefly and honestly, the known asymmetries: gufo's prompt counter excludes cache-hit tokens; the summed throughput is per-engine provenance-labelled (native scheduler window vs measured Δ). It does not claim what radiance's `vllm:prompt_tokens_total` includes or excludes about cached tokens — the engine does not say, and neither does the dashboard (rule 2).
+
+## Testing & Quality
+- Unit/integration in `tests/test_llm_source.py` (fixture-driven, the conftest skip pattern from task 01):
+ - the fixture parse: every mapped key resolves (shape + source strings), the name set equals the fixture's (task 01's pin), `spec_positions` is the ordered 7, `stats`-arm sessions as specced (empty / non-empty / missing, three fakes), the `payload["stats"]` whitelist (a fixture with a renamed `version` key degrades to absent, the poll still returns), the radiance history-sample names (`llm.queue_depth`, `llm.queue_cap`, `llm.decode_step_seconds_recent`, `llm.vram_used_pct`, …) appear and the gufo/vLLM sample names are untouched.
+ - rule 2, three arms: gauge present → source is the gauge name, `derived` false; gauge line removed from the fixture copy → measured Δ, `derived` true, the window (or the refused-window `n/a` + `Δ` label, a fresh-estimator fake); both absent → `n/a`, no `0` anywhere; `queue_cap` with `stats` missing → `null`, not `0`.
+ - the severity pass-through: the `gpu_cache_usage` cell carries a `severity` verdict from the existing table at a fixture value above the warn bound; `spec_accept_rate` carries **no** severity key (decision 4 pinned both ways — an absence-only assertion, so it is asserted on the payload).
+ - `fleet_totals` with a gufo + radiance payload: the running/waiting sums, the true max KV, `engines_live` 2, the seams copy names the provenance labelling.
+ - the gufo and vLLM parse functions are byte-behaviour-unchanged (their existing pins untouched).
+- Coverage: **>90%** on `sources/llm.py`; the whole suite 100%; `ruff` + `pyright` clean.
+
+## Completion Criteria
+- [ ] `parse_radiance_metrics` against the fixture returns every mapped key with its pinned source string; a series removed from a fixture copy answers `{"value": null}` — never `0`.
+- [ ] the three throughput arms are proven in both directions (gauge wins / measured fills / `n/a` with the honest `Δ` label on a refused window).
+- [ ] `spec_positions` is the ordered 7 from the fixture; `queue_cap` is `null` (not `0`) when `/stats` is absent; the KV cell carries the severity verdict and the spec cell carries none.
+- [ ] `engine_result("radiance", …, stats=…)` carries the whitelisted `stats` block with the one computed `vram_used_pct`; a renamed stats key degrades to absent without raising.
+- [ ] full test suite **100% passing** (every test in the project, not just this task's).
+- [ ] README.md updated for anything user-visible this task changes (none expected — the payload surface is task 05's); AGENTS.md touched only if a statement in it became stale (the phase's final pass re-checks both)
+- [ ] no behavior change in completed work (gufo and vLLM payloads, samples, alerts and pages behave exactly as before — the suite's existing pins untouched)
diff --git a/.agents/phases/todo/34_radiance_llm_engine/04_the_radiance_job.md b/.agents/phases/todo/34_radiance_llm_engine/04_the_radiance_job.md
new file mode 100644
index 0000000..97c9fda
--- /dev/null
+++ b/.agents/phases/todo/34_radiance_llm_engine/04_the_radiance_job.md
@@ -0,0 +1,33 @@
+# Task 04 — The `llm:radiance` job: a `/stats` sidecar that can fail without failing the poll
+
+**Phase:** `34_radiance_llm_engine` · **Story:** n/a
+
+## Objective
+
+`collectors.fetch_engine` fetches radiance's `/stats` **best-effort** — a dead or partial `/stats` degrades the session arm (task 03's missing-stats fallback) and never takes the `/metrics` poll down (invariant 4: a failed sidecar keeps the last good payload and marks nothing stale, while a dead `/metrics` is the real `UpstreamError`) — and `llm_jobs` (in the task-02 registry shape) builds the `llm:radiance` job with its own `Job` key, display name and the shared LLM cadence/estimator.
+
+## Work
+
+1. `src/dashboards/collectors.py` — `fetch_engine(…, with_slots=False, with_stats=False)`:
+ - The `with_stats` path, beside the existing `/slots` loop: one `GET {base_url}/stats` (no alias — radiance's own page uses exactly that path); on any `httpx.HTTPError` **or** a status ≥ 400 **or** a JSON that is not a dict, `stats` stays `None` and the function returns the metrics result with the missing-stats session arm. No `UpstreamError` is raised from this sidecar, no retry, no second dial — one best-effort read, the `/slots` idiom.
+ - The `UpstreamError` messages for the **metrics** dial keep the existing `strip_userinfo` redaction (invariant 6) — a `RADIANCE_API_KEY`-free URL never leaks, a tokenised one never leaks its token.
+ - `llm_jobs` (task 02's registry shape) passes `with_stats=True` for the radiance entry. No new `RateEstimator`: radiance rides the LLM cadence's existing estimator (task: `app.build_hub` is untouched — one estimator per measuring cadence stays the rule).
+2. `src/dashboards/app.py` — nothing to change is the check: the `llm` jobs already flow from `llm_jobs` into the hub; confirm `llm:radiance` gets the LLM interval and the LLM-span estimator by the existing wiring (assert in a test, not by comment).
+3. `tests/test_collectors.py`:
+ - a configured `RADIANCE_BASE_URL` (and no gufo/vllm) builds exactly one `llm:radiance` `Job` with `dashboard == "llm"`, display `radiance`, the LLM interval (the registry's shape from task 02, extended);
+ - a fake server with `/metrics` 200 and `/stats` 500 → the result is a healthy `Result` (payload present, `stats` absent, the session detail names the missing sidecar); `/stats` 200 with a non-JSON body → the same healthy degradation; `/stats` 200 with a valid dict → `stats` travels into the result;
+ - `/metrics` 500 with `/stats` 200 → the `UpstreamError` is the metrics one (the sidecar never masks the real failure);
+ - a tokenised `RADIANCE_BASE_URL` (`http://secret@host:8000`) whose metrics dial fails → the `UpstreamError` message contains no `secret` (the redaction pin, the existing engine-fetch precedent extended);
+ - the job list never dials an unconfigured engine (the existing spy pattern).
+
+## Testing & Quality
+- Unit/integration: the five pins above (healthy / degraded / masked / redacted / undialled).
+- Coverage: **>90%** on the touched `collectors.py` paths; the whole suite 100%; `ruff` + `pyright` clean.
+
+## Completion Criteria
+- [ ] `set -a; . ./.env.dev; set +a` + `./scripts/dev-server.sh restart` → `./scripts/dev-server.sh status` shows `llm:radiance` ok (or its real failure) beside the other sources, and `/api/llm/snapshot` (authenticated) carries the radiance payload with the `stats` block.
+- [ ] a faked dead `/stats` leaves the poll healthy (the test above, and the page shows the metrics panel with the counts-otherwise session arm).
+- [ ] `grep -n "with_stats" src/dashboards/collectors.py` shows the flag feeding `fetch_engine` from `llm_jobs` only.
+- [ ] full test suite **100% passing** (every test in the project, not just this task's).
+- [ ] README.md updated for anything user-visible this task changes (none expected — no new env surface beyond task 02's); AGENTS.md touched only if a statement in it became stale (the phase's final pass re-checks both)
+- [ ] no behavior change in completed work (the gufo `/slots` pass and the vLLM job behave exactly as before — the suite's existing pins untouched)
diff --git a/.agents/phases/todo/34_radiance_llm_engine/05_the_llm_page_learns_radiance.md b/.agents/phases/todo/34_radiance_llm_engine/05_the_llm_page_learns_radiance.md
new file mode 100644
index 0000000..4705a3d
--- /dev/null
+++ b/.agents/phases/todo/34_radiance_llm_engine/05_the_llm_page_learns_radiance.md
@@ -0,0 +1,39 @@
+# Task 05 — `/llm` learns radiance: the strip, the 7-bar curve, and the honest empty arms
+
+**Phase:** `34_radiance_llm_engine` · **Story:** n/a
+
+## Objective
+
+The radiance card on `/llm` shows the cells its payload carries — the native/measured throughputs with their provenance tags, the KV bar with its severity colour, the radiance strip (queue cap, build, uptime, per-card VRAM, the 7-bar spec-acceptance curve), and the `requests[]` rows when a request is live, the counts-otherwise sentence at rest — with every chart named for assistive tech and **no verdict colour** on any radiance cell except the KV bar (decision 4).
+
+## Work
+
+1. `src/dashboards/web/static/page-llm.js`
+ - The `only:` scopes extend, not new: `ttft`, `tpot`, `queue_mean`, `running` gain `'radiance'` (the build carries the vLLM-shaped histograms and the `num_requests_*` gauges — task 03's map fills the same cell keys); `spec_accept` becomes `['gufo', 'radiance']` (the gauge reuses gufo's key and is the same 0-1 scale, so the existing `%` row renders unchanged).
+ - New **radiance-only** strip cells (the `STRIP_CELLS` idiom, `only: ['radiance']`, each with a `tip` that tells the operator what the number is for):
+ - `cap` — "queue cap", the engine's `queue_cap` (the admission ceiling; `null` when `/stats` did not answer → the cell prints `n/a`, never `0`);
+ - `build` — `stats.version` (e.g. `1.15.1`), tip "The engine's own version string — a radiance upgrade changes it, the panel does not";
+ - `uptime` — `stats.uptime_s` through the existing `app.js` duration formatter;
+ - `vram` — card 0's `vram_used`/`vram_total` as `32.8 / 34.2 GiB` (GiB = 2³⁰, the only conversion on the page, labelled as such in the tip: "the engine's own view of the card; `/system`'s GPU row is the agent's view"), `n/a` when no card answered.
+ - The **7-bar spec curve** (decision 2): a small hand-rolled SVG on the radiance strip (the `charts.js` idiom, no library, no CDN — rule 5), one bar per position in `spec_positions` order (0..6), height ∝ the 0-1 ratio, drawn in a **neutral** token (the page's existing track/cool colours — never a severity colour, the value is an efficiency number, decision 4), with `role="img"` and an `aria-label` that names the chart and its range (e.g. `Spec-decode draft acceptance by position: 82% at position 0 falling to 65% at position 6`) — the label is **computed from the payload** (the "every chart names itself" rule, pinned below).
+ - The `requests[]` arm: non-empty `sessions.list` (source "radiance /stats") renders the rows as a compact table beside the counts (the gufo slot-grid idiom, shrunk — radiance rows are the engine's own fields, rendered **as-is**: the fixture's shape is the contract; a field that is a number prints `n/a`-free only when it is a number, anything else prints its string verbatim, no conversion, no invented key). Empty list → the `sessions.detail` sentence (task 03's counts + cap) in the existing detail slot.
+ - `SLOT_ENGINES` stays `['gufo']` (the slot grid is gufo's); the radiance rows live in the strip, not the slot grid.
+2. `src/dashboards/web/pages/llm.html`
+ - The empty-state paragraph names all three variables: "Set `GUFO_BASE_URL` (llama.cpp / gufo), `VLLM_BASE_URL` and/or `RADIANCE_BASE_URL`."
+ - The fleet block's `fleet-note`/seams copy is payload-driven (`fleet.seams` from `fleet_totals`, task 03's rewritten sentence) — no new hardcoded copy.
+3. `src/dashboards/web/static/dash.css` — only the tokens the 7-bar curve and the `requests` table need (reuse the existing neutral/cool/edge tokens; **no** new colour vocabulary — colour comes only from the payload's severity verdict, which for radiance is the KV bar alone).
+4. `tests/test_api.py` — the served-byte pins extend: the radiance strip markup (the four new cells' `data-key`s), the curve's `role="img"` + `aria-label` joined to the existing chart-aria-label pin list (an absent label fails the test), and the requests-table container present-when-the-payload-carries-rows (a fixture-driven payload), absent-at-rest with the detail sentence.
+5. Live verification (AGENTS.md rule 10): `./scripts/dev-server.sh restart` with `.env.dev` (gufo + radiance now both configured — the fleet block finally renders: two answering engines), sign in, `/llm`, screenshot the radiance card **at rest** (counts arm, curve, strip) into `data/logs/34-llm-radiance-rest.png`; if a request is in flight during the pass, a second screenshot with the rows visible.
+
+## Testing & Quality
+- Integration: the `test_api.py` pins above (markup, aria-label, both session arms) against fixture-driven payloads.
+- Coverage: the UI is proven by the payload-contract pins + the screenshots (the JS-tests-none decision, PLAN.md); Python-side **>90%** on anything touched (none expected); the whole suite 100%; `ruff` + `pyright` clean.
+
+## Completion Criteria
+- [ ] The radiance card renders all task-03 cells; a payload with `stats` missing shows `n/a` on `cap`/`build`/`uptime`/`vram` (never `0`) and the counts-otherwise session sentence.
+- [ ] The 7-bar curve is present with its computed `aria-label` (the pin fails on an absent label); no severity-colour token in its markup.
+- [ ] The `only:` scopes: the vLLM-shaped rows (TTFT/TPOT/queue-time/running) and `spec accept` render on the radiance card from the fixture payload; no radiance row on the gufo card and no gufo row on the radiance card.
+- [ ] `data/logs/34-llm-radiance-rest.png` exists, showing the card against live data with the fleet block rendered (two engines).
+- [ ] full test suite **100% passing** (every test in the project, not just this task's).
+- [ ] README.md updated for anything user-visible this task changes (the `/llm` page section, if it names the engines it draws); AGENTS.md touched only if a statement in it became stale (the phase's final pass re-checks both)
+- [ ] no behavior change in completed work (the gufo and vLLM cards are byte-identical where this task does not touch them — the served-byte pins)
diff --git a/.agents/phases/todo/34_radiance_llm_engine/06_docs_and_live_verify.md b/.agents/phases/todo/34_radiance_llm_engine/06_docs_and_live_verify.md
new file mode 100644
index 0000000..4ee082a
--- /dev/null
+++ b/.agents/phases/todo/34_radiance_llm_engine/06_docs_and_live_verify.md
@@ -0,0 +1,35 @@
+# Task 06 — Final pass: live read-only proof, fixture re-capture, README/AGENTS.md, the definition of done
+
+**Phase:** `34_radiance_llm_engine` · **Story:** n/a
+
+## Objective
+
+The phase's verification pass: a read-only `live` test that drives the production radiance path against the running engine, the fixture re-capture with a diff read, the documentation pass (README in full; AGENTS.md stale-statements-only), and the definition-of-done checklist run against the dev server.
+
+## Work
+
+1. `tests/test_live_fleet.py` — the radiance test (the file's idiom: skipped unless `--live`, each test skips when its source is unset, **live tests must not write** — radiance exposes no write endpoint, so "read-only" is a list of GETs):
+ - skip when `RADIANCE_BASE_URL` is unset (the source gate, rule 3's live twin);
+ - dial the engine through the **production** path — `collectors.fetch_engine` with the LLM estimator (never a hand-rolled `client.get`), one poll;
+ - assert shape, not numbers: the payload's `engine == "radiance"`, the `stats` block's keys (task 03's whitelist), `spec_positions` length 7 when the fixture/running build publishes it, the session arm is one of the two specced arms, every cell that is present is either a number or `None` (never a fake `0` for an absent gauge — the live twin of rule 2), and `unparsed` is empty (a parse error on the live wire is drift, not a tolerance).
+2. `./scripts/capture-fixtures.sh --with-llm` (`.env.dev` sourced) — re-record the radiance sidecars (and everything else), **read the diff**: a volatile value moving is expected (the headers say so); a renamed or new `radiance:*` series is a contract change — extend task 01's pin (and `RADIANCE_OPTIONAL` where a name is build-optional), never a silent ignore. If the earlier capture's `requests[]` was empty and a request is in flight now, re-capture so the fixture carries the real row shape, and re-run the session-arm tests against it.
+3. `README.md` — the full final-pass review (the phase's documentation mandate): the env-var table (`RADIANCE_BASE_URL` / `RADIANCE_API_KEY` beside the engine rows, the "unset means *not deployed here*" clause), the LLM-engines section (a radiance paragraph in the gufo/vLLM idiom: what it is, the endpoints it serves, the native-gauge/measured-Δ provenance, the `/stats` sidecar), the capture-script section (three radiance endpoints under `--with-llm`), and every other place the engine count or the driveripper fact appears — fixing **every** inconsistency found, including pre-existing ones.
+4. `AGENTS.md` — **stale statements only, no expansion** (its own update policy wins): the fleet table's driveripper line `vLLM :8000` → `radiance :8000` (the operator's decision of 2026-10-24); the `/llm` route row's source column (`gufo /metrics + /slots, vLLM /metrics`) gains `radiance /metrics + /stats`; `config.py`'s paragraph names the radiance settings beside the others; `sources/llm.py`'s bullet says the three field maps (gufo, vLLM, radiance) with the rule-2 precedence note already there. Nothing else — no phase narrative, no new rules.
+5. The definition-of-done run, in order (AGENTS.md):
+ - `uv run pytest` green; `uv run pytest --cov` — coverage floor 94 % held, new/modified Python > 90 %; `uv run ruff check src tests agent && uv run pyright` clean; `uv pip list --outdated` empty (no dependency moved — confirm, do not assume).
+ - `set -a; . ./.env.dev; set +a && ./scripts/dev-server.sh restart` — `/api/health` sane (per-source ok/DOWN; `llm:radiance` answering), `./scripts/dev-server.sh status` read.
+ - `uv run pytest -m live --live` — the new radiance test passes read-only; the existing live tests (gufo, vLLM-where-configured, upsd, agents) still pass.
+ - Screenshots in `data/logs/`: the task-05 rest shot (and the in-flight shot if one was possible), and the overview page (`/`) showing the radiance engine's alert row when its KV bar is rated — the cell-driven `_engine_alerts` needs no code, only proof.
+ - `bash .agents/validate.sh` green.
+
+## Testing & Quality
+- The live test is the integration proof; the re-captured fixtures re-pin the hermetic suite; the documentation pass is checked by reading the two files against the project as it stands.
+- Coverage: no new Python beyond the live test; the whole suite 100 %; `ruff` + `pyright` clean.
+
+## Completion Criteria
+- [ ] `uv run pytest -m live --live` includes the passing read-only radiance test (skipped, correctly, when unset — proven by one run without the env var).
+- [ ] the fixture re-capture diff is read and either clean or folded into the pins (`RADIANCE_SERIES`/`RADIANCE_OPTIONAL`); the suite re-run is green.
+- [ ] README.md: the engine section, env-var table, and capture-script section all name radiance; a full read found no remaining engine-count or driveripper stale statement.
+- [ ] AGENTS.md: the three stale statements fixed (fleet line, route row, the two module bullets), nothing added or expanded beyond them.
+- [ ] `./scripts/dev-server.sh status` shows `llm:radiance` answering; the `data/logs/` screenshots exist; `bash .agents/validate.sh` is green; the coverage floor held.
+- [ ] full test suite **100% passing** (every test in the project, not just this task's) — the phase's "Complete" gate.
diff --git a/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__00_phase.a1.err b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__00_phase.a1.err
new file mode 100644
index 0000000..e69de29
diff --git a/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__00_phase.a1.md b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__00_phase.a1.md
new file mode 100644
index 0000000..c270ea1
--- /dev/null
+++ b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__00_phase.a1.md
@@ -0,0 +1,25 @@
+Phase 33 completion pass is done. Final report:
+
+## Phase 33 — completion report
+
+**All 8 task files complete** (`.agents/phases/complete/33_gateway_route_switching/`), full gate green, docs verified.
+
+### Work in this pass
+
+1. **`tests/test_routectl.py` added** (17 tests, all green) — exercises the *real* helper binary unprivileged against a fake tree (`--root`), the real agent parser, the captured gateway fixtures, and stubbed `nginx`/`haproxy`/`systemctl` on `PATH`. Pins the full protocol (locate → state → dry-run → temp-file/fsync/`os.replace` write → checker → restore on checker failure → reload-never-restart → re-read proof → sidecar record), all three route shapes (haproxy `server`, nginx `proxy_pass`, nginx stream `map` via its sink-value id), `drift` / unknown-id / `map-default` refusals, `list`/`verify`, and the `--serve` peer gate (`SO_PEERCRED` uid check, one JSON line per request, exact body shape).
+
+2. **Real helper defect fixed** — `deploy/dashboards-routectl.py`: `main()`'s early-return paths returned `({"ok": False, …}, EXIT_*)` tuples, so `sys.exit()` printed a Python repr instead of a JSON line (usage errors, agent-import refusal). Now prints `json.dumps(…)` and returns only the code, keeping the one-JSON-line contract.
+
+3. **Documentation consistency fixes** (task 08 criterion 6):
+ - `README.md`: garbled verdict-colour sentence on the switch ("green and to the position") → "in the **on** position and tinted `--gate-on`" (the existence-pair contract).
+ - `README.md`: audit sentence now names all three event kinds — `route-disabled` / `route-enabled` / **`route-denied`** (the code stores one per refused call; dry runs store none).
+ - `page-gateway.js`: replaced the stale pre-33 module docstring ("the switch is not wired") with the two-arm `GATEWAY_ALLOW_WRITES` contract.
+ - `AGENTS.md`: every phase-33 claim re-verified against code (the ten gates, the 35 s/30 s budget pair, `gateway.writes_enabled`, `/api/summary`'s `disabled`, the 1.4.0 agent bullet, the gap list) — no changes needed.
+
+### Verification
+- `uv run pytest --cov`: **1970 passed**, 18 skipped — coverage **98.74%** (floor 94%)
+- `ruff` clean · `pyright` 0 errors · `bash .agents/validate.sh` **exit 0** (incl. `node --check` on every page script)
+- Dev server running; all 10 sources `ok`; bundles exported
+
+### Live infrastructure
+**No live changes made.** The live drill remains unexecuted per `data/logs/NOTES-33-switching.md` (no operator approval; checker binaries unavailable in the podman proxy units). All routes stay enabled, config hashes unchanged, and the sandbox rehearsal + backup tree are in place for the operator's own `DANGER` call. All changes are in the working tree only.
diff --git a/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__00_phase.a1.md.status b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__00_phase.a1.md.status
new file mode 100644
index 0000000..9766475
--- /dev/null
+++ b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__00_phase.a1.md.status
@@ -0,0 +1 @@
+ok
diff --git a/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__00_phase.a1.validate b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__00_phase.a1.validate
new file mode 100644
index 0000000..755e8e2
--- /dev/null
+++ b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__00_phase.a1.validate
@@ -0,0 +1,71 @@
+== uv run pytest --cov (hermetic: no network, live tests skip) ==
+........................................................................ [ 3%]
+........................................................................ [ 7%]
+........................................................................ [ 10%]
+........................................................................ [ 14%]
+........................................................................ [ 18%]
+........................................................................ [ 21%]
+........................................................................ [ 25%]
+........................................................................ [ 28%]
+........................................................................ [ 32%]
+.........................s.............................................. [ 36%]
+........................................................................ [ 39%]
+........................................................................ [ 43%]
+........................................................................ [ 47%]
+........................................................................ [ 50%]
+........................................................................ [ 54%]
+........................................................................ [ 57%]
+........................................................................ [ 61%]
+........................................................................ [ 65%]
+........................................................................ [ 68%]
+........................................................................ [ 72%]
+...................................................ssssssssssssss....... [ 76%]
+........................................................................ [ 79%]
+........................................................................ [ 83%]
+........................................................................ [ 86%]
+........................................................................ [ 90%]
+........................................................................ [ 94%]
+..........................................................sss........... [ 97%]
+............................................ [100%]
+=============================== warnings summary ===============================
+.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
+ /home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
+ from starlette.testclient import TestClient as TestClient # noqa
+
+-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
+================================ tests coverage ================================
+_______________ coverage: platform linux, python 3.14.8-final-0 ________________
+
+Name Stmts Miss Cover Missing
+------------------------------------------------------------------
+src/dashboards/__init__.py 3 0 100%
+src/dashboards/__main__.py 14 1 93% 32
+src/dashboards/alerts.py 201 0 100%
+src/dashboards/api.py 625 9 99% 229, 1273-1275, 1310-1313, 1382, 1390, 1446, 1449
+src/dashboards/app.py 190 0 100%
+src/dashboards/auth.py 265 0 100%
+src/dashboards/certs.py 292 0 100%
+src/dashboards/collectors.py 244 5 98% 386-387, 391-392, 409
+src/dashboards/config.py 128 0 100%
+src/dashboards/debug.py 14 0 100%
+src/dashboards/poller.py 165 3 98% 192-194, 279
+src/dashboards/prom.py 112 5 96% 126, 163, 177, 180, 183
+src/dashboards/rates.py 73 0 100%
+src/dashboards/severity.py 49 0 100%
+src/dashboards/sources/__init__.py 0 0 100%
+src/dashboards/sources/disks.py 134 0 100%
+src/dashboards/sources/gateway.py 613 10 98% 339, 588, 601-602, 652, 785, 1089, 1140, 1207, 1569
+src/dashboards/sources/host.py 209 0 100%
+src/dashboards/sources/llm.py 178 1 99% 285
+src/dashboards/sources/nut.py 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736
+src/dashboards/sources/nut_sim.py 134 0 100%
+src/dashboards/store.py 313 2 99% 553-554
+------------------------------------------------------------------
+TOTAL 4369 56 99%
+Required test coverage of 94% reached. Total coverage: 98.72%
+1970 passed, 18 skipped, 1 warning in 192.42s (0:03:12)
+== uv run ruff check src tests agent ==
+All checks passed!
+== uv run pyright ==
+0 errors, 0 warnings, 0 informations
+== node --check src/dashboards/web/static/*.js ==
diff --git a/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__01_the_root_helper_edits_exactly_one_route.a1.err b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__01_the_root_helper_edits_exactly_one_route.a1.err
new file mode 100644
index 0000000..e69de29
diff --git a/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__01_the_root_helper_edits_exactly_one_route.a1.md b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__01_the_root_helper_edits_exactly_one_route.a1.md
new file mode 100644
index 0000000..ef0c0c6
--- /dev/null
+++ b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__01_the_root_helper_edits_exactly_one_route.a1.md
@@ -0,0 +1 @@
+Let me look at the agent's file-reading helpers and the `parse_nginx_config`/`nginx_include_paths` functions.
diff --git a/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__01_the_root_helper_edits_exactly_one_route.a1.md.status b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__01_the_root_helper_edits_exactly_one_route.a1.md.status
new file mode 100644
index 0000000..9766475
--- /dev/null
+++ b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__01_the_root_helper_edits_exactly_one_route.a1.md.status
@@ -0,0 +1 @@
+ok
diff --git a/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__01_the_root_helper_edits_exactly_one_route.a1.validate b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__01_the_root_helper_edits_exactly_one_route.a1.validate
new file mode 100644
index 0000000..0dbd9a1
--- /dev/null
+++ b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__01_the_root_helper_edits_exactly_one_route.a1.validate
@@ -0,0 +1,69 @@
+== uv run pytest --cov (hermetic: no network, live tests skip) ==
+........................................................................ [ 3%]
+........................................................................ [ 7%]
+........................................................................ [ 11%]
+........................................................................ [ 15%]
+........................................................................ [ 19%]
+........................................................................ [ 23%]
+........................................................................ [ 26%]
+........................................................................ [ 30%]
+........................................s............................... [ 34%]
+........................................................................ [ 38%]
+........................................................................ [ 42%]
+........................................................................ [ 46%]
+........................................................................ [ 50%]
+........................................................................ [ 53%]
+........................................................................ [ 57%]
+........................................................................ [ 61%]
+........................................................................ [ 65%]
+........................................................................ [ 69%]
+........................................................................ [ 73%]
+.........................sssssssssssss.................................. [ 76%]
+........................................................................ [ 80%]
+........................................................................ [ 84%]
+........................................................................ [ 88%]
+........................................................................ [ 92%]
+........................................................................ [ 96%]
+..............sss....................................................... [100%]
+=============================== warnings summary ===============================
+.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
+ /var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
+ from starlette.testclient import TestClient as TestClient # noqa
+
+-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
+================================ tests coverage ================================
+_______________ coverage: platform linux, python 3.14.8-final-0 ________________
+
+Name Stmts Miss Cover Missing
+------------------------------------------------------------------
+src/dashboards/__init__.py 3 0 100%
+src/dashboards/__main__.py 14 1 93% 32
+src/dashboards/alerts.py 201 0 100%
+src/dashboards/api.py 491 1 99% 220
+src/dashboards/app.py 190 0 100%
+src/dashboards/auth.py 265 0 100%
+src/dashboards/certs.py 292 0 100%
+src/dashboards/collectors.py 216 5 98% 328-329, 333-334, 351
+src/dashboards/config.py 126 0 100%
+src/dashboards/debug.py 14 0 100%
+src/dashboards/poller.py 165 3 98% 192-194, 279
+src/dashboards/prom.py 112 5 96% 126, 163, 177, 180, 183
+src/dashboards/rates.py 73 0 100%
+src/dashboards/severity.py 49 0 100%
+src/dashboards/sources/__init__.py 0 0 100%
+src/dashboards/sources/disks.py 134 0 100%
+src/dashboards/sources/gateway.py 584 8 99% 315, 558, 569, 678, 971, 1022, 1089, 1451
+src/dashboards/sources/host.py 209 0 100%
+src/dashboards/sources/llm.py 178 1 99% 285
+src/dashboards/sources/nut.py 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736
+src/dashboards/sources/nut_sim.py 134 0 100%
+src/dashboards/store.py 313 2 99% 553-554
+------------------------------------------------------------------
+TOTAL 4176 46 99%
+Required test coverage of 94% reached. Total coverage: 98.90%
+1855 passed, 17 skipped, 1 warning in 201.21s (0:03:21)
+== uv run ruff check src tests agent ==
+All checks passed!
+== uv run pyright ==
+0 errors, 0 warnings, 0 informations
+== node --check src/dashboards/web/static/*.js ==
diff --git a/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__02_the_privileged_peer_is_a_socket_not_a_sudo_line.a1.err b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__02_the_privileged_peer_is_a_socket_not_a_sudo_line.a1.err
new file mode 100644
index 0000000..e69de29
diff --git a/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__02_the_privileged_peer_is_a_socket_not_a_sudo_line.a1.md b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__02_the_privileged_peer_is_a_socket_not_a_sudo_line.a1.md
new file mode 100644
index 0000000..e5cc2d3
--- /dev/null
+++ b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__02_the_privileged_peer_is_a_socket_not_a_sudo_line.a1.md
@@ -0,0 +1 @@
+Now let me look at the existing deploy-script test harness and the agent's unit template in `install-agent.sh`, plus the agent's main guard.
diff --git a/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__02_the_privileged_peer_is_a_socket_not_a_sudo_line.a1.md.status b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__02_the_privileged_peer_is_a_socket_not_a_sudo_line.a1.md.status
new file mode 100644
index 0000000..9766475
--- /dev/null
+++ b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__02_the_privileged_peer_is_a_socket_not_a_sudo_line.a1.md.status
@@ -0,0 +1 @@
+ok
diff --git a/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__02_the_privileged_peer_is_a_socket_not_a_sudo_line.a1.validate b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__02_the_privileged_peer_is_a_socket_not_a_sudo_line.a1.validate
new file mode 100644
index 0000000..9a87a5d
--- /dev/null
+++ b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__02_the_privileged_peer_is_a_socket_not_a_sudo_line.a1.validate
@@ -0,0 +1,69 @@
+== uv run pytest --cov (hermetic: no network, live tests skip) ==
+........................................................................ [ 3%]
+........................................................................ [ 7%]
+........................................................................ [ 11%]
+........................................................................ [ 15%]
+........................................................................ [ 19%]
+........................................................................ [ 23%]
+........................................................................ [ 26%]
+........................................................................ [ 30%]
+........................................s............................... [ 34%]
+........................................................................ [ 38%]
+........................................................................ [ 42%]
+........................................................................ [ 46%]
+........................................................................ [ 50%]
+........................................................................ [ 53%]
+........................................................................ [ 57%]
+........................................................................ [ 61%]
+........................................................................ [ 65%]
+........................................................................ [ 69%]
+........................................................................ [ 73%]
+.........................sssssssssssss.................................. [ 76%]
+........................................................................ [ 80%]
+........................................................................ [ 84%]
+........................................................................ [ 88%]
+........................................................................ [ 92%]
+........................................................................ [ 96%]
+..............sss....................................................... [100%]
+=============================== warnings summary ===============================
+.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
+ /var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
+ from starlette.testclient import TestClient as TestClient # noqa
+
+-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
+================================ tests coverage ================================
+_______________ coverage: platform linux, python 3.14.8-final-0 ________________
+
+Name Stmts Miss Cover Missing
+------------------------------------------------------------------
+src/dashboards/__init__.py 3 0 100%
+src/dashboards/__main__.py 14 1 93% 32
+src/dashboards/alerts.py 201 0 100%
+src/dashboards/api.py 491 1 99% 220
+src/dashboards/app.py 190 0 100%
+src/dashboards/auth.py 265 0 100%
+src/dashboards/certs.py 292 0 100%
+src/dashboards/collectors.py 216 4 98% 329, 333-334, 351
+src/dashboards/config.py 126 0 100%
+src/dashboards/debug.py 14 0 100%
+src/dashboards/poller.py 165 3 98% 192-194, 279
+src/dashboards/prom.py 112 4 96% 126, 163, 180, 183
+src/dashboards/rates.py 73 0 100%
+src/dashboards/severity.py 49 0 100%
+src/dashboards/sources/__init__.py 0 0 100%
+src/dashboards/sources/disks.py 134 0 100%
+src/dashboards/sources/gateway.py 584 8 99% 315, 558, 569, 678, 971, 1022, 1089, 1451
+src/dashboards/sources/host.py 209 0 100%
+src/dashboards/sources/llm.py 178 1 99% 285
+src/dashboards/sources/nut.py 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736
+src/dashboards/sources/nut_sim.py 134 0 100%
+src/dashboards/store.py 313 2 99% 553-554
+------------------------------------------------------------------
+TOTAL 4176 44 99%
+Required test coverage of 94% reached. Total coverage: 98.95%
+1855 passed, 17 skipped, 1 warning in 175.99s (0:02:55)
+== uv run ruff check src tests agent ==
+All checks passed!
+== uv run pyright ==
+0 errors, 0 warnings, 0 informations
+== node --check src/dashboards/web/static/*.js ==
diff --git a/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__03_the_agent_accepts_one_write.a1.err b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__03_the_agent_accepts_one_write.a1.err
new file mode 100644
index 0000000..e69de29
diff --git a/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__03_the_agent_accepts_one_write.a1.md b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__03_the_agent_accepts_one_write.a1.md
new file mode 100644
index 0000000..4e9263b
--- /dev/null
+++ b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__03_the_agent_accepts_one_write.a1.md
@@ -0,0 +1 @@
+Now let me look at the dashboard side — the `connections[].id` scheme in `sources/gateway.py`, the Handler and `serve`/`main` in the agent, and the phase-32 additivity control script.
diff --git a/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__03_the_agent_accepts_one_write.a1.md.status b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__03_the_agent_accepts_one_write.a1.md.status
new file mode 100644
index 0000000..9766475
--- /dev/null
+++ b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__03_the_agent_accepts_one_write.a1.md.status
@@ -0,0 +1 @@
+ok
diff --git a/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__03_the_agent_accepts_one_write.a1.validate b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__03_the_agent_accepts_one_write.a1.validate
new file mode 100644
index 0000000..7ad6c3b
--- /dev/null
+++ b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__03_the_agent_accepts_one_write.a1.validate
@@ -0,0 +1,71 @@
+== uv run pytest --cov (hermetic: no network, live tests skip) ==
+........................................................................ [ 3%]
+........................................................................ [ 7%]
+........................................................................ [ 11%]
+........................................................................ [ 15%]
+........................................................................ [ 19%]
+........................................................................ [ 23%]
+........................................................................ [ 26%]
+........................................................................ [ 30%]
+........................................s............................... [ 34%]
+........................................................................ [ 38%]
+........................................................................ [ 42%]
+........................................................................ [ 46%]
+........................................................................ [ 50%]
+........................................................................ [ 53%]
+........................................................................ [ 57%]
+........................................................................ [ 61%]
+........................................................................ [ 65%]
+........................................................................ [ 69%]
+........................................................................ [ 73%]
+.........................sssssssssssss.................................. [ 76%]
+........................................................................ [ 80%]
+........................................................................ [ 84%]
+........................................................................ [ 88%]
+........................................................................ [ 92%]
+........................................................................ [ 96%]
+..............sss....................................................... [100%]
+=============================== warnings summary ===============================
+.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
+ /var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
+ from starlette.testclient import TestClient as TestClient # noqa
+
+-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
+================================ tests coverage ================================
+_______________ coverage: platform linux, python 3.14.8-final-0 ________________
+
+Name Stmts Miss Cover Missing
+------------------------------------------------------------------
+src/dashboards/__init__.py 3 0 100%
+src/dashboards/__main__.py 14 1 93% 32
+src/dashboards/alerts.py 201 0 100%
+src/dashboards/api.py 491 1 99% 220
+src/dashboards/app.py 190 0 100%
+src/dashboards/auth.py 265 0 100%
+src/dashboards/certs.py 292 0 100%
+src/dashboards/collectors.py 216 5 98% 328-329, 333-334, 351
+src/dashboards/config.py 126 0 100%
+src/dashboards/debug.py 14 0 100%
+src/dashboards/poller.py 165 3 98% 192-194, 279
+src/dashboards/prom.py 112 5 96% 126, 163, 177, 180, 183
+src/dashboards/rates.py 73 0 100%
+src/dashboards/severity.py 49 0 100%
+src/dashboards/sources/__init__.py 0 0 100%
+src/dashboards/sources/disks.py 134 0 100%
+src/dashboards/sources/gateway.py 584 8 99% 315, 558, 569, 678, 971, 1022, 1089, 1451
+src/dashboards/sources/host.py 209 0 100%
+src/dashboards/sources/llm.py 178 1 99% 285
+src/dashboards/sources/nut.py 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736
+src/dashboards/sources/nut_sim.py 134 0 100%
+src/dashboards/store.py 313 2 99% 553-554
+------------------------------------------------------------------
+TOTAL 4176 46 99%
+Required test coverage of 94% reached. Total coverage: 98.90%
+1855 passed, 17 skipped, 1 warning in 174.63s (0:02:54)
+== uv run ruff check src tests agent ==
+All checks passed!
+== uv run pyright ==
+0 errors, 0 warnings, 0 informations
+== node --check src/dashboards/web/static/*.js ==
+advisory: dependencies are out of date (AGENTS.md rule 8 — update on purpose):
+ ruff 0.17.2 0.17.3 wheel
diff --git a/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__04_the_source_publishes_enabled_and_the_warnings_learn_the_marker.a1.err b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__04_the_source_publishes_enabled_and_the_warnings_learn_the_marker.a1.err
new file mode 100644
index 0000000..e69de29
diff --git a/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__04_the_source_publishes_enabled_and_the_warnings_learn_the_marker.a1.md b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__04_the_source_publishes_enabled_and_the_warnings_learn_the_marker.a1.md
new file mode 100644
index 0000000..3f3094c
--- /dev/null
+++ b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__04_the_source_publishes_enabled_and_the_warnings_learn_the_marker.a1.md
@@ -0,0 +1,28 @@
+Task 04 is complete. Short report:
+
+## Task 04 — `enabled` joins the payload, `off` becomes reachable, warnings learn the marker
+
+**Verdict: done.** All completion criteria met; `bash .agents/validate.sh` **exit 0**.
+
+### What this run added (the tree carried the source work from the earlier session; I verified every behaviour and added the missing mandated surface)
+
+- **`tests/conftest.py`** — two new fixture modes, both the *captured* fleet tree plus an edit, run through the real agent `Collector` on a fake `--root`: `marked` (the helper's three edits: `gitea` `server` line, nextcloud vhost `proxy_pass`, `brain` map entry → sink, each with the exact `#off ` trailing comment, plus the sink block in `stream.d/00-dashboards-sink.conf`) and `hand_edited` (same three shapes, markers removed).
+- **`tests/test_gateway_source.py`** — the six mandated tests: three-marker fixture → three `enabled: false` rows with selectors/endpoints intact, `warnings == []` with the shapes proven present (suppression, not vacuous); the marker-vs-hand-deleted **pair** (silence ↔ `haproxy_backend_empty` + `nginx_map_route_missing`); sink recognised by block, not a hard-coded port; `0` only when the table answered, `null` + no samples when it didn't; stub/answer key-set parity (stub's `None` = absence of the claim).
+- **`tests/test_api.py`** — `disabled` restated in the pinned 13-key set with its reason; summary test gains `marked` (=3) and `hand_edited` (=0) beside `full` (=0); the overview-line test learns `GATEWAY_DISABLED_FIELD` and pins the `> 0` / `Number.isInteger` guards (`0` prints nothing, `null` prints nothing).
+- **`page-index.js`** — the `switched off` tally clause beside the `warnings` one, same guard.
+- **Docs** — README + AGENTS.md name `enabled`/`off`, the `#off` id suffix, and the marker-aware warnings rule.
+- **Rule 8** — `uv pip list --outdated` was non-empty before the gate; updated on purpose (pytest 9.2.0 → needed `asyncio_default_fixture_loop_scope = "function"` in `pyproject.toml`, pydantic 2.14.2, ruff 0.17.3, websockets 17.4). Now empty.
+
+### Evidence
+
+- **Gate**: 1877 passed / 17 skipped · coverage 98.86% (floor 94%; `gateway.py` 98%, `api.py` 99% — both >90%) · ruff clean · pyright 0 · `node --check` clean.
+- **Live (rule 10)**: dev server restarted; all sources ok; live `/api/summary` serves `gateway: {… 'disabled': 0, 'connections': 33, 'warnings': 0}` (the real fleet has no markers — honest zero, live config untouched); browser pass on the overview page: `gateway 10 listeners · 33 connections · 15 live in · 15 live out` — four figures, **no `· 0 switched off` clause**, exactly the empty-state rule restated for the switch. Screenshot: `data/logs/gateway-switch-04-overview-line.png`.
+- Report written to `.agents/reports/33_gateway_route_switching/…04….a2.md`; ntfy sent.
+
+### Flags (left to the driver/operator)
+
+1. **Task 05's `complete/` copy is from an aborted run** — its code is **not** in the tree (no `POST /api/gateway/route`, no `GATEWAY_ALLOW_WRITES`, no `collectors.post_route`), and its transcript ends mid-work. 05 needs a clean re-run; its `todo/` copy is still there, which is what the driver looks for.
+2. **04's `todo/` copy is orphaned** (identical file in `complete/`; 01–03 follow the move convention). Per the working protocol I didn't touch `todo/` — the driver's move step should remove it.
+3. None of the phase-33 work is committed yet.
+
+Ready for task 05 on your go — or let the pipeline re-run it.
diff --git a/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__04_the_source_publishes_enabled_and_the_warnings_learn_the_marker.a1.md.status b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__04_the_source_publishes_enabled_and_the_warnings_learn_the_marker.a1.md.status
new file mode 100644
index 0000000..9766475
--- /dev/null
+++ b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__04_the_source_publishes_enabled_and_the_warnings_learn_the_marker.a1.md.status
@@ -0,0 +1 @@
+ok
diff --git a/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__04_the_source_publishes_enabled_and_the_warnings_learn_the_marker.a1.validate b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__04_the_source_publishes_enabled_and_the_warnings_learn_the_marker.a1.validate
new file mode 100644
index 0000000..d5fbe76
--- /dev/null
+++ b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__04_the_source_publishes_enabled_and_the_warnings_learn_the_marker.a1.validate
@@ -0,0 +1,70 @@
+== uv run pytest --cov (hermetic: no network, live tests skip) ==
+........................................................................ [ 3%]
+........................................................................ [ 7%]
+........................................................................ [ 11%]
+........................................................................ [ 15%]
+........................................................................ [ 19%]
+........................................................................ [ 22%]
+........................................................................ [ 26%]
+........................................................................ [ 30%]
+...............................................s........................ [ 34%]
+........................................................................ [ 38%]
+........................................................................ [ 41%]
+........................................................................ [ 45%]
+........................................................................ [ 49%]
+........................................................................ [ 53%]
+........................................................................ [ 57%]
+........................................................................ [ 60%]
+........................................................................ [ 64%]
+........................................................................ [ 68%]
+........................................................................ [ 72%]
+...............................................sssssssssssss............ [ 76%]
+........................................................................ [ 79%]
+........................................................................ [ 83%]
+........................................................................ [ 87%]
+........................................................................ [ 91%]
+........................................................................ [ 95%]
+....................................sss................................. [ 98%]
+...................... [100%]
+=============================== warnings summary ===============================
+.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
+ /home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
+ from starlette.testclient import TestClient as TestClient # noqa
+
+-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
+================================ tests coverage ================================
+_______________ coverage: platform linux, python 3.14.8-final-0 ________________
+
+Name Stmts Miss Cover Missing
+------------------------------------------------------------------
+src/dashboards/__init__.py 3 0 100%
+src/dashboards/__main__.py 14 1 93% 32
+src/dashboards/alerts.py 201 0 100%
+src/dashboards/api.py 492 1 99% 220
+src/dashboards/app.py 190 0 100%
+src/dashboards/auth.py 265 0 100%
+src/dashboards/certs.py 292 0 100%
+src/dashboards/collectors.py 216 5 98% 328-329, 333-334, 351
+src/dashboards/config.py 126 0 100%
+src/dashboards/debug.py 14 0 100%
+src/dashboards/poller.py 165 3 98% 192-194, 279
+src/dashboards/prom.py 112 5 96% 126, 163, 177, 180, 183
+src/dashboards/rates.py 73 0 100%
+src/dashboards/severity.py 49 0 100%
+src/dashboards/sources/__init__.py 0 0 100%
+src/dashboards/sources/disks.py 134 0 100%
+src/dashboards/sources/gateway.py 613 10 98% 339, 588, 601-602, 652, 785, 1089, 1140, 1207, 1569
+src/dashboards/sources/host.py 209 0 100%
+src/dashboards/sources/llm.py 178 1 99% 285
+src/dashboards/sources/nut.py 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736
+src/dashboards/sources/nut_sim.py 134 0 100%
+src/dashboards/store.py 313 2 99% 553-554
+------------------------------------------------------------------
+TOTAL 4206 48 99%
+Required test coverage of 94% reached. Total coverage: 98.86%
+1877 passed, 17 skipped, 1 warning in 171.02s (0:02:51)
+== uv run ruff check src tests agent ==
+All checks passed!
+== uv run pyright ==
+0 errors, 0 warnings, 0 informations
+== node --check src/dashboards/web/static/*.js ==
diff --git a/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__04_the_source_publishes_enabled_and_the_warnings_learn_the_marker.a2.md b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__04_the_source_publishes_enabled_and_the_warnings_learn_the_marker.a2.md
new file mode 100644
index 0000000..193560d
--- /dev/null
+++ b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__04_the_source_publishes_enabled_and_the_warnings_learn_the_marker.a2.md
@@ -0,0 +1,110 @@
+# Task 04 completion report (attempt 2, manual run)
+
+**Status:** complete — all completion criteria met, `bash .agents/validate.sh` green (exit 0).
+
+Note: attempt `a1`'s transcript died mid-work (it ends at "Now let me read the current
+`warnings()` and `config_connections()` in the source:") yet its `.status` read `ok`; the
+`a1` report for task 05 likewise claims tasks 01–04 were unimplemented and was itself
+aborted. The tree carried the task-04 source work from the earlier session; this run
+verified every mandated behaviour end-to-end and added the missing mandated tests, the
+two fixture modes, the overview clause and its pins, and the doc sentences.
+
+## What this run added (on top of the pre-existing task-04 source work)
+
+- `tests/conftest.py` — two new `GATEWAY_MODES`, both the **captured** tree plus an edit,
+ run through the real agent `Collector` with a fake `--root` tree (wire-shape renames
+ fail tests, never draw empty pipes):
+ - `marked` — the helper's three edits: the `gitea` `server` line, the nextcloud vhost
+ `proxy_pass` line and the `brain.reeseapps.com` stream `map` entry (value rewritten
+ to the sink `127.0.0.1:44450`) each carrying the agent's `#off ` trailing
+ comment (the whole comment, one name — `split_marker`'s grammar), plus the helper's
+ sink `server` block in `stream.d/00-dashboards-sink.conf`.
+ - `hand_edited` — the same three shapes without the markers: the `server` line deleted,
+ the `proxy_pass` line plain, the map entry's value deleted, no sink file.
+- `tests/test_gateway_source.py` — the six mandated tests, all passing:
+ - `test_a_marker_off_route_is_declared_and_disabled` — the `marked` fixture yields
+ exactly three `enabled: false` rows, selectors and endpoints intact (`gitea-2222` →
+ `reeseapps-gitea.reeselink.com:2222`, `nextcloud.reeseapps.com:11000`,
+ `127.0.0.1:44450`), every other row `true`, `#off` id suffix on every off row,
+ `warnings == []` with the marker-caused shapes proven present in the raw sections.
+ - `test_the_source_never_calls_a_route_broken_because_the_operator_disabled_it` /
+ `test_the_same_shape_without_a_marker_is_still_a_warning` — the pair: marker-caused
+ shapes warn nothing; the same shapes hand-deleted bring back
+ `haproxy_backend_empty` (subject `gitea-2222`) and `nginx_map_route_missing`
+ (subject `targetBackend:brain.reeseapps.com`), with no `#off` row in that arm.
+ - `test_the_sink_address_is_recognised_not_hard_coded` — a sink block on a different
+ port (`45999`) still reads as off from the payload's own text; a loopback target
+ that is *not* the block's port is on.
+ - `test_a_disabled_line_still_publishes_only_a_measured_count` — table answered and
+ empty: off rows `0` with a `0.0` sample (measured, never stamped); table absent
+ (`available: null`): every row `null`, no samples.
+ - `test_the_stub_and_the_answer_share_one_key_set` — answered rows carry `true`/`false`
+ (never null, never absent); the stub carries no `enabled`/`disabled` key anywhere
+ (nobody read the config, so its `None` is the absence of the claim); top-level
+ parity modulo `unreachable` / `agent_version`.
+ - plus the extended no-verdict walk (`marked`, `hand_edited` added to the modes).
+- `tests/test_api.py` — `GATEWAY_BLOCK_FIELDS` comment restates the thirteenth key with
+ its reason; `test_the_summary_gateway_block_is_the_payloads_sums...` gains `marked`
+ (`disabled == 3`) and `hand_edited` (`disabled == 0`, the markers gone → the off state
+ gone) beside `full`'s `0`; the stub test's `disabled` pin was already in the tree;
+ `test_the_overview_line_reads_the_gateway_block_it_claims` learns
+ `GATEWAY_DISABLED_FIELD` (table resolved against the live block) and pins the guard:
+ printed only `> 0` and `Number.isInteger` — `0` prints nothing, `null` prints nothing.
+- `src/dashboards/web/static/page-index.js` — `GATEWAY_DISABLED_FIELD`
+ (`disabled` / `switched off`) and the second non-zero-guarded clause beside the
+ `warnings` one.
+- `pyproject.toml` — `asyncio_default_fixture_loop_scope = "function"` (pytest 9.2.0
+ errors at configure without it; the deprecation's own prescription).
+- `README.md` / `AGENTS.md` — the `/gateway` paragraph names `enabled`/`off`, the `#off`
+ id suffix, and the marker-aware warnings rule (marker-caused shapes warn nothing; the
+ same shape from a hand-deleted line still warns). Task 08's docs pass re-checks both
+ project-wide.
+
+## Dependency maintenance (rule 8)
+
+`uv pip list --outdated` was non-empty before this run's gate; updated on purpose:
+`uv lock --upgrade` + `uv sync` — pydantic 2.14.2, pydantic-core 2.50.2, pytest 9.2.0,
+ruff 0.17.3, websockets 17.4 (nodeenv/opentelemetry-api resolved by the same pass).
+Now empty.
+
+## Criteria evidence
+
+- 3-marker fixture → three `enabled: false` rows, intact, `warnings == []`: the source
+ test above; the API arm asserts `block["disabled"] == 3`.
+- Markers removed → warnings back: the pair above (one test pair, both directions).
+- `gateway.disabled`: `3` marked / `0` clean capture / `null` stub (stub test asserts
+ `block["disabled"] is None`); the overview line prints nothing for `0` — pinned in
+ the JS test and observed on the live page (below).
+- No `severity` key at any depth: the recursive walk passes, extended, unmodified in
+ meaning.
+- Gate: `uv run pytest` 1877 passed / 17 skipped; coverage 98.86% (floor 94%;
+ `sources/gateway.py` 98%, `api.py` 99%, both >90%); ruff clean; pyright 0;
+ `node --check` clean; `uv pip list --outdated` empty; `bash .agents/validate.sh`
+ exit 0.
+
+## Live verification (rule 10)
+
+Dev server restarted (`./scripts/dev-server.sh restart`); `/api/health` ok for all
+sources; the restarted server's live `/api/summary` gateway block serves the new key:
+`{'host': 'gateway', 'listeners': 10, 'connections': 33, 'disabled': 0, 'live_in': 14,
+'live_out': 14, 'map_targets': 18, 'warnings': 0}` — `disabled: 0` is the honest live
+value (the real fleet carries no `#off` marker; the live config was not touched).
+Real-browser pass signed in with `testpass`: the ingress line renders
+`gateway 10 listeners · 33 connections · 15 live in · 15 live out` — the four figures
+present, no `· 0 switched off` clause (the guard suppresses it at `0`, the
+empty-state rule restated for the switch). Screenshot:
+`data/logs/gateway-switch-04-overview-line.png`.
+
+## Flags for the operator (bookkeeping, left to the driver)
+
+1. `04_...md` now exists **identically in both** `todo/` and `complete/` (the `a1` run
+ copied it without removing the `todo/` copy; 01–03 follow the move convention).
+ Per the working protocol I did not touch `todo/`; the driver's move step should
+ remove the `todo/` copy.
+2. `05_...md` also exists in `complete/` from the aborted `a1` run — **task 05's work
+ is not in the tree** (no `POST /api/gateway/route`, no `GATEWAY_ALLOW_WRITES`, no
+ `collectors.post_route`), and its `a1` transcript ends mid-work ("Now let me read
+ the code I'm touching"). Task 05 needs a clean re-run; its `todo/` copy is still
+ present, which is exactly what the driver looks for.
+3. None of the phase-33 file moves or code is committed yet (`git status`: 01–03 moved,
+ 04/05 in `complete/` untracked, all code changes uncommitted).
diff --git a/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__05_the_api_gates_the_write_ten_times.a1.err b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__05_the_api_gates_the_write_ten_times.a1.err
new file mode 100644
index 0000000..e69de29
diff --git a/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__05_the_api_gates_the_write_ten_times.a1.md b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__05_the_api_gates_the_write_ten_times.a1.md
new file mode 100644
index 0000000..c76555c
--- /dev/null
+++ b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__05_the_api_gates_the_write_ten_times.a1.md
@@ -0,0 +1,10 @@
+All changes are in the working tree, nothing staged. Task complete — final report:
+
+**Phase 33 task 05 — done. `POST /api/gateway/route` is the app's second and last upstream write.**
+- `config.py`: `GATEWAY_ALLOW_WRITES` (default **off**, NUT truthy rule) + `GATEWAY_WRITE_TIMEOUT_SECONDS` (35 s, outlives the agent's 30 s peer timeout); read budget untouched.
+- `collectors.py`: `post_route` — per-dial `AgentTls`, revocation before any socket, no `Authorization` on the certificate path, redacted URLs, helper's object verbatim.
+- `api.py`: the route with the ten numbered gates (1–3 middleware; 4 `DASH_READ_ONLY`; 5 flag; 6 `route:` throttle + `Retry-After`; 7 hand-parsed body 422; 8 payload state 409 `{changed: false}`; 9 freshness 409; 10 `danger="DANGER"` for both verbs); one event per call, no diff/path/config text in any event; `/api/config` serves `gateway.writes_enabled`.
+- Tests: surface test rewritten (two fleet writes, fails on a third), all ten gates parametrised with a dial spy, revoked-agent no-socket, no-diff event pin, danger/throttle/read-only/write-timeout pins, wire-id base spelling; `test_auth_api` walk + CSRF on the wire.
+- Docs same commit: AGENTS.md invariant 1 now names two gated exceptions (plus the api/collectors/config bullets, gap 8 removed), README + `.env.example` env rows; `DASH_READ_ONLY` greps in both write paths.
+- Dev-server curl checks: writes-off answers the `GATEWAY_ALLOW_WRITES` sentence; live 1.3.0 fleet answers the version gate first (correct order).
+- `bash .agents/validate.sh` **exit 0**: 1915 passed / 17 skipped, coverage 98.72% (94 floor; `api.py` 99%, `collectors.py` 98%), ruff + pyright clean.
diff --git a/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__05_the_api_gates_the_write_ten_times.a1.md.status b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__05_the_api_gates_the_write_ten_times.a1.md.status
new file mode 100644
index 0000000..9766475
--- /dev/null
+++ b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__05_the_api_gates_the_write_ten_times.a1.md.status
@@ -0,0 +1 @@
+ok
diff --git a/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__05_the_api_gates_the_write_ten_times.a1.validate b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__05_the_api_gates_the_write_ten_times.a1.validate
new file mode 100644
index 0000000..5ce70e9
--- /dev/null
+++ b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__05_the_api_gates_the_write_ten_times.a1.validate
@@ -0,0 +1,70 @@
+== uv run pytest --cov (hermetic: no network, live tests skip) ==
+........................................................................ [ 3%]
+........................................................................ [ 7%]
+........................................................................ [ 11%]
+........................................................................ [ 14%]
+........................................................................ [ 18%]
+........................................................................ [ 22%]
+........................................................................ [ 26%]
+........................................................................ [ 29%]
+.......................................................................s [ 33%]
+........................................................................ [ 37%]
+........................................................................ [ 40%]
+........................................................................ [ 44%]
+........................................................................ [ 48%]
+........................................................................ [ 52%]
+........................................................................ [ 55%]
+........................................................................ [ 59%]
+........................................................................ [ 63%]
+........................................................................ [ 67%]
+........................................................................ [ 70%]
+........................................................................ [ 74%]
+.............sssssssssssss.............................................. [ 78%]
+........................................................................ [ 81%]
+........................................................................ [ 85%]
+........................................................................ [ 89%]
+........................................................................ [ 93%]
+........................................................................ [ 96%]
+..sss....................................................... [100%]
+=============================== warnings summary ===============================
+.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
+ /home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
+ from starlette.testclient import TestClient as TestClient # noqa
+
+-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
+================================ tests coverage ================================
+_______________ coverage: platform linux, python 3.14.8-final-0 ________________
+
+Name Stmts Miss Cover Missing
+------------------------------------------------------------------
+src/dashboards/__init__.py 3 0 100%
+src/dashboards/__main__.py 14 1 93% 32
+src/dashboards/alerts.py 201 0 100%
+src/dashboards/api.py 622 9 99% 229, 1273-1275, 1310-1313, 1379, 1387, 1436, 1439
+src/dashboards/app.py 190 0 100%
+src/dashboards/auth.py 265 0 100%
+src/dashboards/certs.py 292 0 100%
+src/dashboards/collectors.py 244 4 98% 387, 391-392, 409
+src/dashboards/config.py 128 0 100%
+src/dashboards/debug.py 14 0 100%
+src/dashboards/poller.py 165 3 98% 192-194, 279
+src/dashboards/prom.py 112 4 96% 126, 163, 180, 183
+src/dashboards/rates.py 73 0 100%
+src/dashboards/severity.py 49 0 100%
+src/dashboards/sources/__init__.py 0 0 100%
+src/dashboards/sources/disks.py 134 0 100%
+src/dashboards/sources/gateway.py 613 10 98% 339, 588, 601-602, 652, 785, 1089, 1140, 1207, 1569
+src/dashboards/sources/host.py 209 0 100%
+src/dashboards/sources/llm.py 178 1 99% 285
+src/dashboards/sources/nut.py 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736
+src/dashboards/sources/nut_sim.py 134 0 100%
+src/dashboards/store.py 313 2 99% 553-554
+------------------------------------------------------------------
+TOTAL 4366 54 99%
+Required test coverage of 94% reached. Total coverage: 98.76%
+1915 passed, 17 skipped, 1 warning in 173.60s (0:02:53)
+== uv run ruff check src tests agent ==
+All checks passed!
+== uv run pyright ==
+0 errors, 0 warnings, 0 informations
+== node --check src/dashboards/web/static/*.js ==
diff --git a/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__06_the_switch_obeys.a1.err b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__06_the_switch_obeys.a1.err
new file mode 100644
index 0000000..e69de29
diff --git a/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__06_the_switch_obeys.a1.md b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__06_the_switch_obeys.a1.md
new file mode 100644
index 0000000..546e106
--- /dev/null
+++ b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__06_the_switch_obeys.a1.md
@@ -0,0 +1,10 @@
+Task 06 complete. Final report:
+
+**33/06 — the switch obeys `gateway.writes_enabled`**
+
+- **Two-arm switch** (`page-gateway.js`): the arm is `gateway.writes_enabled` read from `/api/config` (never inferred; a dead fetch leaves the safe arm). Writes-off: `aria-disabled="true"`, `cursor: not-allowed`, no listener, title = the API's refusal byte-for-byte. Writes-on: focusable `role="switch"`, `cursor: pointer`, one click listener, a title naming the config-file edit → validator → reload. Only the switch's own `aria-checked` may be optimistic; the row's word/class/spine stay the payload's.
+- **Flow**: click → server `dry_run: true` (Gate 10 now exempts it; it asks nothing, writes nothing, and writes no `route-*` event) → dialog prints the diff verbatim (`+`/`−` tinted from the existence pair, `--gate-on`/`--gate-off`) → typed `DANGER` (wrong word sends nothing) → one send, `aria-busy`, no retry loop. `applied: true` flips the switch early + `refresh()`; `applied: false` prints the helper's word and flips nothing.
+- **Pins**: the spec's four named tests added (`…wired_only_when_the_deployment_enables_writes`, `…preview_then_the_word_then_sends_once`, `…painted_from_the_answer_not_the_click`, `…sentence_matches_the_routes_refusal` — the last reads both files via `ast`); hover allowed on the live arm only; `routeInConfig` pin moved to `conn.enabled !== false` with `connections.enabled` in `GATEWAY_PAGE_FIELDS`; dialog/diff pins added.
+- **Gate**: `.agents/validate.sh` green — 1919 passed / 17 skipped, coverage 98.72% (floor 94), ruff + pyright clean, `node --check` clean.
+- **Browser** (Node Playwright): hermetic writes-on smoke (patched `post_route`, `marked` fixture) — live arm, dry-run diff, wrong word, `DANGER` applied, optimistic flip, refused arm; 360px viewport (no sideways scroll, dialog in view), focus in the confirm field, Esc closes and returns focus; hover tint verified. Live dev server (restarted, all sources ok): off arm exact sentence + `not-allowed`.
+- **Docs**: README switch section + agent 1.4.0; AGENTS.md invariant 1 gains the dry-run clause. Screenshots in `data/logs/33-task06-*.png`.
diff --git a/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__06_the_switch_obeys.a1.md.status b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__06_the_switch_obeys.a1.md.status
new file mode 100644
index 0000000..9766475
--- /dev/null
+++ b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__06_the_switch_obeys.a1.md.status
@@ -0,0 +1 @@
+ok
diff --git a/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__06_the_switch_obeys.a1.validate b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__06_the_switch_obeys.a1.validate
new file mode 100644
index 0000000..1880c00
--- /dev/null
+++ b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__06_the_switch_obeys.a1.validate
@@ -0,0 +1,70 @@
+== uv run pytest --cov (hermetic: no network, live tests skip) ==
+........................................................................ [ 3%]
+........................................................................ [ 7%]
+........................................................................ [ 11%]
+........................................................................ [ 14%]
+........................................................................ [ 18%]
+........................................................................ [ 22%]
+........................................................................ [ 26%]
+........................................................................ [ 29%]
+........................................................................ [ 33%]
+...s.................................................................... [ 37%]
+........................................................................ [ 40%]
+........................................................................ [ 44%]
+........................................................................ [ 48%]
+........................................................................ [ 52%]
+........................................................................ [ 55%]
+........................................................................ [ 59%]
+........................................................................ [ 63%]
+........................................................................ [ 66%]
+........................................................................ [ 70%]
+........................................................................ [ 74%]
+.................sssssssssssss.......................................... [ 78%]
+........................................................................ [ 81%]
+........................................................................ [ 85%]
+........................................................................ [ 89%]
+........................................................................ [ 92%]
+........................................................................ [ 96%]
+......sss....................................................... [100%]
+=============================== warnings summary ===============================
+.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
+ /home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
+ from starlette.testclient import TestClient as TestClient # noqa
+
+-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
+================================ tests coverage ================================
+_______________ coverage: platform linux, python 3.14.8-final-0 ________________
+
+Name Stmts Miss Cover Missing
+------------------------------------------------------------------
+src/dashboards/__init__.py 3 0 100%
+src/dashboards/__main__.py 14 1 93% 32
+src/dashboards/alerts.py 201 0 100%
+src/dashboards/api.py 625 9 99% 229, 1273-1275, 1310-1313, 1382, 1390, 1446, 1449
+src/dashboards/app.py 190 0 100%
+src/dashboards/auth.py 265 0 100%
+src/dashboards/certs.py 292 0 100%
+src/dashboards/collectors.py 244 5 98% 386-387, 391-392, 409
+src/dashboards/config.py 128 0 100%
+src/dashboards/debug.py 14 0 100%
+src/dashboards/poller.py 165 3 98% 192-194, 279
+src/dashboards/prom.py 112 5 96% 126, 163, 177, 180, 183
+src/dashboards/rates.py 73 0 100%
+src/dashboards/severity.py 49 0 100%
+src/dashboards/sources/__init__.py 0 0 100%
+src/dashboards/sources/disks.py 134 0 100%
+src/dashboards/sources/gateway.py 613 10 98% 339, 588, 601-602, 652, 785, 1089, 1140, 1207, 1569
+src/dashboards/sources/host.py 209 0 100%
+src/dashboards/sources/llm.py 178 1 99% 285
+src/dashboards/sources/nut.py 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736
+src/dashboards/sources/nut_sim.py 134 0 100%
+src/dashboards/store.py 313 2 99% 553-554
+------------------------------------------------------------------
+TOTAL 4369 56 99%
+Required test coverage of 94% reached. Total coverage: 98.72%
+1919 passed, 17 skipped, 1 warning in 171.78s (0:02:51)
+== uv run ruff check src tests agent ==
+All checks passed!
+== uv run pyright ==
+0 errors, 0 warnings, 0 informations
+== node --check src/dashboards/web/static/*.js ==
diff --git a/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__07_rehearse_then_drill_on_the_live_ingress.a1.err b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__07_rehearse_then_drill_on_the_live_ingress.a1.err
new file mode 100644
index 0000000..e69de29
diff --git a/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__07_rehearse_then_drill_on_the_live_ingress.a1.md b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__07_rehearse_then_drill_on_the_live_ingress.a1.md
new file mode 100644
index 0000000..25b9160
--- /dev/null
+++ b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__07_rehearse_then_drill_on_the_live_ingress.a1.md
@@ -0,0 +1,13 @@
+No interactive channel is available to confirm operator presence, so per the task protocol I stop after step 3 and report.
+
+---
+
+**Task 07 report — steps 1–3 complete; step 4 (live drill) not started, operator absence**
+
+1. **Backups** — all four live configs + agent unit hashed to `/root/dashboards-33-backup-20261101-0950`; `diff -r` byte-identical.
+2. **Rehearsal** — sandbox tree with real config copies: 3 `--apply` arms (haproxy server, nginx vhost, nginx map→sink) and the `map-default` refusal all behaved; checker-failure restore proven; sandbox left byte-identical.
+3. **Install** — 1.4.0 + `--allow-writes` (installer diff: exactly one line), routectl helper/socket/template on the box; two live-kernel bugs found and fixed, both pinned in tests.
+4. **Live-kernel fixes (repo + box)** — `Accept=yes` socket activation (systemd 257 rejected `Service=`); `serve()` reads fd 3 then fd 0 with `recv()`; refusals sent on the wire; ucred layout calibrated (this kernel delivers `{pid,uid,gid}`) with a root `setuid` probe so a matching gid on a foreign uid **cannot** open the gate; a `refused()` tuple-inversion that serialised refusals as JSON lists was caught by the live chain and fixed.
+5. **Gates proven live** — true uid-994 client passes; `nobody` and uid-1000 get EACCES; root-with-matching-gid is refused by the peer gate; full 4-hop dry run (dashboard TLS dial → agent `POST /route` → socket → helper) returned the helper's verbatim diff (`#off iperf3`), nothing written.
+6. **Post-dry-run state** — all four config hashes equal pre-drill, state sidecar empty, zero proxy reloads; full suite/Ruff/Pyright/`validate.sh` green on the final code.
+7. **Step 4 blocked** — the off→on drill on a named route awaits an operator present to watch; the ingress is left byte-identical to its pre-work state.
diff --git a/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__07_rehearse_then_drill_on_the_live_ingress.a1.md.status b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__07_rehearse_then_drill_on_the_live_ingress.a1.md.status
new file mode 100644
index 0000000..2e6b050
--- /dev/null
+++ b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__07_rehearse_then_drill_on_the_live_ingress.a1.md.status
@@ -0,0 +1 @@
+error: compaction failed: Auto-compaction failed: Summarization failed: generation hit the token cap and the summary is incomplete; compaction failed: Auto-compaction…
diff --git a/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__07_rehearse_then_drill_on_the_live_ingress.a1.validate b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__07_rehearse_then_drill_on_the_live_ingress.a1.validate
new file mode 100644
index 0000000..f735b4d
--- /dev/null
+++ b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__07_rehearse_then_drill_on_the_live_ingress.a1.validate
@@ -0,0 +1,73 @@
+== uv run pytest --cov (hermetic: no network, live tests skip) ==
+........................................................................ [ 3%]
+........................................................................ [ 7%]
+........................................................................ [ 10%]
+........................................................................ [ 14%]
+........................................................................ [ 18%]
+........................................................................ [ 21%]
+........................................................................ [ 25%]
+........................................................................ [ 29%]
+........................................................................ [ 32%]
+.........................s.............................................. [ 36%]
+........................................................................ [ 40%]
+........................................................................ [ 43%]
+........................................................................ [ 47%]
+........................................................................ [ 51%]
+........................................................................ [ 54%]
+........................................................................ [ 58%]
+........................................................................ [ 62%]
+........................................................................ [ 65%]
+........................................................................ [ 69%]
+........................................................................ [ 73%]
+.................................................sssssssssssss.......... [ 76%]
+........................................................................ [ 80%]
+........................................................................ [ 84%]
+........................................................................ [ 87%]
+........................................................................ [ 91%]
+........................................................................ [ 95%]
+......................................sss............................... [ 98%]
+........................ [100%]
+=============================== warnings summary ===============================
+.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
+ /home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
+ from starlette.testclient import TestClient as TestClient # noqa
+
+-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
+================================ tests coverage ================================
+_______________ coverage: platform linux, python 3.14.8-final-0 ________________
+
+Name Stmts Miss Cover Missing
+------------------------------------------------------------------
+src/dashboards/__init__.py 3 0 100%
+src/dashboards/__main__.py 14 1 93% 32
+src/dashboards/alerts.py 201 0 100%
+src/dashboards/api.py 625 9 99% 229, 1273-1275, 1310-1313, 1382, 1390, 1446, 1449
+src/dashboards/app.py 190 0 100%
+src/dashboards/auth.py 265 0 100%
+src/dashboards/certs.py 292 0 100%
+src/dashboards/collectors.py 244 5 98% 386-387, 391-392, 409
+src/dashboards/config.py 128 0 100%
+src/dashboards/debug.py 14 0 100%
+src/dashboards/poller.py 165 3 98% 192-194, 279
+src/dashboards/prom.py 112 5 96% 126, 163, 177, 180, 183
+src/dashboards/rates.py 73 0 100%
+src/dashboards/severity.py 49 0 100%
+src/dashboards/sources/__init__.py 0 0 100%
+src/dashboards/sources/disks.py 134 0 100%
+src/dashboards/sources/gateway.py 613 10 98% 339, 588, 601-602, 652, 785, 1089, 1140, 1207, 1569
+src/dashboards/sources/host.py 209 0 100%
+src/dashboards/sources/llm.py 178 1 99% 285
+src/dashboards/sources/nut.py 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736
+src/dashboards/sources/nut_sim.py 134 0 100%
+src/dashboards/store.py 313 2 99% 553-554
+------------------------------------------------------------------
+TOTAL 4369 56 99%
+Required test coverage of 94% reached. Total coverage: 98.72%
+1951 passed, 17 skipped, 1 warning in 193.21s (0:03:13)
+== uv run ruff check src tests agent ==
+All checks passed!
+== uv run pyright ==
+0 errors, 0 warnings, 0 informations
+== node --check src/dashboards/web/static/*.js ==
+advisory: dependencies are out of date (AGENTS.md rule 8 — update on purpose):
+ anyio 4.15.1 4.16.0 wheel
diff --git a/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__07_rehearse_then_drill_on_the_live_ingress.a2.err b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__07_rehearse_then_drill_on_the_live_ingress.a2.err
new file mode 100644
index 0000000..e69de29
diff --git a/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__07_rehearse_then_drill_on_the_live_ingress.a2.md b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__07_rehearse_then_drill_on_the_live_ingress.a2.md
new file mode 100644
index 0000000..d90dcc8
--- /dev/null
+++ b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__07_rehearse_then_drill_on_the_live_ingress.a2.md
@@ -0,0 +1,10 @@
+All green. Task 07 re-run is complete:
+
+1. **Fixed:** anyio 4.15.1 → 4.16.0 (`uv lock` + `uv sync`; `uv pip list --outdated` now empty, rule 8); completed the two missing task-07 deliverables.
+2. **Added** the opt-in live test (step 8): `test_the_switch_round_trip_on_one_named_route` — `live`-marked, gated by a separate `--allow-live-writes` flag (new `conftest.py` option), skips by default with a reason naming its route, restores in a `finally`; dials the app's own `collectors.post_route` and proves the round trip on the production read (dry-run diff, helper's `applied/validated/reloaded`, row `enabled` both ways, byte-identical haproxy section pre/post).
+3. **Wrote** `data/logs/NOTES-33-switching.md`: first line = *all routes enabled*; before/after hashes identical (drill never wrote); guarded unit diff = exactly ` --allow-writes`; socket `0600 dashboards-agent:dashboards-agent`; three probe transcripts (`list` → `{"ok":true,"state":[]}`, foreign uid → EACCES, wire body gate); journal window (two activations, no reload).
+4. **Decisions:** drill stopped at step 4 — (a) no operator said go in-session (the task's own stop condition) and (b) independently, the checker precondition cannot pass: no host `nginx`/`haproxy` binary on the box (podman container units), so a real `--apply` refuses at the checker and restores; the checker fix (container path or host binaries) is a task-08 decision, not made unilaterally.
+5. **New finding (recorded, not fixed):** this box's systemd 257 build silently drops the helper unit's `ProtectSystem=read-only` (gperf bug — `off/yes/strict/full` parse clean); the socket gate is intact; candidate `full` + state-dir `ReadWritePaths` fix left for operator review with the unit diff.
+6. **Gates:** `validate.sh` exit 0 — **1951 passed, 18 skipped**, coverage **98.72%** (floor 94%), Ruff + Pyright clean, JS syntax + hygiene pass; dev server: 10 sources `ok`.
+7. **Live read-only:** `pytest -m live --live` → 13 passed, 1 skipped (the round-trip, skip reason names `haproxy:5201->web.reeselink.com:5201`).
+8. **Next pending:** task 08 (docs + live verify) — the drill itself (needs an operator's go plus the checker fix) and the `ProtectSystem` decision.
diff --git a/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__07_rehearse_then_drill_on_the_live_ingress.a2.md.status b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__07_rehearse_then_drill_on_the_live_ingress.a2.md.status
new file mode 100644
index 0000000..9766475
--- /dev/null
+++ b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__07_rehearse_then_drill_on_the_live_ingress.a2.md.status
@@ -0,0 +1 @@
+ok
diff --git a/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__07_rehearse_then_drill_on_the_live_ingress.a2.validate b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__07_rehearse_then_drill_on_the_live_ingress.a2.validate
new file mode 100644
index 0000000..34e22df
--- /dev/null
+++ b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__07_rehearse_then_drill_on_the_live_ingress.a2.validate
@@ -0,0 +1,71 @@
+== uv run pytest --cov (hermetic: no network, live tests skip) ==
+........................................................................ [ 3%]
+........................................................................ [ 7%]
+........................................................................ [ 10%]
+........................................................................ [ 14%]
+........................................................................ [ 18%]
+........................................................................ [ 21%]
+........................................................................ [ 25%]
+........................................................................ [ 29%]
+........................................................................ [ 32%]
+.........................s.............................................. [ 36%]
+........................................................................ [ 40%]
+........................................................................ [ 43%]
+........................................................................ [ 47%]
+........................................................................ [ 51%]
+........................................................................ [ 54%]
+........................................................................ [ 58%]
+........................................................................ [ 62%]
+........................................................................ [ 65%]
+........................................................................ [ 69%]
+........................................................................ [ 73%]
+.................................................ssssssssssssss......... [ 76%]
+........................................................................ [ 80%]
+........................................................................ [ 84%]
+........................................................................ [ 87%]
+........................................................................ [ 91%]
+........................................................................ [ 95%]
+.......................................sss.............................. [ 98%]
+......................... [100%]
+=============================== warnings summary ===============================
+.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
+ /home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
+ from starlette.testclient import TestClient as TestClient # noqa
+
+-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
+================================ tests coverage ================================
+_______________ coverage: platform linux, python 3.14.8-final-0 ________________
+
+Name Stmts Miss Cover Missing
+------------------------------------------------------------------
+src/dashboards/__init__.py 3 0 100%
+src/dashboards/__main__.py 14 1 93% 32
+src/dashboards/alerts.py 201 0 100%
+src/dashboards/api.py 625 9 99% 229, 1273-1275, 1310-1313, 1382, 1390, 1446, 1449
+src/dashboards/app.py 190 0 100%
+src/dashboards/auth.py 265 0 100%
+src/dashboards/certs.py 292 0 100%
+src/dashboards/collectors.py 244 4 98% 387, 391-392, 409
+src/dashboards/config.py 128 0 100%
+src/dashboards/debug.py 14 0 100%
+src/dashboards/poller.py 165 3 98% 192-194, 279
+src/dashboards/prom.py 112 5 96% 126, 163, 177, 180, 183
+src/dashboards/rates.py 73 0 100%
+src/dashboards/severity.py 49 0 100%
+src/dashboards/sources/__init__.py 0 0 100%
+src/dashboards/sources/disks.py 134 0 100%
+src/dashboards/sources/gateway.py 613 10 98% 339, 588, 601-602, 652, 785, 1089, 1140, 1207, 1569
+src/dashboards/sources/host.py 209 0 100%
+src/dashboards/sources/llm.py 178 1 99% 285
+src/dashboards/sources/nut.py 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736
+src/dashboards/sources/nut_sim.py 134 0 100%
+src/dashboards/store.py 313 2 99% 553-554
+------------------------------------------------------------------
+TOTAL 4369 55 99%
+Required test coverage of 94% reached. Total coverage: 98.74%
+1951 passed, 18 skipped, 1 warning in 189.82s (0:03:09)
+== uv run ruff check src tests agent ==
+All checks passed!
+== uv run pyright ==
+0 errors, 0 warnings, 0 informations
+== node --check src/dashboards/web/static/*.js ==
diff --git a/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__08_docs_and_live_verify.a1.err b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__08_docs_and_live_verify.a1.err
new file mode 100644
index 0000000..e69de29
diff --git a/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__08_docs_and_live_verify.a1.md b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__08_docs_and_live_verify.a1.md
new file mode 100644
index 0000000..6ee7512
--- /dev/null
+++ b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__08_docs_and_live_verify.a1.md
@@ -0,0 +1,10 @@
+All green. Final report:
+
+**Task 08 complete — docs, browser pass, full gate.**
+
+- **Docs**: `AGENTS.md` (invariant 1's two gated writes, 1.4.0 agent + marker grammar + no-retry rule, routectl deploy surface + units + byte-identical agent unit, `sources/gateway.py` `disabled`/merge/warnings + `/api/summary`, `api.py` gate order + audit events, `post_route` revoke-before-socket, fleet record, UI conventions, gap 8) and `README.md` (Route switching section: Enabling it + undo-everything, Switching a route with the three recovery commands and the deliberate-outage sentence, host-agent write, table row, two config rows, stale "unwired" paragraph fixed, orphaned UPS text restored) — plus `GATEWAY_ALLOW_WRITES` declared (never assigned) in both env templates, pinned by the new `tests/test_auth_docs.py` `WRITE_GATES` test.
+- **Browser pass**: `33-switching.cjs` **45/45 checks, exit 0** — both arms, `--gate-off`/`--gate-on` equality end-to-end in dark + light (on-half distinct from `--warn`/`--fail`), paint follows the payload never the click, `applied: false` arm, Escape, reduced-motion still, 360 px zero sideways scroll; 8 screenshots + `33-switching.out`.
+- **Mobile audit**: `27-task02-mobile-audit.cjs` re-run → **exit 0, 371 PASS, 0 FAIL, 0 finding** (`33-task08-mobile-audit.out`).
+- **Gate**: `validate.sh` **exit 0** (1953 passed, coverage 98.76 % ≥ 94, ruff clean incl. `deploy`, pyright 0, outdated empty); plain `uv run pytest` green; live suite **13 passed + the write round-trip skipped by design** (reason names the route it would touch).
+- **Status checks**: dev server (writes off) 10/10 ok unchanged; smoke server (flag on, fake agent) 10/10 ok honest (real mTLS dials — the script now seeds the dev DB's `certs.*` rows read-only, since a fresh DB mints a CA the agents refuse) and a curl write round-trips: dry-run diff → `applied/validated/reloaded` → next payload's row `#off`/`enabled: false`.
+- **Live call (conservative, no live changes)**: no real `DANGER` sent. The dry-run rehearsal stands from task 07; the phase's own decision leaves the drill to the operator, and this fleet's proxies run in podman containers so the host lacks the `nginx`/`haproxy` binary the helper's checker needs — a live drill here would refuse by construction (recorded in `NOTES-33-switching.md`). Read-only live verification is green.
diff --git a/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__08_docs_and_live_verify.a1.md.status b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__08_docs_and_live_verify.a1.md.status
new file mode 100644
index 0000000..9766475
--- /dev/null
+++ b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__08_docs_and_live_verify.a1.md.status
@@ -0,0 +1 @@
+ok
diff --git a/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__08_docs_and_live_verify.a1.validate b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__08_docs_and_live_verify.a1.validate
new file mode 100644
index 0000000..33396bd
--- /dev/null
+++ b/.agents/reports/33_gateway_route_switching/33_gateway_route_switching__08_docs_and_live_verify.a1.validate
@@ -0,0 +1,71 @@
+== uv run pytest --cov (hermetic: no network, live tests skip) ==
+........................................................................ [ 3%]
+........................................................................ [ 7%]
+........................................................................ [ 10%]
+........................................................................ [ 14%]
+........................................................................ [ 18%]
+........................................................................ [ 21%]
+........................................................................ [ 25%]
+........................................................................ [ 29%]
+........................................................................ [ 32%]
+.........................s.............................................. [ 36%]
+........................................................................ [ 40%]
+........................................................................ [ 43%]
+........................................................................ [ 47%]
+........................................................................ [ 51%]
+........................................................................ [ 54%]
+........................................................................ [ 58%]
+........................................................................ [ 62%]
+........................................................................ [ 65%]
+........................................................................ [ 69%]
+........................................................................ [ 73%]
+...................................................ssssssssssssss....... [ 76%]
+........................................................................ [ 80%]
+........................................................................ [ 84%]
+........................................................................ [ 87%]
+........................................................................ [ 91%]
+........................................................................ [ 94%]
+.........................................sss............................ [ 98%]
+........................... [100%]
+=============================== warnings summary ===============================
+.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
+ /home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
+ from starlette.testclient import TestClient as TestClient # noqa
+
+-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
+================================ tests coverage ================================
+_______________ coverage: platform linux, python 3.14.8-final-0 ________________
+
+Name Stmts Miss Cover Missing
+------------------------------------------------------------------
+src/dashboards/__init__.py 3 0 100%
+src/dashboards/__main__.py 14 1 93% 32
+src/dashboards/alerts.py 201 0 100%
+src/dashboards/api.py 625 9 99% 229, 1273-1275, 1310-1313, 1382, 1390, 1446, 1449
+src/dashboards/app.py 190 0 100%
+src/dashboards/auth.py 265 0 100%
+src/dashboards/certs.py 292 0 100%
+src/dashboards/collectors.py 244 5 98% 386-387, 391-392, 409
+src/dashboards/config.py 128 0 100%
+src/dashboards/debug.py 14 0 100%
+src/dashboards/poller.py 165 3 98% 192-194, 279
+src/dashboards/prom.py 112 5 96% 126, 163, 177, 180, 183
+src/dashboards/rates.py 73 0 100%
+src/dashboards/severity.py 49 0 100%
+src/dashboards/sources/__init__.py 0 0 100%
+src/dashboards/sources/disks.py 134 0 100%
+src/dashboards/sources/gateway.py 613 10 98% 339, 588, 601-602, 652, 785, 1089, 1140, 1207, 1569
+src/dashboards/sources/host.py 209 0 100%
+src/dashboards/sources/llm.py 178 1 99% 285
+src/dashboards/sources/nut.py 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736
+src/dashboards/sources/nut_sim.py 134 0 100%
+src/dashboards/store.py 313 2 99% 553-554
+------------------------------------------------------------------
+TOTAL 4369 56 99%
+Required test coverage of 94% reached. Total coverage: 98.72%
+1953 passed, 18 skipped, 1 warning in 191.57s (0:03:11)
+== uv run ruff check src tests agent ==
+All checks passed!
+== uv run pyright ==
+0 errors, 0 warnings, 0 informations
+== node --check src/dashboards/web/static/*.js ==
diff --git a/.env.dev.example b/.env.dev.example
index eddbaff..4e17c30 100644
--- a/.env.dev.example
+++ b/.env.dev.example
@@ -50,6 +50,12 @@ export HOST_TARGETS="driveripper=https://driveripper.example.net:9120 deskwork=h
# else, or to poll faster while developing the page.
# export GATEWAY_HOST=gateway
# export GATEWAY_INTERVAL_SECONDS=10
+# The route switch's write gate — a decision of its own: off by default (one flag does not
+# carry NUT_ALLOW_ACTIONS), and in a dev checkout it is a write to the **real fleet**: it
+# authorises the dashboard's session to POST /api/gateway/route through the agent's
+# certificate-first gate to the routectl helper, which can mark a route in the gateway's
+# config on or off, validate the file with the proxy's own checker, and reload that proxy.
+# export GATEWAY_ALLOW_WRITES=1
# The engines are dialled directly on their hostnames, like the agents.
export GUFO_BASE_URL=http://deskwork.example.net:8731
diff --git a/.env.example b/.env.example
index 05f1813..cf138c5 100644
--- a/.env.example
+++ b/.env.example
@@ -88,6 +88,12 @@ GATEWAY_HOST=gateway
# tables — with headroom because the same handler may be mid-smartctl pass for /disks.
GATEWAY_INTERVAL_SECONDS=30
GATEWAY_TIMEOUT_SECONDS=10
+# The route switch is the app's second write path and a decision of its own: off by
+# default, the same truthy rule as NUT_ALLOW_ACTIONS, and one flag does not carry the
+# other. The write's budget outlives the agent's 30 s peer timeout on purpose, so the
+# dashboard never times out first and leaves you guessing whether the reload happened.
+# GATEWAY_ALLOW_WRITES=1
+# GATEWAY_WRITE_TIMEOUT_SECONDS=35
# --------------------------------------------------------------------------- #
# LLM dashboard: either engine is optional — unset means "not deployed here"
@@ -137,8 +143,8 @@ NUT_USER=
NUT_PASSWORD=
NUT_INTERVAL_SECONDS=5
NUT_TIMEOUT_SECONDS=4
-# Instant commands (battery test, beeper, calibration) are the only write path in the
-# app. Requires NUT_USER plus this flag; the UI still confirms every command.
+# Instant commands (battery test, beeper, calibration) are one of the app's two write
+# paths. Requires NUT_USER plus this flag; the UI still confirms every command.
NUT_ALLOW_ACTIONS=0
# NUT_SIMULATE=1 replaces upsd with the labelled simulator (sources/nut_sim.py) so the UPS page
# is developable with no battery in the house. Every panel it produces says SIMULATED, and it is
diff --git a/AGENTS.md b/AGENTS.md
index dcdcec0..5df2ad4 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -28,24 +28,31 @@ file — plus an overview page:
| `/disks` | Are any physical disks failing, and which — health, temp, wear | the same agents, `smartctl -i -A -H` per whole drive |
| `/llm` | Tokens/s, active sessions, queue depth, KV pressure, latency | gufo `/metrics` + `/slots`, vLLM `/metrics` |
| `/ups` | Battery health, runtime left, power events, test buttons | NUT `upsd` (our own wire client; port **3051**, not 3493) |
-| `/gateway` | Which domains on which port, and where each goes | the same agent's `haproxy` + `nginx` sections joined to `/proc/net/tcp{,6}` on the one `GATEWAY_HOST` host |
+| `/gateway` | Which domains on which port, and where each goes | the same agent's `haproxy` + `nginx` sections joined to `/proc/net/tcp{,6}` on the one `GATEWAY_HOST` host (the page needs agent **≥ 1.2.0**; the route switch's `#off` marker read is **1.4.0**) |
History is a first-class feature: every number worth graphing is stored, rolled up
(raw → 1m → 1h) and served from `/api/*/history`.
None of it is public. One admin signs in at `/login` (the only page an anonymous visitor
reaches); `/settings` (⚙ Account) and `/certs` (⚷ Certs) live behind it. Every page and
-every `/api` route requires the session except the public entries in rule 12.
+every `/api` route requires the session except the public entries in invariant 12.
## Non-negotiable rules
Breaking one is a bug even if the page still renders.
-1. **Read-only by construction, with exactly one exception.** Nothing writes to an upstream
- except `POST /api/ups/command`, gated four times (env allow, not `DASH_READ_ONLY`, the
- upsd account actually having the command, a UI confirmation — typed `DANGER` for
- anything that can cut power) and re-checked on every request; power-cutting commands are
- refused outright while on battery. Hiding a button is not a safety control.
+1. **Read-only by construction, with exactly two exceptions, and both are gated on purpose.**
+ Nothing writes to an upstream except `POST /api/ups/command` and
+ `POST /api/gateway/route`, and each is re-checked on every request by its own gates:
+ the UPS one is gated four times (env allow, not `DASH_READ_ONLY`, the upsd account
+ actually having the command, a UI confirmation — typed `DANGER` for anything that can
+ cut power) and power-cutting commands are refused outright while on battery; the gateway
+ one is gated ten (not `DASH_READ_ONLY`, `GATEWAY_ALLOW_WRITES` — off by default — a
+ per-address write throttle, a hand-parsed body, the route's state in the current
+ payload, the freshness of the read end to end, and a typed `danger="DANGER"` for
+ **both** verbs — a `dry_run` preview asks nothing and writes nothing — for anything
+ that can cut a route through the fleet's only ingress), and it dials once through
+ the same revocation gate the reads take. Hiding a button is not a safety control.
2. **Never invent a number.** A value the upstream does not publish renders as `n/a`, never
as `0`. Every derived value carries its `source` string and `derived: true`; the rate
estimator is keyed by the *resolved* series name **plus its label set** and refuses a
@@ -108,9 +115,9 @@ Breaking one is a bug even if the page still renders.
session's `X-CSRF-Token`. A process whose lifespan never produced `ctx.auth` answers
`503` for everything, public paths included. `tests/test_auth_api.py` walks the
**served** route table so a route that forgets the gate fails a test instead of
- shipping — the middleware guards by default. Nothing may be added to `/api/health`
- without re-deciding that: it is public, and a new field there is a field the whole
- internet reads.
+ shipping — the middleware guards by default. `/api/health` is a contract with
+ deployment, which means **nothing may be added to it without re-deciding that**: it is
+ public, and a new field there is a field the whole internet reads.
## Architecture
@@ -118,7 +125,7 @@ Breaking one is a bug even if the page still renders.
agent/host_agent.py (each host) gufo / vLLM upsd (NUT)
│ GET /snapshot │ GET /metrics │ LIST UPS / VAR / CMD
▼ ▼ ▼
-collectors.py ── fetch, one UpstreamError type, no writes except run_ups_command
+collectors.py ── fetch, one UpstreamError type, no writes except run_ups_command and post_route
│ parse + Δcounter/Δt
▼
sources/host.py · disks.py · llm.py · nut.py · gateway.py poller.py (task per source)
@@ -144,10 +151,25 @@ One line each — the load-bearing behaviour is in the bullet, the rest is in th
error line per non-`true` arm — the missing one saying **not found**, the pinned wording
the dashboard reads to answer "is it installed?" — and `--no-proxy` / `--no-resolve`
remove their keys in silence. Nothing in a section can fail `/snapshot` or `/health`.
- Counters stay counters: the dashboard computes rates.
+ Counters stay counters: the dashboard computes rates. **The one write (1.4.0, gateway
+ hosts):** a single `POST /route`, refused without a verified, non-revoked client
+ certificate **and** the agent's own `--allow-writes` flag (certificate first, in that
+ order), forwarding exactly `{id, action, dry_run}` to the socket-activated root helper
+ `dashboards-routectl` and answering its JSON verbatim — **one connection, one
+ request, one answer, no retry**, because a retry of a write is a second reload. The
+ privilege is the helper's socket, not the agent's: the unit keeps
+ `NoNewPrivileges=true` + `ProtectSystem=strict` byte-identical with and without
+ `--with-routectl`, and no sudoers line is involved. Every `GET` path is untouched —
+ `#off` markers are read on the normal proxy sections (the marker's own grammar is the
+ agent's: `ROUTE_MARKER` / `split_marker` / `config_line_index` — `disabled` on
+ `server`/`proxy_pass`/`map` nodes; a marked line stays in the document).
- `config.py` — env only, no config file format, no CLI flags. `.env` is a *seed*; a real
env var always wins. Safe defaults except the sources, which default to unset. The
- gateway source is enabled only when `GATEWAY_HOST` names a `HOST_TARGETS` entry.
+ gateway source is enabled only when `GATEWAY_HOST` names a `HOST_TARGETS` entry
+ (default `gateway`). The write's two settings are beside it: `GATEWAY_ALLOW_WRITES`
+ (default **off**, the NUT flag's truthy rule) and `GATEWAY_WRITE_TIMEOUT_SECONDS`
+ (default 35 s, deliberately longer than the agent's 30 s peer timeout so the dashboard
+ never times out first); the read's 10 s budget stays the read's alone.
- `poller.py` — job loop, health records, stale/last-good semantics, transition events.
`disks:` is a source of its own (300 s / 30 s) and `gateway` is **one** job (30 s
/ 10 s) that exists only when configured, because the page is about the single ingress;
@@ -160,7 +182,11 @@ One line each — the load-bearing behaviour is in the bullet, the rest is in th
- `collectors.py` — the fetch half. `AgentTls` makes an `https://` `HOST_TARGETS` entry a
certificate-authenticated dial (per-dial client, this app's client cert verified against
its own root; the revocation answer is a fresh read per poll, so a `/certs` revoke bites
- on the next poll with no restart, before any socket opens). Failure messages name the
+ on the next poll with no restart, before any socket opens). `post_route` is the write's
+ dial beside `fetch_snapshot`: the same per-dial client and the same revocation check
+ before any socket opens, but **`Authorization` is never sent on the certificate path** —
+ the bundle is the credential there, and the token travels only on the plain token path.
+ The answer is the helper's own object, verbatim. Failure messages name the
**redacted** URL. Agent `errors` become `degraded` **except** the add-on lines
(`ADDON_ERROR_PREFIXES`: `smart`, `haproxy`, `nginx`, `sockets`, `resolver`) — a host
without smartctl or a proxy config is unconfigured, not broken (rule 3); those lines
@@ -179,8 +205,15 @@ One line each — the load-bearing behaviour is in the bullet, the rest is in th
credited **only through an address the gateway's own `resolver` section published** —
the module never calls a resolver itself (spy-pinned). Pipes are `null`, never `0`, when
unmeasured. `warnings()` judges the two config files against themselves (seven kinds,
- each `{id, kind, subject, message}`) and never joins a warning to a row. No `severity`
- anywhere in the payload (a busy proxy is the job).
+ each `{id, kind, subject, message}`) and never joins a warning to a row. Each row's
+ `enabled` carries the config's own state (the 1.4.0 `#off` marker, or a map entry
+ pointed at the helper's sink); a merge that would mix states splits into two rows
+ keyed by `enabled` (a union of selectors with one disabled line is two facts; the
+ `#off` suffix keeps the halves distinct); the marker-caused warning shapes — an
+ emptied backend, a targetless route, a sink-pointed entry — warn nothing, while the
+ same shape from a hand-deleted line still warns; the switch-off tally is published
+ as `/api/summary`'s `disabled` field. No `severity` anywhere in the payload (a busy
+ proxy is the job).
- `sources/llm.py` — gufo + vLLM field maps, measured Δ rates **diffed per label set**,
prefill speed, the session list from gufo `/slots`. A refused estimator window is still
an answer: the cell keeps its `source` and an honest `Δ` beside a `null`.
@@ -225,11 +258,18 @@ One line each — the load-bearing behaviour is in the bullet, the rest is in th
the startup read (it mints; nothing else may call it); `read_state` is the request-time
read (same validation, never a write). `cryptography` is the app's only new dependency,
used here alone; the agent stays stdlib.
-- `api.py` — GETs everywhere plus the one POST that dials an upstream. Every source's
+- `api.py` — GETs everywhere plus two POSTs that dial an upstream (`/api/ups/command` and
+ `/api/gateway/route`, invariant 1's two exceptions). Every source's
configured gate runs **before** anything that could reach the network (spy-pinned: no
- client, no fetch). `/api/config` is a redaction boundary. All five history routes go
- through one door (`history_many`). `/api/ups/command` stays the app's only upstream
- write (pinned by enumerating every served route's verbs).
+ client, no fetch). `/api/config` is a redaction boundary and serves
+ `gateway.writes_enabled` so the page can render the honest arm. All five history routes go
+ through one door (`history_many`). The upstream write set is exactly those two (pinned by
+ enumerating every served route's verbs; a third fails by name). The route write's gates
+ (invariant 1) run in a pinned order — read-only, flag, per-address throttle, body
+ shape, the id's state in the current payload, end-to-end freshness, typed `DANGER` for
+ both verbs — and every answered call stores one audit event under its own kind
+ (`route-disabled`/`route-enabled`/`route-denied`; a dry run stores none) in a fixed
+ shape: the id, the action, the helper's words — **never the config text**.
- `app.py` — assembly and the **session gate** middleware (invariant 12), answering in
this order: no `AuthState` → `503` for everything; no session and no such route → the
router's `404`; public → through (the origin check still applies to a public write);
@@ -247,8 +287,10 @@ One line each — the load-bearing behaviour is in the bullet, the rest is in th
Metric naming: dotted (`cpu.usage_pct`, `mem.used_pct`, `disk.used_pct@/srv`,
`gpu.busy_pct@0`, `ups.charge_pct`), `@stream` for per-device series. `sample.source` is
the dashboard family — `host`, `llm`, `ups`, `disks` (the SMART series) and `gateway`
-(the connection gauges) — so `GET /api/metrics?source=…` answers "what does *this page*
-have history for". Treat the scheme as a public interface of the history API.
+(the connection gauges: `gateway.in_conns@`, `gateway.out_conns@:`
+— the port belongs in the outbound name because two services on one host are two facts) —
+so `GET /api/metrics?source=…` answers "what does *this page* have history for".
+Treat the scheme as a public interface of the history API.
## The fleet (real environment)
@@ -256,19 +298,43 @@ have history for". Treat the scheme as a public interface of the history API.
| ------------- | ------------- | ------------------------------------------------------------------------- |
| `driveripper` | `driveripper-ops` | Threadripper 24c/48t + Radeon AI PRO R9700, vLLM `:8000`, **upsd**, the dashboards. 16 whole disks; the agent unit runs `User=root` with `CapabilityBoundingSet=CAP_SYS_RAWIO CAP_SYS_ADMIN` (kernel 7.2's unprivileged-passthrough gate; an empty bounding set is capability-less, `CapEff 0`) |
| `deskwork` | `deskwork-ops` | Strix Halo (Radeon 8060S iGPU), gufo `:8731` |
-| `gateway` | `gateway-ops` | the router, the fleet's ingress: haproxy (`/etc/haproxy/haproxy.cfg`, six frontends → six backends) + nginx (`/etc/nginx/nginx.conf` plus `conf.d/` vhosts and `stream.d/`'s `map` on `:443`); runs agent **1.3.0** |
+| `gateway` | `gateway-ops` | the router, the fleet's ingress: haproxy (`/etc/haproxy/haproxy.cfg`, six frontends → six backends) + nginx (`/etc/nginx/nginx.conf` plus `conf.d/` vhosts and `stream.d/`'s `map` on `:443`); runs agent **1.4.0** with the `dashboards-routectl` helper units |
- **Transport is direct dial; the fleet is mTLS.** `HOST_TARGETS` are `https://` with no
userinfo — the certificate bundle is the credential (the token in a URL is the
compatibility path for a host nobody has bundled). All three hosts run the installed
`dashboards-agent` systemd service, installed by `deploy/install-agent.sh` under the
- sudo grant. **Re-stage `/home/ops/agent-install/` first — the grant names a path, not a
- checkout —** and a re-run whose rendered unit differs from the installed one prints the
- diff and refuses without `--force`. `deskwork`/`driveripper` still run **1.1.0**: the
- 1.2.0/1.3.0 sections are purely additive (a 1.1.0 snapshot has none of the keys, which
- `sources/gateway.py` answers as `supported: false`), so they move at their next re-stage.
+ sudo grant — registration is the install: `--fetch-bundle --cert-name
+ --session ` pulls the host's own bundle from this dashboard during the install, so a
+ host is never registered onto a token URL because a `curl` was forgotten. **Re-stage
+ `/home/ops/agent-install/` first — the grant names a path, not a checkout —** and a re-run
+ whose rendered unit differs from the installed one prints the diff and refuses without
+ `--force`. `deskwork`/`driveripper` still run **1.1.0**: the
+ 1.2.0/1.3.0/1.4.0 sections are purely additive (a 1.1.0 snapshot has none of the keys,
+ which `sources/gateway.py` answers as `supported: false`), so they move at their next
+ re-stage.
A TLS agent serves https only — rolling a host back means removing the bundle lines too,
- not just the URL.
+ not just the URL. **The gateway's write (1.4.0) installs beside the agent, never into
+ it**: `install-agent.sh --allow-writes --with-routectl` adds one argument to the agent
+ unit's `ExecStart` (`--allow-writes` — a diff that shows a security directive moving
+ is a stop, not a `--force`) and installs `dashboards-routectl.py` (0755, root,
+ `/usr/local/sbin/`) plus the two units **`dashboards-routectl.socket`** (`0600`, owned
+ by the agent's user) and **`dashboards-routectl@.service`** (root, one instance per
+ connection, `ReadWritePaths` = `/etc/haproxy` + `/etc/nginx/conf.d` +
+ `/etc/nginx/stream.d` only), verifying the agent's uid can connect before reporting
+ success. **No sudoers line is involved** — the grant is a file mode systemd enforces,
+ and `--with-routectl` alone leaves the agent's unit byte-identical; the helper edits
+ one `#off` route (the marker carries the line's original text after its `was:`, a
+ per-route state sidecar records who last changed it and the file's hash, an `enable`
+ restores only an unedited line — an edited one is a `drift` refusal, no byte moves —
+ and a switched-off `map` entry points at the helper's own sink `server`), validates
+ with the proxy's own checker (restore on failure) and **reloads, never restarts**.
+ `--without-routectl` removes it, printing the still-off routes first;
+ `deploy/gateway-routectl-README.md` is the operator's page. The live **drill has not
+ run**: the install is verified end to end (task 07), but this fleet's proxies run in
+ podman containers, so the host has no `nginx`/`haproxy` binary for the checker —
+ `data/logs/NOTES-33-switching.md` records it, and a real `DANGER` is still the
+ operator's call.
- **NUT 2.8.5 on driveripper: configs in `/etc/ups/` (not `/etc/nut/`), upsd on port
3051 (not 3493 — nothing listens on 3493).** NUT 2.8 has no `ACL` directive;
per-source restriction is a **firewall** rule, never an `upsd.conf` line. Two UPSes:
@@ -367,7 +433,12 @@ operator's how-to for both.
configured routes branching off; its only colour pair is existence
(`--gate-on`/`--gate-off`) — no verdict colour anywhere on the page; its filter box is
an **eye, not a query** (it hides rows whose own payload text does not contain what was
- typed; no DNS, no re-ranking).
+ typed; no DNS, no re-ranking). Each row's switch runs on `GATEWAY_ALLOW_WRITES`: off
+ is a `aria-disabled` reading with the flag's own sentence (no hover, no listener, no
+ wire); on is a real control whose press is the dry-run diff → typed `DANGER` → one
+ send, and the row's colour is the payload's word from the next poll — never the
+ click's — with `off` reading `--gate-off` end to end (row, spine, the dialog's diff
+ tints) and the diff scrolling in its own `
`.
- Every chart names itself for assistive tech (`role="img"` + `aria-label`); every chart
on every page has one, pinned in `tests/test_api.py`.
@@ -376,9 +447,9 @@ operator's how-to for both.
1. The UPS gates are the control: the upsd account has `instcmds = all`, and NUT has no
call for a user's privileges — `enabled: true` cannot mean "may send", so the risk
tiers and typed `DANGER` are load-bearing, not decoration.
-2. One admin, one factor: no TOTP/WebAuthn, no per-user accounts; a logout ends only
+2. One admin, one factor — no second factor and no per-user accounts; a logout ends only
this browser's cookie, and the only revocation is a password change (which rotates the
- session secret). The login throttle is per-process and keys on the socket peer address
+ session secret). The throttle is per-process and keys on the socket peer address
(behind nginx every login shares one bucket; a restart clears it).
3. GPU coverage is AMD only — an NVIDIA host would report no GPU at all.
4. UPS history depth: events and numeric series are stored; long-window capacity fade has
@@ -392,8 +463,12 @@ operator's how-to for both.
7. The fleet trusts **this** deployment's CA — a second deployment with its own
`certs.*` rows cannot dial these agents until it carries the same root or re-issues
and re-installs every bundle.
-8. The `/gateway` route switches are built but **not wired** — a route write path is
- phase 33's decision and amends invariant 1; until then the knobs stay `aria-disabled`.
+8. Route switching is closed, and its honest limits are the gap list's:
+ `GATEWAY_ALLOW_WRITES` is off by default (a fresh deployment sees no switch arm at
+ all); the switch is one admin (gap 2) — whoever holds the session can switch a route
+ — with no per-host kill switch and no scheduler; and there is no runtime `disable
+ server` state: the marker is a config line, so a hand reload or a reboot does not
+ undo it (a runtime switch would be quietly undone by the next reload).
## Locked decisions (and why)
diff --git a/README.md b/README.md
index abc3b72..5f5dc54 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@ file:
| `/disks` | Are any of my **physical** disks failing, and which one — health, temperature, power-on hours, wear, sector alarms | the same agents, running `smartctl -i -A -H` per whole drive (never a partition or a mount); a host whose agent has no `smartctl` is not a host on this page |
| `/llm` | Tokens/sec, active sessions, queue depth, KV-cache pressure, latency percentiles | llama.cpp-family (gufo) `/metrics` + `/slots`, vLLM `/metrics` — read-only |
| `/ups` | Battery health, runtime left, power events, and battery-test buttons | NUT `upsd`, on whatever port it really listens on (its own wire-protocol client) |
-| `/gateway` | Which domains are coming in on which port, and where each one goes — **two connection tables, one per proxy config file** (`nginx`, then `haproxy`): a trunk per bound port with its configured routes branching off it, each line reading the config's own **inbound** word (`server_name`, `map` key, `backend` name) on line 1 over the counterpart it routes to on line 2 (`→ host:port`), plus its live connection count — and one filter box above the map that hides the routes whose own labels do not contain what you typed, asking nothing of the server | the agent on **one** host, `GATEWAY_HOST` (default `gateway`), reading its `haproxy.cfg` + nginx tree and `/proc/net/tcp{,6}` — so it needs **agent 1.2.0**, plus **1.3.0**'s `resolver` section to put a number on a route whose target the config writes as a *name* (without it those lines are drawn and dashed), and the whole page is *unconfigured* (never red) unless `GATEWAY_HOST` names a `HOST_TARGETS` entry |
+| `/gateway` | Which domains are coming in on which port, and where each one goes — **two connection tables, one per proxy config file** (`nginx`, then `haproxy`): a trunk per bound port with its configured routes branching off it, each line reading the config's own **inbound** word (`server_name`, `map` key, `backend` name) on line 1 over the counterpart it routes to on line 2 (`→ host:port`), plus its live connection count — and one filter box above the map that hides the routes whose own labels do not contain what you typed, asking nothing of the server | the agent on **one** host, `GATEWAY_HOST` (default `gateway`), reading its `haproxy.cfg` + nginx tree and `/proc/net/tcp{,6}` — so it needs **agent 1.2.0**, plus **1.3.0**'s `resolver` section to put a number on a route whose target the config writes as a *name* (without it those lines are drawn and dashed), plus **1.4.0**'s `#off` marker read to show a route the switch has turned off **or back on** (without it an off route is drawn on, because the marker is a comment a 1.3.0 agent does not read) — an off route stays drawn and counted, its row simply reads `enabled: false` (its id gains a `#off` suffix), and the config-integrity warnings the marker *caused* (a backend emptied of its last `server`, a map entry pointed at the switch's sink) are read as a chosen state while the same shape from a hand-deleted line is still a warning — and the whole page is *unconfigured* (never red) unless `GATEWAY_HOST` names a `HOST_TARGETS` entry |
No Prometheus, no Grafana, no CDN, no build step. Nothing is fetched from the internet at
runtime; every asset is served by this app.
@@ -111,8 +111,12 @@ sudo deploy/install-agent.sh --fetch-bundle https://dashboards.example.com \
sudo deploy/install-agent.sh --as-root --caps CAP_SYS_RAWIO,CAP_SYS_ADMIN
# ...or a kernel-gated host (SMART passthrough, below)
sudo deploy/install-agent.sh --force # ...only when it must overwrite a unit it would change
+sudo deploy/install-agent.sh --with-routectl # ...plus the route-switch helper (the gateway, below)
+sudo deploy/install-agent.sh --allow-writes --with-routectl # the gateway's full write install:
+ # the flag on the agent unit + the helper beside it
deploy/install-agent.sh --status # unit state and /health (https when TLS)
sudo deploy/install-agent.sh --uninstall
+sudo deploy/install-agent.sh --without-routectl # remove helper + units, keep config and state
```
Re-running the installer is the silent, idempotent path it has always been **as long as the unit
@@ -127,9 +131,30 @@ No root shell on the monitored host? `deploy/agent-sudoers` is the single sudoer
lets the fleet's unprivileged `ops` account run the installer; run it without root and it
prints that exact line with the script's own resolved path instead of saying "run as root".
-It is read-only by construction: there is no code path that writes, and the HTTP server
-only answers `GET`. Counters are reported as counters — the dashboard computes rates over
-a labelled window, so the agent never has to remember who polled it last.
+It is read-only by construction **except the one write below**: every `GET` path answers
+a read, and the HTTP server's one other path is the gated `POST /route`. Counters are
+reported as counters — the dashboard computes rates over a labelled window, so the agent
+never has to remember who polled it last.
+
+**The one write (1.4.0, the gateway).** 1.4.0 adds a single `POST /route`, reachable only
+with a verified client certificate and the agent's own `--allow-writes` flag, and it dials
+a **socket-activated root service** rather than giving the agent any privilege. The full
+write install is `--allow-writes --with-routectl`: the first adds exactly one argument to
+the agent unit's `ExecStart` (a re-run diff that shows a security directive moving is a
+stop, not a `--force`), and the second installs `deploy/dashboards-routectl.py` to
+`/usr/local/sbin/` (0755, root) and the two units **`dashboards-routectl.socket`** (a
+`0600` stream socket at `/run/dashboards-routectl.sock`, owned by the agent's user) and
+**`dashboards-routectl@.service`** (the helper, root, one instance per connection, `StandardInput=socket`,
+`ReadWritePaths` limited to `/etc/haproxy`, `/etc/nginx/conf.d` and `/etc/nginx/stream.d`), then
+verifies that the agent's own uid can connect before reporting success. **No sudoers line is
+involved**: the grant is a file mode systemd enforces, the helper's `SO_PEERCRED` check is
+the second look, and with `--with-routectl` alone the rendered `dashboards-agent.service` is
+byte-identical. The helper
+edits exactly one route (a `#off ` marker), validates the file with the proxy's own
+checker before it means anything, restores the previous bytes on a checker failure, and
+**reloads, never restarts**; `--without-routectl` removes it again, printing the list of
+still-off routes first. `deploy/gateway-routectl-README.md` is the operator's page for all
+of it.
Per-disk `smart` health is a best-effort add-on on the whole-disk `/sys/block` list (no
partitions): the agent runs `smartctl -i -A -H` per physical disk — 5 s bound per device,
@@ -271,8 +296,9 @@ a documentation one (RFC 5737 / RFC 3849), because a global IPv6 on Linux is EUI
visitor's address is not ours to publish. Every port, state, column and width in those tables is
verbatim, and those files are volatile by nature — a proxy in service has a different connection set
every second — so the tests assert shape and the ports the configs claim, never a particular row.
-`AGENT_VERSION` is **1.3.0**, the version that adds the `resolver` section on top of the three
-phase 28 shipped; `GET /schema` names all four, each with its `available` flag, and `--no-proxy`
+`AGENT_VERSION` is **1.4.0** — the `resolver` section (1.3.0) and the `#off` marker read
+plus the one write (below) on top of the three phase 28 shipped; `GET /schema` names all
+four, each with its `available` flag, and `--no-proxy`
and `--no-resolve` are listed there too. A host still running 1.2.0 answers with a snapshot that
simply has no `resolver` key — which is why the version spread costs nothing: a section an agent
did not collect is absence, and the gateway page counts the address-written routes it has and draws
@@ -714,10 +740,12 @@ curl -s -H "Authorization: Bearer $SESSION" -X POST -H 'content-type: applicatio
-d '{"ups":"apc","command":"test.panel.start"}' http://127.0.0.1:8090/api/ups/command
```
-That is the app's one write endpoint **that dials anything** (the `/api/auth/*` and `/api/certs/*`
-writes change only this process and its SQLite `meta` table — invariant 1 is about the fleet), and
-the bearer header changes nothing about its gates: a command that can cut output still needs
-`"confirm":"DANGER"`, and is still refused while the UPS is on battery.
+That is the UPS half of the app's two write endpoints **that dial anything** (the
+`/api/auth/*` and `/api/certs/*` writes change only this process and its SQLite `meta` table —
+invariant 1 is about the fleet; the other half is the `/gateway` route switch,
+`GATEWAY_ALLOW_WRITES`-gated), and the bearer header changes nothing about its gates:
+a command that can cut output still needs `"confirm":"DANGER"`, and is still refused
+while the UPS is on battery.
### Lockout
@@ -754,7 +782,9 @@ documentation and is never loaded, and `scripts/dev-server.sh` exports `.env.dev
| `GUFO_API_KEY` / `VLLM_API_KEY` | only if an engine is behind a bearer token; they never leave the server |
| `NUT_HOST` / `NUT_PORT` | upsd address — **whatever port it really listens on**, not always 3493. Reads are unauthenticated in stock NUT |
| `NUT_USER` / `NUT_PASSWORD` | only needed for instant commands (battery tests) |
-| `NUT_ALLOW_ACTIONS` | `1` enables the one write endpoint that dials **anything** — the `/api/auth/*` and `/api/certs/*` writes touch only this process and its `meta` table |
+| `NUT_ALLOW_ACTIONS` | `1` enables the UPS write endpoint that dials **anything** — the `/api/auth/*` and `/api/certs/*` writes touch only this process and its `meta` table |
+| `GATEWAY_ALLOW_WRITES` | `1` enables the second write endpoint, the `/gateway` route switch (`POST /api/gateway/route`); off by default, the same truthy rule as `NUT_ALLOW_ACTIONS`, and the two writes are two decisions — one flag does not carry the other |
+| `GATEWAY_WRITE_TIMEOUT_SECONDS` | the route write's own budget (default 35 s), deliberately longer than the agent's 30 s peer timeout so the dashboard never times out first and leaves you guessing whether the reload happened; the read's 10 s stays the read's own |
| `NUT_SIMULATE` | `1` replaces upsd with a **labelled** simulator (development only) |
| `DASH_READ_ONLY` | `1` disables every mutating endpoint regardless of other flags |
| `DASH_DB` | SQLite path (default `data/dashboards.sqlite3`) |
@@ -1218,17 +1248,23 @@ certs.py ─ root CA + client cert, minted once at startup (unreadable row → n
behind a collapsed **Charts** toggle like `/system`'s, one `/api/gateway/history` request per
panel per refresh. With no `GATEWAY_HOST` the page is the setup sentence rather than a red panel,
and an ingress running an agent older than **1.2.0** says so in the agent's own words with every
- count `n/a` and nothing coloured. Each line's head carries the sketched **switch** — a real
- `role="switch"` button, green and to the position because the route is in the config, named in full
- for a screen reader (`endpoint, reached by , in the nginx config, on`) — and it is
- **unwired on purpose**: `aria-disabled` with a tooltip that says so, and this app writes to no
- upstream, so a route is disabled by editing `haproxy.cfg` or an nginx `server` block and by nothing
- in a browser. Giving either of those two dashes or knobs a real backing store is deliberately **not**
- documented here as a thing you can have: name-based liveness needs the agent to report resolved
- targets (a future agent version), and switching a route needs a second gated write path in front of
- the proxy configs, which is a change to this app's read-only promise rather than a feature. What runs
- today is **agent 1.3.0** — name resolution on the gateway, counts on every declared route, and no
- switching.
+ count `n/a` and nothing coloured. Each line's head carries the **switch** — a real
+ `role="switch"` button, in the **on** position and tinted `--gate-on` because the route is in the config, named in full
+ for a screen reader (`endpoint, reached by , in the nginx config, on`) — and it has two
+ arms on `GATEWAY_ALLOW_WRITES`. With the flag off — the default — it is **unwired on
+ purpose**: `aria-disabled` with a tooltip that says so, and a route changes only by
+ editing `haproxy.cfg` or an nginx `server` block, never in a browser. With
+ `GATEWAY_ALLOW_WRITES=1` the same control is live:
+ activation opens one confirm dialog that first dials the helper as a dry run (`dry_run: true` —
+ it validates and answers with the one-line diff without writing a byte), then asks the operator
+ to type `DANGER`, then sends exactly once. The helper edits the route in its config file,
+ validates the file with the proxy's own checker and reloads that proxy — nothing is restarted —
+ and the switch flips the moment the helper answers `applied: true`, with the row re-read from
+ the next gateway poll. The write takes the read's own revocation gate on the same dial, and its
+ gates (read-only, the flag, a per-address throttle, the route's state in the current payload, the
+ read's freshness, the typed `DANGER` for both verbs) all run before anything reaches the host.
+ What runs today is **agent 1.4.0** — name resolution on the gateway, counts on every declared
+ route, the `#off` marker read, and the switch when the flag is set.
The four event feeds (`/system`, `/disks`, `/llm`, `/ups`) are a **paged window** over the `event`
table rather than a printout of it: the list is a bounded box that scrolls, 50 events to a
page, with `1–50 of 200` between `‹ prev` and `next ›` (disabled at the ends) underneath.
@@ -1444,9 +1480,10 @@ advertising a window no poll ever measured.
## Battery-test buttons
-Instant commands are the only endpoint in the application that changes anything **outside this
-process** (`POST /api/ups/command`; the session and certificate-authority writes change only this
-app's own rows), and they are gated four times: `NUT_ALLOW_ACTIONS=1`, not `DASH_READ_ONLY`, an `upsd.users`
+Instant commands are one of exactly two endpoints in the application that change anything
+**outside this process** (the other is the route switch, in the next section; the session
+and certificate-authority writes change only this app's own rows), and they are gated
+four times: `NUT_ALLOW_ACTIONS=1`, not `DASH_READ_ONLY`, an `upsd.users`
account that actually has the command, and a confirmation in the UI — destructive ones
(deep test, calibration, `load.off`) require typing `DANGER`. Two more sit in front of those
(since the login landed): a session, and this session's CSRF token when the write arrived on the
@@ -1493,6 +1530,58 @@ and an unauthenticated `LIST VAR` from any routed source is the normal state of
`NUT_SIMULATE=1` stands in for upsd during
development; everything it produces is badged `SIMULATED`, and it is never the default.
+## Route switching
+
+`/gateway` can change its own subject: each row's switch turns a route **off** and, the
+other way around, **back on**. Off is the helper's `#off` marker on the config line
+(a switched-off `map` entry is pointed at the helper's own sink `server` instead); on is
+the marker removed and the line's original text restored — but only if the line is
+unedited since (an edited line is a `drift` refusal and no byte moves). The switch is
+**a decision of its own**, off by default, and it is the application's second write path
+(the first is the UPS command buttons above).
+
+### Enabling it
+
+The write is the flag `GATEWAY_ALLOW_WRITES` (the same truthy rule as
+`NUT_ALLOW_ACTIONS`, and one flag does not carry the other) and it only exists on a
+host the operator installed `--allow-writes --with-routectl` on — the certificate-first
+gate on the agent and the socket-activated root helper beside it (*The host agent*
+section above). The install is the agent unit's one extra argument plus the helper and
+its two units, `dashboards-routectl.socket` and `dashboards-routectl@.service`; to undo
+everything, `sudo ./deploy/install-agent.sh --without-routectl` removes the helper and
+its units, **leaves the proxy configs and the state directory alone**, and prints the
+still-off routes first — those are yours to switch back on. With the flag off, every
+switch is a `disabled` reading with the flag's own sentence, and a click does nothing:
+no wire is touched, no request is sent.
+
+### Switching a route
+
+The press runs the flow the operator can see: the **dry-run diff first** — the
+helper's own word for what the change will do, before the operator is asked for the
+word — then the confirmation that carries the diff and a **typed `DANGER`** (both verbs
+ask for it: turning a route back on is a write to the fleet's only ingress, just as
+turning it off is), then **exactly one send** — one connection, one request, one
+answer, **no retry** (a retry of a write is a second reload). The answer's words are
+printed verbatim: `applied: true` flips the switch and the row's colour follows the
+**payload's** word on the next gateway poll — never the click's; `applied: false`
+flips nothing. Every call stores one audit event — `route-disabled`, `route-enabled`, or `route-denied`
+for the gates' and the helper's refusals (a dry run stores none: it changed nothing) —
+so the audit trail is one line per call with who and when — and a switch **outages the
+route on purpose: it will appear in the event feed**.
+
+The honest limits: the switch is one admin (the sign-in's one factor, no more), with no
+per-host kill switch and no scheduler; and there is no runtime `disable server` state —
+the marker is a config line, so a hand reload or a reboot does not undo it (a runtime
+switch would be quietly undone by the next reload).
+
+When the dashboard is the thing that is broken at 03:00, the recovery is on the box:
+`/usr/local/sbin/dashboards-routectl.py` (its operator page is
+`deploy/gateway-routectl-README.md`). `sudo /usr/local/sbin/dashboards-routectl.py list`
+says what is off (the state sidecar), `sudo … verify` checks the sidecar against the
+files (drift is reported, never repaired), and `sudo … enable --id ` puts one route
+back on through the same protocol — the write, the proxy's own checker, restore on a
+checker failure, the reload (never a restart), the state record.
+
## Deployment
`Containerfile` is a two-stage build: `uv.lock` is resolved in the builder (exactly the
diff --git a/agent/host_agent.py b/agent/host_agent.py
index 3aadfff..27d098e 100644
--- a/agent/host_agent.py
+++ b/agent/host_agent.py
@@ -2,13 +2,19 @@
"""host_agent.py — read-only host metrics agent for the homelab dashboards.
**Stdlib only, one file, Python 3.10+.** Copy it to a host, run it, and the dashboard can
-read that host. It never writes anything anywhere: every number comes from reading
-/proc, /sys and statvfs, and the HTTP server only answers GET.
+read that host. Every number comes from reading /proc, /sys and statvfs, and the HTTP
+server answers GET — with exactly one exception: 1.4.0's `POST /route`, the fleet's one
+narrow write (switching a route's `#off` marker). Its gates are the client certificate and
+`--allow-writes`, and it never touches a config file itself: it dials the socket-activated
+root helper (`--routectl-socket`) once and passes the helper's JSON through verbatim.
GET /health -> {"ok": true, "hostname": ..., "version": ...}
GET /info -> slow-changing identity (os, kernel, cpu model, cores, gpu list)
GET /snapshot -> everything, one JSON document (see SCHEMA below)
GET /schema -> this docstring's SCHEMA section, for self-documenting deploys
+ POST /route -> the one write: forward exactly {id, action, dry_run} to the
+ routectl helper and answer its JSON verbatim (1.4.0; certificate
+ plus --allow-writes, in that order — a token-mode POST is refused)
Design rules
------------
@@ -89,6 +95,20 @@ Design rules
are simply absent — `null` a pass that could not run), and `--no-resolve` removes the key in
silence the way `--no-smart` does; `--no-proxy` removes it too, because a box that collects no
proxy has no declared names to look up.
+* **The one write is a forward, not a file edit.** `POST /route` (1.4.0) is the only non-GET
+ path and the only thing this process ever sends to an upstream. The order is the design:
+ the client certificate (the handshake is the identity — a bearer token never gets this
+ path), then `--allow-writes` (the operator's decision, off by default), then the body
+ (exactly `{id, action, dry_run}`, ≤ 4096 bytes, an `action` of `disable`/`enable`, an
+ `id` of at most 256 characters — a refused body never reaches the peer), then the
+ helper's socket. The dial is `AF_UNIX` with a 30 s budget (the helper may run `nginx -t`
+ and a reload), one newline-delimited request, one line back, and **never a retry — a
+ retry of a write is a second reload**: every failure is a 502 (an unreadable or
+ oversized answer, a timeout, or the errno word) and the helper's JSON is passed through
+ verbatim, refusal included. Without `--routectl-socket` (the flag's value set empty) the
+ answer is `503 no route helper on this host`. The bytes are written by the socket-
+ activated root helper (`deploy/dashboards-routectl.py`); this process opens no config
+ file for writing, so `--allow-writes` on a box without the helper is a 503, not a write.
* **The state** is a small pid->cpu-time cache so `processes.top_cpu` can answer a
question /proc cannot (CPU needs two samples), bounded and cleared when stale, plus
the short-TTL SMART result cache above.
@@ -139,18 +159,27 @@ SCHEMA (GET /snapshot)
top_cpu: [{pid, name, cpu_pct}]}
haproxy {available: true|false|null, config: str, frontends: [{name, binds:
[{addr, port}], default_backend: str|null, backends: [str]}],
- backends: [{name, servers: [{name, target, port}]}]}
+ backends: [{name, servers: [{name, target, port,
+ disabled: bool}]}]}
# parsed from --haproxy-config; additive (phase 28) and absent entirely
# with --no-proxy. `addr` is the bind's address as written (`:::22` ->
# `::`), `port` null when a target names none (never 0), and the
# frontend->backend routing is the names themselves: the dashboard
# joins them, the agent reports only what the config says.
+ # `disabled` is the route switch's read side (phase 33, agent 1.4.0):
+ # true when the line carries the helper's `#off ` marker
+ # (ROUTE_MARKER), false otherwise. The marker is a comment in both
+ # configs, so a marked line is still a live line to haproxy/nginx until
+ # the helper rewrites the value — the agent reports the state, the
+ # dashboard draws it, and the kernel does not know markers.
nginx {available: true|false|null, config: str, files: [str],
servers: [{listen: [{addr, port}], server_names: [str], proxy_passes:
[{location: str|null, scheme: str|null,
- target: {host, port}|null, variable: str|null}]}],
- upstreams: [{name, servers: [{host, port}]}],
- maps: [{variable, entries: [{key, target: {host, port}|null}]}]}
+ target: {host, port}|null, variable: str|null,
+ disabled: bool}]}],
+ upstreams: [{name, servers: [{host, port, disabled: bool}]}],
+ maps: [{variable, entries: [{key, target: {host, port}|null,
+ disabled: bool}]}]}
# parsed from --nginx-config plus the files its own `include` lines name
# (one level, at most 8 files / 1 MB of included text; `files` is the
# main config then every include actually merged); additive (phase 28)
@@ -158,6 +187,10 @@ SCHEMA (GET /snapshot)
# written (`[::]:8443` -> `"::"`, a bare `80` -> a null addr, never `*`),
# `server_names` keeps the catch-all `_` verbatim, `location` is null for
# a proxy_pass written in the server block itself (the stream form), and
+ # `disabled` (phase 33, agent 1.4.0) is the line's `#off ` marker
+ # read by the tokenizer, exactly as the haproxy section's: on a
+ # `proxy_pass`, an `upstream` member line, or a `map` entry line. See
+ # the haproxy note for what the flag does and does not mean.
# a `proxy_pass $var` is served with `variable` (the `$` dropped) and
# `target: null`: the map whose variable matches decides per connection,
# so both facts travel unmerged and the dashboard joins them.
@@ -213,7 +246,12 @@ other protection is `--cert-bundle`/AGENT_CERT_BUNDLE: one installed PEM (server
key, the dashboard's root CA, `# BUNDLE-REV ` and `# REVOKED ` lines) turns the
server into HTTPS that answers every path but `/health` only to a client certificate signed
by that root, and refuses one on the denylist on every path — the certificate is the auth, so
-no token is read on that path.
+no token is read on that path. The write path (1.4.0, the gateway) is `--allow-writes`
+(default off) plus `--routectl-socket` (default /run/dashboards-routectl.sock, the helper's
+socket; set the flag's value empty for the `503 no route helper on this host` arm): a
+`POST /route` is then answered certificate-first, then flag, then body, then one dial to
+that socket, and the helper's JSON is passed through verbatim — see the design rule above
+for the refusal sentences.
"""
from __future__ import annotations
@@ -237,8 +275,65 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any, cast
-AGENT_VERSION = "1.3.0"
+AGENT_VERSION = "1.4.0"
SCHEMA_VERSION = 1
+#: POST /route: one newline-delimited JSON request to the routectl helper, one line back.
+#: The budget covers the helper's own checker run (`nginx -t` / `haproxy -c -f`) plus reload.
+ROUTE_TIMEOUT_SECONDS = 30
+#: The largest body the write path accepts, checked before a byte is read off the wire.
+_ROUTE_MAX_BODY = 4096
+#: An answer longer than this is "unreadable" — the helper's status JSON never is.
+_ROUTE_MAX_ANSWER = 131072
+# The route switch's marker, one constant the parser and the write path both read (phase 33).
+# A comment in both haproxy and nginx, so `line, marker = split_marker(line)` on a route line
+# is the whole of the read side: a marked line is still a live line to the proxy until the
+# helper rewrites its value, and this is the state, not a behaviour the agent enforces.
+ROUTE_MARKER = "#off"
+
+
+def split_marker(line: str) -> tuple[str, str | None]:
+ """Split a config line into ``(line, marker)`` for the helper's ``#off `` marker.
+
+ The marker is the **whole** trailing comment (``#...`` through end of line), so the
+ grammar is one and the write path (task 01's ``routectl``) and the parser share it: a
+ line is marked when its comment *is* ``#off `` — a marker with nothing after the
+ word, and nothing but the word, after it. Anything else in the comment is the operator's
+ own note, not a switch, and the line is reported ``disabled: false``. A marked line stays
+ in the document (the helper edits the value, never the line), which is what lets the
+ dashboard tell "declared, off" from "never declared" (that split is the dashboard's
+ ``enabled`` field, phase 33 task 04).
+ """
+ for i, ch in enumerate(line):
+ if ch == "#":
+ marker = line[i:]
+ if marker == ROUTE_MARKER or marker.startswith(ROUTE_MARKER + " "):
+ name = marker[len(ROUTE_MARKER):].strip()
+ if name and " " not in name:
+ return line[:i].rstrip(), name
+ return line, None
+ return line, None
+
+
+def marker_for(name: str) -> str:
+ """The full marker comment for one route name (the write path's spelling, pinned here so
+ the parser and task 01's helper cannot drift). One name per marker: a marker is a whole
+ comment and the name has no spaces, so the split above is exact."""
+ return f"{ROUTE_MARKER} {name}"
+
+
+def config_line_index(text: str, name: str) -> int:
+ """The 1-based line number of the route line carrying ``name``'s marker, or ``-1``.
+
+ Task 01's helper needs the line to edit; the marker names the route, so the search is
+ the marker itself, never the route's text (a target string appears in many lines and
+ only one of them is the route). `-1` is "not found", which the helper maps to its
+ `not-found` refusal.
+ """
+ marker = marker_for(name)
+ for i, line in enumerate(text.splitlines()):
+ if split_marker(line)[1] == name and marker in line:
+ return i + 1
+ return -1
PAGE_SIZE = 0 # resolved from sysconf at runtime
# Filesystems that are not storage. Mirrors the spirit of node_exporter's default
@@ -2037,6 +2132,11 @@ def _scan_haproxy_config(text: str) -> tuple[list[dict[str, Any]], list[dict[str
if backend is not None and backend not in current_fe["backends"]:
current_fe["backends"].append(backend)
if current_be is not None and directive == "server" and len(words) > 2:
+ # the route switch's read side (phase 33, agent 1.4.0): the marker is a trailing
+ # comment, `#off `, and a marked line is still a live line to haproxy until
+ # the helper rewrites the value — the agent reports the state, the dashboard
+ # draws it (`enabled: false`), and the kernel does not know markers.
+ marked = split_marker(stripped)[1]
target = _haproxy_addr_port(words[2])
current_be["servers"].append({
"name": words[1],
@@ -2044,6 +2144,7 @@ def _scan_haproxy_config(text: str) -> tuple[list[dict[str, Any]], list[dict[str
# port — "absent is never 0", on the one field haproxy makes optional
"target": words[2] if target is None else target[0],
"port": None if target is None else target[1],
+ "disabled": marked is not None,
})
return frontends, backends, recognised
@@ -2052,9 +2153,12 @@ def parse_haproxy_config(text: str) -> dict[str, Any]:
"""Parse a haproxy config's **text** into the `haproxy` section's data. Pure, never raises.
`{frontends: [{name, binds: [{addr, port}], default_backend, backends}], backends:
- [{name, servers: [{name, target, port}]}], recognised: bool}` — the frontend→backend routing
- is the names themselves (`default_backend` plus every `use_backend`), so the dashboard joins
- them and this file interprets no rule.
+ [{name, servers: [{name, target, port, disabled}]}], recognised: bool}` — the frontend→
+ backend routing is the names themselves (`default_backend` plus every `use_backend`), so the
+ dashboard joins them and this file interprets no rule. `disabled` is the line's `#off
+ ` marker (phase 33, agent 1.4.0; `ROUTE_MARKER`, `split_marker`), `true` on a marked
+ line, `false` on every other line — the write path (the helper) is the only writer, and a
+ marker with anything but one name after it is the operator's comment, not a switch.
`recognised` answers the separate question "is this text a haproxy config at all" (does its
top level name a section keyword?) and is deliberately **not** part of the served section:
@@ -2070,7 +2174,7 @@ def parse_haproxy_config(text: str) -> dict[str, Any]:
def _nginx_tokens(text: str) -> list[tuple[str, str]]:
- """Split nginx config text into `("w", word)`, `("{", "")`, `("}", "")`, `(";", "")`.
+ """Split nginx config text into `("w", word)`, `("{", "")`, `("}", "")`, `(";", comment)`.
nginx's grammar is words closed by `;` or opened by `{`, with `#` comments and quoted
strings that may contain any of those three — the captured gateway config writes
@@ -2083,16 +2187,28 @@ def _nginx_tokens(text: str) -> list[tuple[str, str]]:
A `#` opens a comment only where a word would have started, which is where nginx's own
lexer reads one: `proxy_pass http://host/#/app` keeps its fragment, and the
`# include /etc/nginx/old.conf;` line an operator commented out contributes nothing.
+
+ The one value a token carries is a `;` (or `}`) token's **end-of-line comment**: a comment
+ on the same line, after the statement that closed it, is attached to that token, and every
+ other comment is dropped as before. That is the route switch's read side (phase 33):
+ `server s1 10.1.1.9:443 #off s1` is a live line with a state, and the statement scanner is
+ what hands the comment to the directive. A comment at the start of a line (a commented-out
+ directive) belongs to no statement and is dropped, exactly as today.
"""
tokens: list[tuple[str, str]] = []
word: list[str] = []
started = False # an empty quoted string is still a word: the `''` key of a map
+ line_terminated = False # a `;` or `}` has been emitted on this line
i, n = 0, len(text)
while i < n:
char = text[i]
if char == "#" and not started and (i == 0 or text[i - 1] in " \t\r\n"):
- while i < n and text[i] != "\n":
- i += 1
+ end = i
+ while end < n and text[end] != "\n":
+ end += 1
+ if line_terminated and tokens and tokens[-1][0] in ";}":
+ tokens[-1] = (tokens[-1][0], text[i:end])
+ i = end
continue
if char in "\"'":
quote = char
@@ -2116,12 +2232,15 @@ def _nginx_tokens(text: str) -> list[tuple[str, str]]:
tokens.append(("w", "".join(word)))
word, started = [], False
tokens.append((char, ""))
+ line_terminated = True
i += 1
continue
if char.isspace():
if started:
tokens.append(("w", "".join(word)))
word, started = [], False
+ if char == "\n":
+ line_terminated = False
i += 1
continue
word.append(char)
@@ -2132,15 +2251,28 @@ def _nginx_tokens(text: str) -> list[tuple[str, str]]:
return tokens
+def _comment_off(comment: str) -> bool:
+ """Does this end-of-line comment carry the route switch's marker?
+
+ The grammar is `split_marker`'s — the one the write path (task 01's helper) shares —
+ applied to a line whose only comment is the one the tokenizer attached: `#off `
+ with exactly one name after the word. The delegation is the point: the parser and the
+ helper cannot read the same marker differently.
+ """
+ return split_marker(f"line {comment}")[1] is not None
+
+
def _nginx_statements(text: str) -> list[tuple[Any, ...]]:
"""The brace-block scanner: tokens in, nested statements out. Raises on an unbalanced file.
- A statement is `("d", name, args)` for a directive or `("b", name, args, children)` for a
- block. That is the whole difference between the two config languages this agent reads:
- haproxy's structure is its indentation, nginx's is its braces, so nginx gets a scanner
- instead of a line loop — and with it the two shapes that make the language what it is, a
- `server { … location { … } … }` nested as deep as it goes and an `upstream` body whose
- `server` lines are *targets* while an `http` body's `server {` is a *block*.
+ A statement is `("d", name, args, comment)` for a directive — `comment` is the end-of-line
+ comment the tokenizer attached (the route switch's marker lives there, phase 33) or `""` —
+ and `("b", name, args, children)` for a block. That is the whole difference between the two
+ config languages this agent reads: haproxy's structure is its indentation, nginx's is its
+ braces, so nginx gets a scanner instead of a line loop — and with it the two shapes that
+ make the language what it is, a `server { … location { … } … }` nested as deep as it goes
+ and an `upstream` body whose `server` lines are *targets* while an `http` body's `server {`
+ is a *block*.
`ValueError` here means "nginx itself would refuse to start on this file": a stray brace or
a directive that runs into the end of the file without its `;`. The caller turns that into
@@ -2156,7 +2288,7 @@ def _nginx_statements(text: str) -> list[tuple[Any, ...]]:
continue
if kind == ";":
if words: # a bare `;` is whitespace to nginx
- containers[-1].append(("d", words[0], words[1:]))
+ containers[-1].append(("d", words[0], words[1:], value))
words = []
continue
if kind == "{":
@@ -2271,6 +2403,10 @@ def _collect_nginx(statements: _NginxStatements, out: dict[str, Any], *, root: b
"""
for statement in statements:
kind, name, args = statement[0], statement[1], statement[2]
+ # the end-of-line comment, if the tokenizer attached one: the route switch's marker
+ # (phase 33) lives on the route line itself, and `_comment_off` is its read
+ comment = statement[3] if len(statement) > 3 and isinstance(statement[3], str) else ""
+ marked = _comment_off(comment)
if kind == "d":
if name == "include" and args:
if root:
@@ -2284,6 +2420,7 @@ def _collect_nginx(statements: _NginxStatements, out: dict[str, Any], *, root: b
# keeps its name and a null port rather than losing the line entirely
"host": args[0] if found is None else found[0],
"port": None if found is None else found[1],
+ "disabled": marked,
})
continue
if map_ctx is not None:
@@ -2297,6 +2434,7 @@ def _collect_nginx(statements: _NginxStatements, out: dict[str, Any], *, root: b
# `upgrade` / `close` values are header words, and calling one a host
# would be this section inventing a fact to fill a field
"target": None if found is None else {"host": found[0], "port": found[1]},
+ "disabled": marked,
})
continue
if server is not None:
@@ -2309,7 +2447,9 @@ def _collect_nginx(statements: _NginxStatements, out: dict[str, Any], *, root: b
# "any domain" is what the page says about it, not what the agent decides
server["server_names"].extend(args)
elif name == "proxy_pass" and args:
- server["proxy_passes"].append(_nginx_proxy_pass(args[0], location))
+ entry = _nginx_proxy_pass(args[0], location)
+ entry["disabled"] = marked
+ server["proxy_passes"].append(entry)
continue
children = cast("_NginxStatements", statement[3])
if name == "upstream" and args:
@@ -2367,9 +2507,14 @@ def parse_nginx_config(main_text: str,
"""Parse an nginx main config plus the documents its includes name. Pure, never raises.
`{servers: [{listen: [{addr, port}], server_names: [str], proxy_passes: [{location, scheme,
- target, variable}]}], upstreams: [{name, servers: [{host, port}]}],
- maps: [{variable, entries: [{key, target}]}], files: [str], includes: [str], unparsed:
- [str], recognised: bool, error: str|null, resolver: str|null}`.
+ target, variable, disabled}]}], upstreams: [{name, servers: [{host, port, disabled}]}],
+ maps: [{variable, entries: [{key, target, disabled}]}], files: [str], includes: [str],
+ unparsed: [str], recognised: bool, error: str|null, resolver: str|null}`.
+
+ `disabled` on a `proxy_pass`, an `upstream` member line, or a `map` entry line is the
+ route switch's read side (phase 33, agent 1.4.0): `true` when that line carries the
+ helper's `#off ` marker, `false` otherwise — one grammar (`split_marker`), the same
+ one the haproxy section uses, and the same one the write path (task 01's helper) shares.
Four of those keys are the ones `Collector.nginx` does not serve, the way
`parse_haproxy_config`'s `recognised` is not: `error` is why the main text could not be
@@ -2979,6 +3124,12 @@ STATUS_PAGE = """
a dash means the gateway could not resolve that name in this reading — hover a
counted line for the address it matched and when that answer was taken.
-
the switch shows what the config says; toggling a route needs a config write
- path and is not wired up yet.
+
the switch shows what the config says; with `GATEWAY_ALLOW_WRITES=1` set on
+ the dashboard, activating it switches that route in its config file — otherwise it is a
+ reading that does nothing, and its title says so.
the filter box hides the routes whose own labels do not contain what you typed —
domain, server_name, map key, endpoint or port. It asks for nothing:
the tallies, the warnings and the charts above it are still the whole ingress, not the filtered
@@ -207,6 +208,32 @@
+
+
+
diff --git a/src/dashboards/web/static/dash.css b/src/dashboards/web/static/dash.css
index e35af96..d5e3647 100644
--- a/src/dashboards/web/static/dash.css
+++ b/src/dashboards/web/static/dash.css
@@ -1403,19 +1403,16 @@ table.data td.san { white-space: normal; }
.gate-link.on .gate-elbow { border-top-color: var(--gate-on); }
.gate-link.off .gate-elbow { border-top-color: var(--gate-off); }
.gate-switch-slot { display: inline-flex; align-items: center; flex: none; }
-/* The switch is a **reading** shaped like a control, and it is the shape the operator sketched: a
- * pill with a knob, green when the route is in the config. It is keyed on `[aria-checked]` and
- * **never** on `:enabled`, because a switch this app cannot wire (invariant 1: nothing here writes
- * to an upstream) must still look green while it is on — styling `:enabled` would paint every one
- * of them red by accident. `cursor: not-allowed` says what a hover would otherwise promise, and
- * there is no `:hover` rule and no listener anywhere in `page-gateway.js`. `aria-disabled="true"`
- * rather than `disabled`, which would drop a real `