# Device Executor Protocol — implementer's guide

**Audience**: an engineer writing a device-side executor from scratch (the process a user starts on their
own machine, e.g. `sema device serve`). This document is meant to be sufficient on its own: every closed
set, frame field, bound and failure rule below is the contract the server actually enforces.

**Shape of the lane**: the cloud worker runs the model; the *tools* (shell commands, file reads/writes)
execute on the user's own machine. The executor is the only thing that touches that machine. It makes a
single **outbound** WebSocket connection to the server and then answers instructions. The server never
dials the device, so the device needs no inbound port, no public address and no tunnel.

**Two contracts, one lane**

| Plane | Transport | Who calls whom | Where it is specified |
|---|---|---|---|
| Management / enrollment / binding | HTTPS, JSON | the user's client calls the server | `docs/ASSISTANT-WIRE-CONTRACT.md` §14 |
| Executor | WebSocket (upgrade on the WS path below), text frames, JSON | the device connects out to the server | **this document** |

**Discovery before you connect**: `GET /v1/capabilities` carries a `deviceExecutor` key — either `false`
(this deployment has no device lane) or an object `{ enabled: true, protocolVersion, maxInflightPerDevice,
wsPath, management }`. Read `wsPath` from there rather than hardcoding it: the path is part of the wire
contract and reading it means a future path change is not a cross-repo break. `protocolVersion` tells you
which version the server speaks before you spend a handshake finding out.

---

## 1. End-to-end lifecycle

```
  user (authenticated client)                device executor                       server
  ───────────────────────────                ───────────────                       ──────
  POST /v1/devices/enroll-tokens  ───────────────────────────────────────────────▶  issues a one-shot token
        ◀── 200 { token, expiresAt, ttlSec }                                        (plaintext, once)

        token handed to the executor
        (out of band: paste / local prompt)
                                      generate an Ed25519 keypair (device-local)
  POST /v1/devices/enroll  ◀── the executor asks the client to POST, or POSTs with the
                               user's credential: { token, pubkey, platformOs,
                               platformArch?, displayName?, workspaceRoot }
        ◀── 200 { deviceId }                                                        stores the PUBLIC key only

                                      GET <wsPath> (WebSocket upgrade)  ──────────▶
                                      ◀── challenge { nonce, protocolVersion }
                                      hello { … signatureB64 … }  ──────────────────▶ verify signature,
                                                                                      acquire the connection lease
                                      ◀── helloAck { gen, heartbeatIntervalMs,
                                                     maxInflight, pendingInstructions,
                                                     chunkWatermarks }
                                                  … or helloReject { code }
                                      fence: kill/drain everything from older generations
                                      generationAck { gen }  ───────────────────────▶ now (and only now) the
                                                                                      server may dispatch
      ── steady state ─────────────────────────────────────────────────────────────────────────────────
                                      ◀── instruction / payload / cancel / resultAck / chunkAck / bye
                                      chunk … chunk … result / heartbeat  ──────────▶
      ── shutdown ─────────────────────────────────────────────────────────────────────────────────────
                                      goodbye { reason, graceMs? }  ─────────────────▶ stop dispatching,
                                                                                      keep collecting results
```

### 1.1 Enrollment (HTTP, once per device)

1. **Issue a token.** `POST /v1/devices/enroll-tokens` with the user's authenticated credential and an
   **empty body** (the body is a closed shape with zero accepted keys; any key is a `400`). The token is
   always issued to the caller's own verified identity — there is no "issue on behalf of" in this version.
   The response `{ token, expiresAt, ttlSec }` carries the **plaintext token exactly once** (the server
   stores only a SHA-256 digest). Default lifetime 900 s, measured from the database clock.
2. **Generate the keypair on the device.** Ed25519. The private key **never leaves the machine** — it is
   not in the enrollment request and not in any later frame. Put it in the OS keychain, or in a file with
   owner-only permissions.
3. **Register.** `POST /v1/devices/enroll` with the *user's* credential (the device has no identity yet,
   so this call cannot be authenticated by the device) and the closed body:

   | Key | Required | Form |
   |---|---|---|
   | `token` | yes | the plaintext enrollment token, 1–256 characters |
   | `pubkey` | yes | **raw 32-byte Ed25519 public key, standard base64** (44 characters); byte-exact key: non-empty, no control characters, no surrounding whitespace, ≤ 128 bytes |
   | `platformOs` | yes | closed set `darwin` \| `linux` (anything else is a `400`; there is no third value) |
   | `platformArch` | no | ≤ 32 bytes |
   | `displayName` | no | ≤ 255 bytes |
   | `workspaceRoot` | yes | non-empty, ≤ 1024 bytes — the device-side root that path resolution falls back to |

   `deviceId` and the owner are **not** inputs: the server mints `dev_<26-char ULID>` and takes the owner
   from the verified credential. The response is `200 { deviceId }` and contains no secret of any kind.
   **A replay of the same token with the same public key is indistinguishable from the first registration**
   (same `200`, same `deviceId`) — that is deliberate, so retrying after a lost response is safe. The same
   token with a *different* public key is refused and audited as token reuse.

Store `deviceId` next to the private key. Revocation is terminal: a revoked device is never resurrected,
and re-enrolling produces a **new** `deviceId`.

**Both enrollment endpoints are rate limited, and the limit is a fixed property of this lane** — it is not
an operator knob and is unrelated to the deployment's general request-rate setting:

| Property | Value |
|---|---|
| Budget | **10 requests per 60 s window, per caller identity** |
| Scope | per server replica, fixed-window (the device lane's deployment shape is a single replica; during a rolling restart two overlapping processes each carry their own window) |
| Buckets | issuing and registering are counted **separately**, so issuing a token never eats the registration budget |
| Over budget | `429 limit.rate_exceeded` with a `retry-after` header (whole seconds, at least 1) |
| Limiter unavailable | also refused with `429` — a rate limiter that fails open is exactly what token guessing wants |

A refused attempt is refused **before the token is consumed**, so a `429` never burns your enrollment
token: wait out `retry-after` and retry the *same* token with the *same* public key. Practical executor
behaviour: honour `retry-after` exactly, never retry faster than once per second, and cap registration
attempts — ten failures in a minute mean the token or the credential is wrong, not that the network is
slow.

#### What `POST /v1/devices/enroll` answers, case by case

| Situation | Status | `errorCode` | What the client should do |
|---|---|---|---|
| Token valid, first registration | `200` | — | Body is exactly `{ "deviceId": "dev_…" }`. Persist it next to the private key. |
| Same token **and the same public key** sent again (lost response, retry) | `200` | — | Same `deviceId` as the first time, no second device row. Safe to retry. |
| Token does not exist | `403` | `device.enrollment_invalid` | see below |
| Token has expired | `403` | `device.enrollment_invalid` | see below |
| Token was issued to a different caller identity | `403` | `device.enrollment_invalid` | see below |
| Token was already used with a **different** public key | `403` | `device.enrollment_invalid` | see below |
| Body is not JSON / not an object / has an unknown key / a required key is missing | `400` | `request.invalid_json` / `request.body_shape` | Fix the request; the message names the key. |
| A field has the wrong type, is too long, or `platformOs` is outside the closed set | `400` | `request.field_invalid` | Fix the request; the message names the field. |
| Caller carries no verified identity with a tenant scope | `403` | `auth.forbidden` | Sign in through the registry auth bridge first. |
| Over the rate budget, or the limiter is unavailable | `429` | `limit.rate_exceeded` | Wait out `retry-after`; the token was **not** consumed. |
| This deployment does not run the device lane | `501` | `capability.device_lane_required` | Nothing to retry against this server. |

**The four `403 device.enrollment_invalid` rows are deliberately indistinguishable on the wire** — same status,
same code, **byte-identical response body**. That is an anti-enumeration property, not an omission: if "expired"
and "unknown" answered differently, the endpoint would be an oracle for guessing tokens. The finer reason is
recorded in the operator-side device audit trail only. So an enrollment client **cannot** branch its failure text
three ways; it gets one outcome and should say all of it, for example: *"Enrollment was refused. The token may
have expired, may already have been used by another device, or may have been issued to a different account.
Ask for a new enrollment token and try again."* Do not retry the same token after this answer.

### 1.2 Connecting

Open a WebSocket to `wsPath` (`GET`, standard upgrade). The server refuses the upgrade with `404` for any
other path, and with `503` while the replica is draining or when its pre-auth connection caps are full.
There is no authentication in the upgrade headers — the handshake happens **inside** the protocol.

The server sends `challenge` immediately on open. From the moment the socket opens you have
`handshakeDeadlineMs` (5000 ms) to get a `helloAck`/`helloReject`; after that the socket is closed. Until
the handshake completes, **frames are capped at `maxPreAuthFrameBytes` (4096 bytes)** and a malformed
pre-auth frame closes the connection outright.

### 1.3 The `hello` signature (byte-exact — do not improvise)

Sign with the device private key over exactly these bytes:

```
  "sema.device.hello.v1"              (the domain tag, raw UTF-8, NO length prefix)
‖ u32be(byteLength(nonce)) ‖ nonce    (nonce as received in the challenge frame, raw UTF-8)
‖ u32be(byteLength(deviceId)) ‖ deviceId          (raw UTF-8)
‖ epoch16                             (your 26-char Crockford base32 ULID decoded to 16 bytes)
‖ u32be(protocolVersion)              (the same integer you put in hello.protocolVersion)
```

- `u32be` = 4-byte big-endian unsigned. The length prefixes are **byte** lengths, not character counts.
- `epoch16` is the ULID's 128 bits, not a timestamp and not a u64. Crockford base32 decode, most
  significant byte first. A 26-character ULID whose first character decodes above 7 is an overflow form
  and is rejected.
- The signature goes into `hello.signatureB64` as base64 of the raw 64-byte Ed25519 signature.
- The `nonce` is **one-time and socket-scoped**: it is consumed by the first `hello` on that socket,
  whether or not the signature verifies. Never sign a nonce from a different socket, and never sign
  anything else with this key.

Any deviation — a different tag, a missing length prefix, a differently-encoded epoch — makes **every**
handshake fail with `auth_failed`, with no hint as to which half is wrong. Get this right first.

#### `epoch` is a byte-exact key: exactly which strings are accepted

`epoch` is a 26-character **upper-case** Crockford base32 ULID whose first character is `0`–`7`. Crockford's
alphabet is case-insensitive on paper; this protocol is **not**, on purpose: the same `epoch` string is also the
key the server compares when fencing connection generations and leases, so two spellings of one ULID would let
one connection be counted as two. That is worse than a failed handshake, so the decoder stays strict.

The two entry points reject differently — if you call the low-level decoder yourself, check for `null`:

| Input | `decodeUlid16(...)` | `buildHelloSignaturePayload({ epoch })` |
|---|---|---|
| valid, e.g. `01J8ZQ4T9X7M3K5N6P8R0S2V4W` | 16 bytes | payload |
| lower-case, e.g. `01j8zq4t9x…` | `null` | throws `Error: device-ws: epoch must be a 26-char Crockford ULID (got "…")` |
| first character `8`, `9`, `A` … `Z` (128-bit overflow) | `null` | throws |
| 25 characters (or 27) | `null` | throws |
| contains an excluded letter `I`, `L`, `O`, `U` | `null` | throws |

The discriminating case, if you want to prove your own decoder judges the right thing: a `Z` as the **last**
character is accepted (it is just the value 31), while a `Z` as the **first** character is rejected (overflow).
A decoder that rejects "any Z" is wrong. Remember that a bad signature is answered with `auth_failed` and nothing
else — the server does not say which half was wrong — so run the vector below and these rejection cases against
your implementation before the first live handshake.

#### Importable source of truth and a fixed test vector

You do not have to transcribe the byte layout. From `@sema-agent/server` 7.88.0 the executor-facing part of the
protocol is importable:

```ts
import { buildHelloSignaturePayload, decodeUlid16, DEVICE_WS_PATH, DEVICE_INSTRUCTION_KINDS } from "@sema-agent/server/device-protocol";
import type { DeviceServerFrame, DeviceFrame, DeviceInstructionFrame } from "@sema-agent/server/device-protocol";
```

The subpath re-exports the closed-set constants, both directions' frame types, and the two pure functions needed to
build the bytes you sign. It deliberately does **not** export the server's half (frame parsing, signature
verification, id minting).

If you implement the encoding yourself (another language, or no dependency on the server package), your
implementation must reproduce this vector byte for byte:

| input | value |
|---|---|
| `nonce` | `bm9uY2UtdGVzdC12ZWN0b3I=` |
| `deviceId` | `dev_01J8ZQ4T9X0000000000000000` |
| `epoch` | `01J8ZQ4T9X7M3K5N6P8R0S2V4W` |
| `protocolVersion` | `1` |

Expected payload, 102 bytes, hex:

```
73656d612e6465766963652e68656c6c6f2e763100000018626d3975593255746447567a644331325a574e306233493d0000001e6465765f30314a385a51345439583030303030303030303030303030303001923f72693d3d0732d4d64601916c9c00000001
```

Read left to right: the domain tag as UTF-8 (`sema.device.hello.v1`, no length prefix), a big-endian u32 length then
the `nonce` string's UTF-8 bytes (the string as received — **not** base64-decoded), the same for `deviceId`, the
16 raw bytes the 26-character Crockford ULID `epoch` decodes to, and the protocol version as a big-endian u32. You
sign exactly these bytes with the device's Ed25519 private key.

### 1.4 `helloReject` — what each code means and what to do

<!-- closed-set: DEVICE_HELLO_REJECT_CODES -->

| Code | Meaning | What the executor should do |
|---|---|---|
| `auth_failed` | deliberately ambiguous: unknown device id, malformed id, malformed epoch, bad signature, or the server's device registry was unreachable | do **not** hammer: back off exponentially. If it never clears, the enrollment is gone — re-enroll. The server will not tell you which of these it was (anti-enumeration) |
| `revoked` | this device has been revoked. Terminal | stop reconnecting, delete the private key, ask the user to re-enroll (it yields a new `deviceId`) |
| `protocol_version_unsupported` | your `protocolVersion` is outside the range this server accepts; the frame carries `minProtocolVersion` | upgrade the executor. Never retry-with-a-different-version in a loop |
| `draining` | this replica is shutting down or upgrading and takes no new connections | reconnect with backoff; a replacement replica will accept you |
| `already_connected` | **the connection lease for this `deviceId` is not available right now**: a previous connection is still considered fresh, the stored lease has not expired yet, or the deployment forbids takeover | retry with bounded backoff — the lease expires on its own (deployment default 45 s, and a previous socket is judged stale after ~3 missed heartbeats). This code does **not** prove another process is alive: a half-open network drop can make your *own* previous connection look fresh. Do not exit permanently on it; do hold a machine-local single-instance lock so you are never racing yourself |

After a reject the server closes the socket. Reconnect policy that works well in practice: 1 s initial
backoff, exponential to a 60 s ceiling, jittered — except for `revoked`, which is terminal.

### 1.5 `helloAck` and the two-phase activation

`helloAck` carries:

| Field | Form | Meaning |
|---|---|---|
| `ok` | always `true` | — |
| `heartbeatIntervalMs` | integer ms | how often you should send `heartbeat` (deployment default 15000) |
| `maxInflight` | integer | how many instructions the server will have outstanding on this device at once (deployment default 4) |
| `gen` | **canonical decimal string** (`"7"`, never `7`, never `"07"`) | the connection generation. Stamp it on every frame you send |
| `pendingInstructions` | `string[]` | instruction ids the server still considers in flight for you — see §5 R2 |
| `chunkWatermarks` | `{ [instructionId]: number }` | per instruction, the highest **contiguous** `streamSeq` the server has already accepted. `-1` means "nothing accepted yet" |

