# Fleet link: `3pm agent` ↔ `3pm-backend`

The fleet link connects a 3pm daemon to a central backend: the daemon's
host enrolls once, then a supervised agent app streams the redacted
process table and host metrics over one WebSocket, and (later) accepts a
small whitelist of remote commands. This directory documents what
exists after phases A, B and C of the plan — the wire contract, the
shared crate, the agent module, and the backend up to ingestion, the
first dashboard and the node API. It is the reference for phase D;
`FEATURES.md` carries the user-facing summary, `CHANGELOG.md` the
history.

| Document | What it covers |
|---|---|
| [link-protocol.md](link-protocol.md) | The wire contract: identifiers, enrollment, link handshake, the two frame vocabularies and how they decode, commands and calls, events, the node's state (`state`) and what never travels, framing/keepalive/retry classes, error codes, fixtures |
| `viking://resources/3pm-docs-archived/backend-agent/threepm-link/` (OpenViking; left the tree 2026-09-09) | `crates/threepm-link`: the pure shared crate, file by file, with its public API |
| [agent.md](agent.md) | `modules/agent` (git submodule, repository `3pm-agent`): CLI, environment, state directory, the supervised process, file by file, tests, caveats |
| `viking://resources/3pm-backend-docs-archived/backend/` (OpenViking; left the 3pm-backend tree 2026-09-09) | The backend: configuration, HTTP API and SSE feed, enrollment and link endpoints, the hub, database, crypto, file by file, tests, caveats |

## Shape of the system

```
3pm daemon ── rpc.sock / pub.sock ── 3pm-agent (__run, supervised app, wait_ready)
                                          │  WebSocket, sub-protocol 3pm.link
                                          │  agent → server: Binary, raw DEFLATE (one stream per connection)
                                          │  server → agent: Text JSON
                                     3pm-backend (axum, SQLite)
                                          │  HTTP JSON API (buckets, tokens, nodes) + SSE feed
                                     dashboard (`/`, `/b/{id}`; calls next)
```

Layering is one-way: `threepm-core ← threepm-protocol ← threepm-link ←
{modules/agent, 3pm-backend}`. `threepm-link` has no I/O, no clock and no
randomness; both endpoints share its vocabulary and its table-driven
tests. The local NDJSON protocol is nested, never re-modelled: a remote
`restart` becomes exactly one local `Request::Restart`.

## Status by step

