# P1 — Tunnel & Mux Protocol + Relay Data Plane — Implementation Plan

> **Plan of record for the byte hot-loop.** Owns the native WS multiplexing protocol
> (frame codec, stream lifecycle, per-stream credit flow-control, 15s heartbeat, reconnection,
> graceful drain) between the host-agent and the **stateless** relay data plane; TLS termination;
> per-tenant **subdomain** routing (route by *authenticated session*, never the Host header);
> **opaque CIPHERTEXT forwarding** (parses zero terminal semantics); and the **v0.8 frp-based MVP
> scaffold** as an explicit stepping-stone toward the native mux.
>
> **Authoritative inputs** (do not reopen): [`EXPLORE_RELAY_SERVICE.md`](./EXPLORE_RELAY_SERVICE.md)
> §0 LOCKED DECISIONS, §3 (control/data split, multiplexing, routing, drain), §5 (MVP shortcuts);
> [`PLAN_RELAY_INDEX.md`](./PLAN_RELAY_INDEX.md) §3 (security invariants), §4 (**FROZEN SHARED
> CONTRACTS** — cited verbatim below, never redefined), §5 (P1 charter).
> Conventions follow [`PLAN.md`](./PLAN.md) / [`PLAN_VOICE_COMMANDS.md`](./PLAN_VOICE_COMMANDS.md):
> stable task IDs in dependency waves, disjoint `Owns:` per task, function-signature-level contracts,
> TDD (tests FIRST), explicit test cases incl. security/negative, per-task security notes.
> `PROGRESS_LOG.md` is **orchestrator-only** (subagents return a ready-to-paste entry, G1).

---

## 0. Scope & non-scope

**In scope (this plan, nothing else):**
- The **native WS mux** substrate: binary frame codec for the §4.1 frame format, per-stream state
  machine (`OPEN → (DATA | WINDOW_UPDATE)* → CLOSE`), **credit-based per-stream flow-control /
  backpressure** (WINDOW_UPDATE), 15s **PING/PONG heartbeat**, and **GOAWAY** graceful drain.
