**Phase 25 final verification pass** (all 3 tasks were already in `complete/`, so this was the phase-level verify + docs pass) **Verified** - `#nav-status` first child of `.masthead-right` on all 7 signed-in templates (incl. `/certs`, `/settings`); `app.js`/`charts.js` untouched - `#nav-status` + `.masthead-right` both `justify-content: flex-end`, `margin-inline-start` still absent, row-break (`flex-wrap` + two `flex-basis: 100%`), non-sticky bar, dropdown slide + 4 close paths, `setInterval(refreshStatus, 15000)` all intact (dash.css diff = comments + the two alignment values) - Clock is the last control on all 7 templates; no `order:` anywhere in the bar **Fixed (defects found in this pass)** - `tests/test_api.py::test_every_page_links_to_both_admin_pages` — duplicated `tabs = html[...]` statement (task 03 edit artifact) - `certs.html` / `settings.html` comments said "all **eight** signed-in pages"; the signed-in set is seven (`/login` is the eighth, anonymous, and has no links/clock) - `AGENTS.md` — suite tally `1542 → 1543 passed`; "the three phase-20 tests" re-worded, since task 02 renamed one to `test_the_masthead_is_three_rows_right_justified_at_every_width` **Results** - `uv run pytest` → **1543 passed, 15 skipped**, 0 failed - `bash .agents/validate.sh` → **exit 0**: coverage **98.94 %** (floor 94 %), `ruff` All checks passed, `pyright` 0 errors, `node --check` clean, hygiene clean - `uv run pytest -m live --live` (`.env.dev` sourced) → **11 passed**; `./scripts/dev-server.sh status` → 9/9 sources `ok` - Browser: `node data/logs/25-task03-verify.cjs` → **235 checks, 0 FAIL**, exit 0 (`data/logs/25-task03-verify.out`, 16 `masthead-25-*.png`) **Completion criteria**: badges pinned+measured (green `system/disks/llm/ups` on both admin pages, 1280 px and 360 px) ✅ · both rows flush right, three rows + dropdown unchanged (pinned + measured) ✅ · clock farthest-right (pinned markup; measured right edge 1260.0 at 1280 px, ≤900 px hidden by the long-standing readout rule with source order asserted) ✅ · pytest/coverage/ruff/pyright ✅ · validate.sh ✅ · no behaviour change in completed work ✅ **Docs**: README reviewed end-to-end against the code (served-route audit vs documented routes, env table vs `config.py`, commands vs `scripts/`, public-path table, `web/` masthead bullet) — consistent, no changes needed beyond the phase's own edits. AGENTS.md — two fixes above; spot-checked every doc-named test, function, script, threshold, `install-agent.sh` sha256 (`ffbcd91f…` matches) and agent version (1.1.0). **Deviations**: left the pre-existing `fastapi 0.153.0 → 0.159.0` advisory (validate prints it as advisory; no dependency work in scope, AGENTS rule 8 triggers on touching deps). No live-infra changes. Overview's "all eight signed-in pages" for the clock is verified against seven templates + `/login` (which has no clock, pinned). **Next pending phase**: `.agents/phases/todo/26_ups_event_split/` (3 tasks).
211 KiB
AGENTS.md — homelab dashboards
The plan, the rules, and the environment facts an agent needs to work on this repo without
re-deriving them. README.md is the operator-facing how-to; this file is the why, the
invariants, and the state of the fleet.
Working protocol
This project runs the .agents/phases/ phased-execution pipeline.
- Always read
.agents/PLAN.mdfirst to understand the project context and goals. - Follow the phased execution protocol in
.agents/phases/. - Never modify
.agents/PLAN.mdor any files in.agents/phases/complete/. - If you need to update any file in
.agents/phases/todo/, you must ask the user for permission first. - Strictly adhere to the LOCKED DECISIONS listed in
.agents/PLAN.md.
Everything below this section is the project's own content: what the app is, the rules that keep its numbers honest, and the state of the fleet it watches.
What this is
Four dashboards for one homelab, behind one domain, in one process, backed by one SQLite file — plus an overview page that says which of the four you need:
| Route | Question it answers | Source |
|---|---|---|
/ |
Is anything wrong, and where do I look first? | all four, in one request |
/system |
What are my machines doing — CPU, memory, disk, GPU, network, and the history | agent/host_agent.py, our own stdlib agent per host |
/disks |
Are any of my physical disks failing, and which one — health, temp, hours, wear | the same agents, smartctl -i -A -H per whole drive (never a partition) |
/llm |
Tokens/sec, active sessions, queue depth, KV pressure, latency percentiles | gufo /metrics + /slots, vLLM /metrics |
/ups |
Battery health, runtime left, power events, battery-test buttons | NUT upsd (our own wire-protocol client; 3051 here, not 3493) |
History is a first-class feature, not a sparkline afterthought: 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), and the two admin pages live behind it: /settings — ⚙ Account in the masthead —
where that session is read, ended and re-credentialled, and /certs — ⚷ Certs, next to it —
where the fleet's certificate authority is listed, rotated, revoked and downloaded. Every page
above, /settings, /certs, and every /api route but the four named in invariant 12 require
the session.
Non-negotiable rules
These are the things that make the numbers trustworthy. Breaking one is a bug even if the page still renders.
- Read-only by construction, with exactly one exception. Nothing in this app writes to
an upstream except
POST /api/ups/command. That endpoint is gated four times (NUT_ALLOW_ACTIONS, notDASH_READ_ONLY, the upsd.users account actually having the command, and a UI confirmation —DANGERfor anything that can cut power) and the server re-checks every gate on every request. Hiding a button is not a safety control. Commands that would cut output are refused outright while the UPS is already on battery. Since phase 11 there are two more in front of it — a session, and this session's CSRF token for a cookie write (invariant 12) — and they are additive:tests/test_auth_api.pyproves theDANGERrequirement and the wire send still behave exactly as they did without them. - Never invent a number. A value the upstream does not publish renders as
n/a, never as0. Every derived value carries thesourcestring it came from andderived: trueso the UI can print how it was obtained (computed:Δtokens/ΔtlabelledΔ2.0s, the LLM's own poll interval since phase 23). Aggregating two different series into one rate is the failure mode this rule exists to catch — the rate estimator is keyed by the resolved upstream series name plus its label set for exactly that reason (phase 13: a label set joining a summed counter mid-window contributes nothing until it has its own baseline, never its carried cumulative value), and the estimator refuses a window too short or too stale to carry the claim. - Unconfigured is not broken. A source with no env config answers
404 {"configured": false}without dialling, and the page says "not configured". A red panel for a server the operator never installed destroys the signal from the panels that matter. - One dead source must not blink another — and nothing may stop polling in silence. Each
source is its own asyncio task with its own timeout and health record (
poller.py). A failed poll keeps the last good payload and marks it stale in the UI instead of zeroing it — on/too, where a host that has never answered still gets a fleet row (unreachable: true, dimmed, every numbern/a) because dropping the row would turn a dead agent into "never configured". Transitions write events; repeated failure writes one line, not 500. The task is the source's only poller, so no exception may end it: a store that refuses an otherwise good poll is a reported failure (ok: false, the real reason inerror,last_okfrozen so the page ages the numbers, onestore-errorevent then onerecordingevent on recovery), and anything that escapes the poll body is logged and the loop continues. Four dev-server sources once froze behind an unretrieved SQLite exception while/api/healthwent on calling themok— a frozen source that looks healthy is the one failure mode worse than an outage. - No CDN, no build step, no internet at runtime. Every asset is served by this app;
charts are hand-rolled SVG (
web/static/charts.js). The engines live on a network with no outbound access — a CDN reference is a request that never comes back. - Never leak a credential. Target URLs may carry a userinfo token (
parse_targetsmoves it into anAuthorizationheader, soTarget.urlends up clean). Nothing that leaves the process may carry one:/api/configservesTarget.display_urlandconfig.strip_userinfo-redacted engine URLs, and every URL inside anUpstreamErrormessage is redacted too — those strings go into the health record and theeventtable, which/api/eventsserves to the browser andpage-system.jsprints. No token, password, or personal hostname goes into tracked files, fixtures, or the browser. The admin's own credential is the same case (phase 11):auth.pyrecords nothing and answersFalse/Noneto every rejection, because the reason a login was refused is a verifier, a salt, or a session token — and this app turns reasons into health records andeventrows that a browser reads. Its oneloggingcall and its tworaisesites (the fail-closed startup and the empty-new-password refusal) carry fixed operator text with nothing interpolated, not even a prefix of a hash;tests/test_auth.pypins that structurally, so the day someone addslogger.warning(f"bad verifier {v}")a test fails instead of a log line. A private key is the same case (phase 15): the root CA key and the dashboard's client key live only inmeta,certs.pyhas nologgingimport and one fixed-text raise naming the rows to delete (pinned structurally bytests/test_certs.py, which also asserts the refusal quotes no line of the stored key), the root key is never written to the temp dir the client material lives in, and a bundle carries the root certificate — never the key — because an agent only has to verify its clients and a stolen agent install must not be a stolen CA. A session token used to fetch a bundle is the same case (phase 24): the installer's--fetch-bundle <url> --cert-name <name> --session <token>sends that token on exactly one request, hands it to itspython3child through the environment rather than argv, prints only the URL in its log line,unsets it before anything is written, and never repeats it in the sudoers hint's re-quoted command (which names--bind/--portand nothing else).tests/test_deploy_scripts.pysigns in for a real token and then greps every file the run could have touched plus both output streams. A bundle exported for the dev loop is the same case (phase 24):scripts/dev-certs-export.pywrites only into the gitignoreddata/certs/tree, creates each file withos.openso0600is its birth mode (and re-chmods a file that already existed looser), dialsGET /api/certs/<name>/bundleover HTTP instead of importingensure_certs— so it cannot mint even by accident — and prints one line per host with no PEM, no token and no password in it (tests/test_deploy_scripts.pygreps the output for all three). - Verify against the deployed upstream; never guess a metric name. Field maps in
sources/llm.pyandsources/nut.pywere captured from the running engines and NUT 2.8.5, and are pinned by fixtures intests/fixtures/. Re-capture withscripts/capture-fixtures.sh(andscripts/capture-node-exporter.shwhen comparing exporters) rather than trusting a README or a memory of an upstream. - Keep dependencies current on purpose. LLM-written version floors are usually stale.
Check after touching dependencies:
uv pip list --outdatedmust come back empty. Python is 3.14 (requires-python >=3.14, ruffpy314, pyright3.14, Containerfilepython3.14) — do not lower these to make something else work. - SQLite owns the history and must stay bounded. Writes go through
store.py; new metrics need a rollup path and a retention story, or a year of charts becomes a disk emergency. Put the DB on a volume in deployment. - The dev server stays up while work happens (
./scripts/dev-server.sh restart, http://127.0.0.1:8090), and UI changes are verified in a real browser with a screenshot before they are called done.data/logs/*.pngis where those land. - The dashboard never starts open, and the stored password beats the environment
(phase 11). The admin verifier and the session-signing key are durable rows in the SQLite
metatable (auth.verifier,auth.session_secret) and are the only place either secret lives;DASH_ADMIN_PASSWORDis a seed that hashes into the row on the first boot of a database and is ignored thereafter (one warning line, value never quoted), so a password is changeable without a redeploy and no container env can roll it back. A server with neither a stored verifier nor the seed refuses to start — aRuntimeErrorout of the lifespan, a non-zero exit, and a message naming the variable, the meta key, and thesqlite3 <db> "DELETE FROM meta WHERE key IN ('auth.verifier','auth.session_secret')"reset command. "Start open with a warning" is not an option: the domain is public and the fleet's hostnames and UPS controls are behind that login.testpassis the local-testing password (dev config and the hermetic suite) and never a production fallback. Invariant 12 is the request-time half; this one is why there is always a credential to check against. - The gate is default-deny and its public surface is four entries (phase 11).
app.py'sPUBLIC_PATHS(/login,/api/auth/login,/api/health) plusPUBLIC_PREFIXES(/static/) are the only things an anonymous caller can reach — every page, every/apidata route, and/api/auth/status/logoutrequire a session read from thedash_sessioncookie or anAuthorization: Bearertoken./api/healthis the one data route that stays open on purpose (the ContainerfileHEALTHCHECKand./scripts/dev-server.sh statuspoll it unsigned, and it leaks nothing); its shape is now a contract with deployment, which means nothing may be added to it without re-deciding that — a new field there is a field the whole internet reads. Adding a path to that list is a security decision, andtests/test_auth_api.py::test_every_route_that_is_not_public_requires_a_sessionwalks the served route table so a route that forgets the gate fails a test instead of shipping — the middleware guards by default, which is why no new route has to remember to. Rules the gate keeps and each has a test: a pageGETgets303to/login?next=…(query string included) and everything else the one JSON refusal, because a redirect a script cannot render is not an answer; every answer carriesno-store;?next=passesauth.safe_nextand is never followed raw (an open redirect on a login page is a phishing kit with a homelab theme); a write additionally needs a matchingOrigin/Sec-Fetch-Siteand — when it came in on the cookie — this session'sX-CSRF-Token, because a bearer caller is something no other site can forge. A process whose lifespan never producedctx.authanswers503for everything, public paths included: "healthy" from a server that cannot say who its admin is is the invariant-4 failure mode, and a login form that cannot verify a password is a worse promise than no form.POST /api/ups/commandkeeps all four of its own gates behind this one (theDANGERword is still what cuts power), andPOST /api/auth/logoutis gated like any other write because a sign-you-out link is a CSRF.POST /api/auth/password(task 04) is the third gated write and the app's only revocation: guesses at the current password spend their ownpassword:<address>bucket, and a right one replacesauth.verifierandauth.session_secretso every browser that is not in front of the form dies on its next request — which is why the response is minted from the new state and never the one being replaced.
Architecture
agent/host_agent.py (each host) gufo / vLLM upsd (NUT)
│ GET /snapshot │ GET /metrics │ LIST UPS / LIST VAR / LIST CMD
│ │ + /slots (gufo) │
▼ ▼ ▼
collectors.py ── fetch, one UpstreamError type, no writes except run_ups_command ──┐
│ parse + Δcounter/Δt │
▼ ▼
sources/host.py · disks.py · llm.py · nut.py poller.py (task per source)
│ Result(payload, samples, events) │
▼ ▼
store.py ── SQLite (WAL): sample(source, host, metric, res, ts) + event ──► api.py ──► web/
Responsibilities, one line each:
agent/host_agent.py— stdlib-only (3.10+), reads/proc,/sys/block,/sys/class/drm,statvfs; servesGET /snapshot+GET /info+GET /schema+GET /health, plus an HTML status page atGET /. Refuses to bind off-loopback without a token or a certificate bundle (exit 2). Counters stay counters: the dashboard computes rates so the agent never remembers who polled it last. TLS mode (phase 15 task 02,AGENT_VERSION1.1.0):--cert-bundle/AGENT_CERT_BUNDLEloads the one PEM the dashboard signs (resolve_bundle, theresolve_tokenidiom: server cert- server key + root CA +
# BUNDLE-REV <n>+# REVOKED <serial>lines, grepped by text because the agent has nocryptography), unpacks it into the unit'sRuntimeDirectorywhen there is one else a temp dir, builds aPROTOCOL_TLS_SERVERcontext withverify_mode = CERT_OPTIONAL, and deletes the PEMs once loaded (the key then lives only in this process, so akill -9has no cleanup to forget).CERT_OPTIONALis the assumption that keeps/healthprobeable: the handler enforces, refusing a peer with no verified certificate 403client certificate requiredon every path but/health, and refusing a serial on the bundle's denylist 403client certificate revokedon every path including/health(the exemption is for a probe holding the CA and no certificate, not for an identity the operator withdrew). The denylist is re-read when the bundle's(mtime_ns, size)stamp changes — that is the whole reason the# REVOKEDlines live in the bundle — while a new certificate needs a restart, becausesslcannot swap a chain under a live socket. Serial matching accepts the hexgetpeercert()renders and the decimalcerts.pywrites, because comparing one spelling to the other accepts a revoked client forever. On the TLS path the bearer token is not read (the handshake is the auth, and a second credential that outlives a revocation defeats the list),/snapshotgains additivetls: true+bundle_revonly in TLS mode, and token mode is byte-identical to before. Task 03 has since flipped the collector (see thecollectors.pybullet), so those two fields are how the dashboard answers "is this host running the bundle/certsissued?" —sources/host.pyandsources/disks.pycarry them into both payloads asagent.tls/agent.bundle_rev, present only when the agent published them. SMART (phase 09):smart()runssmartctl -i -A -Hper whole/sys/blockdisk (parse_smart_outputcovers ATA + NVMe; NVMe media errors map tooffline_uncorrectable, hex-packed vendor raws are never decoded, and an attribute row is read from its RAW_VALUE column rather than the end of the line because this binary annotates that column —35 (Min/Max 34/44),43894h+43m+32.519s— so its last token is not the value), 5 s per device / a pass budget whose 20 s floor scales to 5 s × the disk count / 240 s cache, attached asdisk_io.devices[].smart(gap 10: a 16-drive shelf takes longer than the floor, and truncating the pass would null the tail drives). Missing binary → oneerrorsline for the pass (the wordnot foundin it is whatsources/disks.pyreads to answer "is smartctl installed", andtests/test_agent.pypins that the line still says it); one denied/wedged device → that disk'ssmartisnullwith its own line;--no-smartopts out silently. Never fails the snapshot. An expired cache refreshes on a background thread while the request is served the previous result — only the first pass after startup runs inline — because a wedged pass on the request path blowsHOST_TIMEOUT_SECONDSand would turn the system source red every four minutes over an add-on that page never draws. The refresher is single-flight and keeps the old cache on failure, so a bad pass retries on the next expiry instead of hammering the disk.
- server key + root CA +
config.py— env only, no config file format, no CLI flags..envis a seed; a real env var always wins. Defaults are safe except the sources, which default to unset. The auth block (phase 11) isDASH_ADMIN_PASSWORD(the seed, and_env()strips, soVAR=means unset rather than "the password is empty"),DASH_SESSION_HOURS(720 = 30 days: a dashboard that logs the operator out mid-outage gets bypassed),DASH_COOKIE_SECURE(off, because dev ishttp://127.0.0.1; task 03 also treatsX-Forwarded-Proto: httpsas secure), andDASH_PBKDF2_ITERATIONS(defaultauth.PBKDF2_ITERATIONS, imported so the primitive and the setting cannot drift) — that knob exists so the hermetic suite hashes in microseconds instead of 0.3 s per client and is not a production tuning parameter.poller.py— job loop, health records, stale/last-good semantics, transition events./disksis a source of its own (disks:<host>,DISKS_INTERVAL_SECONDS300 s,DISKS_TIMEOUT_SECONDS30 s, events under the dashboard namedisks) reading the same agents thesystemjobs read: the separate task is what keeps a SMART poll out of the 10-second utilisation loop and lets/disksage on its own instead of blinking/system. The job list is built once (app.build_hub()) because it is wired twice — production and the injected-store test path — and two hand-written copies drifting is how a source ends up polling in tests and not in the container. The success path records (payload → samples → events) and only then moveslast_ok, so a refused SQLite write ages the source and alerts instead of leaving it current with charts that have silently stopped drawing. Events are stored under the dashboard name (system/disks/llm/ups), not the sample source (host) — a page filtering/api/eventsbyhostgets an empty feed forever (found on/systemduring phase 03 browser verification).collectors.py— the fetch half.AgentTls(phase 15 task 03) is what makes anhttps://HOST_TARGETSentry a certificate-authenticated dial:verifyandcertare the pathscerts.write_materialwrote at startup, and the dial is verified against this app's own root and presents this app's own client certificate. Two things about it are load-bearing. (a) The credential travels in a client built per dial, becausehttpxresolves TLS material when a client is constructed (there is no per-requestverify/cert, andverify=<str>/cert=are deprecated anyway — the supported form is anssl.SSLContext, which is also the honest shape: trust in one named CA, hostname checks on) while one shared client dials the plain agents and both engines — averifyon that would make every engine read depend on a CA nobody issued their cert with. (b)statusis a callable doing a freshcerts.agentsread, so a revocation clicked in the browser bites on the next poll with no restart, and a revoked host is refused before any socket opens — invariant 3's shape: a source the app knows it must not talk to says so without a network round trip, and the poller's stale/last-good semantics do the rest.refusal_detail()quotes the agent's own 403 sentence (client certificate required/client certificate revoked) because those are two different operator actions, and the certificate wording is used only for a dial where a certificate was presented — a 401/403 from a token-mode host keeps the token/HTTP wording, or the operator is sent to/certsfor a token problem.agent_tls_map()builds one entry per configured host (the CA and client pair are shared — the dashboard has one identity — only the revocation answer is per name, and an absent entry is active, never-issued being a different fact from revoked);build_hubtakes that map and hands the same entry to thehost:anddisks:jobs of one host, so a revocation fails both sources with one sentence and neither of them dials.fetch_snapshot()keepshttp://targets on the shared client with their bearer token, unchanged from before the phase, so a rollout is per host.NutMonitorowns per-UPS transition bookkeeping (its self-test event takes its severity fromnut.self_test_outcome()and not its own word list, so a passed test is never an amber line in the feed while the card beside it reads green). Its per-UPS tolerance for a failedLIST CMD/LIST RWis for a complete error reply only: one battery whose command list is denied loses its buttons and the poll goes on, while a stalled reply fails the poll, becauseNutClientmarks a socket it could not read to itsENDas unusable (a reply still arriving would otherwise be taken for the next command's answer — the real cause of theexpected 'BEGIN LIST VAR', got 'RW apc …'lines this fleet's event table used to fill with, and the same hazard onINSTCMD, where a lateOKread as a different command's success would be a gate that lies). Failure messages name the redacted URL (strip_userinfo/Target.display_url), never the one that was dialled, because they become health records and event rows.fetch_snapshot()is the one agent/snapshotread thatsystemanddisksshare, so a dead host says the same sentence on both pages and only thetimeoutdiffers (the disks poll has to outlast the agent's smartctl pass, andNoneis translated into httpx's sentinel because a literalNonemeans no timeout at all). Agenterrorsbecome the payload'sdegradedexcept thesmart:add-on lines — a host without smartmontools or device read access is unconfigured, not broken (invariant 3); they stay inagent.errorsfor the/diskspage to explain — which since phase 22 is only the device-read case, because a host whose agent saidsmartctlis missing is not listed on that page at all (api._disk_visible).sources/host.py— snapshot → payload + samples; reboot detection; GPU series per card (gpu.busy_pct@<index>).agent_tls_fields()is the phase-15 passthrough: a TLS agent'stls/bundle_revride into the payload's existingagentblock, and a token-mode snapshot grows nothing (notls: false, nobundle_rev: null— rule 2), so the fields are how the API distinguishes a stale bundle from an agent that never had one.sources/disks.py— the same snapshot projected onto physical drives for/disks(phase 09): one row per whole-/sys/blockdisk (never a partition, never a mount), the agent'ssmartblock carried through verbatim,healththe drive's own word normalised took/failed/None, andsmartctl_availablethree-way on purpose —True(a disk answered),False(the agent said the binary is not installed),None(an agent predating the pass, or--no-smart: nobody knows, son/aplus the agent's ownsmart_errorsline and never a guessed verdict). Since phase 22 the flag's page-side consequence is not only the why-line:api._disk_visiblereadsFalseas "not a host on/disksat all", whileNonekeeps its dimmed row — the computation here is unchanged and the agent still reports all three arms whatever the API decides to list. Nothing here is a rate: power-on hours and the sector counters are stored counters-as-reported, so the source takes noRateEstimatorand its payload carries nowindow_seconds(rule 2 — a rate with no estimator, noΔ<window>label and nosourcestring is an invented number).disk.health_ok@devis not a series either: a verdict is not a number, so the drive's word travels in the payload only. Each row does carry aseveritydict (SEVERITY_FIELDS= the two rated numbers,temp_c/percentage_used, plushealththroughHEALTH_SEVERITY, which translates the drive's own word instead of grading it), andUNGRADED_FIELDSnames the numbers that must never be graded.fleet_disk_summary()sums the per-hostsummarycounts for the page header and drops a host with no payload from the sums while still counting it inhosts_total— the/llmfleet-block rule, so the page never does arithmetic and a dead agent never shrinks a fleet total.sources/llm.py— gufo + vLLM field maps (GUFO_SERIES,VLLM_SERIES,VLLM_HISTOGRAMS), measured Δ rates diffed per label set (rate_key()keys the estimator on the resolved name plus its labels; the cell is the Σ of the per-series rates over the widest window one of them actually used — phase 13, so a joining{engine="1"}series can never lend its carried counter to one interval's delta), prefill speed asmean(prompt tokens)/mean(prefill time), session list from gufo/slots. A refused window (the estimator's floor) is still an answer: the cell keeps itscomputed:Δ<series>/Δtsource and its honestΔ<window>beside anullvalue.sources/nut.py— upsd client,UpsReading,VAR_MAP,assess()(explicit checks, not a mystery score),SELF_TEST_OUTCOMES+self_test_outcome()(theLast self-testverdict follows the outcome of the driver's*.test.resultword, not its phrasing: pass words →ok(includingDone and passed, what the CyberPower here answers), fail words →fail,No test initiated/ empty / no variable →unknownprintingNEVER_TESTED_DETAILso both "never tested" states say one sentence, and an unrecognised word →warn, because a driver inventing a verdict is drift worth noticing and the check prints its own word to be added),COMMANDScatalog with risk tiers,COMMAND_ALIASES+resolve_command()(drivers answer two names for the same action —beeper.enable/beeper.on,beeper.disable/beeper.off— so one entry is offered per action and any of its names hits that entry's verdict, never a gate the rendered name missed),available_commands()filtering, kelvin-vs-celsius normalisation, andcharger_statusnormalised to theCHARGER_STATUSkeys (the same lowercase tokenstates()andon_batteryalready compare), andstates()publishes one chip per fact — a driver exporting bothbattery.charger.statusand the legacy flag that says the same word gets "charging, online", not "charging, online, charging".battery_runtimecomes frombattery.runtimeonly —battery.runtime.lowis a shutdown threshold, not time left, so it lives atbattery_runtime_lowand never becomes the hero's "runtime left".sources/nut_sim.py— a labelled simulator so the UPS page is developable without a battery. It must never be the default, and every payload it produces sayssimulated. Its modelledbattery.mfr.dateis computed relative to today (not a fixed string that ages), so a fresh simulator reads healthy and amber keeps meaning something. Its status follows the real drivers: a below-float pack on mains reportsOL CHRG/chg(the glyph's bolt state is keyed on that token, and the model has to be able to produce it) and a battery being drawn from isOB/dchg, neverol. Every command it advertises is implemented and visible in a variable a real driver publishes — the beeper ones setups.beeper.status(enabled/muted/disabled), and a test window that ends writesups.test.result: PASSEDwhile an aborted one writes nothing; the load/shutdown commands answerOKwithout modelling an outlet, because a faked power cut in the history is indistinguishable from a real one.from_env()is the single place theNUT_SIMULATEtruthy rule lives —app.pybuilds the monitor through it, so the assembly and the model cannot disagree about what is enabled.prom.py— Prometheus text-exposition parser (in-house; only for engines we don't own).scalar(agg="sum")stays right for gauges;values_by_series()is the per-label-set view counter deltas need, because a summed counter mixes whatever label sets exist now.rates.py— Δ/window estimator, keyed by resolved series name + label set. Its effective window isnow - base[0]bounded on both sides (phase 13): underMIN_WINDOW_SECONDS(the poll loop's 0.2 s catch-up gap after any slow fetch) it answers(None, dt)— onen/awith the window it refused — and drops that base so the polls after the stall cannot re-diff it; the window-empty fallback accepts a base no older thanMAX_WINDOW_RATIO × span(the2 × spanceiling the docstring always claimed, now enforced). A rate ≥ 8× the previously published one is held for one poll (the previous value repeats, label unchanged) and publishes only if the next interval confirms it (JUMP_CONFIRM_RATIO) — and confirmation means a different anchor: a held jump remembers the sample its window was diffed from, and stays held while that same sample still anchors the window. A stalled fetch stamps its body with the time the poll finished, so that sample is the stall older than its own timestamp, and every later window off it is the same skewed delta re-diffed over a longer span — measured twice from one sample is one measurement, and the hold simply continues until the stale anchor ages out of the span.min_window=0.0exists for tests that scrape faster than any real loop. The span belongs to the cadence that fills it (phase 23):app.build_hubbuilds one estimator per measuring cadence — the host's atRATE_WINDOW_SECONDS(10), the LLM's atLLM_INTERVAL_SECONDS(2) — because a window wider than the poll averages bursts across the polls the operator is watching and a window narrower than it has nothing inside it to diff. Nothing else about the estimator moved: the floor, the ceiling, the jump gate and the label are the same code, and a 2 s window is 4× above the floor, so a healthy 2 s cadence is never refused.auth.py— every authentication decision as a pure function, stdlib only (phase 11):hash_password/verify_password/needs_rehashover a self-describingpbkdf2_sha256$<iterations>$<salt_hex>$<hash_hex>verifier (so the cost can rise without a migration),mint_session/read_sessionfor an HMAC-signed stateless cookie (no session table and nothing to prune; rotating the stored secret signs every cookie ever issued out, which is how changing the password evicts every other browser),mint_csrf/verify_csrfbound to the session token instead of stored,LoginThrottle(in-process exponential backoff), andorigin_matches/sec_fetch_site_allowedas string functions so the gate is testable without aRequest. The primitives never raise and record nothing: a malformed verifier, a tampered cookie, and a rotated secret all answerFalse/None, because the reason an input was rejected is the credential (invariant 6). No HTTP, no SQLite driver, no new dependency —app.py's middleware andapi.py's/api/auth/*are the callers, so a decision made here is made once and is unit-testable without aRequest. It also carries the two strings the gate and the pages share (LOGIN_PATH,LOGIN_NEXT) andsafe_next(), the only judge of a?next=value: kept verbatim (query string and all, because the range chips live there) when it stays on this app, dropped otherwise — scheme-prefixed, scheme-relative, the percent-encoded spellings of both, anything with a control character (not a path but a secondLocationheader), and anything without a leading/. The verifier bootstrap (task 02) is the one deliberate exception to "never raises":load_auth_state(store, admin_password=…, iterations=…, session_hours=…, db_path=…)reads themetarowsauth.verifier/auth.session_secretthrough a three-methodMetaStoreProtocol(so this module still imports nothing in the package, and its tests use a dict) and returns a frozenAuthState— the environment seeds a password once and the stored verifier wins thereafter (env_ignoredand one fixed-line warning when both are present, so/settingsis the real answer and no redeploy can roll a changed password back); no row and no seed raises aRuntimeErrornamingDASH_ADMIN_PASSWORD, the meta key, and thesqlite3 … DELETE FROM meta …reset command, because "start open with a warning" is the failure this phase exists to prevent; a stored secret that is not 256 bits of hex is regenerated and persisted (the effect is one login prompt, never a lockout). Nothing here reads a clock, so the state carries no expiry — a session's is inside its signed payload.update_verifier(store, state, new_password)writes the new verifier and a fresh secret (secret first, so a crash cannot leave sessions outliving the password they were issued under) and refuses an empty/whitespace password; it is whatPOST /api/auth/passwordcalls, because rotating the secret is this app's revocation list — a stateless cookie has no row to delete, so without it a stolen session would outlive the password that "revoked" it for anotherDASH_SESSION_HOURS. Its two raises, like the loader's, carry fixed text —tests/test_auth.pypins the file's raise sites and its one logging call structurally, so no future edit can interpolate a credential into either.certs.py— the fleet's certificate authority as the same shape of module (phase 15): a pure lifecycle over the three-methodMetaStoreprotocol, no clock it cannot be handed, nologging, and one fixed-textRuntimeError(tests/test_certs.pypins all three structurally, the waytests/test_auth.pypinsauth.py). Fivemetarows and nothing else:certs.root.key/certs.root.cert(self-signedCN=dashboards-root, 10 years),certs.client.key/certs.client.cert(CN=dashboards, 1 year — the identity every agent will check), andcerts.agents, one JSON object mapping aHOST_TARGETSname to{serial, not_after, status, bundle_rev, bundle, san}. A stored row always wins: the root is generated on the first boot of a database and never on a later one, because a CA that rotates with the process untrusts every bundle already installed on a host — and a newly generated root takes its client certificate with it, since a leaf signed by the dead root is a credential nothing can verify.issue_agent_cert/reissue_agent_cert(the API's "rotate") sign a freshCN=<host>leaf whose SAN is the addresses that host is dialled by — the name is always in the list, and an IP goes in as aniPAddressor hostname verification of an IP dial fails — bumpbundle_rev, and resetstatustoactive.revokemarks the host and re-seals every bundle, because the# REVOKEDdenylist lives inside each of them and an agent that cannot see a serial keeps accepting that client; there is no "unrevoke", and revoking a never-issued host still records the revocation (what a revoke enforces is the dashboard refusing to dial). The bundle is the install artifact — agent cert, agent key, the root cert (never the root key, asserted by header count and by parsing the one key against both keypairs),# BUNDLE-REV <n>, then the denylist — stored verbatim so what an operator downloads is byte-identical to what the row holds.rotate_rootmints a new root plus a new client and reissues every agent (bundle_rev+1 each, denylist reset with the generation); the running process keeps the material it loaded at startup, which is the honestrestart_requiredtask 04 answers with. Every certificate carriesSubjectKeyIdentifierand every leaf anAuthorityKeyIdentifiernaming it, non-critical: OpenSSL's strict mode refuses a leaf with no AKI (CERTIFICATE_VERIFY_FAILED: Missing Authority Key Identifier) and Python'sssl.create_default_context()— the context the dashboard's own https dial builds — turns that mode on. The agent's server-side check is not strict, so nothing on the agent side notices;tests/test_certs.pypins the identifiers instead, and a bundle recorded without them is one a strict client rejects.write_materialputs the CA and the client pair in a0700temp dir with0600files for the dialing — the root key is never written to disk, so its only copy stays themetarow — and the lifespan removes the directory on shutdown. Unreadable PEM, a key that does not match its certificate, or a leaf the stored root did not sign is a fail-closed startup (thectx.authrule), and the message is thesqlite3 "<db>" "DELETE FROM meta WHERE key LIKE 'certs.%'"reset.cryptographyis this module's dependency and the app's only new one;agent/host_agent.pystays stdlib and loads the PEMs withssl. There are two reads and the difference is a side effect:ensure_certsis the startup one (it mints what the table is missing, which is why it is whatapp.build_certscalls and nothing else may), andread_stateis the request-time one task 04'sGET /api/certsuses — same validation, same fixed-text refusal, never a write, so a page refresh cannot bootstrap an authority the operator deleted. The material is read with (task 03):collectors.agent_tls_mapturnsAppContext.certsinto the per-host dial pair, and because the revocation answer isagent_status()called per poll, a revoke in this module is what stops the dial — no restart, no cache, no second list to keep in sync. There is a third door and it is deliberately outside this module (phase 24):scripts/dev-certs-export.pyis the dev loop's exporter — stdliburllib, signs in throughPOST /api/auth/login, reads the token out of theSet-Cookie, and downloads eachHOST_TARGETSname's bundle into gitignoreddata/certs/at0600. It cannot mint because it imports neither read; it dials the route the/certspage downloads from, which is what makes the exported file byte-identical to the row.scripts/dev-server.shcalls it after the readiness probe and treats a failure as a warning line — the server being up is the thing the probe proved.severity.py— the only place thresholds live:THRESHOLDS/INVERTEDper metric,classify()→ok/warn/fail/None, plusattach()/with_severity()to put the verdict beside a value. No measurement, no verdict:Nonein →Noneout, never a green pass for an unknown. The verdicts travel additively in payloads —cpu.severity,memory.severity,disks.severity, each UPS card'sseverity(charge/load/runtime),severityinside rated LLM cells (KV pressure, classified on a percent scale because the engines publish a 0-1 ratio), andseverityper host/fleet/UPS row in/api/summary. Pages render the class; they never compare a number to a threshold. There is nogpu.*row and nogpus[].severity(phase 06): these are LLM servers, a busy GPU is the job at every level, so the GPU card and the fleet GPU column render uncoloured — likenet.*, whose colourlessness predates it. Removing the verdict is not removing the data:gpu.busy_pct@N/gpu.mem_used_pct@Nare still stored and charted. The/disksrows (phase 09) aredisk.temp_c(50/70) anddisk.percentage_used(80/100) — an SSD at 100 % of its rated wear is a replacement, not a warning — attached per row bysources/disks.py; the sector counters anddisk.power_on_hoursdeliberately have no row, because a non-zero counter is an alarm chip on presence and zero is not an achievement (the/llmpreemption-chip rule), soclassify("disk.reallocated", 1)isNoneand the page shows a chip instead of a colour.alerts.py—build(settings, job_states, host_payloads, engine_payloads, ups_payload, recent_events)→ the "what is wrong right now" list/api/summaryserves asalerts({id, severity, source, message, href, at}, fail first then warn, then by source). A pure projection: no I/O, no store, no thresholds of its own — it reads job states for reachability, theseveritydicts for breaches,health.checks[]for UPS conditions. Rules: an unconfigured source never alerts (invariant 3); a source that is ok but older than 3× its own poll interval iswarnwhile a source that stopped answering isfail(the interval is the job's own dashboard's, sodisksgoes amber after 15 quiet minutes rather than 30 seconds); a dead source gets exactly one line (its stale payload adds nothing) that names it once — the poller's"UpstreamError: "wrapper and the duplicated host name are stripped, because a message that saysgatewaytwice reads as two problems, and the same rule silences thedisksecho of a dead agent (the disk job dials the same/snapshot, so its failure carries no new information — while adisksjob failing while its host answers, SMART wedged inside the handler, is the only voice that fault has);unknownand missing numbers are silence; one alert per (source, metric) and at most 12, the last being a marker that says how many it hid. This is why the overview can be two panels. There is no disk-health rule and none is wanted: a drive the hardware itself calledFAILEDis a red chip on its/diskscard and a line in that page's event feed, not an overview alert —severity.pyrates no sector counter and this module may not have a threshold of its own, so grading a drive here would be the bug, not the fix (phase 09). ItsHREFtable does carry/disks, so the one alert a disk job can raise — SMART failing while its host answers — links to the page that names the drive.store.py—sample(source,host,metric,res,ts)+event+meta, rollups, pruning. Themetatable is the only durable key/value state in the app and is reached through three named helpers —get_meta/set_meta/delete_meta(the last returns its rowcount, because the password-reset procedure has to be able to say whether a row was there, and one key per call, because a reset that deletes a row it did not name deletes the operator's credential).stats()readslast_maintainthroughget_meta()too — outside its own lock, whichget_metatakes itself andthreading.Lockis not reentrant — whilemaintain()'s write stays inline inside that method's transaction. No per-request path may pay a whole-table scan (phase 14 task 02):series_names()(1.4 s ofSCAN sampleon a production-sized store, behind/api/metrics) and the twoCOUNTs behindstats()(1.25 s, run byapp.js's/api/healthpoll on every tab every 15 s) answer from short-TTL caches — 60 s and 30 s, the series list additionally invalidated only by a write that can change it (task 04: an insert cannot remove a name, so_note_series_write()ends the cache on a(source, host, metric)the store has never written and leaves a poll of an existing series alone — invalidating on every sample was task 02's defect, and it put the 572 ms scan back on nearly every/api/metricscall on a store nine pollers write to, behind a cache that never served;delete_series()/maintain()invalidate unconditionally because aDELETEcan take a name,delete_series()also forgetting the triples it emptied so a re-created series counts as new again), through a generation counter so a write racing a read cannot park a stale answer under a fresh timestamp, with the name cache bounded to 256(source, host)keys because/api/metricstakes both as free text and the existence tracking capped atSERIES_TRACKED_SERIES_MAX, below which it falls back to invalidating on every write. A tracking set made wrong by another process deleting rows costs at most one TTL window, which is the reason the cache has a TTL and not only the tracking.last_maintainandsize_bytesare never cached: alast_maintainthat stops moving is how an operator spots the invariant-4 failure mode, and it costs a three-row lookup. The resolution decision is one compound statement of per-(host, metric, res)branches whoseCOUNTis capped byLIMIT max_points + 1— exact, because the decision only ever asks whether a count is0,≤ max_points, or neither — andpick_res_many()/history_many()are the batched door the N × M endpoints go through. The rows stay a per-series seek measuredly: aROW_NUMBER()window or a compound of the 27 tail selects both cost more than 27 index seeks on the seeded store (data/logs/NOTES-store-perf.mdrecords the table), so batching the decision is the fix and batching the fetch would be a round trip traded for work. The write side is windowed too (phase 14 task 03):maintain()readslast_maintainthroughget_meta()before its transaction (same non-reentrant-lock rule asstats()) and bounds each rollup step to what became due since, instead of averaging every row the resolution retains — 3.97 M rows of 1-minute history per minute, a 5.4 s write transaction that made a poller'sadd_manyoutlivebusy_timeout=5000(gap 4'sstore-errorline). Two properties make that a cost bound and not a data change, and both are pinned: the lower bound reaches past the previous pass (one bucket plus slack, so ten quiet minutes are a catch-up scan rather than ten minutes of empty charts), and it is snapped down to a bucket boundary — the step's ceiling is a row bound, so the newest bucket is partial and a later pass re-rolls it as its rows land, and a floor that cut into a bucket would overwrite the fuller average with the mean of its tail rows (2.5 becoming 4.5 on a six-sample minute: rule 2 with a chart's clothes on). A window is only a cost bound if the plan can seek to it, and two things were in the way. The prune keeps its full retention bound — a rollup may window because it only adds rows, a prune must reach anything that expired — but it is oneDELETEper resolution, because oneWHERE (res=? AND ts<?) OR (res=? AND ts<?) OR (…)is unreadable to the planner and read the whole 6.4 M-row table to delete nothing (554 ms of a 562 ms pass; 0.3 ms per statement now). And the store had no query statistics, so every window was priced as a scan however few rows were inside it:maintain()ends withPRAGMA optimizeand__init__setsautomatic_analyze=0, because SQLite's own refresh lands inside whichever poller's commit trips its counter — the exact path this shortens — while a tick a minute costs ~100 ms once and 0.0 ms afterwards. Measured on the seeded store: 5,368 ms → 2.7 ms steady, 48 ms for a ten-minute catch-up, and the two-connection race at the app's cadence goes from 5 of 9 inserts raised to 100 of 100 landing with a 21 ms worst wait.__init__also setswal_autocheckpoint(SQLite's own 1 000 pages, written down so the knob is visible) plusjournal_size_limit = 64 MB, the cap that truncates a runaway WAL at the next checkpoint.busy_timeout=5000is unchanged: the fix is a shorter write, not a longer wait.api.py— GETs everywhere plus the one POST that dials an upstream, and theauth_router()that creates, describes, re-credentials, and ends a session (POST /api/auth/loginpublic;GET /api/auth/status,POST /api/auth/logoutandPOST /api/auth/passwordprotected). Those three, and thecerts_router()of phase 15 (GET /api/certs,GET /api/certs/<name>/bundle,POST /api/certs/<name>/rotate,POST /api/certs/<name>/revoke,POST /api/certs/root/rotate), touch this process and itsmetatable and dial nothing — invariant 1 is about the fleet, andtests/test_api.pypins the three lists separately rather than widening the one that names/api/ups/command. The/api/certssurface (phase 15 task 04) is the CA's own admin half. The list reads the stored state throughcerts.read_state— the request-time twin ofensure_certs, which mints and must only ever be called at startup, because aGETthat bootstrapped a root CA would replace the authority nobody asked it to replace — and its rows are the union ofHOST_TARGETSandcerts.agents, so a configured-but-uninstalled host appears asstatus: "unissued"with nulls (invariant 3, rule 2) and an entry whose host left the config is listed but not writable.deployed_bundle_revcomes from that host'ssystempayloadagentblock (task 03's passthrough), never from what the CA hopes.bundle_revis what was issued; the two numbers side by side are the "install the new bundle" sentence, and neither carries aseverity— an expiry and an identity counter are not graded numbers. A<name>outsideHOST_TARGETSis refused in the route's first line with404 {"configured": false}before any signing, dial, or store write (spy-pinned both ways, and the same gate makesGET …/bundleanswer{"configured": true, "issued": false}for a host that is configured and has nothing issued — two different facts, two different bodies). The bundle download is the stored row verbatim asapplication/x-pem-filewith anattachmentfilename, because it isinstall-agent.sh's input and it carries that host's private key (session-gated,no-store).POST /api/certs/root/rotateanswersrestart_required: truerather than pretending a hot-swap: the CA and the client pair are startup material, so the table is new at once while the process keeps dialling with the old one. Every write logs one fixed-textcerts-source event naming host and action, and serials travel as decimal strings — a 64-bit certificate serial through a JS number is a serial a page cannot match against a bundle's# REVOKEDline (invariant 6: no PEM reaches an event, a health record, or any payload but the bundle route's). The login route is where a sign-in password is compared: it throttles before it hashes, refuses an absent/empty/non-object body with422so""never reachesverify_password, answers every failure with the one sentencewrong password, and answers429+Retry-Afterwhile the per-address backoff runs (the only extra fact it adds, because a wait is something the caller has to act on). After a good sign-in it also upgrades a verifier minted at a lowerDASH_PBKDF2_ITERATIONS(auth.needs_rehash), keeping the signing secret — that knob has to actually buy something, and a login is the one moment the plaintext is in hand. A refused upgrade write is swallowed with one fixed-textloggingline (this module's only one, carrying no credential: invariant 6) becausedatabase is lockedon a bookkeeping row must not turn a correct password into no login at all.POST /api/auth/passwordis task 04: it validatesnewfirst (MIN_PASSWORD_LENGTH = 8, non-empty once stripped, different fromcurrent— length is the whole policy, because composition mandates produce sticky notes), throttles the current password on its own keypassword:<address>, refuses a wrong one with403+ apassword-deniedevent, and on the right one callsauth.update_verifierand answers from the new state with a cookie and CSRF token minted from the rotated secret. Building that response from the oldctx.authis the bug the order exists to prevent: the operator would be signed out by their own form.AppContextcarriesauthand the oneLoginThrottleper process — a throttle built per request is a throttle that never trips. A refused sign-in writes alogin-deniedevent and nothing else, and so do the two password outcomes (password-denied,password-changed):alerts.pyturns onlykind: "unreachable"into an overview row and every feed filters bysource, so/api/events?source=authis the audit trail and the overview stays about outages (do not "fix" that by hiding the event). Neither fact appears on/api/health— which is public — or/api/config./api/configis a redaction boundary./api/disks/*(phase 09) is the drive-health pair:/snapshotanswers one entry per configured host with drives to show (disk rows +smart+smartctl_available+ per-rowseverity+ afleetblock summed by the API, with a configured-but-never-answered host as anunreachablestub whose counts areNone, not0), and/historyserves thedisk.*@devseries with unknown names answering as empty series like the other two history endpoints. The configured gate runs before anything that could reach the network —HOST_TARGETSempty is404 {"configured": false}with no client, no fetch and nodisksjob in the hub at all (invariant 3), andtests/test_api.pyproves it with spies onhttpx.AsyncClient.getandcollectors.fetch_snapshot. Configured-ness and visibility are two different gates, and only one helper decides the second:api._disk_visible(host)drops a host whose payload'ssmartctl_availableis explicitlyFalse("no smartmontools, no drives to report" — the VM case) fromhostsand therefore fromfleet(fleet_disk_summarysums whatever it is handed, sohosts_totalis the row count), whileTrue,Noneand anunreachablestub all stay — an agent that cannot say is not an agent that said no, and a dead agent must not read as "never configured" (invariant 3). The check isis not False, not falsy, and it runs in/api/summary's disks block too, before both the fleet sum and the per-host rows, so the two endpoints cannot list different machines;statesstill names everydisks:<host>job (hidden included, still polling, stillok) and the source, the store and the health records see no change at all — which is also why an overview alert from a hidden host'sdisksjob still names that host (alerts.pyreads job states, not this list): hiding a card must never hide a fault (phase 22). The new surface grows no write of its own — a fourth dashboard must not quietly become a second one, so the app's only POST that dials an upstream stays/api/ups/command(invariant 1, pinned by enumerating every served route's verbs and sorting the writes into fleet / session / certificate-authority lists — the third is phase 15's, and it writesmetarows and nothing else). All fourhistoryroutes reach the store through one door,api._history_batch()→store.history_many()(phase 14 task 02). That is a cost change and nothing else: the series list is host-major and metric-minor in request order, each entry the same{source, host, metric, range_seconds, points}document the per-series loop built, with the same empty-series answer for a name that has no rows.tests/test_api.pypins it for all four routes against points read straight out of the store withquery()— a third path, in neither the loop nor the batch — so a future "optimisation" that moves a point, an order, or a key fails a test instead of redrawing a chart wrong./api/summaryis the overview's single call and the cheapest whole-fleet probe there is: the page renders onlyalertsplussystem.fleet/system.hosts, whiledisks.hosts,llm.engines,ups.ups, thestatesmaps andrecent_eventsstay for programmatic consumers (live tests, an external check, a notifier).system/llm/ups/recent_eventsare unchanged andalerts(phase 02) anddisks(phase 09) are additive — thedisksblock isfleet_disk_summaryverbatim plus one row per host (disks/with_smart/health_failed/alarm_disks/smartctl_available/failed_devices), so a notifier learns which drive the hardware calledFAILEDwithout a second call, andtests/test_api.pypins every one of those key paths, with and without targets configured, so retiring a panel can never shrink the payload. Since phase 22 those rows are the visible hosts — the same_disk_visiblepredicate/api/disks/snapshotapplies, over the same entries, before the fleet sum — sodisks.fleetequals/api/disks/snapshot'sfleetand a host with no smartmontools is missing from both, whilesystem.hostsstill lists it (a missing binary is a fact about the drive page and nothing else) anddisks.statesstill lists its job. A configured job with no payload yet is listed as a stub (unreachable: true, every valuenull) insystem.hosts/llm.enginesexactly as/api/system/snapshotdoes, sohosts_totalcounts the machines there are andhosts_livethe ones answering.app.py— assembly, and the session gate (session_gatemiddleware, invariant 12) whosePUBLIC_PATHS/PUBLIC_PREFIXESare the whole anonymous surface. It answers in this order and the order is the design: noAuthState→503for everything; no session and no such route → the router's404(a typo is not a sign-in problem, and "sign in required" would otherwise be the only answer anyone outside ever gets for a wrong URL); public → through, except that the origin check still applies to a public write (a cross-site login post spends the throttle of whoever is typing) andGET /loginfor someone already signed in goes to the dashboard, not back to the form; no session →303to/login?next=…for a page and the JSON refusal for everything else; a write → origin, then this session's CSRF token when it arrived on the cookie._own_originreadsX-Forwarded-Protofor the same reason the cookie'sSecureflag does, so behind nginx a real same-origin write is not a cross-site refusal — the bug that only appears in production, which is why it has a test.create_app()takes injectable settings/store so tests build an app with an in-memory DB and no targets. The lifespan buildsctx.auth(auth.load_auth_state) after the context exists and before the first poll, for the owned-store and injected-store branches alike — one call site, because two copies drift — and aRuntimeErrorfrom it leaves the process non-zero with the operator's fix in the output. That failure is never caught here: a dashboard that cannot say who its admin is does not start the loops either (__main__.pypassesSETTINGStocreate_app()explicitly so the served app is configured by the same object the bind address came from).build_certs(store)is the same rule for phase 15: one helper, called beforebuild_hubin the owned-store (build_context) and injected-store (lifespan) branches, whoseCertMaterial(the CA + client paths in a private temp dir) hangs offAppContext.certsand whose directory the lifespan'sfinallyremoves. What hands that material to the loops isagent_tls_map(material, store, targets)→build_hub(..., tls=...), called in both branches the same way: no material (or no targets) means no TLS map, which is the plainhttp:///token path rather than an error, and one map covers both of a host's jobs. A corrupt stored certificate raises out of the same place — a dashboard that cannot present its own client certificate would otherwise spend the fleet's TLS history reporting an upstream problem it caused itself.build_hubalso builds twoRateEstimators, because a window is only a claim about the polls that fill it (phase 23,TODO.md:8's "ramp up/ramp down"): thehostjobs getRateEstimator(span=settings.rate_window)(10 s — what a 10 s cadence can fill) and thellmjobsRateEstimator(span=settings.llm_interval)(2 s, one poll interval, so a slot that starts reads steady on the next poll instead of climbing one fifth of the true rate per poll), and there is no third knob — the LLM window isLLM_INTERVAL_SECONDS, which is the only way the pair cannot drift. One estimator for both could only be right about one of them: a 2 s span over a 10 s poll sits the window-empty fallback against the2 × spanceiling and answersn/aevery poll, and a 10 s span over a 2 s poll smooths the work the operator is watching. The estimator's own gates (theMIN_WINDOW_SECONDSfloor, the ceiling, the 8× jump confirmation with its independent anchor) are unchanged and apply to the smaller window, whoseΔ2.0slabel the payload carries with the value.web/— five dashboard pages (/,/system,/disks,/llm,/ups) plus the login page (/login,pages/login.html+static/page-login.js), one stylesheet,charts.js. No framework./loginrenders the masthead's brand link and theme toggle and nothing else from it: no tabs (every tab bounces straight back here, and a nav of links that all redirect is a lie about what is available), no status dots and no clock (there is nothing signed in for them to say). It posts throughDash.fetchJSONlike every other script, which is only safe becausebounceToLogin()does nothing when the browser is already onLOGIN_PATH— without that guard a401would reload the form instead of printingWrong password., and the page that exists to end a bounce loop would be one. So the form branches onerr.status:401is the inline sentence plusfield.select()(fixing a typo without retyping is the whole UX here),429shows the server'sdetailand counts down itsRetry-Afterwith the button disabled until the wait is over —fetchJSONtherefore carriesresponse.headerson theErrorit throws, because a wait this page made up would be an invented number (rule 2) — and anything else prints the server'sdetailverbatim. Every refusal goes out throughtextContent, neverinnerHTML: this is the one page a stranger reaches first. Its own copy ofauth.safe_nextguards the one redirect followed on client strength (already-signed-in?next=), andDash.ensureAuth()up front stops a flash of the form for a browser that is already in. The login page is audited for orphanids and CDN references too (tests/test_auth_api.py), fetched unsigned because a signed-inGET /loginis a303and would audit/'s markup under the wrong name./settings(pages/settings.html+static/page-settings.js) is a page and not a sixth dashboard: same<head>and masthead, the five tabs with noaria-current(it is not one of them), the same four#nav-statusbadges as every dashboard (phase 25 task 01 —TODO.md:3's "hosts disks engines UPS disappear when in the certs/account page" was the missing container, andDash.initChrome()fills it from/api/healthwith no JS change), and it is reached from the⚙ Accountlink in.masthead-rightof every dashboard including itself — outside.tabs, so the pinned tab structure is untouched (tests/test_api.py::test_every_page_links_to_both_admin_pages). It prints/api/auth/status(name, expiry as a local timestamp and as time left), signs out throughPOST /api/auth/logout, and changes the password throughPOST /api/auth/passwordafter checking locally what it can judge itself (non-empty current, ≥ 8 new characters —tests/test_api.pypins that number againstapi.MIN_PASSWORD_LENGTHso the two cannot drift — new matching confirm), printing403/422/429throughtextContentas the server words them, then re-reads the status so the panel cannot show a session the rotation ended. Its static sub-line is the precedence rule (env seed → stored verifier wins → reset by deletingauth.verifier), because that sentence is what stops the "I edited the env file and nothing happened" call. Soapp.jsexportsresetAuth(): it clears the memoisedauthStatusand its in-flight promise, and must be called after a password change (the secret rotated, so the cachedcsrfis now invalid — every later write, the UPS buttons included, 403s until a reload) and on any401bounce./certs(pages/certs.html+static/page-certs.js) is the second of those admin pages, and it is the same shape for the same reasons: five tabs with noaria-current, the four fleet badges in#nav-statuslike every other masthead (phase 25 task 01, same reasoning as/settingsabove), and both masthead links —⚷ Certsbeside⚙ Account— exactly as the five dashboards render them. Both links are on both admin pages (phase 20 task 04,TODO.md:4), which retires phase 15's "no self-link" precedent: chrome that changes shape depending on where you already are reads as a broken button, while a link to the page you are on is a no-op that answers with the page you were looking at (data/logs/20-task04-admin-links.cjsmeasures exactly that — one document response, no redirect, the same cluster afterwards). The two links therefore sit on all seven signed-in pages (the five dashboards plus/settingsand/certs) in one order (⚷ Certs→⚙ Account→ theme toggle → clock — phase 25 task 03 moved the clock to the end so it is the farthest-right item on aflex-endrow), which is what lets the pinned audit be one loop overMARKUP_PAGESinstead of two over different tuples;/loginis the eighth page and the one with neither. Everything on it isGET /api/certs: the Root CA block, the display-only Dashboard client line (that certificate is minted with the root and has no route of its own), and one row per host with its status chip, serial, expiry (local timestamp andDash.durationleft), issued-vs-deployedbundle_rev,sanlist, and the three controls. Two rules it exists to keep. (1) No colour the API did not send:/api/certspublishes noseverity— an expiry is a date and a revision count is not a graded number — so the page has noseveritytoken at all, the one chip class map isstatusword →ok|failwithunissuedmapping to nothing (a certificate that does not exist never borrows green), and a behind bundle is a neutralstalechip. (2) No control the API would refuse: a row withconfigured: falseprints why (not in HOST_TARGETS) instead of three buttons that all answer404, a never-issued host has no Revoke and no bundle link (that route answersissued: false), and the download is a plainGETlink because a session cookie is all a signedGETneeds. Writes areconfirm()d, not typed-DANGERd — that gate is for cutting power and this page cannot — and the outcome line prints the route's ownhint, sorestart_required's restart sentence lives inapi.pyand nowhere else. ItsCOLUMNStable names every payload key it indexes, andtests/test_api.pyresolves that table against a liveGET /api/certsrow in both directions (theSLOT_FIELDSdevice), so a rename on either side fails a test instead of printingn/abeside a healthy fleet. The two CNs are markup, and pinned tocerts.ROOT_CN/certs.CLIENT_CNthe way/settingspins its password floor. The agents table istable.data.scroll— the repo's one scroll mechanism (TODO.md:9: at phone width the serial / bundle /san/ controls tail was clipped by.card { overflow: hidden }with no way to reach it), and the class on the served table and the servedtable.data.scrollrule are pinned together intests/test_api.py, because the mechanism exists only while both name it (measured at 360 px and 1280 px bydata/logs/21-task03-scroll.cjs).app.js's masthead status dots are one per dashboard job — includingdisks, because the same agent can answer the 10-second utilisation poll and have a wedged SMART pass, and one dot for both would be right about neither.app.jsowns the colour vocabulary:Dash.severityFrom(cellOrSeverity)turns a payload verdict into anok|warn|failclass (''for anything unrated, so unknown never looks green) andDash.hero(el, {label, value, unit, digits, severity, sub, bar})renders/patches a.herobig number (plus an optional.bar) from that class — it never compares a number to a threshold.Dash.severityFor(value, warnAt, failAt)is the documented fallback for values the API does not rate, and no page script calls it any more —/was converted in phase 02,/systemin phase 03,/llmin phase 04,/upsin phase 05 and/disksshipped that way from its first commit, so every colour on all five pages comes from aseverityverdict the API sent (tests/test_api.pyfails if a page script names it again). A value the API should rate gets a row inseverity.py, never a bound in JS. The masthead is three rows at every width, the two control rows flush right (phase 20 task 01 made the rows,TODO.md:5-6; phase 25 task 02 re-justified them,TODO.md:4— "I made a mistake earlier, I did want hosts disks engines UPS badges and certs/account/dark right justified"): row 1 the brand,#nav-toggleand the five tabs; row 2 the#nav-statusper-source dots; row 3 the.masthead-rightcontrols (⚷ Certs,⚙ Account, theme, then the clock — phase 25 task 03 moved the clock last forTODO.md:5's "the time should show up as the farthest right item"; phase 21 task 01 deleted the⏸ Pausethat used to sit beside the links, button,Spaceshortcut andsetPaused/_pausedplumbing alike: it froze the numbers on screen without growing astalelabel, and a backgrounded tab already idles its polls, which is the only stop this app needs). That was CSS and no markup change, which decides the mechanism:#nav-statusis a child of.masthead-right, so there is no wrapper to promote, and the rows are line breaks —.mastheadisflex-wrap: wrap, and both#nav-statusand.masthead-rightcarryflex-basis: 100%withjustify-content: flex-end(line breaking uses a flex item's base size, before shrink, so a 100 % item is alone on its line: the div's break starts row 2 of the bar and the dots' starts row 2 inside it, leaving the controls to wrap to row 3)..masthead-rightlost themargin-inline-start: autothat pinned the cluster to the right edge of the one-row bar.flex-direction: columnwas the other option the task allowed and is the wrong one: it puts the brand and the tabs on separate rows.height: var(--header-h)becamemin-height(three rows do not fit in 58 px and a fixed height clips the controls) and is declared once — the ≤640px block no longer restatesheight/min-height/flex-wrap, and that pair drifting is exactly what would clip the bar on a phone only. Every page that renders the bar now renders three rows —/certsand/settingsgrew the dots row in phase 25 task 01, where#nav-statusis pinned as the first child of.masthead-righton all seven signed-in pages (app.jsis untouched: the container's existence is what makesinitChromebuild the four badges, which is why the fix was markup and not JS) — and/loginremains the one page with no dots, rendering two rows (brand, then the theme toggle). Pinned on the served bytes by phase 20's three tests intests/test_api.py— the row declarations (task 02 renamed that one totest_the_masthead_is_three_rows_right_justified_at_every_width), the mobile block's silence about the bar's height, and the bar'sposition— and measured in a browser at 1280 px and 360 px bydata/logs/20-task01-rows.cjs(seven pages × two widths plus the dropdown; screenshotsdata/logs/masthead-rows*.png). Phase 25 task 02 moved the alignment of those two rows to the other edge (TODO.md:4— "I made a mistake earlier, I did want hosts disks engines UPS badges and certs/account/dark right justified"): the twojustify-contentdeclarations areflex-end,margin-inline-startis still absent from the cluster (a full-basis row aligned byflex-endis the mechanism, not the old single-row right-pinning), and the ≤640px block still restates neither the alignment nor the bar's height, so the phone's wrapped control row follows the base rule. The pin istests/test_api.py::test_the_masthead_is_three_rows_right_justified_at_every_width(the row-break assertions are the same ones phase 20 wrote, deliberately: the re-justify must not cost the rows), and the measured half isdata/logs/25-task02-justify.cjs— seven pages × two widths, each demanding both rows computeflex-endand sit flush with the bar's right padding edge, the four badges present, three rows intact, the dropdown still under the whole bar and no sideways scroll, plus/login's two-row bar with no badges (data/logs/25-task02-justify.out, screenshotsdata/logs/25-task02-*.png). Phase 25 task 03 then put the clock at the end of the control row (TODO.md:5— "The time should show up as the farthest right item on the navbar"): the<span class="clock" id="clock">moved from the first control to the last one on all seven templates, so the row reads⚷ Certs→⚙ Account→ theme toggle → clock. It is a markup move and deliberately not a CSSorder:declaration, because on aflex-endrow the last child is the farthest-right item and anorder:override would be something the served-byte pin cannot see —test_every_page_links_to_both_admin_pagestherefore assertshtml.index('id="clock"') > html.index('id="theme-toggle"')on every page (the two Certs/Account-before-theme assertions stay, and the/loginhalf of the test is untouched). The clock keeps its long-standing@media (max-width: 900px) { .clock { display: none } }hide: it is a readout and not a control, so this is not the "no control is hidden to make the width work" rule the masthead keeps. Measured bydata/logs/25-task03-verify.cjs— seven pages × two widths, each demanding three rows, both right-hand rows flush with the bar's right padding edge and the chips' content (not the full-basis row box) measured to prove it is not flush left, the clock the rightmost painted thing on the bar at 1280 px, the cluster's source order unchanged at 360 px where the clock is hidden, and the four badges populated on/certsand/settingsas on the dashboards; plus the 360 px dropdown and/loginunsigned at both widths (data/logs/25-task03-verify.out, screenshotsdata/logs/masthead-25-*.png). The bar is not sticky (phase 20 task 02,TODO.md:10— "It shouldn't be sticky at all. We want to maximize the amount of screen space for data viewing"):position: sticky; top: 0becameposition: relativewith no offset, so the whole masthead scrolls away with the page and the phone's "sticky only part way down" quirk — the fixed-height sticky bar fighting the wrapped rows — went with it.relativerather than the defaultstaticbecause.mastheadis still the positioned ancestor the ≤640px dropdown'stop: 100%resolves against (astaticbar moves the menu to the top of the document), andz-index: 40stays so the menu's41still means one above the bar; the consequence is that the open menu scrolls with the bar, which is what an unpinned bar means. The onlyposition: stickyleft in the sheet is a table's ownthead, inside its own scroll container, and this task is not it. Proven bydata/logs/20-task02-unsticky.cjs(seven pages scrolled at 1280 px and 360 px — with a squeezed-height leg for the pages that fit a tall viewport — plus the 360 px dropdown still landing directly under the bar and still hanging off it after a scroll; screenshotsdata/logs/masthead-unsticky*.png). Note that a browser cannot tellrelativefromrelative; top: 0— Chrome computestop: 0pxfor either — so the absence of the offset is a served-CSS pin, not a measurement. The tab nav collapses into a hamburger at ≤640px (phase 18): the seven tabbed pages each carry#nav-toggle(class="ghost nav-toggle",type="button",aria-controls="nav-tabs",aria-label="Toggle navigation",☰) between.brandand<nav class="tabs" id="nav-tabs">, and/logincarries neither — it renders the brand link and the theme toggle only. Its base rule is#nav-toggle { display: var(--nav-toggle-display) }with--nav-toggle-display: nonein:root, and that token is a specificity ladder, not decoration: the button also carriesbutton.ghost { display: inline-flex }, so a.nav-toggle { display: none }loses and ships a visible button into the desktop masthead. The@media (max-width: 640px)block reveals it through the token, hidesnav.tabs, and tightens the bar'srow-gapplus the cluster's gap (the cluster still wraps inside its own row — hiding a control to make the width work is not the deal) and pads the bar down byenv(safe-area-inset-top, 0px)(the block'spadding-blockvalue restated on the top side so the two cannot drift), because a real device's top-edge zone — Android Chrome is edge-to-edge by default, and iOS does the same once the page opts intoviewport-fit=cover— does not deliver taps to the page: the first-row button in it is unreachable, not unbound, and no headless emulation has such a zone, so a fully green emulated pass still ships a dead button (the same symptom brain of reese fixed in production;env()resolves to 0 on every non-notch surface, which render byte-identical, and the dropdown'stop: 100%follows the taller bar); it renders the dropdown as thenavelement itself — since phase 20 task 03 that element is one box in two states:nav.tabscarries the geometry (position: absolute; top: 100%; z-index: 41) under the whole bar — three rows since task 01, which is the intended look: a menu below the masthead, not wedged between its rows — and the closed state (visibility: hidden; opacity: 0; transform: translateY(-8px); pointer-events: none), whilenav.tabs.is-openflips only those four properties to their visible counterparts. That shape is what the slide needs, not a stylistic choice: the block used to close the menu withdisplay: none, and a box that is not rendered has no intermediate state, so the 180 msopacity/transformtransition the operator asked for (TODO.md:11, "a slide out and slide back in", afterbrain_of_reese's.app-nav) could never have run.visibilityis delayed by exactly the slide on the closed rule (so the box fades out before leaving the tree) and undelayed on.is-open(or the menu would be invisible for the first 180 ms of its own opening);visibility: hidden+pointer-events: nonekeep the closed menu out of the a11y tree, out of the tab order and unhittable, exactly asdisplay: nonedid, and the closed state costs no scroll area (measured). With no second menu element, nobodymarker class, no colour outside the tokens and no JS change at all, both states are named in the file's one@media (prefers-reduced-motion: reduce)block — naming only the closed one would leave the higher-specificity open transition animating (brain of reese's phase-46 lesson). One binding inapp.js'sinitChrome()owns the state through asetNavMenu(open)funnel (.is-openon the nav andaria-expandedon the button, always together) with four close paths: the button again, a tab click delegated on the nav,Escwith focus returned to the button, andmatchMediaturning false on resize back above the breakpoint — which drops a stale.is-openso the next shrink starts closed andaria-expandednever claims a menu that is not on screen. Theif (navToggle && appNav)guard is the whole/loginstory: the shared script loads there and does nothing. The640pxliteral lives in two files — the stylesheet block and that media query — and has to move in both, or the menu opens into a hidden nav. The contract is pinned on the served bytes by threetests/test_api.pypins — the button on all seven pages and nowhere on/login, and one test that compares the CSS media query with the JSmatchMediainstead of grepping for640pxtwice, so a drift fails with both numbers in the message — and proven in a browser at 360px bydata/logs/nav-task04-verify.cjs(the/+/systeminteraction, 24 checks) anddata/logs/nav-final-pass.cjs(the phase-completion sweep: all seven tabbed pages at 360px and at 1280px,/loginat both, 45 checks — its desktop leg now expects the three-row bar's height, not the old fixed 58 px, and still demands inline tabs, no button and a dropped.is-open). Phase 20 task 03 adds three served-CSS pins (the closed state'svisibility/opacity/transform/pointer-eventsand its delayed-visibilitytransition, the open state's visible counterparts with the delay dropped, and the both-states reduced-motion rule inside the file's only such block) plus the guard that nodisplay: nonecomes back to that block, and the browser half isdata/logs/20-task03-slide.cjs(the computed closed state, a mid-transition frame at ≈60 ms in both directions, the four close paths witharia-expanded, the snap under emulatedprefers-reduced-motion: reduce, the untouched desktop tabs and the no-phantom-scroll measurement; screenshotsdata/logs/slide-*.png). The safe-area line is pinned in the same test's rule loop; it was verified on the operator's mobile Chrome rather than in an emulator, because CDP'ssafeAreaInsetsoverride does not feedenv(safe-area-inset-top)— the one half of this contract a headless pass cannot prove.
Metric naming: dotted (cpu.usage_pct, mem.used_pct, disk.used_pct@/srv,
gpu.busy_pct@0, llm.decode_tokens_per_second, ups.charge_pct), @stream for per-device
series — /api/metrics answers with exactly these names. The sample.source column is the
dashboard family — host, llm, ups, and disks for the SMART series
(disk.temp_c@nvme0n1, disk.power_on_hours@…, disk.reallocated@…, disk.pending@…,
disk.uncorrectable@…, disk.percentage_used@…). Those keep their own family instead of
sitting beside host's disk.used_pct@/mount because the cadences differ thirty-fold and
GET /api/metrics?source=disks has to answer "what does this page have history for".
That scheme is the history API's key — treat it as a public interface.
The fleet (real environment, verified 2026-10)
| Host | ssh alias | What it gives |
|---|---|---|
driveripper |
driveripper-ops |
Threadripper 24c/48t + Radeon AI PRO R9700, vLLM :8000, upsd, the dashboards |
deskwork |
deskwork-ops |
Strix Halo (Radeon 8060S iGPU), gufo :8731 |
gateway |
gateway-ops |
router, no GPU |
- Transport is direct dial: dev is the prod topology, repo-wide (phase 10 task 05). The
designed shape is one
dashboards-agentsystemd service per host, installed byopsthrough thedeploy/agent-sudoersgrant withdeploy/install-agent.sh, bound to the address the dashboard dials — since phase 24 registered with a certificate bundle (--fetch-bundle, so itsHOST_TARGETSURL ishttps://and carries no userinfo); what was once the token in the URL is the compatibility path for a host nobody has bundled. driveripper's upsd serves the LAN on 3051 with the host and laptop ranges allowed by the firewall, never by aupsd.confline. The old ssh-tunnel script is deleted and neither.env.devnor.env.dev.exampledeclares a forwarded port — dev dials the same addresses the deployed dashboard does, andscripts/capture-fixtures.shreadsHOST_TARGETSfrom.env.devinstead of naming hosts of its own (tests/test_deploy_scripts.pyfails the day any tunnel name or port returns). Fleet transport, re-verified from the laptop on 2026-10-19. The port-selective inter-VLAN drop phase 10 recorded on the lab gateway10.2.0.1is gone:gateway(10.1.1.27:9120) anddeskwork(10.1.0.51:9120) both take a plain TCP connect and answer/snapshot, anddriveripper's upsd answersLIST UPSon10.1.0.126:3051from this same laptop. All three hosts now run the installed service —systemctl is-active dashboards-agentisactiveon each, with/usr/local/lib/dashboards-agent/host_agent.pyand a--token-fileunit bound to the address the dashboard dials.driveripperjoined during phase 13 on 2026-10-19, in two operator steps. It went from no agent at all (unitinactive, nothing listening →ConnectionRefused, anunreachablestub row) toactivewith/usr/local/lib/dashboards-agent/host_agent.pyinstalled at 13:27, and then its bearer token went intoHOST_TARGETS' URL userinfo — sohost:driveripperanddisks:driveripperareokand thelivesuite is 11 passed / 0 failed (it was 9/2 on the missing token that morning, and 8/3 on the ConnectError before that). The unit reads--host 10.1.0.126 --port 9120 --token-file /etc/dashboards-agent.token(640 root:dashboards-agent, the modedeploy/install-agent.shwrites) and is still in thediskgroup (getent group disk→disk:x:6:dashboards-agent) — but it does not run as that user: gap 10's hand-fix setUser=root, and re-read from the host on 2026-10-20 the installed unit isUser=root+CapabilityBoundingSet=CAP_SYS_RAWIO CAP_SYS_ADMINwith no device filter. Those two extra lines are now template output, and the unit's rewrite is no longer silent: phase 17 took theDevicePolicy/DeviceAllowlines out of the template (task 01), gave it--as-rootand--caps(task 02), and made a re-run whose generated unit differs from the installed one print the diff and exit 1 unless--forcewas passed (task 03), sosudo deploy/install-agent.sh --bind 10.1.0.126 --port 9120 --as-root --caps CAP_SYS_RAWIO,CAP_SYS_ADMIN --forcereproduces it and a re-run that would quietly replace a hand-fixed unit is now a refusal with a diff attached rather than a clobber (gap 10)./api/healthtoday reads all nine sourcesok:host:driveripper,host:deskwork,host:gateway,disks:driveripper,disks:deskwork,disks:gateway,nut,llm:gufo,llm:vllm. The fleet is mTLS since 2026-10-21 (phase 24 task 03), so the bearer-token transport is now history rather than the designed shape. All three hosts runagent/host_agent.py1.1.0 from an installed bundle, every unit carries--cert-bundle /etc/dashboards-agent.certs(plusRuntimeDirectory=dashboards-agent, and still its--token-fileline), andHOST_TARGETSis"driveripper=https://10.1.0.126:9120 deskwork=https://10.1.0.51:9120 gateway=https://10.1.1.27:9120"— nohttp://entry and no userinfo anywhere: the certificate is the credential (the pre-flip.env.devis backed up indata/env-backup/).agent.tls: true+agent.bundle_rev: 2in every payload,deployed_bundle_rev == bundle_revfor all three on/certs, and./scripts/dev-server.sh statusreads the nine sourcesok. The per-host order was the safety: install agent 1.1.0 + bundle →systemctl restart→ the CA-only--statusprobe answers → then that host's URL flips, so no source was red longer than its own host's restart.data/logs/NOTES-24-rollout.mdis the transcript (sha256 of what was staged where, the unit diffs the re-run guard printed, the before/after SMART tables, the screenshots). Two facts in it are load-bearing for the next host: the fetch needed an SSH reverse tunnel (no monitored host can route back to a dev box, sossh -R 18099:127.0.0.1:8090 <host>+--fetch-bundle http://127.0.0.1:18099is how registration reaches the dashboard), and--statustakes its address from--bind, so run bare it dials127.0.0.1and reports "no answer" about a healthy host. Rolling a host back is not just the URL: a TLS-mode agent serves https only, so the--cert-bundleline and/etc/dashboards-agent.certsboth have to go with it. And the fleet now trusts this dashboard's CA — a second deployment with its owncerts.*rows cannot dial these agents until it carries the same authority or re-issues and re-installs every bundle. Phase 10's proof that the old drop was not this repo's firewall stands as history indata/logs/NOTES-verify.md— do not re-add a tunnel on the strength of it, and re-run the probe rather than trusting either note. The per-host record and the exact root commands are indata/logs/NOTES-agent-install.md(its firewall lines are the phase-10 state, not today's), how far the NUT wire reaches from each network is indata/logs/NOTES-upsd-acl.md, and the end-to-end direct-dial verification (health table, no-tunnel-port proof, live-suite tally, screenshots, and the probes that put the block on the lab gateway's port allow-list rather than in this app) is indata/logs/NOTES-verify.md, and the phase-11 login verification (thecurlrefusal codes, the six screenshots, the twometakeys the database actually holds, and the operator's deployment-repo actions) is indata/logs/NOTES-auth.md. Those notes are local evidence, not tracked files:data/is gitignored (invariant 10'sdata/logs/*.pngincluded), so a fresh clone has the conclusions in the prose above and the captures live on the box that ran them — re-run the probe rather than trusting either. - Engines are reached directly: gufo
deskwork.reeselink.com:8731, vLLMdriveripper.reeselink.com:8000. - NUT 2.8.5 on driveripper: configs live in
/etc/ups/, not/etc/nut/, and upsd is on port 3051, not 3493. Nothing listens on 3493 at all, so a default client reports Connection refused and it looks like a dead service —ss -ltnHplusprintf 'LIST UPS\n' | nc 127.0.0.1 3051(answersBEGIN LIST UPS) is how that was pinned down. NUT 2.8 has noACLdirective (this Fedora build is--without-wrap; upsd logsACL in upsd.conf is no longer supported - switch to LISTENonce per line and then ignores it), so per-source restriction is a firewall rule and never anupsd.confline — and 2.8 does not refuse non-local clients the way older docs suggest:LISTEN 0.0.0.0 3051+LISTEN :: 3051with3051/tcpin theFedoraServerzone answers any client routed to it (reads are unauthenticated;SET/INSTCMDstill need anupsd.usersaccount). The host range10.1.0.0/21reaches it, and since 2026-10-19 so does the laptop: a rawLIST UPSto10.1.0.126:3051from the dev box answersBEGIN LIST UPS, which is whynutreadsokin./scripts/dev-server.sh statuswith two real UPSes and noNUT_SIMULATE. Phase 10 recorded10.2.0.1dropping 3051/3493 for this laptop; that is history now, and the probes behind both statements are indata/logs/NOTES-upsd-acl.md. Two UPSes are exported over USB and dialled directly asNUT_HOST+NUT_PORT=3051, and that reads live from every vantage the dashboard has ever used — the host itself, the deployed dashboard, and since 2026-10-19 the laptop (/upsshows both boxes at 100 % charge,OL, with noSIMULATEDbadge anywhere):apc= APC Back-UPS RS 1500MS,cyberpower= CyberPower PR1500LCDRT2U (that isups.description, the operator's label — the driver's ownidentity.modelanswersGX1500U, so the description is what identifies the box). Real-hardware quirks the parsers already handle:battery.mfr.dateis the useful date (battery.dateis often an unset placeholder —2001/09/25, slashes and all, on the APC), CyberPower reportsbattery.mfr.date: CPS(not a date →unknown, never a fake age), neither driver reports a battery temperature or the NUT 2.8battery.charger.status(so the battery glyph's state has to come fromon_battery, andcharger_statusmust stay optional), andups.test.resultis where the two boxes disagree: the APC still answers the factoryNo test initiatedwhile the CyberPower answersDone and passed(a test has run on it since the phase-05 capture; both words re-read from the wire on 2026-10-19 and pinned asLIVE_SELF_TEST_WORDSintests/test_nut.py, which fails if the fixture and the capture ever drift). Both UPSes are recorded verbatim (LIST UPS/LIST VAR/LIST CMD, serials scrubbed) astests/fixtures/nut_<ups>.varsby./scripts/capture-fixtures.sh, and the wire quotes a field only when it must —VAR apc ups.status "OL", not three quoted fields. - Host access is via ssh keys as
ops, and passwordless sudo is different on every host — that is what decides how much of the fleet an install can finish unattended.gatewaygrants(ALL) NOPASSWD: ALL, which is why its agent installed in phase 10 with nodeploy/agent-sudoersline present;driverippergrants a command-scoped set (smartctl,upsc,udevadm, the/etc/upsreads, and thetee /etc/ups/upsd.users|upsd.conf+systemctl restart|reload nut-serverpair documented indeploy/nut-sudoers);deskworkgrants an unrelated set (btrfs scrub,snapper,setfacl /srv/*,osbuild-auto.sh). Neitherdeskworknordriverippergrantsvisudo, so a sudoers change is the operator's action.deskworkanddriveripperboth grant the installer —(root) NOPASSWD: /home/ops/agent-install/deploy/install-agent.sh— and both also grant(root) NOPASSWD: /usr/bin/systemctl restart *. The earlier record here said deskwork'sopshad no installer grant and that its install went through the operator's root; that is out of date —sudo -n -lon 2026-10-21 lists the grant, and phase 24 task 03 installed deskwork's bundle asops. The grant names a path, and until phase 24 that path held an Oct-18 snapshot of the script which carried neither the later flags, nor the filter removal, nor the re-run guard — running it would have replaced driveripper'sUser=rootCapabilityBoundingSetunit with the snapshot's capability-less one, silently. That trap is now closed on all three hosts:/home/ops/agent-install/isops-owned, so the repo'sdeploy/install-agent.sh(sha256ffbcd91f…) andagent/host_agent.py(1.1.0) were staged over it without root, and each snapshot was left beside the grant asinstall-agent.sh.oct18-backup, which sudo will not run. Re-staging is therefore the first step of any install, not an optional one — the grant is only as current as the file it points at. Thesystemctl restart *grant on those two hosts is the other half of an unattended install: a bundle changes the unit, and the unit only takes effect on a restart thatsystemctl enable --nowwill not perform. The template now writes the live unit's shape: no device filter at all (phase 17 task 01 — systemd's defaultauto, withPrivateDevices=false) and--as-rootplus--caps CAP_SYS_RAWIO,CAP_SYS_ADMINfor theUser=rootandCapabilityBoundingSet=CAP_SYS_RAWIO CAP_SYS_ADMINlines (task 02). The old allow-list's missing nodes are no longer a concern for the next install: with no filter,/dev/ng*n*and/dev/bsg/*are reachable the same way/dev/nvme*and/dev/sd*are, subject to DAC and SELinux.
- SMART: real captures, and one live host finally reads them.
smartctl7.5 is installed on deskwork and driveripper. Theopsaccount (which is who runs a hand-started dev agent) is in nodiskgroup, driveripper grants it(root) NOPASSWD: /usr/sbin/smartctl*and deskwork grants nothing, andhost_agent.pyshells the binary as itself — it never calls sudo — so an agent run asopsstill answers Permission denied (exit 2) and reportssmart: null. What changed by 2026-10-19 is thatdeskwork's installed service runs asdashboards-agent, which is in thediskgroup (getent group disk→disk:dashboards-agent) on an agent installed after the SMART pass:/disksshows real numbers there —smartctl_available: true,nvme0n1healthy at 55 °C that day — amber, pastdisk.temp_c's 50 °C threshold, and a plain 48 °Cokon the 2026-10-20 re-read — 19 166 power-on hours (an odometer, so that figure is a capture date, not a fact about the drive), 11 % of rated wear — so the wear bar and the alarm chips are drawn from live hardware, not only fixtures.gatewayanswerssmartctl_available: false(no smartmontools in its VM: the install it half of the three-way flag — and, since phase 22, the arm that keeps it off the/diskspage entirely, so/api/disks/snapshotthere lists deskwork + driveripper andfleet.hosts_totalis 2 whilehost:gateway/disks:gatewayboth stayok— re-verified live read-only on 2026-10-21, where the two cards still draw their pre-phase numbers (deskwork nvme0n148 °C / 19 201 h / 11 %,driveripper16 drives with thenvme5n113 uncorrectablechip,/unchanged at three fleet rows;data/logs/NOTES-22-disks-live.md), anddriveripperis the fourth case the flag was built for:smartctl_available: truewith almost no numbers. Its agent answers (16 whole-disk rows,nvme0n1…nvme7n1+sda…sdh) andsmartis non-null for the 8 NVMes, but every one of them carries only identity —health,temp_c,power_on_hours,percentage_usedand the counters are allnull— while the 8 spinning drives come backsmart: nulleach with the agent's own linesmart: sda: Try an additional '-d ata' or '-d sat' argument.Pinned on 2026-10-19 with read-only probes (data/logs/NOTES-driveripper-agent.md): thedisk-group open of the block nodes works (agent user,O_RDWR), there is no SELinux denier (zero AVCs withauditdrunning), and the install'sDevicePolicy=strict/DeviceAllowlines were removed from the unit (they matched nothing useful anyway — systemd resolvesDeviceAllowglobs by name against/proc/devices, where this kernel lists NVMe asblkext, sonvme*matches nothing). What locks the agent out is kernel 7.2's unprivileged passthrough gate: NVMe admin via the block node is unprivileged-only for five identify types; the SMART-log read needs the drive's ACEL to mark it supported (the Samsung 980 PRO's 2020-era5B2QGXA7does not —NVME_IOCTL_ADMIN_CMD: Permission denied) orCAP_SYS_ADMIN, and SATA SMART behind a SAT layer is ATA pass-through, which the unprivilegedSG_IOallow-list (scsi_cmd_allowed(): read-safe opcodes only) does not include — explicit-d sat/-d atafails identically, so nohost_agent.smart()retry form can reach it.deskworkreads live with the same user/unit because its drive's firmware does advertise the page. So/disksshows driveripper's drive inventory withn/avalues and a why-line — the designed degradation, resolved the same day (gap 10): the unit now runsUser=rootwithCapabilityBoundingSet=CAP_SYS_RAWIO CAP_SYS_ADMIN, and all 16 drives read live (the 980 PRO's 59 °C / 6328 h / 14 % row,nvme1n1at 58 % wear, the red13 uncorrectablealarm chip onnvme5n1; verified indata/logs/disks-driveripper-live.png). A root process with an empty capability bounding set is still capability-less (CapEff: 0) because the kernel gates check capabilities, not uid — the named bounding set is what unblocked it. Real data is in the repo now:tests/fixtures/smartctl_driveripper_<dev>.txtis all 16 drives (8 NVMe, 8 spinning) read through that sudo rule, andtests/test_agent.pyparses every one — which is what found the parser's one real bug: this binary annotates the attribute table's RAW_VALUE column (35 (Min/Max 34/44)on every Seagate, and one Western Digital writes its hours as43894h+43m+32.519s), so reading the end of the row lost the temperature of all eight HDDs and one power-on count. The captures are also the first honest look at this fleet's drives:nvme5n1reports 13 media-and-data-integrity errors andnvme1n1is at 58 % of its rated wear, so the alarm chip and the amber wear bar on/disksare real features with real data behind them, not demo furniture. Lighting up the rest of it was the same two operator actions that travel together — install the agent ondriveripper, and give its agent user device read access: thediskgroup (sudo usermod -aG disk <agent-user>, which is whatdeploy/install-agent.shdoes on install), not a sudo rule, because the agent must read the device itself — on anagent/host_agent.pyfrom phase 09 or later (never redeploy without the grant: 16 permission-denied lines would age nothing but are pure noise, while with the grant they are 16 drives' worth of temperature, hours and sector counters). The agent and thediskgroup are both in place ondriveripper— thediskgroup alone is not enough on kernel 7.2 (the passthrough gate, see above), so the unit additionally runs as root with the two gate capabilities bounded (gap 10) — all 16 drives now read live.gatewayneeds smartmontools first and will still read no SMART after that, being a VM with no physical disk behindvda— and it is the host phase 22's visibility gate exists for: itssmartctl_available: falsemeans/disksdraws no gateway card at all and/api/disks/snapshotlists two hosts, not three — while itsdisks:gatewayjob still polls, still readsok, and its transitions still reach that page's drive-events feed, because the gate hides a host row and not the job's history. That is a different designed degradation from a host that cannot read its drives for any other reason (a denied device,--no-smart, an agent predating the pass): that one keeps its dimmed cards, every numbern/a, and the one-line why — no host here is in thatNonearm today (all three agents post-date the SMART pass), so the arm that stays visible is proven bytests/test_api.py's pins and by nothing on the fleet.tests/fixtures/smartctl_reference_*.txtstay as what they are: reference shapes (labels verbatim from the deployed binary, values synthetic), kept beside the real captures so the synthetic edge cases stay pinned. Re-record the real ones withSMART_HOSTS="driveripper-ops" ./scripts/capture-fixtures.sh: it tries plainsmartctlfirst (so a fixture can never read healthier than the agent does), falls back tosudo -n, refuses to overwrite good fixtures with unreadable output, and scrubs Serial/WWN/EUI-64 (test_smartctl_captures_carry_no_unscrubbed_serialsfails the day it stops).
Commands
uv sync --python 3.14 # deps + editable install
cp .env.example .env # production-shaped config (DASH_ADMIN_PASSWORD seeds
# the login on the first boot of a database — with no
# seed and no stored verifier the server exits non-zero)
cp .env.dev.example .env.dev # dev-only: the real hosts (this fleet's URLs are
# `https://` with no userinfo — the bundle is the cred)
set -a; . ./.env.dev; set +a # dev dials the fleet the prod way, no tunnels
./scripts/dev-server.sh restart # keep http://127.0.0.1:8090 up; sign in with the
# DASH_ADMIN_PASSWORD from .env.dev (`testpass`), then
# export every issued HOST_TARGETS bundle to data/certs/
./scripts/dev-server.sh status # per-source ok/DOWN — unauthenticated: /api/health
./scripts/dev-server.sh bundles # what that export left: names, sizes, modes
./scripts/dev-server.sh logs
uv run python scripts/dev-certs-export.py --url http://127.0.0.1:8090 \
--password "$DASH_ADMIN_PASSWORD" --names <the HOST_TARGETS names>
# the export on its own (phase 24): reads
# GET /api/certs/<name>/bundle and writes
# data/certs/<name>.bundle at 0600 — mints nothing
uv run pytest # hermetic: 1543 passed + 15 skipped (11 live,
# 4 store_perf), no network
uv run pytest --cov # coverage gate (98.94%; floor 94%)
uv run pytest -m live --live # opt-in: real fleet / engines / upsd
uv run pytest -m store_perf --store-perf # opt-in: the store hot-path timings over the seeded
# store, priced against `store_perf.BUDGETS` (the cached
# reads report cold *and* warm, so a before/after never
# books a cache hit as a speed-up)
uv run python scripts/watch-store-perf.py
# ... and the live proof: 25 signed-in minutes against the
# running dev server, timing the page paths while counting
# `store-error` rows and the WAL size (DASH_WATCH_SECONDS
# shortens it; writes store-perf-watch.{log,tsv,json})
uv run python scripts/watch-llm-window.py
# ... and the phase-23 proof: signed-in reads of
# /api/llm/snapshot at the app's own cadence, judging every
# slot start/end against "settled within two polls", the
# Δ<window> labels and the 1 m rollups — read-only, it
# starts no workload (DASH_LLM_WATCH_SECONDS shortens it;
# DASH_LLM_WATCH_REPLAY=<tsv> re-derives the verdict from a
# captured pass over the same samples, sending nothing)
uv run python scripts/store_perf_probe.py # ... as a report (data/logs/store-perf-baseline.{md,json},
# then store-perf-task03.* for the write side and
# store-perf-task04.* for the budget table);
# --rebuild re-seeds, --dev-db <file> also times a real
# store read-only, DASH_PERF_STORE_DIR moves the cache, and
# every pass that *writes* runs on a scratch copy so the
# seeded baseline never moves under the next measurement
# the bundle for one host: the stored bytes (that host's private key included) and
# `install-agent.sh --cert-bundle`'s only input — signed-in GET, see README's rollout
# curl -s -H "Authorization: Bearer $SESSION" -o /tmp/agent-<host>.bundle \
# http://127.0.0.1:8090/api/certs/<host>/bundle
uv run ruff check src tests agent && uv run pyright
./scripts/capture-fixtures.sh # re-record ground truth (agents + UPS + gufo /slots)
SMART_HOSTS="deskwork-ops driveripper-ops" ./scripts/capture-fixtures.sh
# ... plus smartctl per physical disk (needs device read
# access: plain smartctl, else `sudo -n`; scrubs serials)
bash .agents/validate.sh # the phase gate: the four checks above (pytest
# --cov --cov-fail-under=94, ruff, pyright, plus a
# node --check of web/static) + repo hygiene.
# Always `uv run` — a bare `python3`/`ruff` on PATH
# tests an interpreter nobody deploys.
Deployment: Containerfile (two-stage, uv sync --frozen, runs as uid 10001 on 0.0.0.0:8090,
SQLite under /var/lib/dashboards — put a volume there) and deploy/install-agent.sh
per monitored host (systemd unit, own user, --bind/--port/--token/--rotate-token/
--cert-bundle/--fetch-bundle/--cert-name/--session/--as-root/--caps/--force/
--status/--uninstall; it stores the token
0640 root:dashboards-agent — the
unit hands the agent the path and the agent opens it as its own user, so that user must be able
to read it and no other account may, and a --cert-bundle file is installed to
/etc/dashboards-agent.certs at that same mode after a shape check that runs before anything is
touched (with a local file this script dials nothing; the fetched path is the one dial, below), so
0600 would fail the unit exactly like it fails the token file; the task file's 0600 is the
intent — root and the agent's own user only — and 0640 is the mode that achieves it) — never
puts a token on argv, and runs under ProtectSystem=strict with no device
filter — SMART device access is DAC via the disk group + SELinux, because kernel 7.2 matches
a device-filter glob by name against /proc/devices (where NVMe is blkext), so the old
allow-list denied the whole namespace; PrivateDevices=false stays explicit because a private
/dev with no device list kills the unit at spawn (status=208/STDIN). Where DAC is not enough,
--as-root writes User=root and --caps CAP1,CAP2 writes that CapabilityBoundingSet line
(space-separated, as systemd spells it): kernel 7.2's unprivileged-passthrough gate checks
capabilities rather than uid, so --as-root alone is capability-less (CapEff 0) and prints a
note saying so, while --caps without --as-root is refused with exit 1 before need_root and
before any write. Both default off, and neither touches the token file's mode or the --status
probe. A bundle-installed
unit also gets --cert-bundle plus RuntimeDirectory=dashboards-agent (mode 0700 — the bundle unpacks its
PEM blocks there and the agent deletes the key once the SSL context holds it), keeps the
--token-file line for a host-by-host rollout, and --status then probes https on the
bundle's CA with no client certificate via a python3 ssl one-liner — task 02's
CERT_OPTIONAL + /health exemption is what makes that probeable, and there is no external TLS
binary. A re-run without the flag leaves an installed bundle in place, and --uninstall removes
it with the token — and neither is guarded, because both are deliberate. Registration can fetch
that bundle (phase 24 task 02): --fetch-bundle <dashboard url> --cert-name <HOST_TARGETS name> --session <signed-in token> dials GET /api/certs/<name>/bundle with python3 urllib (no
curl assumption on a monitored host) in the install run and hands the bytes to that same
--cert-bundle path, so the shape check, the 0640 install, the unit's TLS lines and the
--status probe behave identically for a fetched and a downloaded bundle. The three flags are
all-or-nothing, are refused together with a local --cert-bundle and with --status/--uninstall,
and the fetch plus the shape check run before need_root: a 401, an unissued name, a proxy's
200-with-HTML or a dashboard that never answers is one line naming the status and no partial
install. The session is a fetch credential only — it travels to python in the environment (argv
is world-readable through ps, which is why the agent's token is a file), is unset as soon as
the bytes are in hand, appears in no written artefact and not even in the sudoers hint's re-quoted
command, and the fetched temp file is removed by an EXIT trap on every path. What is guarded is the
unit itself (phase 17 task 03): it is rendered to a temp file first, and if an installed $UNIT
differs the run prints refusing to overwrite … plus the diff -u on stdout and exits 1 before
the unit write, the daemon-reload and the enable, so a redeploy cannot undo a hand-fix and an
identical re-run stays the silent zero-noise path. --force prints the same diff and applies it —
which is what the TLS rollout's --cert-bundle step needs on a token-mode host, because that step
is a unit change. What it applies is the file: systemctl enable --now only starts a unit,
so a run that rewrote ExecStart leaves the already-running agent on its old settings until
systemctl restart dashboards-agent — and --status chooses http from https by reading the unit
file, so probing before that restart reports "no answer" for a change that landed correctly (the
rollout in README restarts for exactly this reason). It takes its address from the flags rather
than the unit, so a bare --status probes 127.0.0.1:9120 and answers "no answer" about an agent
bound to a LAN address that is healthy — always pass the same --bind/--port the install did. No root shell? The one sudoers
line that lets ops run the installer is deploy/agent-sudoers; run the script without root and
it prints that line with its own resolved path.)
The quadlet unit + env template + nginx/SNI live in the deployment repo, like every other
service here. The source itself is git@git.reeseapps.com:services/dashboards.git (origin,
branch main); there is no CI on it, so bash .agents/validate.sh is the only gate.
Testing policy
Real captures for shape, fakes for behaviour, live for drift.
- Anything that must match an upstream —
/proclayouts, vLLM series names, NUT framing — is asserted against a verbatim capture intests/fixtures/. If the schema drifts, a fixture test fails before a dashboard does.smartctl_driveripper_<dev>.txtis the whole 16-drive fleet read through asmartctlsudo rule (Serial/WWN/EUI-64 scrubbed), andtest_the_real_drives_answer_every_number_the_disks_page_printsasserts every number/disksprints comes out of real drive output — that test is what caught the parser reading the end of an attribute row instead of its annotated RAW_VALUE column. - Anything that needs a failure mode — upsd refusing a command, an engine 500ing, a host
rebooting mid-poll, a counter reset — is a fake in
tests/conftest.py. - A verdict the operator sees is pinned on the surface that shows it, in both directions.
tests/test_nut.pygrades the word;tests/test_api.py'sself_test_appharness holds thenutjob's own payload (its collector is swapped, so a poll cannot overwrite what the test mutated) and rewrites theLast self-testcheck in the object/api/ups/snapshotserves: a pass (Done and passed) and a never-tested pack produce no/api/summaryalert row, a fail produces exactly onefailrow per UPS linking to/ups. The two directions ship together on purpose — an absence-only assertion is satisfied by deleting the check, which is not the fix the report asked for (phase 12). The same file pins that the/upscheck list needs no markup for a new verdict:<li class="${check.status}">plusul.checks li.<verdict> .markindash.cssis the whole render path, and no page asset or stylesheet rule names one check (a second opinion about the driver's word is the bug). - A measurement window is pinned from both sources, through one contract module (phase 23).
tests/rate_window_contract.pynames the two Δ windows (HOST_WINDOW=RATE_WINDOW_SECONDS,LLM_WINDOW=LLM_INTERVAL_SECONDS), thedelta_label()rounding the pages use to printΔ2.0s/Δ10.0s, andassert_window()— the estimator's own bounds (MIN_WINDOW_SECONDSup to2 × span, because a span equal to the cadence means the base comes from the window-empty fallback and a real window is the poll gap: 2.009-2.026 s live on/llm) plus the printed label, so raising a cadence knob moves the window and the pin together instead of rotting it. Four files read it:test_api.pyspies the estimator object each source's jobs receive and pins that/api/config's LLM window is the LLM's own span;test_llm_source.pywalks both throughput cells of both engines at 2 s and re-runs the phase-13 floor refusal through the production parse path at that span;test_host_source.pykeeps the host payload'swindow_secondsat 10 s and shows the same 10 s cadence starved ton/aby a 2 s span (why one estimator cannot serve both);test_rates.pyruns the floor, the2 × spanceiling and the 8× jump gate at both spans, because those are properties of the estimator and not of the window size. A pin naming one source's window is what this prevents — the two payloads are the same shape and only the number differs, so half the pin is how a quiet regression would look. - Page payloads that a rewrite must not break are pinned as a table.
tests/card_contract.pyholds the dotted/api/system/snapshotpaths the/systemhero cards read (CARD_METRICS,GPU_METRICS,HOST_SHAPES);tests/test_system_contract.pyasserts it against the three captures (discrete GPU, APU, no GPU) and over HTTP, andtest_every_host_serves_the_hero_card_contractasserts the same table against the live agents — so a rename fails a test, and a card that showsn/acannot be "fixed" by inventing a field.networkcarries noseverityon purpose (throughput has no good/bad) and neither does a GPU row (phase 06: a busy GPU is the job on an LLM server) — the contract pins both absences.tests/ups_contract.pyis the same device for the/upshero row and battery glyph (HERO_FIELDS,UPS_SHAPES,assert_ups_hero_contract):tests/test_ups_contract.pydrives it fromtests/fixtures/nut_<ups>.vars— verbatimLIST UPS/LIST VAR/LIST CMDreplies from the two real UPSes — andtest_ups_hero_fields_are_present_on_the_real_batteriesdrives it against live upsd. The captures are what make the driver disagreements testable: one of them answersbattery.mfr.date: CPS, neither exportsbattery.temperatureorbattery.charger.status.tests/slots_contract.pyis the same device for gufo's/slotsrows, which the/llmslot grid (phase 07) reads (assert_slot_shape, required/optional field tables, duplicate slot ids, thenext_token[0]entries): hermetically fromtests/fixtures/slots_gufo.json— the verbatim/slotsreply, idle slots and all, captured wheneverGUFO_BASE_URLis set — and live viatest_gufo_slots_answer_the_pinned_shape. That fixture exists because its absence is how the deleted slot table rendered dashes forever: its field names (slot.state,slot.n_prompt,slot.decode_per_second) were a memory of an upstream, not a capture. The contract names one canonical key per fact and the grid renders that one: the capture answersid_taskandtask_idfor the same request, soCANONICAL_TASK_ID_FIELDdecides and the alias is captured but rendered by nothing — a rename fails a test instead of becoming a silenta ?? bchain in the page.tests/test_api.pyreads the servedSLOT_FIELDStable and resolves every key it names against the capture, in both directions. A busy slot'spromptis the user's in-flight request text, so the capture script blanks it and the page never reads it. live(tests/test_live_fleet.py) re-runs the production code paths against the real fleet, engines, and upsd. Skipped unless--live; each test skips when its source is unset, so partial fleet availability is normal. Live tests must not write — a battery test on hardware something is plugged into is not a test. Anhttps://agent is dialled with the stored authority, throughfetch_snapshotand never a hand-rolledclient.get(phase 24 task 03). The module-scopedagent_tlsfixture reads thecerts.*rows out of the same SQLite file the app uses — through a read-onlysqlite3URI whoseset_meta/delete_metaraise, because "live tests do not write" includes not minting or repairing an authority from a test — and handscollectors.agent_tls_mapthe materialcerts.write_materiallaid down, exactly asapp.build_hubdoes. The failure this removes is the misleading kind: a rawgetagainst a healthy mTLS agent dies withCERTIFICATE_VERIFY_FAILED: self-signed certificate in certificate chain, which reads like a dead host and is really a test that forgot to present a certificate. No store file, or no authority in it, skips — the same partial-environment rule as an unreachable host.- The agent's real-
/proctest (tests/test_agent.py) touches only the box running pytest, so it stays in the default run and is not markedlive. tests/auth_contract.pyis why the HTTP suite can start a server at all: every app in a test is seeded withadmin_password="testpass"(the local-testing password — a server with no stored verifier and no seed refuses to start, so an app under test needs a credential exactly like a deployment does) and hashed atpbkdf2_iterations=1000, because the real 600 000 costs ~0.3 s per client and the suite starts a hundred-odd of them. The real figure stays pinned intest_auth.pyandtest_config.py, so the cheap cost here cannot become the default there.- The perimeter is its own file, and it is paranoid in two specific ways.
tests/auth_client.py::sign_in(client)is the one way a test gets a session: it poststestpass, asserts the sign-in worked (so a suite cannot silently start from a 401 and fail three lines later on something that looks like an unrelated bug), and puts the returned CSRF token on the client's default headers — which is what let the ~200 pre-existing HTTP assertions intests/test_api.pystay untouched, asserting UPS behaviour rather than the new gate.tests/test_auth_api.pythen runs the gate through a real ASGI request withfollow_redirects=Falseon every anonymous call:TestClientfollows redirects by default, so an unsignedGET /api/configwould fetch the login page and report200— the way a gated suite ends up passing while testing nothing. Two of its tests are the ones to keep if you keep none:test_every_route_that_is_not_public_requires_a_sessionwalks the served route table (walk_routesflattens pages,include_routerwrappers and the/staticmount, and raises on anything it does not recognise, so the audit cannot silently shrink) and demands401/303for every path outsidePUBLIC_PATHS, GET and POST — the test that protects the next phase rather than this one; and the UPS pair prove the CSRF refusal on the wire (a scriptednut.NutClientrecords nothing for a tokenless write and does record it once the header is there, anddriver.killpowerstill needsDANGERbehind the session), because 403 afterinstcmdwould be a power cut with an error message attached. - The setup surface has its own pins, because prose rots quietly.
tests/test_auth_docs.pyreads the artefacts an operator actually holds when a dashboard will not come up: both tracked env templates (everyDASH_*auth setting declared; every secret-shaped assignment a placeholder — the same allow-list.agents/validate.sh2b uses, souv run pytestcatches a committed password on the working tree and not only in the gate; the local-testing word allowed in prose and forbidden as a value),scripts/dev-server.sh(its probe and itsstatusride/api/health, nodash_session//api/auth//Authorization:anywhere, the password named but never assigned), andREADME.md/AGENTS.md(the Signing in section before Configuration, the four public entries, the resetDELETE FROM meta …command verbatim, the four config rows, theDASH_COOKIE_SECURE=1deployment note, the three auth gaps). No server starts and no request is made: a doc that stops naming the reset command is a locked-out admin, and that is worth a test even though it asserts nothing about Python. - A module that holds a private key is pinned structurally, not only behaviourally
(phase 15).
tests/test_certs.pyreadssrc/dashboards/certs.pyas text and fails if it grows aloggingimport, a raise carrying an interpolated value, or an import from the rest of the package — the same three pinstests/test_auth.pyholds overauth.py— because this app turns log lines and error strings intoeventrows and/api/healthfields that a browser reads, and a stored PEM is a key. Its behavioural half runs the whole lifecycle over a dictMetaStore(five rows on first boot, no byte changed on the second, rotate/revoke/root-rotate and their denylists, a bundle that carries the agent key and never the root key), and one test completes a real client-certificate handshake through the materialwrite_materiallays down. The other two halves run real code rather than a model:tests/test_agent.pydrives the realhost_agenthandler over a TLS-wrapped loopback port (the handshake matrix, the live denylist re-read, token mode unchanged) andtests/test_deploy_scripts.pyrunsinstall-agent.shin a sandbox whoseinstall/chownare faked, which is how a0640mode, a byte-identical bundle and the https--statusprobe are proven without a root shell or a host. A0600in a task file is not a fact about this repo: the agent opens its own bundle and re-reads it for the denylist, so the group bit is load-bearing. - A bundle source is validated once, whichever source it is (phase 24 task 02). The
installer's
--fetch-bundleis tested through the sameserved_dashboardsocket as the export, because the claim is the same claim: the file a host installs is the route's bytes. The pins that go with it are the ones a second install path would break — byte-identical0640install with the unit's TLS lines;issued: false, a 401 and a 200-with-HTML each refused with their own sentence, no stub call logged and no file written (the stub log is the proof the refusal came before the root boundary); a tempTMPDIRleft empty on the success path and on every refusal path, including the one where the shape check is what stopped the run; the three flags all-or-nothing and refused beside a local--cert-bundle, beside--status/--uninstall; and one label-aware validator, pinned by demanding that the local path still reads--cert-bundle …and never names the flag that was not used. - A script that is an HTTP client is tested over a real socket (phase 24). The dev bundle
exporter is run as a subprocess against a real
create_app(...)served by uvicorn on127.0.0.1:0in a thread, not throughTestClient: what has to be proven is that it signs in through the public route, reads the session token back out of aSet-Cookieheader, and sends it asAuthorization: Bearer— none of which a transport-level test client has to serialise. The pins that go with it are the ones that keep the artifact honest: the file byte-identical to the route's bytes,0600(including the re-chmodof a file somebody loosened), no file and one note forissued: falseand forconfigured: false, exit 2 with nothing written for a refused sign-in, no mint (the serials, revisions and root are the same after the run), and no PEM, token or password in the output. The dev-server composition is pinned on the served text too — export after the readiness probe, failure as|| echoand noreturn 1— because "it runs later, and never failsstart" is the part a refactor loses. - A live measurement is re-derivable, so a verdict can be re-checked without re-watching
(phase 23).
scripts/watch-llm-window.pywrites the series it judged (value, theΔ<window>the payload carried, the engine's own busy count, the raw token counter) beside its verdict, andDASH_LLM_WATCH_REPLAY=<tsv>re-computes that verdict from the file with no traffic sent. The pass line is read on the same samples re-diffed over the estimator's own span and on the published value, because the phase-13 jump gate can legitimately hold a correct number for a poll — judging only the published column would call a designed hold a ramp. - A cost claim is pinned as a shape and a control, never as a wall clock (phase 14). The default
run keeps the seeded-store tests hermetic and asserts shape and determinism — counts,
retention windows, the statements a plan is taken from, idempotence — while the timings live behind
-m store_perf --store-perfand inscripts/store_perf_probe.py, because a budget asserted on first writing is a number fitted to the machine it was written on. Two rules came out of it. (1) A cheaper query is proven equal, not just faster:test_windowed_rollups_answer_exactly_what_one_full_window_rollup_answerscompares every stored row of a windowed store against a second store rolled the old way, andtest_a_re_rolled_bucket_only_ever_gains_rowspins the one bug that equivalence alone misses (a window floor that cuts into a bucket re-rolls it from its tail rows). (2) A race that comes back clean has to be shown capable of going dirty:store_perfruns the pre-fix whole-windowmaintain()as a control beside the windowed one andtests/test_store.pyfails if the control stops contending, because "0database is locked" from a race that never met is the invariant-4 failure mode with a green checkmark attached. (3) A budget is a table, not a test body (task 04): the 14 ceilings live instore_perf.BUDGETSwith the measured figure and the margin in each row'sbasis, the probe renders that table, and two default-run tests fail if a budget stops being priced or a ceiling contradicts its own ratio partner — a ceiling nobody measures is a comment. (4) A cache's cost claim needs the write cadence in the measurement: task 02's series cache reported 0.0 ms on a quiet seeded store and served almost nothing on a live one, because every sample write invalidated it. The pinned version is statement-counting, not timing — a poll of existing series issues noSELECT, a write of a name nobody wrote ends the cache — and the live version isscripts/watch-store-perf.py, which is where the defect showed up. - Coverage gate: 80% is the repo floor, the phase gate in
.agents/validate.shenforces 94% (--cov-fail-under), and ~98% is where we are; keep it there. New/modified Python still has to clear >90% on its own. API tests usecreate_app()with an in-memory DB, never a real target, and never a real upstream:/api/ups/command's send is proven against a scriptednut.NutClient(ScriptedNutClientintests/test_api.py) because the live suite is read-only — that fake is the only place the write reaches a "driver".
UI conventions
Hand-rolled SVG (charts.js), one stylesheet, dark/light toggle, Δ<window> labels on
measured rates, n/a for the unknown, SIMULATED badges on modelled data, per-source status
dots in the header. Every chart names itself for assistive tech: pages pass ariaLabel to
LineChart, which puts role="img" + aria-label on the <svg> (an unlabelled plot is
silence, and the legend is a sibling that labels nothing). Every LineChart on every page has
one — /system's three per-host slots, /disks's two per-host plots, /llm's four trend
plots, /ups's four battery plots — each pinned by tests/test_api.py, so a chart that ships
anonymous fails a test.
Window chips (5m…30d on /system and /llm, 1h…90d on /ups and /disks; the API accepts
5m…90d — SMART lands every 300 s, so a 5-minute window on /disks is one dot). A
panel that cannot get data says why in the panel — the operator should never have to open
devtools to learn that a source is unconfigured. Escape anything upstream-provided through DashCharts.escapeHtml.
Flavour text is not helpful information. A page does not narrate its own data pipeline or
its own design; copy exists only where the operator needs it to act — a why-this-panel-is-empty
sentence, a provenance tag, a measured Δ<window> label, a chart's unit, a SIMULATED badge,
an error that names what failed. The audit is one question per string: would the operator act
differently without it? No → delete it, container and all. Deleted in phase 08 (the cited
examples, from TODO.md:7): "every number is scraped from the box it belongs to · null
renders as n/a" (/'s footer), "agent-reported values; rates are measured over the Δ window
in the footer, and each panel's charts cover only that host" (/system's Per host sub-line),
and the same genre — /system's "numbers are measured, not inferred", /llm's "each throughput
number carries its provenance", /ups's "commands are the only write path in this app", each
event feed's content caption (the empty-state sentence already says it), and the design
clauses inside empty-state prose. What survived the audit did so because it is a measurement or
a why: every Δ<window> footnote, provenance() on each rate, fleet.seams, sums only
engines answering now, the queued-request note under the slot grid, the SIMULATED banners, and
the setup instructions in every empty state. tests/test_api.py pins the deleted sentences
absent from every served page and script (a sentence that migrates to another page is the
same slop), the kept functional strings present, and that no removed sub-line left an empty
.sub/.faint container behind.
The overview is exactly two panels — Warnings & errors (ul.alerts, one row per
/api/summary alert: verdict class, source linked to its dashboard via the alert's own
href, message, age) and Fleet. A non-empty list floats the alerts panel above the
fleet table (#overview-panels[data-state] swaps them); an empty one is an explicit
"all clear" state below it, because an empty list is a message, not a blank box. A host that
is not answering keeps its row, dimmed with the shared .stale class and reading n/a, so
the table never implies last good numbers are live and never silently loses a machine.
No summary cards and no raw event feed survive on / — tests/test_api.py pins the
two-panel shape against the served HTML.
Nothing waits for a writer that never came: tests/test_api.py fails if a page keeps an
id that no page script, no shared script, no stylesheet rule and no aria-*/for
attribute references — the orphan a deleted renderer leaves behind, caught on served bytes
because there is no JS test runner (a locked anchor). Same rule for dash.css: delete the
rules a removed panel made dead (.grid.wide went with the overview's fleet+events row);
component vocabulary the pages are still being converted to (.hero-bar, the grid and bar
variants) stays even while only one page uses it.
A big number is Dash.hero() on .hero / .hero-bar (dash.css): 2.4rem tabular numerals,
muted unit, mono .sub footnote (Δ2.0s on /llm's rates since phase 23, "at the current load"
on /ups), coloured by the payload's severity through the existing .ok / .warn / .fail
classes — there is no .sev-* family and no second palette, and tests/test_api.py fails if either appears or if a CSS
rule reaches for a token that :root does not declare.
The /disks card is one panel per physical drive, led by identity and verdict, not by a
hero (phase 09): a drive has no one-number story the way a UPS has charge %, so the card opens
with the device path (/dev/nvme0n1) and the drive's own word (OK / FAILED / unknown, a
.chip coloured only by severity.health — an unreadable drive gets grey unknown, never a
green pass), then the identity line (model · size · SSD|HDD, nothing invented where a field is
absent) and three equal .metric cells: Temp, Power-on, Wear. Colour comes from
severity.temp_c / severity.percentage_used and nowhere else; power_on_hours is an odometer,
written with a null verdict, so it cannot read green at 100 h or red at 60 000 h. The sector
counters are .chip.fail alarm chips that exist only while non-zero (the /llm preemption
rule, and why severity.py rates no counter): a drive at zero draws no chip row at all, because a
permanent "0 pending sectors" line trains the eye to ignore it and a green one would call a quiet
drive an achievement. Per host the three-way smartctl_available decides one .banner.info
why-line and nothing else — null prints the agent's own smart_errors line or names both fixes
without claiming to know which half is missing, and an agent that is not answering says that
instead — and in either case the drive grid takes the shared .stale dim while the host card
stays uncoloured (invariant 3: unreadable drives are unconfigured, not broken, and a red panel
here would train the operator to ignore red on the drives that do report). The false arm — the
agent said smartctl is not installed — no longer reaches the page at all: api._disk_visible
drops that host from /api/disks/snapshot, from the fleet sums and from /api/summary's disks
rows (phase 22), because a card whose only content is install smartmontools and grant the disk
group is not a host on a drive page; whyLine()'s false sentence stays as the renderer for a
payload that says it, so relaxing the gate later cannot turn those hosts into silent blank cards.
Nothing on the page is per
partition or per mount: the rows are the agent's whole-/sys/block list, and a filesystem field
naming its way into page-disks.js fails a test. The fleet header renders
fleet_disk_summary() — hidden below two hosts (one box's sums duplicate its own cards) and
coloured nowhere, because a count of drives is not a graded number — and the per-host Charts
disclosure follows /system exactly: collapsed, lazy-built, one /api/disks/history request
per host per refresh naming disk.temp_c@dev and disk.percentage_used@dev for every drive (the
endpoint answers unknown names as empty series on purpose), the wear plot created only once some
drive publishes the field, legends naming drives because the card heading already says who, and
pruneHosts taking the cards, the toggle and the plots off the page together.
A cell-scale trend is DashCharts.sparkline() — the llm_monitor drawSpark idiom, so a
percent metric is plotted against fixedMax (never autoscaled: an 8 % KV window that once
held a 92 % spike must not read flat, and the axis gets top/bottom scale marks so "a third of
the way up" says a third of what). align: 'right' pins the newest sample right and grows the
line leftwards inside a real span in seconds — x stays time, not sample index. Stroke colour
comes from the payload severity via the --ok/--warn/--fail tokens only; an unrated
series draws a faint --text-faint stroke with no fill, because a shape is not a verdict.
Below minPoints (2) it prints no history rather than drawing a confident line through one
point, labels: {current, max} renders the now / max foot through the caller's format, and
there is no animation at all (prefers-reduced-motion holds by construction). minSpan is the
other half of the same honesty for count metrics: history arrives as rollups, so eight seconds
of one queued request averages to 0.14 and an axis fitted to that draws a full-height spike for
a queue nobody was in. /llm's count rows moved to the phase-07 stats strip (numbers, not
sparks), so no page passes minSpan today and drawSparks() still plumbs it through — a count
sparkline that comes back must set it, and the component's own contract test keeps the option
honest (test_sparkline_ships_the_fixed_scale_and_verdict_options).
A battery is DashCharts.battery(container, {charge, severity, state, label}) — one inline SVG
whose fill is the charge: 1 viewBox unit per percent, clamped to 0–100 and never rounded up
(a 2 % cell must look nearly empty, because a reassuring battery above a flat one is a lie),
clipped to the plate so 0 % leaves an empty shell rather than a stray pixel. Colour comes only from
the payload severity token; a measured charge with no verdict draws faint grey at half strength
(sparkline's rule — a shape is not a verdict) and never an empty shell ("0 %" and "not rated" are
different sentences). Every state has a shape as well as a colour, so the glyph survives
monochrome: solid shell online, bolt charging, dashed outline onbattery, no fill with a dash
pattern through the body unknown; a state word it does not know degrades to unknown, never to
a confident "on line". Accessible by default — role="img" + <title> + aria-label built
from the inputs ("apc: Charge 100 percent, on line"), because the glyph carries information the
markup does not say in words — and a page that prints that sentence next to it passes
decorative: true to make the <svg> aria-hidden rather than say it twice. No animation and no
ResizeObserver: one viewBox + preserveAspectRatio scaled by .battery in dash.css, so the
hero row gets a big cell on a desktop and a small one on a phone.
The /ups card is led by the glyph and three heroes (phase 05): the battery on the left, then
Charge %, Load % and Runtime left on .heroes at the shared 2.4rem, coloured from
severity.charge_pct / severity.load_pct / severity.battery_runtime and nothing else. Charge
carries no .bar — the glyph beside it is that value drawn as a shape, and one value drawn
twice in one row is the pattern this app avoids (Load keeps its bar; nothing else draws it). Its
.sub lines are the context each number needs and nothing more: 24 V nominal · 27.3 V for the
pack, 128 W of 900 W when the driver publishes both realpower figures (a wattage inferred from
a percent against another model's rating would be an invented number), at the current load for
runtime. Runtime prose is runtimeText()'s (1h 32m, 10m 21s) and an estimate the driver never
made goes into Dash.hero() as null, so it prints faint and uncoloured rather than as 0m. The
readings the hero row displaced — line in, output, battery volts and amps, battery °C — stay on the
card, demoted to .ups-electrical: muted, .95rem, never folded away, because they are what you
reach for while the UPS is on battery, and battery °C reads n/a on both real drivers here.
state is derived in the page from on_battery → charger_status (chg charges; dchg/rec are
the server's own on-battery tokens; full is on line, not charging — a rested floating battery
reports it) with one fallback: a state chip the API rated ok, because nut.states() reserves
ok for on-line/fully-charged and neither real driver here publishes battery.charger.status at
all. ups.status itself is never re-parsed in JS (tests/test_ups_contract.py). /ups passes
decorative: true, because the charge hero and the state chips already say the sentence. The glyph
repaints only when charge / verdict / state change (drawGlyph() keys a data-drawn triple): the
charge float jitters every NUT_INTERVAL_SECONDS, and rebuilding the <svg> on that re-announces
an accessible name for a difference no eye can see.
An event row names its subject once. Every feed (/system, /disks, /llm, /ups) renders the
message through Dash.eventText(event), which drops the UpstreamError: wrapper and a
leading duplicated subject the way alerts._clean_error() does for the overview — poller.py
messages start with their own label, and each row already prints that label as a chip, so
"gufo gufo is answering again" reads as two problems. A name in the middle of a message stays:
the UPS rows used to replace() it anywhere and mangled "Quick battery test sent to apc: OK".
This is presentation only — the stored event and /api/events keep every word, and the raw
error string still surfaces in /api/health.
An event feed is a window, not a printout (Dash.eventsPager(el, events, opts) in
app.js, one helper with four call sites (/system, /disks, /llm, /ups) — a pager that
works on one page is the bug this
shape prevents). ul.events is a bounded 320px scroll container (overscroll-behavior: contain) and the pager puts EVENT_PAGE_SIZE = 50 events in it, with a .events-pager
strip under the list: 1–50 of 200 between ‹ prev and next ›, both aria-labelled for
direction (the feed is newest-first, so prev walks towards now) and disabled at the ends.
The strip is hidden when the feed fits on one page — every row on screen is the whole
story, and a disabled arrow says "more history" when there is none. Each page keeps its own
row(event) (so /ups keeps its longer timestamp and runtime footnote, and escaping stays
the page's job — the pager has no default renderer and says so in a row when a caller forgets
one) and its own empty sentence, so the loading… and empty states read exactly as they
did before paging existed. Two numbers are load-bearing and pinned together: the fetch
(EVENT_FEED_LIMIT = 200, /api/events?limit=…) must exceed one page, because a limit at or
below EVENT_PAGE_SIZE is a pager that silently never appears; /api/events itself is
unchanged — no page/limit vocabulary grew for one page's layout. Paging state lives in
a WeakMap keyed by the <ul>: it survives a poll (so page 3 is still there two seconds
later — resetting every refresh would make page 2 unreadable) and resets to page 1 when the
event set changes (an operator reading page 3 of yesterday's history must not land on page 3
of a list that just changed). Rows are re-written only when their markup changed, which is
what keeps the reader's scroll position inside the container. The /ups feed is also
expandable (phase 19, TODO.md:4): its card body is a details.events-box disclosure with
no open attribute, so that card is one Power events summary line until the operator opens it
— a closed <details> renders no content, which is /system's Charts rule applied to a feed,
and the #events id stays inside the disclosure so the pager and its bounded scroll keep their
target (pinned on the served bytes). The disclosure is also what makes it lazy: refresh()
fetches the feed only while state.events.open is true, so a card nobody is looking at costs the
poll loop nothing (a hidden-but-still-polled list is the half-measure this is). The first expand
fetches; a re-expand paints the rows already in the <ul> while they are younger than one poll
interval (state.pollMs, the page's own cadence) and re-fetches only past it — a <details>
keeps its children, so a fresh re-open costs no request and loses neither the pager's page nor
the reader's scroll. painted/fetchedAt are stamped on the success path alone, which is the
invariant-4 shape here: a failed fetch leaves the rows on screen, leaves the stamp where it was,
and the next open re-fetches. Its styling is details.charts-box's shape with one structural
difference: that toggle sits inside a card body, while this <summary> replaces the card's
header — so it carries the header's type, padding, sunken ground and bottom rule (a closed card
reads as its heading, an open one puts the feed under the same rule every other card draws) and
no margin-bottom when open, because the .body below carries that spacing. That is also why
details.events-box > .body restates the .8rem: .card > .body is a child selector, so a
body moved inside a disclosure stops being padded until something says so — while ul.events
and .events-pager are class-based and needed no new rule to keep the 320px scroll and the
1–50 of N strip working inside it.
The /system host card is one row of six hero slots — CPU, MEM, TEMP, DISK, GPU, NET (the
.heroes grid; tests/test_api.py pins the slots and their order on the served script).
Colour comes from the payload's severity dicts and nowhere else, and a missing number or
verdict prints n/a uncoloured — gateway's CPU temp, a driver with no temperature
input, a rate with no second sample yet. What a machine does not have is a different
fact and prints nothing at all: the GPU slot ships hidden and renderGpuSlot() un-hides
it only for a host whose payload carries a card, so gateway's card is five heroes with
no GPU slot in the row (TODO.md:12 — "Boxes without a GPU should not report N/A but
instead should omit these values"), and the sheet's [hidden] { display: none !important }
is what takes the wrapper out of the grid so the row reflows instead of leaving a wide gap.
The two rules are one sentence each in the page and one test each; collapsing them into "any
missing thing is n/a" is what made a router look broken over the GPU it never had
(invariant 3). NET is one slot with two lines (.hero-lines, ↓ in / ↑ out) and is
deliberately colourless: throughput has no good/bad, so network carries no severity.
The GPU slot is one busy hero per card plus four labelled values under it — Model
(mono, gpus[].model), VRAM (used / total), Pool (mem_used_pct, the honest APU
view a Strix Halo needs because its model lives in GTT) and Temp (gpus[].temp_c — the
agent's max over that card's hwmon labels, the same value stored as gpu.temp_c@N, so the
cell and the history cannot disagree; the per-label breakdown stays in "More detail"). They
replaced one ·-joined 11px .sub — TODO.md:12: "Radeon 8060S Graphics · VRAM 180.2 MiB / 512.0 MiB · pool 79% · 69 °C is too much info for such small text and should be its
own values" — and they are .metric cells in a .gpu-values grid, so a 20-character model
string wraps instead of truncating. The four cells come from one GPU_VALUE_FIELDS table,
which tests/test_api.py resolves against every capture and against the served
/api/system/snapshot (SLOT_FIELDS' device): a rename on either side fails a test instead
of printing n/a beside a healthy fleet. A field the agent did not publish prints n/a in
its own cell (the card is real, so it is a number the card is supposed to have) and never
0 °C. Nothing in this slot is coloured and nothing draws a bar — no severity, no
ok|warn|fail token in the markup or the grid's rules (phase 06: a 95 %-busy card on an LLM
server is the job, and a bar under a verdictless number would be a verdict by shape).
Everything not glance-worthy — load averages, clock, disk read/write/busy, uptime, GPU power
and the per-label temp breakdown, filesystems, processes — folds into a collapsed "More
detail" <details>: a simpler surface, not a less complete page (the table keeps its Temp
column; this was a promotion, not a de-duplication).
A Δ<window> label is a measurement, never a config value: sources/host.py sends the window
the estimator actually used as the payload's window_seconds, and /system's footnote prints
those (a range when the hosts disagree) instead of the poll interval, which only coincides with
them at the defaults (since phase 23 the /llm rates are measured over one poll interval, so
their tag reads Δ2.0s while /system's stays Δ10.0s — each page naming the window its own
estimator used). /llm's hero .sub prints the rate cell's own window through the same
provenance() tag, and the hero's title carries the exact source string — "78 tok/s" must
always say whether it was scraped, measured Δcounter/Δt, or estimated (llm_monitor's
setThroughput idiom; dropping the tag to look cleaner is a regression, not a simplification).
The /llm page reads per engine (phase 04, decided in
.agents/phases/complete/04_llm_chart_readability/01_chart_set_decision.md, laid out by phase
07): a row of two Dash.hero() throughput numbers (decode, prompt — deliberately uncoloured,
because 0 tok/s on an idle server is healthy and severity.py has no throughput row) and the
latency rows, each a mean with a mono p95 … foot (no hover; p95 needs no stored series) and a
sparkline() fixed at 160px so rows line up. Rows are engine-scoped by the only flag on both
components: gufo never grows a TTFT mystery, vLLM never a spec-accept hole.
The stats strip (#strip, phase 07 task 03) is the summary; the panels are the detail, and no
row is printed in both — the UPS hero row's "one value drawn twice" rule, widened to two
components. One .card.stat per configured engine between the fleet block and the panels: the
reachability dot, the engine name and the age stamp in the head (same distinction every card
draws — the dot speaks reachability, the numbers never do), then the KV-cache bar — the one
cell on the page with a verdict, so the fill and a breach's text take their colour from the
payload's severity through .bar.ok/.warn/.fail and the ratio→percent conversion the panel's
cell used to make, with ok staying quiet ink and a missing gauge an empty bar plus n/a — then
prompt tok/s / decode tok/s with their Δ<window> tags, total tokens (lifetime, so its
source is the tooltip), the sessions this engine reports, prefix cache hit, spec accept
(gufo) and queue time with its p95 … foot (vLLM). STRIP_ROWS is the whole table and its
key is the cellValue() key, so every number on it is a cell of /api/llm/snapshot: no
field was invented in sources/llm.py for this card, and there is deliberately no peak-ctx
row (phase assumption). The session row is engine-shaped — vLLM publishes running/waiting
counts, gufo's is slots busy, counted from the sessions.list rows /slots answered
(is_processing, the shape pinned by tests/slots_contract.py); when /slots did not answer
there are no rows to count, so it falls back to the published sessions.active carrying the
API's own sessions.detail sentence rather than reporting 0 busy slots from no data (rule 2).
preemptions / device_lost are alarm chips that exist only while non-zero (.chip.fail,
container emptied and hidden at zero — the rule is the presence of an incident, never a graded
threshold) and they live on this card, not in a permanent cell; the lifetime token counter left
the panel's footnote line for the strip's row. One /api/llm/history request per refresh
carries every chart and spark series (HISTORY_SERIES = the four chart series +
SPARK_CELLS; the endpoint answers unknown names as empty series on purpose), and the two series
phase 04 added (llm.tpot_seconds vLLM-only, llm.spec_accept_rate gufo-only) stay stored
additively in sources/llm.py HISTORY_METRICS. Above the strip sits the fleet block
(#fleet, phase 07: "I'm missing the aggregate throughput at the top") — ALL ENGINES, a
reachability dot, an
N of M engines tag, the note sums only engines answering now, then running / waiting /
prompt tok/s / decode tok/s / total tokens. FLEET_FIELDS in page-llm.js is a list of
fleet_totals() keys and nothing else: the page sums no number of its own, a dead engine is
dropped from every total presented as current (its own stale-labelled panel still shows its
last good values), and the tag counts the answering engines, names the missing one in its
tooltip and takes the shared .stale dim while the sums are partial. It hides below two
engines — a cross-engine sum of one engine duplicates that engine's own panel — and it
carries no severity colour anywhere: fleet_totals has no severity to render (the
summed-KV bar is gone for that reason) and severity.py rates no throughput or queue depth,
so the numbers stay ink and only the dot speaks reachability. fleet.seams — the API's own
note on how the two engines count prompt tokens differently — is the prompt sum's tooltip
rather than another line of ink. The block, the strip and the panels keep the page's shared
.85rem gap (phase 13, TODO.md:4: fleet and strip rendered flush into one wall):
#strip { margin-top: .85rem } is unconditional because below two engines #fleet is [hidden]
and the strip is the first visible section, and #banners:empty ~ #fleet { margin-top: .85rem }
gives the block its top gap only when the banner slot renders nothing, so a banner's own
margin-bottom:.85rem never stacks a second gap under it. tests/test_api.py pins both rules on
the served stylesheet, pins that exactly one rule styles each of the two ids (a second,
unguarded one is the double gap), and pins that #banners/#fleet/#strip are still direct
siblings of <main> — the ~ combinator resolves between siblings, so re-flowing that markup has
to rewrite the selectors instead of losing the gap. The
four trend charts stay but at height 110 below the panels, each with an ariaLabel. Panels
sit at their own height (#engines carries .items-start): a two-hero gufo card next to a
latency-row vLLM one otherwise grows a blank bottom, and a blank panel reads as "no data". An
engine that leaves the payload leaves both components (pruneEngines), never a stale card in
either grid.
The slot grid (llm_monitor's, phase 07 task 04) belongs to the gufo panel, under its
sessions (GET /slots) label. One card per row of sessions.list, keyed by the row's own
id in state.slots: built once by slotCard(), patched in place afterwards, and reordered
by appendChild of the existing node so DOM order stays slot order — rebuilding a busy slot's
card every 2 s poll re-announces the whole card to assistive tech for a difference of a few
tokens. SLOT_FIELDS is the whole table and each cell names exactly one /slots key
(path: 'next_token' reads next_token[0], where the capture puts n_remain); slotValue()
is the only place a row is indexed, so tests/test_api.py can resolve every printed field
against tests/fixtures/slots_gufo.json and a rename fails there rather than printing dashes
or a plausible wrong number. No per-slot speed cell: the capture publishes no per-slot rate key
and a page-side Δn_decoded/Δt would be a measurement with no estimator, no Δ<window> label and
no source string (rule 2) — the engine's rate lives on its strip card and panel. llama.cpp's
-1 sentinels print a word or nothing: id_task: -1 is idle, n_remain: -1 is n/a,
never a negative countdown and never the 0 that reads as one token from done. The busy marker
is a border, a glyph (○/●) and the word processing in the brand --accent, never an
ok|warn|fail token — a slot generating tokens is the job (phase 06's GPU reasoning), so
severity.py rates no slot and the stylesheet section contains no verdict token. The empty
state is a sentence, never a card of nothing, and it distinguishes the two cases: no /slots
answer prints the API's own sessions.detail, an answer with no rows prints engine reports 0
slots; sessions.source is the gate. The note under the grid stays because a queued
(deferred) request is not in /slots — the waiting row counts those. vLLM's panel gets one
line (exposes no per-slot JSON) with sessions.detail as its tooltip and no grid: a
component of permanent n/as for a server that cannot answer is the dead-cell anti-pattern.
The gate is the engine name (SLOT_ENGINES), not row count, so an engine that has not answered
yet still shows its label and its honest empty sentence.
Charts belong to the host they chart. /system has two sections — the host panels and the
transition events — and nothing else: the fleet-wide chart stack, the nine-figure fleet-totals
panel and the host/metric detail picker are deleted, along with the #chart-* / #detail-* ids and
the select / label.field styling only those pickers used. Each panel stacks its own
.host-charts — utilisation (CPU, memory, worst disk), network (in/out), then GPU busy — where
the GPU chart slot is created and destroyed with host.gpus (its own thing from the hero
slot above it, which is hidden instead), so gateway shows neither a phantom card nor a hole. That stack is collapsed behind a per-host Charts <details> (phase 06: "only
visible if expanded… there will be lots of hosts"): the default /system view is the hero row,
the summary carries the window label the stack will draw (the chips themselves stay in the page
head, one control per page), and drawHostCharts() is the single place that decides whether to
paint — so nothing is built or drawn while the stack is shut (LineChart inside a
display:none container measures 0 px, and rebuilding three charts per host every poll for
plots nobody sees is the waste this removes). The history request is deliberately unchanged:
one per host per refresh, naming every series the panel could draw, and its response is cached
per host so the first expand paints from data in hand rather than waiting a poll; a cached
response from another window is re-fetched instead of being drawn under the wrong label.
Expanding one panel must not stretch its collapsed siblings into blank cards, so #hosts
carries the existing .grid.items-start. One /api/system/history request per host per refresh
(hosts=<that host>&metrics=<that panel's series>&range=…, pinned by
test_every_system_chart_is_drawn_inside_its_own_host_panel and the collapse by
test_system_history_charts_are_collapsed_behind_a_toggle): unknown metrics come back as
empty series on purpose, so a panel asks for every series it could draw and asks once. A legend
here names metrics (or one GPU's model), never hosts — the panel heading already says who this
is, and a legend that grew a second host is the bug this replaced. A host that stops reporting
loses its panel, its Charts toggle and its charts (pruneHosts deletes the card plus the
payload/chart/history maps), never a chart of nothing or an orphan control in the grid. Per-core
visuals are gone from /system and renderCores is deleted from charts.js with its last
caller.
drawHostChart() shades a chart only when one series has points — two or three filled areas
stack into one block and bury the quieter line, which is how a idle CPU would hide behind a busy
memory plot; the cpu.usage_pct@coreN series is still stored and still queryable from
/api/system/history — removing the plot is not removing the data.
Known gaps / next work
-
UPS commands work, and the account can now cut power — the app's gates are the control. Three defects were cleared on 2026-10-17, each verified against real hardware. (a)
NutClient.login()sentUSER/PASS, which the deployed upsd 2.8.5 does not implement — each answeredERR UNKNOWN-COMMAND, so_read_line()raised andPOST /api/ups/commandcould only 502 beforeinstcmd(), credentials or not. It now sendsUSERNAME/PASSWORD, the pair the server's ownHELPadvertises (HELP VER PROTVER GET LIST SET INSTCMD LOGIN LOGOUT USERNAME PASSWORD STARTTLS), pinned bytest_login_uses_the_verbs_upsd_2_8_5_answers_to— the fake upsd has noUSER/PASSbranch, so a regression answersERR UNKNOWN-COMMANDexactly like the real thing. (b) 12 of the 23 commands the two real UPSes advertise had noCOMMANDSentry, andavailable_commands()drops anything the catalog misses — the page silently offered less than the hardware does. Every advertised command is catalogued now, each with a risk tier:test_every_command_the_hardware_advertises_is_cataloguedfails if a capture gains a command the catalog does not know, andtest_every_command_that_can_cut_power_is_in_the_dangerous_tierkeepsload.off,driver.killpowerand theshutdown.*sequence in the tier that typesDANGERand is refused on battery.shutdown.stopis the exception the name hides — it cancels a shutdown, so it issafe, and the test says so in words. (c)[dashboards]in/etc/ups/upsd.usersisinstcmds = allat the operator's request, so this app may send every advertised command on both UPSes, power-cutting ones included..env.dev(mode0600, gitignored) carries the credentials. What remains to know:- Proven live, and reversibly.
beeper.enablereturnedOKandups.beeper.statusreadenabledon the APC afterwards;driver.killpowerwithout the wordDANGERstill answers 422 and never reaches the wire. A battery test has deliberately not been run — it puts real load on a battery things are plugged into, which stays the operator's call. enabled: truecannot mean "this account may send it".available_commands()filters onLIST CMD, which upsd serves unfiltered, and NUT has no call for a user's privileges — so the wire cannot tell the page what the account is granted. Withinstcmds = allthe two agree by configuration rather than by check, which is precisely why the risk tiers and the typedDANGERare load-bearing and not decoration.- Pre-existing upsd log noise: every upsd start logs
Unknown upsmon type =(20 occurrences over 12 starts, oldest before this work). Harmless —upsmonlogs into both UPSes right after it — but unexplained. - One connection every few minutes answers nothing for >4 s. Live upsd here stalls a
single poll past
NUT_TIMEOUT_SECONDSroughly every 2–4 minutes and then answers normally again;nutreadsokthroughout, because the poller's job is one amber line plus the recovery, not a frozen page (invariant 4). What phase 12's verification pass fixed on our side was the cascade that used to follow it — the event table filling withexpected 'BEGIN LIST VAR', got 'RW apc battery.charge.low "10"', which blamed the wire protocol for a stall two commands earlier (see theNutClientbullet above). The stall itself is an upsd/driver-side question with no repo bug in it, and a battery test is not how to chase it. Editingupsd.usersat all: never grantactions = -, and never write it blind — the runningupsmonauthenticates as its own account from that same file for both UPSes, so dropping it means no orderly power-down. Copy the untouched sections from the live bytes andcmpthem before writing;teetruncates in place, so owner and mode survive.
- Proven live, and reversibly.
-
GPU coverage is AMD only.
host_agent.gpus()reads amdgpu DRM sysfs (gpu_busy_percent,mem_info_vram_*, hwmon temps, clocks). That covers the current fleet (R9700 + Strix Halo iGPU; gateway has none). An NVIDIA host would report no GPU at all — needs anvidia-smi/NVML or DCGM path, and a fixture from a real card before the field map is written. -
UPS history depth. Events and the numeric series are stored; long-window battery health (capacity fade over months) has the rollups but no dedicated view.
-
The dev DB's
database is lockedline is fixed (phase 14); a cold-cache tail is left. The dev store (~640 MB on 2026-10-19, its WAL grown past 140 MB at its worst) loggedstore-error: OperationalError: database is lockeda few times an hour acrosssystem/llm/upsthrough 2026-10-18; the poller handles it exactly as invariant 4 requires — the source goes red with the real reason, ages, and writesrecordingwhen the next poll lands — so nothing freezes, but those polls' samples are lost. The one cause this repo can prove is two servers on one file, which is whyscripts/dev-server.sh stopwaits for the old pid to die (its comment records the incident). A controlled 25 s external read transaction during the phase-09 pass produced none, and phase 12's re-check on 2026-10-19 found the 190 storedstore-errorrows all older than 2026-10-18 06:33 with none in the previous 24 h of the running dev store — the absence of a sample, not the absence of the pressure, since the file only keeps growing. Retention/wal_checkpointtuning for a long-running store was its own phase rather than a phase-09 follow-up — that phase is14_store_performance, and the two paragraphs below are what it measured and changed. Phase 14 has since split this in two, and the read half is closed. The in-process half was never a SQLite lock at all:SampleStore._lockserialises everything, so a 1.25 sstats()from a browser tab's/api/healthpoll was a poller waiting up to 1.45 s to insert 30 rows (measured,data/logs/NOTES-store-perf.md). Task 02 took the two whole-table reads off the per-request path, and/api/healthnow answers in ~1.2 ms instead of ~1.27 s, so the store mutex is no longer held by a reader for seconds at a time. The write half is closed too (task 03):maintain()rolls up only what became due sincelast_maintain, its prune is oneDELETEper resolution instead of oneORthe planner could not use, andPRAGMA optimizeruns there (with SQLite's automatic ANALYZE switched off, so no poller's commit pays for it). On the seeded 6.4 M-row store that is 5,368 ms → 2.7 ms steady / 48 ms for a ten-minute catch-up, and the two-connection race at the app's cadence is 100 of 100 inserts, zerodatabase is locked, max wait 21 ms where the pre-fix control still waits 4.76 s (task 04's re-run of the same budget: 6.8 ms steady / 46 ms catch-up / 8.5 ms worst wait, so the row is a range, not a digit). The WAL follows the same shape: a steady pass leaves ~0 MB and a catch-up tick 4 MB instead of 38 MB that never comes back down, andjournal_size_limit=64 MBcaps what a long-lived reader can make it grow to (data/logs/NOTES-store-perf.mdhas the tables). The live proof is in (task 04, 25 minutes / 74 passes / 296 signed-in requests against the running dev server on the operator's own 636 MB store): 0 newstore-errorrows (still 190, newest 2026-10-18 06:33:30, i.e. 39 h before the watch), WAL 6.5 → 7.77 MB against the 67.1 MB cap and a main file that did not move, 0 non-200 responses, and page paths at 1.8-3.3 ms median (/api/health,/api/summary,/api/metrics, a 3-host × 9-metric 90-day/api/system/history). The watch also caught a defect in task 02's fix — the series cache was invalidated by every sample write, so on a store the hub writes to every few seconds it never served and/api/metricspaid the 572 ms scan on nearly every call (measured: 535 ms per call pre-fix, 1.8 ms median after) — and that is what the store bullet above now describes. What is left is the cost of the two cold answers themselves, measured on that store: 599 ms forseries_names('host'), 729 ms forstats()' counts, now once per cache window (~1.3 s of store mutex per minute) instead of once per call — which is why the watch's medians are 1.8-3.3 ms and its p95s are 620/729 ms: the p95 is the window boundary, and 37 of 74 passes had one request over 250 ms, every one of them a cold re-derivation. No cache policy reaches that; the fix is the bounded(source, metric)structure or a counts table named in task 03's note. Two cheaper knobs exist and were deliberately not pulled, because both change what/api/healthanswers and move rather than remove the mutex hold: raisingSTATS_CACHE_TTL_SECONDS, and refreshing both caches from the maintenance tick so the miss lands on a poller instead of a page load. That is a phase, not a cleanup. Two servers on one file can still contend, andbusy_timeout=5000is unchanged on purpose so a second one stays visible as a slower write; what they contend with is now milliseconds instead of 5.4 s. One consequence worth naming, because two other gaps pointed here for it: the ~10 s process-wide stall (gap 9'sΔ-window dilution,poller's catch-up gaps) is gone with the mutex holds — coincident multi-second gaps in the 2 sllmand 5 supsraw streams went from 393 and 198 in the two halves of 2026-10-19 to 0 across the 110 minutes the phase-14 store has run (re-measured in this phase's verification pass from thesampletable; the signature separates a process stall from one slow engine). -
The login throttle keys on the socket peer address (phase 11), and the one
LoginThrottleinstance now holds two keys per address: the bare one forPOST /api/auth/loginandpassword:<address>forPOST /api/auth/password. Behind nginx every login shares one bucket, so anyone who can reach the login form can lock the operator out of the dashboard — not the UPS, not the data, not a host — for at mostLoginThrottle.max_seconds(1 h by default) at a time, and a process restart clears the counters. The two keys are separate on purpose, so neither form can be hammered at the other's rate and a stolen cookie plus the change form is not an unthrottled oracle for the old password; the cost of that choice is that one address can be blocked twice over. Accepted for a single-admin box rather than becoming anX-Forwarded-Forknob: that alternative is spoofable by anyone the moment somebody forgets to configure which proxy to trust, which would hand the attacker the throttle key instead of leaving it on the network layer. PBKDF2-HMAC-SHA256 atPBKDF2_ITERATIONS(600 000) stays the real cost of every guess; the throttle only makes hammering the form slower than useless. -
One admin, one factor — no second factor and no per-user accounts, by design (phase 11). The operator asked for a single admin, and that is what the model is: one verifier row, one username in every signed session, one name in every
password-changedevent, no read-only role and no per-viewer view. The consequence is worth stating out loud, because it is the whole security budget of a public domain: the PBKDF2 cost plus the backoff is the only thing between a stranger and the fleet's hostnames and the UPS command buttons, so there is no defence in depth behind it — no TOTP, no WebAuthn, no per-IP allow-list, no "this login is from somewhere new" notice. Adding a second human means more than a second row: the gate has no notion of scope, so a colleague would be able to cut power unless somebody designs a read-only session first. That is a phase, not an edit. -
The throttle is per-process, so a second uvicorn worker multiplies it (phase 11).
LoginThrottleis a dict in one process's memory:uvicorn --workers 4hands anyone who reaches the form four independent buckets and four times the attempts before a429, and a restart clears all of them (the gap-5 note above). Nothing else in the auth design has that problem — the session is an HMAC-signed cookie and the CSRF token derives from it, so both are worker-safe by construction with no store to share and nothing to prune. The reason this stays unfixed is that the app is single-process by nature anyway: every worker would run its own poller loops against the same SQLite file (AGENTS.md invariant 4, and thedatabase is lockedincident in gap 4 is what two processes on one store already looks like). If this is ever revisited, the fix is a shared counter (themetatable would do, at login rates), not a bigger number in one process. -
A logout ends this browser's cookie, not a copy of it (phase 11). The session is an HMAC-signed cookie with no server-side record — that is the design invariant 12 and gap 7 rest on — so
POST /api/auth/logoutcan only tell this browser to drop the cookie (verified against a running server: a replayed token from a logged-out jar still authenticates until it expires). The same is true of anAuthorization: Bearertoken, which was never in a cookie to begin with. The only revocation this app has is rotatingauth.session_secret, whichPOST /api/auth/passworddoes on purpose, and the resetDELETE FROM meta …does as the lockout escape hatch. Accepted for a single-admin box on a domain nobody else signs in to — the operator's own machine is the threat model here, not a shared kiosk — and the reason a lost laptop is a password change and not a sign-out. A real revocation list would be a row per session with a prune rule, which is the thing this design traded away. -
The
/llmdecode tok/sspike is fixed, and verified live (phase 13, 2026-10-19; the diagnosis is in the git-ignoreddata/logs/NOTES-decode-spike.md, the live proof indata/logs/NOTES-decode-stable.md). Task 04's sustained watch passed the ceiling test: 11 minutes / 285 signed-in/api/llm/snapshotpolls against the live engines, worst value 117.57 tok/s (gufo) and 98.00 (vLLM) — both in the payload and in the stored raw series, 2.6× under the 300 tok/s pass line, with zero values printed without theirΔ<window>label. The stall is still there (38 catch-up gaps < 1.5 s in 11 minutes, a 10.6 s gap between two samples) and the estimator now absorbs it: three refusals each labelled with the sub-0.5 s window it refused (Δ0.387s,Δ0.399s,Δ0.363s), two ≥ 8× steps held for one poll and published only when the next interval confirmed them, and — the point of the whole fix — 1 m rollups topping out at 51.9 / 34.1 tok/s where task 02 found a 222.7 tok/s minute, so the trend chart's y-scale stops being set by arithmetic. Fixtures were not re-captured: task 02 compared the live exposure withtests/fixtures/series by series (gufo 15/15, vLLM 128/128, identical sets), task 13 changed no parsing, and the page still reads15 series · 0 unparsed/128 series · 0 unparsed. Screenshots:data/logs/llm-decode-stable.png(+-top, the fleet-block gap and bothΔtags) anddata/logs/llm-decode-burst.png(a real two-slot burst: 62.1 tok/sΔ8.5s, fleet sum 102.4). The confirmed mechanism was neither a counter bug nor a label-set sum:poller.Job._loopsleepsmax(0.2, interval - elapsed), so any fetch slower thanLLM_INTERVAL_SECONDSis followed by a poll 0.2 s later, and the estimator happily diffed a whole stall's tokens over that gap — the 2026-10-18 09:16 gufo row read 3284.59 tok/s for an engine doing 53, with an inflated tail because the stale sample stayedinside[0]. Task 03 made that impossible by construction (see therates.pybullet above): the window floor turns the catch-up poll into onen/aand drops the stale base so the tail re-bases; the fallback's2 × spanceiling is enforced (the honest 15.25 s diluted row still publishes); jumps ≥ 8× hold one poll and publish only on confirmation; andvalues_by_series()+rate_key()diff every counter per label set, so the latent joining-series defect (candidate (a)) contributes 0 tokens until it has its own baseline. The four task-02xfail(strict=True)tests are green with their markers removed and every assertion kept. Spike-free across two days of raw history the fleet's real maximum is 119.9 tok/s (gufo / deskwork) and 171.7 (vLLM / driveripper) — anything above ~300 is arithmetic, not work, which was task 04's pass line and what the watch above stayed under. TheΔ<window>label stayed honest throughout — both before and after the fix it names the window actually used; do not "fix" anything here by hiding or rewriting it. The ~10–15 s process-wide stall that triggers a refused poll was gap 4's and14_store_performance's problem, and phase 14 closed it (measured on the dev store from the sample table: coincident multi-second gaps in the 2 sllmand 5 supsraw streams — the signature of a process-wide stall, not a slow engine — were 393 in 00:00–12:00 and 198 in 12:00–21:36 on 2026-10-19, and 0 across the 110 minutes the phase-14 store has been running, worstllmgap 7.1 s and that one the recorded gufo timeout). The mechanism is the one phase 14 removed: a whole-tablestats()count held the store mutex for ~1.25 s on every browser tab's/api/healthpoll, andmaintain()held the write lock for 5.4 s. The estimator no longer turns a slow fetch into 0.2 s arithmetic, and onen/aper stall is the designed degradation (the residual the verification pass measured is the paragraph below). The verification pass found one residual path and closed it (same day, live rows in the store the fix was running): a stalled fetch stamps its body with the time the poll finished, so the sample it stores is the stall older than its own timestamp. The floor cannot see that (the window is honest, the value is old), the sample then anchors every window for the rest of the span, and the jump gate published the second one because it was measured from the same anchor — gufo 12:42:10 read 232.28 tok/s with exactly one request running at ~50, followed by the tell-tale decaying tail 146.4 / 114.1 / 94.8 and the true 47.5 (a 13:40:58 row repeats it at 200.6).rates.pynow requires an independent anchor for a confirmation (_pendingstores the base sample's timestamp and the hold continues while the window is still diffed from it), so a skewed sample is held until it ages out and the honest rate publishes — the reconstruction of both rows is pinned bytest_a_held_jump_is_not_confirmed_by_a_window_on_the_same_anchor(estimator) andtest_gufo_a_stale_scrape_body_never_becomes_a_rate_above_the_engine(throughparse_gufo_metrics), each of which fails on the pre-fix gate and prints the live shape. That closes the gate's path, not the skew itself, and the verification pass measured the difference. A slow reply's counter is true somewhere between the request and the reply and nothing in the payload says where, so when the resulting jump is under 8× the last published rate the gate never engages: the post-fix watch (10 min / 540 reads,data/logs/ llm-decode-verify-pass.tsv) caught gufo at 203.05 tok/s labelledΔ2.595swhere two busy slots were really doing ~110, then 127.4 / 100.2 / 89.6 as the window grew back to the span and 35.6 once the freed slot settled — every value labelled, 0 of 540 above the 300 pass line, and no stored raw row over it since the restart either. A collapsed window is what makes a burst look like a rate, so the fix for this family is the ~10 s stall itself (gap 4 /14_store_performance— which has since removed it, see gap 4 and the paragraph above, so what is left of this family is the sub-8× skew itself), not a fresh threshold invented in the estimator: task 03 pinnedMIN_WINDOW_SECONDSas an absolute floor and pinned that an in-bounds diluted row publishes (test_a_fallback_inside_the_documented_ceiling_still_publishes), so a span-relative floor would be a re-decision of that phase, not a bug fix. Do not "fix" the residual by hiding the short window or its label. Phase 23 changed the size of the window, not the gates (TODO.md:8: "a strange delay and a 'ramp up/ramp down' effect"). What was left after phases 13/14 was the span itself: one 10 s-window estimator served both cadences, so on the 2 sllmpoll a slot's first seconds were spread over five polls — the capture replay intests/test_llm_source.py::test_gufo_the_window_the_llm_jobs_are_spanned_by_is_one_that_fillsprints 12.5 → 20 → 30 tok/s for an engine steady at 50, and the same five polls backwards when the slot ends.build_hubnow builds one estimator per measuring cadence (thellmjobs spanned byLLM_INTERVAL_SECONDS, the host jobs byRATE_WINDOW_SECONDS), which reads 50 on the first poll after the change and 0 on the first after it ends. Every phase-13 protection still applies to the smaller window —Δ2.0sis 4× aboveMIN_WINDOW_SECONDS, the ceiling is relative to the span, and the 8× jump gate is the estimator's own — so this is the smoothing dial turned down, not a second definition of a rate. The live half has landed (task 03, 2026-10-21, read-only: nothing was written and no workload was started — the busy periods were the operator's own requests). A 13-minute signed-in pass of/api/llm/snapshotat the app's own 2 s cadence (scripts/watch-llm-window.py; seriesdata/logs/llm-window-watch.tsv, verdictdata/logs/llm-window-watch.{json,log}) recorded zero values printed without theirΔ<window>label across 780 rows, with every window in 0.61-3.38 s and never 10 s. It found 17 busy-edges on gufo and judged the 5 starts + 5 ends that held long enough (an edge followed by the next request within a poll is not a slot ending, and prefill polls genuinely decode ~0 — both are reported, neither is judged): every one was inside the 8× pass line at poll 2 on the ungated Δ. Re-diffing the same samples over the span this phase replaced is the comparison the operator's complaint was about: median |ln(value/steady)| at poll 2 was 0.037 with this window against 0.924 with the 10 s one, whose median edge needed poll 3 to settle (the five starts read 0.98 / 1.04 / 0.95 / 1.00 / 1.27 of steady at poll 2 on the ungated 2 s Δ where those same five read 0.25 / 0.34 / 0.96 / 0.40 / 0.40 on the 10 s one; ends took polls 4-5 to fall away against poll 2 now). One start had its first correct value held for a poll by the phase-13 jump gate and published on the next — designed behaviour, and the reason the pass line is read on the ungated re-diff as well as on the published column. The stored 1 m rollups for the window topped out at 42.3 tok/s (raw max 201.6 — that row's ownΔ0.610slabel says so, gap 9's sub-8× skew, far under the 300 pass line). A second pass over the same engines an hour later (data/logs/llm-window-watch-pass2.log) reproduced it: 3 starts + 2 ends, 0 unsettled, 0 unlabelled, 1 m rollups max 24.7 tok/s. The browser half (data/logs/23-live-window.cjs, transcriptdata/logs/23-live-window.out, screenshotsdata/logs/23-live-llm-{opening,burst-settled,midburst,post-free,gufo-idle,vllm-idle,final}.png) checked the same claims as rendered: 866 decode figures, 0 without a tag in either component that prints the number (the panel hero's.suband the strip card's.prov), every tag a Δ inside the estimator's own bounds —MIN_WINDOW_SECONDSto2 × span, the modal oneΔ2.0son both the page and the API (833 of 866) and never the host'sΔ10s. Its own edge checks (isolated only, for the same reason) read inside the 8× line at poll 2 on two starts and exactly0.0 tok/sat poll 2 on two ends — the frame named post-free is only taken once the engine is provably idle, because the busy signal flickers between back-to-back requests. -
driveripper's 16 drives readn/a: kernel 7.2's unprivileged-passthrough gate (2026-10-19, resolved the same day; the installer template that shipped the broken device filter was cleared in phase 17). The agent is installed, thediskgroup grant is in place, the block nodes open, and the page renders all 16 real drive rows — but kernel 7.2.11 blocks the agent user'ssmartctlat the command level (not the open; the probe matrix is indata/logs/NOTES-driveripper-agent.md): the 8 SATA drives' SMART is ATA pass-through behind a SAT layer, and the kernel's unprivilegedSG_IOallow-list (scsi_cmd_allowed(), read-safe opcodes only) does not include it —CAP_SYS_RAWIOor root unblocks it, with or without-d sat/-d ata; the 8 Samsung/WD NVMes' SMART-log read needs the drive's ACEL to advertise the page (the 980 PRO's5B2QGXA7, 2020-era, does not:NVME_IOCTL_ADMIN_CMD: Permission denied) orCAP_SYS_ADMIN;deskworkreads live because its firmware does. Options, in order of narrowness: (a) accept the designed degradation (what/disksrendered then — the identity is real, the values aren/awith a why-line); (b)AmbientCapabilities=CAP_SYS_RAWIO(+CapabilityBoundingSet) on the unit — unblocks exactly the 8 HDDs, is the capability the kernel names for the gate, but a compromised agent could then write raw block I/O — test it in asystemd-run --scopebefore committing; (c)User=rootwith the empty capability set — unblocks all 16 but hands a token-guarded process root's file-read DAC (not recommended); (d) a Samsung firmware update — the only clean fix for the NVMe side. Resolved the same day with a tighter variant of (c):User=root+CapabilityBoundingSet=CAP_SYS_RAWIO CAP_SYS_ADMIN— all 16 drives read live (root DAC is the price; the bounding set keeps the grant named rather than full). Two empirical notes: a root process under the unit's empty bounding set is capability-less (CapEff: 0), because the kernel gates check capabilities, not uid — that is why the firstUser=rootattempt still readn/a; andCapEffcame back0000000000220000(SYS_ADMIN + systemd's auto CHROOT; RAWIO in the bounding set but not effective) — the 8 SATA drives' reads were unblocked by SYS_ADMIN alone, so the effective grant is {SYS_ADMIN}; the RAWIO in the line stays as the named intent for theSG_IOgate. The agent also gained the pass-budget scale-up — 20 s floor, 5 s × disk count, an explicit override still winning — because a 16-disk pass takes more than 20 s when the drives answer, and truncating it would null the tail drives. Phase 16 pinned that default intests/test_agent.py(test_the_pass_budget_scales_with_the_disk_count,test_the_pass_budget_floor_is_twenty_seconds,test_the_pass_budget_error_line_names_the_scaled_figure— which demands the line read80sfor 16 disks, not the floor; the override test stays). Re-read live on 2026-10-20: the installed agent answers 16/16 drives with temperatures and nopass budget … exhaustedline. Thedeploy/install-agent.shtemplate no longer ships the device-filter lines that misbehaved on this host — phase 17 task 01 removed them (thenvme*glob matches nothing on this kernel because/proc/deviceslists NVMe asblkext, and a strict device policy denies everything a list fails to match), and the unit pins intests/test_deploy_scripts.pynow assert their absence. Task 02 added the two flags that reproduce this host's remaining lines —--as-root→User=root,--caps CAP_SYS_RAWIO,CAP_SYS_ADMIN→CapabilityBoundingSet=CAP_SYS_RAWIO CAP_SYS_ADMIN, both off by default,--capsrefused without--as-root,--as-rootalone accepted with the CapEff-0 note — pinned bytests/test_deploy_scripts.py(exactUser=/CapabilityBoundingSet=lines in a fake-root sandbox, the refusal before any write, the--helprecipe). Task 03 removed the unconditional rewrite: the unit is rendered to a temp file, and a re-run whose render differs from the installed unit prints the diff on stdout and exits 1 unless--forceis passed, so the flag line above now needs--forceto land and a routine re-run can no longer clobber this host's unit (pinned by the refuse/force/silent sequence intests/test_deploy_scripts.py, including the--forceleg of this host's own recipe — the same--bind 10.1.0.126 --port 9120 --as-root --caps CAP_SYS_RAWIO,CAP_SYS_ADMIN --forceline the fleet record above prints). Task 04 verified the reproduction read-only rather than by running it: the unit installed ondriveripper(systemctl cat, 2026-10-20) differs from what this template renders with that exact flag line in comment lines only — every directive matches,User=root,CapabilityBoundingSet=CAP_SYS_RAWIO CAP_SYS_ADMIN, no device filter,PrivateDevices=false, the sameExecStarton the token file — and the host still answersactivewithCapEff: 0000000000220000and 16/16 drives with temperatures in/snapshot. So the operator's--forcerun lands the new rationale prose and changes nothing systemd acts on (the three captures and the exact probe list are indata/logs/NOTES-17-installer-repro.md; no live change was made while pinning this phase). Two facts that came out of reading the live host, both load-bearing for the next install: the sudo-granted/home/ops/agent-installpath held the Oct-18 file (no--as-root, no--caps, no--force, no--cert-bundle, still nineDeviceAllowlines) and it refused the recipe withunknown argument: --as-rootand exit 2 beforeneed_root, so that path could not apply this recipe at all — while a re-run of it without the flags was still the silent clobber the guard exists for. Resolved on 2026-10-21: phase 24 task 03 staged the repo's script and agent 1.1.0 into/home/ops/agent-install/on all three hosts (the directory isops-owned, so no root needed) before running the granted path, and left the snapshot beside it as a file sudo will not execute. The rule that came out of it is in the fleet record above: re-stage first, because a path grant is only as current as the file. (The other fact from that read — an applied change reaches the file but not the running process until something restarts it — is in the Deployment paragraph, and README's rollout now restarts.)tests/fixtures/smartctl_driveripper_*.txt(captured viasudo) remain the ground truth for the drives' shape. -
The fleet runs on installed bundles; what is left is rotation hygiene (phase 15 tasks 01-05 landed 2026-10-20; phase 24 task 03 rolled the fleet out 2026-10-21). The rollout is done and verified: gateway, deskwork and driveripper each run
agent/host_agent.py1.1.0 with/etc/dashboards-agent.certsinstalled0640 root:dashboards-agent, their units carry--cert-bundle+RuntimeDirectory=dashboards-agent(and keep--token-file),HOST_TARGETSishttps://for all three with no userinfo,agent.bundle_revreads2on every host and equals what/api/certsissued, and the nine sources areok. The transcript isdata/logs/NOTES-24-rollout.md, and task 04 re-read the whole end state read-only afterwards (data/logs/verify-24-mtls.py→24-task04-endstate.txt, plus the rendered proofdata/logs/24-task04-verify.cjs→24-task04-{certs,disks,system}.png): nine sourcesok,status: activewithdeployed_bundle_rev == bundle_revon all three rows,agent.tls+agent.bundle_rev+agent.version 1.1.0in every host payload, driveripper's 16 drives and deskwork'snvme0n1reporting temperature, hours and wear, deskwork's GPU card drawn on/system, gateway still absent from/diskswithdisks:gatewaystillok, nohttp://and no userinfo in any URL the dashboard serves, and no transport or certificate row in/api/summary's alerts. It revoked nothing: the standing negative control for "a revoked serial is refused" remains phase 15'sdata/logs/verify-15-tls-e2e.py(transcriptverify-15-tls-e2e.out) plus the two suite pins —tests/test_collectors.py'stest_a_revoked_agent_is_refused_before_anything_is_dialledandtests/test_api.py'stest_a_revoked_agent_fails_both_of_its_sources_without_a_dial.The docs are part of this end state, because a fleet comes back to
http://through a setup file somebody copies:tests/test_deploy_scripts.pynow pins thatREADME.md,AGENTS.mdand both tracked env templates name the registration trio (--fetch-bundle,--cert-name,--session), and that everyHOST_TARGETSexample line in the templates ishttps://with no userinfo (an emptyHOST_TARGETS=stays allowed — unconfigured is not misconfigured). The token-mode dial itself is unchanged and still documented as the compatibility path; what may not come back is it being the shape a template seeds.Two things that gap learned belong with this record, because both bite before anybody reads a dashboard:
- A
certs.root.certrow older than the SKI/AKI fix signs chains nobody can verify. "A stored row always wins" is the invariant that keeps a CA from rotating with the process, and its cost is that a root minted before the extension keeps being used forever: a leaf issued afterwards carries a correct AKI pointing at an identifier its issuer does not have, andssl.create_default_context()— strict mode, the contextAgentTls.ssl_context()builds — answersCERTIFICATE_VERIFY_FAILED: Missing Subject Key Identifier. The agent is healthy,--statusanswers over TLS, and the dashboard calls the host unreachable. The repair isPOST /api/certs/root/rotate, a dashboard restart (the CA and client pair are startup material) and a fresh bundle per host — neverDELETE FROM meta, which throws away the authority instead of replacing it. Pinned bytests/test_certs.py::test_a_stored_root_without_an_identifier_still_boots_and_root_rotate_repairs_it, which also pins the other half: reading such a table must not fail closed, because/certsis the page that fixes it. - These bundles came from the dev-box authority. The fleet trusts the
certs.*rows in the database./scripts/dev-server.shruns against, and nothing else dials these agents (asssweep on each host saw only the dev box on 9120). A second deployment with its own authority cannot read this fleet until it carries the same root or re-issues and reinstalls; that is a deployment-repo decision, and the honest version is one authority per fleet.
Everything below is the phase-15/24 history that led here.
certs.pymints the CA and the dashboard's client certificate intometaon the first boot of a database — verified against the running dev store: the fivecerts.*rows exist and did not change across two restarts — signs per-host server certificates intocerts.agents, and does reissue, revocation, and root rotation. Task 02 gaveagent/host_agent.pyits TLS mode (see the agent bullet above: one installed bundle, the per-path client-cert check, the live# REVOKEDdenylist,bundle_revin the snapshot) and task 03 gave the collector the matching half: anhttps://HOST_TARGETSentry is fetched with this app's client certificate and verified against its own root, and a host markedrevokedfails itshost:anddisks:jobs withagent certificate revokedbefore any socket opens — spy-pinned intests/test_collectors.pyand again through the realbuild_hubwiring intests/test_api.py, where the revocation is written into the samemetatable the running app reads. So both halves are proven against each other with no network and no hardware. What phase 15 left missing — an installed bundle, which is what makesDELETE FROM metathe expensive command the reset message warns about — is what task 03 supplied: deletingcerts.*now costs the fleet's identity, and every host has to be re-bundled. Phase 24 task 01 has since closed the dev-side half of that sentence:./scripts/dev-server.sh restartexports every issued bundle into gitignoreddata/certs/<name>.bundleat0600, so putting one on a host is anscpplusinstall-agent.sh --cert-bundlerather than a hand-run signedcurl(scripts/dev-certs-export.py, invariant 6's export rules, pinned bytests/test_deploy_scripts.pyagainst a real uvicorn-servedcreate_app). Task 02 has since removed even that hand-run from the rollout:install-agent.sh --fetch-bundle <url> --cert-name <name> --session <token>fetchesGET /api/certs/<name>/bundleduring the install and feeds the bytes to the unchanged validator +0640install + TLS-unit path, refusing a 401, an unissued name or a non-bundle body before it writes anything, with the session token absent from every artefact the run leaves (pinned the same way, over the same socket) — which is the path all three hosts were installed through, over an SSH reverse tunnel because no monitored host can route back to a laptop. Proving the dial also found one defect in task 01's output: the leaves carried noAuthorityKeyIdentifier, and OpenSSL's strict mode — which is what Python'sssl.create_default_context()turns on, and that is the context this dial builds — refuses such a chain withCERTIFICATE_VERIFY_FAILED: Missing Authority Key Identifier. Fixed incerts.py(SKI on the root and every leaf, AKI on every leaf, non-critical), pinned intests/test_certs.py, and the reason a bundle recorded before it must not be installed. Task 04 put the same lifecycle behind the gate:/api/certslists the stored authority (throughcerts.read_state, so a page refresh cannot mint anything) with one row per configured host and per existing entry,GET /api/certs/<name>/bundleserves the stored bytes as anattachment, and the three writes rotate, revoke, and rotate the root withrestart_required: truesaid out loud — each leaving one fixed-textcerts-source event and no PEM outsidemeta(pinned by a grep of the stored rows and of every payload a browser reads). Task 05 put the page on top of it:/certs(⚷ Certs, in every signed-in masthead — its own included, since phase 20 task 04) renders that document — the root CN/serial/expiry, the display-only client line, one row per host with its status chip and issued-vs-deployed bundle rev and its Rotate / Revoke / bundle controls — and keeps two rules the API already keeps: no colour the payload did not send (noseveritytoken on the page at all;unissuedis a grey chip, a behind bundle a neutralstaleone) and no control the API would refuse (no buttons on aconfigured: falserow, no revoke or download on a never-issued host, the download a plain sessionGET). Writes areconfirm()d rather than typed-DANGERd, and the restart sentence is the route's ownhint. Task 06 has since landed the installer half:install-agent.sh --cert-bundle <file>validates the bundle by the agent's own text shape beforeneed_root(a bad file says "wrong file", not "add a sudoers line"; exit 2), installs it0640 root:dashboards-agentbyte-identically — the task file's literal0600cannot work, because the agent opens the bundle as itself and re-reads the denylist live (the exact incident the token file's comment records), and the task's own intent is "only root and the agent's own service user may read it", which is what 0640 achieves — re-runs without the flag leave the installed bundle in place, the unit gains--cert-bundle+RuntimeDirectory=dashboards-agent(0700) from one conditional branch while keeping the token line,--statusprobeshttpswith the bundle's CA and no client certificate (python3ssl, never an external TLS binary; an unreadable bundle says run --status as root instead of pretending),--uninstallsweeps the bundle with the token, anddeploy/agent-sudoersgained no line (the bundle travels through the same root invocation).tests/test_deploy_scripts.pypins all of it, including the probe run end-to-end against a real in-process TLS agent: CA-only reads/health, a foreign root is refused, token mode keeps the plain probe. The final pass proved the composition rather than the halves: a realhost_agent.py --cert-bundleon loopback and a real dashboard process over a scratch database (HOST_TARGETS=loop=https://127.0.0.1:<port>) answer 27 checks —host:loop/disks:loopokthrough the certificate dial,agent.tls/agent.bundle_revin both snapshots, the bundle download byte-identical to themetarow, an unsigned/certsa303with?next=, a revoke followed byagent certificate revokedon both jobs with no dial, the dashboard's own client identity untouched by a host revoke (the list is serials, not hosts), and a revoked client serial refused 403 by the running agent on/healthtoo the moment the re-sealed bundle is in place (data/logs/verify-15-tls-e2e.py, transcriptverify-15-tls-e2e.out). What was left was the live rollout, and phase 24 task 03 ran it on 2026-10-21, host by host, in the order that keeps every number honest: install 1.1.0 together with the bundle (never the bundle alone — the installed agents were 1.0.0 and do not know--cert-bundle), restart the unit, let the CA-only--statusprobe answer, and only then flip that host's URL, because a URL flipped before its bundle is installed turns the host red by construction. All three: the trivialgateway, the drop-in-carryingdeskwork, anddriveripperwith the gap-10 line (--as-root --caps CAP_SYS_RAWIO,CAP_SYS_ADMIN --force), whose dry-run diff moved comments and the TLS lines only —User=rootandCapabilityBoundingSet=CAP_SYS_RAWIO CAP_SYS_ADMINcame through byte-identical, and all 16 drives read live on both sides of the rewrite (same singlenvme5n1alarm chip). Every host ran the installer twice, once without--forceto read what task 17's guard would refuse before letting it apply. Staging the repo's script and agent into the sudo-granted/home/ops/agent-install/path was step one on all three (gap 10), and fetching was reached through an SSH reverse tunnel because no monitored host can route back to the dev box.data/logs/NOTES-24-rollout.mdis the transcript: commands, unit diffs, the before/after SMART tables, the screenshots, and the two operational traps named at the head of this gap. What the fleet now never does is answer an agent over plainhttpor accept a bearer token on the certificate path — the TODO's "the client should never allow non-mtls communication" is the shipped state, and a new host has to be registered the same way. - A
-
The nav collapses on a phone; the page content below it does not (phase 18's verification pass, 2026-10-20). At 360px
/'s fleet table measures 450-510 px inside a 320 pxsection.card, and.card { overflow: hidden }clips the tail columns (WORST DISK,GPU BUSY,TEMP,UP) with no scroll container to reach them —table.dataonly scrolls with its.scrollmodifier, which that table does not carry. Measured as pre-existing by serving thegit show HEAD:bytes of bothdash.cssandpages/index.htmlover a live session: same clipping (a clipped card never un-clips when the document scrolls sideways). What phase 18 did change is that the document no longer scrolls sideways at a phone width at all — 360 px where it was 750 px — because the bar that overflowed its fixed 58 px row now breaks into rows, so nothing is lost and nothing gained: the masthead is complete at 360 px and the table tail is still out of reach. The fix is a responsive-table pass (.scrollon the wide tables, or a per-host card layout at ≤640px) and it touches pinned page shape on/, so it is a phase rather than a cleanup —21_page_cleanups'.scrollon the/certstable is the same shape applied to one table and does not reach this one;27_mobile_tables_and_auditis that pass, still intodo/. Of the two operator TODO items that sat beside it,20_masthead_nav_polishhas landed in full (now complete): the bar is three rows at every width (flush right on the two control rows since phase 25 task 02), it is no longer sticky, the phone menu slides, and both⚙ Accountand⚷ Certsrender on all seven signed-in pages including the two they name (task 04, reversing phase 15's "no self-link" rule at the operator's request). The other,24_dev_mtls_rollout(never accepting a non-mTLS agent) is closed: its fleet half landed on 2026-10-21 and task 04 re-read the end state read-only the same day — gap 11. Nohttp://agent URL and no token-mode agent is left on this fleet, and the setup templates no longer seed one (tests/test_deploy_scripts.pykeeps it that way). What remains of phase 24 is ordinary rotation hygiene, not a missing step.
Locked decisions (and why)
- SQLite, not Prometheus/Grafana. Handful of boxes, 1–10s resolution, one file the container owns. Rollups keep a year of charts at a few hundred MB.
- Our own stdlib host agent, not node_exporter. One JSON snapshot with exactly the
fields the page needs (GPU model strings, per-mount usage, temperature labels) versus
~1500 series and a re-labeling layer; no dependency to install,
ProtectSystem=strictfriendly, and read-only by construction.scripts/capture-node-exporter.shremains to compare ground truth if that is ever revisited. - Our own NUT client, not
pynut/upsc. The protocol is ~10 line commands, the third-party libs are subprocess-based or stalled, and we need instant commands with explicit gating. - In-house Prometheus parser. Only two upstreams use the format; keeps
prometheus_clientout of the dependency set. cryptographyfor X.509, dashboard-side only (phase 15). Minting a CA and signing leaves is not something to hand-roll, and there is exactly one place in this app that does it (certs.py). The agent gets no new dependency: it loads the PEMs the dashboard signed with stdlibssl, which is why the bundle is plain text and why the agent still runs on a host's Python 3.10.- Port 8090, inherited from the
llm_monitorprototype this started from: 8000 is vLLM and 8731 is gufo in this fleet. .envis a seed, never an override — container/systemd env must win.
Definition of done (per change)
The first three are exactly what bash .agents/validate.sh (the phase gate) checks, so no
phase may be called done on a subset of them.
uv run pytest green · coverage not down · ruff + pyright clean · uv pip list --outdated
empty if deps moved · dev server restarted and /api/health sane · UI change screenshotted
in a browser against live data · fixtures re-captured if an upstream contract changed ·
this file and README.md updated when a rule, command, gap, or fleet fact changed.