# Link protocol (`3pm.link`)

The contract between an agent and a fleet backend. Everything here is
typed in `crates/threepm-link` and frozen by the fixtures under
`crates/threepm-link/tests/fixtures/link/v5/`; a change that is not
additive bumps `LINK_VERSION`.

The vocabulary's generation is `link_min..=link_max` alone. The
WebSocket sub-protocol carries no number: it names the framing, a
mismatch is a 400 at the upgrade, and a version outside the accepted
range is `ERR_LINK_VERSION` one frame in ("update 3pm"). No build speaks
two versions, so a fleet crosses one **nodes first, backend last**: an
updated agent in front of an older backend is refused and links again on
the `Auth` schedule once the backend is deployed.

## 1. Identifiers

| Thing | Format |
|---|---|
| Cluster id | `3pmc_` + 16 lowercase base32 characters (80 bits). Public partition key. |
| Node id | `3pmn_` + 16 |
| Enrollment token | `3pmk_<token_id8>.<secret32>`. The backend stores `token_id`, `sha256(full token)`, expiry, `uses_left`, `pre_approved`. Its `Debug` is redacted. |
| Fleet token | `3pma_<id10>_<secret64>`, lowercase hex (40 + 256 bits). The node's bearer for the backend's HTTP routes, minted in every `welcome` and never stored on disk by the backend: it keeps `sha256(token) → node row` in memory. Its `Debug` shows `3pma_<id10>_<redacted>`. |
| Blob id, call id, artifact id | `^[A-Za-z0-9_-]{1,64}$` (`BlobId`, `CallId`, `ArtifactId`). The backend mints blob ids from 16 random bytes, call ids from 12 and artifact ids from 16, base64url; the agent derives a call's blobs as `<call>-d<n>` and `<call>-f<n>`, and never mints an artifact id — it echoes the one the upload answered. |
| Digest | 64 lowercase hex characters (`Sha256`) |
| Keys, signatures, nonces | base64url without padding; Ed25519 keys 32 bytes, signatures 64; nonces ≥ 16 bytes, single use |

Base32 is RFC 4648 lowercase without padding (`a–z`, `2–7`), chosen so a
line can be pasted and read aloud without ambiguity.