- The **stateless relay data-plane node**: TLS termination for inbound browser `wss://`, holding
  outbound agent mux tunnels, the WS-upgrade authorization edge (Origin **retained** + capability
  token **required**), **per-tenant subdomain routing** resolved from the authenticated principal,
  and the **opaque byte splice** browser-WS ↔ mux-stream ↔ (agent's) `ws://127.0.0.1:3000`.
- The **v0.8 frp scaffold**: `frps` config (wildcard vhost/subdomain routing) as the transport
  stepping-stone, contract-compatible with the native mux so the v0.9 swap reshapes nothing.

**Explicitly NOT in scope (owned by other plans — depend on their interfaces, never their files):**
- Accounts / host & routing registries / pairing issuance / cert authority / metering → **P3 CONTROL PLANE**.
- Ed25519 enrollment, agent dial-out client, `frpc` wrap, loopback forwarder, `npx …-agent pair` → **P2 AGENT**.
- Capability-token issuance/signing, mTLS cert verify, deny-by-default authz model, CI cross-tenant
  tripwire, revocation, audit log, rate-limits → **P5 AUTH & ISOLATION**. (P1 *enforces* these at the
  edge via injected interfaces; it does not *own* their logic.)
- E2E handshake + AEAD envelope → **P4 E2E** (rides P1 DATA frames as opaque ciphertext).
- Login / dashboard / browser-side crypto / client-side preview → **P6 FRONTEND**.

**Base app unchanged:** `src/`, `public/`, `protocol.ts`, the session model — **byte-for-byte
untouched**. P1 terminates at `ws://127.0.0.1:3000` where the agent (P2) is *just another local WS
client*. The single base-app touch-point (`ALLOWED_ORIGINS` gaining the subdomain) is applied at
**agent-install** time (P2), not by P1.

---

## 1. Security invariants this plan enforces

Per INDEX §3, P1 is a **primary owner** of **INV1, INV2, INV5, INV6, INV7, INV11, INV15** and
contributes to **INV9, INV12, INV13** (transport carries them). Mapping to enforcement points:

| INV | Where P1 enforces it | Task |
|---|---|---|
| **INV1** cross-tenant isolation — A can never reach B | `authorizeUpgrade` (thin adapter, FIX 6a) resolves the subdomain → `requestedHostId` and **delegates the `token.host == hostId` gate to P5's `onUpgrade`/`onReattach`**; Host header is a hint only, never the authority. No code path resolves a host by raw addr/port/user hostname. | T8 (→P5), T9, T13 |
| **INV2** relay handles only ciphertext | DATA payloads are copied **opaque**; the splice never decodes them; relay imports **no** xterm/ANSI parser. | T2, T10, T13 |
| **INV5** no plaintext/secret at rest | Data plane holds **no** durable buffers; per-connection allocation only, **no global mutable buffer pool**; nothing written to disk/Redis by P1 except metadata via P3. | T10, T13 |
| **INV6** deny-by-default authz on connect AND reattach | The deny-by-default **decision** is P5's (`onUpgrade`/`onReattach`, FIX 6a); `authorizeUpgrade` is a thin adapter that opens a stream **only** on P5's `{ok:true}` and passes every 401/403 through verbatim. Reattach flows the same P5 gate via `onReattach` (base-app `sessionId` re-authorized as a claim, not trusted). | T8 (→P5) |
| **INV7** stateless data plane | Node holds only in-RAM tunnel/stream maps; a crash loses nothing (agent reconnects + re-registers via P3). No DB-of-record on the node. | T9, T10, T11, T13 |
| **INV11** no terminal parsing in relay/agent | Static tripwire: relay/mux code has zero dependency on any terminal/ANSI parser; frames pass through opaque. | T2, T10, T13 |
| **INV15** capability token required on WS upgrade | The adapter extracts the token via the **frozen §4.3 FIX-5 subprotocol/cookie carriers only** (never the query string) and forwards it to P5, which enforces Origin/CSWSH + token validity + `aud`/`rights`. The relay echoes only `APP_SUBPROTOCOL` (`term.relay.v1`), never the token entry. | T8 (→P5) |
| **INV9** secrets validated at startup, never logged | `loadDataPlaneConfig` fails fast on missing TLS/CA/secret material; no secret ever logged. **Data-plane edge access logs MUST NOT include the raw upgrade `req.url`, the `Authorization` header, or the `Sec-WebSocket-Protocol` header value** (any of which can carry the capability token); log only `{subdomain, hostId, jti, decision, ts}`. | T1, T8, T10 |
| **INV12** fast revocation | **T14 subscribes to the frozen §4.2 `relay:revocations` bus** (FIX 4) and maps a `KillSignal` → immediate §4.1 teardown: host/account scope → `drainHost(reason=revoked)` RST+close (grace forced to 0, ≤ `REVOCATION_PUSH_BUDGET_MS`), global → node-wide GOAWAY+RST. Three-tier levers: `closeStream(hostId, {jti})` (single-device, see OQ6), `closeTunnel`/`drainHost(revoked)` (whole host, immediate), `operatorDrain`/`shutdown` (grace only for clean migration). P5 **publishes**; every P1 node **subscribes** (P1 never imports P5). | T6, T9, T10, T11, **T14** |
| **INV13** anti-replay/injection | P1 preserves per-message ordering and never reorders/dedups DATA (so P4's `seq`/nonce guarantees hold end-to-end); illegal frame transitions RST the stream. | T3, T6 |

---

## 2. Files & ownership (new package `term-relay/`, data-plane subtree only)

P1 and P3 co-inhabit `term-relay/` but own **disjoint subtrees** (INDEX §0). P1 owns `term-relay/mux/`
(the reusable protocol library, also imported by P2), `term-relay/data-plane/`, and
`term-relay/frp-scaffold/`. **Types** for §4.1 live in the INDEX-frozen `relay-contracts/`; P1 imports
them read-only and never redefines them. Files ≤ 400 lines typical, 800 hard max (`coding-style.md`).

| File | Action | Task | Phase |
|---|---|---|---|
| `term-relay/package.json`, `term-relay/tsconfig.json`, `term-relay/vitest.config.ts` | create — package skeleton (P1 seeds; P3 extends its own subtree) | T1 | v0.8 |
| `term-relay/data-plane/config.ts` | create — env → `DataPlaneConfig` (Zod, fail-fast, INV9) | T1 | v0.8 |
| `term-relay/mux/frame-codec.ts` | create — `encodeMuxFrame`/`decodeMuxFrame`/`decodeHeader` (§4.1) | T2 | spec v0.8 / live v0.9 |
| `term-relay/mux/type-bytes.ts` | create — `MuxFrameType ⇄ byte` + `flags` bit maps (§4.1) | T2 | v0.9 |
| `term-relay/mux/frame-guards.ts` | create — Zod boundary validation of OPEN/WINDOW_UPDATE/GOAWAY payloads (CBOR decode + `relay-contracts` schema) | T2 | v0.9 |
| `term-relay/mux/stream.ts` | create — pure stream state machine (`nextStreamState`, transitions) | T3 | v0.9 |
| `term-relay/mux/flow-control.ts` | create — credit-based `FlowController` (per-stream + link) | T4 | v0.9 |
| `term-relay/mux/heartbeat.ts` | create — 15s PING/PONG liveness (injectable timer) | T5 | v0.9 |
| `term-relay/mux/mux-session.ts` | create — multiplexer over one WS: demux, streams, flow, heartbeat, GOAWAY | T6 | v0.9 |
| `term-relay/data-plane/subdomain-router.ts` | create — `extractSubdomain` + `RouteResolver` interface (impl = P3) | T7 | v0.9 |
| `term-relay/data-plane/upgrade.ts` | create — `authorizeUpgrade` edge (Origin + capability token, INV1/6/15) | T8 | v0.9 |
| `term-relay/data-plane/agent-listener.ts` | create — accept agent dial-out, mTLS-verify (P5 iface), build `MuxSession`, register route (P3 iface) | T9 | v0.9 |
| `term-relay/data-plane/relay-node.ts` | create — stateless node: browser upgrade → openStream → **opaque splice** | T10 | v0.9 |
| `term-relay/data-plane/drain.ts` | create — GOAWAY graceful drain + `closeTunnel` for revocation (INV7/INV12) | T11 | v0.9 |
| `term-relay/data-plane/revocation-subscriber.ts` | create — subscribe `relay:revocations` (§4.2, FIX 4) → `KillSignal`→§4.1 CLOSE+RST/GOAWAY via T10/T11 (INV12) | T14 | v0.9 |
| `term-relay/frp-scaffold/frps.toml` | create — wildcard-subdomain vhost config (v0.8 substrate) | T12 | v0.8 |
| `term-relay/frp-scaffold/README.md` | create — VPS + wildcard DNS/TLS + `frps` runbook; retirement note | T12 | v0.8 |
| `term-relay/frp-scaffold/plugin-hook.ts` | create — thin frp server-plugin HTTP shim → delegates authz to P3 (no tenancy logic here) | T12 | v0.8 |
| `term-relay/test/**` (mux + data-plane + tripwire) | create — vitest unit/integration + INV tripwires | T2–T14 | per task |
| `relay-contracts/**` | **read-only** (INDEX-frozen §4.1 types) — P1 imports, never edits | — | — |

**Freeze rule:** T2's codec + T3–T5 pure modules export a stable surface consumed by T6, then T6 by
T9/T10; freeze each layer before the next consumes it (same discipline as PLAN_VOICE_COMMANDS §2).

---

## 3. Dependency waves

```
W0  package skeleton + config                         T1
      │
      ▼
W1  pure protocol leaves (all parallel)               T2 codec · T3 stream · T4 flow · T5 heartbeat
      │            (also parallel, separate lane)      T12 frp scaffold  ── v0.8, no dep on W1
      ▼
W2  mux session (assembles T2–T5)                      T6
      │
      ▼
W3  data-plane wiring (parallel where file-disjoint)  T7 router · T8 upgrade(→P5) · T9 agent-listener
      │                                                T10 relay-node (dep T6,T8,T9) · T11 drain (dep T6,T9)
      │                                                T14 revocation-subscriber (dep T10,T11) ── FIX 4
      ▼
W4  invariant tripwires + integration                 T13
```

**Cross-plan dependencies (interfaces, not files):**
- **T8** (FIX 6a) **delegates the authorization decision to P5** → injected `Authorizer` (`onUpgrade`/`onReattach`,
  §6a) consuming P5's frozen `UpgradeContext`/`AuthzOutcome`/`DpopContext`. T8 no longer imports a
  `CapabilityVerifier`/`OriginCheck` — P5 owns Origin/CSWSH + token verify + DPoP + rate + burn + audit.
- **T7/T8** depend on **P3**'s host registry read → injected `RouteResolver` (subdomain → `{hostId, accountId}`);
  T8 uses it only to name `requestedHostId` for the `UpgradeContext` (P5 makes the cross-tenant call).
- **T9** depends on **P5**'s mTLS peer-cert verify → injected `MtlsVerifier`; and **P3**'s Redis routing
  table write → injected `RouteRegistrar` (`route:{host_id}` heartbeat-TTL, §4.2).
- **T10** DATA path is contract-shared with **P2** (agent side of §4.1) and carries **P4**'s `E2EEnvelope`
  opaquely (§4.4) — P1 needs no P4 code (INV2).
- **T11** GOAWAY drain is invoked by **P3** node-drain coordination (`operatorDrain`/`shutdown`, grace honored); whole-host `closeTunnel`/`drainHost(reason=revoked)` (immediate, grace forced to 0) and single-device `closeStream(hostId, {jti})` are the levers.
- **T14** (FIX 4) **subscribes to the frozen §4.2 `relay:revocations` bus** — **P3/P5 publish** a `KillSignal`,
  every P1 node consumes it and drives T10/T11 teardown. The `KillSignal`/`RevocationScope`/channel constants
  live in `relay-contracts` (promoted out of `relay-auth`) so **P1 never imports P5** (DAG stays acyclic). P3
  OQ4's "control→node drain channel" reconciles to this exact channel.

In **v0.8**, the frp scaffold (T12) lets P2/P3 integrate against a real transport while T2–T14 are
built; the v0.9 native-mux swap is contract-compatible by construction (both speak §4.1 routing +
opaque byte forwarding), so nothing downstream reshapes.

---

## 4. Frozen shared contracts consumed (cited verbatim from INDEX §4 — NOT redefined)

P1 **imports** these from `relay-contracts/`; it must never re-declare them locally.

**§4.1 Mux frame (15-byte header)** — `version(1B)=0x01 · type(1B) · flags(1B: bit0 FIN, bit1 RST) ·
streamId(4B uint32, 0=link-level) · payloadLen(8B uint64 ≤ maxFrameBytes) · payload(var)`. Types:
`0x01 OPEN · 0x02 DATA · 0x03 CLOSE · 0x04 PING · 0x05 PONG · 0x06 WINDOW_UPDATE · 0x07 GOAWAY`.
Lifecycle `OPEN → (DATA | WINDOW_UPDATE)* → CLOSE`; illegal transition ⇒ **RST that stream, never the
tunnel**; `streamId` allocated by the **relay**, monotonic per tunnel, **never reused**.

```ts
// from relay-contracts (§4.1) — imported, frozen:
export type MuxFrameType = 'open' | 'data' | 'close' | 'ping' | 'pong' | 'windowUpdate' | 'goaway'
export interface MuxFrameHeader {
  readonly version: 1; readonly type: MuxFrameType; readonly fin: boolean; readonly rst: boolean
  readonly streamId: number; readonly payloadLen: number
}
export interface MuxOpen {
  readonly streamId: number; readonly subdomain: string; readonly requestPath: string
  readonly originHeader: string; readonly remoteAddrHash: string; readonly capabilityTokenRef: string
}
export function encodeMuxFrame(h: MuxFrameHeader, payload: Uint8Array): Uint8Array
export function decodeMuxFrame(buf: Uint8Array): { header: MuxFrameHeader; payload: Uint8Array }
```

- **§4.3 CapabilityToken** — the token **shape** T8 references (but does **not** itself verify — verification
  is delegated to P5, FIX 6a): `sub` (principal, from authenticated session, **never client**), `aud`
  (subdomain, Host-confusion guard, INV1), `host` (exact `host_id`, single host, never wildcard),
  `rights: ('attach'|'manage'|'kill')[]`, `iat`/`exp` (short TTL), `jti` (revocation lookup). P1 extracts the
  raw token at the edge and hands it to P5's `onUpgrade`/`onReattach`, which calls `verifyCapabilityToken(raw,
  expectedAud, now)` internally — T8 never calls it directly.
- **§4.3 WS-upgrade subprotocol wire format (FIX 5) — FROZEN, cited verbatim; T8's `extractCapabilityToken`
  MUST use these constants, never local literals**:
  ```ts
  // from relay-contracts (§4.3) — imported, frozen. Client opens:
  //   new WebSocket(url, [APP_SUBPROTOCOL, TOKEN_SUBPROTOCOL_PREFIX + base64url(rawToken)])
  export const APP_SUBPROTOCOL = 'term.relay.v1' as const          // the app subprotocol — the ONLY value ever accepted/echoed
  export const TOKEN_SUBPROTOCOL_PREFIX = 'term.token.' as const   // the token entry = this prefix + base64url(token)
  export function encodeTokenSubprotocol(rawToken: string): string // => 'term.token.' + base64url(rawToken)
  export function extractTokenFromSubprotocols(values: readonly string[]): string | null // strips prefix, base64url-decodes; null if absent
  ```
  **Echo rule (MUST):** read the token from the `term.token.<b64u>` entry via `extractTokenFromSubprotocols`,
  strip it before handing to P5, and the accepted/echoed `Sec-WebSocket-Protocol` in the handshake response
  **MUST be `APP_SUBPROTOCOL` (`'term.relay.v1'`), NEVER the token entry**. Query-string token transport is
  forbidden. (The earlier local literals `'term.relay.v1'`/`'term.token.'` in this plan are now these frozen
  constants — no local redefinition.)
- **§4.2 control→data-plane revocation teardown (FIX 4) — FROZEN, cited verbatim; consumed by T14**:
  ```ts
  // from relay-contracts (§4.2) — imported, frozen. P3/P5 PUBLISH; every P1 node SUBSCRIBES.
  export type RevocationScope =
    | { readonly kind: 'host'; readonly hostId: string }
    | { readonly kind: 'account'; readonly accountId: string }
    | { readonly kind: 'global' }
  export interface KillSignal {
    readonly scope: RevocationScope
    readonly at: number          // epoch seconds the revocation was issued
    readonly reason: string      // metadata only, ZERO payload (INV10)
  }
  export interface RevocationBus { publish(signal: KillSignal): Promise<void> }
  export const RELAY_REVOCATIONS_CHANNEL = 'relay:revocations' as const
  export const REVOCATION_PUSH_BUDGET_MS = 2000 as const
  ```
  P1 **subscribes** to `RELAY_REVOCATIONS_CHANNEL` and, per affected stream, injects a §4.1 `CLOSE`+`flags.RST`
  (host/account scope) or a link-level `GOAWAY` (global scope) using the **existing frozen §4.1 wire — no new
  frame type** — within `REVOCATION_PUSH_BUDGET_MS`. P5 stays socket-free (publishes only).
- **§4.2 data model** — consumed read-only via `RouteResolver`/`RouteRegistrar`: `hosts.host_id`
  (unguessable UUIDv4, never recycled), `hosts.subdomain` (stable), `route:{host_id} → {relayNodeId,
  updatedAt}` (Redis heartbeat-TTL, INV7). P1 **reads** `hostId`/`accountId`; it **writes** only the
  routing entry through P3's registrar.
- **§4.4 E2EEnvelope** — carried **inside** each DATA payload once E2E ships; P1 forwards it opaque and
  **never** derives keys or inspects `seq`/`nonce`/`ciphertext` (INV2). No P1 change at v0.10.

---

## 5. Task specifications

### W0

#### T1 · Package skeleton + data-plane config `[ ]`  — v0.8
- **Owns**: `term-relay/package.json`, `term-relay/tsconfig.json`, `term-relay/vitest.config.ts`,
  `term-relay/data-plane/config.ts`, `term-relay/test/config.test.ts`
- **Depends**: `relay-contracts/` exists (INDEX W0). · **Parallel-safe**: with T12.
- **Contract**:
  ```ts
  export interface DataPlaneConfig {
    readonly baseDomain: string          // e.g. 'term.example.com' — for extractSubdomain
    readonly bindHost: string            // default '0.0.0.0'
    readonly bindPort: number            // browser wss listener
    readonly agentBindPort: number       // agent dial-out listener
    readonly tlsCertPath: string; readonly tlsKeyPath: string   // relay's OWN server cert (browser-facing wss:)
    readonly agentCaCertPath: string     // CA/trust-anchor that MUST sign the agent's client cert on agentBindPort (mTLS root)
    readonly agentCaChainPath: string    // intermediate chain for agent-cert validation (rotation-safe; may equal agentCaCertPath if single-tier)
    readonly relayNodeId: string         // stable node id written into route:{host_id}
    readonly maxFrameBytes: number       // §4.1 payloadLen ceiling; default 1 MiB
    readonly initialWindowBytes: number  // §4.1 credit initial window; default 256 KiB
    readonly heartbeatIntervalMs: number // default 15_000
    readonly routeTtlMs: number          // Redis route TTL; default 45_000
  }
  export function loadDataPlaneConfig(env: NodeJS.ProcessEnv): DataPlaneConfig
  ```
- **Steps (TDD)**:
  1. **RED** `config.test.ts`: missing `TLS_CERT_PATH`/`TLS_KEY_PATH`/`BASE_DOMAIN` → `loadDataPlaneConfig` throws with a clear message (fail-fast, INV9); **missing `AGENT_CA_CERT_PATH` (the mTLS trust-anchor) → throws** — the agent-facing listener MUST NOT start without a CA to validate client certs against (else "mTLS" silently degrades to accept-any-cert, Finding-4 footgun); non-numeric `MAX_FRAME_BYTES` → throws; happy path returns typed frozen object; defaults applied.
  2. **GREEN** implement with a Zod schema (`z.coerce.number()` for ints, `z.string().min(1)` for paths). Return `Object.freeze(...)` (immutability). **Never log** cert/CA paths' contents; never log secrets.
- **Security note (INV9)**: startup MUST fail if TLS server material, the `AGENT_CA_CERT_PATH` mTLS trust-anchor, or `BASE_DOMAIN` is absent; no secret value is ever emitted to logs. The CA-path presence check is the config-level guarantee that T9 can wire TLS-layer `requestCert: true`/`rejectUnauthorized: true` (no accept-any-cert path exists).
- **Accept**: `npx vitest run config` green; `tsc --noEmit` clean.

### W1 — pure protocol leaves (all parallel, DOM-free, no `ws`/`pg`)

#### T2 · Mux frame codec `[ ]`  — spec written v0.8, becomes substrate v0.9
- **Owns**: `term-relay/mux/frame-codec.ts`, `term-relay/mux/type-bytes.ts`, `term-relay/mux/frame-guards.ts`, `term-relay/test/frame-codec.test.ts`
- **Depends**: `relay-contracts` §4.1 types. · **Parallel-safe**: T3–T5, T12.
- **Contract**:
  ```ts
  export const MUX_HEADER_BYTES = 15
  export const MUX_VERSION = 1 as const
  export const TYPE_TO_BYTE: Readonly<Record<MuxFrameType, number>>  // open→0x01 … goaway→0x07
  export const BYTE_TO_TYPE: ReadonlyMap<number, MuxFrameType>
  export function encodeMuxFrame(h: MuxFrameHeader, payload: Uint8Array): Uint8Array
  export function decodeHeader(buf: Uint8Array): MuxFrameHeader        // header-only peek (15B)
  export function decodeMuxFrame(buf: Uint8Array): { header: MuxFrameHeader; payload: Uint8Array }
  // CBOR-encoded typed payloads, validated at the boundary (coding-style: validate untrusted data):
  export function encodeOpen(open: MuxOpen): Uint8Array
  export function decodeOpen(payload: Uint8Array): MuxOpen             // Zod-guarded; throws on bad shape
  export function encodeWindowUpdate(credit: number): Uint8Array       // uint32
  export function decodeWindowUpdate(payload: Uint8Array): number
  export function encodeGoaway(lastStreamId: number, reason: number): Uint8Array
  export function decodeGoaway(payload: Uint8Array): { lastStreamId: number; reason: number }
  ```
- **Steps (TDD)**:
  1. **RED** `frame-codec.test.ts` (round-trip + adversarial):
     - round-trip each type; `encode∘decode === identity` for header fields incl. `fin`/`rst` bit packing.
     - `streamId=0` link-level frames decode with `streamId===0`.
     - `payloadLen` uses uint64 big-endian; a value > `Number.MAX_SAFE_INTEGER` → **throws** (safe-int guard, §4.1).
     - **negative**: truncated buffer (< 15B) → throws; `payloadLen` ≠ actual payload length → throws; unknown `type` byte (0x00, 0x08) → throws; `version ≠ 0x01` → throws; `payloadLen > maxFrameBytes` caller-check hook (codec exposes the raw length; the ceiling is enforced by T6 with config).
     - `decodeOpen` rejects a CBOR payload missing `subdomain`/`host`-scope fields (Zod) — **fuzz malformed CBOR → throws, never returns partial** (INV boundary validation).
  2. **GREEN** implement with `DataView` for the fixed header (big-endian) and `cbor-x` (or a tiny CBOR) for typed payloads; Zod-validate decoded OPEN/WINDOW_UPDATE/GOAWAY.
- **Security note (INV2/INV11)**: this module imports **no** terminal/ANSI parser; DATA payloads are handled as opaque `Uint8Array` and never inspected. `frame-guards.ts` validates *control* frames only (OPEN/WU/GOAWAY), never DATA content.
- **Accept**: `npx vitest run frame-codec` green; static check (T13) confirms no xterm/ansi import.

#### T3 · Stream state machine `[ ]`  — v0.9
- **Owns**: `term-relay/mux/stream.ts`, `term-relay/test/stream.test.ts`
- **Depends**: `relay-contracts` types. · **Parallel-safe**: T2/T4/T5.
- **Contract** (pure reducer — no I/O, no mutation of inputs):
  ```ts
  export type StreamState = 'idle' | 'open' | 'halfClosedLocal' | 'halfClosedRemote' | 'closed'
  export type StreamTransition = { readonly next: StreamState } | { readonly illegal: true }
  export function initialStreamState(): StreamState                     // 'idle'
  export function nextStreamState(
    current: StreamState, type: MuxFrameType, fin: boolean, dir: 'inbound' | 'outbound'
  ): StreamTransition
  export function isTerminal(s: StreamState): boolean
  ```
- **Steps (TDD)**:
  1. **RED** `stream.test.ts`: legal path `idle --OPEN--> open --DATA*--> open --CLOSE--> closed`; `DATA` with `fin` → half-closed in the sending direction; both-side FIN → `closed`. **Illegal (→ `{illegal:true}`, caller RSTs the stream):** DATA before OPEN; DATA after CLOSE; OPEN on an already-open streamId; any frame after `closed`. Direction matters (inbound FIN vs outbound FIN produce different half-closed states).
  2. **GREEN** implement as a total function returning a **new** state (never mutate).
- **Security note (INV13)**: illegal transitions are *rejected at the stream level* (RST one stream), never silently coerced — this is what lets P4's `seq` monotonicity mean something end-to-end.
- **Accept**: `npx vitest run stream` green; function < 50 lines.

#### T4 · Credit-based flow control `[ ]`  — v0.9
- **Owns**: `term-relay/mux/flow-control.ts`, `term-relay/test/flow-control.test.ts`
- **Depends**: none (pure). · **Parallel-safe**: T2/T3/T5.
- **Contract**:
  ```ts
  export const DEFAULT_INITIAL_WINDOW = 256 * 1024
  export const WINDOW_REPLENISH_THRESHOLD = 0.5   // emit WINDOW_UPDATE when half consumed
  export interface FlowController {
    registerStream(streamId: number, initialWindow: number): void
    releaseStream(streamId: number): void
    canSend(streamId: number, bytes: number): boolean   // false ⇒ backpressure (sender blocks)
    consumeSendCredit(streamId: number, bytes: number): void  // deduct on outbound DATA
    grantCredit(streamId: number, credit: number): void       // apply inbound WINDOW_UPDATE
    creditFor(streamId: number): number
    onDelivered(streamId: number, bytes: number): number      // returns credit to replenish (0 if < threshold)
  }
  export function createFlowController(): FlowController
  ```
- **Steps (TDD)**:
  1. **RED** `flow-control.test.ts`: `canSend` true until credit exhausted, then false; `consumeSendCredit` past window is rejected by `canSend` (never negative credit); `grantCredit` unblocks; `onDelivered` returns a replenish amount only once the threshold is crossed, else 0; unknown streamId → `canSend` false (deny-by-default), `creditFor` 0. Per-stream isolation: exhausting stream A's window does **not** affect stream B (**heavy `vim`/`top` redraw can't starve another stream**, §4.1).
  2. **GREEN** implement with a per-stream `Map<number, {window, sent, delivered}>`; immutable-style updates (replace the record).
- **Security note**: unknown/released streams are deny-by-default (no credit) — prevents send-after-close write amplification.
- **Accept**: `npx vitest run flow-control` green.

#### T5 · Heartbeat / liveness `[ ]`  — v0.9
- **Owns**: `term-relay/mux/heartbeat.ts`, `term-relay/test/heartbeat.test.ts`
- **Depends**: none. · **Parallel-safe**: T2–T4.
- **Contract** (timer injectable, per PLAN_VOICE_COMMANDS confirm-window pattern):
  ```ts
  export const HEARTBEAT_INTERVAL_MS = 15_000     // §4.1: PING every 15s
  export const HEARTBEAT_MISS_LIMIT = 3           // 3 missed PONGs ⇒ dead (~45s)
  export interface HeartbeatDeps {
    sendPing(token: Uint8Array): void
    onDead(): void
    now?: () => number
    schedule?: (fn: () => void, ms: number) => { cancel(): void }  // default setInterval-backed
  }
  export interface Heartbeat { start(): void; stop(): void; onPong(token: Uint8Array): void }
  export function createHeartbeat(deps: HeartbeatDeps): Heartbeat
  ```
- **Steps (TDD)**:
  1. **RED** with a fake scheduler: after `start`, a PING is sent every interval with a fresh 8-byte token; a matching `onPong` resets the miss counter; **3 intervals with no PONG → `onDead` fires exactly once** and no further PINGs; a PONG with a stale/unknown token does **not** reset the counter (prevents a spoofed/duplicated PONG from masking a dead link); `stop` cancels cleanly (no `onDead` after stop).
  2. **GREEN** implement with the injected scheduler; token = random 8 bytes (crypto RNG).
- **Security note (INV13-adjacent)**: PONG must echo the exact outstanding token; an unmatched token is ignored, so a replayed PONG can't keep a dead/hijacked tunnel "alive".
- **Accept**: `npx vitest run heartbeat` green.

### W2

#### T6 · Mux session (multiplexer over one WS) `[ ]`  — v0.9
- **Owns**: `term-relay/mux/mux-session.ts`, `term-relay/test/mux-session.test.ts`
- **Depends**: T2, T3, T4, T5. · **Parallel-safe**: with T7, T12.
- **Contract**:
  ```ts
  export interface MuxStreamHandle {
    readonly streamId: number
    writeData(payload: Uint8Array): boolean   // false ⇒ backpressured (buffered until WINDOW_UPDATE)
    close(rst?: boolean): void
  }
  export interface MuxSessionDeps {
    role: 'relay' | 'agent'
    sendWire(frame: Uint8Array): void          // write encoded frame to the underlying ws
    onOpen(open: MuxOpen, stream: MuxStreamHandle): void   // relay: n/a (relay opens); agent: dial :3000
    onData(streamId: number, payload: Uint8Array): void    // OPAQUE bytes (INV2) — never parsed
    onClose(streamId: number, rst: boolean): void
    onDead(): void
    maxFrameBytes: number
    initialWindowBytes: number
  }
  export interface MuxSession {
    openStream(open: MuxOpen): MuxStreamHandle   // relay-side: allocates monotonic streamId
    onWire(buf: Uint8Array): void                // feed inbound bytes: decode → dispatch
    drain(lastStreamId: number, reason: number): void   // send GOAWAY (T11)
    close(): void
  }
  export function createMuxSession(deps: MuxSessionDeps): MuxSession
  ```
- **Steps (TDD)**:
  1. **RED** `mux-session.test.ts` (two `MuxSession`s wired mouth-to-mouth via each other's `sendWire`):
     - relay `openStream(open)` → agent's `onOpen` fires with a fresh monotonic `streamId`; `streamId`s never reused within the session.
     - `writeData` on relay → agent `onData` receives **byte-identical** payload; a known plaintext marker in the payload is delivered verbatim and **never** inspected by the session (INV2).
     - **flow control**: fill a stream's window → `writeData` returns `false`; a WINDOW_UPDATE from the peer resumes flushing buffered data; stream A backpressure does not block stream B.
     - **heartbeat**: no PONG for `HEARTBEAT_MISS_LIMIT` intervals → `onDead`.
     - **lifecycle enforcement**: an inbound DATA for an unknown/closed streamId → session emits a **CLOSE with RST for that stream only**, the tunnel stays up (T3 illegal transition).
     - **frame ceiling**: an inbound frame with `payloadLen > maxFrameBytes` → RST that stream (or GOAWAY if link-level), never OOM.
  2. **GREEN** implement: incremental wire parser (buffer until a full 15B header + payload), `Map<streamId, streamCtx>` with T3 state + T4 credit, T5 heartbeat on streamId 0, GOAWAY handling.
- **Security note (INV2/INV5/INV11)**: DATA is copied opaque; buffers are **per-stream, per-session** (no global pool), freed on CLOSE — no cross-tenant buffer bleed possible. The session imports no terminal parser.
- **Accept**: `npx vitest run mux-session` green; file ≤ 400 lines (extract helpers if larger).

### W3 — data-plane wiring

#### T7 · Subdomain router `[ ]`  — v0.9
- **Owns**: `term-relay/data-plane/subdomain-router.ts`, `term-relay/test/subdomain-router.test.ts`
- **Depends**: T1 (config). · **Parallel-safe**: T8, T9, T11, T12.
- **Contract**:
  ```ts
  // RouteResolver is IMPLEMENTED BY P3 (control plane); P1 owns only the interface it depends on.
  export interface ResolvedHost { readonly hostId: string; readonly accountId: string; readonly subdomain: string }
  export interface RouteResolver { resolveSubdomain(subdomain: string): Promise<ResolvedHost | null> }
  export function extractSubdomain(hostHeader: string, baseDomain: string): string | null
  ```
- **Steps (TDD)**:
  1. **RED**: `extractSubdomain('alice.term.example.com', 'term.example.com')` → `'alice'`; a bare `baseDomain` → `null`; a **wildcard/confusion attempt** `'alice.term.example.com.evil.com'` → `null`; multi-label subdomain `'a.b.term.example.com'` → `null` (reject nested; single-label tenant only, INV1 Host-confusion guard); case-insensitive host; trailing dot tolerated.
  2. **GREEN** implement a strict suffix match returning the single leftmost label only.
- **Security note (INV1)**: `extractSubdomain` is a **hint** for candidate lookup; it is **never** the authority — T8 requires the capability token's `aud` to equal this subdomain and `token.host` to equal the resolver's `hostId`. Nested/confusing Host headers resolve to `null` (reject).
- **Accept**: `npx vitest run subdomain-router` green.

#### T8 · Upgrade authorization edge — **THIN ADAPTER over P5** `[ ]`  — v0.9  **(INV1/INV6/INV15 surface)**
> **FIX 6a — single-owner authorization.** The authorization **decision** (Origin/CSWSH + capability-token
> verify + DPoP proof-of-possession + single-use `jti` burn + pre-auth throttle + step-up + per-tenant rate +
> deny-by-default cross-tenant gate + audit) has **exactly one owner: P5** (`relay-auth/src/enforce/onUpgrade.ts`
> / `onReattach.ts`). **`authorizeUpgrade` holds NO independent authz logic** — it only (a) parses the upgrade
> (subdomain + FIX-5 token extraction + remoteAddr hash), (b) resolves the subdomain → `requestedHostId` via P3's
> `RouteResolver`, (c) builds P5's frozen `UpgradeContext` (incl. `dpop: DpopContext` + `activeSessionCount`),
> (d) calls the injected P5 `Authorizer`, and (e) maps the returned `AuthzOutcome` → a §4.1 `MuxOpen` (or a
> 401/403 close). Reattach flows the same gate via `onReattach` (base-app `sessionId` re-authorized as a claim).
- **Owns**: `term-relay/data-plane/upgrade.ts`, `term-relay/test/upgrade.test.ts`
- **Depends**: T2 (MuxOpen), T7 (resolver). **Cross-plan**: **P5 `Authorizer` = `onUpgrade`/`onReattach` + `UpgradeContext`/`AuthzOutcome`/`DpopContext` (§6a, imported read-only)**, P3 `RouteResolver`. · **Parallel-safe**: T9, T11.
- **Contract** (all P5 types imported from `relay-auth`, never redefined here):
  ```ts
  import { APP_SUBPROTOCOL, extractTokenFromSubprotocols } from 'relay-contracts'   // §4.3 FIX 5, frozen
  import type { UpgradeContext, AuthzOutcome, DpopContext } from 'relay-auth'       // §6a, P5-owned, frozen

  // P5 is the sole authorizer. P1 injects it; P1 supplies the ctx, P5 makes the decision.
  export interface Authorizer {
    onUpgrade(ctx: UpgradeContext, now: number): Promise<AuthzOutcome>
    onReattach(ctx: UpgradeContext & { sessionId: string }, now: number): Promise<AuthzOutcome>
  }
  export interface UpgradeRequest {
    readonly host: string; readonly origin: string | undefined
    readonly url: string                       // requestPath ONLY (opaque passthrough) — NEVER a token source
    readonly subprotocols: readonly string[]   // parsed Sec-WebSocket-Protocol values (FIX-5 token transport)
    readonly cookies: Readonly<Record<string, string>>  // parsed Cookie header (fallback token transport)
    readonly remoteAddr: string
    readonly dpop: DpopContext                 // FIX 6a: proof-of-possession material P5 needs (passed through, not inspected)
    readonly activeSessionCount: number        // FIX 6a: supplied for P5's per-tenant/step-up policy
    readonly sessionId?: string                // present ⇒ reattach path (→ onReattach); absent ⇒ onUpgrade
  }
  export type UpgradeDecision =
    | { readonly ok: true; readonly open: MuxOpen; readonly hostId: string; readonly acceptedSubprotocol: string }
    | { readonly ok: false; readonly status: 401 | 403 }
  // Token transport is FIXED, not free-form (FIX 5). Extract ONLY from the two allowed carriers via the
  // frozen §4.3 helper, NEVER from req.url query. Returns the opaque token string, or null if absent.
  export function extractCapabilityToken(req: UpgradeRequest): { token: string; via: 'subprotocol' | 'cookie' } | null
  export function authorizeUpgrade(req: UpgradeRequest, deps: {
    authorizer: Authorizer; resolver: RouteResolver
    baseDomain: string; now: () => number; remoteAddrSalt: string
    requiredRight: CapabilityRight   // 'attach' for a session upgrade
  }): Promise<UpgradeDecision>
  ```
- **Capability-token transport (FIX 5 — frozen §4.3, cited verbatim, INV15).** The browser's native `WebSocket`
  API cannot set arbitrary request headers, so the token rides one of exactly **two** carriers, in this order:
  1. **`Sec-WebSocket-Protocol` subprotocol (PREFERRED).** The client opens
     `new WebSocket(url, [APP_SUBPROTOCOL, encodeTokenSubprotocol(rawToken)])` — i.e.
     `['term.relay.v1', 'term.token.' + base64url(token)]`. `extractCapabilityToken` reads the token via the
     frozen `extractTokenFromSubprotocols(req.subprotocols)` (strips `TOKEN_SUBPROTOCOL_PREFIX`, base64url-decodes).
     The relay **MUST echo `acceptedSubprotocol = APP_SUBPROTOCOL` (`'term.relay.v1'`)** in the handshake response
     — **never** the `term.token.<b64u>` entry (echoing the token leaks the bearer secret). `UpgradeDecision.ok`
     therefore fixes `acceptedSubprotocol: 'term.relay.v1'` (non-nullable — the app subprotocol is always echoed).
  2. **Short-lived `HttpOnly; Secure; SameSite=Strict` cookie (FALLBACK).** Set by a prior authenticated
     HTTPS call (P6), scoped to the tenant subdomain, read from `req.cookies`. `HttpOnly` keeps it out of
     JS/`Referer`; `Secure` keeps it off plaintext; `SameSite=Strict` complements the retained Origin/CSWSH check.
  - **FORBIDDEN: the query string.** The token is a bearer-equivalent secret; `authorizeUpgrade` and
    `extractCapabilityToken` MUST NOT read any token from `req.url`. `req.url` carries **only** `requestPath`
    (opaque `?join=<id>` passthrough, never parsed for authz). Query-string tokens leak verbatim into
    reverse-proxy/CDN/frp access logs, browser history, and `Referer` headers.
- **Adapter logic (NO local authz decision — parse, delegate, map; INV6 deny-by-default lives in P5)**:
  ```
  subdomain = extractSubdomain(req.host, baseDomain);          if !subdomain    → 401   (unparseable Host, local)
  extracted = extractCapabilityToken(req);   // FIX-5 carriers only; NEVER req.url query
  if !extracted                                                                 → 401   (no token carrier present, local)
  resolved = await resolver.resolveSubdomain(subdomain);       if !resolved     → 403   (unknown subdomain, local)
  ctx: UpgradeContext = {
    capabilityRaw: extracted.token, originHeader: req.origin ?? '', expectedAud: subdomain,
    requestedHostId: resolved.hostId,           // P1 NAMES the host it resolved; P5 gates token.host against it (INV1)
    requiredRight, remoteAddrHash: hash(remoteAddrSalt, req.remoteAddr),
    activeSessionCount: req.activeSessionCount, dpop: req.dpop, principal: null /* P5 resolves from token */,
  }
  outcome = req.sessionId
    ? await authorizer.onReattach({ ...ctx, sessionId: req.sessionId }, now())   // P5 owns the decision
    : await authorizer.onUpgrade(ctx, now())
  if !outcome.ok                                                                 → { ok:false, status: outcome.status }
  open = { streamId: 0 /*allocated by MuxSession*/, subdomain, requestPath: pathOf(req.url),
           originHeader: req.origin ?? '', remoteAddrHash: ctx.remoteAddrHash,
           capabilityTokenRef: outcome.jti /* OQ5 RESOLVED — P5 §6a AuthzOutcome.ok now carries the verified jti */ }
  return { ok:true, open, hostId: outcome.hostId, acceptedSubprotocol: APP_SUBPROTOCOL }
  ```
  **`account_id`/`host_id` are derived by P5 from the signed token + registry — NEVER from client query/body/header (INV3).** The adapter passes `requestedHostId` (resolved from the subdomain), the raw token, Origin, and DPoP material to P5; **every accept/reject verdict is P5's**. `requestPath` is opaque passthrough the relay never parses for authz.
- **Steps (TDD)**:
  1. **RED** `upgrade.test.ts` (mock **P5 `authorizer`** + `resolver` — assert delegation, not local re-decision):
     - **delegation:** a happy `onUpgrade` (returns `{ok:true, hostId, principal, jti}`) → `{ok:true}`, `open.hostId === outcome.hostId`, `open.subdomain === 'alice'`, `acceptedSubprotocol === APP_SUBPROTOCOL`; assert `authorizer.onUpgrade` was called **exactly once** with a `UpgradeContext` carrying `expectedAud==='alice'`, `requestedHostId===resolved.hostId`, `requiredRight`, `dpop`, and `activeSessionCount` (proves ctx is fully populated).
     - **no local authz:** when P5 returns `{ok:false, status:401}` (foreign Origin / bad token / expired / aud-mismatch — all decided in P5), the adapter returns that status **verbatim** and opens **no** stream; the adapter contains no Origin/token/rights/cross-tenant branch of its own (assert by construction + coverage: every reject status originates from the `authorizer` mock).
     - **reattach:** `req.sessionId` present → `authorizer.onReattach` is called (not `onUpgrade`) with `{...ctx, sessionId}`; its `{ok:false}` maps straight through.
     - **token transport (FIX 5/INV15):** a token in `Sec-WebSocket-Protocol` (`['term.relay.v1','term.token.<b64u>']`) is extracted via `extractTokenFromSubprotocols` and forwarded as `ctx.capabilityRaw`, and `acceptedSubprotocol === 'term.relay.v1'` (the `term.token.` entry is **never** echoed); a token in `cookies['term_cap']` is accepted; **a token placed ONLY in the query string (`req.url = '/term?cap=<token>'`) → 401 and `authorizer.onUpgrade` is NEVER called** (spy count 0) — `extractCapabilityToken` never reads `req.url`, so no ctx is even built.
     - **cross-tenant is P5's (INV1):** the adapter forwards `requestedHostId = resolved.hostId` and the raw token; the 403 for `token.host !== hostId` is asserted at P5's `onUpgrade` (its own test), **not** re-implemented here — T8 only asserts the status is passed through.
     - **local parse failures only:** unparseable Host → 401 without calling the authorizer; unknown subdomain (`resolver` null) → 403 without calling the authorizer (nothing to authorize).
  2. **GREEN** implement thin: parse → resolve → build ctx → `await authorizer.onUpgrade/onReattach` → map. `hash` = HMAC-SHA256(salt, ip) truncated (audit-only, INV10). No decision branches beyond the three local parse guards above.
- **Security note**: FIX 6a makes this the **adapter**, not the gate — the "single hardest gate in P1" is now P5's `onUpgrade`/`onReattach`, so the decision cannot drift between two implementations. The adapter still enforces the FIX-5 transport (token only via subprotocol/cookie, never query; echo only `APP_SUBPROTOCOL`) and derives identity only from the signed token + registry via P5 (INV3). **Logging discipline (INV9):** the edge MUST NOT log `req.url`, the `Authorization` header, or the `Sec-WebSocket-Protocol` value (each can carry the bearer-equivalent token); it logs only `{subdomain, hostId, jti, decision, ts}`. P5 owns the *permanent CI cross-tenant tripwire*; T13 ships P1's data-plane-level assertion that the adapter never overrides a P5 deny.
- **Accept**: `npx vitest run upgrade` green; adapter has **zero** authz branches (delegation-only), verified by the "every reject originates from the authorizer mock" assertion.

#### T9 · Agent tunnel listener `[ ]`  — v0.9  **(INV4/INV7/INV12/INV14 surface)**
- **Owns**: `term-relay/data-plane/agent-listener.ts`, `term-relay/test/agent-listener.test.ts`
- **Depends**: T6 (MuxSession), T1 (config). **Cross-plan**: P5 `MtlsVerifier` (§ INV14), P3 `RouteRegistrar` (§4.2 `route:{host_id}`). · **Parallel-safe**: T8, T11.
- **Contract**:
  ```ts
  export interface MtlsVerifier {   // P5 impl — verifies the peer cert against the host registry pubkey
    verifyPeer(peerCert: Uint8Array): { hostId: string; accountId: string } | null
  }
  export interface RouteRegistrar {  // P3 impl — Redis route:{host_id} with heartbeat TTL (INV7)
    register(hostId: string, relayNodeId: string, ttlMs: number): Promise<void>
    heartbeat(hostId: string): Promise<void>
    deregister(hostId: string): Promise<void>
  }
  export interface AgentTunnel { readonly hostId: string; readonly session: MuxSession; closeTunnel(): void }
  export function createAgentListener(deps: {
    config: DataPlaneConfig; mtls: MtlsVerifier; registrar: RouteRegistrar
    onTunnel(t: AgentTunnel): void
  }): { attach(ws: WebSocketLike, peerCert: Uint8Array): void; tunnels(): ReadonlyMap<string, AgentTunnel> }
  ```
- **TLS-layer contract (two-tier defense, Finding-4):** the agent-facing TLS server on `agentBindPort`
  MUST be constructed with **`requestCert: true` AND `rejectUnauthorized: true`**, `ca` loaded from
  `config.agentCaCertPath` (+ `agentCaChainPath`). This makes the TLS handshake itself reject any
  connection that presents **no** client cert or a cert not chaining to our CA — **before** `attach()`
  or `verifyPeer()` is ever called. `verifyPeer` then runs only on a cert that has **already passed
  CA-chain validation**, and does the app-layer bind (cert pubkey → registry `agent_pubkey`, INV14/INV4).
  This is the explicit guard against the "send-any-cert, we check it ourselves after the fact" downgrade:
  presentation is enforced at the TLS layer, identity binding at the app layer — never one without the other.
- **Steps (TDD)**:
  1. **RED** `agent-listener.test.ts` (mock ws + mtls + registrar):
     - valid peer cert → `MuxSession` created, `registrar.register(hostId, nodeId, routeTtlMs)` called, tunnel indexed by `hostId`.
     - **TLS-layer enforcement (Finding-4):** a TLS handshake with **no client cert presented** is rejected at the TLS layer (`rejectUnauthorized`) — `attach()`/`verifyPeer()` are **never** invoked; assert the `attach` spy count is 0. Likewise a cert not chaining to `config.agentCaCertPath` → TLS reject, `attach` never called. Assert the TLS server options passed to the server factory are `{ requestCert: true, rejectUnauthorized: true, ca: <from agentCaCertPath/agentCaChainPath> }`.
     - **invalid/expired cert (`verifyPeer` → null) → the ws is closed, NO route registered** (INV14 — CA never binds an unknown pubkey; no shared secret path, INV4). (This is the app-layer check that runs *after* a cert has already passed CA-chain validation, e.g. cert chains to our CA but its pubkey is not in the host registry.)
     - heartbeat: every `heartbeatIntervalMs`, `registrar.heartbeat(hostId)` is called; on `onDead`/ws-close, `registrar.deregister(hostId)` runs and the tunnel is removed (**stateless — nothing durable survives**, INV7).
     - **revocation (INV12):** `closeTunnel()` tears down the ws + deregisters within the test tick.
     - reconnection: a second `attach` for the same `hostId` (agent reconnected on this node) replaces the prior tunnel and re-registers (latest wins; old one deregistered).
  2. **GREEN** implement; the node holds only an in-RAM `Map<hostId, AgentTunnel>` — **no DB of record** (INV7). Build the TLS server with `requestCert: true`, `rejectUnauthorized: true`, `ca` from the config CA paths; `attach` is only ever reached for a TLS-validated peer cert.
- **Security note (INV4/INV7/INV12/INV14)**: agent auth is **mTLS only**, enforced at **two tiers** — TLS-layer `requestCert`/`rejectUnauthorized` against `config.agentCaCertPath` guarantees a cert was presented *and* chains to our CA, then P5's `verifyPeer` binds that cert's pubkey to the registry `agent_pubkey`. No bearer/shared secret is ever accepted here; there is no code path that reaches `attach()` for an unauthenticated or no-cert peer. A node crash loses only the in-RAM map; agents reconnect and re-register (P3). `closeTunnel` is the revocation lever P5/P3 call to drop a live tunnel in seconds.
- **Accept**: `npx vitest run agent-listener` green.

#### T10 · Stateless relay node + opaque splice `[ ]`  — v0.9  **(INV2/INV5/INV7/INV11 core)**
- **Owns**: `term-relay/data-plane/relay-node.ts`, `term-relay/test/relay-node.test.ts`
- **Depends**: T6, T8, T9, T1. · **Parallel-safe**: T11 (disjoint file).
- **Contract**:
  ```ts
  export interface RelayNode {
    handleAgentTunnel(ws: WebSocketLike, peerCert: Uint8Array): void
    handleBrowserUpgrade(req: UpgradeRequest, ws: WebSocketLike): Promise<void>  // authorize → openStream → splice
    drain(reason: number): Promise<void>          // DRAIN_REASON value; delegates to T11
    closeTunnel(hostId: string): void             // WHOLE-host revocation lever (P5/P3) — kicks every device
    // SINGLE-device revocation (Finding-3): RST exactly the stream(s) opened under one capability token,
    // leaving every other device on the same host attached. Driven by P5's revoked:{jti} list (§4.2).
    // Returns the number of streams RST (0 if none matched — deny-by-default: unknown selector is a no-op).
    closeStream(hostId: string, selector: { readonly jti: string } | { readonly streamId: number }): number
  }
  export function createRelayNode(deps: {
    config: DataPlaneConfig; listener: ReturnType<typeof createAgentListener>
    authorize: (req: UpgradeRequest) => Promise<UpgradeDecision>   // = authorizeUpgrade bound to deps
  }): RelayNode
  ```
- **Splice (the byte hot-loop) — OPAQUE, zero parsing**:
  ```
  decision = await authorize(req);            if !decision.ok → ws.close(decision.status)   (INV6/INV15)
  tunnel = listener.tunnels().get(decision.hostId);  if !tunnel → ws.close(1013 /*try later*/)
  stream = tunnel.session.openStream(decision.open)                    // relay allocates streamId
  splices.add({ hostId: decision.hostId, streamId: stream.streamId,   // per-jti index for closeStream (Finding-3)
                jti: decision.open.capabilityTokenRef, ws, stream })
  ws.onMessage(bytes => stream.writeData(bytes))     // browser → agent : verbatim Uint8Array (INV2)
  stream via session.onData(id, bytes) → ws.send(bytes)   // agent → browser : verbatim (INV2)
  ws.onClose(() => { stream.close(); splices.remove(...) })
  stream.onClose(() => { ws.close(); splices.remove(...) })
  ```
  Buffers are **per-connection** (each `ws`↔`stream` pair) — **no global mutable buffer pool** (INV5, kills cross-tenant buffer bleed). The node never decodes DATA (INV2/INV11): a byte is a byte.
- **`closeStream` (single-device revocation, Finding-3/INV12)**: the node maintains an in-RAM splice
  index (`splices`) mapping `hostId → jti → {streamId, ws, stream}` (built at splice time above, torn down
  on close — still **stateless**, in-RAM only, INV7). `closeStream(hostId, {jti})` looks up every splice
  for that host+`jti`, calls `stream.close(/*rst*/ true)` (RSTs just those streams via the mux, tunnel
  stays up) and `ws.close(4403)` on each, removes them from the index, and returns the count. `{streamId}`
  is the same by exact stream. An unmatched selector RSTs nothing and returns 0 (deny-by-default no-op —
  never falls back to a broader kill). This is the primitive P5 calls when it adds a `jti` to
  `revoked:{jti}` (§4.2) to revoke **one shared device** while the legitimate owner's other devices
  (different `jti`s, latest-writer-wins sharing) stay attached.
- **Steps (TDD)**:
  1. **RED** `relay-node.test.ts` (in-memory ws pairs + a fake agent MuxSession):
     - browser upgrade authorized → a stream opens to the correct `hostId`'s tunnel; bytes typed at the browser arrive **byte-identical** at the agent side and vice-versa.
     - **INV2 ciphertext marker:** push a unique marker through; assert the relay-node code never inspects it and the marker is not retained in any node-level structure after CLOSE.
     - unauthorized upgrade (decision 401/403) → ws closed with the status; **no** stream opened, **no** tunnel touched.
     - no tunnel for the host (agent offline) → ws closed (1013), no crash.
     - **INV7 crash-survival:** drop the node's in-RAM maps (simulate crash) → no persisted state; a fresh node with a reconnected tunnel serves new upgrades; in-flight browser ws just reconnect (base-app backoff).
     - **INV1 isolation at the node:** an upgrade authorized for `hostA` can only ever reach `hostA`'s tunnel — there is no API to reach a tunnel by index/address; lookup is strictly `decision.hostId` (from the token, INV3).
     - **single-device revocation (Finding-3/INV12):** open two browser splices on the same host under two different `jti`s (`jtiA`, `jtiB` — the base-app multi-device sharing case); `closeStream(hostId, {jti: jtiA})` RSTs **only** `jtiA`'s stream and closes its ws (code 4403), returns `1`, while `jtiB`'s splice keeps flowing bytes bidirectionally (the legitimate owner's other device stays attached). Two splices under the **same** `jti` → both RST, returns `2`. `closeStream(hostId, {jti: 'unknown'})` → returns `0`, RSTs nothing (deny-by-default no-op, never widens to a whole-host kill). After a ws/stream closes normally, its splice-index entry is gone so a later `closeStream` for its `jti` returns `0` (no stale handle).
  2. **GREEN** implement thin; keep the file ≤ 300 lines (extract the splice helper / splice-index into a small module if needed). The splice index is in-RAM only (INV7).
- **Security note (INV2/INV5/INV7/INV11/INV12)**: the node is a **ciphertext-shuttle** — opaque splice, per-connection buffers, no durable state, no terminal parser. `closeStream` gives P5 a **surgical** revocation lever (RST exactly the streams under one revoked `jti`) so revoking a single compromised/shared device does **not** kick the owner's other devices — removing the "nuke everyone or nothing" false choice that would otherwise delay real revocation. This is the load-bearing "byte-shuttle → ciphertext-shuttle" upgrade.
- **Accept**: `npx vitest run relay-node` green.

#### T11 · Graceful drain + GOAWAY `[ ]`  — v0.9  **(INV7/INV12)**
- **Owns**: `term-relay/data-plane/drain.ts`, `term-relay/test/drain.test.ts`
- **Depends**: T6 (GOAWAY), T9 (tunnels). · **Parallel-safe**: T10 (disjoint file).
- **Contract**:
  ```ts
  export const DRAIN_REASON = { operatorDrain: 1, revoked: 2, shutdown: 3 } as const
  export type DrainReason = typeof DRAIN_REASON[keyof typeof DRAIN_REASON]
  export function drainNode(deps: {
    tunnels(): ReadonlyMap<string, AgentTunnel>
    registrar: RouteRegistrar
    reason: DrainReason
    inFlightGraceMs: number        // HONORED ONLY for operatorDrain/shutdown; FORCED to 0 for revoked (Finding-2)
  }): Promise<void>
  export function drainHost(hostId: string, deps: {
    tunnels(): ReadonlyMap<string, AgentTunnel>
    registrar: RouteRegistrar
    reason: DrainReason
    inFlightGraceMs: number        // HONORED ONLY for operatorDrain/shutdown; FORCED to 0 for revoked
  }): Promise<void>   // single-host (revocation / operator drain)
  ```
- **Grace-window rule (reason-differentiated, Finding-2/INV12):** the effective grace is
  `effectiveGraceMs = (reason === DRAIN_REASON.revoked) ? 0 : inFlightGraceMs`. A nonzero
  `inFlightGraceMs` is a *convenience for graceful operator drains/shutdowns only* (let an in-flight
  vim/paste flush before moving the host to another node). **`reason=revoked` is a security action against
  a compromised/malicious host or device** — it MUST force immediate teardown regardless of any caller-
  supplied `inFlightGraceMs`: RST every stream now, close the tunnel now, `registrar.deregister` now. This
  is what makes INV12's testable assertion ("its live tunnel drops within seconds") hold even when the
  operator-drain grace is tuned to tens of seconds.
- **Steps (TDD)**:
  1. **RED**:
     - `drainNode` with `reason=operatorDrain` sends **GOAWAY(lastStreamId, reason)** on streamId 0 to every tunnel; existing streams are allowed to finish within `inFlightGraceMs`, then tunnels close and routes are deregistered (P3). New `openStream` after GOAWAY is refused.
     - **`drainHost(hostId, {reason: DRAIN_REASON.revoked, inFlightGraceMs: N})` tears down all of that host's streams IMMEDIATELY regardless of N** (assert with a large N, e.g. 30_000): every stream is RST, the tunnel closes, and `registrar.deregister(hostId)` runs within the test tick — the grace window is **never** waited on. Same for `drainNode` with `reason=revoked` across all tunnels.
     - `drainHost` targets exactly one host and leaves siblings running (whole-host revocation, INV12).
  2. **GREEN** implement: compute `effectiveGraceMs` from `reason` before scheduling teardown; deregister via `registrar.deregister` so the ingress stops routing to this node/host.
- **Security note (INV12)**: `drainHost` is the whole-host revocation path — for `reason=revoked` it is GOAWAY + **immediate** RST + close + deregister (zero grace), dropping a compromised host's live tunnel within seconds; `operatorDrain`/`shutdown` keep a grace window purely for clean migration. **The customer's PTY survives** on their machine (base-app PTY≠WS decoupling); a relay-node bounce is invisible to the running Claude Code task (EXPLORE §3). For revoking **one shared device without kicking the others**, use T10's `RelayNode.closeStream` (per-`jti`), not a whole-host drain.
- **Accept**: `npx vitest run drain` green.

#### T14 · Revocation subscriber — `relay:revocations` → §4.1 teardown `[ ]`  — v0.9  **(INV12 data-plane teardown owner, FIX 4)**
> **FIX 4 — one control→data-plane teardown contract.** Fast revocation must tear down an **already-open**
> tunnel, not merely refuse the next connect. The frozen §4.2 bus (`RELAY_REVOCATIONS_CHANNEL` +
> `KillSignal`/`RevocationScope`, promoted into `relay-contracts` so P1 does **not** import P5 — DAG stays
> acyclic) is the **single** named channel: **P3/P5 PUBLISH, every P1 node SUBSCRIBES**. This task is P1's
> subscriber — the data-plane end of INV12. It injects **no new wire type**: it maps a `KillSignal` onto the
> existing §4.1 `CLOSE`+`flags.RST` (per stream) or link-level `GOAWAY` (global) via T10/T11's levers.
- **Owns**: `term-relay/data-plane/revocation-subscriber.ts`, `term-relay/test/revocation-subscriber.test.ts`
- **Depends**: T10 (`RelayNode.closeStream`/`closeTunnel`), T11 (`drainHost`/`drainNode`). **Cross-plan**: subscribes to a Redis pub/sub client (injected); the publisher is **P5** `revoke()` / **P3** (§4.2). · **Parallel-safe**: none in W3 (consumes T10+T11); runs after both freeze.
- **Contract** (frozen §4.2 types imported from `relay-contracts`, never redefined):
  ```ts
  import type { KillSignal, RevocationScope } from 'relay-contracts'
  import { RELAY_REVOCATIONS_CHANNEL, REVOCATION_PUSH_BUDGET_MS } from 'relay-contracts'   // 'relay:revocations', 2000

  export interface RevocationSubscriberDeps {
    subscribe(channel: string, onMessage: (raw: string) => void): { close(): void }  // injected Redis pub/sub
    node: Pick<RelayNode, 'closeTunnel'>                       // whole-host lever (T10)
    drainHost(hostId: string, reason: typeof DRAIN_REASON.revoked): Promise<void>     // immediate host teardown (T11)
    tunnels(): ReadonlyMap<string, AgentTunnel>               // to enumerate hosts for account/global scope
    routeResolver: RouteResolver                              // account → hostIds this node currently serves
    now: () => number
    onApplied?(signal: KillSignal, hostsAffected: number, elapsedMs: number): void   // budget/audit metadata only (INV10)
  }
  // Pure scope→hosts selector (testable in isolation): which of THIS node's live hosts a signal hits.
  export function hostsForScope(scope: RevocationScope, liveHosts: ReadonlyMap<string, AgentTunnel>,
                                accountOf: (hostId: string) => string): readonly string[]
  export function startRevocationSubscriber(deps: RevocationSubscriberDeps): { close(): void }
  ```
- **Mapping (KillSignal → §4.1 teardown, no new frame type)**:
  ```
  on message on RELAY_REVOCATIONS_CHANNEL:
    signal = parseKillSignal(raw)            // Zod-validate at the boundary; malformed → drop + count (no throw across the bus)
    switch signal.scope.kind:
      'host'    → drainHost(scope.hostId, DRAIN_REASON.revoked)   // GOAWAY? no — RST every stream now + close tunnel + deregister (T11, grace forced 0)
      'account' → for hostId in hostsForScope(scope, tunnels(), accountOf): drainHost(hostId, revoked)
      'global'  → for every tunnel: drainHost(hostId, revoked)    // link-level GOAWAY + RST all (node-wide)
    onApplied?(signal, hostsAffected, now()-startedAt)            // metadata only; MUST NOT log the reason string as payload (INV10)
  ```
  Every path uses **T11's `reason=revoked` immediate teardown (grace forced to 0)** so the §4.2
  `REVOCATION_PUSH_BUDGET_MS = 2000` budget is met. An unknown/absent host (already gone) is a **no-op**
  (deny-by-default: nothing to tear down, never a broader kill).
- **Steps (TDD)**:
  1. **RED** `revocation-subscriber.test.ts` (fake pub/sub + spy `drainHost`/`closeTunnel`, in-RAM tunnels):
     - a `{scope:{kind:'host',hostId:H}}` message → `drainHost(H, revoked)` called once **within `REVOCATION_PUSH_BUDGET_MS`** (assert with a fake clock); a host **not** on this node → no-op (0 calls).
     - `{scope:{kind:'account',accountId:A}}` → exactly the node's live hosts owned by A are torn down; a sibling host of another account keeps flowing (INV1 blast-radius contained to the account).
     - `{scope:{kind:'global'}}` → every live tunnel torn down (node-wide GOAWAY+RST).
     - **immediacy (INV12):** teardown does **not** wait any grace — assert `drainHost` is invoked with `reason=revoked` (grace forced to 0 per T11), even if the node's operator-drain grace is large.
     - **malformed signal** (bad JSON / missing `scope`) → dropped, counted, **no** teardown fired, subscriber stays alive (a poisoned message can't kill the bus or trigger a spurious kill).
     - **no payload leak (INV10):** `signal.reason` is never written to a data structure or log as content; only `{scope, hostsAffected, elapsedMs}` metadata is emitted via `onApplied`.
     - `close()` unsubscribes cleanly (no teardown after close).
  2. **GREEN** implement thin: `subscribe(RELAY_REVOCATIONS_CHANNEL, …)`, Zod-parse, `hostsForScope`, fan out to `drainHost(…, DRAIN_REASON.revoked)`. `hostsForScope` is a pure function (unit-tested standalone).
- **Security note (INV12/INV1/INV10)**: this closes the FIX-4 gap — P5/P3 publish a `KillSignal`, **every** P1 node reacts by RST-ing exactly the in-scope streams and dropping the tunnels **immediately** (grace 0), so a compromised host/account is off the data plane within the 2s budget **even for connections already open**. Scope selection is blast-radius-bounded (`host` ⊂ `account` ⊂ `global`); a host this node doesn't serve is a no-op (no cross-tenant reach, INV1). The `reason` string is metadata only — never forwarded as terminal payload (INV10). P1 **subscribes only**; it never publishes (P5 stays the sole revocation authority). **Single-device (per-`jti`) revocation** is a finer granularity than the frozen `KillSignal` scope expresses — see §9 OQ6.
- **Accept**: `npx vitest run revocation-subscriber` green; teardown latency assertion ≤ `REVOCATION_PUSH_BUDGET_MS`.

### W1-parallel lane — v0.8 scaffold

#### T12 · frp MVP scaffold (v0.8 stepping-stone) `[ ]`  — v0.8
- **Owns**: `term-relay/frp-scaffold/frps.toml`, `term-relay/frp-scaffold/README.md`, `term-relay/frp-scaffold/plugin-hook.ts`, `term-relay/test/plugin-hook.test.ts`
- **Depends**: none (independent lane). **Cross-plan**: P3 exposes an authz hook endpoint; P2 wraps `frpc` (agent side).
- **Deliverables**:
  - `frps.toml`: single-node `frps` on the VPS, **wildcard vhost** so `*.term.<domain>` routes by subdomain to the agent's proxy (EXPLORE §5.4); TLS terminated here or at Cloudflare in front (M6 scheme-following preserved end-to-end); `vhostHTTPSPort`/`subdomainHost` set to `BASE_DOMAIN`.
  - `plugin-hook.ts`: a **thin** HTTP server-plugin shim implementing frp's `Login`/`NewProxy`/`NewUserConn` hooks by **delegating** the decision to P3's control-plane authz endpoint (checks `agentToken`/`clientToken` hashes, subdomain ownership). **No tenancy logic lives here** — it forwards and enforces the deny-by-default answer.
  - `README.md`: the café-demo runbook — VPS + wildcard DNS (`*.term.<domain>`) + wildcard TLS (LetsEncrypt DNS-01) + `frps` + the P2 `frpc`-wrapped agent; **plus an explicit "retirement" section**: what each frp piece maps to in the native mux (frp yamux → §4.1 mux; frp subdomain vhost → T7 router; frp login plugin → T8 upgrade gate) so v0.9 is a swap, not a rewrite.
- **Steps (TDD)**:
  1. **RED** `plugin-hook.test.ts`: a `NewUserConn`/`Login` payload with an unknown token → hook returns **reject** (deny-by-default); a valid one whose subdomain the control plane owns → allow; the hook **forwards** to P3 and never decides tenancy itself; malformed payload (Zod) → reject.
  2. **GREEN** implement the shim (Zod-validate the frp hook body at the boundary).
- **Security note**: even the v0.8 scaffold keeps auth at the relay edge (the auth the base app never had, EXPLORE §5.5). The shared `clientToken`/`agentToken` gate is a **known, temporary** shortcut (INDEX v0.8) — it is replaced by capability tokens (T8) + mTLS (T9) + Passkey/pairing (P2/P3/P5) in v0.9/v0.10; the frame path is already designed so ciphertext (P4) drops into DATA without reshaping (INV2). **No plaintext terminal bytes are parsed even in v0.8** — frp forwards opaque TCP/WS streams (INV11).
- **Accept**: `npx vitest run plugin-hook` green; README runbook reproduces the café demo on one VPS.

### W4 — invariant tripwires & integration

#### T13 · P1 invariant tripwires + integration `[ ]`  — v0.9
- **Owns**: `term-relay/test/inv-tripwires.test.ts`, `term-relay/test/integration/data-plane.test.ts`
- **Depends**: T2–T11, T14. · **Parallel-safe**: none (integration convergence, like PLAN §W4).
- **Steps (tests)**:
  - **INV11 static:** assert the `term-relay/mux/**` and `term-relay/data-plane/**` dependency graph imports **no** xterm/ANSI/terminal parser (scan `import`s; fail if any match `/xterm|ansi|vt100|terminal-parser/i`).
  - **INV2 ciphertext:** end-to-end through a real `MuxSession` pair + `relay-node` splice, inject a unique plaintext marker; assert it appears **only** as opaque bytes in transit and is absent from every relay-side retained structure and from logs.
  - **INV1 cross-tenant (data-plane assertion, complements P5's permanent CI tripwire):** device authorized for host A → 403 when hitting host B's subdomain, on first connect **and** on reconnect/reattach.
  - **INV7 stateless:** kill a node mid-stream → no data at rest; reconnected tunnel on a new node serves fresh upgrades; PTY (base app) unaffected.
  - **INV6 deny-by-default (FIX 6a):** the `authorizeUpgrade` adapter opens a stream **only** on P5's `{ok:true}`; every P5 `{ok:false}` and every local parse-guard failure returns 401/403 with no stream opened — assert the adapter has no independent allow branch (a P5 deny is never overridden into an allow).
  - **INV13 ordering:** DATA frames are delivered in order and never reordered/deduped by the relay (so P4's `seq` holds); an illegal transition RSTs one stream, not the tunnel.
  - **INV12 revocation (Finding-2/Finding-3 + FIX 4):** end-to-end, a `KillSignal{scope:{kind:'host',hostId}}` published on `relay:revocations` is consumed by **T14** and tears down that host's live tunnel within `REVOCATION_PUSH_BUDGET_MS` (already-open streams, not just the next connect); an `account` scope tears down only that account's hosts on the node (siblings keep flowing, INV1); `closeStream(hostId, {jti})` drops exactly one shared device's stream while a sibling device (different `jti`) keeps flowing; `drainHost(hostId, {reason: revoked, inFlightGraceMs: large})` tears down the whole host **without** waiting the grace window (immediate), whereas `reason: operatorDrain` honors it.
  - **INV15 token transport (Finding-1):** a browser upgrade carrying the capability token **only** in the query string is rejected (401) end-to-end; a token via `Sec-WebSocket-Protocol` (or `HttpOnly` cookie) is accepted; assert no test log line contains the raw token, `req.url`, or the `Sec-WebSocket-Protocol` value (INV9 access-log discipline).
  - **INV14 mTLS presentation (Finding-4):** a simulated agent handshake with no client cert (or a cert not chaining to `agentCaCertPath`) is rejected at the TLS layer — `attach()`/`verifyPeer()` are never reached.
- **Accept**: `npx vitest run` (all `term-relay/` P1 tests) green; coverage ≥ 80% across mux + data-plane (`coding-style`/`testing.md`).

---

## 6. Security section (P1)

**Threat model:** the relay bridges full interactive shells for many tenants; a data-plane compromise
is worst-case RCE-shaped (EXPLORE §4). P1's job is to keep the node a **dumb ciphertext-shuttle** so it
*cannot* leak or inject what it *cannot* parse, and to make cross-tenant reach structurally impossible.

- **Cross-tenant isolation (INV1)** — the only path to a host is `authorizeUpgrade`, which (FIX 6a) resolves
  the subdomain → `requestedHostId` and **delegates the `token.host == hostId` gate to P5's `onUpgrade`/`onReattach`**
  — the decision has exactly one owner, so it cannot drift between two implementations. No code resolves a host
  by raw address/port/user hostname. Host header is a hint, never authority (`aud` guard + nested-subdomain
  rejection, T7; P5 owns the token/`aud` check). `host_id` is an unguessable, never-recycled UUIDv4 (§4.2).
  Fuzzed `host_id` guessing → 403 (P5 `onUpgrade`; T13 asserts the adapter passes it through).
- **Ciphertext-shuttle (INV2/INV11)** — DATA payloads are opaque `Uint8Array`; no module imports a
  terminal parser (static tripwire T13). E2E envelopes (§4.4) ride DATA unread; **no P1 change at v0.10**.
- **Deny-by-default authz (INV6) + capability token on upgrade (INV15)** — the full decision (Origin/CSWSH
  retained **AND** §4.3 token required, default reject, least-privilege rights, DPoP, single-use burn, rate,
  step-up, audit) is **owned solely by P5** (`onUpgrade`/`onReattach`, FIX 6a). P1's `authorizeUpgrade` is a
  **thin adapter**: it extracts the token via the frozen §4.3 FIX-5 carriers (subprotocol/cookie, never query),
  builds P5's `UpgradeContext` (incl. `dpop` + `activeSessionCount`), and opens a stream only on P5's `{ok:true}`.
- **`account_id`/`host_id` never client-supplied (INV3)** — identity derives only from the token +
  registry; `requestPath` query (`?join=…`) is opaque passthrough, never used for authz.
- **No shared secrets / mTLS (INV4/INV14)** — the agent listener accepts only mTLS-verified peers
  (P5 checks the cert against the registry **public** key); no bearer/shared-secret branch exists in v0.9+.
- **Stateless, no plaintext at rest (INV5/INV7)** — the node holds only in-RAM maps + per-connection
  buffers (no global pool → no buffer bleed); a crash persists nothing; P1 writes no shell bytes anywhere.
- **Fast revocation (INV12), FIX 4 single channel** — every P1 node **subscribes** to the frozen §4.2
  `relay:revocations` bus (T14); **P3/P5 publish** a `KillSignal`. On a `host`/`account`-scoped signal the
  subscriber tears down the in-scope tunnel(s) via `drainHost(reason=revoked)` — RST every stream + close +
  deregister **immediately (grace forced to 0, Finding-2)**, within `REVOCATION_PUSH_BUDGET_MS` — and on
  `global` a node-wide GOAWAY+RST. `operatorDrain`/`shutdown` keep a grace window only for clean migration.
  Single-device (per-`jti`) `closeStream` remains a T10 lever but currently has no bus scope (§9 OQ6). P1
  **subscribes only, never publishes** (P5 stays sole revocation authority; P1 does not import P5). The
  customer's PTY survives locally in every case.
- **Secrets (INV9)** — `loadDataPlaneConfig` fails fast on missing TLS server material, the
  `AGENT_CA_CERT_PATH` mTLS trust-anchor, or `BASE_DOMAIN`; **no `console.log`** in P1 (`code-review.md`);
  nothing secret is ever logged. **The capability token is bearer-equivalent**, so the data-plane edge
  access log excludes the raw upgrade `req.url`, the `Authorization` header, and the
  `Sec-WebSocket-Protocol` value — it records only `{subdomain, hostId, jti, decision, ts}` (Finding-1).
- **Anti-replay/injection carrier (INV13)** — P1 preserves DATA ordering and RSTs illegal stream
  transitions, so P4's per-message nonce + monotonic `seq` are meaningful end-to-end.

**v0.8 shortcut, stated honestly:** the frp scaffold uses a shared `clientToken`/`agentToken` gate at
the edge (INDEX v0.8). It is a **known temporary** posture, replaced by capability tokens + mTLS +
Passkey/pairing before the first paid signup (EXPLORE §7). Even in v0.8, frp forwards opaque streams —
**no terminal parsing** (INV11).

---

## 7. Verification

```bash
# --- pure protocol leaves ---
npx vitest run --dir term-relay frame-codec      # §4.1 codec round-trip + adversarial/fuzz
npx vitest run --dir term-relay stream           # stream state machine (legal + illegal transitions)
npx vitest run --dir term-relay flow-control     # credit windows, per-stream isolation, deny-by-default
npx vitest run --dir term-relay heartbeat        # 15s PING/PONG, miss→dead, stale-token ignored

# --- mux + data plane ---
npx vitest run --dir term-relay mux-session      # multiplex, backpressure, RST-one-stream, frame ceiling
npx vitest run --dir term-relay subdomain-router # extractSubdomain, Host-confusion rejection
npx vitest run --dir term-relay upgrade          # FIX 6a: adapter delegates to P5 onUpgrade/onReattach; FIX 5 token transport; zero local authz branches
npx vitest run --dir term-relay agent-listener   # INV4/INV7/INV14: mTLS-only, register/heartbeat/deregister
npx vitest run --dir term-relay relay-node       # INV2/INV5/INV7/INV11: opaque splice, per-conn buffers
npx vitest run --dir term-relay drain            # INV7/INV12: GOAWAY drain + per-host revocation
npx vitest run --dir term-relay revocation-subscriber  # INV12/FIX4: relay:revocations KillSignal → §4.1 CLOSE+RST/GOAWAY ≤ 2s

# --- v0.8 scaffold ---
npx vitest run --dir term-relay plugin-hook      # frp hook deny-by-default → delegates to P3

# --- invariant tripwires + integration + coverage ---
npx vitest run --dir term-relay inv-tripwires    # INV11 static import scan, INV2 marker, INV1 data-plane 403
npx vitest run --dir term-relay integration      # end-to-end data-plane splice through a MuxSession pair
npx vitest run --dir term-relay --coverage       # ≥ 80% across mux + data-plane (testing.md)
cd term-relay && npx tsc --noEmit                # types clean; relay-contracts imported read-only
grep -rEi 'xterm|ansi|vt100|terminal-parser' term-relay/mux term-relay/data-plane && echo FAIL || echo OK  # INV11
```

**Manual / deployment verification (v0.8 café demo, T12):** one VPS, wildcard DNS `*.term.<domain>`,
wildcard TLS (LetsEncrypt DNS-01 or Cloudflare in front), `frps` running, a P2 `frpc`-wrapped agent
paired on a laptop. From a phone browser open `alice.term.<domain>`, pass the `clientToken` gate, land
in the laptop shell — **no router/VPN/static-IP configured**. Confirm: (1) a relay-node restart does
**not** kill the running shell (PTY survives, INV7); (2) a foreign-Origin page cannot open the WS
(CSWSH retained); (3) all 212 base-app tests still pass (`npm test` in repo root) — the relay adds a
layer *underneath* the existing WebSocket, it does not modify `src/`.

---

## 8. Phasing summary

| Phase | P1 tasks | Deliverable |
|---|---|---|
| **v0.8 MVP** | T1 (config), T12 (frp scaffold), **T2 spec-written** | Café demo on frp; §4.1 frame format authored (not yet the substrate); auth at the relay edge (shared-token shortcut). |
| **v0.9 SaaS** | T2–T11, **T14**, T13 | Native WS mux **replaces frp**: §4.1 as the real substrate, per-stream flow control, 15s heartbeat, reconnection, GOAWAY drain, stateless node, subdomain routing by authenticated session, **upgrade gate as a thin adapter delegating to P5 `onUpgrade`/`onReattach` (FIX 6a)**, mTLS agent listener, opaque splice, and the **`relay:revocations` subscriber that turns a `KillSignal` into immediate §4.1 teardown (FIX 4)**. |
| **v0.10 E2E-hardening** | *(no P1 code change)* | DATA already carries P4's `E2EEnvelope` opaquely (INV2) — the frame path was designed so ciphertext drops in without reshaping. P1 only re-runs its INV2/INV13 tripwires against the E2E build. |

---

## 9. Open questions (for the orchestrator — do NOT guess, per CLAUDE.md subagent rule)

1. **CBOR dependency for typed control-frame payloads (OPEN/WINDOW_UPDATE/GOAWAY).** §4.1 says OPEN is
   "CBOR of MuxOpen". Confirm the shared CBOR lib choice (`cbor-x`?) and whether it belongs in
   `relay-contracts/` (so P2 agent + P1 relay share one encoder) or is P1-owned in `mux/`. Leaning:
   put the CBOR codec in `relay-contracts/` next to the frozen types so both sides import one impl.
2. **Where `encodeMuxFrame`/`decodeMuxFrame` physically live.** §4.1 lists the signatures under "P1
   owns" but the *types* are frozen in `relay-contracts/`. This plan puts the **binary codec impl** in
   `term-relay/mux/frame-codec.ts` (P1) and has P2 import it. Confirm P2 importing from `term-relay/mux/`
   is acceptable, or whether the codec should move into `relay-contracts/` to keep the agent from
   depending on the relay package. (Either is contract-compatible; it's a packaging call.)
3. **TLS termination boundary.** EXPLORE §3 allows terminating TLS at the relay **or** at Cloudflare in
   front. `DataPlaneConfig` carries cert/key paths for in-process termination; confirm the default
   deployment (in-process LetsEncrypt vs CF-in-front) so the T12 runbook picks one as primary.
4. **Ingress vs in-process routing at scale.** This plan keeps routing in-process on a single node
   (v0.8/v0.9, YAGNI per EXPLORE §7 v0.11). Confirm the Redis-routing-table + L7-ingress (Envoy/Traefik)
   split is deferred to a later multi-node plan and not P1's responsibility now.
5. **`AuthzOutcome` surfaces `jti` — RESOLVED (reconcile OQ5).** T8 is a thin adapter that no longer verifies
   the capability token (P5's `onUpgrade` does), so it cannot read `token.jti` itself — but `MuxOpen.capabilityTokenRef`
   (used by T10's per-`jti` splice index for single-device `closeStream`) needs it. P5 now adds `readonly jti: string`
   to the `ok` branch of `AuthzOutcome` (PLAN_RELAY_AUTH_ISOLATION.md T3), surfacing the *verified* token's jti; P1
   consumes `outcome.jti` read-only for `capabilityTokenRef`. No local redefinition of `AuthzOutcome` in P1.
6. **Single-device (per-`jti`) revocation vs the frozen `KillSignal` scope.** FIX 4 froze `RevocationScope` as
   `host | account | global` only — it has **no per-device/per-`jti` granularity**. But INV12 and T10's
   `closeStream(hostId, {jti})` still require revoking **one shared device while the owner's other devices stay
   attached**. The `relay:revocations` bus therefore cannot currently express single-device revocation; T14 only
   covers host/account/global. **Ask the INDEX owner** whether to (a) add a `{kind:'device'; hostId; jti}` arm to
   `RevocationScope` (then T14 routes it to `closeStream`), or (b) keep single-device revocation on a separate
   per-`jti` mechanism. Pending that decision, T10's `closeStream` remains but has no bus trigger. **Do NOT extend
   `RevocationScope` locally** — it must change at the INDEX coordination point (§4.2).
