# Changelog

All notable changes to 3pm-backend. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

### Added
- **A re-enrolled host replaces its old row** (`http/supersede.rs`): when
  a node becomes active (enrollment, `approveNode`, approval turned
  off), every older row of the bucket with the same hostname (ASCII case
  ignored, never empty) that has no live session and is `offline` is
  deleted as `deleteNode` does (`api_nodes::forget`, shared). Kept: a
  linked, `stale` or revoked row; a pending node replaces nothing; the
  newest of twins admitted together stays (row id order). Fixes the
  reinstalled tablets of 2026-10-10 (new `node.key`, the old row
  offline under the same name, `3pm fleet ssh <host>` ambiguous). Rows
  already duplicated are not swept retroactively. Tests: `FakeAgent`
  gains `hostname`; `real::link_node` waits for the node to be `online`
  in the bucket, since a rig's two daemons share this machine's name.
- **`getBucket` takes the feed's query** (`history=0`, `match=`,
  `view=live`, applied as to the feed's `init`; unknown values 400):
  `3pm fleet nodes` asks `history=0&view=live` — 2.1 MB → ~65 KB on the
  prod bucket of 2026-10-07 (2 MB of it was `history`, which a node with
  `fleet:read` received and never read). Without the query the answer is
  unchanged; the generated method keeps its signature and takes it in
  `options.query`. `view=live` keeps `module` on each process row (the
  feed's frames too): `3pm fleet apps` prints it.
- **Link traffic by category** (lot 3 of `rd/logs-bytes-counter/PLAN.md`,
  no schema change): the agent's `traffic` section (agent ≥ the one of
  lot 2) is read at every store tick of a collected node through
  `NodeState::traffic` + `traffic_view` and stored as
  `threepm_link_bytes_total` / `threepm_link_messages_total`
  `{cluster, node, link_dir, link_kind}` — the closed vocabulary only
  (`Traffic::known`, ≤ 30 pairs), an unknown kind dropped and warned once
  per node. Catalog `link.bytes` / `link.messages` (counters, `rate`),
  `link_dir` / `link_kind` as `Custom` labels on those lines only (no
  reserved word). The node card gains a "Link traffic" panel
  (`queryMetrics`, `rate(…[1m])` by direction and kind, state kinds
  "est.", `rx_bps` beside). An older agent sends none: nothing stored.

