phase: 28_gateway_agent_proxy

**Phase 28 final pass — complete, 100 % green.** All five tasks were already `complete/`; this pass verified the criteria, re-ran the live half, and did the docs pass.

- Gate `bash .agents/validate.sh` **rc=0**: `1650 passed, 15 skipped`, coverage **98.97 %** (floor 94), `ruff` clean, `pyright` **0 errors**, `node --check` clean, hygiene clean (`/tmp/validate-done.log`).
- Live: `pytest -m live --live` **11 passed**; `./scripts/dev-server.sh status` nine sources `ok`; mTLS re-read of `gateway` → agent **1.2.0**, all three sections `available: true`, 6/6 frontends routed, 11 nginx servers + `$targetBackend`, **19** listeners, no core errors (`data/logs/28-finalpass-live.out`); installed agent file byte-identical to this tree (`b0e9ebf1b718…`).
- Criteria: 1 ✅ pytest/coverage · 2 ✅ three sections over a captured tree, `false`/`null` + one `errors` line each elsewhere with `cpu`/`mem`/`disk_io`/`system` intact · 3 ✅ `AGENT_VERSION` 1.2.0 + SCHEMA names all three · 4 ✅ six fixtures verbatim, capture refuses a bad read, 116 socket address columns hold no globally routable endpoint · 5 ✅ gateway runs 1.2.0 and answers over mTLS (transcript `data/logs/28-task05-rollout.md`, `28-finalpass-live.out`) · 6 ✅ validate · 7 ✅ additive: HEAD's 1.1.0 and this 1.2.0 `collect()`ed over one `--root` are key-for-key equal plus the three new sections, `--no-proxy` equal exactly (`data/logs/verify-28-final.py` → 41 PASS, `28-final-verify.out`).
- Docs fixed (README/AGENTS/Containerfile, all re-green): README's opening said "Four dashboards" above a five-row table and said the new sections are "stored in the snapshot" (they are in the **agent's** document — no `/api` payload, no `sample` row, nothing charts them until phase 29); `Containerfile`'s header still said "three dashboards"; AGENTS's coverage figure (98.94 → **98.97 %**), invariant 6 gained the captured-socket-table identifier rule, the agent bullet gained "phase 28 stops at the wire", and the fleet record names the stash-and-compare control plus the volatile listener count. Full record: `data/logs/NOTES-28-final.md`.
- Deviations: none functional; no git index/history touched, phase files untouched (`PLAN.md` and `complete/` unchanged). `uv pip list --outdated` still lists 4 packages (fastapi 0.153→0.161 etc.) — advisory only, dependencies untouched by this phase, left for a deliberate pass.
- Next pending phase: `.agents/phases/todo/29_gateway_dashboard_page/` (the page that consumes these three sections).
This commit is contained in:
2026-10-22 20:24:01 -04:00
parent 5d37444fb0
commit 44d61d3ab9
45 changed files with 4759 additions and 42 deletions
+166 -16
View File
@@ -116,11 +116,20 @@ page still renders.
looser), dials `GET /api/certs/<name>/bundle` over HTTP instead of importing `ensure_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.py` greps the output for all three).
**A captured socket table is the same case** (phase 28): `scripts/capture-gateway-config.sh`
keeps every config byte (a `server_name` and its `proxy_pass` are the ingress map the page draws,
and a key *path* is a filename, not a key) and rewrites **only** the two address columns of
`/proc/net/tcp{,6}`, and only when `ipaddress` calls the address globally routable — a Linux
global IPv6 is EUI-64, so it encodes this box's NIC, and an inbound visitor's address was never
ours to publish. Documentation space (RFC 5737 / RFC 3849), first-seen order, same width, same
`::ffff:` spelling, idempotent; ports, states and every other column stay verbatim.
`tests/test_agent.py` fails the day a fixture grows a routable endpoint back.
7. **Verify against the deployed upstream; never guess a metric name.** Field maps in
`sources/llm.py` and `sources/nut.py` were captured from the running engines and NUT
2.8.5, and are pinned by fixtures in `tests/fixtures/`. Re-capture with
`scripts/capture-fixtures.sh` (and `scripts/capture-node-exporter.sh` when comparing
exporters) rather than trusting a README or a memory of an upstream.
exporters, `scripts/capture-gateway-config.sh` for the agent's `haproxy`/`nginx`/`sockets`
ground truth) rather than trusting a README or a memory of an upstream.
8. **Keep dependencies current on purpose.** LLM-written version floors are usually stale.
Check after touching dependencies: `uv pip list --outdated` must come back empty.
Python is 3.14 (`requires-python >=3.14`, ruff `py314`, pyright `3.14`, Containerfile
@@ -194,7 +203,8 @@ store.py ── SQLite (WAL): sample(source, host, metric, res, ts) + event ─
Responsibilities, one line each:
- `agent/host_agent.py` — stdlib-only (3.10+), reads `/proc`, `/sys/block`, `/sys/class/drm`,
`statvfs`; serves `GET /snapshot` + `GET /info` + `GET /schema` + `GET /health`, plus an
`statvfs` — plus, for the proxy add-on, the haproxy and nginx configs and `/proc/net/tcp{,6}`
on the host that has them; serves `GET /snapshot` + `GET /info` + `GET /schema` + `GET /health`, plus an
HTML status page at `GET /`. 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.
@@ -238,6 +248,105 @@ Responsibilities, one line each:
*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.
**The proxy-config add-on (phase 28 task 01)** is the `haproxy` section: `Collector.haproxy()`
reads `--haproxy-config` (default `/etc/haproxy/haproxy.cfg`, resolved under `--root` so the
fake-tree tests read the fake tree, one size-bounded read per snapshot) and
`parse_haproxy_config()` turns its text into `{frontends: [{name, binds: [{addr, port}],
default_backend, backends}], backends: [{name, servers: [{name, target, port}]}]}`. Three rules
keep it honest. (a) `available` is `smartctl_available`'s three-way answer — `true` read and
parsed, `false` no file at that path, `null` a file that could not be read or holds no haproxy
section — and each non-`true` arm costs exactly **one** `haproxy:` line for the pass, the missing
one saying **not found** (pinned wording, the same cross-module contract as smartctl's, because
the dashboard reads that word to answer "is haproxy installed?"). (b) The section reports the
config's own text and infers nothing about a request: a bind keeps the address as written
(`:::22` splits at the **last** colon into `"::"` and `22`, so an IPv6 bind survives, and
`bind :8080` keeps `addr: null` because `*` is a spelling the config did not use), a `server`
with no port keeps `port: null` and never `0`, a `use_backend` whose target is a fetch
expression (`%[req.hdr(host),map_dom(...)]`) contributes **no name** rather than an expression
where a backend belongs, and everything outside `bind`/`default_backend`/`use_backend`/`server`
is ignored without a word. The frontend→backend routing is therefore the names, which the
dashboard joins. (c) A `listen` section is reported as the frontend+backend pair haproxy itself
defines it as, so a config written in that shorthand cannot read as "this gateway routes
nothing". The section is absent from the document with `--no-proxy` (silence, the `--no-smart`
precedent — "not collected" stays distinguishable from "collected and empty"), it is additive in
the payload, and nothing in it can fail `/snapshot` or `/health`.
`tests/fixtures/gateway_haproxy.cfg` is the verbatim capture the parser is pinned to.
**The same add-on's `nginx` section (phase 28 task 02)** reads `--nginx-config` (default
`/etc/nginx/nginx.conf`) **plus the files its own `include` lines name, one level deep** — the
gateway keeps its vhosts in `conf.d/*.conf` and its SNI routing in `stream.d/*.conf`, so the
main config alone is not the picture. `parse_nginx_config(main_text, included)` is the pure
half over text, and it needs a real scanner rather than haproxy's indent loop because nginx
nests by `{}`: `_nginx_tokens` keeps a `;` inside a quoted value as data (the real config writes
`add_header … "max-age=31536000; includeSubDomains" always;`), `_nginx_statements` builds the
block tree and raises only for a file nginx itself would refuse to start on, and the walk fills
`server` blocks (`listen`, `server_name`, `proxy_pass`), `upstream` blocks and `map` blocks.
Same three rules as `haproxy`: `available` is three-way with exactly one `nginx:` line per arm
(the missing one saying **not found**, the pinned wording the dashboard reads), `--no-proxy`
removes the key in silence, and nothing in the section can fail `/snapshot` or `/health` —
including a config that will not tokenize, which is `available: null` plus its one line with
every other section intact. What it refuses to do is interpret: `listen` keeps the address as
written (`[::]:8443` → `"::"`, a bare `80` → `addr: null`) and ignores the `ssl`/`proxy_protocol`
flags, `server_name _` stays the literal `_`, a map's value becomes a `target` only when it is
an address (the `$connection_upgrade` map's `upgrade`/`close` are header words, and calling one
a host is inventing a fact to fill a field), and a `proxy_pass $var` travels with `variable`
(the `$` dropped once here, so both sides of the join spell it identically) and a **null
`target`** — the map decides per connection, so a merged target would be rule 2 with a page's
clothes on. Include-following is one level and bounded (8 files / 1 MB of included text, the
smart pass-budget shape): the excess is dropped with **one** line naming the count and the
section stays `available: true`; `files` is the main config then the documents actually merged,
spelled the way the config spells them (`/etc/nginx/conf.d/http.conf`, never the path under
`--root`, which is also what lets the parser match its own `include` lines); a literal include
naming a file this host does not have costs nothing and no line; and an `include` inside an
included file is not followed at all — `root=False` makes that structural, not a convention.
**The same add-on's `sockets` section (phase 28 task 03)** is the live half: `read_proc_table`
reads `/proc/net/tcp` and `/proc/net/tcp6` (resolved under `--root`, the config rule) through at
most `PROC_NET_MAX_LINES` = **4096 lines per table** — `/proc`'s own header among them, so 4095
sockets — because a gateway's file is one line per socket and the read must not cost the poll
somebody else's connection count; the excess is reported in **one** line naming the bound and the
section stays `available: true`. `parse_proc_net_tcp(text, family)` keeps two kernel states only:
`0A` LISTEN → `{family, addr, port}` and `01` ESTABLISHED →
`{family, local: {addr, port}, remote: {addr, port}}`; every other state is a socket in transit
(`TIME_WAIT` above all) and is skipped in silence, since drawing it would put two pipes on one
connection and bury the live pairs. `_proc_net_addr` unrolls `/proc`'s per-32-bit-word
little-endian spelling through `socket.inet_ntop`, so a wildcard keeps its literal `0.0.0.0` /
`::` — the value the inbound join compares against — and a word that is not an address is `addr:
null` with the port kept, never a guess and never `0`. The two tables are read **independently**:
`available` is `true` when either answered (a kernel built without IPv6 has no `tcp6`, which is
absence, so no line — the nginx include rule), `null` with one combined `sockets:` line when
neither did, and a table that is there and denied costs one line even beside a good one. This
section has **no `false` arm on purpose** — `/proc/net/tcp` is not something a host leaves
uninstalled, and `false` beside an empty list would read as "no listeners", which is rule 2 — and
**no join with the config**: a socket does not know its domain, so phase 29's page joins these
ports to the `haproxy`/`nginx` sections and an unclaimed socket stays visible as one.
**Task 04 made the three sections a versioned contract with captured ground truth:**
`AGENT_VERSION` is **1.2.0** (`tests/test_agent.py` pins the constant and asserts the
`GET /schema` block names `haproxy`/`nginx`/`sockets` — each with its `available` flag — plus
`--no-proxy`, `--haproxy-config` and `--nginx-config`), and `scripts/capture-gateway-config.sh`
re-records the six fixtures the parsers are pinned to over `GATEWAY_SSH` (`gateway-ops`): the
haproxy config, the nginx main config **plus every document its own glob `include` names** (so
the fixture tree cannot drift from the config's tree while the parser still follows one level),
and `/proc/net/tcp{,6}`. Three rules it exists to keep. (a) **No privilege and no copy tool**: one
`ssh <alias> cat <path>` per document, because `/proc/net/tcp` reports size 0 and `scp` would copy
an empty file, and every path it reads is world-readable — the same read the agent performs as its
own user, so a capture that needed sudo would be recording something the agent cannot see.
(b) **A bad read is never a fixture**: a nonzero `cat`/`ls`, an empty body or a missing glob include
exits non-zero having written nothing, and a half-written file is removed — the pin is that the
recorded bytes survive a dead alias and an empty read both. (c) **Configs verbatim, socket tables
identifier-free**: the configs keep every `server_name`, `proxy_pass` and key path (a domain is the
map the page draws and a key path is a filename, not a key, while an agent-only bundle carries no
key at all), but a globally routable address in a socket table becomes a documentation one (RFC
5737 `203.0.113.0/24`, RFC 3849 `2001:db8::/32`) through an idempotent column substitution — ports,
states, every other column and every space are verbatim, loopback/private/link-local/wildcard
addresses are kept, and the IPv4-mapped `::ffff:` spelling survives — because a global IPv6 on
Linux is EUI-64 (derived from the NIC MAC) and an inbound visitor's address is not this repo to
publish: the same rule that scrubs a drive's serial (invariant 6). Those two tables are volatile by
nature, so `tests/test_agent.py` asserts their **shape**, the ports the recorded configs claim, and
the absence of a globally routable endpoint — never a particular row, which a re-run may lawfully
change. What phase 28 stops at is the wire: `sources/host.py` passes **none** of the three into
`/api/system/snapshot` and `store.py` records no series for a proxy or a socket, so the sections
live in the agent's own document and reach no page until phase 29's. What the dashboard does read
today is their **error lines**, through `collectors.ADDON_ERROR_PREFIXES`, so a host with no proxy
config stays out of `/system`'s `degraded` (invariant 3).
- `config.py` — env only, no config file format, no CLI flags. `.env` is a *seed*; a real
env var always wins. Defaults are safe except the sources, which default to unset. The auth
block (phase 11) is `DASH_ADMIN_PASSWORD` (the **seed**, and `_env()` strips, so `VAR=` means
@@ -301,11 +410,17 @@ Responsibilities, one line each:
same sentence on both pages and only the `timeout` differs (the disks poll has to outlast
the agent's smartctl pass, and `None` is translated into httpx's sentinel because a
literal `None` means *no timeout at all*). Agent `errors`
become the payload's `degraded` **except** the `smart:` add-on lines — a host without
smartmontools or device read access is unconfigured, not broken (invariant 3); they stay
in `agent.errors` for the `/disks` page to explain — which since phase 22 is only the
device-read case, because a host whose agent said `smartctl` is *missing* is not listed on
that page at all (`api._disk_visible`).
become the payload's `degraded` **except** the add-on lines named by
`ADDON_ERROR_PREFIXES` (`smart`, `haproxy`, `nginx`, `sockets`) — a host without smartmontools,
without device read access, without a proxy config, or whose kernel socket table did not answer
is unconfigured, not broken (invariant 3), and `degraded` is `alerts.py`'s input, so a fleet-wide
agent upgrade would otherwise put an overview WARN on every box that never ran haproxy or nginx.
Those lines stay verbatim in `agent.errors` for the page that owns the add-on — `/disks` explains
the `smart:` ones (and since phase 22 a host whose agent said `smartctl` is *missing* is not
listed on that page at all, `api._disk_visible`), phase 29's gateway page reads the
`haproxy:`/`nginx:`/`sockets:` ones the same way.
**A new agent add-on section adds its prefix here in the same commit**, or its absence-report
becomes a red banner on a page that has nothing to say about it.
- `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's
`tls`/`bundle_rev` ride into the payload's existing `agent` block, and a token-mode snapshot
@@ -979,7 +1094,7 @@ That scheme is the history API's key — treat it as a public interface.
| ----------------- | ------------------ | -------------------------------------------------------------------- |
| `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 |
| `gateway` | `gateway-ops` | router, no GPU, the fleet's ingress: haproxy (`/etc/haproxy/haproxy.cfg`, mode `0644`: six `frontend`s on `:::<port> v4v6` → six `backend`s, one `server <name> <domain>:<port> check inter 5s` each — the file the agent's `haproxy` section parses and `tests/fixtures/gateway_haproxy.cfg` captures) and nginx (`/etc/nginx/nginx.conf` plus `conf.d/http.conf` vhosts on `4443`/`8443` and `stream.d/stream.conf`'s `map $ssl_preread_server_name $targetBackend` → `proxy_pass $targetBackend` on `443` — the tree the agent's `nginx` section parses) |
- **Transport is direct dial: dev is the prod topology, repo-wide (phase 10 task 05).** The
designed shape is one `dashboards-agent` **systemd service per host**, installed by `ops`
@@ -1023,14 +1138,41 @@ That scheme is the history API's key — treat it as a public interface.
`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 run `agent/host_agent.py` **1.1.0**
from an installed bundle, every unit carries `--cert-bundle /etc/dashboards-agent.certs` (plus
history rather than the designed shape.** `gateway` runs `agent/host_agent.py` **1.2.0** (phase 28
task 05, 2026-10-22) and `deskwork`/`driveripper` still run **1.1.0**; the spread costs nothing,
because 1.2.0 is purely additive — the three proxy-config sections are new keys and a 1.1.0
snapshot simply has none — so those two move at their next re-stage rather than on a flag day.
The control that proves "purely additive" is `data/logs/verify-28-final.py`: it stashes the whole
working tree, imports both `host_agent.py` files side by side, calls both `collect()`s against
the same `--root`, and demands the 1.2.0 document equal the 1.1.0 document key-for-key with
`haproxy`/`nginx`/`sockets` added — and 1.2.0 with `--no-proxy` equal it exactly.
All three are installed from a bundle, every unit carries `--cert-bundle /etc/dashboards-agent.certs` (plus
`RuntimeDirectory=dashboards-agent`, and still its `--token-file` line), and `HOST_TARGETS` is
`"driveripper=https://10.1.0.126:9120 deskwork=https://10.1.0.51:9120
gateway=https://10.1.1.27:9120"` — **no `http://` entry and no userinfo anywhere**: the
certificate is the credential (the pre-flip `.env.dev` is backed up in `data/env-backup/`). `agent.tls: true` + `agent.bundle_rev: 2` in
every payload, `deployed_bundle_rev == bundle_rev` for all three on `/certs`, and
`./scripts/dev-server.sh status` reads the nine sources `ok`. The per-host order was the safety:
`./scripts/dev-server.sh status` reads the nine sources `ok`. **Phase 28 moved the gateway to
1.2.0 on 2026-10-22 and only the agent file moved.** Task 04 read it read-only first
(`data/logs/verify-28-task04.py` → `28-task04-probe.out`): 1.1.0, `agent.tls: true` + `bundle_rev 2`,
every pre-1.2.0 section answering with live numbers and **no** `haproxy`/`nginx`/`sockets` key —
the control that made the install provably additive. Task 05 then ran the phase-24 procedure on
that one host (re-stage `/home/ops/agent-install/` first with `*-20261022-backup` snapshots, gap 10;
`sudo /home/ops/agent-install/deploy/install-agent.sh --bind 10.1.1.27 --port 9120` with the
arguments read back off `systemctl cat`; `systemctl restart dashboards-agent`) and verified it two
ways: the CA-only `--status` probe answers `version 1.2.0` on `/health` and still refuses
`/snapshot` with `client certificate required`, and the dashboard's own mTLS dial
(`data/logs/verify-28-task05.py` → `28-task05-verify.out`) reads `agent_version 1.2.0` with all
three sections `available: true` (6 frontends all routed / 6 backends, 11 nginx servers and the
`$targetBackend` map, 17 listeners including 443) beside every pre-1.2.0 section intact and
`host:gateway`/`disks:gateway` `ok`. That listener count is a reading, not a fact: the same probe
re-run on 2026-10-22 for this phase's completion pass (`data/logs/verify-28-final.py` →
`28-finalpass-live.out`) answered **19** listeners with the config halves unchanged (6/6, 11
servers) — `/proc/net/tcp` is what a busy gateway writes every second, which is exactly why no
test asserts a row of it. The one fact from that transcript worth carrying to the next
host: the bundle **on disk** — not the `--cert-bundle` flag — is what puts the TLS lines into the
unit render, so an upgrade re-run that omits the flag is the silent path (no guard refusal) and
`systemctl cat` came back byte-identical (`26bd812…`); only `host_agent.py` changed. The per-host order was the safety:
install agent 1.1.0 + bundle → `systemctl restart` → the CA-only `--status` probe answers →
*then* that host's URL flips, so no source was red longer than its own host's restart.
`data/logs/NOTES-24-rollout.md` is the transcript (sha256 of what was staged where, the unit
@@ -1112,8 +1254,9 @@ That scheme is the history API's key — treat it as a public interface.
filter removal, nor the re-run guard — running it would have replaced driveripper's `User=root`
+ `CapabilityBoundingSet` unit with the snapshot's capability-less one, **silently**. That trap
is now closed on all three hosts: `/home/ops/agent-install/` is `ops`-owned, so the repo's
`deploy/install-agent.sh` (sha256 `ffbcd91f…`) and `agent/host_agent.py` (**1.1.0**) were staged
over it without root, and each snapshot was left beside the grant as
`deploy/install-agent.sh` (sha256 `ffbcd91f…`) and `agent/host_agent.py` (**1.1.0**, re-staged as
**1.2.0** on `gateway` in phase 28) were staged over it without root, and each snapshot was left
beside the grant as
`install-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. The `systemctl restart *` grant on those two hosts is the other half of an unattended
@@ -1233,9 +1376,9 @@ uv run python scripts/dev-certs-export.py --url http://127.0.0.1:8090 \
# GET /api/certs/<name>/bundle and writes
# data/certs/<name>.bundle at 0600 — mints nothing
uv run pytest # hermetic: 1549 passed + 15 skipped (11 live,
uv run pytest # hermetic: 1650 passed + 15 skipped (11 live,
# 4 store_perf), no network
uv run pytest --cov # coverage gate (98.94%; floor 94%)
uv run pytest --cov # coverage gate (98.97%; 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
@@ -1270,6 +1413,11 @@ uv run ruff check src tests agent && uv run pyright
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)
./scripts/capture-gateway-config.sh # ... plus the gateway's proxy tree and socket tables:
# haproxy.cfg, nginx.conf + its glob includes, and
# /proc/net/tcp{,6} over $GATEWAY_SSH (gateway-ops);
# world-readable paths only, and a failed or empty read
# never replaces a good fixture
bash .agents/validate.sh # the phase gate: the four checks above (pytest
# --cov --cov-fail-under=94, ruff, pyright, plus a
@@ -2342,7 +2490,9 @@ memory plot; the `cpu.usage_pct@coreN` series is still stored and still queryabl
(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.py`
**1.1.0** with `/etc/dashboards-agent.certs` installed `0640 root:dashboards-agent`, their units
carry `--cert-bundle` + `RuntimeDirectory=dashboards-agent` (and keep `--token-file`),
carry `--cert-bundle` + `RuntimeDirectory=dashboards-agent` (and keep `--token-file`) — the
gateway has since moved to **1.2.0** for phase 28's proxy-config sections on the same unit and
the same bundle, see the fleet record above —
`HOST_TARGETS` is `https://` for all three with no userinfo, `agent.bundle_rev` reads `2` on
every host and equals what `/api/certs` issued, and the nine sources are `ok`. The transcript is
`data/logs/NOTES-24-rollout.md`, and task 04 re-read the whole end state **read-only** afterwards