30 KiB
AGENTS.md — homelab dashboards
Orientation for an agent starting work in this repo: what the app is, the rules that keep
its numbers honest, where things live, the fleet it watches, and the commands. README.md
is the operator-facing how-to (quickstart, agent install, signing in, configuration,
deployment) — read it for those; this file does not repeat it. This file is orientation,
not a changelog: phase history lives in .agents/phases/ and data/logs/. When a phase
closes, update the sections below only if a rule, invariant, command, or fleet fact
changed — never append the phase's narrative.
Working protocol
- Read
.agents/PLAN.mdfirst; follow the phased execution protocol in.agents/phases/. - Never modify
.agents/PLAN.mdor files in.agents/phases/complete/; ask the user before updating anything in.agents/phases/todo/. - Strictly adhere to the LOCKED DECISIONS in
.agents/PLAN.mdand the section at the bottom of this file.
What this is
Five dashboards for one homelab, behind one domain, in one process, backed by one SQLite file — plus an overview page:
| Route | Question it answers | Source |
|---|---|---|
/ |
Is anything wrong, and where do I look first? | all five, in one request |
/system |
CPU, memory, disk, GPU, network, and the history | agent/host_agent.py, our own stdlib agent per host |
/disks |
Are any physical disks failing, and which — health, temp, wear | the same agents, smartctl -i -A -H per whole drive |
/llm |
Tokens/s, active sessions, queue depth, KV pressure, latency | gufo /metrics + /slots, vLLM /metrics |
/ups |
Battery health, runtime left, power events, test buttons | NUT upsd (our own wire client; port 3051, not 3493) |
/gateway |
Which domains on which port, and where each goes | the same agent's haproxy + nginx sections joined to /proc/net/tcp{,6} on the one GATEWAY_HOST host |
History is a first-class feature: every number worth graphing is stored, rolled up
(raw → 1m → 1h) and served from /api/*/history.
None of it is public. One admin signs in at /login (the only page an anonymous visitor
reaches); /settings (⚙ Account) and /certs (⚷ Certs) live behind it. Every page and
every /api route requires the session except the public entries in rule 12.
Non-negotiable rules
Breaking one is a bug even if the page still renders.
- Read-only by construction, with exactly one exception. Nothing writes to an upstream
except
POST /api/ups/command, gated four times (env allow, notDASH_READ_ONLY, the upsd account actually having the command, a UI confirmation — typedDANGERfor anything that can cut power) and re-checked on every request; power-cutting commands are refused outright while on battery. Hiding a button is not a safety control. - Never invent a number. A value the upstream does not publish renders as
n/a, never as0. Every derived value carries itssourcestring andderived: true; the rate estimator is keyed by the resolved series name plus its label set and refuses a window too short or too stale. Aggregating two different series into one rate is the failure mode this rule exists to catch. - 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. - 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. A failed
poll keeps the last good payload and marks it stale instead of zeroing it; a host that
never answered still gets its row (
unreachable: true, dimmed,n/a), because dropping it would turn a dead agent into "never configured". 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,last_okfrozen, one event thenrecordingon recovery), and anything that escapes the poll body is logged and the loop continues. 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 (
src/dashboards/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 intoAuthorization, soTarget.urlends up clean), but nothing that leaves the process may: URLs inUpstreamErrormessages, health records,eventrows (/api/eventsserves them to the browser) and/api/configare redacted. The admin's credential, the private keys, and session tokens are the same case:auth.pyrecords nothing and answersFalse/Noneto every rejection,certs.pyhas nologgingimport, and both modules'raisesites andauth.py's oneloggingcall carry fixed text with nothing interpolated — pinned structurally bytests/test_auth.pyandtests/test_certs.py. No token, password, or personal hostname goes into tracked files or fixtures; captured socket tables and resolver answers substitute globally routable addresses for documentation space (RFC 5737 / RFC 3849), idempotently. - Verify against the deployed upstream; never guess a metric name. Field maps in
sources/llm.pyandsources/nut.pywere captured from the running engines and pinned bytests/fixtures/. Re-capture withscripts/capture-fixtures.sh(andscripts/capture-gateway-config.shfor the gateway's proxy tree, socket tables and resolved names) rather than trusting a README or a memory of an upstream. - Keep dependencies current on purpose. After touching dependencies,
uv pip list --outdatedmust come back empty. Python is 3.14 everywhere (requires-python, ruffpy314, pyright, Containerfile) — do not lower it 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 (data/logs/*.png) before they are called done. - The dashboard never starts open, and the stored password beats the environment. The
admin verifier and the session-signing key are durable
metarows (auth.verifier,auth.session_secret) and 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. A server with neither a stored verifier nor the seed refuses to start (aRuntimeErrorout of the lifespan, non-zero exit).testpassis the local-testing password (dev config and the hermetic suite), never a production fallback. - The gate is default-deny and its public surface is four entries (
/login,/api/auth/login,/api/health,/static/). A pageGETgets303to/login?next=…(query string included, checked byauth.safe_next); everything else the one JSON refusal; every answer carriesno-store; a write additionally needs a matchingOrigin/Sec-Fetch-Siteand — when it came in on the cookie — this session'sX-CSRF-Token. A process whose lifespan never producedctx.authanswers503for everything, public paths included.tests/test_auth_api.pywalks the served route table so a route that forgets the gate fails a test instead of shipping — the middleware guards by default. Nothing may be added to/api/healthwithout re-deciding that: it is public, and a new field there is a field the whole internet reads.
Architecture
agent/host_agent.py (each host) gufo / vLLM upsd (NUT)
│ GET /snapshot │ GET /metrics │ LIST UPS / VAR / CMD
▼ ▼ ▼
collectors.py ── fetch, one UpstreamError type, no writes except run_ups_command
│ parse + Δcounter/Δt
▼
sources/host.py · disks.py · llm.py · nut.py · gateway.py poller.py (task per source)
│ Result(payload, samples, events)
▼
store.py ── SQLite (WAL): sample(source, host, metric, res, ts) + event ──► api.py ──► web/
One line each — the load-bearing behaviour is in the bullet, the rest is in the module:
agent/host_agent.py— stdlib-only (3.10+), one per host; servesGET /snapshot,/info,/schema,/health. Reads/proc,/sys/block, amdgpu DRM for GPUs,statvfs;smartctl -i -A -Hper whole/sys/blockdisk with a scaled pass budget (20 s floor, 5 s per disk) whose refresh runs on a background thread — never inline on a later poll. Refuses to bind off-loopback without a token or a certificate bundle (exit 2). TLS mode (≥ 1.1.0): the bundle is the auth (the bearer token is the compatibility path), the# REVOKEDdenylist in the bundle is re-read when the bundle changes, and a serial on it is refused on every path including/health. The proxy add-on (≥ 1.2.0): thehaproxy,nginx(plus its one-levelincludes) andsocketssections; the resolver section (1.3.0) looks up only the names the two config files declare, through libc on the box that dials, cached and single-flight. All four sections are additive,availableis three-way (true/false/null) with exactly one error line per non-truearm — the missing one saying not found, the pinned wording the dashboard reads to answer "is it installed?" — and--no-proxy/--no-resolveremove their keys in silence. Nothing in a section can fail/snapshotor/health. Counters stay counters: the dashboard computes rates.config.py— env only, no config file format, no CLI flags..envis a seed; a real env var always wins. Safe defaults except the sources, which default to unset. The gateway source is enabled only whenGATEWAY_HOSTnames aHOST_TARGETSentry.poller.py— job loop, health records, stale/last-good semantics, transition events.disks:<host>is a source of its own (300 s / 30 s) andgatewayis one job (30 s / 10 s) that exists only when configured, because the page is about the single ingress; it is handed noRateEstimator(a count is not a rate). The success path records (payload → samples → events) and then moveslast_ok, so a refused SQLite write ages the source and alerts instead of leaving it current with charts that silently stop drawing. Events are stored under the dashboard name (system/disks/gateway/llm/ups), not the sample source. The job list is built once (app.build_hub()) because it is wired twice (production and the injected-store test path).collectors.py— the fetch half.AgentTlsmakes anhttps://HOST_TARGETSentry a certificate-authenticated dial (per-dial client, this app's client cert verified against its own root; the revocation answer is a fresh read per poll, so a/certsrevoke bites on the next poll with no restart, before any socket opens). Failure messages name the redacted URL. Agenterrorsbecomedegradedexcept the add-on lines (ADDON_ERROR_PREFIXES:smart,haproxy,nginx,sockets,resolver) — a host without smartctl or a proxy config is unconfigured, not broken (rule 3); those lines stay verbatim for the page that owns the add-on. A new agent add-on section adds its prefix here in the same commit.sources/host.py— snapshot → payload + samples; reboot detection; GPU series per card. Carries the agent'stls/bundle_revpassthrough (present only when published — rule 2).sources/disks.py— physical drives only (never a partition, never a mount), the agent'ssmartblock verbatim,smartctl_availablethree-way on purpose, per-rowseverity(temp_c,percentage_used, the drive's own word forhealth); sector counters are alarm chips, never graded. Nothing here is a rate.sources/gateway.py— the snapshot projected onto the connection topology. The join is two attributions and nothing else: a socket whose local port names a configured listener is inbound; a socket whose remote(address, port)equals a target written as an address in a config is outbound; everything else is ignored. A name target is credited only through an address the gateway's ownresolversection published — the module never calls a resolver itself (spy-pinned). Pipes arenull, never0, when unmeasured.warnings()judges the two config files against themselves (seven kinds, each{id, kind, subject, message}) and never joins a warning to a row. Noseverityanywhere in the payload (a busy proxy is the job).sources/llm.py— gufo + vLLM field maps, measured Δ rates diffed per label set, prefill speed, the session list from gufo/slots. A refused estimator window is still an answer: the cell keeps itssourceand an honestΔ<window>beside anull.sources/nut.py— the NUT wire client,assess()(explicit checks, not a mystery score),SELF_TEST_OUTCOMES, theCOMMANDScatalog with risk tiers andavailable_commands()filtering, kelvin/celsius and charger-status normalisation.sources/nut_sim.py— a labelled simulator; never the default, every payload sayssimulated; its modelled battery date ages relative to today.prom.py— in-house Prometheus text parser (only for engines we don't own);values_by_series()is the per-label-set view counter deltas need.rates.py— the Δ/window estimator: keyed by series name + label set, window bounded both sides (floor;2 × spanceiling), a rate ≥ 8× the previous one held one poll and confirmed only by an independent anchor.app.build_hubbuilds one estimator per measuring cadence — host atRATE_WINDOW_SECONDS(10 s), LLM atLLM_INTERVAL_SECONDS(2 s) — because one estimator cannot be right about two cadences.severity.py— the only place thresholds live.classify()→ok/warn/fail/None; verdicts travel additively in payloads; pages render the class and never compare a number to a threshold; unknown in → unknown out, never a green pass. There is nogpu.*row and nogateway.*row: a busy GPU or a busy proxy is the job at every level.alerts.py— the pure "what is wrong right now" projection/api/summaryserves: reachability, severity breaches, UPS checks. An unconfigured source never alerts; a dead source gets exactly one line; one alert per (source, metric), at most 12. It has no thresholds of its own.store.py—sample+event+meta, rollups, pruning.metais the only durable key/value state, reached throughget_meta/set_meta/delete_metaonly. No per-request path may pay a whole-table scan:series_names()andstats()answer from short-TTL caches.maintain()is windowed (only what became due since the last pass, snapped down to a bucket boundary) and its prune is oneDELETEper resolution; it ends withPRAGMA optimize(auto-analyze is off, so no poller's commit pays for it).auth.py— every auth decision as a pure function, stdlib only: PBKDF2 verifier (self-describing, so the cost can rise without a migration), HMAC-signed stateless session cookie, CSRF bound to the session, per-address login throttle,safe_next(), and the verifier bootstrap (env seeds once; the stored row wins; no row and no seed raises). The primitives never raise and record nothing — the reason an input was rejected is the credential (rule 6).certs.py— the fleet's certificate authority over the same three-methodMetaStoreprotocol: root CA, dashboard client cert, and per-host agent certs, all inmeta; the bundle is the install artifact (agent cert + key + root certificate, never the root key, +# BUNDLE-REV+ the denylist). A stored row always wins — the root is generated on the first boot of a database and never on a later one.ensure_certsis the startup read (it mints; nothing else may call it);read_stateis the request-time read (same validation, never a write).cryptographyis the app's only new dependency, used here alone; the agent stays stdlib.api.py— GETs everywhere plus the one POST that dials an upstream. Every source's configured gate runs before anything that could reach the network (spy-pinned: no client, no fetch)./api/configis a redaction boundary. All five history routes go through one door (history_many)./api/ups/commandstays the app's only upstream write (pinned by enumerating every served route's verbs).app.py— assembly and the session gate middleware (invariant 12), answering in this order: noAuthState→503for everything; no session and no such route → the router's404; public → through (the origin check still applies to a public write); no session →303for a page / JSON for the rest; a write → origin, then CSRF.build_hubis called in both the owned-store and injected-store branches; the lifespan buildsctx.authbefore the first poll, and aRuntimeErrorthere leaves the process non-zero.web/— the six dashboards plus/login,/settings,/certs; one stylesheet,charts.js, no framework. Colour comes only from the payloadseverityverdict (/gatewaycolours nothing by verdict — its only pair is existence);n/afor the unknown,SIMULATEDbadges, per-source status dots,Δ<window>labels on measured rates. The served-byte pins intests/test_api.pyare the UI contract — when a UI rule is in doubt, read that test.
Metric naming: dotted (cpu.usage_pct, mem.used_pct, disk.used_pct@/srv,
gpu.busy_pct@0, ups.charge_pct), @stream for per-device series. sample.source is
the dashboard family — host, llm, ups, disks (the SMART series) and gateway
(the connection gauges) — so GET /api/metrics?source=… answers "what does this page
have history for". Treat the scheme as a public interface of the history API.
The fleet (real environment)
| Host | ssh | What it gives |
|---|---|---|
driveripper |
driveripper-ops |
Threadripper 24c/48t + Radeon AI PRO R9700, vLLM :8000, upsd, the dashboards. 16 whole disks; the agent unit runs User=root with CapabilityBoundingSet=CAP_SYS_RAWIO CAP_SYS_ADMIN (kernel 7.2's unprivileged-passthrough gate; an empty bounding set is capability-less, CapEff 0) |
deskwork |
deskwork-ops |
Strix Halo (Radeon 8060S iGPU), gufo :8731 |
gateway |
gateway-ops |
the router, the fleet's ingress: haproxy (/etc/haproxy/haproxy.cfg, six frontends → six backends) + nginx (/etc/nginx/nginx.conf plus conf.d/ vhosts and stream.d/'s map on :443); runs agent 1.3.0 |
- Transport is direct dial; the fleet is mTLS.
HOST_TARGETSarehttps://with no userinfo — the certificate bundle is the credential (the token in a URL is the compatibility path for a host nobody has bundled). All three hosts run the installeddashboards-agentsystemd service, installed bydeploy/install-agent.shunder the sudo grant. Re-stage/home/ops/agent-install/first — the grant names a path, not a checkout — and a re-run whose rendered unit differs from the installed one prints the diff and refuses without--force.deskwork/driveripperstill run 1.1.0: the 1.2.0/1.3.0 sections are purely additive (a 1.1.0 snapshot has none of the keys, whichsources/gateway.pyanswers assupported: false), so they move at their next re-stage. A TLS agent serves https only — rolling a host back means removing the bundle lines too, not just the URL. - NUT 2.8.5 on driveripper: configs in
/etc/ups/(not/etc/nut/), upsd on port 3051 (not 3493 — nothing listens on 3493). NUT 2.8 has noACLdirective; per-source restriction is a firewall rule, never anupsd.confline. Two UPSes:apc(APC Back-UPS RS 1500MS) andcyberpower(CyberPower PR1500LCDRT2U — that isups.description; the driver's ownidentity.modelanswersGX1500U). Real driver quirks the parsers already handle: CyberPower'sbattery.mfr.dateisCPS(not a date →unknown, never a fake age); neither driver reports a battery temperature orbattery.charger.status;ups.test.resultisNo test initiated(APC) vsDone and passed(CyberPower) — both words re-read from the wire and pinned intests/test_nut.py. - SMART:
smartctl7.5 on deskwork and driveripper; the agent shells it as itself (never sudo). This binary annotates the attribute table's RAW_VALUE column (35 (Min/Max 34/44),43894h+43m+32.519s), so the parser reads that column, not the end of the line.gatewayhas no smartmontools (a VM) →smartctl_available: false→ no/diskscard at all (the visibility gate,api._disk_visible), while thedisks:gatewayjob still polls and its events still reach the page. - Ground truth:
./scripts/dev-server.sh status(per-source ok/DOWN, unauthenticated) anduv run pytest -m live --live(opt-in, read-only). The per-host install record is indata/logs/(gitignored) — re-run the probes rather than trusting a note.
Commands
uv sync --python 3.14 # deps + editable install
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 testpass
./scripts/dev-server.sh status # per-source ok/DOWN (unauthenticated)
./scripts/dev-server.sh bundles # the bundles the restart exported to data/certs/
./scripts/dev-server.sh logs
uv run pytest # hermetic: no network (skips 12 live + 4 store_perf)
uv run pytest --cov # coverage gate (floor 94%)
uv run pytest -m live --live # opt-in: real fleet / engines / upsd — read-only
uv run pytest -m store_perf --store-perf # opt-in: store hot-path timings vs store_perf.BUDGETS
uv run ruff check src tests agent && uv run pyright
bash .agents/validate.sh # the gate: the four checks above + repo hygiene
./scripts/capture-fixtures.sh # re-record ground truth (UPS + gufo /slots + smartctl)
./scripts/capture-gateway-config.sh # the gateway's haproxy/nginx tree, /proc/net/tcp{,6},
# and the recorded resolver answers (via $GATEWAY_SSH)
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. The README's Deployment section is the
operator's how-to for both.
Testing policy
Real captures for shape, fakes for behaviour, live for drift.
- Anything that must match an upstream (
/proclayouts, vLLM series names, NUT framing, smartctl output, the gateway's configs) is asserted against a verbatim capture intests/fixtures/. If the schema drifts, a fixture test fails before a dashboard does. - Failure modes (a dead upsd, an engine 500ing, a host rebooting mid-poll, a counter
reset) are fakes in
tests/conftest.py. live(tests/test_live_fleet.py) re-runs the production code paths against the real fleet. Skipped unless--live; each test skips when its source is unset. Live tests must not write — a battery test on hardware something is plugged into is not a test. Anhttps://agent is dialled throughfetch_snapshotwith the stored authority, never a hand-rolledclient.get.- A verdict the operator sees is pinned on the surface that shows it, in both directions — an absence-only assertion is satisfied by deleting the check.
- API tests use
create_app()with an in-memory DB and thetestpassseed atpbkdf2_iterations=1000(the real 600 000 cost is pinned separately intests/test_auth.pyandtests/test_config.py, so the cheap cost cannot become the default there). tests/test_auth_api.pyis the perimeter: it walks the served route table and demands a session for every path outside the four public entries, and proves the UPS CSRF refusal on the wire — 403 afterinstcmdwould be a power cut with an error message attached.- A cost claim is pinned as a shape and a control, never as a wall clock (timings
live behind
store_perfand in the probe scripts, with budgets in a table). - Coverage: 80 % is the repo floor,
bash .agents/validate.shenforces 94 %; new/modified Python must still clear > 90 % on its own.
UI conventions (short)
- Hand-rolled SVG (
charts.js), one stylesheet, dark/light toggle, no framework, no CDN, no build step. - Colour comes only from the payload
severityverdict, throughDash.severityFrom(); a missing number or verdict printsn/auncoloured — never0, never green. A value the API should rate gets a row inseverity.py, never a bound in JS. Δ<window>labels on measured rates print the window the estimator actually used (the payload'swindow_seconds), never the poll interval;SIMULATEDbadges on modelled data; per-source status dots in the header.- Flavour text is not helpful information: copy survives only where the operator would
act differently without it — a why-this-panel-is-empty sentence, a provenance tag, a
measured
Δlabel, a unit. No pipeline narration. - A wide table scrolls sideways inside its own card at ≤ 640 px; card headers wrap their facts; disclosures scroll what they reveal; nothing is painted past a card edge and eaten. The masthead is three rows, the two control rows flush right, and the bar is not sticky.
- An event feed is a window (50 per page,
Dash.eventsPager), not a printout;/upssplits power events (behind a collapsed disclosure) from connection errors (a card of their own, hidden at rest). /gatewayis two panels (one per config file), trunks per bound port with their configured routes branching off; its only colour pair is existence (--gate-on/--gate-off) — no verdict colour anywhere on the page; its filter box is an eye, not a query (it hides rows whose own payload text does not contain what was typed; no DNS, no re-ranking).- Every chart names itself for assistive tech (
role="img"+aria-label); every chart on every page has one, pinned intests/test_api.py.
Known gaps (the current state, one line each)
- The UPS gates are the control: the upsd account has
instcmds = all, and NUT has no call for a user's privileges —enabled: truecannot mean "may send", so the risk tiers and typedDANGERare load-bearing, not decoration. - One admin, one factor: no TOTP/WebAuthn, no per-user accounts; a logout ends only this browser's cookie, and the only revocation is a password change (which rotates the session secret). The login throttle is per-process and keys on the socket peer address (behind nginx every login shares one bucket; a restart clears it).
- GPU coverage is AMD only — an NVIDIA host would report no GPU at all.
- UPS history depth: events and numeric series are stored; long-window capacity fade has the rollups but no dedicated view.
- The store's cold-cache tail (a ~1.3 s whole-table re-derivation once per cache window)
is a known cost, not a bug; the
database is lockedcascade is fixed by the windowedmaintain(). driveripper's drives read live only because the unit runs as root with the bounded gate capabilities (kernel 7.2's unprivileged-passthrough gate); the installer template reproduces it with--as-root --caps CAP_SYS_RAWIO,CAP_SYS_ADMIN --force.- The fleet trusts this deployment's CA — a second deployment with its own
certs.*rows cannot dial these agents until it carries the same root or re-issues and re-installs every bundle. - The
/gatewayroute switches are built but not wired — a route write path is phase 33's decision and amends invariant 1; until then the knobs stayaria-disabled.
Locked decisions (and why)
- SQLite, not Prometheus/Grafana. Handful of boxes, 1–10 s 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 versus ~1500 series and a re-labeling layer; no dependency to
install,
ProtectSystem=strictfriendly. - Our own NUT client, not
pynut/upsc. The protocol is ~10-line commands; the third-party libs are subprocess-based or stalled; 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. Minting a CA is not something to hand-roll; the agent stays stdlib (it loads the PEMs withssl), which is why the bundle is plain text and the agent runs on a host's Python 3.10.- Port 8090, inherited from the
llm_monitorprototype: 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)
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 (in data/logs/) · fixtures
re-captured if an upstream contract changed · this file and README.md updated when a
rule, command, or fleet fact changed.