### Fixed
- **Logs ingest, review of 2026-10-07** (no schema change): dropping
  what the catalog refused is linear (a set of the refused templates,
  not a scan of the list for each record: 50 000 templates, 20 000
  refused went from 4.65 s to 26 ms in a debug build) and runs off the
  async runtime (`spawn_blocking`), so a batch of tiny `tpl` records
  against a full catalog no longer blocks a tokio worker for seconds; a
  body read then refused `503` (the fleet's 32 slots taken) is charged
  to the node's budget at its compressed size, so it is not uploaded
  again for free; that `503`'s `retry-after` is 2 s, plus 1 s per 32
  batches turned away in the last 10 s window (a slot freed does not
  reset it), 10 s at most (was
  1 s, which brought a reconnecting fleet back all together); a new
  source at the 1 000-source cap never evicts a source the same batch
  writes counts, lines or drops under (the keep set was its declared
  sources and its templates' only).
- **Logs, review of 2026-10-06** (no schema change, still V22):
  `logsQuery` asks its counts five nodes at a time without a `node`
  filter (VictoriaMetrics refuses a query over
  `-search.maxUniqueTimeseries`), a refused chunk is
  `truncated.counts`, not a failed answer; the log catalog is purged of
  rows not named for 30 days (hourly) and a node at its bound evicts its
  least recently seen source/template instead of refusing every new one
  forever (a refusal left is said in the answer, `refused`, and logged);
  a template counted is marked seen once a day; a change of seats writes
  the seats that moved, in the batch's one transaction (was ~4 000 row
  writes for one new template); `filters.tpl` needs `node` and `src`
  (`400`); records with a control character in an id or label are
  skipped (also line/paragraph separators, zero-width and bidirectional
  marks); at most 32 batches stored at once fleet-wide, taken once the
  body is read (`503` + `retry-after`), a body slower than 30 s a `408`;
  eviction never takes a row the batch names, the `refused` list is the
  link's `LogsAccepted.refused`; count chunks run 4 at once; parameter `distinct` is merged over the whole range.
- **Insights log rules**: count queries scoped per node and template,
  failures and truncations counted (`partial_rules`), chronic kernel
  errors are `inventaire` unless ×3 their usual rate, `log_new_error`
  skips templates of a miner epoch younger than a day (state reset; the
  epoch is read from any series of the node, so an old quiet source still
  fires); "chronic" needs every day of the history there is, 3 to 7.

### Added
- **Logs query** (`logsQuery`, `POST /api/buckets/{id}/logs/query`, the
  owner's, like `eventsQuery`): `{range ≤ 30 d, filters? {node, src,
  owner, sev (a word, a list, or `warning+`), tpl}, text?, limits?
  {templates ≤ 500 (200), lines ≤ 1000 (200)}}` → `{range, sources,
  templates, lines, truncated}`. Sources from the catalog with the lines
  counted, shipped and dropped over the range and their templates;
  templates worst severity first then by lines, text and first/last seen
  from the catalog, parameter stats merged (`distinct` the largest, `top`
  the most frequent), the unlabelled ones as one `tpl: null` "(other
  templates)" per source and severity; shipped lines newest first. One
  instant VictoriaMetrics query and three VictoriaLogs queries (tenant
  `0:1`) per request; bounds in `limits.logsQuery`.
- **Log ingest** (`pushLogs`, `POST /api/buckets/{id}/logs`, the
  agent's own route, `link-protocol.md` §6b): a node's batches of logs,
  gzip NDJSON, its fleet bearer, any active node of the bucket. With
  `--events-url` the `welcome` now says `logs {apps, host, files}`;
  without it the route answers `503 ERR_STORE_DISABLED`. Refused before
  the body is read: `content-length` missing or over 1 MiB (`413`), a
  batch of the node already in flight or the node over its budget
  (128 KiB/s inflated, 4 MiB burst: `429` + `retry-after`); a body that
  inflates past 4 MiB is a `413`. Records that do not read, are of an
  unknown kind, break a bound or name an undeclared source are skipped
  and counted (`{accepted, skipped}`); a retried batch (same `run`,
  `seq` not past the last) is `{duplicate: true}` and stored once, and
  a line is stored once per `(node, src, pos)` (last 50 000 per node, in
  memory). Sources and templates in SQLite (migration V21:
  `log_sources`, `log_templates`; 1 000 sources and 50 000 templates per
  node), counts as `threepm_log_messages{cluster,node,src,tpl,sev}` (the
  node's 2 000 busiest templates labelled, the rest `tpl="other"`) and
  drops as `threepm_log_dropped{…,src,reason}` in VictoriaMetrics, lines
  and parameter stats (`kind=line|param`) in VictoriaLogs tenant `0:1`,
  stream `cluster,node,src` — events stay in `0:0`. A store that does not
  take the batch is a `503 ERR_STORE_UNAVAILABLE`; the retry writes only
  the half still owed, under the labels first chosen. The template seats
  survive a restart (migration V22, `log_templates.seated`); a template
  id reused by another template (another severity, no word in common)
  is first seen again. A body that is not gzip or does not open with
  `batch` is a 400. Counted in `GET /api/v1/info` → `store.logs`.
- **Sessions only for the caller's own key** (with 3pm's agent of the
  same release): the agent's new refusal code `key_not_authorized` (the
  caller's `cli.key` is not in the node's own `authorized_keys`, the
  message naming the fingerprint and the `3pm link authorize` to type)
  settles a session as `err.code` `forbidden`, keeping `key_not_authorized`
  in `result.code`; an older backend stored it as `unknown`, so deploy
  this one before the agents. `nodeShell` takes `record` (the caller's choice, `true` only with `--record`, absent from an older 3pm = yes): it only
  lowers the bucket's `record_shells`, never raises it, and the
  effective value goes back in the `CallView` before the handshake that
  binds it. The request the node receives is bound to the session's
  handshake: a backend that changes argv, `--user`, `host:port` or
  `record` on the way gets no session. Proven against the real agent by
  `tests/it/real_agent_access.rs` (a key the node does not admit, a
  rewritten request, an `-R` connection taken by another key, no
  `authorized_keys`, a refused burst).
- **Insights read the logs** (`insights/apps/log_rules.py`): four
  rules on what the nodes ship, each finding its templates by their
  shipped lines in VictoriaLogs tenant `0:1` (7 days) and summing their
  counts in `threepm_log_messages`. `log_segfault` (kernel `segfault at`
  / `general protection` ≥ 3 in 1 h on a node), `log_unit_failed`
  (systemd `Failed with result` in 24 h, naming the unit; one alone is
  `inventory`), `log_kernel_errors` (kernel `err`/`crit` lines over 100
  in 1 h, e.g. ACPI or `brcmfmac`), `log_new_error` (an `err`/`crit`
  template with lines in the last hour and none in the 7 days before,
  on a node that shipped logs a day ago; its text from the node's `new`
  line). A store query that fails or answers garbage skips that rule
  for the pass, not the whole rules pass.
- **`insights` / `insight`** (`GET /api/buckets/{id}/insights[/{incident}]`):
  the incidents the insights apps rank for a bucket, relayed from their
  `triage` (`--insights-url`, `THREEPM_BACKEND_INSIGHTS_URL`; contract
  `GET /api/v1/incidents?cluster=` version 1), this bucket's cluster
  only, cached 30 s. Text for an LLM by default, JSON with `?format=json`;
  each incident's store name linked to the catalog and a `queryMetrics`
  body to draw it, no MetricsQL served. Filters `tier`, `node`,
  `min_score`, `limit`, `known`. Fleet-wide incidents name their
  machines (`hosts`) and draw them; an unknown incident id is a 404 that
  never reaches triage; a malformed incident is left out; the URL is
  checked at start. New codes 409 `ERR_INSIGHTS_DISABLED`,
  503 `ERR_INSIGHTS_UNAVAILABLE`. Open to a node's bearer with
  `fleet:read`. `src/insights/`, `src/http/api_insights.rs`.
- **`nodeListen`** (`POST …/nodes/{node}/listen {bind, port, e2e}`,
  `3pm fleet ssh -R`): one long `AgentRequest::Listen` on two sealed
  `pipe` legs (`e2e` required, else 422; `bind` empty = the node's
  loopback, `port` 0 = the node's pick). The node says `bound`, then
  `accepted {conn, peer}` per connection; the caller's keepalive every
  20 s keeps the quiet legs alive (no sweeper change). A non-loopback
  `bind` is the node's own `[shell] gateway_ports`, never this
  backend's. Guarded exactly as `nodeForward` (`3pm:forward`,
  `ERR_FORWARD_NOT_ALLOWED`, no cross-site `Bearer`); never recorded;
  a shell's call for every sweep. Audit: `open_requested` / `refused`
  with `bind` and `port`, `ended`. `src/http/api_listen.rs`.
- **`nodeForward {accepted: {listen, conn}, e2e: true}`**, with no
  `host`/`port` (422 otherwise): the node takes over connection `conn`
  of the caller's listen instead of dialing. **One 404**
  (`ERR_NOT_FOUND`, `no running listen <id> of yours on node …`) unless
  `listen` is a `listen` call on this node, placed by this caller, still
  running — the same words for each case, so a guessed id tells nothing
  of another caller's listen; `3pm:forward` is checked before it. Audit:
  `listen` and `conn` in place of `forward`. `e2e` on `nodeForward`
  is passed to the agent's `forward` (`-W`, `ssh`'s `ProxyCommand`, is
  a plain sealed forward: nothing new here).
- **End-to-end `3pm fleet ssh`: an opaque relay.** `nodeShell` and
  `nodeForward` take `e2e: true`: the legs carry Noise records the
  backend relays without reading, never tees, never records. Refused
  at once with 409 `ERR_NODE_NO_E2E` (naming the agent's version) for a
  node whose session did not announce `shell_e2e`. The request carries
  `e2e` and `record` (the bucket's `record_shells`) to the agent.
- **Recordings made by the node.** For a sealed session the agent
  uploads the asciicast (`putCallArtifact`, cap `RECORDING_MAX_BYTES`
  for `3pm:shell`) and names it in `exited {recording}`; the backend
  claims it as the call's artifact (`shell::kept_recording`) and the
  `ended` line names it — or says `recording: lost` when the agent
  died before sending it. A sealed session may go LAN direct even when
  the bucket records shells.
- **A copy is never recorded.** An `exec` whose argv is one of `3pm
  fleet cp`'s fixed scripts, byte for byte
  (`threepm_link::cp::is_cp_transfer`), asks `record: false` and is
  not teed in clear either: a download's screen is the file. The audit
  keeps its command line and paths; its `ended` line names no
  recording, `lost` included. The agent applies the same predicate.
- **Audit: `e2e` and `cli_key`** on `open_requested` and the agent's
  `open` (the caller's key fingerprint).
- **Shell legs may go LAN direct.** `nodeShell` and `nodeForward` take
  `direct: true` (the caller takes its legs through its own agent):
  between two neighbours both set to `direct: all`, the answer names
  the target (`peer`), the target's ticket the caller, and a `pipe`
  leg the writer reports `direct` leaves the relay like a `live` one
  (kept hidden, fed by `blob_stat`). Never for a session in clear
  that is recorded (`record_shells` on, the default): its screen is
  read off the relay. An end-to-end session, recorded by its node, may.
  A forward, never recorded, goes direct whenever its caller asks.
- **Session recording** (`record_shells`, on by default; schema V18):
  the screen leg of every shell, command and attach session is copied,
  off the relay through a bounded queue that never waits, into an
  asciicast v2 artifact of its call (`ext` `cast`, action `3pm:shell`),
  64 MiB at most, a marker on a cap or a gap; never the keyboard leg,
  never a forward. Its id is on the backend's `ended` audit line
  (`recording`). `setBucketSettings` takes `record_shells`; settings
  page toggle "Record shell sessions".
- **`nodeForward`** (`POST …/nodes/{node}/forward {host, port}`, `3pm
  fleet ssh -L`): one `AgentRequest::Forward` per local connection, on
  two `pipe` legs carrying raw bytes (`END` = half-close). Guarded like
  `nodeShell` but by its own `callers` action `3pm:forward` and its own
  403 `ERR_FORWARD_NOT_ALLOWED`; closed to a cross-site `Bearer`.
  Audited (`open_requested` / `refused` with `forward = host:port`,
  `ended`); a shell's call everywhere (`is_shell`, the SQL sweeps).
- **`nodeShell` takes `user`** (`3pm fleet ssh -l <user>`): the shell
  or the command placed as `run_as {user, req}` (422 for a name that is
  not a plain login, or with `attach`); `user` on the `open_requested`
  and `refused` audit lines. A `run_as` call is a shell everywhere a
  shell is found (its legs, its view, the sweeps).
- **`nodeShell` attaches to a `tty` app** (`3pm fleet attach <node>
  <app>`): the body takes `attach {app, instance?, mode?, read_only?,
  peek?}` (exclusive with `command`, 422 otherwise) and places
  `AgentRequest::Attach`. Its calls are a shell's everywhere
  (`is_shell`, the SQL sweeps); `open_requested` and `refused` carry
  `app`; the agent's new `attach` audit line is stored with `app`,
  `instance`, `mode`, `read_only`.
- **`nodeShell` runs one command** (`3pm fleet ssh <node> -- <cmd>`):
  the body takes `command` (its words: 1 to 256, 16 KiB in all, no NUL,
  the first not empty, else 422) and `pty`, and places `AgentRequest::Exec`
  instead of `Shell`. The calls of an `exec` are a shell's everywhere
  a shell's are found (the legs hung up on a refusal, a session whose
  legs are gone brought forward, a reconnect leaving it in flight);
  `open_requested` and `refused` carry `command` (the line the target's
  shell is given, `threepm_link::exec_line`: `cat '>' /etc/x`, never
  the words space-joined, which read `>` as a redirection), and the agent's
  `exec` audit event is stored (`event: exec`, `command`, `pty`).
  `MAX_BODY_BYTES` of the route grows from 1 KiB to 64 KiB.
- **`nodeShell`: an interactive shell on a node** (`POST
  /api/buckets/{id}/nodes/{node}/shell`, body `{term, cols, rows}`, the
  server side of `3pm fleet ssh`). Opens a pair of `pipe` legs charged
  to the caller and places `AgentRequest::Shell` with `from` (the
  call's `placed_by`) and `from_hostname`; the answer is the `CallView`
  (`input.blob` the keyboard, `output.blob` the screen, `live`
  counters while it runs). Same gate and scope as `nodeAction` (the
  account, or a node's bearer with `fleet:invoke`), but a node needs the
  target's `callers` empty or naming `3pm:shell` with `app` `-` — else
  403 with a new code, `ERR_SHELL_NOT_ALLOWED` — and a `Bearer` from
  another site never opens it (`guard::BEARER_CLOSED`); `term` is 1–64
  characters of `[A-Za-z0-9._+-]` (422), the size clamped; refused 409
  while the backend stops. `nodeCall` with `op: shell` is a 400 that
  names this route. The reply `exited` settles the call (`ok` on a
  zero status, else `err` `failed` saying the code or the signal), and
  may come on a **later** link connection: one node's link closing no
  longer settles its in-flight shells (a restart still does); a shell
  whose two legs the relay has forgotten gets a one-minute deadline
  instead (`db::calls_shell`). **Audit** (`kind = shell` in the events
  store): the backend writes `open_requested` when the call is placed
  (`refused`, with why and no `call`, for an attempt it turns away once
  it knows the target) and `ended` when it settles — on the agent's
  answer, its refusal included, and on a `lost` / `unacked` settled by
  the sweeper or a restart; a shell the agent refuses has its two legs
  ended at once (`Registry::hang_up`), not at the relay's idle minute; the
  agent's `AgentEvent::Shell` events become `open`, `line`, `screen`,
  `close` lines, none of them rate-limited, and none of them on the
  bucket's feed (a node with `fleet:read` follows it). `eventsQuery`
  takes a `call` filter. Behind nginx the shell needs the block of
  FEATURES "Behind nginx" as it is: `proxy_http_version 1.1`, request
  buffering off and timeouts over a minute, or a quiet shell is cut.
- **`pipe` blobs: a stream that loses nothing** (the transport of the
  coming `3pm fleet ssh`). A third blob mode beside `file` and `live`:
  framed like `live`, but its window is trimmed only behind its reader
  and its `PUT` stops being read once 256 KiB ahead
  (`PIPE_WINDOW_BYTES`), so TCP carries the back pressure home instead
  of the window dropping messages (a message bigger than the window is
  let through to its end, or writer and reader would wait on each
  other). One reader, from the first byte,
  once (a second one is 409), memory only, reserved like a `live` leg.
  Opened in pairs by `Registry::open_pipe_session` (both legs charged
  to the caller, or neither). The sweeper: a leg whose one reader left
  is over on the next tick and takes its running twin (a twin that has
  finished is spared, so the end of the screen is delivered); a session
  one of whose legs is parked on its window is waiting, not idle, read
  or not (a reader that never came: 10 min); otherwise the `live`
  minute; never past a day. A given-up pipe cuts its readers at once
  and answers its `PUT` 408 even while the client sends nothing. At
  the door a pipe refuses another site (`Bearer` included), a `?k=`
  (`viewBlob` mints none: 403), `as=mjpeg` (422), and the bucket's
  account when it is not one of its two ends; it never goes direct. A
  backend that stops ends every open pipe, so a session's requests
  close instead of holding the graceful shutdown. `nodeShell` opens
  them (above); `live` and `file` blobs are unchanged.
- **Network traffic per app.** A 3pm node's per-app network figures
  (its daemon's meter, Linux and macOS) are stored:
  `threepm_process_net_received_bytes_total` / `…_sent_…` per app
  (`process.net.received` / `.sent`) and
  `threepm_subprocess_net_received_bytes_total` / `…_sent_…` per tree
  group, accumulated like the other group counters
  (`subprocess.net.received` / `.sent`). The feed's records carry
  `net` (`RedactedNet`: `r`, `t`, `rb`, `tb`, `pa`) and the tree lines
  `net` (`NetUsage`), both in `api.d.ts`. No dashboard display yet.
- **Top processes of a node.** A 3pm node's `top_processes` (the
  heaviest processes of the host by CPU, memory and network, on by
  default) is served live: `nodeHost` and the dashboard feed's host
  carry the table — unlike `extended`, which the feed drops. No series
  is stored per process (pids churn: unbounded cardinality); one
  freshness series, `threepm_host_sampled_top_at_seconds`
  (`system.sample.top`), says when the table last landed. Names are
  cleaned and cut again on the way in, and a table of more than 30 rows
  is dropped whole. `HostTopProcess` is in `api.d.ts`, with
  `footprint_bytes` (macOS nodes: the memory top is ranked on it, not
  on `mem_bytes`; Linux nodes keep the RSS) — live only, no series.
- **Extended Monitoring stored and queryable.** What a 3pm node with
  `[hostmetrics] extended` (on by default) sends is persisted:
  - **Host families:** one series per family, `threepm_hostx_<family>_total`
    (counter family) or `threepm_hostx_<family>` (gauge; an `Unknown`
    kind is stored as a gauge), `field` = the kernel's spelling, the row
    under the label that already means it (`core`, `iface`, `blockdev`,
    `array`; `cpuidle` `<core>/<state>` → `core` + `row`) or a generic
    `row`. A family, field or row off the grammar (name
    `[a-z][a-z0-9_]*` ≤ 48 bytes not ending in `_total`, values ≤ 64
    bytes without control characters) and non-finite values are
    skipped, never mangled. Rendered label blocks are cached per
    (row, field) and evicted like the others.
  - **Catalog 231 → 291 lines:** 42 `host.ext.*` (one per family, the
    three eBPF short-lived-process families included, each saying where
    its fields are documented), 16 `subprocess.*` (faults, context
    switches, disk and logical I/O, syscalls, threads, fds, fd usage,
    PSS, footprint, swap — present only where the platform gives them),
    `process.memory.pss` (the tree's PSS, absent unless every member's
    was read) and `system.sample.extended` (freshness,
    `threepm_host_sampled_extended_at_seconds`). `field` and `row` are
    `Custom` labels like `bat` — an application's own `field` label
    still works — and the inventory lists neither.
  - **Slow-tier counters rate over a floor:** a catalog line may carry
    `min_window_ms`; `host.ext.cpuidle` (read every 5 min) rates over at
    least 10 minutes, capped at the query's range.
  - **The live feed carries no `extended`** (~40 KB a Linux node at
    every tick, drawn by no dashboard): `nodeHost` and `queryMetrics`
    serve it; the store reads the full host.
  - Measured with a real 3pm on edgecr4ft.net (2026-09-29): a node with
    the mode on writes 2 436 series vs 663 off (+1 228 `hostx`,
    +~380–545 per-process series seen in a 2-min window, mostly a
    forking test app's children (~220 at steady state); +50–80
    `shortlived*` with a root agent); its link costs 777 B/s vs 529 B/s. No schema change; a
    node without the mode, or an older agent, writes what it did.
- **The install + link line.** `createToken` and `bucketToken` answer
  `install_line` beside `paste_line`: `curl -fsSL
  https://3pm.edgecr4ft.net/install.sh | sh -s -- link <cluster> <token>
  <server>`, which installs 3pm on a new host and links it (re-run, it
  updates and keeps the link). The bucket page shows it under *Link a
  host* with the bucket's permanent token (hidden when that token
  cannot be read); the settings page and a minted token show both lines.
- **Bucket settings: approval of new hosts can be turned off.** A page
  per bucket, `/b/{cluster_id}/settings` (linked from the bucket page),
  with one box: *Require approval for new hosts*. On — the default, and
  what every bucket did — a host linked with a reusable token is
  `pending` until `approveNode`. Off, every host that links with a
  valid token of the bucket is active at once: the token is the whole
  gate, which is what enrolling a batch of hosts with one reusable
  token wants. Turning it off approves the hosts already waiting, in
  the same transaction, each announced on the feed as a `node` event;
  turning it back on sends nobody back to the queue.
  - **New route `POST /api/buckets/{id}/settings`**
    (`setBucketSettings`), body `{ "approval": boolean }`, answers the
    `BucketView`. The owner's, guarded like every write; a body that
    names no setting, a wrong type or an unknown field is a 400.
  - `BucketView` gains `approval` (`getBucket`, `listBuckets`, the
    feed's `init`). A node reading the bucket with its own bearer does
    not get it: whether the gate is open is the owner's to know.
  - Schema 14: `buckets.approval`, default 1.
- **Bucket setting `require_access_rights`.** A box on the settings
  page, *Require nodes to specify access rights*, **on by default** —
  what every bucket did: a node asks of its bucket only what
  `setNodeAccess` granted it. Off, every **active** node of the bucket
  is treated as holding `fleet:invoke` and `fleet:list` (a target named
  by hostname is found in the bucket's list) for as long as it stays
  off — nothing to grant node by node, and a node that enrolls tomorrow
  talks to the others at once. Never `fleet:read`: hosts, metrics and
  the catalog of actions stay a grant of their own. The rows are
  untouched (`NodeView.scopes` still shows what the owner granted), so
  turning it back on takes back exactly what it gave, and both ways are
  felt on a node's very next request, with nothing to relink. A
  target's `callers` rules still narrow who may call it.
  `setBucketSettings` takes `{ approval?, require_access_rights? }` —
  a setting left out is unchanged, a body naming none is a 400 — and
  `BucketView` gains `require_access_rights` (the owner's, like
  `approval`: a node's bearer does not read it). Schema 16 added
  it as `open_calls` (default 0) and was deployed under that name;
  schema 17 renames it to `buckets.require_access_rights` (default 1)
  and carries each bucket's choice over, inverted.
  - A caller that holds no grant of its own is told its call settled
    (`call_result`) like any other: the setting is read from the
    database at that moment, not from a bucket row as old as the
    target's session.
- **A permanent enrollment token per bucket.** The settings page shows
  one `3pm link …` line that is always the same: the token never
  expires, enrolls any number of hosts, and no route renews or revokes
  it — what a provisioning script (`tabloid link --all`) wants to hold.
  **New route `GET /api/buckets/{id}/enroll-token`** (`bucketToken`,
  the owner's; never a node's bearer): `{ token_id, token, paste_line,
  created_at }`, created on the first read and identical at every read
  after. It is not pre-approved, like any reusable token: with
  `approval` on its hosts wait, with `approval` off it is the whole
  gate. `listTokens` shows it with `permanent: true` (never its
  secret) and `revokeToken` refuses it, 409 `ERR_CONFLICT`.
  - **Its secret is stored in clear** (`tokens.secret`) — the only
    token for which that is true, because it has to be read back;
    every other token is still kept as its hash alone. Whoever reads
    the database can enroll hosts into the bucket.
  - Schema 15: `tokens.permanent`, `tokens.secret`, one permanent token
    per bucket (a partial unique index).
- **A node can be deleted.** `Delete`, next to `Revoke` / `Approve` on
  the rows of the bucket page — every node but one that is **online**
  (deleting it would cut a working link its agent cannot mend by
  itself: revoke it, or wait for it to go stale) — after a
  confirmation, and **new route
  `DELETE /api/buckets/{id}/nodes/{node}`** (`deleteNode`, the owner's,
  `204`; `404` for a node the bucket does not know). The row goes, and
  with it its calls and its artifacts — rows and files; the calls still
  in flight on it are settled first, so whoever placed one is told at
  once. Its bearer is dropped, its live session is closed 4401
  `ERR_NODE_UNKNOWN`, and the hub forgets what it sent. Unlike a
  revoke, nothing is held against the key: the host may enroll again,
  as a new node. What the store recorded (metrics, events) is left to
  the store's retention.
  - **The feed says `reset`** — no new event kind. The hub publishes
    it and every subscriber's stream ends on it, exactly as for a
    subscriber that lagged: the client comes back for a fresh `init`,
    where a deleted node is sure not to be. A page written before
    nodes could be deleted follows it too.

### Changed
- **`require_access_rights` off now also opens `fleet:read`**: every
  node of such a bucket reads the others' hosts, metrics and catalog of
  actions, on top of listing and calling them (`db::effective_scopes`).
  A node that may call every action of every other gained little by
  being kept from reading them, and a caller (the `lan` module) checks
  what a peer declares before calling it. Turn the requirement back on
  to keep the fleet's map a grant of its own.
- **LAN direct on by default**: a node's `direct` setting defaults to
  `all` (a call's files **and** its live legs go agent to agent between
  LAN neighbours) instead of `files`. New nodes enroll at `all`; migration
  V20 turns every node stored at `files` into `all` (`off` is kept) —
  including a node an operator had set to `files` on purpose, which
  cannot be told from the old default: set it back with `setNodeAccess
  { "direct": "files" }`. An unknown or corrupt stored word reads as
  `files` (fail-safe). A live leg gone direct has no Watch preview
  ("direct · no preview"): an operator who wants the old behaviour sets
  `files` again the same way.
- **Process-tree series are stored by group, not by pid.** The
  `threepm_subprocess_*` series are keyed by `(app, instance, ns,
  proc)`: one set per executable name in an instance's tree, whatever
  the number of pids. The `pid` label is gone — a kiosk's shell loop
  (`sleep 3` in a loop) made ~17 000 pids a day per tablet, 708 000 of
  the 735 000 series created on 2026-10-01, and past VictoriaMetrics'
  daily cap every node loses its new series. Gauges are summed over the
  members (`pss`, `footprint`, `swap`, `threads`, `open_fds` only when
  every member gives them); `detached` is now a count; the ten
  `*_total` counters are accumulated per group by the backend, so they
  never go down when a member exits (a member is `(pid, start)`; one
  already running at the group's first sight is a baseline, so a
  backend restart reads as a counter reset; a group unseen for a minute
  starts again at 0). **Removed:** `subprocess.start_time`
  (`threepm_subprocess_start_time_seconds`) and `subprocess.fd_limit`
  (`threepm_subprocess_fd_limit`). **Added:** `subprocess.procs`
  (`threepm_subprocess_procs`, the members of the group) and
  `subprocess.fds.usage_max` (`threepm_subprocess_fd_usage_ratio_max`,
  the highest open fds / soft limit among the members). `pid` is no
  longer a query label: a query naming it gets a 400 (``label `pid` is
  neither built in … nor a valid custom label name``); it stays a
  reserved label key for applications. The old per-pid series stay in
  the store until retention (30 days): a range that spans the deploy
  shows the per-pid series before it and the group series after. The per-member detail stays live
  (`nodeProcessTree`, the tree panel). New module
  `store/tree_groups.rs`.
- **The link is at version 5: the file an action returns is uploaded,
  not sent as frames.** `blob_chunk` is gone from the wire, and with it
  the whole receiving half here — the per-session blobs on disk, their
  bounds and their sweeps (≈ 800 lines). Same flag day as link 4:
  **update the nodes first, this backend last.**
  - **New route `PUT /api/buckets/{id}/calls/{call}/artifacts`**
    (`putCallArtifact`, agent only). The answering node uploads the
    file's **raw** bytes with the bearer of its `welcome`, before the
    reply that names it, and gets back `201 {artifact_id, bytes,
    sha256}`. The bytes are gzipped and hashed (over the raw file) as
    they stream into the store, and the row is written **pending**: no
    read shows it until a reply claims it. Headers: `content-length`
    (required), `x-3pm-ext`, `x-3pm-instance`.
    Refused before a byte of the body is read — 404 (no such call, or
    one that has settled), 403 (a bearer that is not the call's
    **target**, an account), 400 (a header missing, an extension that
    is none, an instance the call never reached), 409 (a second file
    for one instance, a seventeenth for the call), 413 (`content-length`
    past what the action declared it may answer — `output.max_bytes`,
    bounded by 256 MiB; **32 MiB** when it declares none, the cap the
    chunks had — or past what the bucket still has room for, below).
    The refusal is answered **without reading the body**, so an agent
    whose file cannot be kept learns so on the first round trip instead
    of pushing 32 MiB for it. A body longer than it declared, or one
    that stops short, keeps nothing; neither does one whose call
    settled while it was arriving (the row is written under the very
    condition its reply claims it under, so a file nothing can claim is
    never a row).
  - **A bucket's artifact bytes are bounded before the write, not only
    after it.** `UPLOAD_BYTES_PER_BUCKET_MAX` (2 GiB, twice retention's
    1 GiB) refuses an upload the bucket has no room for. The per-file
    cap is the **node's own** declaration, so without this a node
    declaring `o: 256 MiB` and answering a sixteen-instance call writes
    4 GiB between two retention passes, and again on the next call.
    Schema **V13** brings back V8's covering index for the sum.
  - Half-written files cannot outlive their upload: the writing handle
    removes its `.tmp` when it is dropped unfinished, and a start
    sweeps `artifacts/**/*.tmp` for whatever a killed run left (every
    deploy, before this, leaked one per upload in flight).
  - **Claiming.** An outcome's `file` has to name an upload this call
    is holding, from this instance, with the size and digest the upload
    measured; anything else fails that outcome and removes the upload it
    named. The claim writes the outcome's app, source and action on the
    row — the upload only knew what the call's request said. Whatever
    is still pending when the call settles (`replied`, `unacked`,
    `lost`, abandoned with its link) is removed, file then row.
  - **One outcome type in both directions** (`Outcome` replaces
    `RemoteOutcome` and `SettledOutcome`): the node that placed a call
    now hears the **`file`** on its `call_result` too, and fetches it
    with its own bearer (`artifactFile`, which already admitted a node
    party to the call). `Payload` is gone — a `data` is inline or it is
    nothing: past the reply's 256 KiB of gzip the agent fails that
    outcome `too_large` rather than shipping bytes.
  - Schema **V12**: `artifacts.pending`. Rows written before it were
    stored by the reply itself, so they are claimed by definition.
  - A generated agent-only client method (`enroll`, `link`,
    `putCallArtifact`) now declares the route's path arguments before
    throwing: a method's arity is part of the contract.

- **The link is at version 4, and its sub-protocol is `3pm.link`.**
  One flag day, three changes to how the wire is typed and read —
  nothing of what it carries moved. A link-3 agent is refused at the
  upgrade (400: the sub-protocol no longer matches) or at the handshake
  (4426 `ERR_LINK_VERSION`), so **update the nodes first, this backend
  last**, then restart the agents refused during the window.
  - **The handshake is three frames**: `challenge` → `hello` →
    `welcome`. `proof` is gone: `hello.sig` is the Ed25519 signature
    over the challenge's nonce and the node id of the `Authorization`
    header, verified against the key on that row — the header is a
    claim and this is what makes it true. `hello` no longer carries the
    agent's name (a constant) or its node id (the header's, bound by
    the signature).
  - **`welcome.pace`** replaces `metrics_every_s` / `trees_every_s` /
    `state_every_s`, which had three meanings of "absent":
    `{state_s, metrics_s, trees: {mode}}`, every field mandatory, every
    period floored at 1 s on decode. The three flags are unchanged and
    are folded into it in one place (`Config::pace`): `--trees-every-s
    0` is `{"mode":"shape"}`, `n` is `{"mode":"figures","every_s":n}`.
  - **One enum per direction and a two-step decode.** The backend
    matches `AgentFrame` exhaustively (`ws::session::dispatch`,
    `calls::frames`) instead of ignoring a `_` arm, and `decode_agent`
    answers `Inbound`: a tag this build does not know is skipped, a
    **body** it cannot read is skipped and counted — neither ends an
    established session, where a frame body used to close it 4400. Only
    a message that is not a JSON object with a string `t`, or one over
    the cap, still does. **Before** the `hello` the peer has proved
    nothing (the `Authorization` header is a claim over two public
    ids), so there it is stricter: at most 8 such frames are skipped —
    said at `debug!`, counted nowhere — and the ninth closes the
    connection 4400.
  - **A `hello` whose nonce the store no longer holds is refused**
    `ERR_NONCE_INVALID` (4401). The challenge nonce was already taken
    once per connection; its verdict was dropped on the floor, so the
    code the spec documents could not be emitted. A nonce that lapsed
    or was swept while the handshake ran now ends it, whatever the
    signature verified against.
- **`GET /api/v1/info` gains `link: {frames_invalid, crc_full_mismatch}`**
  (`Info.link`, `LinkInfo` in `api.d.ts`): agent frames this backend
  accepted and could not act on, since it started. `frames_invalid`
  counts the bodies above; `crc_full_mismatch` the sessions in which a
  full state section failed its checksum — accepted as before, checks
  off for the session, but now said once at `error!` **with the section
  name** instead of a warning that named none, and counted. Both `0`
  means the fleet and this build agree on the wire.
- **Typed identifiers on the link.** `BlobId`, `CallId` and `Sha256`
  (`threepm-link`) replace the backend's own `blobs::BlobId` and every
  ad-hoc check of a blob name, a call id or a digest: the grammar is
  enforced by the type when the frame is decoded, so a `blob_chunk`
  naming `../etc/x`, a `call_result` with a digest of the wrong shape
  or an `output` that is not 64 hex characters never becomes a frame at
  all. The link's own `LinkInput` and `OutputBlob` replace
  `threepm_protocol::calls::{ActionInput, OutputRef}` where a call
  crosses the link — neither carries the `path` or the `token` that
  belong to the receiving host — and `call_result.results` carries
  `SettledOutcome`, which has no `file` and no blob `data`: what the
  backend cannot deliver cannot be typed. `blobs::BlobId::generate()` →
  `blobs::new_blob_id()`.
- **Enrollment is one `POST /api/v1/enroll`, and a node is no longer
  given a certificate.** The 3-frame WebSocket dialogue
  (`enroll_challenge` → `enroll` → `enrolled`) is gone: an agent posts
  `{cluster_id, token, node_pubkey, hostname, os, arch, agent_version,
  protocol, link_min, link_max, proof}` and reads back `{node_id, state,
  link, server_time_ms}`, or an ordinary `{"error":{code,message}}` with
  the code's status — 400 `ERR_BAD_REQUEST`, 401 `ERR_TOKEN_INVALID` /
  `_EXPIRED` / `_EXHAUSTED` / `ERR_PROOF_INVALID`, 403
  `ERR_NODE_REVOKED`, 426 `ERR_LINK_VERSION`, 429 `ERR_RATE_LIMITED`.
  `proof` no longer carries a server nonce (`3pm-enroll-v2\0<token_id>\0
  <pubkey>`): what bounds a replay is the token's use count, which was
  always true. The checks run in the same order as before and the use is
  still taken in the node row's transaction, so a failure never burns a
  one-use token and two racing hosts never both win the last use.
  `link` is the highest version the request's range shares with the
  server's, and `server_time_ms` is what the agent checks its clock
  against before it writes anything. The per-address budget is taken
  before a byte of the body is read, and the route is capped at 8 KiB.
- **The link upgrade names the node instead of presenting a
  certificate**: `Authorization: 3pm-node <cluster-id>:<node-id>`
  (`threepm_link::LinkAuth`). Both ids are public, so the header is a
  claim, not a credential — the key on the row is what the proof frame
  is verified against, as it already was. A row that is not there is
  401 `ERR_NODE_UNKNOWN` (new code, replacing `ERR_CERT_INVALID`);
  pending and revoked are 403 as before.
- **`nodes.caps` is what the host declared**, not what the backend
  granted: `hello.caps` (the owner's `link.toml` allow-list) is written
  to the row at every handshake, verbatim, and shown in `NodeView.caps`
  as the list it is — an empty one means the node accepts nothing, or
  has not linked since it enrolled. The backend grants nothing, and
  reads nothing into an empty list.
- The link is at **version 3** (`info.link_min = link_max = 3`): an
  agent speaking link 2 is refused, at enrollment with 426 and at the
  handshake with 4426, and must be updated. Nodes enrolled under link 2
  keep their id and key and link again without re-enrolling.

### Removed
- **The per-bucket certificate authority, node certificates and their
  serials.** `crypto/ca.rs`, `crypto/verify.rs` and `ws/issue.rs` are
  gone, with the re-issue in `welcome` (which no longer carries `cert`
  or `accepted_caps`), `--cert-ttl-days` /
  `THREEPM_BACKEND_CERT_TTL_DAYS` (the flag no longer parses),
  `createBucket`'s CA, and the `cert_serial` / `cert_not_after` /
  `cert_expired` fields of `NodeView` — with the dashboard's "Cert
  until" column. Schema **V11** drops `buckets.{ca_pub, ca_key,
  next_serial}` and `nodes.{cert_json, cert_serial, cert_not_before,
  cert_not_after}`; `node_id` and `pubkey` stay, which is all a
  handshake reads.
- The enrollment nonce store (`AppState.enroll_nonces`): there is no
  enrollment nonce any more. `crypto/nonce.rs` stays for the link
  handshake, `NonceLease` with it — the link's challenge nonce is now
  leased for the life of the connection, so an upgrade that never
  answers gives it back when its task ends instead of at the 60 s TTL.
- **A call's output is no longer copied into the artifact store.** The
  answer a target pushes to the call's output blob stays that blob:
  `getBlob` reads it until the relay's retention takes it, and nothing
  of it is kept for thirty days or counted against the bucket's
  gigabyte of artifacts. Gone with the copy: `output_artifact_id` (on
  the outcome, `getCall` and the feed's `call` events), the backend's
  SHA-256 check of those bytes — a well-formed digest that does not
  match is now relayed as the target announced it, for the caller to
  check when it pulls the blob (`output.{bytes,sha256,ext}` on the call,
  unchanged) —, the "too big" and "bucket full" cases, and the
  `artifacts_bucket_bytes` index (schema V9 drops it). An outcome that
  names another blob, a malformed digest or extension, or a second
  claim on the call's one output still fails. Files an action leaves
  and `data` past 256 KiB are still artifacts; `identity` rows already
  stored are still served by `artifactFile` until retention takes them.
  `calls/adopt.rs` → `calls/output.rs` (the reference check only).
- **The four older node-state streams, and link version 1.** `telemetry`,
  `host_patch`, `app_metrics` and `trees` frames are no longer read, nor
  `resync`, `trees_request` and `host_resync` sent: the `state` stream
  (below) is the only way a node's state comes in, and
  `threepm_link::NodeReader` the only patch reader. Gone with them: the
  per-stream readers and gap detectors (`NodeHost::apply` and its size
  bound, `NodeApps::apply`, `NodeTrees::apply`, the telemetry
  heuristics that asked for a full patch or a tree after a 5 s grace,
  the 100-per-frame caps), three of the session's four resync timers,
  `last_full` and the store's fallback on it, the `full` / `merged` /
  `tick` answers of `nodeHost.identity` (always `"patched"` now; the
  field stays), `TelemetrySnapshot.procs_dropped`, and
  `--host-patch-every-s` / `THREEPM_BACKEND_HOST_PATCH_EVERY_S` (the
  flag no longer parses). `--state-every-s 0`, which meant "the older
  streams", is refused at start: `1` at least. The link is at
  **version 2** (`info.link_min = link_max = 2`): an agent older than
  the `state` stream offers link 1 and is refused at the handshake
  with `ERR_LINK_VERSION` (close 4426) until its node runs `3pm update`
  (and `3pm restart agent`); a frame of a removed stream, should one
  come, is an unknown tag — skipped, never an error. The feed's frame
  types (`TelemetrySnapshot`, `FullPolicy`, `strip_static`) are the
  backend's own now (`hub::feed_snapshot`), no longer wire types. An
  instance's metrics and tree leave with their section (`gone`, or the
  agent restarted without it) and — a safety net — with their row of
  the process table, absent two frames in a row behind a daemon that
  answers; a state or a tree refused (caps, label grammar)
  drops what was held for the instance instead of leaving it behind its
  section. `hub/{host,apps,trees,node_state,registry_state}.rs`,
  `ws/{session,resync}.rs`; `tests/it/host_patch_e2e.rs` deleted,
  `app_metrics_e2e`, `trees_e2e`, `host_e2e` ported to `state` frames
  (`tests/it/support/state.rs`), link 1 refused in `dashboard_e2e` and
  `ws::handshake`.
- **Dead link code: `renew` / `cert` frames, `hello.last_seq`,
  `welcome.session`, the TLS pin.** None of it did anything: `renew`
  only counted as a sign of life and `cert` was never sent, `last_seq`
  was read and dropped, the session id was minted and used nowhere (the
  backend numbers its sessions itself), and no pin was ever set — so
  `tls_pin` leaves `GET /api/v1/info` (`Info` in `api.d.ts`) and the
  pasted line carries the bare cluster id, `3pm link 3pmc_<id>
  3pmk_<token> ws(s)://…`; `Advertise::tls` / `url` lose their `served`
  argument (a bare `host:port` is `ws://`). A certificate is renewed by
  `welcome.cert` at the handshake, and only there. A `renew` frame from
  an older agent is an unknown tag: skipped, counted as liveness.
  **Update the agents first**: an agent ≤ 0.5.0 needs `welcome.session`
  to decode the welcome, so it cannot hold a link to this backend.