| Step | Deliverable | Commit |
|---|---|---|
| 1–3 | `crates/threepm-link` (ids, envelope, errors, records, telemetry, commands, events, certs, proofs, pins, backoff, server URLs, frozen fixtures) | `cd894e4` `b0c0344` `daec6ec` |
| 4–6 | `3pm-backend` skeleton, SQLite layer, bucket CA, tokens, nonces, `GET /api/v1/enroll`, buckets/tokens API with the pasted line | `de8a6d3` `6e08f8b` `e63c8bd` |
| 7 | `modules/agent` skeleton: CLI, inputs, options, state directory, `status`, `unlink`, registration everywhere the bundle is assembled | `68472d5` |
| 8 | Agent enrollment: identity, transport, enroll dialogue, `link` end to end against a mock backend | `1f98191` |
| 9 | Agent session: handshake, single writer/reader, reconnection schedule, polite close | `9162b10` |
| 10 | Agent telemetry: 1 Hz redacted process table + host snapshot, lifecycle events as `emit` | `9879080` |
| 11 | Backend 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}` = `init` | `82d9401` |
| 12 | First dashboard: embedded `assets/` (buckets, bucket page with the pasted line, nodes, per-node process table and raw `telemetry` JSON), `http/pages.rs`; `tests/it/real_agent.rs` — a real 3pm links to the backend and the feed shows its processes | `c693b5c` |

| 13 | Node API: `GET /nodes`, `POST /approve`, `POST /revoke` (cuts the live session 4403), `NodeView` complete (`cert_expired`, `last_auth_error`), approve / revoke buttons on the bucket page | `21f8722` |
| 16 | `3pm link` / `3pm unlink` built-in sugar over the `agent` module (arguments passed through, the agent's help and exit code, `--json` via `THREEPM_JSON`, reinstall hint without the module); the pasted line runs as printed; `tests/real_agent.rs` goes through it | `eeb7c56` |
| review | Adversarial review of phases A–C (security, protocol, concurrency, tests) and its fixes: backend silence timer, close drain, fresh view after welcome + post-attach re-check, shutdown that cannot hang, own enrollment nonce store + per-IP budget, one-transaction enrollment, 1 MiB caps before inflation, header timeout, asset path guard, cross-site guard + `--admin-token`; link mirror enums with `Unknown`, frame budget, handshake tolerance, bare 4403; agent shutdown during connect, bus backoff, bounded ping, enrollment skew check; the missing tests on every side | `92654ce` `fad300c` `fd3505b` |

Phase C is complete and, from phase D, the `3pm link` / `3pm unlink`
sugar (step 16, pulled forward). Not built yet: remote commands (14),
TLS on the
backend (17), the final docs pass (18). Since then the backend moved to its own
repository (`3pm-backend`, next to this one); its `just e2e` runs the
real-agent test against a debug 3pm built from `../3pm`. The backend
also meters what each node sends (`NodeView.rx_bps` / `rx_bytes`,
counted in payload bytes before inflation, the rate averaged over the
last 5 complete seconds and shown as `Rx/s` on the bucket page); what a
node weighs on the link (≈ 350–460 B/s, everything included) is in
[link-protocol.md § 7 Volume](link-protocol.md#volume). A linked host now
also sees its link above the process table (`3pm ls` opens with `⇆ 3pm
link | node … | dash: …`, red with the reason when it is down), and the
bucket page's address is printed wherever the link is — after `3pm link`,
in `3pm agent status`, in that line.

Since link version 2 everything a node says about its state — the
host, the process table, each instance's application metrics and
process tree — travels as **one stream**: a `state` frame a tick
carrying the sections that moved (`host`, `procs`, `app/<id>`,
`tree/<id>`, each with its own revision), cut as JSON merge patches by
the agent against what the backend holds, rebuilt by one reader;
`state_resync {secs}` asks sections again in full. The `welcome` paces
it with one `pace {state_s, metrics_s, trees}` object. The contract is
[link-protocol.md § 7](link-protocol.md#7-the-nodes-state-state); the
agent side is the one task of [agent.md](agent.md#the-supervised-app)
(`node_state.rs`, `node_state/paces.rs`, `stream.rs`). The `telemetry`,
`host_patch`, `app_metrics` and `trees` streams of link 1 — with
`resync`, `host_resync`, `trees_request`, `welcome.full_every_s` and
`host_patch_every_s` — are gone, and a peer that does not speak the
current version is refused at the handshake (`ERR_LINK_VERSION`).

Link version 3 dropped the node certificate and the bucket CA: a node is
its row on the backend — id, public key, state, re-read at every
handshake — and enrollment became one signed `POST /api/v1/enroll`
instead of a three-frame WebSocket dialogue. The link upgrade names the
node in the clear (`Authorization: 3pm-node <cluster-id>:<node-id>`) and
proves it with the same nonce and signature as before
([link-protocol.md §2–§3](link-protocol.md)).

Link version 4 gave each direction its own frame enum (`ServerFrame`,
`AgentFrame`), cut the handshake to three frames (the proof moved into
`hello.sig`), typed every identifier the two sides exchange (`BlobId`,
`CallId`, `Sha256`, and the link's own `LinkInput` / `OutputBlob` in
place of the local wire's `ActionInput` / `OutputRef`), folded the three
pace knobs of `welcome` into one `pace` object, and made decoding two
steps: a tag this build does not know is skipped, a body it cannot read
is **counted and answered**, and neither ends a session. The
sub-protocol lost its number (`3pm.link`).

Link version 5 left the link with **one** way to move bytes. The
`blob_chunk` frame is gone: the file an action returns is uploaded by
the agent to the backend's artifact store over HTTP (`PUT
/api/buckets/{cluster}/calls/{call}/artifacts`) before the reply, and
the reply names it (`Outcome.file = {artifact_id, bytes, ext, sha256}`,
fetched with `artifactFile` by either end). A `data` too big for the
reply's inline budget is now an error telling the author to return a
file instead of silently becoming bytes to fetch. With no frame only an
agent could send, the two outcome types collapse into one `Outcome`
carried by both `reply` and `call_result`
([link-protocol.md §5](link-protocol.md#5-commands-and-calls)).

## Security model in one paragraph

Each host has an Ed25519 **node key**, generated at `link`, stored
owner-only in `<home>/agent/node.key`, never leaving the host. A node is
its row in the backend's database — cluster, id, public key, state — and
nothing else: no certificate, no CA, nothing to renew, and the row is
re-read at every handshake. Enrollment is authorised by a one-time
**token** `3pmk_<id>.<secret>`; the backend stores its SHA-256 only and
compares in constant time. A single-use token valid for at most one hour
yields an *active* node; anything reusable or longer-lived yields a
*pending* node that a human approves. Enrollment itself is one signed
`POST /api/v1/enroll`; every connection afterwards proves possession of
the node key against a server nonce (domain-separated messages for the
two). Wire growth is additive only: unknown message tags, error codes and
enum values decode to `Unknown` and are skipped.

## Caveats carried over from the plan

- Link handshake nonces live in memory: one backend instance.
- Environment variable **names** travel by default (`--no-env-keys`
  turns them off); values never do.
- Windows: the agent talks to the daemon over `UnixStream` like `serve`;
  the 0600 mode on `node.key` is Unix-only (temp + rename still holds).
- The backend's mutating endpoints have no authentication yet (an
  `--admin-token` is an open decision).