`gen` is a string because it is a 64-bit database value; parse it as an integer only if your language can
hold it, and always **echo the string you were given** rather than re-formatting a number.

**The server will not dispatch anything until you send `generationAck`.** Use that gap to reconcile your
local state against `pendingInstructions`, which is **authoritative**:

- an instruction **listed** in `pendingInstructions` is still the server's business and yours: **keep it
  running** (the server has re-bound it to the new generation `G`, and its result and remaining chunks are
  expected under `G`). This happens only on a same-epoch reconnect;
- an instruction you are still running or holding a result for that is **not listed** has been written off
  by the server: **terminate it** and send nothing for it. Its frames would be refused, and continuing to
  run it means side effects nobody is accounting for;
- anything you are holding from a generation older than `G` that you cannot map to a listed id — in
  particular work inherited from a socket you superseded — must be killed before you proceed.

Only then send `generationAck { gen: G }`. Sending it while unaccounted older-generation work is still
alive defeats the fence and can let two generations run at once. Note what the server does and does not
check here: it enforces the *ordering* (no dispatch before the ack) but it cannot verify that you actually
fenced — that half is yours, and it is the reason a device-level single-instance lock matters (R10).

### 1.6 Shutting down

Send `goodbye { gen, reason, graceMs? }`. The server stops *starting* new dispatches for this connection
and keeps collecting results for the ones already in flight, then closes the socket after `graceMs` (if you
omit it, the deployment's reconnect grace — default 120000 ms — is used). Sending `goodbye` does not settle
anything: finish or fail your in-flight instructions properly before you exit.

⚠️ **`goodbye` is not a hard barrier.** The check happens when a dispatch *enters* the pipeline, and a
dispatch that was already past that point — waiting for an in-flight slot or for a database round trip —
can still write its `instruction` frame to you after your `goodbye`. So an instruction arriving after
`goodbye` is normal, not a protocol error: **execute it or answer it with an explicit failure**. Silently
dropping it strands work that has already crossed the server's one-way commit (§5.0) and can only end as
outcome-unknown.

---

## 2. Frame schemas

Every frame is a **single WebSocket text frame containing one JSON object**, discriminated by `t`. Binary
WebSocket frames are refused (before the handshake they close the connection). Binary *payloads* travel as
base64 inside JSON fields.

### 2.1 Transport constants and bounds

<!-- closed-set: DEVICE_CONSTANTS -->

| Constant | Value | Meaning |
|---|---|---|
| `DEVICE_WS_PATH` | `/v1/device/ws` | the upgrade path (prefer the `wsPath` from `/v1/capabilities`) |
| `DEVICE_PROTOCOL_VERSION` | `1` | the version this server speaks |
| `DEVICE_MIN_PROTOCOL_VERSION` | `1` | the lowest version it accepts; below it, a loud `helloReject` — never a silent downgrade |
| `DEVICE_HELLO_DOMAIN_TAG` | `sema.device.hello.v1` | the handshake signature domain tag (§1.3) |

<!-- closed-set: DEVICE_WS_LIMITS -->

| Key | Value | Meaning |
|---|---|---|
| `maxFrameBytes` | `1048576` | hard cap on any single frame (1 MiB), also the transport-level payload cap |
| `maxPreAuthFrameBytes` | `4096` | cap applied **before** the handshake completes — your `hello` must fit in it |
| `handshakeDeadlineMs` | `5000` | wall clock from socket open to `helloAck`/`helloReject` |
| `defaultPayloadPartBytes` | `262144` | the slice size the server uses for `payload` down-chunks (256 KiB) |

Note the asymmetry: the transport cap is 1 MiB, and the 4096-byte pre-auth cap is applied **after** the
frame has been reassembled. Keep `hello` small; it is not a place for long `supports` prose.

### 2.2 Device → server frames

<!-- closed-set: DEVICE_FRAME_TYPES -->

| `t` | Fields | Notes |
|---|---|---|
| `hello` | `protocolVersion` int, `deviceId` non-empty string, `epoch` string, `signatureB64` string, `platform` `{os, arch, executorVersion}` all strings, `workspaceRoot` string, `pathFlavor` string, `supports` string[], `resuming?` `{unackedResults: int ≥ 0}` | first frame on the socket, once per socket. `epoch` = a **new ULID per executor process start** (it is how the server tells "the same process reconnected" from "the process restarted") |
| `generationAck` | `gen` | sent once, after you have fenced older generations (§1.5) |
| `heartbeat` | `gen`, `seq` int ≥ 0, `inflight` string[] | `seq` monotonically increasing per connection; `inflight` = instruction ids you are still working on |
| `result` | `gen`, `instructionId` non-empty, `deviceSeq` int ≥ 0, `outcome` | the single terminal answer for one instruction (§3.3) |
| `chunk` | `gen`, `instructionId`, `streamSeq` int ≥ 0, `kind`, `dataB64?`, `exitCode?` | output stream (§4) |
| `goodbye` | `gen`, `reason`, `graceMs?` int ≥ 0 | graceful shutdown |

All numeric fields must be **safe integers** (no floats, no strings). All of these frames except `hello`
carry `gen`, and it must be the **canonical decimal string** you were handed in `helloAck` — leading
zeros, a plus sign, whitespace or a float form are rejected as malformed.

### 2.3 Server → device frames

<!-- closed-set: DEVICE_SERVER_FRAME_TYPES -->

| `t` | Fields | Notes |
|---|---|---|
| `challenge` | `nonce` string, `protocolVersion` int | first frame the server sends, immediately on open |
| `helloAck` | `ok`, `heartbeatIntervalMs`, `maxInflight`, `gen`, `pendingInstructions`, `chunkWatermarks` | §1.5 |
| `helloReject` | `code`, `minProtocolVersion?` | §1.4; the socket closes right after |
| `instruction` | `gen`, `instructionId`, `rootSessionId`, `issuedSeq` int, `kind`, `args`, `cwd?`, `shellEnv?`, `timeoutMs` int | one unit of work (§3) |
| `payload` | `gen`, `instructionId`, `part` (`begin` \| `chunk` \| `commit`), `offset?`, `dataB64?`, `totalLen?`, `sha256?` | the bytes for a write-family instruction (§3.4) |
| `cancel` | `gen`, `instructionId` | idempotent; answer with a terminal `result` (§5 R11) |
| `resultAck` | `gen`, `instructionId` | you may release that instruction's buffers |
| `chunkAck` | `gen`, `instructionId`, `upToStreamSeq` int | cumulative watermark; you may release chunks ≤ it |
| `bye` | `gen`, `reason` | the server is dropping this connection (§5 R13) |
| `ping` | — | **declared but never sent today** (see §8) |

`instructionId` is minted by the server as `ins_<26-char ULID>`: unguessable, and it is the **idempotency
key** for everything about that unit of work. `issuedSeq` increases per connection and is informational.

### 2.4 Malformed frames: what the server does

Refusals come in two stages. Both write an audit row on the server; the difference that matters to you is
whether the connection survives.

**Stage 0 — the transport, before any of this.** The WebSocket server's own message cap is
`maxFrameBytes`, and it is enforced by the transport while reassembling: a message over 1 MiB is a protocol
error (close code 1009) and **the connection is dropped**, not the frame. Your in-flight work then takes the
ordinary disconnect path (reconnect grace, R4). Never send an oversized frame expecting to lose only it.

**Stage 1 — parsing** (application-level, after a complete message has been reassembled)

<!-- closed-set: DEVICE_FRAME_REJECT_REASONS -->

| Reason | Trigger | Consequence |
|---|---|---|
| `frame_too_large` | over the cap that applies to this phase. In practice this is the **pre-auth** 4096-byte cap: it is checked after reassembly, so it is the one an application-level refusal can still catch. Post-handshake the cap equals the transport cap, so an over-size frame has already died at stage 0 | pre-auth: connection closed |
| `not_json` | not valid JSON — also raised for any **binary** WebSocket frame | same |
| `not_object` | valid JSON but not a top-level object | same |
| `unknown_type` | no string `t`, or a `t` outside the device→server set | same |
| `bad_shape` | a field is missing, of the wrong type, out of range, a non-canonical `gen`, a non-canonical `dataB64`, or a `chunk` whose `kind` and payload disagree | same |

**Stage 2 — admission, after a frame parsed cleanly**

<!-- closed-set: DEVICE_FRAME_GATE_REJECT_REASONS -->

| Reason | Trigger | Consequence |
|---|---|---|
| `pre_hello` | a non-`hello` frame before the handshake, an extra frame while a handshake is already in flight, or a second `hello` on an established connection | first case: connection closed. Other two: frame dropped (deliberately not a disconnect — one stray frame must not be able to kill a legitimate handshake) |
| `generation_mismatch` | `gen` is not exactly this socket's negotiated generation, or this socket is no longer the device's current connection. Also raised when a terminal result cannot be fenced against the device lease | frame dropped, no ack. For the terminal case the instruction settles as outcome-unknown |
| `unknown_instruction` | `instructionId` is neither in flight nor in the server's bounded memo of recently settled instructions (the most recent 512) | frame dropped, no ack — a late result for a long-forgotten instruction is unreachable |
| `instruction_not_committed` | the instruction is still tracked but not in the dispatched state; also raised when a **new** `chunk` arrives while that instruction's terminal result is being accepted | frame dropped; in the after-terminal case the instruction is settled outcome-unknown, because the stream is self-contradictory |
| `device_mismatch` | the `instructionId` belongs to a different device | frame dropped |

**The settled memo, and why `resultAck` is weaker than it looks.** An instruction leaves the in-flight table
the moment it settles — for *any* reason, including the ones you never asked for (abandoned on a new-epoch
reconnect, given up on at the execution ceiling, failed on a stream gap). Its id then sits in a bounded memo
of the 512 most recently settled instructions, and a late `result` that hits the memo is answered with
`resultAck` and **nothing else**. So:

- `resultAck` means **"stop holding this frame"**, not "your outcome was accepted". You cannot distinguish,
  from the ack alone, "the server recorded your exit code" from "the server wrote this off ten seconds ago
  and is just letting you free the buffer";
- a late `chunk` that hits the memo is **silently ignored** — no `chunkAck`, no error. It cannot revive or
  overturn a settled instruction;
- once the id ages out of the memo, both frame kinds are refused `unknown_instruction`.

This is deliberate and it is safe in exactly one direction: nothing you send after a settlement can change
the answer the caller already got. Build your outbox around it — retry, then release, and never infer
success from an ack.

Rule of thumb: **after the handshake, a malformed frame costs you the frame, not the connection** (an
over-size one is the exception — that is the transport, stage 0). And a dropped `result` or `chunk` is never
silently forgiven: it becomes a gap or a missing terminal, and the instruction ends as outcome-unknown.

---

## 3. Instructions

### 3.1 The closed set, its arguments, and the success outcome each one owes

Every `path` in `args` arrives **already absolute and POSIX-normalized** — the server resolved it against
the session's working directory before sending. `cwd` (when present) is likewise absolute; when it is
absent, run in the instruction's implied working directory (the resolution base the server already used).

<!-- closed-set: DEVICE_INSTRUCTION_KINDS -->

| Kind | `args` | Must answer with | Notes |
|---|---|---|---|
| `exec` | `{ command: string }` | `{ok:true, kind:"exec", exitCode, truncated?}` | output goes over `chunk` frames **and** the stream must end with exactly one `exit` chunk (§4) |
| `execStream` | `{ command: string }` | `{ok:true, kind:"exec", exitCode, truncated?}` | same, streamed to the caller live |
| `readTextFile` | `{ path }` | `{ok:true, kind:"read"}` | the bytes travel as `data` chunks |
| `readTextLines` | `{ path, offset?, limit? }` | `{ok:true, kind:"read"}` | `limit` = maximum number of lines; content as `data` chunks |
| `readBinaryFile` | `{ path }` | `{ok:true, kind:"read"}` | content as `data` chunks |
| `writeFile` | `{ path, contentLen, sha256 }` | `{ok:true, kind:"write", canonicalPath, inode?, created?}` | content arrives via `payload` (§3.4) |
| `writeFileExclusive` | `{ path, contentLen, sha256 }` | `{ok:true, kind:"write", …}` | **optional face** — atomic create-if-absent; fail with `already_exists` |
| `writeFileGuarded` | `{ path, contentLen, sha256, expect }` | `{ok:true, kind:"write", …}` | **optional face** — verify `expect` and write in one atomic backend step (§3.5) |
| `appendFile` | `{ path, contentLen, sha256 }` | `{ok:true, kind:"void"}` | content via `payload` |
| `fileInfo` | `{ path }` | `{ok:true, kind:"stat", info}` | `info` required |
| `listDir` | `{ path }` | `{ok:true, kind:"stat", entries}` | `entries` required (may be empty) |
| `canonicalPath` | `{ path }` | `{ok:true, kind:"stat", canonicalPath}` | **effectively mandatory** — see §3.6 |
| `exists` | `{ path }` | `{ok:true, kind:"stat", existsValue}` | the boolean is required; omitting it is a protocol violation, not a `false` |
| `readLink` | `{ path }` | `{ok:true, kind:"stat", linkTarget}` | **optional face**; `linkTarget: null` means "not a symlink" |
| `createDir` | `{ path, recursive? }` | `{ok:true, kind:"void"}` | the server sends `recursive: true` by default |
| `remove` | `{ path, recursive? }` | `{ok:true, kind:"void"}` | the server sends `recursive: false` by default |
| `createTempDir` | `{ prefix? }` | `{ok:true, kind:"write", canonicalPath}` | the created directory's path **is** the receipt |
| `createTempFile` | `{ prefix?, suffix? }` | `{ok:true, kind:"write", canonicalPath}` | same |
| `statBatch` | `{ paths: string[] }` | `{ok:true, kind:"stat", …}` | declared but **not dispatched today** (see §8) |

The server checks the outcome *kind* against the instruction kind. Answering an `execStream` with
`{ok:true, kind:"void"}` — a perfectly well-formed frame — is refused as a protocol violation and the
instruction fails; it is not a shortcut around the stream rules. A `cancelled` outcome and any `ok:false`
outcome are always accepted.

### 3.2 Declaring what you implement: `hello.supports`

`supports` is a plain `string[]` of instruction kinds. The server intersects it with its own closed set;
**a kind you do not declare is never dispatched to you**, and a call that needs it fails loudly with
`not_supported` instead of silently doing something else. A kind the server does not recognize is simply
ignored (that is how a newer executor talks to an older server without breaking it).

Three faces are **presence-typed**, which is a stronger statement than "optional":

- `writeFileExclusive` — atomic create-if-absent
- `writeFileGuarded` — verify-and-write in one atomic step
- `readLink` — read a symlink target

The engine decides *at environment-construction time* whether these exist, and when they do not it
degrades honestly (an advisory check instead of an atomic one). **Therefore: absence is absence. Never
emulate a guarded or exclusive write with a two-step "check then write".** A two-step emulation reopens
exactly the race the atomic primitive exists to close, while wearing the face of an atomic operation —
which is worse than not having it, because nothing downstream can tell.

Two more consequences you must design for:

- **Do not vary `supports` between connections.** A device that reconnects declaring *more* protective
  write faces than it had when a task's environment was built will have its ordinary (non-atomic) writes
  **refused** — the server treats "gained a protective face mid-task" as a silent-downgrade hazard and
  fails loudly rather than write non-atomically.
- **Declare only what you truly implement atomically.** Declaring `writeFileGuarded` and then emulating it
  is the one failure mode this design cannot detect.

### 3.3 Outcomes and error codes

A `result` frame's `outcome` is one of:

```jsonc
{ "ok": true, "kind": "exec",   "exitCode": 0, "truncated": { "stdout": true, "stderr": false } }
{ "ok": true, "kind": "read" }
{ "ok": true, "kind": "stat",   "info": WireFileInfo, "entries": WireFileInfo[],
                                "existsValue": true, "canonicalPath": "/…", "linkTarget": "/…" | null }
{ "ok": true, "kind": "write",  "canonicalPath": "/…", "inode": "…", "created": true }
{ "ok": true, "kind": "void" }
{ "ok": true, "kind": "cancelled", "state": "not_started" | "killed" }
{ "ok": false, "errorCode": "…", "message": "…" }
```

`WireFileInfo` = `{ path: string, kind: "file"|"dir"|"symlink"|"other", size: int, mtimeMs: int, mode?: int }`.
On the `stat` arm, only the field the instruction asked for is required; the others are omitted. On the
`write` arm, `canonicalPath` is required and is the **real on-disk object you just wrote** — `inode` (as a
string) and `created` (did this call create the file?) should be sent whenever you can determine them:
they are the receipt that binds an adjudication to a real object rather than to a path that merely existed
a moment ago. If you omit `created`, the consumer reports the weaker of the two answers.

`message` is required on the failure arm and is shown to the model — make it specific.

<!-- closed-set: DEVICE_OUTCOME_ERROR_CODES -->

| `errorCode` | Use it when |
|---|---|
| `aborted` | the work stopped because it was cancelled |
| `already_exists` | an exclusive create found the target present |
| `auth_failed` | the command could not run for a credential reason on the device (exec family) |
| `callback_error` | a callback/hook on the device side failed (exec family) |
| `invalid` | the arguments do not make sense for this target (e.g. `readLink` on a non-symlink) |
| `is_directory` | a file operation hit a directory |
| `not_directory` | a directory operation hit a non-directory |
| `not_found` | the path does not exist |
| `not_supported` | you do not implement this face (prefer simply not declaring it) |
| `outcome_unknown` | **you cannot say whether the effect happened.** Never auto-retried by anything upstream |
| `permission_denied` | the OS refused, or your own containment policy refused |
| `precondition_failed` | a guarded write's `expect` did not hold |
| `shell_unavailable` | no shell / the executable could not be resolved — the command **never started** |
| `spawn_error` | the process could not be spawned |
| `suspended` | the work was suspended rather than completed |
| `target_unavailable` | the target could not be reached; the work **never started** |
| `timeout` | **you** enforced `timeoutMs`, killed the work, and are reporting it |
| `transport_lost` | a transport the device itself depends on went away |
| `unknown` | none of the above |

Two codes carry load-bearing semantics upstream and must not be used loosely:

- `timeout` is treated as **retryable**. Use it only when you actually killed the work and know it is
  dead. If you are not sure the command is dead, use `outcome_unknown`.
- `outcome_unknown` is never auto-retried, anywhere. It is the honest answer for "the command may have
  already run and I cannot confirm". Choosing `timeout`/`transport_lost` instead invites a second
  execution of a command that already had side effects.

### 3.4 Write payloads

For `writeFile`, `writeFileExclusive`, `writeFileGuarded` and `appendFile` the content does **not** travel
in `args`. The server sends the `instruction` frame and then, immediately, a payload sub-sequence on the
same connection:

```
payload { part: "begin",  instructionId, totalLen, sha256 }
payload { part: "chunk",  instructionId, offset, dataB64 }   × N   (offset in bytes, ascending)
payload { part: "commit", instructionId, totalLen, sha256 }
```

`sha256` is the **hex** digest of the full content, computed by the server over the real bytes;
`args.contentLen` and `args.sha256` carry the same values. Required executor behaviour:

1. accumulate into a temporary file in the same directory as the target (so the final rename is atomic);
2. on `commit`, verify the length **and** the digest;
3. only then rename into place — atomically, honouring the instruction's semantics (exclusive create,
   guarded expectation, append);