### Fixed
- **A blob `PUT` refused mid-body answers its status rather than a
  reset.** A refusal found before the body (404, 413 on the declared
  length, 429) already read a bounded part of the rest before answering,
  so the sender could read it; one found while the body was arriving —
  over the blob's length (422), its bucket's quota, given up on (408) —
  answered at once. It now drains like the others: up to 16 MiB or 2 s,
  then `Connection: close`.
- **`viewBlob`'s `?k=` is documented as what it is: reusable for its
  minute, not single-use.** The module said "once"; a `<video>` asks
  for the same URL again for every `Range` it needs, so the token reads
  its one blob as often as it is presented within its sixty seconds, and
  a test now holds that.
- **An artifact upload no longer compresses on the server's async
  workers.** `putCallArtifact` and shell recordings gzipped and hashed
  every piece of the body on the task reading it: up to 256 MiB of gzip
  holding a runtime worker, and every task queued behind it. The bytes
  are now gathered into 256 KiB batches, each hashed and deflated on the
  blocking pool; the stored `.gz`, the digest and the answer are
  unchanged.
- **A blob written a few bytes at a time costs what it holds.** Every
  piece of an HTTP body was kept as a chunk of its own, holding on to
  the buffer it was read into, and a window counts payload bytes: a body
  sent a byte at a time made its 2 MiB window cost ~110 MiB. Writes
  under 1 KiB are now copied onto the window's last chunk while it stays
  under 1 KiB (a `live` stream's only within the same millisecond, so it
  still ages as before), which keeps a window's real cost within a tenth
  of what it counts.
- **A node that placed a call hears its `call_result` while it stays
  linked, even through a slow socket.** A result its session's queue
  had no room for waited five seconds and was dropped: the caller,
  still connected, only learned its call was over from its next
  `welcome` — after its own timeout, or never if it never relinked. The
  result now waits for a slot as long as the session lives (one stuck
  sending ends within `--silence-ms`, and its next `welcome` carries the
  call as before), bounded to 64 results and 8 MiB per caller node and
  64 MiB over all of them (`calls::held`); past that it is left to the
  `welcome`. Agents already handle `call_result`: nothing changes on
  their side.
- **A call placed as its node's session ends is refused, not left
  `sent` for its whole timeout.** A route read the session, stored the
  call, then handed its frame over: when the session ended in between,
  the frame went into a queue nobody read any more and the call, missed
  by the session's abandon, waited for its deadline (a day for a
  session). A session that ends now closes its queue before it leaves
  the hub (`ws::teardown`): a frame handed after that is refused (`409
  ERR_NODE_OFFLINE`, the call `refused`, or `unacked` when the abandon
  got to it first), and one queued before it is settled `unacked`.
- **A reply sent just before the agent closes is no longer settled
  `lost`.** Each `ack` and `reply` is stored by a task of its own, and a
  session that ended abandoned its calls right after reading the
  `Close`: a reply read first, still being claimed, lost to the abandon
  and its call was settled `lost` though the node had answered it. A
  closing session now waits for its call frames' tasks (2 s at most)
  before it abandons anything.
- **A node whose stored `callers` cannot be read is warned about once,
  not on every read.** The row is read on every request that names the
  node, so the warning repeated with each of them; it now names the
  node and is said once per node until the backend restarts.