Blob, call and artifact ids become **path segments** (`PUT
/blobs/<blob>` on the agent's data socket, `GET …/calls/<call>` and `GET
…/artifacts/<artifact>` on the backend), so their alphabet is
base64url's and nothing more — no `.`, hence no `..`. Every
identifier above, and every digest, is enforced by its **type**, at the
field, when the frame is decoded: a frame that breaks one never becomes
a frame (§4), and nothing downstream has to remember to check.

A node **is** `(cluster_id, node_id, Ed25519 public key)`: one row on the
backend, `node.key` + `link.toml` on the host. There is no certificate
and no CA — the row is the authority, and it is re-read at every
handshake, so a revocation or an approval takes effect on the next
connection rather than when a document expires.

## 2. Enrollment — `POST /api/v1/enroll`

One request and one answer over HTTPS: a host trades an enrollment token
for a node id, which needs no session and no nonce of its own.

Request body (`EnrollRequest`):

```json
{"cluster_id": "3pmc_…", "token": "3pmk_….…", "node_pubkey": "<base64url>",
 "hostname": "web-01", "os": "linux", "arch": "x86_64",
 "agent_version": "0.19.0", "protocol": 1, "link_min": 5, "link_max": 5,
 "proof": "<base64url>"}
```

`proof` = Ed25519(node key, `enroll_message(token_id, node_pubkey)`) =
`"3pm-enroll-v2\0" token_id "\0" node_pubkey`. No server nonce: what a
replay has to beat is the token's use count, and the proof binds the
token to the key it enrolls.

Answer 200 (`EnrollResponse`): `{node_id, state: active|pending, link,
server_time_ms}`, `link` being the highest version the request's
`link_min..=link_max` shares with the server's. A refusal is an HTTP
status with the body `{"error":{"code","message"}}`: 400
`ERR_BAD_REQUEST`, 401 `ERR_TOKEN_INVALID` / `ERR_TOKEN_EXPIRED` /
`ERR_TOKEN_EXHAUSTED` / `ERR_PROOF_INVALID`, 403 `ERR_NODE_REVOKED`,
426 `ERR_LINK_VERSION`, 429 `ERR_RATE_LIMITED`.

Server checks, in this order: body well formed → the two version ranges
overlap → cluster known → `token_id` known and hash matches (constant
time) → token not revoked / expired / exhausted → `node_pubkey` is 32
bytes → proof verifies → an existing node with that key is not revoked →
**atomic decrement**
`UPDATE tokens SET uses_left = uses_left - 1 WHERE id = ? AND uses_left > 0`
(0 rows ⇒ exhausted) → upsert node (same `node_id` for a known key, else
random; `active` when the token was pre-approved, else `pending`). The
use and the node land in **one transaction**.

The agent writes nothing before the answer, and nothing at all when
`|server_time_ms − now| > 5 min` (`ERR_CLOCK_SKEW`, the delta shown).
The request is bounded to 10 s, and the server budgets concurrent
enrollments per client address (16 by default; beyond it 429
`ERR_RATE_LIMITED`).

## 3. Link — `GET /api/v1/link`

Headers: `Authorization: 3pm-node <cluster-id>:<node-id>` (`LinkAuth`:
the two ids as they are pasted, prefixes included, one colon),
`Sec-WebSocket-Protocol: 3pm.link`, `User-Agent: 3pm-agent/<version>`.

That header is a **claim**, never a credential — both ids are public.
What proves the node is `hello.sig`, verified against the public key
**on the row**; the row also carries the state, and both are read again
at every handshake.

HTTP rejections before any frame, body `{"error":{"code","message"}}`:
400 `ERR_BAD_REQUEST` (header missing or not that shape, wrong
sub-protocol), 401 `ERR_NODE_UNKNOWN` (no such node in that cluster),
403 `ERR_NODE_REVOKED`, 403 `ERR_NODE_PENDING`, 429
`ERR_RATE_LIMITED`, 503 `ERR_LINK_BUSY` (the backend holds as many
pending handshakes as it takes — from that client address, or in all:
retry soon; an agent that predates the code reads it as `ERR_UNKNOWN`,
and the 503 alone makes it Transient). Not `ERR_LINK_VERSION`: the
version is judged on `hello`, one frame in, and closes 4426.

| # | Direction | Message |
|---|---|---|
| 1 | S→A | `challenge {nonce, server_time_ms, link_min, link_max}` |
| 2 | A→S | `hello {sig, boot_id, protocol, link, version, caps, direct_port?}` |
| 3 | S→A | `welcome {link, server_version, pace, fleet_token?, pending_calls?}` |

`sig` = Ed25519(node key, `link_message(nonce, node_id)`) =
`"3pm-link-v1\0" node_id "\0" nonce`, `node_id` being the one of the
`Authorization` header: the signature is what makes that claim true, so
the agent's name (a constant) and the node id (already in the header)
are not fields of their own.

The agent checks the challenge before signing: the version overlap (the
highest common version goes into `hello.link`), then clock skew
(`|server_time_ms − now| ≤ 5 min`, else `ERR_CLOCK_SKEW` with the
delta). Unknown frames before `challenge` or between `hello` and
`welcome` are skipped on both sides, within the same 10 s.

A frame the peer cannot **decode** is read differently here than on an
established session (§4), because there is no session to protect yet.
The agent fails the attempt on one: a `challenge` or a `welcome` it
cannot read is a disagreement it will not resolve by waiting, so it
retries on the Transient schedule rather than reporting a handshake
timeout. The backend skips one and reads on within the same 10 s budget:
the peer has not proved anything, so its frame is worth no more than an
unknown tag. Failures:
Close 4401 `ERR_PROOF_INVALID`, 4408 `ERR_HANDSHAKE_TIMEOUT` (10 s),
4426; the server reads until the agent's close (2 s cap) before dropping
the socket, so the close code is never lost to an RST. A second
connection of the same `node_id` closes the previous one with 4409
`ERR_SESSION_REPLACED`. The server re-reads the node's state right after
registering the session: a revoke that raced the handshake closes it
4403 at once.

`boot_id` is random per agent process, so the server tells a restart
from a reconnect. `hello.caps` is what the **host owner** allows
(`link.toml` `caps`), declared so the backend can show it and enforced
by the agent: the backend grants nothing. Capabilities: `metrics:read`,
`proc:restart`, `proc:stop`, `proc:action`, `node:shell`; one this build
does not know decodes to `unknown` and is dropped. All are on by
default; a `link.toml` of version 2 that lists exactly the four before
`node:shell` reads as all of them (`agent.md`, *State directory*).
`direct_port` is the TCP port of the agent's LAN listener when it has
one (`link.toml` `direct`, on by default), announced at every handshake
(§5, *Bytes between LAN neighbours*).

`welcome.pace` is how fast the backend wants the node's state, and it is
mandatory in full:

```
pace { state_s: u32,            // the state tick
       metrics_s: u32,          // the app/* sections
       trees: {mode: "off"} | {mode: "shape"} | {mode: "figures", every_s: u32} }
```

Every period is floored at 1 s where it is decoded. `trees` is tagged on
`mode`, and a mode a build does not know reads as `off` — fewer sections
is the safe way to be wrong, and the backend learns from the sections it
never gets.

`welcome.pending_calls` lists the calls this node placed that settled
(or are still open) while it was away; the agent fetches each result
over HTTP (`getCall`). Entries that are not legal call ids are dropped
on decode, repeats are deduplicated, and the list is cut at
`PENDING_CALLS_MAX` (256) distinct ids — a receiver pages through
`getCall` for the rest rather than refusing the frame.

## 4. Frames

Every message is one JSON object tagged by `t`. One enum per direction,
so each side's `match` is exhaustive over what can actually arrive and a
frame it could never send is not in its vocabulary.

Server → agent (`ServerFrame`):

```
challenge {nonce, server_time_ms, link_min, link_max}
welcome {link, server_version, pace, fleet_token?, pending_calls?}
call {id, req: AgentRequest, deadline_ms?}
call_result {call, state, results, err?, output?}    a call THIS node placed has settled
state_resync {secs}                                  these sections did not apply: send them in full next; none named = all
error {code, message, server_time_ms?}
```

Agent → server (`AgentFrame`):

```
hello {sig, boot_id, protocol, link, version, caps, direct_port?}
ack {id}                                             sent by whoever accepts the call, as its first act
reply {id, res: AgentResponse}
emit {ev: AgentEvent}
state {ts, gen, secs}                                every pace.state_s; none = the heartbeat (§7)
path_report {blob, path, bytes}                      which way a blob this agent wrote to a peer went (direct | relay)
blob_stat {blob, bytes, last_ms}                     every 5 s: progress of a live blob gone direct, latest figure only
error {code, message, server_time_ms?}
```

**Decoding is two steps**: the tag, then the body.

| Outcome | Meaning |
|---|---|
| `Frame(f)` | a frame this build knows |
| `Unknown {tag}` | a tag it does not: skip it, count it |
| `Invalid {tag, id?, why}` | a known tag whose body did not decode: skip it, count it, answer it when `id` says which call it was about |

`id` is the frame's `id` (`call`, `ack`, `reply`) or `call`
(`call_result`) when the raw object carried one **as a string** that is a
legal call id; anything else names no call to answer. `why` says what
failed and is cut at `WHY_MAX` (256 bytes) where it is built: a serde
message quotes the value it choked on, and receivers repeat `why` into a
refusal, a log line and a call row before the sender has authenticated.

**A frame body never ends a session** on either side: the backend
counts it (`GET /api/v1/info` → `link.frames_invalid`) and reads on; the
agent counts it (`3pm link status --json` → `frames_invalid`) and, for a
`call`, answers `reply {id, res: err {code: "malformed"}}` rather than
letting the call die on its deadline. Only a message that is not a JSON
object with a string `t`, or one over 1 MiB, is an error — a broken
codec, not a disagreement about a field.

Every new numeric field is an `Option`. Enum vocabularies (`ErrorCode`,
`Capability`, `NodeState`, `AgentRequest.op`, `AgentResponse.res`,
`AgentEvent.ev`, `AgentErrCode`, `Path`, and the link-local `ProcStatus`
/ `ProcEventKind` carried by `RedactedRecord.status` and
`AgentEvent::Process.kind`) carry an `Unknown` catch-all — an unknown
process status or event kind costs one field, never the frame. The one
field read leniently rather than typed is `action.peer`: a peer this
build cannot read is dropped and the call runs through the relay.

## 5. Commands and calls

`AgentRequest` (tag `op`): `ping`, `restart {target}`, `stop {target}`,
`action {target, instance?, source?, action, params, timeout_ms?,
input?, output?, stream?, peer?}`, `shell {term, cols, rows, input,
output, from, from_hostname?}`, `exec {argv, pty?, term, cols, rows,
input, output, from, from_hostname?}`, `attach {target, instance?,
mode?, read_only?, peek?, cols, rows, input, output, from,
from_hostname?}`, where `target` is `{id}` or
`{name}`. Never `toggle_watch`, never `all`, and there is no variant for
`start`, `delete`, `save`, `scale` or `kill` — the enum **is** the
allow-list. Required capability: `proc:restart` / `proc:stop` /
`proc:action` / `node:shell` (`shell`, `exec`, `attach` and `run_as`); `ping` needs none. `proc:action` covers
the **bytes** of a call as well as its trigger; the fine-grained
refusal is the backend's (`callers` on the target, and the declared
`input.max_bytes`).

`AgentResponse` (tag `res`): `pong {version, protocol, pid}`, `ok
{affected: [RedactedRecord]}`, `triggered {results: [Outcome]}`,
`exited {code?, signal?}` (the end of a `shell`, an `exec` or an `attach`, *Shell sessions*),
`err {code, message}` with `AgentErrCode` ∈ `not_found`, `forbidden`,
`unavailable`, `timeout`, `daemon_error`, `malformed`.

Commands are **at-most-once**: a `call` in flight at a disconnect is not
replayed and its reply is withheld; the UI shows "not acknowledged —
retry?" with a new id. The `ack` is sent by whatever accepts the call, as
its first act, so a call that could not be taken (the inbound queue was
full) is never acked and that view is the truth. Each call runs in its
own task, at most `CALLS_IN_FLIGHT_MAX` (4) at a time (`ping` excepted;
the fifth **waits** for a permit, in order, and is `unavailable` only
when none came before its deadline — less the margin its refusal needs
to go out; a session — `shell`, `exec`, `attach`, `forward` — is counted
apart and refused at once when its own budget is full), and asks the
daemon with a bounded budget:
an action's `timeout_ms` (30 s when absent) plus 5 s, 30 s for the rest,
never past the call's `deadline_ms`. A daemon predating the op answers
`err {unknown_request}`, forwarded as `daemon_error`; a daemon down is
`unavailable`.

`action` becomes the daemon's `dispatch` with `remote: true` (an action
declared `remote: false` answers `refused`), and each instance it
reached answers with one `Outcome`: the daemon's `ActionOutcome`
(`proc_id`, `name`, `instance`, `source`, `action`, `duration_ms`, `ok`,
`err? {code, message}`; codes `failed`, `timeout`, `busy`, `gone`,
`refused`, `too_large`, unknown → `unknown`) with its result in link
form:

| Key | What it is | How the bytes travel |
|---|---|---|
| `data? {p: "inline", gz_b64}` | the handler's JSON, gzip + base64url | in the frame, within the reply's budget |
| `file? {artifact_id, bytes, ext, sha256}` | the file the action returned | uploaded to the artifact store before the reply |
| `output? {blob, bytes, ext, sha256}` | the file it pushed to the blob the caller opened (`action.output`) | the blob relay, the caller pulls it |

`Outcome` is **one type in both directions**: the same object travels up
in a `reply` and back out in a `call_result`, because nothing in it is
bytes only an agent could send. Until link 5 there were two, and the
asymmetry was `blob_chunk`.

Inline is a **per-reply** budget, not a per-outcome one: every inline
`data` of one reply together holds at most 256 KiB of gzip
(`INLINE_MAX_GZ_BYTES`), spent in outcome order through `InlineBudget`
— which is what keeps the largest reply (16 outcomes, every field at its
bound) under the 1 MiB frame cap. A `data` that does not fit **fails its
own outcome** with `too_large` ("the result is N gzip bytes; a result
this size is a file"), and the rest of the reply travels: a result too
big for the frame is not quietly turned into bytes to fetch, it is an
action to rewrite. `err.message` is the handler's text (cut to 1 KiB by
the daemon) and travels in clear: no secret and no third-party input
belongs in an action's error message.

The file an action returns is an **artifact**, not a payload of this
link. Before it sends the reply, the agent uploads the file, hashing the
raw bytes as it streams them; the outcome's `file` then names what came
back. The upload is the one HTTP route of this contract that is not on
the link, so it is spelled out here:

```
PUT /api/buckets/{clusterId}/calls/{callId}/artifacts
Authorization: Bearer <the welcome's fleet_token, `3pma_…`>
content-length: <raw bytes>        required
x-3pm-ext: <ext>                   the extension the daemon gave the file (`is_file_ext`)
x-3pm-instance: <n>                which instance of the target app returned it
<body: the file's raw bytes, not compressed>

201 {"artifact_id": "…", "bytes": 184322, "sha256": "…"}
```

`bytes` and `sha256` are over the **raw** bytes, and the agent compares
the answer's digest with its own before naming the artifact: the store
compresses what it keeps, so a digest over gzip would depend on which
end compressed it.

| Refusal | When |
|---|---|
| 400 | a header missing or malformed (`content-length`, `x-3pm-ext` not a file extension, `x-3pm-instance` not a number) |
| 403 | the bearer is not the call's **target** node |
| 404 | no such call in this bucket, or it has already settled |
| 408 | the body was still arriving at the call's deadline, or sent no byte for 60 s |
| 409 | this instance already uploaded one, or is uploading one, or the call already holds `ARTIFACTS_PER_CALL_MAX` (16 — the action fan-out) |
| 413 | over the cap: the action's declared `output.max_bytes` (up to 256 MiB), `FILE_DEFAULT_MAX_BYTES` (**32 MiB**) when it declares none — or over the bucket's upload budget, `UPLOAD_BYTES_PER_BUCKET_MAX` (**2 GiB**, twice the artifacts retention keeps, `content-length` against the stored bytes and the uploads still arriving) |
| 422 | the body ended short of `content-length` |

Nothing is kept on a refusal. An upload refused, or a digest that
disagrees, fails **that outcome** (`failed` / `too_large`, with the
backend's reason) and nothing else of the reply; a backend that sent no
`fleet_token` fails it before a byte is read. The artifact is
**pending** until the reply claims it — the claim checks that it belongs
to this call and this instance and that its `bytes`/`sha256` match the
outcome's — and one still pending when the call settles is removed.

Every end that holds the outcome fetches the artifact the same way, with
its own bearer (`artifactFile`) — which is what lets one type serve both
directions.

### Calls with bytes attached

An `action` may carry bytes, and then they are **blobs on the backend**,
never frames on this link: `input? {blob, bytes?, sha256?, ext?, live}`
is what the handler must `GET`, `output? "<blob>"` the blob it must
`PUT` its file to, and `stream?` says both legs are open-ended instead
of sized files. A declared `bytes` is what makes a blob a file (verified
by the receiver); nothing declared is a live stream.

The two things a blob needs locally — a filesystem `path` and the token
that opens it — never cross, and that is **structural**: the link
declares its own input, which has no field for either, and the local
request is *built* from it with both left empty. An input that names no
blob does not exist here either: one without would read as a file of the
receiving host, which a link never describes.

`output` and `file` are not the same thing and do not replace each
other: `output` is a transfer the **caller** set up and consumes,
`file` is what the action returned without being asked, kept with the
call for whoever looks at it later.

### Shell sessions

`shell` (`3pm fleet ssh`) opens an interactive login shell on a pty of
the target, as the agent's own unix user (root where the agent runs as
root). The agent runs it itself; it never reaches the daemon. `term`,
`cols` and `rows` are the caller's terminal (`TERM`, size); `input` is
the keyboard leg the agent reads and `output` the screen leg it writes:
two blobs of the lossless `pipe` mode (framed like a live stream, with
back-pressure and one reader), carrying the messages of the local
`attach` protocol (`threepm_protocol::shared::attach`: input, resize,
ping up; output, exited, pong down). `from` is who placed the call — the
backend's `placed_by`, `node:<id>/-` or the account — and
`from_hostname` that node's hostname: the agent stamps them on every
audit line without asking the backend.

The call ends with `exited {code?, signal?}`: `code` when the shell
exited, `signal` when a signal killed it, neither when the agent could
not tell. Either leg ending ends the session (the shell gets `SIGHUP`,
then `SIGKILL` 5 s later). The agent runs the shell **beside** the
connection that received the call, not in it: a reconnect of the link
leaves the shell (and its legs, HTTP of their own) running, and its
`exited` and audit events go out on whichever connection is live when
they are sent — so a `reply` may arrive on a later connection than the
`call`.
A build that predates the op decodes it as `unknown` and answers `err
{forbidden, "unknown command"}`; a host whose `caps` leave out
`node:shell` answers `err {forbidden, "missing capability
node:shell"}`. Who may place it is the backend's call: the target's
`callers` rules must name the builtin action `3pm:shell` (app `-`) when
they are not empty — `*` never covers a `3pm:` action.

`exec` (`3pm fleet ssh <node> -- <cmd>`) runs one command instead, on
the same two legs, under the same capability, permits and hang-up
rules: the agent runs `<login shell> -c "<argv>"`, each word of `argv`
quoted for the shell when it needs to be and the words joined by spaces
— what ssh does with its own. With `pty` the command gets a pty of
`term`, `cols` × `rows` and the legs carry what a `shell`'s do. Without
(`pty` absent or false; `term`, `cols`, `rows` then unused), the command
gets three pipes: its stdout comes back as `output` messages, its stderr
as `stderr` (`0x1d`), and the caller's `eof` (`0x05`) closes its stdin —
bytes both ways, unchanged, under the legs' back-pressure. It ends like a
shell, with `exited`, after `exited` and `END` on the screen leg. A
separate op, not a field of `shell`: a build that predates it answers
`err {forbidden, "unknown command"}`, where one reading a `shell` with a
field it ignores would start an interactive shell and take a script's
stdin for keystrokes.

`attach` (`3pm fleet attach <node> <app>`) joins the terminal of a
`tty = true` app instead, on the same two legs, under the same
capability, permits and stop rules. The agent opens a **fresh**
connection to its daemon's `rpc.sock` (never one of its pooled
ones: the connection leaves NDJSON), sends the daemon's own `attach
{target, instance, read_only, mode, peek, cols, rows}` and, once it is
answered `attached`, relays the messages as they are: the keyboard
leg's to the daemon, the daemon's (`screen`, `diff`, `output`, `title`,
`bell`, `restarted`, `exited`, `lagged`, `detached`, …) to the screen
leg. The daemon's refusal (`not_found`, `not_tty`, `unavailable`,
`invalid_spec`) is the call's `err` (`not_found` stays `not_found`, the
rest `daemon_error`). The app's own `exited` ends nothing: the app may
restart. The session ends when the daemon says `detached` (forwarded,
then `END`), or when a leg ends or the agent stops: the agent sends the
daemon `detach` — the app runs on — and the caller gets `detached
{client}` and `END` when its screen leg still listens. Either way the
reply is `exited {}` (neither code nor signal). Audit: `open`, `attach`,
the raw input lines (`line {alt: true}`; the agent relays the screen
without reading it), `close`.

`run_as {user, req}` (`3pm fleet ssh -l <user>`) wraps a `shell` or an
`exec` (anything else: `malformed`): the same session, run as the unix
account `user` (`[a-z_][a-z0-9_-]{0,31}`, `threepm_link::valid_user_name`).
An envelope rather than a field, so an agent that predates it answers
`unknown command` instead of running the session as itself. An agent
running as root becomes the account (its groups, home, shell; the
daemon's hosted-user drop); one that is not root takes only its own
name (`forbidden` otherwise); an unknown account is `not_found`. Both
are the call's reply, before any leg opens. The agent's `open` line
names the account it ran as.

`forward {host, port, input, output, from, from_hostname?}` (`3pm
fleet ssh -L`, one call per connection the caller accepted; `-W`, one
call whose local side is the command line's stdin and stdout) opens one
TCP connection from the target to `host:port` — any destination, the
name resolved by the target — under its own capability `node:forward`
and the shells' cap of sessions in flight. The two `pipe` legs carry
**raw bytes** as `live` messages, no `attach` kind byte: `input` is
what the caller's peer writes, `output` what the destination writes
back, and a message flagged `END` (empty) half-closes its way — the
caller's `END` is a `shutdown(WR)` toward the destination, the
destination's EOF is an `END` on `output`. The agent connects first
(5 s): a failure is the call's `err {unavailable, "connect host:port: …"}`
and the legs never open. Then the reply is `exited {code}` once both
ways are closed, `0` when both ended by an `END`, `1` when a leg or the
connection broke; the agent stopping ends it with an `err`. Audit: the
backend's `open_requested` / `refused` (`forward = host:port`) and
`ended`, the agent's `close` with `bytes_in` / `bytes_out`. Who may
place it: the target's `callers`, when not empty, must name the builtin
action `3pm:forward` (app `-`); `3pm:shell` does not cover it.
`link.toml` version 4 reads a version-3 `caps` listing exactly the five
capabilities of that day as all of them, so a default link gains
`node:forward` on update; any other list is kept as written.

`-W host:port` is that same call, nothing added on the wire: stdin is
the `input` leg, the `output` leg goes to stdout (data only, every
message on stderr), exit 0 when both ways closed, 255 otherwise. It is
what ssh's `ProxyCommand` wants, so the real `ssh` reaches a node's
sshd through the relay:

```
Host *.3pm
  ProxyCommand 3pm fleet ssh $(echo %h | sed 's/\.3pm$//') -W localhost:%p
```

(`ssh envision@envision-surface-7caf.3pm`, `rsync -e ssh … web-1.3pm:`.)

`listen {bind, port, input, output, from, from_hostname?, e2e}` (`3pm
fleet ssh -R`, always sealed) binds `bind:port` on the target — `bind`
empty is its loopback, `port` 0 one it picks — under `node:forward`
and one of the sessions in flight. A non-loopback `bind` is refused
(`err {forbidden}`) unless the target's own `link.toml` has `[shell]
gateway_ports = true`, a setting the backend can neither read nor
change. The legs carry `threepm_link::listen` messages, one per live
message inside the sealed records: on `output` `bound {port}`, then
`accepted {conn, peer}` per connection (numbered from 1); on `input`
the caller's `keepalive` every 20 s, each answered, and `END` to close.
Each accepted connection is parked for 10 s, closed unless a `forward
{accepted: {listen, conn}, e2e}` (no `host`/`port`) placed by the same
`from` with the same sealed caller key takes it — once — and pumps it
as a dial. The listen ends `exited {code}` (0 on the caller's `END`, 1
on a cut leg or 60 s without a word) and closes the port and every
connection still parked; the agent stopping ends it with an `err`.
Audit: the backend's `open_requested` (`bind`, `port`) and `ended`,
each connection a forward's own lines naming the listen; the agent's
`close`.

#### The end-to-end channel (`e2e`)

`shell`, `exec`, `attach`, `forward` and `run_as` carry `e2e: bool`
(default `false`) and `record: bool` (default `false`); an agent that
can seal announces `shell_e2e: true` in its `hello`. With `e2e`, the
two legs carry, in place of their bytes, records `[u16 BE
length][Noise message]` of `Noise_XX_25519_ChaChaPoly_BLAKE2s` between
the caller's 3pm and the target's agent (at most 65 519 clear bytes per
record), whatever those bytes are (`attach` messages, or the raw bytes
of `cp` and `forward`). The prologue binds the handshake to the call id,
the target's node id **and the canonical request** — every field that
decides what runs (the operation and its argv, terminal and size, the
attach target and mode, the forward's destination or listen, the
`run_as` account, `record`), encoded the same way by both ends: a
backend that rewrote the clear `AgentRequest` it relays fails the
handshake before anything starts. Three messages open it, before any
session byte: `e` on `input`, `e, ee, s, es` on `output`, `s, se` on
`input` once the caller checked `s` against its pin (`known_nodes`,
TOFU). The agent then checks the caller's static key against **its own
`authorized_keys`** (no file = nobody): a key not listed is
`key_not_authorized` and nothing runs — this, not the backend, is what
admits a caller. It starts the shell, the command or the connection
only then. The agent's static key is `shell.key` in its state directory;
the caller's fingerprint comes back in `ShellEvent::Open {cli_key?}`
for the audit.

The handshake's payloads negotiate **compression** of the records: the
caller's `e` carries the words it asks, separated by spaces (today
`zstd`, or nothing), the agent's `e, ee, s, es` the words it grants (a
part of those, or nothing), `s, se` nothing. The agent grants `zstd`
only to an `exec` without `pty` (`fleet cp`, `fleet ssh -C -T`), never
to a pty, an `attach` or a `forward`/`listen`. Once granted, both legs'
records carry one frame each: a flag byte, `0` the bytes as they are,
`1` a zstd frame, of at most 65 518 clear bytes; any other flag, or a
frame that opens to more, is `e2e_broken`. Nothing of it is in
`AgentRequest` or the canonical request: the payloads are hashed into
the handshake, so a backend that strips or adds the ask breaks it like
any change to the prologue. An agent that predates it reads the ask and
ignores it, and answers no payload; a caller that predates it asks
nothing — either way the records are as before.

Refusals, as the call's `err`: a request without `e2e` to an agent of
this version is `e2e_required` (no setting relaxes it); a handshake or
a record that does not open is `e2e_broken`, and the session ends. The
backend keeps the agent's code in the stored answer (`result.code`, its
action code being `forbidden` / `failed`); the command line reads that
code, never the message. The backend itself refuses `e2e` to a node
whose live session did not announce `shell_e2e` (`409
ERR_NODE_NO_E2E`), and never tees a sealed session.

`record: true` asks the agent to record the session (the bucket's
`record_shells`): it writes the asciicast v2 of what it puts on
`output` **before** sealing (`threepm_protocol::cast`, the backend's own
rules: screen only, 64 MiB, never waiting), uploads it with
`putCallArtifact` before replying, and names it in `exited {code?,
signal?, recording?}`. A forward is never recorded, nor a copy: an
`exec` whose argv is `sh -c <one of 3pm fleet cp's four scripts, byte
for byte> 3pm-cp <args…>` (`threepm_link::cp::is_cp_transfer`, shared
by both sides) — the backend asks `record: false` for it, and the agent
records none even asked. An agent that dies
mid-session sends no recording; the backend's `ended` line says
`recording: lost`. A sealed session may take the LAN direct way even
when recorded.

### Calls this node places

`call_result {call, state, results, err?, output?}` reports that a call
**this** node placed on another node of the bucket (`io.call`, `3pm
fleet call`) has settled. `call` is the backend's call id, `state` its
word for the row (`replied`, `lost`, `unacked`, …) — free text, so a
state this build never heard of still reaches the app that is waiting —,
`results` one `Outcome` per instance the target reached — the same
objects the target's `reply` carried, `file` included —, `err` what the
backend sends instead of outcomes (refused by the target's caller list,
node offline, deadline passed), and `output` the file the call produced.

An artifact named here is fetched by **this** node: `artifactFile` lets
a node that is party to a call read it with its own bearer, which is why
the two directions share one type since link 5.

The backend sends `call_result` on a non-lossy path and **drops** it when
the session is gone rather than queueing it; `welcome.pending_calls` is
how the agent picks those up again.

### Bytes between LAN neighbours

When the backend judges the two ends of a call to be neighbours on one
LAN, it names the other end in **both** tickets: `action.peer` for the
target (on this link), the call view's `peer` for the caller (over
HTTP). `Peer {node_id, pubkey, addrs}`: the peer's node id, its
base64url Ed25519 public key — the only key its TLS handshake may
present (raw public keys, RFC 7250, mutual) — and at most
`PEER_ADDRS_MAX` (8) private socket addresses to try, in order. The
bytes may then go agent to agent over TCP + TLS; the blobs of the ticket
stay open on the backend as the fallback, and nothing about the call
itself changes.

The agent announces a port only, in `hello.direct_port`; the backend
picks the addresses from the node's `host` section (its private
interfaces), and only for two nodes behind the same public IP. That
section is the node's own report, so a hostile node can steer its
neighbours' connections at any private `ip:port` they can reach: a TCP
connect and a TLS hello go out, nothing more. What bounds it: a receiver
dials `Peer::dialable()` only (private addresses with a port,
deduplicated, at most 8, whatever arrived), and the dialled end must
present `peer.pubkey` before a byte of the blob is sent. A consequence
to know: the public key of a node becomes known to the nodes it calls or
is called by on its LAN. The peer never reaches the daemon.

The agent that **writes** such a blob sends `path_report {blob, path,
bytes}`, `path` ∈ `direct` | `relay` (unknown values decode to
`unknown`): `direct` **when the two meet**, before the transfer, `relay`
once the relay has taken it. `direct` lets the backend forget the blob it
had opened, unless its relay already carried a byte of it or has a
reader on it, or the call named no peer (then the report is refused);
`relay` only records the path. Only a call's **input** goes direct
today: its output is read after the reply, too late for any rendezvous.
While a live blob goes direct, `blob_stat {blob, bytes, last_ms}` every
5 s keeps the call's view moving.

## 6. Events

`AgentEvent` (tag `ev`): `process {id, name, kind, exit_code?,
exit_signal?}` — **no `stderr_tail`** (attacker-influenced plaintext in a
compressed stream) —, `daemon_shutdown {reason}` and `app {id, name,
instance, ts, source?, event_name, level, severity_number, attrs, exc?,
fingerprint?, repeat}`: an application event (`io.event`,
`notify_error`) exactly as the daemon admitted it — rate-limited per
fingerprint (`repeat` = how many it suppressed since the previous one
through), `cwd` and `$HOME` already stripped from `exc.stacktrace` by
the daemon (`docs/metrics.md`). `attrs`, `exc.message` and label values
are the app's own text: the SDK docs say no secret belongs there. Logs
never travel **on the link**: they have their own HTTP channel (§6b).

`action {id, name, instance, ts, source?, call, action, origin, ok,
err_code?, duration_ms}` is the audit line of one custom action on one
instance, whoever called it: `origin` is `local` (`3pm trigger` on the
machine) or `remote` (a `call` from this backend, `call` being the
daemon's own call id, not the link's). Neither the params nor the result
ride it, and neither do the daemon's local `placed_by` / `input_bytes`:
the backend wrote the call row itself and already holds both.

`shell {call, from, ts, event}` is the audit of a `shell` (or `exec`)
session this node serves: `call` is the link's call id of the command, `from`
the caller it named, `ts` the agent's clock (Unix ms), and `event`
(tag `what`) one of:

| `what` | Fields | When |
|---|---|---|
| `open` | `user`, `shell` | the shell started: its unix user and program |
| `line` | `line`, `alt` | a command line as the screen showed it, prompt included (`alt: false`), or the raw input line typed under a full-screen program (`alt: true`); `[hidden]` when the terminal did not echo; at most 4 KiB, marked when cut |
| `screen` | `alt`, `line` | a full-screen program took (`alt: true`) or gave back the terminal; `line` is the last line typed before |
| `close` | `code?`, `signal?`, `duration_ms`, `bytes_in`, `bytes_out`, `lines` | the session is over; `lines` counts the audit lines it sent, so a loss shows |
| `exec` | `command`, `pty` | an `exec` session, right after `open`: the command line the shell was given (at most 4 KiB, marked when cut). Without a pty nothing else is written but `close` — never its stdin; with one, `line` and `screen` follow as for a shell |
| `attach` | `app`, `instance`, `mode`, `read_only` | an `attach` session, right after `open`: the app as the daemon resolved it; then only raw input lines (`line {alt: true}`) and `close` |

A kind this build does not know decodes to `unknown` and the event
survives. The audit is as complete as the screen lets it be, no more
(`tmux`, an escape sequence, typing blind): what the agent cannot hide
is written by the backend itself — the opening and the end of the call.

## 6b. Logs — `POST /api/buckets/{cluster}/logs`

Not a frame: the WebSocket keeps one DEFLATE context for every frame, and
a log line is attacker-influenced plaintext (`sshd` writes the user names
a stranger types); compressed next to a secret, its size would leak the
secret. A node posts its logs as batches, `application/x-ndjson` gzip,
`Authorization: Bearer <fleet token>`, every
`LOG_PUSH_EVERY_MS` (10 s), one request in flight, at most
`LOG_BODY_MAX_GZ_BYTES` (1 MiB) compressed and `LOG_BODY_MAX_BYTES`
(4 MiB) inflated. Types: `threepm_link::logs`.

`welcome.logs {apps, host, files}` says which sources the backend takes;
absent, the agent ships and probes nothing. The host's `link.toml`
`[logs] {apps, host, files, host_level, exclude}` is the other half: a
source flows only when both say yes (`LogsSettings::effective`).

One record per line, tagged `k`, in this order: `batch {run, seq, ts}`
first (a retried batch repeats `run` and `seq`, and is stored once), then
every `src` and `tpl` before a record that names them.

| `k` | Fields | What |
|---|---|---|
| `batch` | `run`, `seq`, `ts` | who sends, which batch |
| `src` | `src`, `kind` (`app`\|`journal`\|`file`), `path?`, `app?`, `instance?`, `stream?`, `owner? {kind, name, target?, unit?, container?}`, `format?` | a source, before anything names it; again when it changes and hourly |
| `tpl` | `src`, `tpl`, `sev`, `text` | a template (variables masked), when it appears or its text changes |
| `cnt` | `src`, `from`, `to`, `n: [[tpl, lines, sev], …]`, `params? [{tpl, var, distinct, top}]` | counts per template over a window |
| `line` | `src`, `tpl?`, `ts`, `sev`, `why`, `line`, `ident?`, `unit?`, `pos?` | a raw line, shipped because it is news: `why` = `new`, `error`, `back`, `spike`, `return`, `context`; `pos` = its stable place in the source (`dev:ino:gen:offset` — `gen` bumps when the file is truncated or its inode reused; `dev:ino:offset` from older nodes, read as generation 0 —, the journal cursor, the bus sequence; opaque to the backend) |
| `dropped` | `src`, `lines`, `reason` (`budget`\|`lag`\|`ring`\|`stale`) | lines read and not shipped |

`sev`: `debug`, `info`, `notice`, `warning`, `err`, `crit` (syslog 0–2);
`warn`, `error`, `critical`, `alert`, `emerg` read as their canonical word.
The `n` triple reads its first three elements and skips the rest, so it
may grow at its tail. A `welcome.logs` switch added after the first three
defaults **off**.

Exactly once, as far as it is cheap: a batch retried within a run repeats
`(run, seq)` and is stored once; a `line` with a `pos` is stored once per
`(node, src, pos)`, so lines re-read after an agent restart are not
duplicated. Counts are deltas: the lines of the one batch in flight when
an agent stopped may be counted twice (at most one push period). A node
counts a line in the window of the moment it **read** it (a backlog read
after a stop lands in the current window, not its own). On a clean stop
the node closes its open windows early (`to` = the stop, at least 5 s
before the window's end) and tries one last batch for a few seconds; a
crash, or a backend away at the stop, loses the open windows' counts —
the lines themselves are read again, their positions not committed.
Every word a build does not know — a record kind, a source or owner kind,
a severity, a `why`, a reason — decodes to `unknown`, and the record
survives (an unknown record kind is skipped and counted). Bounds
(`LogRecord::check`, applied by the backend; the agent cuts first with
`cap_text`): a line 8 KiB, a template 2 KiB, any other string 1 KiB, a
`cnt` 4 096 triples and 1 024 params, `top` 3 values.

Answers: `200 {accepted, skipped, duplicate?, refused? {sources, templates, reason}}` (`refused`: new sources or templates the node's catalog did not take, `reason` `catalog_full`; also counted in `skipped`, the node counts them and does not send them again in a loop) (the node then saves its
offsets and journal cursor), `429` + `retry-after`, `413`, `400` (a body
that is not gzip or does not open with a valid `batch`: the batch is
dropped, never retried), `401` (wait for the next `welcome`), `503
ERR_STORE_DISABLED` / `ERR_STORE_UNAVAILABLE` (with `retry-after: 1` when
the backend has 32 batches in flight); a timeout or a 5xx retries the same
batch, 1 s doubling to 60 s; a `retry-after` only ever lengthens that
pause (the longer of the two, at most 60 s), never shortens it.

What the node guarantees, because VictoriaMetrics keeps one point per
series per 5 s (`-dedup.minScrapeInterval`): a window's counts for one
`(src, tpl)` travel in **one** batch (a source with more templates than a
`cnt` holds may use several `cnt` records, in the same batch: the backend
sums them, `tpl="other"` included, before writing), and two windows of
one source end at least 5 s apart. Template and source ids are never
reused for something else: a node whose miner state is lost starts new
source ids (its state epoch is part of them), so `(src, tpl)` always names
one template.

## 7. The node's state (`state`)

**The one diff engine of the link** (`threepm-link/src/node_state/`): the
agent gets **complete states** from its daemon — which cuts no diff —,
keeps what the backend holds, and sends what moved; the backend rebuilds
the state with one reader.

```
state {
  ts,            // agent clock, Unix ms: the reader derives uptimes from it
  gen,           // the agent's incarnation (its start time, ms)
  secs: [{       // every section that moved this tick; none = the heartbeat
    sec,         // "host" · "procs" · "app/<id>" · "tree/<id>" · "traffic"
    rev,         // the section's revision after p; p was cut from rev - 1
    full?,       // p is the whole section
    crc?,        // CRC-32 of serde_json::to_vec(section after p): every full one, and one a minute, each section at its own moment of it
    gone?,       // the section is gone: forget it, no p
    p            // JSON Merge Patch (RFC 7386) over the section
  }]
}
state_resync { secs }   // S→A: send these in full next; none named = every section
```

One frame every `pace.state_s`, empty when nothing moved: the heartbeat,
whose clock the uptimes are read on. `pace.metrics_s` paces the `app/*`
sections, `pace.trees` the `tree/*` ones.

A node's state is a set of **sections**, each an independent JSON
document with its own revision — not one document: a gap resends one
section, a section's full state fits one frame (nothing is ever
chunked), and sections do not move at the same pace. One frame a tick
carries them all; several frames a tick only when the 1 MiB cap forces
it, a section never split; one over the cap alone is not sent, warned
once, and cut again next tick. A name the reader does not know is held
all the same: it needs no schema.

| Section | Holds | Shape |
|---|---|---|
| `host` | `HostMetrics`; `gone` while the host agent is down | `null` members dropped; arrays of rows as tables `{"~r": {key: row}, "~o"?: [keys]}` keyed by a fixed path table (`networks`→`name`, `disks`→`mount_point`, `block_devices`→`name`, `sensors`→`label`, `raid`→`name`, `batteries`→`name`, `top_processes`→`pid`, `3pmDaemon.processes`→`role`, anything else by index; a key that is a non-negative whole number is written zero-padded to 10 digits, so rows sorted by it are sorted as text and need no order; `#2` for a key taken twice; the order kept only when the rows are not sorted). 3 significant digits |
| `procs` | the table of `RedactedRecord`s and `daemon` (`{version, protocol, pid}` from the daemon's `Pong`, absent while it is down) | `{daemon, procs: table keyed by id}`, the daemon's order. **No `uptime_ms`**: `started_at_epoch_ms` instead, worked out by the agent once per `(id, gen, pid)` — `created_at_epoch_ms` is the row's creation, not the spawn — and the reader's `uptime_ms` is the frame's `ts` minus it. **No `app_metrics_rev`** either (it moves with every flush; `app/<id>.rev` has it): it reads as 0. 3 significant digits |
| `app/<id>` | one instance's application metrics, once its registry ever moved (series, or a source that only declares actions); `gone` when the instance leaves the table | `{id, name, instance, ns?, gen, rev, src: [{s, pid?, a: [{n, d?, t?, o?}]}], m: {sid: {def: {i, n, k, u?, d?, s?, l}, v \| h: {s, count, sum, z, b: {index: count}}}}}`. An action's `t` is its declared timeout (ms) and `o` its declared `output.max_bytes`, what the backend reserves for a caller that asks for its file (absent: 256 MiB; the backend bounds it again). The application's values are **never rounded**; a value JSON cannot write (NaN, ±∞) reads as 0. `sid`s are the daemon's, unique per instance and incarnation |
| `tree/<id>` | one instance's `RedactedTree`, **only while it has more than one process** (back to one, or trees off: `gone`) | `{id, gen, rev, c, m, omitted?, procs: table keyed by pid}`, a parent before its children; a member is `{p, pp, n, c, m, t, d?, x?, nt?}` (pid, parent, executable name ≤ 64 bytes, cpu, RSS, start, detached, Extended Monitoring's counters, network traffic as `RedactedNet`). 3 significant digits |
| `traffic` | the agent's link counters since its start (`since_ms`, its `gen`'s start): bytes and messages per direction (`up` agent → backend, `down` backend → agent) and per kind, every 5 s (`max(1, 5 / state_s)` ticks). A name only (`node_state::TRAFFIC`), **outside `Section`**: a backend that does not know it holds it unread | `{since_ms, up?: {kind: {b, m}}, down?: {kind: {b, m}}}`, a counter still at zero left out, so a patch carries only the counters that moved. Read leniently (`traffic_view`, never the strict derive): an entry that is not `{b?, m?}` of whole non-negative numbers is skipped, unknown kinds kept up to 64 a direction; a store writes `Traffic::known()` only (the closed list × `up`/`down`). Kinds (`threepm_link::traffic::KINDS`, a closed list): `host` `extended` `top` `procs` `app` `trees` `events` `shell` `calls` `control` `traffic` `logs` `artifacts` `blobs` `api` |

```
RedactedRecord { id, name, instance, namespace, status, pid, cpu_percent: Option<f32> /* None under 2 s of uptime */,
  mem_bytes, uptime_ms, restarts, unstable_restarts, listen_ports, exit_code, exit_signal,
  created_at_epoch_ms, next_cron_epoch_ms, waiting_for, module: Option<String>,
  interpreter: Option<String> /* basename */, watch: bool, env_keys: Vec<String> /* empty with --no-env-keys */,
  app_series /* series the instance declares */, app_metrics_rev /* its registry's revision */,
  gen /* the instance's incarnation */,
  proc_count /* live processes of the instance's tree */,
  pss: Option<u64> /* Extended Monitoring, Linux */,
  net: Option<RedactedNet> /* the tree's network traffic */ }