4. any failure at any step fails the **whole instruction** and leaves **no half-written file**.

`defaultPayloadPartBytes` is 262144, but do not assume it: use `offset` and `totalLen`.

### 3.5 `writeFileGuarded` and its `expect`

`expect` is `{ canonicalPath: string, fileId?: string, exclusive?: boolean, noFollow?: boolean }`:

| Field | Meaning |
|---|---|
| `canonicalPath` | the fully-resolved path the caller believes it is writing |
| `fileId` | the identity of the object the caller believes is there (an inode-like string); mismatch ⇒ `precondition_failed` |
| `exclusive` | the write must create the file; if it exists ⇒ `already_exists` |
| `noFollow` | do not traverse a final symlink; if the last component is a symlink ⇒ `precondition_failed` |

The whole value of this instruction is that the **check and the write happen in one atomic step on your
side**. Verifying with `stat` and then writing is exactly the emulation that is forbidden (§3.2).

### 3.6 Which kinds are effectively mandatory

- `exec`, `execStream` — without them the lane cannot run tools.
- `exists`, `canonicalPath`, `fileInfo` — the **governance-bearing reads**. The engine's write/read gates
  adjudicate against the *device's* filesystem facts; these three are how it learns them.
  `canonicalPath` in particular is declared authoritative by this lane: if it is missing, the engine reads
  the silence as a resolver failure and refuses reads rather than trusting a lexical judgement. Implement
  it.