- **The backend's memory no longer climbs for half an hour under
  load.** A fleet bench (calls, `fleet cp`, streams and shells between
  two nodes) took the backend from 25 to 57 MB over 30 minutes before it
  levelled off. Two bounded caches were filling: each bucket's feed log
  kept its last 4 096 events whatever their size — a node's `telemetry`
  is ~11 KB a second, so ~45 MB for a two-node bucket (16 MB of it in
  the bench's first 11 minutes) — and every relayed answer over 2 MiB
  kept its last 2 MiB in memory for its 15-minute retention, though no
  reader could ever be served from it. The feed log now also stops at
  8 MiB of events per bucket (a page reconnecting past it is told to
  `reset`, as past 4 096 events), and a `file` blob whose first byte is
  gone, or a `pipe` whose reader has been, lets its window go as soon
  as it has settled with nobody reading. The blob sweeper also drops an
  owner's creation instants once they are out of the rate's window.
- **A node's `state` frames are applied on its own lock, not the
  hub's.** The patches, the CRCs, the typed decodes and the telemetry
  rebuilt for a frame held the hub's one mutex, shared by every node,
  feed and route: a node sending large frames slowed everybody down,
  within its work budget. Each node's state and its host, metrics and
  trees now sit behind a lock of their own (`hub::node_side`); the hub's
  is taken only to touch the node before and to publish what the frame
  moved after. Readers (`nodeAppMetrics`, `nodeProcessTree`,
  `nodeHost`, the store's collect) clone the node's pointer under the
  hub's lock and read it after letting go, so the two locks are never
  held together; the hub-wide work budget is a leaf lock of its own.
  Two sessions of a node crossing publish only the later frame, and a
  frame landing on a node revoked meanwhile publishes nothing.
- **LAN direct no longer dials a neighbour through the `lan` overlay.**
  A node running the `lan` module reported its `3pmlan0` address
  (`10.77.x`) among its private addresses, first in interface order, so
  a LAN neighbour's direct leg went into the lan's own fleet sessions
  (4 Mbit/s per peer) instead of the host's network: 0.48 MB/s measured
  between two tablets on one Wi-Fi. `3pmlan` interfaces are now left
  out like container bridges, from the direct candidates and from the
  nodes table's `private` column.
- **`putCallArtifact`: uploads in flight share the bucket's budget, and
  have a clock.** Each upload checked the bucket's stored bytes alone,
  so sixteen arriving at once each fitted the 2 GiB budget on their own.
  An upload now reserves its `content-length` against the bucket, beside
  the stored files and every upload still arriving (413 past it), until
  its row is written; a second upload of the same instance of a call
  while the first arrives is a 409. The body is given up (408, nothing
  kept) at the call's deadline and after 60 s without a byte, so a node
  that opens an upload and goes quiet holds nothing.
- **A call a node placed that expires is told to that node.** The
  sweeper settled it `unacked` / `lost` for the feed only; the node now
  gets its `call_result` (or its next `welcome`), as for a call a closed
  link took — not its own deadline, a day away for a session.
- **Blob disk budget leaks on the relay's error paths.** A blob forgotten
  while its `PUT` still ran (its call abandoned or swept) released its
  file but left its writer going: the next piece the ring dropped opened
  a new file, counted against `--blob-disk-mb` until the process
  restarted. It is now expired, and its writer stops at its next piece
  (408). And a spill whose file could not be created, whose blocking
  task died, or whose run went while the bytes were being appended now
  gives back the budget it had taken for them.
- **Reconnects without a key can no longer park the fleet's agents.**
  Both ids a link upgrade names are public, and a full handshake-nonce
  store answered 429 `ERR_RATE_LIMITED` — which every agent takes as
  "back off for up to 15 minutes". The upgrade now takes a slot of its
  client address before anything else (`--link-pending-per-ip`, 64; an
  IPv6 client by its /64, enrollment's budget too) and gives it back once
  the handshake is over; past it, and when the nonce store is full, the
  answer is **503 `ERR_LINK_BUSY`** (was 429): a code no agent knows, on
  a 503, so every agent retries within its transient backoff (1–60 s).
  Behind a reverse proxy `--trust-proxy` is now required in practice,
  or the whole fleet shares the proxy's 64 slots.
- **One node can no longer hold the hub with its `state` frames.** A
  frame was applied patch by patch under the hub's one lock, so a frame
  repeating a 4 MiB section a thousand times — or a stream of them —
  made the hub walk gigabytes of JSON while every other node, route and
  feed waited. Now only the last patch of each section of a frame
  applies (the copies are counted in `dropped`); each section's size is
  kept exact by re-measuring the one section a patch moved (no more
  re-measure of the whole node, up to 32 MiB a frame); and the work a
  frame causes — the process table rebuilt for every frame, heartbeats
  included, each patch, and each read of a whole section it causes — is
  taken from a per-node budget (3 × 32 MiB a second, a burst of 4×) and
  then from one shared by every node (256 MiB a second). A frame over
  either is refused but for its `gone`s, its sections asked again in
  full, the node warned once a session; an honest node at every cap
  never comes near it. The pending `state_resync` names are capped at
  the sections a node may hold. And a session runs at most 32 `ack` /
  `reply` tasks at once: past them it stops reading its socket until one
  is done (an agent flooding unknown acks waits on itself), and its
  silence clock stops meanwhile, so a slow database never closes an
  honest session.
- **An unreadable `callers` column closes the node instead of opening
  it.** `nodes.callers` was decoded like `scopes`: anything that did not
  parse (a rule with a field this build does not know, a hand-edited
  row) read as the empty list — which for `callers` means *every node
  holding `fleet:invoke` may call every action*, `3pm:shell` included.
  It now reads as the documented deny rule `{"from":"*","app":"-"}`
  alone (`db::access::decode_callers`, a warning in the log): valid, so
  the Access panel still shows it and a `setNodeAccess` of the scopes
  alone writes it back; the owner reopens the node by writing the list.
- **A failing stream call no longer fills its bucket's blob quota.**
  Each `stream: true` call held its two `live` legs' full reservation
  (10 MiB each) until the idle sweep a minute later, so a caller retrying
  a failing stream action in a loop (prod 0.50: the `lan` module, ~1000
  calls) filled the 4 GiB quota and every `3pm fleet ssh` got 429
  `ERR_QUOTA`. Now a stream call answered as a failure (an `err`, or no
  outcome `ok`) or past its deadline gives every open leg back at once —
  charge and creation; a `PUT` still sending ends 408, a reader sees the
  end — and a link that drops gives back only the legs nothing touched
  (a session gone LAN-direct, or still carrying bytes, goes on). A
  finished output leg a caller is still draining keeps its tail: a
  handler that streamed then failed is read to its end. A call that ends
  well keeps its legs as before (`calls/legs.rs`). And an open `live` leg
  is charged what it holds — a 256 KiB floor plus its window and `INIT`,
  in 256 KiB steps — instead of 10 MiB from its opening; each write
  reserves what it may add before landing, atomically on the ledger, and
  a bucket with no room left drops the window's oldest messages first
  (counted as dropped or unwatched, like a full window), then refuses
  the write 429. Opening a blob checks and posts its reservation in the
  same step, so a growing leg cannot take the room it was admitted on.
  A shell's `pipe` legs keep their full reservation (`blobs/blob_charge.rs`,
  `blobs/quota.rs`).
- **A quiet pipe leg no longer cuts its streaming twin.** The sweeper
  gave up a `pipe` leg written once and then idle 60 s, and took its
  twin with it: a `-L` download whose client sent nothing after its
  request was cut at a minute. An idle leg now lives while its twin
  carries bytes (relayed, or reported direct with a counter that
  grows: a direct leg's reports alone, which come every 5 s whatever
  it carries, excuse nothing). Agents ≤ 0.45 that drop
  a forward's keyboard `GET` after its END still lose the tail through
  `reader_left`: no grace (a drained reader and a dead one look alike).
- **A recording's stop reason shows in `getCall`.** `summary` was
  stored as a bare sentence that `getCall` could not parse; it is now
  JSON, and a recording row stored before reads its bare sentence as
  that string (no migration). A node's recording that stopped early sends its reason as a
  code (`x-3pm-stopped`: `cap`, `gap`, `gap_end`), turned into the
  backend's own sentence — never the node's text.
- **`getCall` documents the agent's refusal code** (`result.code`:
  `e2e_required`, `e2e_broken`) kept beside `err.code`; the CLI reads
  it to tell the channel's refusals (exit 255).
- **A node's `agent_version` follows the agent, not its enrollment.**
  The column was written by `POST /api/v1/enroll` and never again, and
  an agent is updated without enrolling again: the bucket page showed
  `0.1.0` for a fleet running 0.23.0. `hello` already carries the
  version (it was only logged); `welcome_frame` now writes it to the
  row when it differs, as it does for `caps`, before the view the feed
  announces is read. A node shows its real version from its next link.
- **`last_seen_at` is the node's last frame, not the start of its
  session.** The row is written when a session attaches and when it
  goes, and the view served that value alone: a node online for two
  hours and sending 300 B/s read "2h ago". The hub's `last_frame_at` is
  now layered on the view (`NodeLive::layer`, so `listNodes`,
  `getBucket` and the feed's `node` events alike), the later of the two
  winning; an offline node, or a backend just restarted, still shows
  the row's value.
- **A blob gives its owner's open slot back when it settles, not
  fifteen minutes later.** The per-owner count of open blobs was only
  decremented when the registry forgot a blob, so a relayed file held
  its slot through its whole retention (`BLOB_TTL_MS`): a caller that
  downloaded forty outputs in a quarter of an hour was refused
  ("too many blobs open on this node") with nothing in flight. The count
  now lives on the blob ledger and drops at `finish` / `expire`, or at
  `forget` for a blob that never settled; it bounds transfers in flight
  only. Bounds raised with it: 100 open blobs per owner (was 40), 200
  opened per minute (was 32).
- **A call's unused output blob gives its reservation back as the call
  settles.** An output blob reserves 256 MiB of its caller's share of
  the bucket's quota (half of `--blob-quota-mb`: 8 outputs under the
  default 4 GiB), and a call whose handler failed before pushing its
  file (`EACCES` on a screen capture) held it until the 60 s idle sweep:
  two dashboard pages on one token, five captures each, was ten
  reservations in a minute and a burst of `ERR_QUOTA`. The blob is now
  forgotten the moment nothing can write it — a reply in which no
  outcome claimed it (an error, inline data only, a refused
  reference), a reply to a call settled otherwise meanwhile, a call
  abandoned with its closed link, and a call past its deadline — and a
  blob that carried nothing gives its creation back to the per-minute
  count as well. A session's `live` return leg is left to its session,
  and a blob someone is reading to the sweeper. `calls/output.rs::release`.
- **A call whose link closed no longer waits out its deadline.** An agent
  aborts the calls of the connection that delivered them and replays
  nothing, but the backend kept their rows `sent` / `acked` until the
  sweeper's deadline — a day for a stream session. After a backend
  restart, or when the target's link fell, the caller's `io.open`
  session never learned its call was over (a camera stream stayed dead
  until its sender was restarted). The calls a closed session carried
  are now settled at once, `unacked` or `lost`, with
  `result.err = {code: "lost", message}`: when the node's session ends
  (up to the moment a newer session of the node attached), and at start
  for every call the previous run left in flight. A linked caller hears
  its `call_result` right away, one that is not finds the call in its
  next `welcome.pending_calls`, and the feed hears the `call` event. A
  reply still being stored (claimed less than 60 s ago) is left to
  settle its call.
- **What a node's `state` stream left behind on the paths no test took**
  (review of the `state` reader, before its first release).
  *A daemon that stopped answering without restarting* made the agent
  send an empty table and — rightly — no `gone`; the backend aged every
  instance out after two such frames, and one whose section never moves
  (an instance that only declares actions, a tree in shape-only mode)
  never came back: the agent believed it delivered. Instances are no
  longer aged on a table that names no daemon, and an instance aged out
  takes its section with it (the next patch is a gap, answered whole).
  *An agent that restarted* never says `gone` for what went away while
  it was down: the sections of its former incarnation stayed in the
  reader for ever — a dead `host` still fed the store, and, 404
  sections later, every new one was refused without a word. A frame of
  another `gen` now forgets what it did not bring again, a 405th
  section is warned once a session. *A `host` section dropped over the
  size bound* left the typed host behind it. *A gap usually cost two
  full sections*: the patches an agent had cut before it read
  `state_resync` noted the section again; one that came in full
  meanwhile is no longer asked (`Resync::settle`). Also: one budget for
  all the sections of a node (32 MiB, next to 4 MiB a section), a
  revoked node keeps its view and none of its state, the process table
  is decoded when it moves instead of at every frame (heartbeats
  included), patches are measured without being written, the reader's
  refusals are what `telemetry.dropped` /
  `threepm_telemetry_dropped_total` count (they read a constant 0),
  `FullPolicy` is one function (`feed_snapshot::full_due`).
  `hub/{node_state,registry_state,apps,trees,registry,host,feed_snapshot}.rs`,
  `ws/{resync,session}.rs`; tests: `hub::node_state`,
  `hub::registry_tests`, `ws::resync`,
  `state_e2e::a_reconnection_keeps_the_node_and_a_restart_leaves_nothing_behind`.
- **A page served elsewhere can run a custom action.** The cross-site
  guard refused every guarded route to a request carrying another
  origin *whatever credential it brought*, so a dashboard on its own
  origin could log in and read metrics but got 403 `ERR_FORBIDDEN` on
  `nodeAction`, `getCall` and each mutation, and needed a server-side
  proxy to place a call. The guard is about an **ambient** credential
  (the session cookie); an `Authorization: Bearer …` is put on the
  request by the page that holds the token, never by the browser, so it
  now opens the guard from any origin on a route behind an account
  (`guard::Opens`, next to the existing `?k=` rule) and `auth` answers
  for the token — 401 for a bad one, never a fallback on the cookie.
  Still refused cross-site: the session cookie, `Basic` (a browser
  replays cached `Basic` credentials by itself), and `register` /
  `login` whatever they carry. `http/guard.rs`,
  `tests/it/cors_e2e.rs::a_bearer_writes_from_another_origin`,
  `tests/it/auth_e2e.rs`.
- Serving a call no longer needs `fleet:invoke`. `getBlob` and `putBlob`
  admit a node that is party to the blob (the call's writer or reader)
  whatever its row grants — membership was already the authorisation
  (`Endpoint::party_admits_a_node`, `auth::node_gate`), and a blob that
  is not the node's still reads as a 404. A target could not read the
  bytes sent to it without also being granted the right to call every
  node of its bucket.
- A blob that carried nothing gives its creation back to its owner's
  per-minute rate when it is forgotten (`blobs::Registry::forget`), so a
  call refused by a rule, by an absent node or by its target does not
  spend one of the thirty-two a minute. One byte written or one reader
  attached and it counts, as before.

### Added
- **Bytes between LAN neighbours.** When a node places a call carrying
  bytes on another node of its bucket and the two linked from the same
  public address, each announced a LAN listener (`hello.direct_port`)
  and each has a private address in its host, both tickets name the
  other end: `peer` (node id, public key, at most eight private
  addresses) on the placing node's answer and on the target's `call`
  frame. The two agents may then move the blob between themselves; the
  blobs stay open here as the fallback. The writer's `path_report` says
  which way it went: `direct` forgets the relay's blob at once (refused
  when the relay already carried a byte of it or has a reader on it),
  `relay` is only recorded; `path` on `getCall` and on the feed's `call`
  events. Per node, `setNodeAccess.direct` = `off` | `files` (default) |
  `all` (live legs too, both ends), shown on the node view; `--direct
  on|off` (`THREEPM_BACKEND_DIRECT`, default on) for the whole backend.
  Schema V10: `nodes.direct`, `calls.path`. An account's call, a call
  without bytes and a node calling itself never get a peer.
- **Live sessions between LAN neighbours.** With `direct: "all"` on both
  nodes, a session's legs go agent to agent like a call's file. On its
  writer's `path_report`, a leg gone direct stays in the relay's registry
  but is served to nobody (`404`) and reserves nothing — no quota, no
  open slot, its creation given back once, however often the report is
  repeated — so its session's twin rules read it like any other leg. Its
  counters and its liveness come from the writer's `blob_stat` frames
  (every 5 s, the latest figure kept, never going back): `live` on the
  call keeps counting, with `live.direct: true`, and the dashboard shows
  "direct · no preview" instead of the Watch button — the relay has no
  picture to serve. Silent for 15 s, the leg is over — an end and an
  abandon read the same — and it ends alone: its twin, if it went by the
  relay and is still writing, runs on (a short question, a long answer);
  one idle or never started ends with it.
- **A call's output blob reserves what the action declared.** The
  target's `output.max_bytes` (`io.action(name, {output: {max_bytes}})`,
  published as `AppAction.o`) is what the output blob declares, and so
  what it holds against its caller's share of the quota — the node's
  published word, never a size the caller sends, which could reserve
  less than the handler writes. Over what the call reaches the largest
  declaration wins; one that names none, a value outside 1..=256 MiB,
  or an action the node never published reserves 256 MiB, as before
  (`calls/output.rs::reservation`, `place.rs::declared_actions` shared
  with `declared_timeout`). The relay holds the handler to it: a `PUT`
  past the declared size is cut. `AppActionView.output_max_bytes`
  (`nodeAppMetrics`, `nodeActions`) shows the bound; the JS client is
  regenerated.