RedactedNet { r, t /* bytes/s received, sent over the daemon's last 5 s window, whole bytes */,
  rb, tb /* bytes since the daemon first measured the incarnation (or the process) */,
  pa /* the window lost notifications: a lower bound; elided when false */ }
```

`cpu_percent`, `mem_bytes` and `listen_ports` are the totals of the
instance's **process tree** (`docs/metrics.md` → *Process trees*);
`proc_count` is the number of live members, the fleet table's `+N`.
`net` is the tree's traffic, each socket once (`docs/metrics.md` →
*Network traffic per app*); absent on a node with no source, before the
daemon's second window, and from an older agent — `rb`/`tb` restart
with the incarnation and with a daemon update, which a reader takes as
a counter reset. A tree member's `nt` counts every socket it holds.
`app_series`, `gen` and `proc_count` are elided at zero. The record is
what a `reply` carries; the `procs` **section** above reshapes it, which
is where `app_metrics_rev` is dropped and `uptime_ms` becomes
`started_at_epoch_ms`.

**Link traffic** (`traffic`, `threepm_link::traffic`). Up, a WS frame counts its compressed payload (what `rx_bytes` counts) and one message; a `state` frame's bytes are split over its patches **pro rata** of their JSON length (`pro_rata`, integer shares, the envelope and the rounding to `control`, the shares summing to the frame's size) — `host.extended`, `host.top_processes` and each `tree/<id>` member's `x` (`procs["~r"].*.x`) carved out to `extended` / `top` (`patch_weights`) — and each patch counts one message for its kind; an envelope with no patch (the heartbeat) is one `control` message. The total per frame is exact, the split an **estimate** (a section the DEFLATE window already holds costs almost nothing yet is charged its raw size). HTTP counts the body only, one message per request, retries included. A new `gen` starts the counters over; a reconnect keeps them.

**What the agent asks the daemon**, every tick: `list` and
`host_metrics`; `app_metrics` — `id` alone, always answered whole —
for the instances whose `(gen, app_metrics_rev)` is not the
one the backend's section was shaped from; `process_trees` for those
with more than one process whose `(gen, tree_rev)` moved, and for all of
them when the figures are due. The daemon's counters only say *whether*
to ask.

**Delivery**, per section: a patch applies only under the `gen` it was
cut in and onto `rev - 1`; anything else is a gap (`StateGap::{Gen, Rev,
Crc}`), the section is left untouched, the rest of the frame applies,
and the backend answers `state_resync` naming it (at most once a
second). **Only the last patch of each section in a frame applies**
(`StateFrame::last_of_each`): an honest agent cuts a section once a
tick, but may say `gone` for it in the same frame (an instance cut, then
deleted), and the last word holds. A frame that repeats a section costs
one apply of it, never one per copy; a later copy whose base was an
earlier one is a gap, answered as any other. The agent's writer advances **only once the frame is written on
the socket**: a patch cut and never written is cut again, larger, at the
same revision. Everything is owed in full at the first tick of a
session, on `state_resync`, and when the daemon was replaced (another
`Pong.boot_id`: its revision lines start over). Nothing is said `gone`
while the daemon itself is down: an empty table then says nothing of the
instances — and the backend ages no instance out on a table that names
no daemon, so one whose section never moves is still there when the
daemon answers again. An agent that **restarted** (another `gen`) owes
every section it has in full and never says `gone` for what went away
while it was down: the backend forgets, on the first frame of the new
`gen`, every section that frame did not bring again.

A **full** patch with a wrong CRC is accepted and turns the check off
until the node's next session — refusing it would ask for another, for
ever. It is not silent: the reader says so (`crc_failed_on_full`), and
the backend logs it once per session with the section and counts it
(`GET /api/v1/info` → `link.crc_full_mismatch`). The CRC rides one patch
a minute per section, each section at its own moment of the minute, so
the sections that move every tick are not all checked on the same one. A
section that comes in full is no longer asked, even if a gap noted it a
moment before. The backend bounds what it holds: 404 sections a node,
4 MiB a section, 32 MiB a node; a section over a size bound is dropped,
and whole again at its next patch; a 405th is refused until one held
goes.

**Never on the wire:** `ProcessRecord`, `StartSpec`, environment values,
`script`, `cwd`, log paths, `stderr_tail`, `last_error`, log lines,
process arguments. A property test draws secrets from an alphabet
disjoint from every other field and asserts none reaches the JSON.
`RedactedRecord.name` and `RedactedProc.n` are text the app controls
(the ecosystem name; `process.title`, `PR_SET_NAME`): display only.

### Volume

Measured on two real 10-minute traces, payload bytes on the link
(`rd/old/bench/host-patch/results/`), host + table + application metrics
+ trees, everything included: **357 B/s** on a dev Mac with 11 apps,
**463 B/s** on a Linux fleet server with 8 apps. Agent ≈ 0.27 ms a tick,
reader ≈ 0.02 ms. At the most a node manages — 20 instances at the
1 000-series cap, every series moving every second — the daemon hands
over 3.5 MB/s of whole states (0.28 ms an answer), the agent spends
≈ 60–70 ms a tick cutting them and the link carries 176 KB/s; 20
instances × 50 series: 3 ms a tick, 8 KB/s.

## 8. Framing, keepalive, close

| Direction | WS type | Encoding |
|---|---|---|
| agent → server (everything, `hello` included) | Binary | raw DEFLATE stream, one `Compress` per connection, `FlushCompress::Sync` after every message; the server keeps one `Decompress`, caps at 1 MiB after inflation ⇒ Close 1009 |
| server → agent | Text | clear JSON ≤ 1 MiB |

Ping every 20 s from both sides; 60 s without any frame (pongs count) ⇒
the connection is dead. The agent's writer flushes every 5 s even when
idle (pongs the stack queued while reading would otherwise sleep in the
sink). A full write buffer (32 KiB growing to 1 MiB) means the peer
stopped reading: dead connection, not growing memory. Close: the agent
sends `Close(Normal)` on shutdown and waits 2 s for the echo.

### Retry classes (agent)

| Close / HTTP | Class (base, cap) |
|---|---|
| 1000/1001/1009/1011, TCP/TLS/DNS, EOF, 60 s silence, HTTP 5xx (503 `ERR_LINK_BUSY`) | Transient (1 s, 60 s) |
| 4400 / HTTP 400 `ERR_BAD_REQUEST` | Auth (5 min, 30 min): a malformed frame is a bug, not a blip |
| 4401 / HTTP 401 | Auth (5 min, 30 min) |
| 4403 / HTTP 403 `ERR_NODE_REVOKED` | **Stop**: logged once, probe every hour |
| 4403 / HTTP 403 `ERR_NODE_PENDING` | Pending (1 s, 5 s): an approval is seen within 5 s |
| 4403 with no reason (a proxy stripped it) | Auth — revoked *or* pending, so never Stop on a bare code |
| 4409 `ERR_SESSION_REPLACED` | Replaced (30 s, 60 s, floor 30 s) |
| 4426 / HTTP 426 | Auth, log "update 3pm" |
| 4429 / HTTP 429 | RateLimited (1 min, 15 min) |
| skew > 5 min | Auth, `ERR_CLOCK_SKEW` with the delta |

Full jitter: uniform in `[floor, min(cap, base·2^attempt)]`; `attempt`
resets after a session of at least 60 s. The `ERR_*` carried in a close
frame's reason (or in a preceding `error` message) wins over the bare
code; a bare code maps back through `ErrorCode::from_close_code`, except
4403, which names no code (revoked or pending: bare, it is Auth).

## 9. Error codes

| Code | HTTP | Close | Meaning |
|---|---|---|---|
| `ERR_BAD_REQUEST` | 400 | 4400 | malformed message, missing field, wrong sub-protocol |
| `ERR_LINK_VERSION` | 426 | 4426 | link version outside the accepted range |
| `ERR_TOKEN_INVALID` | 401 | 4401 | unknown token, hash mismatch, wrong cluster, revoked — one code, no oracle |
| `ERR_TOKEN_EXPIRED` | 401 | 4401 | |
| `ERR_TOKEN_EXHAUSTED` | 401 | 4401 | no use left |
| `ERR_NONCE_INVALID` | 401 | 4401 | unknown, reused or stale handshake nonce |
| `ERR_PROOF_INVALID` | 401 | 4401 | `hello.sig` does not verify |
| `ERR_CLOCK_SKEW` | 401 | 4401 | `server_time_ms` travels with it |
| `ERR_RATE_LIMITED` | 429 | 4429 | |
| `ERR_NODE_UNKNOWN` | 401 | 4401 | no such node in that cluster: only enrolling builds the link again |
| `ERR_NODE_REVOKED` | 403 | 4403 | the agent stops retrying |
| `ERR_NODE_PENDING` | 403 | 4403 | awaiting approval |
| `ERR_HANDSHAKE_TIMEOUT` | 408 | 4408 | |
| `ERR_LINK_BUSY` | 503 | — | the link upgrade found the pending handshakes full (per client address, or the nonce store); newer than some agents, which decode it to `ERR_UNKNOWN` and retry Transient on the 503 |
| `ERR_SESSION_REPLACED` | 409 | 4409 | |
| `ERR_INTERNAL` | 500 | 1011 | |

Close codes are `4000 + HTTP status` (mnemonic); `ERR_INTERNAL` closes
with 1011. Unknown codes decode to `ERR_UNKNOWN` (500 / 1011). This is
the **wire's** vocabulary: a refusal an agent reaches on its own — a
clear-text URL to a non-loopback host without `--insecure` — is its own
code string in its own `--json` output, not one of these.

## 10. Fixtures

`crates/threepm-link/tests/fixtures/link/v5/*.json` freeze one sample
per family: `server_frame`, `agent_frame`, `error_code`, `capability`,
`node_state`, `agent_request`, `agent_response`, `outcome`,
`artifact_ref`, `agent_event`, `redacted_record`, `redacted_tree`,
`state_frame`, `action_origin`, `path`, `pace`, `enroll_request`,
`enroll_response`, `blob_id`, `call_id`, `artifact_id`, `sha256`, `log_record`,
`logs_accepted`, `traffic` (and a `traffic` patch in `state_frame`). The `link_fixtures` test fails when a
frozen sample no longer round-trips, when a frame tag of either
direction has no sample, or when a fixture file is missing — it guards
shape, not values. A companion property walks each server frame as JSON,
corrupts every typed identifier it carries in turn, and asserts the
frame decodes to `Invalid` naming its call, never to an error.

Regenerate deliberately, and review the diff as a compatibility
decision:

```
UPDATE_LINK_FIXTURES=1 cargo test -p threepm-link --test link_fixtures -- --ignored update_fixtures
```