- `readTextFile`, `readTextLines`, `readBinaryFile`, `writeFile`, `appendFile`, `listDir`, `createDir`,
  `remove`, `createTempDir`, `createTempFile` — the ordinary filesystem surface the model uses; omitting
  any one of them makes that tool fail with `not_supported` for the user.
- `readLink` — optional but strongly recommended: it is the fourth governance read, and its absence makes
  symlink adjudication coarser.
- `writeFileExclusive`, `writeFileGuarded` — optional; implement them only if you can do them atomically.
- `statBatch` — safe to omit today.

---

## 4. Streaming execution

An instruction's output travels as `chunk` frames bound to its `instructionId`.

<!-- closed-set: DEVICE_CHUNK_KINDS -->

| `kind` | Carries | Rules |
|---|---|---|
| `stdout` | `dataB64` | must carry `dataB64`, must **not** carry `exitCode` |
| `stderr` | `dataB64` | same |
| `data` | `dataB64` | file content for the read family; same field rules |
| `exit` | `exitCode` | must carry `exitCode`, must **not** carry `dataB64`. **Exactly one per exec stream, and it must be the last chunk** |

**Sequencing.** `streamSeq` starts at `0` and increases by exactly one per chunk, per instruction. The
server delivers in order and buffers out-of-order chunks; it acks with `chunkAck { upToStreamSeq }`, a
**cumulative** watermark meaning "everything up to and including this is accepted" (`-1` = nothing yet).
You may release buffers at or below the watermark.

- A gap that is not filled within the deployment's gap timeout (default 30 s) **fails the instruction**
  with `outcome_unknown` — output is never silently truncated.
- The reorder buffer is bounded (default 64 entries / 2 MiB decoded). Running far ahead of the watermark
  overflows it and also fails the instruction.
- Re-sending a `streamSeq` at or below the watermark is absorbed idempotently and re-acked. Re-sending a
  seq that is buffered but not yet delivered is **first-write-wins**: the first copy is kept. So a
  retransmission must carry the *same bytes* as the original.