- **One patch reader: the node's whole state arrives as `state`
  frames.** The backend asks its agents (`welcome.state_every_s`,
  `--state-every-s` / `THREEPM_BACKEND_STATE_EVERY_S`, default `1`) for
  one frame a tick carrying what moved in
  the node — host, process table, application metrics, process trees —
  and rebuilds it with one `threepm_link::NodeReader` per node
  (`hub::node_state::NodeState`). A patch off its revision, of another
  incarnation or with a bad checksum is answered `state_resync {secs}`
  naming the section (at most once a second), the rest of the frame
  applied; a section past 4 MiB, or past 404 sections, is dropped and
  whole again at its next patch. What the frame moved goes, typed, to
  the holders every consumer already reads, and every frame — a
  heartbeat included — publishes the `telemetry` the feed always
  carried: the feed, `nodeHost`, `nodeAppMetrics`, the trees, the rings
  and the store keep their shapes. (The streams it replaces are removed
  in this release: see "Removed".)
- **The host arrives as a diff.** A node's host is rebuilt from what
  moved in it — the `host` section of its `state` frames (first
  delivered as a `host_patch` stream of its own with
  `--host-patch-every-s`, removed again in this release: see "Removed")
  — and held typed by
  `hub::host::NodeHost`. The store, the node view's addresses and
  accounts, `nodeHost` and the feed read the rebuilt host; the feed's
  `telemetry` keeps its shape (`host_full` / `host_tick` put in by the
  backend). `nodeHost.identity` is `"patched"`.
- **The feed takes a query** (`GET /api/buckets/{id}/events`,
  `http::feed_query`): `history=0` sends `init.history` empty,
  `match=<text>` keeps the nodes whose hostname contains `<text>` (in
  `init`, and on the feed as `node` events move them in or out),
  `view=live` cuts each `telemetry` frame to the host's gauges and a
  short row per process. Applied to `init`, the replay and the live
  stream after the audience's projection; without a query the feed is
  passed through untouched, and a value it does not know is 400. The
  generated clients take it after the handlers — `events(id, handlers,
  query)`, `follow(id, { query })` — so existing callers read the same,
  and send `history: 0` unless told otherwise. Measured on a bucket of
  nine hosts at one frame a second: 108 KB/s for the whole feed, 14 KB/s
  for `?history=0&match=envision&view=live`, 1.1 KB/s once nginx gzips
  `text/event-stream` (FEATURES.md, « Behind nginx »).
- **Batteries are stored.** `HostMetrics` grew a `batteries` section and
  an `on_ac` flag; both now reach VictoriaMetrics. Twelve gauges per
  pack under a new `bat` label (its sysfs name, `BAT0`):
  `system.battery.charge` (ratio), `.energy` / `.energy_full` /
  `.energy_design` (watt-hours), `.health` — the last two divided,
  **not clamped**, because a fresh pack legitimately reads above 1 —
  `.power` (watts, a magnitude), `.voltage` (volts), `.cycles`,
  `.temperature`, `.present`, and two codes for the two strings:
  `.state` is sysfs `status` as a **nominal** code (0 unknown, 1
  charging, 2 discharging, 3 not charging, 4 full, 5 a status this
  backend has no name for, the same `…_other` idiom as the process and
  TCP state maps) and `.level` is `capacity_level` as an **ordinal**
  (1 critical … 5 full). `system.on_ac` is node-scoped, not a pack's:
  one host, one answer, even with two packs on a dock. Absent stays
  absent throughout — no `status` file writes no state, an `Unknown`
  level writes nothing rather than rank itself below `Critical`, a
  missing sensor writes no temperature, and a full pack drawing
  `Some(0.0)` W writes a real 0. `technology` and `model` are not
  stored: a chemistry and a part number never move, and they are not
  worth the one label slot left under `MAX_LABELS`. New units
  `watt_hours`, `volts` and `state`; the catalog is 231 lines.
  `store/host/rows.rs`, `store/prefix.rs` (batteries are name-keyed and
  evicted like the mounts), `query/catalog/lines/host_rows.rs`,
  `store/host/tests_rows.rs`.
- `bat` is a `Custom` query label, not a built-in: a built-in must also
  be reserved against an application's own labels
  (`every_builtin_label_is_reserved`), and that reserved list mirrors
  the daemon's by hand — reserving a word here alone would have the
  store drop an application label the daemon had accepted.
  `QueryLabel::Custom` now holds a `Cow<'static, str>` so a static
  catalog line can name one in a `const`.
- **`just api` writes a third client: the Node SDK's.** The same route
  table, generated as CommonJS into the sibling checkout
  (`../3pm/packages/node/lib/fleet/api.{js,d.ts}`, published as
  `require('@3pm/api/fleet')`) — and skipped, with a message, when
  there is no `3pm` beside this one. One template feeds both: it marks
  its browser-only blocks (`//{{browser}}` … `//{{/browser}}` — the
  token prompt, the `EventSource` reader) and the Node build cuts them,
  so the browser client and the Node one cannot drift.
  `--check` covers the two new files like the other three.
- **A token prompt in the generated client.** `await connect({ baseUrl
  })` returns a ready `Client`, asking the viewer for an account token
  (`3pmu_…`) only when it has none remembered for this page — so a
  front page of your own, served from anywhere, needs no login code of
  its own. The card is drawn in a shadow root (the host page's CSS
  cannot deform it, nor its own leak out), follows the viewer's
  light/dark preference, and takes Enter to submit, Escape or a click
  outside to dismiss (rejecting with a `TokenPromptCancelled` error).
  Nothing is stored until the backend has accepted the token: the
  `3pmu_` prefix is checked for a useful message, then one `listBuckets`
  call — the cheapest authenticated read, and unguarded, so it answers
  cross-origin — decides. It is remembered under
  `3pm.token:<location.pathname>` (`connect({ namespace })` overrides
  the key), every `localStorage` access wrapped so a private window
  prompts each time rather than breaking. A 401 later on clears the
  token, reopens the card and retries the call that met it;
  `api.forgetToken()` clears it on purpose. `new Client(…)` is
  unchanged and no DOM code runs at import.
- The generated client is **browser-only** and now says so: `connect()`
  throws a message naming the SDK's fleet client in a runtime with no
  `document`. Nothing that worked under Node was removed — the claim of
  Node support was.
- **CORS, open on every route.** The backend sent no `Access-Control-*`
  header at all: a page on another origin could only reach it through a
  proxy that added them. A `CorsLayer` now wraps the whole router
  (`http::cors`, applied outermost in `http::router`): any origin, any
  method, the request headers a preflight asks for, response headers
  exposed with `*`, and a ten-minute preflight cache.
  `Access-Control-Allow-Headers` deliberately mirrors the preflight
  rather than sending `*` — the Fetch spec excludes `Authorization`
  from that wildcard, so `*` would have failed every `Bearer` call
  while looking permitted. No `Access-Control-Allow-Credentials`: it is
  forbidden with a wildcard origin, and the session cookie is
  `SameSite=Strict; HttpOnly`, so a cross-origin caller is bearer-token
  only by design. The cross-site guard is unchanged and still 403s
  `register`, `login` and every mutation carrying another origin —
  because the layer is *above* it, that 403 now carries the CORS header
  and reads as a 403 with `ERR_FORBIDDEN` instead of an opaque CORS
  failure, and a preflight is answered 200 instead of meeting the
  guard or the router's 405. `tests/it/cors_e2e.rs`.
- Two "Link two machines" recipes in `GET /api/v1/routes`: the
  `setNodeAccess` body that lets a node place calls (`scopes`), and the
  one that lets it call an app's actions on another node (`callers`),
  checked by the recipes test like the metrics ones. FEATURES "Node
  calls" shows the same thing with `curl`.
- `NodeView.user` and `NodeView.sessions`: the account a node's daemon
  runs as, and the accounts logged in on it, read off the node's last
  complete host snapshot (`hub::addrs`, beside the addresses) and
  layered onto the view — never stored, and never read from a tick,
  which does not carry `user` at all. A change of either announces the
  node on the feed the way a moved address does. `user` goes out with
  `fleet:list`, beside `peer_ip` and `private_ips`; **`sessions` needs
  `fleet:read`** — who is logged in on someone else's box is the one
  thing on a node view that is about people — and a node without that
  scope gets no key rather than an empty list, on its own row as on a
  neighbour's. The owner reads both. A node whose agent predates the
  fields carries neither. The dashboard's card prints `user …` in the
  line that already carries `public …` and `private …`.
- **Live streams.** `nodeAction` takes `stream: true` and opens a
  session of two `live` blobs, one each way, carrying framed messages
  (`KEY`/`END`/`INIT`, 1 MiB each at most, refused on their header
  rather than buffered). The window forgets whole messages, keeps the
  stream's `INIT` outside it for whoever joins, sends a viewer that
  falls behind on to the newest `KEY`, and never hands anyone half a
  message. `CallView.live` and the feed's `call` events count
  `bytes_in`, `bytes_out` and `dropped_out` from memory (`dropped_out`
  is what a viewer that cannot keep up costs: what the window forgets
  before anyone has connected is not one); a session ends on a minute's
  silence either way — a leg that has not started is excused while the
  other is alive — or after twenty-four hours, and reserves ten
  megabytes of its bucket's quota per leg.
  `CallInputView.bytes` is now optional and `CallInputView.live` says
  which it is.
- `viewBlob` (`POST /api/buckets/{id}/blobs/{blob}/view`): the owner
  mints the sixty-second `?k=` URL an `<img src>` can carry, with
  `{as: "mjpeg"}` for the rendering. `getBlob?as=mjpeg` serves a stream
  as `multipart/x-mixed-replace`, one `image/jpeg` part per message
  behind a fresh sixteen-byte boundary, `nosniff` and a `sandbox` CSP;
  a message that is not a whole JPEG is dropped without ending the
  response. The dashboard's call card shows a session's counters and
  its picture.
- **Node identity outside the WebSocket.** Every `welcome` mints the
  node a bearer for the HTTP routes (`3pma_…`, `Welcome.fleet_token`),
  valid for its session plus a ten-minute grace, kept in memory as
  `sha256(token) → nodes.id` and nowhere else. Fourteen routes accept
  it, each under a fleet scope granted per node by the bucket's owner —
  `fleet:list` (the bucket, its nodes, the feed, the per-server and
  per-app inventories), `fleet:read` (metrics, host, process trees, the
  catalogue of actions), `fleet:invoke` (the call routes). Default:
  nothing. Every request re-reads the node's row, so granting a scope
  and taking one away are both felt on the node's next request, with
  nothing to reconnect.
- `setNodeAccess` (`POST /api/buckets/{id}/nodes/{node}/access`): the
  owner grants the scopes and writes who may place a call on the node
  (`callers`, wildcards allowed except on an action of the `3pm:`
  prefix; each field at most 64 characters, at most 64 rules, unknown
  keys refused so a typo cannot widen a rule). Each list it names is
  replaced; the answer is the node's `NodeView`, which also goes out on
  the feed.
- `NodeView.scopes` and `NodeView.callers`, and `node` on every route of
  `GET /api/v1/routes`, `assets/api.json` and the generated client: the
  scope a node's bearer needs, next to `auth`.
- The dashboard's **Access** panel per node, and a badge per scope in the
  node table.
- Schema V5: `nodes.scopes`, `nodes.callers`, `calls.placed_by`, and an
  index on `calls(state, deadline_at)` — the sweeper scanned every call
  of every bucket once a second.
- Retention on `calls`: 30 days, swept with the artifacts.
- Blob relay: `putBlob` / `getBlob` (`PUT` / `GET
  /api/buckets/{id}/blobs/{blob}`), an in-memory registry, a 2 MiB
  ring per blob spilling into a single file only while a reader is
  behind it, byte-exact tailing, back pressure by window, quotas per
  node, per bucket and for the disk, sixty-second `?k=` URL tokens,
  and the codes `ERR_TIMEOUT` (408), `ERR_CONFLICT` (409),
  `ERR_TOO_LARGE` (413), `ERR_UNPROCESSABLE` (422) and `ERR_QUOTA`
  (429). Schema V6.
- **Node calls.** A node places a call on another node of its bucket
  with its own bearer: `fleet:invoke` on the caller **and** a `callers`
  rule on the target, which are granted apart. `nodeAction` takes
  `input` (`{bytes, sha256?, ext?}`), `output: true` and `from_app`, and
  answers `input.blob` / `output.blob` on the `CallView` — one blob each
  way, authorised by membership of the call and by nothing else, so
  neither application ever speaks to the network. `placed_by` (on
  `CallView`, on the feed's `call` and `artifact` events, in the row)
  reads `node:<3pmn_…>/<app>`, with `-` for a call that named no
  application. A call with an input names its `instance` (422
  otherwise), `deadline_at` allows for the transfer as well as the
  timeout (`transfer_budget_ms`), and `stream: true` is refused until
  the streams land. The target's answer, once its SHA-256 is checked
  here, becomes an artifact stored `encoding: identity`
  (`output_artifact_id` on the outcome, served by `artifactFile` without
  `Content-Encoding`); one too big to keep stays a blob. The caller is
  told on its own link (`call_result`, a bounded send, its scope
  re-read first), and `Welcome.pending_calls` hands it the calls it
  placed that settled while it was away — the last 24 hours, at most
  256, newest first, served by the index `calls_placed`.
- `CallView.result.ok` (the action ran: `res === "triggered"`, never
  whether every instance succeeded) and `CallView.output.{bytes,
  sha256, ext}`, filled in from the outcome that claimed the blob once
  the call is terminal: what lets the side pulling the answer check it
  against a digest instead of trusting the relay. The outcome keeps the
  reference it was given (`results[].output`) next to
  `output_artifact_id`, which the feed now names too.
- Schema V7: `calls_placed` on `calls(bucket_id, placed_by, sent_at)`,
  and `pending_calls` matches a half-open range on `placed_by` rather
  than `instr` so the index serves the filter and the order — every
  `welcome` scanned the bucket's thirty days of calls before, which is
  exactly what a reconnection storm does at once.
- **Node addresses** — the node view gains `peer_ip` (where the last
  link came from, the forwarded client behind `--trust-proxy`) and
  `private_ips` (the private addresses of the node's interfaces from
  its last `host_full`: RFC 1918, CGNAT, IPv6 ULA; container and VM
  bridges left out). A `node` event announces a change of either. The
  dashboard prints them next to each card's hostname (`public … ·
  private …`).
- **Behind a TLS proxy** — `--advertise` takes a URL whose scheme wins
  (`--advertise wss://fleet.example.com` → the pasted line ends with
  `wss://fleet.example.com:443`); a bare `host:port` keeps the old rule.
  An invalid URL stops the start, and outside `--dev` a clear-text
  advertise is warned about (agents refuse it to a remote host without
  `--insecure`). `--trust-proxy <ip>[,<ip>…]` believes those peers'
  `X-Forwarded-For` (rightmost untrusted hop; an unreadable one keeps
  the peer): the per-address enrollment budget and the link logs key on
  the real client instead of the proxy.