**The `exit` chunk.** Both `exec` and `execStream` must end with exactly one `exit` chunk, even when the
command produced no output at all (then it is `streamSeq: 0`). The server cross-checks it:

- zero `exit` chunks for a successful exec outcome ⇒ the instruction fails (a missing tail is
  indistinguishable from a truncated one);
- more than one ⇒ fails;
- an `exit` chunk that is not the last delivered chunk ⇒ fails;
- `exit.exitCode` different from the `result` frame's `exitCode` ⇒ fails.

Read-family instructions use `data` chunks and **no** `exit` chunk; their terminal is the `result` frame.

**Ordering against the terminal.** Send every chunk **before** the `result` frame. A *new* chunk that
arrives while the terminal result is being accepted settles the instruction as `outcome_unknown` — on an
ordered socket, "result then more output" is self-contradictory. Once the instruction is fully settled, a
late chunk is simply dropped without a `chunkAck` (§2.4): it can neither overturn the answer nor reach the
caller. Either way, output sent after the terminal is output nobody will ever see.

**Encoding.** `dataB64` must be **canonical** base64: the standard alphabet (`A–Z a–z 0–9 + /`), correct
`=` padding, no line breaks, no URL-safe alphabet, and the exact padding form that re-encodes to the same
string. A lax encoder that emits a sloppy variant will have its frames refused as `bad_shape`, which turns
into a stream gap, which fails the instruction.

**Flow control.** There is no credit window beyond `chunkAck`. The practical bounds are the reorder buffer
above and, on the consumer side, a pending-delivery bound (2 MiB / 8192 entries) whose overflow **cancels
the remote command**. Two habits keep you inside them: keep chunks moderate (≤ 64 KiB of raw bytes is a
good default) and do not stream thousands of empty chunks — the entry count is bounded too, so a flood of
zero-byte chunks is just as fatal as a flood of large ones.

---

## 5. Reliability semantics

This is the section to implement literally. Each rule is stated as a testable **given / when / then**.

### 5.0 The one invariant everything else serves

**At most once.** An instruction is executed at most once on the device. The server crosses a one-way
commit *before* the first byte of the `instruction` frame reaches the socket, and it **never re-sends a
committed instruction** — not after a reconnect, not after a timeout, not ever. `ws.send()` succeeding
proves only that bytes entered a local buffer, so "re-send if unsure" is not available to either side.
Everything the server cannot confirm collapses to `outcome_unknown`, whose contract is "may have already
executed — verify before re-running".

### 5.1 Instruction states

<!-- closed-set: DEVICE_INSTRUCTION_STATES -->