- **Accounts: login by token** — no password, no email. `POST
  /api/auth/register` (`register`) creates an account and returns its
  token once (`3pmu_<id>_<secret>`, only the SHA-256 stored) with a
  session cookie; `POST /api/auth/login` trades a token for a session
  (`3pm_session`: `HttpOnly`, `SameSite=Strict`, 30 days sliding,
  `Secure` with `--cookie-secure`), `POST /api/auth/logout` closes it
  and keeps the token; `GET /api/auth/me` (name, tokens without their
  secrets, bucket count), `POST /api/auth/tokens` (another token, shown
  once), `DELETE /api/auth/tokens/{token_id}` (closes the sessions it
  opened; never the last one, 409 `ERR_LAST_TOKEN`). Scripts send the
  token as `Authorization: Bearer` (or Basic, the token as the
  password: `curl -u :$TOKEN`). Every bucket belongs to an account
  (migration V4: `users`, `user_tokens`, `sessions`, `buckets.user_id`)
  and every route under `/api/buckets` wants one: 401
  `ERR_UNAUTHORIZED` without, 404 on another account's bucket (the
  answer a missing bucket gets). `--registration closed|open`
  (`THREEPM_BACKEND_REGISTRATION`): closed by default, the first
  account only (then 403 `ERR_REGISTRATION_CLOSED`), which adopts
  every bucket without an owner; `--dev` means open. The route table
  carries `auth` (`public` / `user`) into the descriptor, the
  generated map and the LLM index. Dashboard: a login card (paste a
  token, or create an account and copy its token), the name and "Log
  out" in the header, "Account tokens" on `/`. The generated client's
  option is `token`; with one, the feed is read through `fetch`
  (`EventSource` sends no header).
- **Remote actions** — `POST /api/buckets/{id}/nodes/{node}/actions`
  (`nodeAction`) runs an action an app declared through its SDK
  (`io.action`), on every instance of the app or on one, under the
  caller's `timeout_ms` or else the timeout the app declared (30 s when
  none); `POST …/nodes/{node}/calls` (`nodeCall`) sends `restart`,
  `stop` or `ping` (an `action` is refused, 400: use `nodeAction`); `GET
  …/calls/{call}` (`getCall`) follows a call from `sent` to `replied` —
  or `unacked`, `refused`, `lost` (new state: acknowledged, never
  answered by the deadline; the sweeper settles both) — with each
  instance's outcome and the handler's JSON. A node offline answers 409,
  a node whose queue is full 503 `ERR_NODE_BUSY` (the call is recorded
  `refused`). The feed announces every step (`call` events: state, ids,
  timings, each instance's ok and error code) and every file
  (`artifact` events: ids and sizes) — never params, results, messages
  or summaries, which only `getCall` and `listArtifacts` serve. Commands
  are now executed: the agent answered `unavailable` until this step.
- **Artifacts** — a result past the call's 256 KiB inline budget, or the
  file an action left (`3pm:profile`: a `.cpuprofile` / `.heapprofile`),
  crosses the link as `blob_chunk` frames, assembled on disk under
  `<data-dir>/tmp` (cleared at startup; a blob at most 64 chunks and
  32 MiB, a session up to 128 blobs open at once — 4 calls in flight ×
  16 instances × data and file — and 256 MiB of them together, so a
  fan-out or several profiles at once land every file), checked (count,
  size, SHA-256) and stored under `--data-dir` (default `<db>.d/`),
  never in SQLite; `GET /api/buckets/{id}/artifacts` (`listArtifacts`,
  `?node=`, `?app=`; the generated client takes them as
  `listArtifacts(clusterId, {node, app})`) lists them with a profile's
  hot frames (`summary`, computed just after the call is replied:
  `null` until it lands, usually within seconds), `GET
  …/artifacts/{artifact}/file`
  (`artifactFile`) streams the gzip with `Content-Encoding: gzip` and
  `Cache-Control: no-store`. Kept 30 days and 1 GB per bucket.
- **Dashboard** — an app that declares actions has them in a row
  right under it in the node card's process table (under its CPU and
  memory, as `3pm ls` lists it): its buttons per source (`CPU profile
  10 s`, `Heap profile 30 s` for `3pm:profile`, with a countdown; a
  params field for the rest). A click opens the **Action results**
  dialog on the call, followed to its end (fetched with `getCall` as
  the feed reports each step, JSON folded, "waiting for the node…"
  until then; the last 10 calls stay). The process table is kept by
  app id across frames, so the row and what is typed in it survive
  the one-second refresh. An "Artifacts" panel per node lists the top
  5 frames, raw and gzip sizes and downloads. The buttons and the
  artifacts panel run as the account that owns the bucket.
- **`GET /api/buckets/{id}/nodes/{node}/actions`** (`nodeActions`) —
  the actions the node's apps declare, per instance and source (name,
  description, timeout), nothing of the metrics: what the action block
  polls every 3 s for every card that is not offline, so the bar no
  longer depends on the "Custom metrics" panel being open.
- **`GET /api/buckets/{id}/nodes/{node}/host`** (`nodeHost`) — the
  host as it is now. A tick blanks the host's identity on the link
  (hostname, OS, kernel, arch, boot time, CPU brand and counts, totals,
  interface names, MACs and IPs, mount points, GPU models: 5 KiB a
  second saved), and only the first frame, an identity change and one
  frame a minute carry it whole (`host_full`); the route puts the last
  `host_full`'s identity back under the last tick's figures
  (`threepm_link::merge_static`, rows paired by position) and says
  where it came from (`identity`: `full`, `merged`, `tick`). 404 before
  the node's first frame.
- **Live JSON** — a button per node in the node table and a `JSON`
  button per app in each card open a dialog with the node's documents
  as the API serves them, refreshed every second: the host now
  (`nodeHost`, what the node button opens on), the last telemetry
  frame as the feed sent it, `nodeAppMetrics`, `nodeProcessTrees`,
  `nodeActions`, or one app composed from the three (its process
  record, its custom metrics, its tree); a selector narrows any of them
  to one app. The document is a collapsible tree (json-formatter-js
  2.5.23, MIT, vendored under `assets/vendor/`, loaded on the page —
  nothing from a CDN): the first level open, `►` to unfold, a hover
  preview, syntax colours, and what the reader unfolded kept across
  the one-second repaints; a **search** box cuts the document down to
  the keys and values holding the text, each with its parent whole
  and the keys above it, unfolded, the hit highlighted; **text** shows
  the raw JSON, a **stop /
  resume** button freezes the document to study it (a change of source
  or app loads that document once and stays frozen), and copy.
- **Events and Metric keys fold and filter** — both lists sit behind
  their title (closed by default, the count next to it) with a filter
  box: words, every one of which must appear in the line (a key's id,
  kind, unit, labels, source and description are matched); the count
  says what is shown of the whole.
- **SDK guide** on the bucket page (`assets/pages/guide.js`): how an
  app sends custom metrics, events and actions through `@3pm/api`
  (`io.gauge` / `counter` / `histogram` / `metric`, `io.event` /
  `notifyError`, `io.action` with a JSON result, parameters, a file
  result, a local-only one), how it is profiled (`3pm profile`, the
  buttons here, the API, opening a `.cpuprofile`), and the `3pm`
  commands to know (`start`, `ls --tree`, `show`, `events -f app`,
  `trigger`, `profile`, `link`) — every example naming this bucket, its
  first online node and its first app, coloured by highlight.js 11.12
  (BSD-3, vendored under `assets/vendor/hljs/` with the `bash`,
  `javascript` and `json` grammars — nothing from a CDN).
- **The action routes are guarded** — `nodeAction`, `nodeCall`,
  `getCall`, `listArtifacts` and `artifactFile` refuse cross-site
  requests, like every mutating route, and answer only the account
  that owns the bucket: they run code on the nodes and serve what it
  produced.
- **`proc:action` capability** — granted by default at enrollment;
  migration V3 grants it to every node still holding the earlier
  default (the three), and `welcome` re-issues the certificate of a
  node whose row and certificate disagree, so the capability is live at
  the next hello, not at the certificate's expiry.
- Flags: `--data-dir` (`THREEPM_BACKEND_DATA_DIR`), `--call-margin-ms`
  (`THREEPM_BACKEND_CALL_MARGIN_MS`, 15 000: over an action's timeout,
  before a call is settled here).
- `nodeAppMetrics`: each source lists the `actions` it exposes
  (`{name, description?, timeout_ms?}`).
- Events store: `kind: action` lines (the node's `proc_action` audit:
  app, instance, source, call, action, origin, ok, err_code, duration;
  a trigger the daemon refused as a whole comes without `err_code` nor,
  sometimes, `source`, and reads `failed`).

### Fixed
- **A call the target's agent refused no longer reaches the caller as a
  success.** An agent past its four commands in flight answers
  `unavailable`; the backend settled that as `replied` with no outcome
  and pushed `call_result` with `err: None`, so the caller read
  `{ok: true, results: []}` while `getCall` said `ok: false`. The error
  now travels in `Link::CallResult.err` and in `getCall`'s `result.err`
  (`calls/answer.rs::error_of`: `unavailable` → `busy`, retryable).
- **A settled call reaches its caller before the dashboard hears of it**,
  and in fewer database trips: `calls/settle.rs` split into `settle` and
  `announce`, claim + row + settle + calling node in one transaction
  (`db/scope_settle.rs::claim_reply`), bucket + node in one trip when a
  node places a call (`Db::scope_and_node`), and an inline result is
  relayed as the agent encoded it instead of decoded and re-encoded.
- **A finished `live` session gives its blobs back at once.** Both legs
  used to stay in the relay for 15 minutes after the session ended
  (`BLOB_TTL_MS`, meant for a `file` answer somebody may still fetch),
  each still counting against the node's 40 open blobs
  (`BLOBS_PER_NODE`) and its bucket reservation: twenty sessions in a
  row and the node read `429 ERR_QUOTA` "too many blobs open on this
  node" for a quarter of an hour. A session is now forgotten on the
  sweeper's next tick once both legs are over and nobody is still
  draining either one (`blobs::sweep::retention`); its `live` counters
  on `getCall` go with it. `file` blobs keep their retention.
- **One `live` stream was capped at about 4 MB/s over a link with
  latency.** Every accepted socket had its send buffer set to 128 KiB,
  to keep a relayed frame's latency at 20 ms instead of 800. Setting
  `SO_SNDBUF` does more than choose a size: it turns Linux's send buffer
  auto-tuning off for the life of the socket, pinning the bytes that may
  be in flight at twice what was asked. 256 KiB over the 60 ms round
  trip of a fleet link is 4.37 MB/s — and 4 MB/s of an offered 5 is what
  a hundred 50 KiB images a second measured under `tc netem delay 30ms`,
  with 18 % of them thrown away by the window behind a queue that stood
  at 200 ms. On Linux the buffer is now the kernel's to size and
  `TCP_NOTSENT_LOWAT` alone keeps the queue shallow, which is what it is
  for: it bounds what is queued **unsent**, not what is in flight.
  Measured end to end under the same `netem`: p50 216 → **61.0 ms** at a
  hundred images a second (p90 61.3, floor 60), 261 frames lost → 10 of
  1 485, and nothing lost at thirty. On the socket, 2 626 560 bytes of
  send buffer where there were 262 144 — up to about 43 MB/s a stream
  where the path permits it, a bound read off the socket rather than
  measured. Where
  there is no such knob (macOS, the BSDs, Windows) `SO_SNDBUF` is
  unchanged and so is the latency it bought. Nothing on the wire, in the
  API or in `--live-window-ms` moves; the agent's own outgoing socket
  carries the same change.
- **A `live` viewer the window ran past was left mid-message.** The
  reader was handed every piece from its position to the newest whole
  message and gave them to the socket one at a time, so between two of
  them its position was wherever a *write* happened to end — the middle
  of a message, as soon as a write is a TCP segment rather than a frame.
  Being sent on to the last `KEY` from there put half a message on the
  wire ahead of a whole one, and the decoder at the other end read four
  bytes of payload as a length: a session died on `frame of 2122219134
  bytes, at most 1048576` and never resynchronised. `?as=mjpeg` was cut
  the same way and is fixed with it: a browser watching a leg was handed
  half a message and its multipart reader lost the stream from there.
  Not seen on a loopback, where one write happens to be one message;
  under 30 ms of delay each way, where one message is thirty segments,
  it is a matter of minutes.

  A stream is now served one **message** at a time
  (`Ring::served_end_from`), so a reader that has drained what it was
  handed stands on a boundary, and the jump is held until then. Two
  things follow. A reader's backlog is now a message (1 MiB at most)
  where it could be a whole window (8 MiB), so a slow viewer costs the
  relay less memory, not more. And a reader is handed one piece per
  message rather than one per write — which on this chain is the same
  thing: an agent sends one body frame per message, so a chunk of the
  `PUT` carries one message and the window already held one per write.
  Measured end to end at 2 KiB and five hundred messages a second,
  latency and CPU do not move (p99 880 µs against 867, process CPU
  2410 ms against 2430, two runs each). The extra piece is only ever
  paid for by a writer that packs several messages into one chunk, and
  it costs that writer's reader 549 ns a message against 13. What the
  window drops and where a viewer rejoins are unchanged, and a 50 KiB
  frame at a hundred a second across two agents still crosses a bare
  loopback at 1.5 ms p99.

### Changed
- **The backend takes 3pm's release version.** `just publish` in
  `3pm-rd` writes it into `Cargo.toml` and into the version the
  generated clients carry (`assets/api.{js,json}`, the Node SDK's
  `lib/fleet/api.js`), so the backend, 3pm, the agent and the SDKs of
  one release share one number (was 0.1.0 whatever the release).
  `/api/v1/routes?version=` answers that number.
- **`fleet:invoke` is enough to place a call.** An empty `callers`
  list on the target used to accept nobody; it now accepts every node of
  the bucket holding `fleet:invoke`, for every action, `3pm:profile`
  included. `callers` only narrows: once it holds a rule, a call goes
  through when a rule admits it (and a `3pm:` action still has to be
  named in full there). Existing nodes with no rule become callable by
  every node holding the scope; `{"from": "*", "app": "-"}` closes one.
- **A node may call itself through the fleet.** `nodeAction` aimed at
  the caller's own node used to answer `422` ("a node does not call
  itself through the fleet"); it is now judged like any other caller
  (`fleet:invoke`, then the target's `callers`), and the bytes loop
  through the backend. Two applications of one host can hold a `live`
  session this way, which the daemon never opens locally
  (`tests/it/real_agent_streams_loopback.rs`: one daemon, one agent).
- `getBlob` is behind the cross-site guard, like every other read of
  what a call produced: a request whose `Origin` or `Sec-Fetch-Site`
  names another site reads 403 — **unless** it carries a `?k=` view
  token, which is a minted credential and not an ambient one, and is
  what an `<img src>` has. A browser tag and a `curl` are unaffected;
  the session cookie driven from a sister host of the same registrable
  domain no longer is.
- A node may hold at most **half** of its bucket's blob quota
  (`--blob-quota-mb`): past its share it reads `429 ERR_QUOTA` and its
  neighbours keep their room, where one blob of `--blob-max-mb` used to
  take the lot. `viewBlob` tokens are capped per bucket (64) as well as
  for the process, so one account's minting no longer expires another's.
- The dashboard's pages carry `Content-Security-Policy: frame-ancestors
  'none'` and every asset carries `X-Content-Type-Options: nosniff`; the
  `events` feed carries `X-Accel-Buffering: no`, which D10 always asked
  of it. A blob that was given up on now **fails** its readers' bodies
  instead of ending them cleanly, so a viewer can tell a cut stream from
  a finished one; and a `PUT` refused before its body is read gets its
  status back rather than a connection reset.
- Two scopes, two answers to the same routes: with `fleet:list` a node
  reads every node's identity and its process table and its own grant;
  `fleet:read` adds the hosts, the daemons, the per-process history and
  the applications' `emit` events. It never reads a neighbour's grant.
  `getBucket`, `listNodes` and the feed (`init`, `node`, `telemetry`)
  are projected by one rule (`http::Audience`); an account reads
  everything, as before.
- A node's own calls only, everywhere they are told: `getCall` answers
  404 and `artifactFile` 404 for a call its node did not place,
  `listArtifacts` leaves them out, and the live feed drops their `call`
  and `artifact` events — the fleet's own state (`init`, `node`,
  `telemetry`, `emit`) goes through as before. The owner of the bucket
  still sees all of it.
- `Caller` carries `account: Option<Account>`: a node is an identity of
  its own, not a stand-in for the owner.
- The route table is assembled from one module per resource
  (`api/surface/table/`), concatenated at compile time — the count is
  asserted during const evaluation, so a group cannot grow unnoticed.
- Three socket options on every accepted connection, the same three the
  agent already set on its own outgoing one: `TCP_NODELAY`, so a small
  last write no longer waits on an ACK (up to 40 ms a hop); `SO_SNDBUF`
  at 128 KiB and, on Linux, `TCP_NOTSENT_LOWAT` at 64 KiB, so the
  kernel's invisible send queue stays shallow and a relayed frame's
  latency is bounded by the link rather than by the backlog. Setting
  `SO_SNDBUF` explicitly also turns Linux's auto-tuning off for that
  socket, which caps one connection at roughly 8 MB/s over a 30 ms
  link: the deliberate trade of D11, now made on both ends of the
  fleet's data path.
- **`BucketDetail.advertise` carries its scheme** — `ws://127.0.0.1:9616`
  or `wss://fleet.example.com:443`, the address the pasted line ends
  with (it was the bare `host:port`); the bucket page shows it as is.
- **Breaking: `--admin-token` is removed** (with
  `THREEPM_BACKEND_ADMIN_TOKEN`, `BucketDetail.admin_token_set` and
  `http::admin::ENFORCE_TOKEN`): accounts replace it, and passing the
  flag now fails at startup instead of leaving an operator believing
  the backend is protected. `http/admin.rs` is `http/guard.rs`, the
  cross-site refusal alone. Reads under `/api/buckets` (lists, the
  feed, metrics) are no longer open: they want the owner's login too.
- `calls.state` admits `lost` (the table is rebuilt by migration V3, rows
  kept).
- **`GET /api/buckets/{id}/metrics/inventory`** (`metricsInventory`):
  what a dashboard or an LLM starts from, in one call, shaped for few
  tokens. `tokens` comes first: `{ claude, o200k }`, what the answer
  costs an LLM under Claude's tokenizer (estimated from its bytes with a
  ratio measured per shape) and OpenAI's `o200k_base` (exact) — the
  same pricing as `GET /api/v1/routes`.
  `metric_groups` are the catalog lines the store holds at least one
  series for over the last two UTC days (a histogram by its `_count`),
  grouped by the labels they carry, with nothing empty or derivable: no
  `source` (the `app.` prefix says it), no `default_time` equal to the
  kind's, no empty unit or description; an application line lists the
  apps whose series carry it (`apps`). `label_values` are what every
  other label of those lines holds (`window`, `mount`, `iface`, `src`,
  an application's own labels…; not `node` / `app` / `instance` / `ns`,
  which `hosts` gives, nor `pid`), at most 50 values each, `more`
  counting the rest. `hosts` are every enrolled node
  with its processes (`{ app, instance, ns? }`, read from `process.up`,
  label values ready for a query's `filters`). The text opens with an
  `about:` line saying how the lists join: every series carries `node`
  = a node_id, and `hosts` names it. It answers TOON-style
  `text/plain` by default — a header per list with its count and
  fields, a row per entry — and the JSON with `?format=json` or an
  `Accept` naming `application/json` (the generated client's);
  `?detail=index` leaves the descriptions out. On a real bucket (3
  hosts, 15 processes, 10 labels' values): 14 873 tokens as JSON, 9 732
  as an index, 12 436 and 7 657 as text (before `label_values` and
  `apps`, 13 282 as JSON against 20 807 for the same content in
  `metricsCatalog`'s shape with a flat process list). The stored names
  and the processes are read side by side
  (`/api/v1/label/__name__/values`, `/api/v1/series`), then each listed
  label's values and one series read of every application series but a
  histogram's buckets (none without an application line), all scoped
  twice and cached a minute; 400 on an
  unknown parameter, 409 without `--metrics-url`, 503 when the engine
  is down.
- **`GET /api/buckets/{id}/metrics/servers`** (`metricsByServer`): the
  inventory by server — which metric is stored on which server, for
  which app, where `metricsInventory` lists the lines of the whole
  bucket at once (a Linux-only line, or an app's line on one host only,
  looked available everywhere). `{ tokens, metric_groups, servers }`:
  `metric_groups` describe the placed lines once, as the inventory
  does (without `apps`); `servers` is keyed by node_id, each `{
  hostname, os, arch, state, metrics?, apps? }` — `metrics` the ids of
  the host's own lines stored there, `apps` keyed by app name with the
  ids its series carry on that server (`process.*`, `subprocess.*`,
  `app.*`), catalog order, nothing empty; a node only the store knows
  comes last, `state: "unknown"`. Present means a series over the last
  24 hours. Text by default (`servers:` then `<app>[<n>]: <ids>`
  lines), JSON when asked, `?detail=index` as the inventory. One
  instant query answers for the bucket, `count by (__name__, node, app)
  (last_over_time({cluster=…}[1d]))` — `last_over_time` because
  `present_over_time` drops the series name — scoped twice, cached a
  minute: 0.1 s for 586 rows on the dev bucket. On it (3 servers, 12
  apps across them): 17 731 Claude tokens as text, 12 530 as an index,
  20 052 and 14 427 as JSON. `MetricsReader` gains `placements`.
- **`GET /api/buckets/{id}/metrics/apps`** (`metricsByApp`): the same
  placements read app first — each app with the servers it has series
  on and, on each, the ids those series are: `{ tokens, metric_groups,
  hosts, apps }`, `apps` keyed by app name (sorted), each keyed by
  node_id in the order of `hosts` (`{ node_id, hostname, os, arch,
  state }`, every enrolled node, then a node only the store knows).
  Only an app's lines (`process.*`, `subprocess.*`, `app.*`) are
  described and listed; a host's own lines are left to
  `metricsByServer`. Text by default (`hosts[n]{…}:` rows, then
  `apps:` with `<node_id>[<n>]: <ids>` lines), JSON when asked,
  `?detail=index`; no new read — the instant query and its cache are
  `metricsByServer`'s. On the dev bucket (11 apps on 3 servers): 4 983
  Claude tokens as text, 4 031 as an index, 5 281 and 4 240 as JSON.
  The bucket page links both (**by server**, **by app**, next to
  **inventory**).
- **Process trees: every process of an app, live and stored.** The
  agent sends each app's process tree (3pm's `3pm ls --tree`) on the
  model of the application metrics — the shape when its membership
  moves, the figures every `--trees-every-s` seconds (1 by default,
  `0` for the shape only; `welcome.trees_every_s` on the wire) — and
  the backend keeps the last one per instance and incarnation. Two
  routes read it: `GET /api/buckets/{id}/nodes/{node}/trees`
  (`nodeProcessTrees`: every instance with a tree, each with its lines
  and the folded rows `3pm ls --tree` draws, computed by the same code,
  plus the `refused` count) and `GET …/procs/{proc}/tree`
  (`nodeProcessTree`: one instance, 404 while the backend holds no
  tree for it — running alone, or before its first tree). The
  dashboard's process table writes `+N` after an app with other
  processes (`proc_count` on the record) — a click opens the card's
  new **Process trees** panel, which polls the node once a second and
  draws the folded tree (chains on one row, twins as `×N`, `⚠` on a
  process `3pm stop` cannot reach), then every process. The store
  writes one series set per member — `threepm_subprocess_cpu_ratio`,
  `_memory_rss_bytes`, `_start_time_seconds`, `_detached` on the app's
  labels plus `proc` and `pid` — and the count as
  `threepm_process_procs`; the catalog lists them (`subprocess.*`,
  `process.procs`) and `proc` / `pid` join the reserved label keys.
  Nothing goes on the feed. An instance the telemetry shows with more
  than one process and no tree for its incarnation after 5 s is asked
  for one (`trees_request`, once a second per node at most, and again
  every 5 s while it stays missing — except one the node has no room
  for, already holding 100 trees: it is not asked for a tree it would
  refuse, and is asked as soon as a place frees up, never more of them
  at once than there are free places, a request in flight holding its
  place for 5 s), and so is one
  running alone while
  its tree is still held — the agent then sends it empty and the
  backend drops it — and one whose held tree is of another incarnation
  past the grace (a restarted daemon numbers its gens from 1 again); a
  frame and a node are held to 100 trees, a tree to 256 lines and
  64-byte names, and a tree for an instance the telemetry does not list
  is refused — each counted; an empty tree takes no place and is never
  refused for the node cap (a full node would otherwise count one per
  new instance at every session start); member names are cleaned again on the way
  in (control characters, bidi overrides, line separators blanked). A
  tree alone over the message cap is left out by the agent and asked
  again through the telemetry, like an application-metrics patch. The
  store writes a member's
  figures only within a minute of the frame that carried them, so
  `--trees-every-s 0` yields a point per member after each shape frame
  rather than a frozen value.
  `ProcessTreesView`, `ProcessTreeView`, `TreeProc` and `CompactRow`
  in the client's types, `proc_count?` on `RedactedRecord`.
- **`GET /api/v1/routes` documents the query routes well enough to
  write one.** A route whose body is a small language of its own now
  carries `details` next to its one-line `doc`: for `queryMetrics`,
  what every field of `range` / `queries` / `formulas` / `options`
  means, which time function goes with which metric kind, what the
  answer holds, and a runnable example body; for `eventsQuery`, the
  same in short. A new top-level `limits` block gives the numeric
  bounds by route name — range, points, series (and their defaults),
  queries, formulas, filters, values, value length, body size —
  straight from `query::validate::Limits`, the constants the validator
  refuses on, so they cannot drift from the code the way the comments
  had. The step floor is not among them on purpose: it is the store's
  resolution, and `step_floor_from` points at `store.interval_ms` of
  `GET /api/v1/info`. `details` is spliced into `assets/api.d.ts` as
  JSDoc (`assets/api.js` keeps the one-liner), and the client example
  the descriptor ships gains a metrics and events section. Two tests
  hold it: `limits` is keyed by routes that take a body, and each
  documented example is parsed and run through the very validation its
  handler applies — an example that rots fails the build.
- **The route descriptor says what it costs to read, and reads well to
  an LLM.** `GET /api/v1/routes` answers text by default — the JSON
  table with `?format=json`, or for an `Accept` naming
  `application/json`, which the generated client sends: an index of
  one TOON-style row per route a caller can use (name, method, path,
  body, answer, admin, doc), the routes that are not for an LLM
  (pages, the client module, the agent's WebSockets) as a short list,
  and how to read more. `?route=<name>` is one route in full: its
  `details`, its bounds, and the TypeScript of its body and answer with
  every type they name, JSDoc included, read from the generated
  `api.d.ts` — the shapes an LLM had no way to see from the table. The
  client example stays in the JSON. Every answer opens with its price,
  `tokens: { claude, o200k }`: `o200k` counted exactly with OpenAI's
  `o200k_base` (`tiktoken-rs`, ranks embedded), `claude` estimated from
  the bytes with a ratio measured per shape through Anthropic's
  `count_tokens` API — Claude counts about 1.5× more than `o200k` here,
  so neither stands in for the other. Priced at a fixed point (the
  field included), once per process; `src/llm.rs` holds the counting,
  the TOON cell and the negotiation for every LLM-facing answer. The
  index also gives `metricsInventory`, `queryMetrics` and `eventsQuery`
  whole, their types once — what a dashboard starts from, in the first
  read — after a `## Recipes` section: a body for each common question
  (the value now, a total over a period, the top N, a percentage by
  formula, a counter's rate, one host by name, one label value, the
  error events) and how to read its answer, then the rules no type says
  (legends carry `node` = a node_id, no value predicate, a bool gauge
  reads per instance, an `app.*` metric may come from several apps).
  Every recipe body is run through its route's validation by a test.
  The table costs 8 497 Claude tokens as JSON, 10 741 as the index
  (1 876 for its lists alone), 750 to 3 900 per route in full.

- **Every numeric field of `HostMetrics` is persisted.** The store used
  to write 14 host series out of the ~370 numbers a host agent
  collects; it now writes all of them, and the catalog grew from 49
  entries to 212. New families, one module each under `store/host/`:
  the CPU per logical core and by state (`core` label, the state in the
  name), the kernel counters, the `/proc/meminfo` decomposition, the
  page-fault counters, the VFS cache, the entropy pool, macOS's memory
  compressor, PSI (`window` = 10/60/300 seconds, as the load averages
  use it in minutes), processes and TCP connections by state, the TCP
  and socket counters, conntrack, file handles, the daemon's cgroup,
  the NTP offset, then one series set per network interface (`iface`),
  block device (`blockdev`), temperature sensor (`sensor`), GPU (`gpu`,
  by index) and RAID array (`array`), and how fresh each sampling tier
  is. Six built-in query labels come with it — `core`, `iface`,
  `blockdev`, `sensor`, `gpu`, `array` — reserved in 3pm first, so an
  application cannot claim one and lose its own series.

  Nothing changed on the wire: every number already rode each tick.
  What a tick blanks (`strip_static`) is read from the last full
  snapshot alone, and a test holds it — a stripped tick must write
  exactly what its full snapshot writes.

  The per-interface counters are new names
  (`threepm_host_interface_*`); the summed `threepm_host_network_*`
  pair is untouched, so no existing chart moves. Mounts are no longer
  capped at the eight largest and no longer filtered on `removable` /
  `read_only`: `system.disk.*` now covers every mount point, so a
  `sum by (node)` over it will read higher than before.

  `max_series` goes from 100/500 to 200/2 000 and the label-values cap
  from 1 000 to 5 000 — a chart of one host family across a fleet needs
  the room — and the writer keeps four requests in flight instead of
  two, since a node now fills several times more of a body.
- **3pm's own metrics, persisted.** A node's `HostMetrics.3pmDaemon`
  (what the host agent measures of the 3pm daemon and of itself, 3pm
  `dfc5ada`) is written as 27 series on the node's labels:
  `threepm_daemon_{cpu_ratio,memory_rss_bytes,open_fds,threads}` and
  `threepm_host_agent_{…}` for the two processes, and the daemon's
  counters — apps by status, restarts, timers, bus subscribers, log
  sinks/rotations/bytes, `RLIMIT_NOFILE`, handoffs, agent respawns.
  The role and the status are in the name, not in a label, so nothing
  competes with an application's own `role`/`status`. Catalog ids
  `daemon.*` / `host_agent.*`, in `GET …/metrics/catalog` and the
  dashboard's table. A tick the daemon did not answer (mid-`3pm update`)
  skips the counters, never the process rows. The JavaScript client's
  `HostMetrics` type gains the `3pmDaemon` section.

### Internal
- The integration tests are one binary, `tests/it` (a module per former
  file, `support` compiled once), instead of forty-three: a change to
  the library relinks one test binary. `just test` and `just e2e` run
  under `cargo nextest` (`.config/nextest.toml`, the real-agent tests
  four at a time), and `just test` takes a filter
  (`just test 'test(/^auth_e2e::/)'`). Dev builds keep line tables only
  and build dependencies without debug info at `opt-level = 1`.
- Fleet RPC, simplification pass, no behaviour change: the two tests of
  a parked writer share one mount, the two linked nodes of the test
  support read a `call` frame through one function, and the unread
  `Registry::disk_crossings` is gone (the counter it wrapped is still
  read through `counters.crossings()`).

### Changed
- **The process series are those of the app's whole process tree.** A
  node running a 3pm with process trees reports an app's `cpu_percent`
  and `mem_bytes` as the sums over the spawned pid and every descendant
  (an `npm run dev` that starts Electron and its helpers goes from
  `0% / 71mb` to `62% / 3.0gb`); the agent relays them as before, so
  `threepm_process_cpu_ratio` and `threepm_process_memory_rss_bytes`,
  the fleet table, the history rings and every chart on them carry the
  tree's totals without a change here. Memory is the members' RSS
  summed, an upper bound when they share pages. Only the catalog says
  so: the two descriptions (`GET …/metrics/catalog`, the **Metric keys**
  table) name the tree instead of "one process". A node on an older 3pm
  keeps reporting the spawned pid alone; the two look the same on the
  wire.

### Fixed
- The five routes still documented as needing `--admin-token`
  (`nodeAction`, `nodeCall`, `getCall`, `listArtifacts`, `artifactFile`),
  the same claim in the feed's own documentation (which also said the
  feed needed no token at all), and the last mention of the flag in the
  dashboard: it was removed with the accounts.
- `options.max_series` was documented as "≤ 500 per result, default
  100" in `assets/api.d.ts` and in `query/mod.rs`; it has been 1–2 000
  with a default of 200 since the host-metrics work. Both now say so,
  and `limits.queryMetrics` serves the numbers from the validator
  itself.
- The step arithmetic the new `details` and the `.d.ts` quote said "30
  days fits at `30m`" and "`5m` fits about 7 days" — true only at
  `max_points: 2000`, not at the default 600, where the same 30 days
  draws at `2h` and `5m` reaches two days (and 7 days at `5m` is 2 018
  points, past 2 000 as well). Corrected, and pinned by
  `the_documented_step_arithmetic_is_what_the_validator_does`: prose is
  the one part of the descriptor no generator checks.
- A query on an application counter compiled the series without the
  `_total` the store appends (`rate(threepm_app_x{…})` found nothing);
  the dynamic catalog now names a counter `threepm_app_<name>_total`,
  and a row loaded with the old name is rewritten at the next flush.
  Found on the SAW rig (plan §8): `app.host.processed_callbacks`.

### Added
- The bucket page ends with **Metric keys**: every key the bucket
  holds from `metricsCatalog` — kind, unit, default time function,
  labels and origin (`static` / `app`) — applications first, counted
  next to the title, asked again every 30 s while the tab is visible.
  Rendering and escaping in `tests/dashboard_render.rs`.
- `tests/real_agent.rs` gains the chain for real (plan §10): an
  application on the Node SDK (`@3pm/api` from `../3pm/packages/node`)
  under a real `3pm`, the real agent linked, this backend writing to a
  fake engine — the labelled counter and the histogram's `vmrange`
  buckets in the store's lines, the catalog, `nodeAppMetrics`, a
  quantile query explained, the event in the store and back through
  `eventsQuery`. Run by `just e2e`; skipped without `node`.
- Application metrics (step D of
  `3pm-app-metrics-plan.md` in `~/3pm-rd-doc-2026-09-09.tgz`): the
  agent's `app_metrics` patches are applied per instance in the hub
  (`threepm_link::patch::apply`, keyed by process id and incarnation),
  a patch off its base or a telemetry record ahead of the state for 5 s
  is answered with `resync` (coalesced, one frame a second per node);
  the store writes `threepm_app_<name>` / `_total` /
  `_bucket{vmrange}` + `_count` + `_sum` with `app`, `instance`, `ns`,
  `src` and the app's labels re-validated; `GET
  /api/buckets/{id}/nodes/{node}/app-metrics` (`nodeAppMetrics`)
  serves the live state; the catalog gains the bucket's `app.<name>`
  lines (learnt at each flush, persisted in the new `app_metrics`
  table — schema V2 — 5 000 per bucket, purged after 30 days) with
  `MetricKind::Histogram`, custom labels, `time.fn: "quantile"`
  (`histogram_quantile` over `sum(rate(_bucket)) by (vmrange, …)`),
  `source` on every catalog line, and `options.explain` →
  `results[].expr`. `welcome.metrics_every_s` from
  `--app-metrics-every-s` (1). `info.store.catalog_over_cap`.
- Application events (`AgentEvent::App`) become log lines: `kind:
  "app"`, `_time` = the app's clock (bounded to ± 5 min of the
  backend's), `received_at`, `app`, `instance`, `src`, `event_name`,
  `level`, `severity_number`, the attributes flat under `a.`,
  `exception.type` / `message` / `stacktrace`, `fingerprint`, `repeat`.
- `POST /api/buckets/{id}/events/query` (`eventsQuery`): the persisted
  events, newest first — a range, exact filters, a limit — compiled to
  LogsQL in the store (`store/victoria/logsql.rs`), read on
  `/select/logsql/query`; 409 without `--events-url`. The writer's
  event buffer is bounded (20 000, drops counted); a node may emit 100
  application events a second (burst 500), the feed forwards 50 a
  second per bucket (`info.feed_app_events_dropped`).
- Dashboard: a "Custom metrics" panel per node card (polls
  `nodeAppMetrics` every second while open) and the "Events" list
  completed from `eventsQuery`; application events described with
  their level, name, repeat and exception. `tests/dashboard_render.rs`
  runs the panels under `node` with `<img onerror>` in every app
  string.

### Changed
- `validate` takes the bucket's catalog (`&dyn Lookup`); `SpecPlan.metric`
  is owned; `QueryLabel` is no longer `Copy` (`Src`, `Custom(name)`);
  `MetricsReader` gains `explain`; `SampleSink` takes a `&str` name and
  a `text` method for blocks rendered with `vmrange`; `store::spawn`
  takes the catalog's `Persist`; `Hub::telemetry` returns the instances
  to resync.

- The metrics query is cached and coalesced: the engine's answer to
  each spec is kept for a step (5 s to 60 s) under `--store-query-cache-mb`
  (64; `0` turns it off), keyed by the request itself, so the same chart
  refreshed within a step by any number of viewers is one request to the
  engine — and identical requests in flight wait for the first one's
  answer instead of leaving too (`src/store/cache.rs`). Label values are
  cached a minute. `GET /api/v1/info` → `store.cache` reports entries,
  bytes, hits, misses, coalesced.
- Every JSON route is gzipped when the client asks (`Accept-Encoding:
  gzip`; `tower-http`, fastest level): a full chart answer, tens of MB
  of numbers, shrinks about tenfold. The feed and the sockets never are.
- Charts read the persisted metrics through `POST
  /api/buckets/{id}/metrics/query` (`queryMetrics(clusterId, body)` in
  the client): a JSON query — a range and a step, queries naming a
  catalog metric with filters, a time function and an aggregation,
  formulas over their refs (`A / B * 100`), options (`max_points`,
  `max_series`, `sort`, `fill`, `mode: summary`) — validated against
  the catalog (unknown field, unknown metric, a label the metric lacks,
  `rate` on a gauge, a bad regex, a range past 31 days… are 400
  `ERR_BAD_REQUEST` naming the field) and compiled in the backend,
  scoped to the bucket twice (a matcher and `extra_label`), the engine
  asked for the `max_series + 1` best series by `sort` over the whole
  range; the answer is one `times[]` and numeric `values[][]` per
  series with a summary. `step` is a duration string or milliseconds.
  `GET …/metrics/catalog` lists what a query may name (OpenTelemetry-
  style ids, kind, unit, labels, default time function);
  `GET …/metrics/labels/{label}/values` the values a label takes.
  `--store-query-concurrency` (8) bounds the chart queries in flight,
  whatever the number of engine requests each fans out to; 409
  `ERR_STORE_DISABLED` without `--metrics-url`, 503
  `ERR_STORE_UNAVAILABLE` when the engine is down, busy or slow. The
  engine's query language never crosses the API: `src/query/` is
  engine-agnostic (a test holds it) and `src/store/victoria/` compiles
  the plan.
- The telemetry is persisted: `--metrics-url` (VictoriaMetrics) and
  `--events-url` (VictoriaLogs) name the engines, `--store-interval-ms`
  (5 s) the stored resolution. Every interval the store takes the last
  frame of every node out of the hub (one lock, pointer clones —
  nothing runs per frame) and writes one sample per series as gzipped
  Prometheus text: `threepm_process_*` (cpu ratio, rss, up, restarts,
  start time) with `cluster`, `node`, `app`, `instance`, `ns`;
  `threepm_host_*` (cpu, load, memory, swap, processes, uptime,
  network counters summed over the physical interfaces, the largest
  mounts); `threepm_node_info` / `threepm_node_up`. Process lifecycle
  events go to the logs engine as NDJSON. Retries 1 s / 2 s / 4 s on
  429, 5xx, timeouts and connection errors; two flushes queued at
  most, the oldest dropped whole; a hung engine never holds the
  shutdown. Without the flags nothing changes. `GET /api/v1/info.store`
  shows the counters (`sent`, `dropped`, `errors`, `retries`,
  `last_error_kind`, `last_flush_at`).
- A JavaScript client for the whole API, generated from the backend's
  own route table. `src/api/surface.rs` declares every route once
  (`name`, method, path, `kind`, `guarded`, argument names, body and
  response types, doc); `http::router` mounts exactly that table, and
  `just api` writes `assets/api.js` (one ES module: a method per
  endpoint, `ApiError` carrying the backend's `ERR_*`, and a
  `Subscription` that reconnects on its own and turns a `reset` into a
  fresh `init`), `assets/api.d.ts` and `assets/api.json`. Same module
  in a browser and in Node — `new Client({baseUrl, adminToken})`, with
  `fetch` / `EventSource` taken from the global scope or passed in.
  `just check` fails when what is committed no longer matches the
  table.
- `GET /api/v1/routes[?version=]`: that table as data, live. `version`
  defaults to `latest`; `latest` and the backend's own version answer
  the same table, any other is a 404 naming the version this build
  does serve. `Client.discover()` reads it and follows that backend's
  paths from then on, so a page built against one version keeps
  working against another. The document also carries `client {url,
  types, descriptor, example}` — where the module and its types are
  (relative, so they resolve behind any proxy) and a runnable snippet,
  so fetching this one document is enough to get started.
- `GET /api/v1/client.js`: the client under the version of the API it
  calls — the URL to import from elsewhere. `/assets/api.js` serves
  the same bytes; `.d.ts` files are now served as `text/plain` instead
  of `application/octet-stream`.
- Both dashboard pages carry an `API routes` / `JS client` nav.
- The client tells a page where its live data is. `Client.follow(
  clusterId, {signal, onError})` is the SSE feed as an async iterable
  of `[kind, payload]` (leaving the loop or aborting the signal closes
  it; a `reset` is an item followed by a fresh `init`), over a generic
  `Client.iterate(name, args, options)`. Generated like the rest: an
  `sse` entry of the table names its iterator twin (`iter`), and the
  twin takes the feed's own arguments. `docs/examples/follow.mjs`.
- The client knows which backend it was generated from: `VERSION` is
  exported, `discover()` compares it with the answering backend's
  `version` and reports a difference to `onVersionMismatch(backend,
  client, api)` — a `console.warn` by default, `null` to silence, any
  function to show it (the dashboard appends `client generated for
  <version>` to its header). `client.versionMismatch` says it after
  the fact. The client follows the backend's table either way.
- Requests can be abandoned: every generated method, `call()` and
  `discover()` take `options` last, and `options.signal` (an
  `AbortSignal`) cancels the request — it rejects with `fetch`'s
  `AbortError`, not an `ApiError`. `request()` takes the same
  `{signal, retry}` in place of its bare `retry` flag.
- The two agent WebSockets (`enroll`, `link`) say what they are:
  their generated methods throw an `AgentOnlyError` naming the
  sub-protocol and the feed to read instead, and their route docs
  point at `events`.
- The descriptor groups the live surface: `realtime {feed: {route,
  transport: "sse", eventKinds, iter}, agent: {routes, transport:
  "websocket", subprotocol, min, max, specPath}}` points into `routes`
  by name (the table itself stays whole), replacing the top-level
  `link` and `eventKinds`. `specPath` is a workspace path, not a URL —
  the backend serves no copy of the protocol doc. The client exports
  the same block as `REALTIME`, and `LINK` is its agent half.
- The two ways around the route table are closed:
  `no_route_is_mounted_outside_the_table` refuses a route mounted by
  hand in `http::router` (it would be absent from `/api/v1/routes` and
  from the client, and no other test would see it — they all walk the
  table), and `every_type_the_table_names_is_declared` refuses a
  `returns` / `body` naming a type `types.tmpl.d.ts` does not declare
  (nothing here compiles TypeScript, so a typo would otherwise only
  break in a consumer's editor). The rule itself moved into
  `CLAUDE.md`, next to the other non-negotiable ones.
- `tests/api_surface.rs` holds the table to the running server: every
  entry is mounted on the method it declares (an undeclared verb
  answers 405), every `guarded` entry answers 401 without the admin
  token and no unguarded one does, `/api/v1/routes` matches
  `assets/api.json`, the generated files are current, and every field
  the API serializes is declared in `api.d.ts`.

### Changed
- The store renders the label block of a series once, at first sight,
  and keeps it across flushes (`src/store/prefix.rs`: per node, per
  process, per mount, per load window; unseen for twelve flushes, it
  goes) — a flush is one `push_str` per line instead of a sorted label
  set per process; and the bodies of a flush are gzipped side by side
  when there are several. `just bench`, 10 000 nodes: render 146 → 68 ms,
  render + gzip 193 → 84 ms.
- Release builds use one codegen unit and thin LTO: the hot loops (the
  store's render, the engine's answer parsed) swung by 20 % between
  unrelated changes with the default partitioning; pinned, and the
  benchmarks measure the code rather than the luck.
- The engine's `query_range` answer is parsed borrowed: label names and
  values point into the body, the points go straight to numbers through
  a visitor (`store/victoria/points.rs`) — no string per point where a
  full answer (500 series × 2 000 points) used to allocate a million.
  `parse_matrix` on that answer: 51 ms → 33 ms (`just bench`,
  `query/parse_matrix`); the rest is serde_json reading 29 MB of JSON.
- A body the JSON layer refuses on `POST /api/buckets` and `POST
  /api/buckets/{id}/tokens` is a 400 `ERR_BAD_REQUEST` in the API's
  error shape (the one sentence axum has about it), not axum's own 422
  text — the same answer the metrics query already gave. An absent body
  on `/tokens` is still the defaults.
- A time function's `window` takes milliseconds as well as `1m`, like
  `range.step`. A series' `summary` (what `sort` ranks by) is over the
  points the engine returned, before `fill`, so the `max_series` cut
  here is exactly the engine's; `count` is those points.
- The store's shutdown counts every sample: the sends in flight finish
  within the 2 s budget while the queue and one last collect go out with
  one attempt each; whatever the budget cuts off is counted `dropped`
  (before, an abandoned request's samples were in neither `sent` nor
  `dropped`). The writer's freshness is a flag per node, set by a
  `telemetry` frame and cleared by the collect, instead of comparing
  the frame's arrival time with the writer's own clock — a frame
  recorded between the two readings can no longer be missed.
- The link logs name the agent's address: `node <id> (<hostname>)
  linked from <ip>:<port>: <agent> <version> boot <boot_id>` when a
  session opens, the same `<ip>:<port>` on every line that ends one
  (left, silence, refusal, ordered close), and `link refused from
  <ip>:<port>` when the certificate never gets past the upgrade.
- The dashboard is written on the generated client and driven by the
  route table it serves. `assets/app.js` imports the client from
  `/api/v1/client.js`, calls `discover()` (`GET /api/v1/routes`) and
  hands the client to the page; from then on every path comes from
  that table by name — bucket links (`api.url("bucketPage", id)`), the
  bucket page's own argument read back from the `bucketPage` template
  (`pathArgs`), the feed opened under the name `realtime.feed.route`
  gives, the `API routes` / `JS client` / `types` links re-pointed at
  `client.{descriptor,url,types}`. No URL and no `fetch` of its own;
  both pages load it with `<script type="module">`. Split into
  `ui.js` (an escaping `html` template tag, the formatters, `reason`,
  `pathArgs`), `pages/index.js`, `pages/bucket.js` (the feed, tokens,
  approve / revoke, the event log) and `pages/fleet.js` (`Fleet`: the
  nodes' rows and cards, patched one node at a time). A `reset` no
  longer reloads the page — the client reopens the feed and redraws
  from the new `init`. The header names the backend that answered
  (`3pm-backend <version>`, from the descriptor) on both pages.
- The dashboard is dark: `assets/style.css` keeps the same tokens with
  a near-black ground, `color-scheme: dark`, and status colours
  lightened to stay legible on it.

### Added
- Per-node receive traffic. Every WebSocket message from an agent is
  counted in payload bytes as it arrives (deflated frames, pings and
  pongs; before inflation): `NodeView` gains `rx_bps` — the average
  over the last 5 complete seconds — and `rx_bytes`, the total since
  the backend started (across sessions of the node). The sweeper rolls
  the rate once a second and publishes a `node` event when it moves;
  `GET /api/buckets/{id}`, `GET …/nodes` and the SSE `init` carry both.
  The bucket page shows an `Rx/s` column in the nodes table (the total
  on hover) and `rx …/s` on each node card.

### Changed
- Extracted from the 3pm repository (`3pm-backend/` there, history
  kept) into its own repository. The shared crates are path
  dependencies on a sibling checkout (`../3pm/crates/…`); `just
  backend` / `just backend-dev` became `just check` + `just test` /
  `just dev`, and the real-agent test runs with `just e2e`.

### Added (while inside the 3pm repository)
- Skeleton, SQLite layer, bucket CA, tokens, nonces, `GET
  /api/v1/enroll`, buckets/tokens API with the pasted line.
- Ingestion: `GET /api/v1/link` admission, handshake machine, session
  task, hub (registry, presence `online → stale → offline`, rings,
  per-bucket event log, sweeper), SSE feed, `GET /api/buckets/{id}`.
- First dashboard (embedded `assets/`), node API (`GET /nodes`, `POST
  /approve`, `POST /revoke`), `--admin-token` and the cross-site guard,
  enrollment rate limits, header timeout, 1 MiB frame caps.