| State | Meaning |
|---|---|
| `queued` | accepted by the server but not yet past the commit; the device has structurally never seen it |
| `dispatch_committed` | the commit was crossed; the device may have seen it. The only state in which a `result` is accepted |
| `terminal` | settled (by the device's answer, by a cancel receipt, or by a server-side backstop) |
| `terminal_never_started` | rejected before the commit — safe to retry; the device never saw it |

There is deliberately **no "delivered" state**: it would not be observable, and inventing one creates a
window in which a result that arrives "too early" is discarded.

### 5.2 Rules

**R1 — a committed instruction is never re-sent.**
*Given* the server has written an `instruction` frame for `I`, *when* the connection drops at any moment
afterwards, *then* the server never sends `I` again — on this connection, on a reconnect, or on another
replica. Whoever received it holds the only copy of that work.
*Executor:* you own `I` from the moment you parse it. Do not expect a re-delivery, and never treat a
re-delivered-looking frame with a **new** `instructionId` as the same work.

**R2 — same-epoch reconnect resumes; it does not re-dispatch. `pendingInstructions` is the only authority.**
*Given* `I` was dispatched and the socket dropped, *when* you reconnect with the **same `epoch`** and the
server still holds `I` in flight, *then* `helloAck.pendingInstructions` contains `I` and
`helloAck.chunkWatermarks[I]` is the highest contiguous `streamSeq` already accepted (`-1` = none).
*Executor:* keep executing `I` (do not restart it, do not kill it — see §1.5). After `generationAck`,
re-send only (a) `result` frames not yet acked and (b) chunks with `streamSeq > chunkWatermarks[I]` —
re-stamped with the **new** `gen`. For any instruction **not** listed: terminate it and discard its
buffers; the server has closed its books and will refuse or silently absorb its frames.

⚠️ **Resume is a best effort, not a guarantee — three things can remove `I` from the list even on a
same-epoch reconnect inside the grace window**, and the honest rule is that you must handle its absence:
1. **the server process is not the same one.** In-flight state, watermarks and the settled memo live in the
   memory of the replica that dispatched `I`; a restart, a redeploy or a connection that lands on a
   different replica loses them, and a shutting-down replica settles its in-flight work as outcome-unknown;
2. **its own deadlines kept running while you were gone.** The execution ceiling (`timeoutMs` + grace), the
   cancel grace and the chunk-gap timer are *not* paused by a disconnect — `I` can be long settled by the
   time you get back;
3. **the reconnect grace expired** (R4).

So: never derive "I may keep running" from the clock. Derive it from the list.

**R3 — a new epoch abandons everything in flight.**
*Given* `I` was in flight, *when* you reconnect with a **different `epoch`** (i.e. the executor process
restarted), *then* the server settles `I` as `outcome_unknown` *before* sending `helloAck`, and `I` is
absent from `pendingInstructions`. A later `result` for `I` is either refused `unknown_instruction` or
answered with a bare `resultAck` from the settled memo (§2.4) — **the ack does not mean it was accepted**;
a later `chunk` is dropped.
*Executor:* mint a fresh `epoch` on every process start, and never resume or re-run work from a previous
process — that work is accounted for as "may have run".

**R4 — no reconnect within the grace ⇒ the books close.**
*Given* the socket dropped with `I` in flight, *when* no connection for this device is established within
the reconnect grace (default 120 s), *then* `I` settles `outcome_unknown`; a later reconnect does not list
it, and its `result` is either refused or memo-acked without being believed (§2.4).
*Executor:* reconnect promptly. If you were offline longer than the grace, your results are no longer
deliverable — that is not a licence to re-run anything.

**R5 — a lost `result` (or a lost `resultAck`) is retried, bounded, never re-executed.**
*Given* you sent `result` for `I` and no `resultAck` arrived, *when* you re-send the **same** frame with
the current `gen`, *then* one of three things happens: (a) if the server has not settled `I`, the resend is
processed normally; (b) if `I` is already settled — **for any reason, including one you did not cause**
(abandoned on a new epoch, given up at the execution ceiling, failed on a stream gap) — the server answers
`resultAck` from the memo of the 512 most recently settled instructions and changes nothing; (c) if `I` has
aged out of that memo, the frame is refused `unknown_instruction` and **no ack will ever come**.
*Executor:* hold the terminal result until `resultAck`, retry it on each reconnect, and bound both the
buffer and the retry count. Give up locally after that — never escalate a missing ack into re-execution.

**R6 — `deviceSeq` makes a retransmitted result idempotent.**
*Given* `I`'s highest accepted `deviceSeq` is `n`, *when* a `result` for `I` arrives with `deviceSeq ≤ n`,
*then* the server re-acks and absorbs it without touching the outcome; with `deviceSeq > n` on an unsettled
instruction, it is treated as a **new** terminal.
*Executor:* assign `deviceSeq` once per instruction result and reuse that exact value for every
retransmission of it. Do not bump it "because this is a retry".

**R7 — a lost `chunkAck` is recovered by re-sending, and re-sending is safe.**
*Given* chunks `0..n` were sent and `chunkAck` was lost, *when* you re-send from `chunkWatermarks[I] + 1`
(or anything below it), *then* chunks at or below the watermark are absorbed and the server re-sends the
current watermark; a seq already buffered is kept as first-written and also re-acked.
*Executor:* resume from the watermark. A retransmission of a given `streamSeq` must carry the same bytes
as the original — the first copy the server buffered is the one it will deliver.

**R8 — a gap is a failure, not a truncation.**
*Given* the server is waiting for `streamSeq k`, *when* `k` does not arrive within the gap timeout
(default 30 s) or the reorder buffer overflows (default 64 entries / 2 MiB), *then* `I` fails with
`outcome_unknown` — the command already ran, so the result cannot be faithfully reported.
*Executor:* never skip a `streamSeq`, never renumber after a reconnect, never "compact" a stream.

**R9 — old-generation frames are void.**
*Given* `helloAck` negotiated generation `G`, *when* you send any frame whose `gen ≠ G` (or on a socket
that is no longer this device's current connection), *then* the frame is dropped with a
`generation_mismatch` audit and **no ack**.
*Executor:* stamp every frame with the current `G`; re-stamp everything you retransmit after a reconnect;
and discard, without acting on them, any frames you receive on a socket you have superseded. This fence is
the only thing that stops a stale socket's traffic from corrupting a fresh connection's accounting.

**R10 — nothing is dispatched before `generationAck`.**
*Given* `helloAck(G)`, *when* you have not yet sent `generationAck { gen: G }`, *then* the server
dispatches nothing to you.
*Executor:* use that window to reconcile against `pendingInstructions` (§1.5) — **keep** the listed work
running, **kill** everything else you inherited from an older generation — and only then send
`generationAck`. Sending it while unaccounted older-generation work is still alive is the one way to get two
generations running at once. The server enforces the ordering but cannot verify the fence, so pair this with
a **device-level single-instance lock**: the cloud side cannot stop a second executor process on the same
machine from taking over the lease mid-command; only you can.

**R11 — `cancel` demands a terminal, within a grace.**
*Given* the server sent `cancel` for `I`, *when* you do not send a terminal `result` within the cancel
grace (default 10 s, restarted each time the cancel is delivered), *then* `I` settles `outcome_unknown`.
`cancel` is idempotent, is delivered at most once per generation, and **is replayed on the first active
tick after a same-epoch reconnect** (a cancel written to a socket that then dropped may never have
arrived).
*Executor:* treat a repeated `cancel` for the same `I` as a no-op. Answer exactly one terminal:
`{ok:true, kind:"cancelled", state:"not_started"}` if the work had not begun, `state:"killed"` if you
killed it, or the work's ordinary outcome if it had already finished. Answer promptly — 10 s is the budget.

**R12 — two-layer timeout: yours is authoritative, the server's is a backstop.**
*Given* an instruction carrying `timeoutMs`, *when* that budget elapses, *then* you must kill the work and
answer `{ok:false, errorCode:"timeout"}` — that is the only path to the retryable `timeout` word. *When*
you answer nothing, the server gives up at `timeoutMs` + a grace (default 30 s) and settles
`outcome_unknown`; a later result for `I` is then undeliverable.
*Executor:* enforce `timeoutMs` yourself, and prefer answering late-but-honestly over not answering.

**R13 — the three `bye` reasons are three different instructions to you.**

<!-- closed-set: DEVICE_BYE_REASONS -->

| `reason` | What happened | Correct reaction |
|---|---|---|
| `revoked` | the device was revoked; in-flight work is being written off as outcome-unknown | **terminal.** Stop reconnecting, delete the key material, tell the user to re-enrol (new `deviceId`) |
| `draining` | this replica is shutting down or upgrading | finish nothing new; reconnect with backoff — a replacement replica will take you |
| `superseded` | another connection took this device's lease | **another executor instance is live.** Do not race it: stop, and fix the single-instance lock on this machine |

*Given* a `bye`, *then* the server closes the socket right after; anything you send afterwards is void
(R9). In-flight instructions are settled `outcome_unknown` for `revoked` and `superseded`.

**R14 — liveness.**
*Given* `helloAck.heartbeatIntervalMs = H` (default 15000), *when* the server sees **no frame at all**
from you for `H × 3`, *then* it closes the connection and your in-flight work enters the reconnect grace
(R4). Any device→server frame refreshes liveness, but heartbeats are what keep a quiet connection alive.
*Executor:* send `heartbeat { gen, seq, inflight }` every `H`, with `seq` increasing and `inflight` listing
the instruction ids you are actually working on.

**R15 — graceful shutdown.**

<!-- closed-set: DEVICE_GOODBYE_REASONS -->

| `reason` | Use it when |
|---|---|
| `shutdown` | the executor process is exiting (service stop, machine shutdown) |
| `sleep` | the machine is suspending; you expect to come back with the **same** `epoch` |
| `user_stop` | the user stopped the executor |

*Given* you sent `goodbye`, *then* the server stops dispatching new instructions immediately, keeps
collecting results for the in-flight ones, and closes after `graceMs` (default: the reconnect grace).
*Executor:* send `goodbye` before you exit; then either finish your in-flight work or fail it explicitly.

### 5.3 Cancel receipt states

<!-- closed-set: DEVICE_CANCEL_STATES -->

| `state` | Meaning |
|---|---|
| `not_started` | the instruction had not begun executing when the cancel took effect — nothing happened on the machine |
| `killed` | it was running and you killed it — side effects up to that point may exist |

These two are a real distinction to the caller: `not_started` is safely retryable, `killed` is not.

### 5.4 What an executor must never replay

- **Never re-execute** an instruction it already started or finished, for any reason, including a missing
  `resultAck`, a reconnect, or a restart.
- **Never replay a cached outcome against a different `instructionId`.** Results are not keyed by command
  text; every `instructionId` is a distinct unit of work, even if the command string is identical.
- **Never replay frames stamped with an old `gen`** — re-stamp, or drop.
- **Never send a second `hello` on an established connection**, and never re-send `generationAck` for a
  superseded generation.
- **Never re-number, re-order or re-cut a stream on retransmission.** Same `streamSeq`, same bytes.
- **Never send anything for an instruction the server did not list in `pendingInstructions`** after a
  reconnect.

---

## 6. Security requirements for the executor

1. **Outbound only.** The executor opens the connection; nothing dials in. Do not open a listening socket
   "for convenience" — this lane's safety story depends on the device having no inbound surface.
2. **The private key never leaves the machine.** It is generated locally at enrollment, only the public
   key is uploaded, and no frame ever carries it. Store it in the OS keychain or a file readable only by
   its owner. There is no long-lived bearer token on the wire to steal — the handshake is a fresh
   signature over a server-chosen nonce every time. Keep it that way.
3. **The enrollment token is not a device credential.** It is plaintext, single-use and short-lived
   (default 15 minutes). Do not log it, do not persist it after a successful registration, and do not
   retry a registration with a *different* public key — that is recorded as token reuse and refused.
   Both enrollment endpoints are rate limited (10 per 60 s per caller identity, §1.1); honour the
   `retry-after` header rather than spinning, and note that a rate-refused attempt does not consume the
   token.
4. **Sign only handshakes, only with the nonce from the current socket.** The domain tag exists so that a
   signature from this key cannot be replayed into another protocol. Never expose a generic "sign these
   bytes" helper over any local IPC.
5. **The four governance reads must answer truthfully.** `exists`, `canonicalPath`, `fileInfo` and
   `readLink` are not conveniences — the engine's write gates adjudicate on the *device's* filesystem
   facts, and there is no second source of truth. Answering approximately (guessing `existsValue`,
   reporting a lexical path as canonical, resolving symlinks "helpfully") corrupts decisions made on the
   user's behalf. When you cannot answer, fail with a real error code; do not invent an answer.
   *Honest boundary*: this is a decision-and-audit property, not enforcement against a compromised
   machine. The protocol assumes an honest executor; it protects against races and mistakes, not against
   an executor that lies.
6. **Respect `workspaceRoot` and resolve nothing yourself.** Paths arrive absolute and normalized; the
   server has already resolved them against the session's working directory, falling back to the
   `workspaceRoot` you registered. Do not re-interpret them, do not expand `~`, do not follow a different
   base. You **may** enforce your own containment policy on top (refusing paths outside a root you are
   willing to serve) — if you do, refuse with `permission_denied`. What you must never do is silently
   redirect a path to somewhere else: a write that lands in a different place than the caller adjudicated
   is the exact failure this lane is built to prevent.
7. **Do not cache and replay results.** No answer may be served from a cache keyed by command text, path
   or content hash. Every instruction runs (or fails) on its own.
8. **Treat `shellEnv` and `cwd` as data, not as instructions.** They are supplied by the deployment and
   the task; merge `shellEnv` over your process environment for that command only, and never persist it.
9. **Keep content out of your logs.** File bytes, payload contents and command output belong in the
   protocol frames, not in a local log file that outlives the task.
10. **One executor per device id.** Hold a machine-local single-instance lock keyed by `deviceId`. Two
    processes sharing one identity produce `already_connected` / `superseded` flapping and, worse, can
    have commands running on both sides of a generation change.

---

## 7. Minimum viable executor — checklist

**Frames you must send**

- [ ] `hello` — with a correct signature (§1.3), a fresh per-process `epoch`, `workspaceRoot`, and an
      honest `supports` list
- [ ] `generationAck` — after fencing older generations
- [ ] `heartbeat` — every `heartbeatIntervalMs`, with `seq` and `inflight`
- [ ] `result` — exactly one terminal per instruction, held until `resultAck`
- [ ] `chunk` — contiguous `streamSeq`, canonical base64, exactly one trailing `exit` chunk for exec
- [ ] `goodbye` — on shutdown

**Frames you must handle**

- [ ] `challenge`, `helloAck`, `helloReject` (all five codes, §1.4)
- [ ] `instruction` — an exhaustive switch over the kinds you declared; an undeclared kind should never
      arrive, and if one does, answer `not_supported` rather than guessing
- [ ] `payload` — `begin` / `chunk` / `commit`, temp file + digest verification + atomic rename
- [ ] `cancel` — idempotent, answered with a terminal within ~10 s
- [ ] `resultAck` / `chunkAck` — release buffers
- [ ] `bye` — all three reasons, each with its own reaction (R13)
- [ ] an `instruction` that arrives **after** your `goodbye` — execute it or fail it explicitly (§1.6)
- [ ] `ping` — accept and ignore it if it ever appears (§8)

**Instructions to implement first**

- [ ] `exec`, `execStream`
- [ ] `exists`, `canonicalPath`, `fileInfo` (governance reads — do these before anything optional)
- [ ] `readTextFile`, `readTextLines`, `readBinaryFile`
- [ ] `writeFile`, `appendFile`
- [ ] `listDir`, `createDir`, `remove`, `createTempDir`, `createTempFile`

**Safe to leave undeclared at first**

- [ ] `readLink` (recommended soon after — it is the fourth governance read)
- [ ] `writeFileExclusive`, `writeFileGuarded` — declare only when atomic (§3.2)
- [ ] `statBatch` — not dispatched today

**Process shape (recommended)**

- A **separate, long-lived process** from any interactive shell session on the machine — it must survive
  the user closing a terminal, and it must not share state with an interactive session.
- A machine-local single-instance lock keyed by `deviceId`.
- Reconnect with exponential backoff and jitter (1 s → 60 s), except on `revoked`.
- A separate, slower retry path for **enrollment**: honour `retry-after` on a `429`, never faster than
  once per second, with a hard attempt cap (§1.1).
- A bounded outbox for unacked results and chunks, with an explicit give-up (R5) instead of unbounded
  growth.
- A clean process-kill path: every running command must be killable by `instructionId` for `cancel` and
  for the generation fence.

---

## 8. Honest absences (today, not a roadmap)

- **No background or long-lived process face.** Every instruction is bounded by `timeoutMs` and ends with
  a terminal result. There is nothing today that carries `logcat -f`, `flutter run`, a dev server or any
  other process that is supposed to outlive its instruction. There is also **no stdin channel** and no
  interactive/pty face: an instruction cannot be fed input after it starts, only cancelled.
- **Hook commands and stdio MCP servers run on the cloud host, not on the device.** Do not expect the
  device to be asked to run them.
- **Presence assumes a single replica.** "Is this device online" is answered from the connection held by
  the replica that answers the request.
- **`ping` is declared but never sent.** It exists in the closed set so that adding server-side pings
  later is additive. Accept and ignore it.
- **`statBatch` is declared but never dispatched.** Nothing calls it today.
- **`readTextLines.offset` is never sent today.** The field exists in the wire type, but the only caller
  sets `limit`. Implement `limit`; accept `offset` if you like, but nothing will exercise it yet.
- **`hello.resuming` is accepted but not acted upon.** The resume decision is made entirely from `epoch`
  plus the server's own pending set; sending `resuming` is harmless and informational.
- **`hello.pathFlavor` is accepted as a free string and not enforced.** This version is POSIX-only; the
  platform closed set at enrollment is `darwin | linux`. Windows is not supported — declaring it would be
  a lie about path semantics, shell quoting and workspace validation, none of which are negotiated yet.
- **`hello.workspaceRoot` does not change path resolution.** The resolution base is the `workspaceRoot`
  recorded at **enrollment**. Send the same value in `hello`; changing it there has no effect today.
- **No symlink-chain instruction.** There is no per-hop chain op; `canonicalPath` answers the endpoint and
  `readLink` answers a single hop. Do **not** emulate a chain with repeated `readLink` calls: each hop is
  a round trip and the filesystem can change between them, which is precisely the race the absent op would
  have closed.
- **No artifact/file-transfer face beyond the read and write instructions**, and everything is bounded by
  the 1 MiB frame cap (large writes are sliced by `payload`; large reads by `data` chunks).
- **No credit-based flow control.** `chunkAck` is a watermark, not a window (§4).
- **In-flight state is per server process, and resume is best-effort.** The pending set, the chunk
  watermarks and the settled memo live in one replica's memory; a restart, redeploy or a reconnect that
  lands elsewhere loses them, and the per-instruction deadlines keep running while you are disconnected. The
  protocol's answer is `pendingInstructions` — there is no durable instruction journal to reconcile against
  (R2).
- **`resultAck` is not an acceptance receipt.** It means "you may release this frame". An instruction the
  server already wrote off is still acked from the settled memo, so nothing on the wire tells you whether
  your outcome was the one the caller saw (§2.4).
- **`goodbye` is not a hard dispatch barrier.** A dispatch already inside the pipeline can still reach you
  after it (§1.6).
- **An over-size frame costs the connection, not the frame.** The 1 MiB cap is enforced by the transport
  while reassembling, so there is no application-level refusal for it after the handshake (§2.4 stage 0).

---

## 9. First bind: telling the server which device a session runs on

Registering a device does not by itself route any work to it. The session must be **bound** to the device,
and that happens on the submit leg: the task/run submission body carries a top-level optional key
`deviceId`, at the same level and with the same trust posture as `cwd`.

The short version for an executor implementer:

- the key is carried by the **root task submission only** (descendants inherit; the resume family does not
  accept it);
- the first submission that carries it binds the session (concurrent submissions produce a single winner);
- afterwards, omitting it reuses the binding, repeating the same value is idempotent, and naming a
  *different* device is refused — changing devices is an explicit rebind action, never an implicit one;
- a non-device deployment refuses the key outright rather than ignoring it.

The full contract — request shapes, every refusal code, the rebind verb, and the disclosed races — is in
`docs/ASSISTANT-WIRE-CONTRACT.md` §14.6 (and §14.1–14.5 for the management verbs). It is not repeated
here.
