diff --git a/designs/plans/2026-10-16-session-protocol-core.md b/designs/plans/2026-10-16-session-protocol-core.md new file mode 100644 index 0000000..ec73256 --- /dev/null +++ b/designs/plans/2026-10-16-session-protocol-core.md @@ -0,0 +1,57 @@ +# atem-session v1 core and shared fixture runner Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. + +**Goal:** Implement the accepted atem-session v1 contract (r5) as a transport-free Rust core β€” wire types, canonical digest, client state machines and the host authority (registry, leases, receipts) β€” and run every shared fixture in `designs/protocol/fixtures/` against it. + +**Architecture:** `src/session_protocol/` is pure logic with injected time, journal and PTY/dispatch effects, so the fixtures drive it deterministically. The runtime (later plan) wires it to real PTYs, workers and connections; nothing here touches sockets or files except the journal implementation. + +**Spec:** `designs/protocol/atem-session-v1.md` (r5, accepted for compatibility mode) and `designs/protocol/fixtures/schema.md`. Product context: `designs/session-multiplexing.md`. + +## Global Constraints + +- Branch `feat/session-protocol` (from `main` + the accepted protocol). Commits end with a blank line and `πŸ€– Built with SMT `. Never push. +- Compatibility mode only: no resume, no browser clients, no account-wide access, no relay-confidentiality claim; Β§9 inputs (`resume_proof`, freshness) are refused (`unauthorized`/`forbidden`), never stubbed as trusted. +- Representation rules of Β§1 exactly (UUID v4 lowercase, `ses-`+12 `[a-z2-7]`, opaque ids 1–128 `[A-Za-z0-9._:-]`, counters 0..=2^53βˆ’1 rejecting overflow, ms times, lowercase hex SHA-256, size bounds checked before allocation, duplicate keys rejected at any depth). +- Digest = SHA-256 of RFC 8785 JCS over the supported domain; must pass every `digest-vectors.json` vector and reject every `must_reject` input. +- Expiry is `now >= deadline`; authority uses an injected monotonic clock only. +- Every fixture file runs in `cargo test` through a data-driven harness that reads the JSON at test time (all files in the directory; a new fixture needs no Rust change). Wire messages are decoded with the real wire types (strict). +- Never rustfmt `src/app.rs`, `src/main.rs`, `src/cli.rs`, `src/websocket_client.rs`, `src/memory/{mod,crypto,secrets}.rs`; new files may be formatted. Never project-wide `cargo fmt`. No new dependencies unless unavoidable (serde_json with `preserve_order` off is fine; implement JCS and duplicate-key detection in-crate). +- Full `cargo test` reading every `test result` line; clippy not above main's count. + +### Task 1: wire types, strict decoding, canonical digest + +Files: `src/session_protocol/{mod.rs,wire.rs,canonical.rs}`, `src/main.rs` (`mod session_protocol;`), tests. + +- `wire.rs`: serde types for every op in Β§3–§7 (`hello`, `challenge`, `resume_proof`, `welcome`, `list`, `sessions`, `session_changed`, `acquire`, `renew`, `release`, `lease`, `lease_released`, `lease_changed`, `submit`, `receipt`, `receipt_query`, `receipts`, `error`), the `binding`/`target` objects, descriptors (all fields required), receipt states/kinds, error codes; the terminal-v1/chat-v1 `data.binding` field shape. `decode(bytes) -> Result`: size bound, strict JSON (duplicate keys at any depth, non-integers/exponents where integers are required, unsafe integers, lone surrogates, invalid UTF-8 β†’ errors), field validation per Β§1. `encode(&Envelope) -> Vec`. +- `canonical.rs`: `jcs(&Value) -> Result` (UTF-16 key order, ES string escaping, safe integers only, no floats) and `payload_digest(&Value) -> String`; a strict parser front end used by both. +- Tests: every `digest-vectors.json` vector (canonical + sha256) and `must_reject` entry; every wire message appearing in any fixture's `json` decodes and re-encodes to an equal JSON value; targeted negative cases per Β§1 row. +- Commit `feat(session-protocol): wire types, strict decoding and canonical digest`. + +### Task 2: client state machines + client-side fixtures + +Files: `src/session_protocol/client.rs`, `src/session_protocol/fixtures.rs` (harness), tests. + +- `LeaseClient` (Β§5 reply-correlation rules 1–5, deadline = send time + `ms_left` capped, repeat-acquire min rule, `now >= deadline` expiry, `controls()`, `input_enabled()`), `RegistryClient` (Β§4 runtime-epoch fencing, per-attachment `seq`, gap β†’ `list`, resync discard, duplicates ignored, removal without resurrection, fixed `runtime_gen` per epoch). +- Harness: loads all `fixtures/*.json`, runs `side:"client"` scenarios against these machines (`deliver_to_client`, `send` records outgoing requests and their send times, `advance_ms`, `assert_client`), and host scenarios via Task 3 (until then they're listed and skipped with a visible count). +- Commit `feat(session-protocol): lease and registry client state machines with shared fixtures`. + +### Task 3: host authority + host-side fixtures + +Files: `src/session_protocol/host.rs`, `src/session_protocol/journal.rs`, harness, tests. + +- `SessionHost` (pure): host context (account, device, trust gen, runtime gen/epoch), attachments with permissions and `upgraded`, sessions (id, process_gen, descriptor fields, managed flag), the lease authority per session (Β§5: 15 s, takeover, repeat acquire, local input revocation with input-epoch bump, release, expiry on `now >= deadline`), legacy mapping (Β§0: terminal-v1 subscribe/control/resize/stop/input with stream and generation checks; unscoped legacy mutations refused for managed sessions), registry snapshot/changes with per-attachment `seq` and `removed`, submissions and receipts (Β§6: immutable request binding, host-computed digest, admission checks, state transitions incl. dispatch-failure rules, `receipt_query` scoping, retention bounds), errors (Β§7, no disclosure). +- Effects are returned as values (`Effect::PtyWrite{session, bytes, input_epoch}`, `Resize`, `Stop`, `Dispatch`, `Send{attachment, msg}`), so the harness counts them and the runtime will execute them. The PTY write boundary re-checks the input epoch (`apply_write(session, epoch)`). +- `Journal` trait (append receipt transition, fsync point, load on restart) + `MemJournal` with failure injection (`fail_next_fsync`); restart reloads receipts and applies the Β§6 crash rules. +- Harness runs every `side:"host"` scenario (all actions in schema.md incl. hold/deliver, crash, restart, reconnect, failure injection) asserting messages and counters. +- Commit `feat(session-protocol): host authority β€” registry, leases and durable receipts β€” with shared fixtures`. + +## Runtime wiring notes + +Left for the plan that wires `SessionHost` into the runtime (from the final review of Tasks 1–3): + +- **Journal compaction.** `MemJournal` drops payloads in place once a request leaves `accepted`; a file journal needs real compaction (rewrite/segment) for that, and for `Evicted` records, so the log doesn't grow without bound. After an fsync error it must stop admitting or truncate to its last durable record (see the `Journal` trait docs). +- **Receipt indexing.** Receipts live in one map keyed by `request_id`; per-session counts, in-flight counts and the prompt queue scan it. Index by session (and by attachment) before the limits get large. +- **Input sequence and dedup across transports.** terminal-v1 `input.sequence` is carried in `Origin::Remote` but not yet checked for order or duplicates; the same message arriving over direct and relay transports must be deduplicated (Β§0) before it reaches the host. +- **`receipt_limit` per lifetime.** The bound counts every receipt retained for the session, including final ones, so a long-lived session eventually refuses submissions until it closes; decide whether final receipts older than some age count. +- **Periodic `advance`.** Expiry is correct without ticks (messages and `admit_write` carry `now`), but `lease_changed{expired}`, observation windows (interrupt/stop 10 s), retried durable writes, tombstone and retention eviction only happen on `advance`: the runtime must call it periodically (e.g. every second) and before writing queued PTY chunks. diff --git a/designs/protocol/atem-session-v1.md b/designs/protocol/atem-session-v1.md new file mode 100644 index 0000000..02d69b8 --- /dev/null +++ b/designs/protocol/atem-session-v1.md @@ -0,0 +1,454 @@ +# atem-session v1: sessions, control leases and receipts (r6: r5 accepted for compatibility mode, minus legacy paths β€” for review) + +Status: r6 (2026-10-16) removes every legacy path from r5 at the user's +direction (no backward compatibility; Atem and Astation update together) β€” +needs Astation's review. r5 was accepted by Astation on 2026-10-15 as the +compatibility-mode implementation contract ([r5 review](https://truck.netbird.cloud:58310/protocol/atem-session-v1-r5-review.md)). +Final conformance needs the shared fixtures to pass in the Rust, Swift and +browser implementations; Β§9 remains the security gate. History: r1 (`7a0c691`), r2 (`9383193`), r3 +(`1bf805c`, [r3 review](https://truck.netbird.cloud:58310/protocol/atem-session-v1-r3-review.md)) +and r4 (`fd907cc`, [r4 review](https://truck.netbird.cloud:58310/protocol/atem-session-v1-r4-review.md)) +were reviewed by Astation ([r1 review](https://truck.netbird.cloud:58310/protocol/atem-session-v1-review.md), +[r2 review](https://truck.netbird.cloud:58310/protocol/atem-session-v1-r2-review.md), +summarised in [session-multiplexing.md](../session-multiplexing.md) Β§12). r2 +answered r1 items 1–7 and 9; r3 answered r2 review items 1–5; r4 answered the three r3 corrections; r5 answers the three r4 findings. Item 8 (trust, freshness, end-to-end channel, +QR grants) stays an implementation gate (Β§9). Not final until both sides +accept it and the fixtures in `fixtures/` pass in Rust, Swift and the browser. + +## 0. Layering and activation + +``` ++-----------------------------------------------------------------+ +| atem-session v1 (this doc): registry, leases, receipts | +| terminal-v1 / chat-v1 streams, bound to this doc's targets | ++-----------------------------------------------------------------+ +| secure channel (Β§9 gate): endpoint keys, E2EE, freshness | ++-----------------------------------------------------------------+ +| transport: direct ws :8080 | relay (atem_id + connection_id) | ++-----------------------------------------------------------------+ +``` + +- Direct and relay are thin transports; messages are identical on both and the + host deduplicates across them. +- **Compatibility mode** (until Β§9 is implemented): the messages run inside + today's authenticated Astation socket for isolated tests and for the one + Astation already verified with this host. In this mode: no resume (Β§3), no + browser clients, no account-wide native access, and no claim of relay + confidentiality. Admission fails closed; no placeholder trust decision is + exposed as production authorization. +- **One session authority, no legacy paths** (r6). Atem and Astation are + updated together; there is no backward compatibility. Every remote mutation + of a session β€” terminal input/resize/stop, chat submit, interrupt β€” is a + session-v1 message carrying `binding` and the current lease, after `welcome`. + The old ops are removed from the host and answered with + `error{code:"unsupported_version"}` and no effect: `agentInput`, + `agentPrompt`, `voiceCommand`/`voiceToggle` prompts, `userCommand`, + terminal-v1 `control` and its `lease_id` field, terminal-v1 `list`/`sessions` + (session-v1 `list` is the only registry), chat-v1 `submit` without + `binding`, and any session op before `welcome`. Terminal ids are session ids + only (no `claude`/`codex` aliases). + - Local terminal input on the host revokes any remote lease first (Β§5). + Fixture `removed-ops-have-no-effect`. + +## 1. Representation + +| Item | Rule | +|---|---| +| Envelope | `{"type":"session","data":{"v":1,"op":"…","id":"", …}}`; replies carry `re` | +| UUID | lowercase canonical v4 (`8-4-4-4-12`), validated | +| `session_id` | `ses-` + exactly 12 chars `[a-z2-7]` | +| Opaque ids (`account_id`, `atem_device_id`, `client_device_id`) | 1–128 bytes, `[A-Za-z0-9._:-]` | +| Counters (`host_trust_gen`, `lease_epoch`, `runtime_epoch`, `seq`) | JSON integers 0 ≀ n ≀ 2^53βˆ’1; a value that would exceed it is refused (`error{code:"unsupported_version"}` for peers, fatal for the host's own counters: it stops admitting and asks for re-pairing) β€” never wraps | +| Times | integer milliseconds since the Unix epoch; **display/estimation only** β€” authority uses monotonic clocks (Β§5) | +| Digest | SHA-256, lowercase hex, 64 chars | +| Bounds | message ≀ 1 MiB after decoding; `payload` ≀ 64 KiB canonical; prompt text ≀ 32 768 bytes; `receipt_query.request_ids` ≀ 64; `detail` ≀ 512 bytes; ≀ 64 in-flight requests per attachment (excess β†’ `busy`) | +| Unknown fields | ignored; unknown `v` β†’ `unsupported_version` | +| Parsing | bounds checked before allocation; duplicate object keys, non-integer numbers where integers are required, and out-of-range values are rejected | + +## 2. Identities, target and binding + +| Field | Meaning | +|---|---| +| `account_id` | account owning the data | +| `atem_device_id` | this Atem device/profile | +| `host_trust_gen` | increments durably on unpair/rebind; older values rejected | +| `runtime_gen` | UUID, new each broker (runtime) start | +| `session_id` | one agent session, stable for its life | +| `process_gen` | UUID, one agent process; replacement/resume = new value | +| `controller_session` | UUID, logical controller identity assigned by the host | +| `attachment_gen` | UUID, one live attachment of a controller to the host; new on every connect/resume; fences the previous one | +| `lease_epoch` | per session, increments on every grant, transfer, expiry, release, revoke | + +Every request that targets a session carries one **binding** object: + +```json +{"target":{"account_id":"account-a","atem_device_id":"host-a","host_trust_gen":1, + "session_id":"ses-abcdefghijkl","process_gen":"22222222-2222-4222-8222-222222222222"}, + "attachment_gen":"33333333-3333-4333-8333-333333333333", + "lease_epoch":7} +``` + +- Session ops (Β§4–§6) carry it as `data.binding`. terminal-v1 and chat-v1 + carry it as `data.binding` too β€” inside `data`, next to the family's own + fields, never inside a nested payload and never beside the outer envelope. + Their own ids must agree with it: terminal-v1 `terminal_id` = chat-v1 + `agentId` = `binding.target.session_id`; terminal-v1 `generation` = chat-v1 + `generation` (both are the agent process's generation) = + `binding.target.process_gen`. A disagreement is `stale_target`. Exact + envelopes: + +```json +{"type":"terminal","data":{"v":1,"op":"subscribe","terminal_id":"ses-aaaaaaaaaaaa","generation":"22222222-2222-4222-8222-222222222222", + "binding":{"target":{…},"attachment_gen":"33333333-…"}}} +{"type":"terminal","data":{"v":1,"op":"input","terminal_id":"ses-aaaaaaaaaaaa","generation":"22222222-…","stream_id":"…","sequence":0,"bytes":"aGkN", + "binding":{"target":{…},"attachment_gen":"33333333-…","lease_epoch":7}}} +{"type":"terminal","data":{"v":1,"op":"resize","terminal_id":"ses-aaaaaaaaaaaa","generation":"22222222-…","stream_id":"…","rows":40,"cols":120, + "binding":{"target":{…},"attachment_gen":"33333333-…","lease_epoch":7}}} +{"type":"terminal","data":{"v":1,"op":"stop","terminal_id":"ses-aaaaaaaaaaaa","generation":"22222222-…","stream_id":"…", + "binding":{"target":{…},"attachment_gen":"33333333-…","lease_epoch":7}}} +{"type":"agentChat","data":{"v":1,"op":"open","conversationId":"…","agentId":"ses-aaaaaaaaaaaa", + "binding":{"target":{…},"attachment_gen":"33333333-…"}}} +{"type":"agentChat","data":{"v":1,"op":"submit","conversationId":"…","agentId":"ses-aaaaaaaaaaaa","generation":"22222222-…", + "requestId":"44444444-…","text":"run the tests", + "binding":{"target":{…},"attachment_gen":"33333333-…","lease_epoch":7}}} +``` + + terminal-v1 has no `lease_id` field (the binding's epoch is the lease); a + chat `submit` is the Β§6 `submit` with + `kind:"prompt"`, `request_id = requestId`, `payload = {"text": text}`. +- `lease_epoch` is required for mutations, absent for reads. +- Any mismatch β†’ `error{code:"stale_target"}` with **no** current values; the + client re-lists (Β§4). Errors never reveal another target's ids, paths or + receipts; an unauthorized or unknown session is `unknown_session` either way. + +## 3. Hello, attachment and resume + +| op | dir | fields | +|---|---|---| +| `hello` | Cβ†’H | `resume?: {controller_session}` | +| `challenge` | Hβ†’C | `nonce` (32 bytes, base64url) β€” only when resuming | +| `resume_proof` | Cβ†’H | `proof` (Β§9: signature by the client's authenticated key over the transcript) | +| `welcome` | Hβ†’C | `account_id`, `atem_device_id`, `host_trust_gen`, `runtime_gen`, `controller_session`, `attachment_gen`, `client_kind` (`native`/`browser`, as authenticated), `permissions` (`view`, `control`, `approve`), `authorization_ms_left`, `server_time` | + +- The client's device and kind come from the authenticated channel, never from + `hello` fields or relay labels. A `controller_session` UUID is not a credential. +- Resume needs key possession over a fresh `challenge` and yields a **new** + `attachment_gen`; the host fences the old attachment (closes it and rejects + anything still arriving on it) before accepting mutations on the new one. + Resume never re-acquires a lease and never revives an expired one. +- Compatibility mode: no `challenge`/`resume_proof`; every `hello` gets a new + `controller_session` and `attachment_gen` and holds no lease. A `hello` + with `resume`, and any `resume_proof`, is refused `unauthorized`. A second + `hello` on an attachment that was already welcomed is refused + `unauthorized` (a new welcome needs a new attachment). +- A broker restart (new `runtime_gen`) invalidates all attachments and leases; + workers and receipts stay addressable by `session_id`/`process_gen`. + +## 4. Session registry + +| op | dir | fields | +|---|---|---| +| `list` | Cβ†’H | β€” | +| `sessions` | Hβ†’C | `runtime_gen`, `runtime_epoch`, `seq`, `sessions: [descriptor]` | +| `session_changed` | Hβ†’C | `runtime_gen`, `runtime_epoch`, `seq`, `descriptor` | + +Descriptor: + +```json +{"session_id":"ses-abcdefghijkl","process_gen":"…","provider":"claude", + "cwd_label":"Atem","state":"running","activity":"ready","activity_since":1760500000000, + "controller":{"controller_session":"…","lease_epoch":7}|null, + "capabilities":["terminal-v1","chat-v1"],"created_at":1760490000000} +``` + +- Every descriptor field is **required** in both `sessions` and + `session_changed` (a change carries the complete new descriptor, never a + patch); `controller` is `null` when no lease is held and `capabilities` may be + `[]`. Decoders reject a descriptor with a missing field. A removal is a + complete descriptor with `state:"removed"` (its last values). +- `state`: `running`, `exited`, `interrupted`, `recovery_required`, `closed`, + `removed`. `activity`: `ready`, `working`, `waiting`, `unknown`. +- **Runtime fencing.** `runtime_epoch` is a host-persisted counter incremented + at every broker start (with `runtime_gen` its UUID). A client accepts a + snapshot or change only if its `runtime_epoch` β‰₯ the highest it has accepted + from this host; a higher epoch replaces all state (a snapshot at `seq` 1 of + epoch 8 replaces `seq` 100 of epoch 7); anything from a lower epoch is + discarded, so a delayed old-runtime snapshot can never switch the client back. + Each accepted epoch has one fixed `runtime_gen`; the same epoch with a + different `runtime_gen` is rejected. After accepting a new epoch from a + change (not a snapshot), the client sends `list` and applies nothing from + that epoch until its snapshot arrives. +- **Per-attachment sequence.** `list` subscribes the attachment. The host then + sends, on that attachment's single ordered stream, one `sessions` snapshot + followed only by `session_changed` events. `seq` counts the messages *this + attachment* received in this epoch (snapshot = n, next change = n+1, …), so + changes to sessions it can't see never create gaps. Within an epoch the client + applies a change only if `seq == last + 1`; `seq ≀ last` is a duplicate and is + ignored; `seq > last + 1` is a gap: it sends `list` and, until the new + snapshot arrives, discards every change with `seq` ≀ that snapshot's `seq`. + A snapshot whose `seq` is lower than the last applied one (same epoch) is + discarded. +- **New attachment.** `seq` belongs to one attachment. On every `welcome` + (reconnect) the client clears its registry state β€” sessions, last `seq`, + removals and held changes β€” keeping only its highest accepted + (`runtime_epoch`, `runtime_gen`) as the fencing floor; the new attachment's + first snapshot applies at any `seq`. +- **Removal.** A removed session is sent as `state:"removed"`; applying it + deletes the session, and a later change for that `session_id` is only accepted + with a higher `seq` in the same epoch and a different `process_gen` β€” never + as a resurrection of the removed one. (Tombstones are kept 10 minutes host-side + only to answer late requests with `unknown_session`.) +- The registry and every response are filtered by the attachment's view + permission: `list` without `view` is `forbidden`; an op with a `binding` + from an attachment without `view` is `unknown_session`. `cwd_label` is the last path component only; clients show + `session_id` when labels repeat. Presence is never liveness evidence. + +## 5. Control lease + +| op | dir | fields | result | +|---|---|---|---| +| `acquire` | Cβ†’H | `binding` (no `lease_epoch`), `takeover` | `lease` or `error` | +| `renew` | Cβ†’H | `binding` | `lease` or `error` | +| `release` | Cβ†’H | `binding` | `lease_released` | +| `lease` | Hβ†’C | `binding` (with the current `lease_epoch`), `ms_left` | β€” | +| `lease_changed` | Hβ†’viewers | `session_id`, `process_gen`, `controller` (or null), `lease_epoch`, `reason` (`granted`, `takeover`, `local_input`, `expired`, `released`, `revoked`, `process_ended`) | β€” | + +Normative rules: + +- **Duration**: a lease lasts 15 s from its grant/renewal on the host's + monotonic clock (expired when `now >= grant + 15 000 ms`), capped by the attachment's remaining authorization. Clients + renew every 5 s and never after their own monotonic estimate of expiry. + `ms_left` is for estimation only. +- **Fencing**: a lease is held by one (`controller_session`, `attachment_gen`). +- **Epochs**: `lease_epoch` strictly increases per session and may skip values + (the host reserves epochs durably in blocks, so after a restart the next + grant can be well above the last one seen); clients compare, never count. +- **Recipients**: `lease_changed` goes to every welcomed attachment with `view` + except the one whose own request caused the change (it gets the direct + reply: `lease` or `lease_released`). Expiry, local input, revocation and + process end have no requester, so the holder gets `lease_changed` too. +- **Reply correlation and the client deadline** (normative for clients): + 1. A `lease` reply is applied only if its `re` equals one outstanding + `acquire`/`renew` request id, and each outstanding request consumes at most + one reply; replies with an unknown, already consumed or duplicate `re` are + dropped, even when the binding matches. + 2. Before applying it, the client checks its previously acknowledged local + deadline on the monotonic clock; if that deadline has passed (`now >= + deadline`, whether or not a UI timer fired), the reply is dropped, the + client becomes a viewer, and only an explicit new `acquire` can regain + control. + 3. Otherwise the new deadline = the request's monotonic **send** time + + `ms_left` from the reply (never the reply's arrival time + 15 s), and never + later than the authorization deadline. + 4. For a repeat `acquire` while already holding control, the new deadline = + `min(previous deadline, request send time + ms_left, authorization + deadline)`: it never extends and honours a shorter remaining lifetime. + 5. Expiry is `now >= deadline`, on both host and client. A client that computes + a deadline already reached when it applies a reply disables control and input + immediately (it never holds control with zero remaining lifetime). An explicit `acquire` after expiry is + a new operation (rule 2 still discards replies to requests sent before it). + Fixture `late-renew-reply-does-not-resurrect-control` (acquire at t=0, renew + at t=5000, reply delivered at t=17000 with no timer tick; a duplicate earlier + reply while a later renew is pending; a repeat-acquire reply near expiry). +- **Renew** after expiry β†’ `stale_lease` (it never re-acquires). +- **Repeat acquire** by the current holder on the same attachment returns the + current lease unchanged (same epoch, host deadline unchanged, `ms_left` = what + remains); on another attachment of the same controller β†’ `stale_lease` + (resume first). +- **Takeover** (`takeover: true`, needs `control`) moves the lease to the caller + with a new epoch; the previous holder gets `lease_changed{reason:"takeover"}` + and stays a viewer. Without takeover a held lease β†’ `lease_held`. +- **Local input wins**: local terminal input on the host revokes any remote lease + (new epoch, `reason:"local_input"`) before that input is written. +- **Input epoch**: the worker's PTY writer checks the session's input epoch at + write time for every queued chunk. Transfer, expiry, release, local input and + revoke bump that epoch first, so already-queued remote bytes are dropped, not + written. Raw keystrokes are never replayed after reconnect. +- **Restart**: leases are not persisted. A worker restart (new `process_gen`) or + broker restart (new `runtime_gen`) invalidates them; the epoch counter itself + is persisted per session so it never repeats. +- **Scope**: the lease governs terminal input/resize/stop, chat submission, + interrupt, approval answers and future mutations. Disconnect stops renewal; + the lease then expires. + +## 6. Submissions and receipts + +| op | dir | fields | +|---|---|---| +| `submit` | Cβ†’H | `binding` (with `lease_epoch`), `request_id`, `kind`, `payload` | +| `receipt` | Hβ†’C | `request_id`, `target`, `kind`, `state`, `detail?`, `updated_at` | +| `receipt_query` | Cβ†’H | `binding` (no `lease_epoch`), `request_ids` (≀ 64) | +| `receipts` | Hβ†’C | `receipts: [receipt]` (only those bound to that exact target) | + +Kinds and payloads (v1): + +| kind | payload | completes when | +|---|---|---| +| `prompt` | `{"text": string}` | the provider confirms the turn ended (chat-v1 `complete`) | +| `interrupt` | `{}` | the provider reports the turn interrupted, or the turn was already over (`detail`) | +| `stop` | `{}` | the agent process exits | +| `approval` | reserved for v1.1 (needs an approval identity); rejected with `forbidden` + `detail:"approval answers are not supported in v1"` | + +- **Digest**: the host computes `payload_digest = sha256(JCS(payload))`, JCS = + RFC 8785 over the supported domain (objects, arrays, strings, booleans, null, + integers in the safe range; no floats). A client-supplied digest is not used. + Known-answer vectors: `fixtures/digest-vectors.json`. +- **Immutable request**: (`request_id`, `account_id`, `atem_device_id`, + `host_trust_gen`, `session_id`, `process_gen`, `kind`, `payload_digest`). + Same `request_id` with every bound value equal β†’ the recorded receipt (needs + current view permission for the target; no lease, no re-execution). Same + `request_id` with any value different β†’ `request_id_reused`. +- **Admission** of a new request β€” and every lease-scoped mutation + (terminal input/resize/stop) β€” is checked in this order: `control` + permission (`forbidden`), then a session that accepts work + (`session_closed`, even when the lease is also stale), then the current + lease (`stale_lease`). If the durable write of + `accepted` fails, the reply is `error{code:"busy"}`, nothing is recorded and + the client may retry with the same `request_id`. +- A duplicate `submit` names the current target: one naming an earlier + `process_gen` of the session is `stale_target`, while `receipt_query` for + that earlier target still returns its receipts. +- **Scheduling**: `prompt`s are serialized per session (one paste at a time; + later ones stay `accepted` until the previous one leaves `executing`); + `interrupt` and `stop` are dispatched immediately. +- **States and transitions** (all others are invalid and never happen): + +| from | to | when | +|---|---|---| +| β€” | `accepted` | admitted and durably recorded (fsync) β€” then the reply | +| β€” | `rejected` | refused at admission (nothing recorded as accepted) | +| `accepted` | `executing` | durably recorded before the first external side effect | +| `accepted` | `rejected` | before any side effect: session closed, trust generation changed, or the durable write of `executing` failed | +| `executing` | `completed` | the kind's completion (table above) observed | +| `executing` | `rejected` | dispatch failed **with proof that no side effect happened** (see below) | +| `executing` | `outcome_unknown` | dispatch failed after a possible partial effect, the agent process ended before the kind's completion, or crash/restart and reconciliation can't establish the outcome | + + `completed`, `rejected` and `outcome_unknown` are final. Every exit from + `executing` is persisted (fsync) before it is reported; a receipt never stays + `executing` because of an ordinary failure. +- **Dispatch failures** (normative, per kind): + - `prompt`: the paste is one PTY write of all chunks reserved together. If the + reservation/enqueue fails, no byte reached the PTY β†’ `rejected`, + `detail:"could not write to the terminal"`. If the writer reports an error + after some bytes may have been written β†’ `outcome_unknown`, + `detail:"write interrupted"`. If the agent process ends before the turn + completes β†’ `outcome_unknown`, `detail:"agent exited"`. + - `interrupt`: the interrupt key could not be enqueued β†’ `rejected`; written + but no interrupted/ended turn observed within 10 s β†’ `outcome_unknown`, + `detail:"no interrupt observed"`. + - `stop`: the signal could not be delivered (e.g. `ESRCH`/`EPERM`) and the + process is still alive β†’ `rejected`, `detail:"stop failed"`; delivered but + the process hasn't exited within 10 s β†’ `outcome_unknown`, + `detail:"process still running"` (a later observed exit doesn't change it). + - A failure that cannot be classified as "no side effect" is + `outcome_unknown`. Neither final failure state is ever retried + automatically; a duplicate `submit` returns the recorded final receipt. A receipt's + `request_id`, target, kind and digest never change; replies carry the same + values through recovery. +- **Crash boundaries**: `accepted` is persisted (fsync) before the reply; + `executing` is persisted before the first external side effect (e.g. the + paste). After a crash, two cases stay distinct: + `accepted` work has provably not started any side effect, so it may still be + dispatched β€” once, under its original target, and only if the host trust + generation is unchanged (else `rejected`, `detail:"host trust changed"`). + `executing` work may have started a side effect: it is never dispatched again; + it is reconciled with the worker/provider, and if that can't establish the + outcome it becomes `outcome_unknown`. +- Controller disconnect and lease expiry don't cancel accepted work. +- **Retention**: receipts live for the session's life and 7 days after it closes. + An accepting session's receipts are never evicted; at its limit new + submissions get `receipt_limit`. Closed sessions answer `session_closed`. +- chat-v1: its `submit.requestId` is this + `request_id`, it carries `binding`, and its `accepted`/`complete` follow this + receipt's `accepted`/`completed`. + +## 7. Errors + +`error{re, code, detail?}`; `detail` is human-readable, bounded, and never +contains secrets, paths, raw OS errors or other targets' identifiers. + +Errors are answered in the family of the request (ruling i): + +- session-v1 requests: session `error{re, code, detail?}`. +- A valid terminal-v1 request that is refused: terminal-v1 + `error{terminal_id, stream_id?, code, message}` (`stream_id` when the + request carried one; `message` is fixed text per code). +- chat-v1: `error{requestId?, code, text}` (chat-v1's `error` gains `code`). + A chat `submit` hears about its request only through chat-v1 ops mapped + from its receipt, never a session `receipt`: `accepted`/`executing` β†’ + `accepted{requestId}`; `completed` β†’ `complete{requestId, turnId?, + outcome:"done"}`; `rejected` β†’ `error{requestId, code:"rejected", text: + detail}`; `outcome_unknown` β†’ `error{requestId, code:"outcome_unknown", + text: detail}`. Refusals at admission use the Β§7 code. +- Removed forms (Β§0) and requests that don't decode β€” of any family β€” get a + session `error{code:"unsupported_version"}` without `re` when the request + had no readable `id`, and have no effect. + +| code | when | +|---|---| +| `unsupported_version` | unknown `v`, out-of-range representation, or a removed op (Β§0) | +| `unauthorized` | not authenticated, attachment fenced, or authorization expired | +| `forbidden` | missing permission | +| `stale_target` | trust generation, session or process generation mismatch | +| `unknown_session` | unknown or not visible to this attachment | +| `lease_held` | another controller holds the lease (no takeover) | +| `stale_lease` | wrong, expired or other-attachment lease epoch | +| `request_id_reused` | same request id, different bound values | +| `receipt_limit` | the session's receipt journal is full | +| `session_closed` | the session no longer accepts submissions | +| `busy` | the agent or attachment can't take it now | +| `paused` | authorization freshness deadline passed; viewing/control paused | + +## 8. Fixtures + +One JSON file per scenario in `fixtures/`, schema `fixtures/schema.md`: + +```json +{"name":"lease-expires-without-renewal", + "setup":{"sessions":[{"session_id":"ses-aaaaaaaaaaaa","process_gen":"…"}],"clients":["c1"]}, + "steps":[ + {"send":"c1","json":{"type":"session","data":{"v":1,"op":"acquire",…}}}, + {"expect":"c1","match":{"op":"lease","binding.lease_epoch":1}}, + {"advance_ms":15001}, + {"expect_all":{"op":"lease_changed","reason":"expired"}}, + {"send":"c1","json":{…"op":"renew"…}}, + {"expect":"c1","match":{"op":"error","code":"stale_lease"}}, + {"assert":{"pty_writes":0,"dispatches":0}}]} +``` + +The setup fixes the host context (`account_id`, `atem_device_id`, +`host_trust_gen`, `runtime_gen`, `runtime_epoch`) and each client's +authenticated identity and permissions; nothing is derived from message fields. +Actions (deterministic clock, see `fixtures/schema.md`): `send`, `expect`, +`expect_none`, `advance_ms`, `hold`/`deliver` (delay and reorder a specific +message), `disconnect`, `resume`, `local_input`, `crash_worker`, +`restart_runtime`, `fail_next_fsync`, `change_trust_gen`, `assert` (host +counters: PTY writes, resizes, stops, dispatches, receipts visible per client; +client state for client-side fixtures: `controls`, `deadline_ms`, registry). Negative scenarios assert both the +error and the absence of writes, dispatch, disclosure or resurrection. The +first set (r3 adds the r2-review cases: removed ops have no effect, late/duplicate +lease replies, runtime epoch snapshots, snapshot/change reordering, hidden-session +changes, change after removal, receipt-query authorization, changed-target id +reuse, failed durable write, local reclaim at the PTY boundary, old attachment +and old runtime input): hello/welcome, list/snapshot+revision, gap recovery, tombstone, +acquire/renew/expiry, repeat acquire, takeover, local input revocation, queued +bytes dropped on transfer, stale epoch, submit acceptedβ†’executingβ†’completed, +dispatch failure before any effect (β†’ rejected) and after a possible partial +effect (β†’ outcome_unknown), each followed by a duplicate submit and recovery +after restart returning the same final receipt, +duplicate submit, reused id (other target), crash after executing β†’ +outcome_unknown, trust change before dispatch β†’ rejected, closed session, +receipt query scoped to target. + +## 9. Gate: trust, freshness, channel and browser grants (joint) + +Must be specified jointly, implemented and tested before account-wide native +access, resume, browser clients or relay confidentiality are enabled: trust +anchor installation, signed native membership and bounded management +delegation, host acceptance of account-wide authority, unpair generation +commits, signed revocation ordering, authenticated freshness proofs (the 24 h +window starts at a validated authority proof, never at relay presence, +reconnection, an old signature or a relay timestamp; rollback protection and +conservative restart/clock handling), channel key establishment with +replay/downgrade protection binding endpoint keys, account, roles, permissions +and generations, the `resume_proof` format, and QR grant binding. diff --git a/designs/protocol/fixtures/closed-session-refuses-work-before-the-lease-check.json b/designs/protocol/fixtures/closed-session-refuses-work-before-the-lease-check.json new file mode 100644 index 0000000..e0c6387 --- /dev/null +++ b/designs/protocol/fixtures/closed-session-refuses-work-before-the-lease-check.json @@ -0,0 +1,193 @@ +{ + "name": "closed-session-refuses-work-before-the-lease-check", + "setup": { + "host": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 7 + }, + "sessions": [ + { + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222", + "chat": true + } + ], + "clients": [ + { + "name": "c1", + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "permissions": [ + "view", + "control" + ], + "client_kind": "native", + "holds_lease_epoch": 1 + } + ] + }, + "steps": [ + { + "send": "c1", + "json": { + "type": "terminal", + "data": { + "v": 1, + "op": "subscribe", + "terminal_id": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333" + } + } + } + }, + { + "expect": "c1", + "type": "terminal", + "match": { + "op": "attached", + "session.terminal_id": "ses-aaaaaaaaaaaa" + }, + "capture": { + "s": "stream_id" + } + }, + { + "close_session": "ses-aaaaaaaaaaaa" + }, + { + "expect": "c1", + "match": { + "op": "lease_changed", + "reason": "revoked", + "lease_epoch": 2, + "controller": null + } + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "submit", + "id": "11111111-1111-4111-8111-000000000001", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + }, + "request_id": "44444444-4444-4444-8444-000000000001", + "kind": "prompt", + "payload": { + "text": "run the tests" + } + } + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "error", + "re": "11111111-1111-4111-8111-000000000001", + "code": "session_closed" + } + }, + { + "send": "c1", + "json": { + "type": "terminal", + "data": { + "v": 1, + "op": "input", + "terminal_id": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "stream_id": "${s}", + "sequence": 0, + "bytes": "aGkN", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + } + } + } + }, + { + "expect": "c1", + "type": "terminal", + "match": { + "op": "error", + "code": "session_closed", + "terminal_id": "ses-aaaaaaaaaaaa", + "stream_id": "${s}" + } + }, + { + "send": "c1", + "json": { + "type": "agentChat", + "data": { + "v": 1, + "op": "submit", + "conversationId": "conv-1", + "agentId": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "requestId": "44444444-4444-4444-8444-000000000002", + "text": "run the tests", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + } + } + } + }, + { + "expect": "c1", + "type": "agentChat", + "match": { + "op": "error", + "code": "session_closed", + "requestId": "44444444-4444-4444-8444-000000000002" + } + }, + { + "assert": { + "pty_writes": 0, + "dispatch_attempts": 0, + "stops": 0 + } + } + ], + "note": "Closing a session revokes its lease; later work is session_closed (checked before the lease) in each family, with no effect." +} \ No newline at end of file diff --git a/designs/protocol/fixtures/digest-vectors.json b/designs/protocol/fixtures/digest-vectors.json new file mode 100644 index 0000000..30eec86 --- /dev/null +++ b/designs/protocol/fixtures/digest-vectors.json @@ -0,0 +1,140 @@ +{ + "algorithm": "sha256(RFC 8785 JCS(payload)) lowercase hex. Supported domain: objects, arrays, strings (valid Unicode scalar values only), true/false/null, integers within Β±(2^53βˆ’1). Keys sorted by UTF-16 code units. Parsers must reject the must_reject inputs before canonicalising (duplicate keys at any depth, lone surrogates, non-integers, exponents, unsafe integers, invalid UTF-8).", + "vectors": [ + { + "name": "prompt-ascii", + "payload": { + "text": "run the tests" + }, + "canonical": "{\"text\":\"run the tests\"}", + "sha256": "51b365c654de71c69dac5afdc09dbbf06929ef6137ee7878f32937071d2923a8" + }, + { + "name": "prompt-unicode", + "payload": { + "text": "hΓ©llo δΈ–η•Œ" + }, + "canonical": "{\"text\":\"hΓ©llo δΈ–η•Œ\"}", + "sha256": "98945f882da718228730803d0d175141da947f0f1169099d47b558dd44746e8e" + }, + { + "name": "empty", + "payload": {}, + "canonical": "{}", + "sha256": "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a" + }, + { + "name": "nested-sorted", + "payload": { + "text": "a", + "b": [ + 1, + true, + null, + "x" + ] + }, + "canonical": "{\"b\":[1,true,null,\"x\"],\"text\":\"a\"}", + "sha256": "11132adf10a49624454ea644e1582f0669cf71867b61bd12f20b80ee3cb28e02" + }, + { + "name": "escapes", + "payload": { + "text": "line1\nline2\t\"q\"\\" + }, + "canonical": "{\"text\":\"line1\\nline2\\t\\\"q\\\"\\\\\"}", + "sha256": "40e4b600f8bca9c4c6636065e2274854929101b360f5bc2a4d221ad42e9b450a" + }, + { + "name": "utf16-key-order-supplementary", + "payload": { + "πŸ˜€": 1, + "fi": 2, + "a": 3, + "Γ©": 4 + }, + "canonical": "{\"a\":3,\"Γ©\":4,\"πŸ˜€\":1,\"fi\":2}", + "sha256": "db257859f37825cbdbc7e3fa930596e1fa4eac1ce98d86df74169ec143eaf4aa" + }, + { + "name": "supplementary-value", + "payload": { + "text": "emoji πŸ˜€ and CJK δΈ–" + }, + "canonical": "{\"text\":\"emoji πŸ˜€ and CJK δΈ–\"}", + "sha256": "b9c8f42c512a0164707cae3a06ec873f703bc9e963cf6c6871d96c01eab49d6e" + }, + { + "name": "control-escapes", + "payload": { + "text": "\u0000\u0001\u001f\b\f\n\r\t/" + }, + "canonical": "{\"text\":\"\\u0000\\u0001\\u001f\\b\\f\\n\\r\\t/\"}", + "sha256": "235e007d5d4c4a4288ab4b44483417567dcc3391c301176fbcc39413735f07c9" + }, + { + "name": "safe-integer-max", + "payload": { + "n": 9007199254740991, + "m": -9007199254740991 + }, + "canonical": "{\"m\":-9007199254740991,\"n\":9007199254740991}", + "sha256": "3f54c80abdfbc38f08ed5654622eea6e7d7e58cbac36cf5a0670c8aeb054703d" + }, + { + "name": "nested-key-order", + "payload": { + "z": { + "b": 1, + "a": 2 + }, + "a": [ + { + "y": true, + "x": false + } + ] + }, + "canonical": "{\"a\":[{\"x\":false,\"y\":true}],\"z\":{\"a\":2,\"b\":1}}", + "sha256": "7f886746bde2f1329e79e1fa3909a0f0d44b044c2e301ee84d6ae25eede4d698" + } + ], + "must_reject": [ + { + "name": "float-rejected", + "payload_json": "{\"n\":1.5}" + }, + { + "name": "duplicate-key-rejected", + "payload_json": "{\"text\":\"a\",\"text\":\"b\"}" + }, + { + "name": "unsafe-integer-rejected", + "payload_json": "{\"n\":9007199254740992}" + }, + { + "name": "lone-high-surrogate", + "payload_json": "{\"text\":\"\\ud800\"}" + }, + { + "name": "lone-low-surrogate", + "payload_json": "{\"text\":\"\\udc00x\"}" + }, + { + "name": "negative-unsafe-integer", + "payload_json": "{\"n\":-9007199254740992}" + }, + { + "name": "exponent-number", + "payload_json": "{\"n\":1e3}" + }, + { + "name": "nested-duplicate-key", + "payload_json": "{\"a\":{\"k\":1,\"k\":1}}" + }, + { + "name": "invalid-utf8-bytes", + "payload_hex": "7b2274657874223a22ff227d" + } + ] +} \ No newline at end of file diff --git a/designs/protocol/fixtures/dispatch-failure-after-partial-write-is-outcome-unknown.json b/designs/protocol/fixtures/dispatch-failure-after-partial-write-is-outcome-unknown.json new file mode 100644 index 0000000..fec5311 --- /dev/null +++ b/designs/protocol/fixtures/dispatch-failure-after-partial-write-is-outcome-unknown.json @@ -0,0 +1,177 @@ +{ + "name": "dispatch-failure-after-partial-write-is-outcome-unknown", + "setup": { + "host": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 7 + }, + "sessions": [ + { + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222", + "chat": true + } + ], + "clients": [ + { + "name": "c1", + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "permissions": [ + "view", + "control" + ], + "client_kind": "native", + "holds_lease_epoch": 1 + } + ] + }, + "steps": [ + { + "fail_next_pty_write_after_partial": true + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "submit", + "id": "11111111-1111-4111-8111-000000000001", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + }, + "request_id": "44444444-4444-4444-8444-444444444447", + "kind": "prompt", + "payload": { + "text": "run the tests" + } + } + } + }, + { + "expect": "c1", + "match": { + "op": "receipt", + "request_id": "44444444-4444-4444-8444-444444444447", + "state": "accepted" + } + }, + { + "expect": "c1", + "match": { + "op": "receipt", + "request_id": "44444444-4444-4444-8444-444444444447", + "state": "executing" + } + }, + { + "expect": "c1", + "match": { + "op": "receipt", + "request_id": "44444444-4444-4444-8444-444444444447", + "state": "outcome_unknown", + "detail": "write interrupted" + } + }, + { + "assert": { + "dispatch_attempts": 1, + "pty_bytes_written": "unknown" + } + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "submit", + "id": "11111111-1111-4111-8111-000000000002", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + }, + "request_id": "44444444-4444-4444-8444-444444444447", + "kind": "prompt", + "payload": { + "text": "run the tests" + } + } + } + }, + { + "expect": "c1", + "match": { + "op": "receipt", + "request_id": "44444444-4444-4444-8444-444444444447", + "state": "outcome_unknown" + } + }, + { + "restart_runtime": { + "runtime_gen": "99999999-9999-4999-8999-999999999999", + "runtime_epoch": 8 + } + }, + { + "reconnect": "c1", + "attachment_gen": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "receipt_query", + "id": "11111111-1111-4111-8111-000000000003", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" + }, + "request_ids": [ + "44444444-4444-4444-8444-444444444447" + ] + } + } + }, + { + "expect": "c1", + "match": { + "op": "receipts", + "receipts.0.request_id": "44444444-4444-4444-8444-444444444447", + "receipts.0.state": "outcome_unknown" + } + }, + { + "assert": { + "dispatch_attempts": 1 + } + } + ], + "note": "The final failure state is durable, never retried, returned for a duplicate submit and still reported after a runtime restart." +} \ No newline at end of file diff --git a/designs/protocol/fixtures/dispatch-failure-before-any-effect-is-rejected.json b/designs/protocol/fixtures/dispatch-failure-before-any-effect-is-rejected.json new file mode 100644 index 0000000..0e5f7f7 --- /dev/null +++ b/designs/protocol/fixtures/dispatch-failure-before-any-effect-is-rejected.json @@ -0,0 +1,177 @@ +{ + "name": "dispatch-failure-before-any-effect-is-rejected", + "setup": { + "host": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 7 + }, + "sessions": [ + { + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222", + "chat": true + } + ], + "clients": [ + { + "name": "c1", + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "permissions": [ + "view", + "control" + ], + "client_kind": "native", + "holds_lease_epoch": 1 + } + ] + }, + "steps": [ + { + "fail_next_pty_enqueue": true + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "submit", + "id": "11111111-1111-4111-8111-000000000001", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + }, + "request_id": "44444444-4444-4444-8444-444444444446", + "kind": "prompt", + "payload": { + "text": "run the tests" + } + } + } + }, + { + "expect": "c1", + "match": { + "op": "receipt", + "request_id": "44444444-4444-4444-8444-444444444446", + "state": "accepted" + } + }, + { + "expect": "c1", + "match": { + "op": "receipt", + "request_id": "44444444-4444-4444-8444-444444444446", + "state": "executing" + } + }, + { + "expect": "c1", + "match": { + "op": "receipt", + "request_id": "44444444-4444-4444-8444-444444444446", + "state": "rejected", + "detail": "could not write to the terminal" + } + }, + { + "assert": { + "dispatch_attempts": 1, + "pty_bytes_written": 0 + } + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "submit", + "id": "11111111-1111-4111-8111-000000000002", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + }, + "request_id": "44444444-4444-4444-8444-444444444446", + "kind": "prompt", + "payload": { + "text": "run the tests" + } + } + } + }, + { + "expect": "c1", + "match": { + "op": "receipt", + "request_id": "44444444-4444-4444-8444-444444444446", + "state": "rejected" + } + }, + { + "restart_runtime": { + "runtime_gen": "99999999-9999-4999-8999-999999999999", + "runtime_epoch": 8 + } + }, + { + "reconnect": "c1", + "attachment_gen": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "receipt_query", + "id": "11111111-1111-4111-8111-000000000003", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" + }, + "request_ids": [ + "44444444-4444-4444-8444-444444444446" + ] + } + } + }, + { + "expect": "c1", + "match": { + "op": "receipts", + "receipts.0.request_id": "44444444-4444-4444-8444-444444444446", + "receipts.0.state": "rejected" + } + }, + { + "assert": { + "dispatch_attempts": 1 + } + } + ], + "note": "The final failure state is durable, never retried, returned for a duplicate submit and still reported after a runtime restart." +} \ No newline at end of file diff --git a/designs/protocol/fixtures/duplicate-submit-returns-receipt-and-reuse-is-rejected.json b/designs/protocol/fixtures/duplicate-submit-returns-receipt-and-reuse-is-rejected.json new file mode 100644 index 0000000..e5c5e59 --- /dev/null +++ b/designs/protocol/fixtures/duplicate-submit-returns-receipt-and-reuse-is-rejected.json @@ -0,0 +1,151 @@ +{ + "name": "duplicate-submit-returns-receipt-and-reuse-is-rejected", + "setup": { + "sessions": [ + { + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222", + "chat": true + } + ], + "clients": [ + { + "name": "c1", + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "permissions": [ + "view", + "control" + ], + "holds_lease_epoch": 1, + "client_kind": "native" + } + ], + "host": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 7 + } + }, + "steps": [ + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "submit", + "id": "11111111-1111-4111-8111-000000000003", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + }, + "request_id": "44444444-4444-4444-8444-444444444444", + "kind": "prompt", + "payload": { + "text": "run the tests" + } + } + } + }, + { + "expect": "c1", + "match": { + "op": "receipt", + "request_id": "44444444-4444-4444-8444-444444444444", + "state": "accepted" + } + }, + { + "expect": "c1", + "match": { + "op": "receipt", + "request_id": "44444444-4444-4444-8444-444444444444", + "state": "executing" + } + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "submit", + "id": "11111111-1111-4111-8111-000000000004", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + }, + "request_id": "44444444-4444-4444-8444-444444444444", + "kind": "prompt", + "payload": { + "text": "run the tests" + } + } + } + }, + { + "expect": "c1", + "match": { + "op": "receipt", + "re": "11111111-1111-4111-8111-000000000004", + "request_id": "44444444-4444-4444-8444-444444444444", + "state": "executing" + } + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "submit", + "id": "11111111-1111-4111-8111-000000000005", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + }, + "request_id": "44444444-4444-4444-8444-444444444444", + "kind": "prompt", + "payload": { + "text": "something else" + } + } + } + }, + { + "expect": "c1", + "match": { + "op": "error", + "code": "request_id_reused" + } + }, + { + "assert": { + "dispatches": 1 + } + } + ] +} \ No newline at end of file diff --git a/designs/protocol/fixtures/exited-session-refuses-work-before-the-lease-check.json b/designs/protocol/fixtures/exited-session-refuses-work-before-the-lease-check.json new file mode 100644 index 0000000..bef5be3 --- /dev/null +++ b/designs/protocol/fixtures/exited-session-refuses-work-before-the-lease-check.json @@ -0,0 +1,193 @@ +{ + "name": "exited-session-refuses-work-before-the-lease-check", + "setup": { + "host": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 7 + }, + "sessions": [ + { + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222", + "chat": true + } + ], + "clients": [ + { + "name": "c1", + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "permissions": [ + "view", + "control" + ], + "client_kind": "native", + "holds_lease_epoch": 1 + } + ] + }, + "steps": [ + { + "send": "c1", + "json": { + "type": "terminal", + "data": { + "v": 1, + "op": "subscribe", + "terminal_id": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333" + } + } + } + }, + { + "expect": "c1", + "type": "terminal", + "match": { + "op": "attached", + "session.terminal_id": "ses-aaaaaaaaaaaa" + }, + "capture": { + "s": "stream_id" + } + }, + { + "crash_worker": "ses-aaaaaaaaaaaa" + }, + { + "expect": "c1", + "match": { + "op": "lease_changed", + "reason": "process_ended", + "lease_epoch": 2, + "controller": null + } + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "submit", + "id": "11111111-1111-4111-8111-000000000001", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + }, + "request_id": "44444444-4444-4444-8444-000000000001", + "kind": "prompt", + "payload": { + "text": "run the tests" + } + } + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "error", + "re": "11111111-1111-4111-8111-000000000001", + "code": "session_closed" + } + }, + { + "send": "c1", + "json": { + "type": "terminal", + "data": { + "v": 1, + "op": "input", + "terminal_id": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "stream_id": "${s}", + "sequence": 0, + "bytes": "aGkN", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + } + } + } + }, + { + "expect": "c1", + "type": "terminal", + "match": { + "op": "error", + "code": "session_closed", + "terminal_id": "ses-aaaaaaaaaaaa", + "stream_id": "${s}" + } + }, + { + "send": "c1", + "json": { + "type": "agentChat", + "data": { + "v": 1, + "op": "submit", + "conversationId": "conv-1", + "agentId": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "requestId": "44444444-4444-4444-8444-000000000002", + "text": "run the tests", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + } + } + } + }, + { + "expect": "c1", + "type": "agentChat", + "match": { + "op": "error", + "code": "session_closed", + "requestId": "44444444-4444-4444-8444-000000000002" + } + }, + { + "assert": { + "pty_writes": 0, + "dispatch_attempts": 0, + "stops": 0 + } + } + ], + "note": "Admission order is control -> session accepting -> lease: after the agent exits, a submit, terminal input or chat submit carrying the old lease epoch is session_closed (not stale_lease), each in its own family, with no effect." +} \ No newline at end of file diff --git a/designs/protocol/fixtures/family-errors-and-chat-results.json b/designs/protocol/fixtures/family-errors-and-chat-results.json new file mode 100644 index 0000000..e21f763 --- /dev/null +++ b/designs/protocol/fixtures/family-errors-and-chat-results.json @@ -0,0 +1,414 @@ +{ + "name": "family-errors-and-chat-results", + "setup": { + "host": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 7 + }, + "sessions": [ + { + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222", + "chat": true + } + ], + "clients": [ + { + "name": "c1", + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "permissions": [ + "view", + "control" + ], + "client_kind": "native", + "holds_lease_epoch": 1 + } + ] + }, + "steps": [ + { + "send": "c1", + "json": { + "type": "terminal", + "data": { + "v": 1, + "op": "subscribe", + "terminal_id": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333" + } + } + } + }, + { + "expect": "c1", + "type": "terminal", + "match": { + "op": "attached", + "session.terminal_id": "ses-aaaaaaaaaaaa" + }, + "capture": { + "s": "stream_id" + } + }, + { + "send": "c1", + "json": { + "type": "terminal", + "data": { + "v": 1, + "op": "input", + "terminal_id": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "stream_id": "${s}", + "sequence": 0, + "bytes": "aGkN", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 2 + } + } + } + }, + { + "expect": "c1", + "type": "terminal", + "match": { + "op": "error", + "code": "stale_lease", + "terminal_id": "ses-aaaaaaaaaaaa", + "stream_id": "${s}" + } + }, + { + "send": "c1", + "json": { + "type": "terminal", + "data": { + "v": 1, + "op": "input", + "terminal_id": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "stream_id": "not-the-stream", + "sequence": 0, + "bytes": "aGkN", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + } + } + } + }, + { + "expect": "c1", + "type": "terminal", + "match": { + "op": "error", + "code": "stale_target", + "terminal_id": "ses-aaaaaaaaaaaa", + "stream_id": "not-the-stream" + } + }, + { + "send": "c1", + "json": { + "type": "agentChat", + "data": { + "v": 1, + "op": "submit", + "conversationId": "conv-1", + "agentId": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "requestId": "44444444-4444-4444-8444-000000000001", + "text": "run the tests", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 2 + } + } + } + }, + { + "expect": "c1", + "type": "agentChat", + "match": { + "op": "error", + "code": "stale_lease", + "requestId": "44444444-4444-4444-8444-000000000001", + "conversationId": "conv-1" + } + }, + { + "send": "c1", + "json": { + "type": "agentChat", + "data": { + "v": 1, + "op": "submit", + "conversationId": "conv-1", + "agentId": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "requestId": "44444444-4444-4444-8444-000000000002", + "text": "run the tests", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + } + } + } + }, + { + "expect": "c1", + "type": "agentChat", + "match": { + "op": "accepted", + "requestId": "44444444-4444-4444-8444-000000000002", + "conversationId": "conv-1", + "agentId": "ses-aaaaaaaaaaaa" + } + }, + { + "turn_complete": "ses-aaaaaaaaaaaa", + "turn_id": "turn-1" + }, + { + "expect": "c1", + "type": "agentChat", + "match": { + "op": "complete", + "requestId": "44444444-4444-4444-8444-000000000002", + "turnId": "turn-1", + "outcome": "done" + } + }, + { + "send": "c1", + "json": { + "type": "agentChat", + "data": { + "v": 1, + "op": "submit", + "conversationId": "conv-1", + "agentId": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "requestId": "44444444-4444-4444-8444-000000000002", + "text": "run the tests", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + } + } + } + }, + { + "expect": "c1", + "type": "agentChat", + "match": { + "op": "complete", + "requestId": "44444444-4444-4444-8444-000000000002", + "outcome": "done" + } + }, + { + "fail_next_pty_enqueue": true + }, + { + "send": "c1", + "json": { + "type": "agentChat", + "data": { + "v": 1, + "op": "submit", + "conversationId": "conv-1", + "agentId": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "requestId": "44444444-4444-4444-8444-000000000003", + "text": "run the tests", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + } + } + } + }, + { + "expect": "c1", + "type": "agentChat", + "match": { + "op": "accepted", + "requestId": "44444444-4444-4444-8444-000000000003" + } + }, + { + "expect": "c1", + "type": "agentChat", + "match": { + "op": "error", + "requestId": "44444444-4444-4444-8444-000000000003", + "code": "rejected", + "text": "could not write to the terminal" + } + }, + { + "fail_next_pty_write_after_partial": true + }, + { + "send": "c1", + "json": { + "type": "agentChat", + "data": { + "v": 1, + "op": "submit", + "conversationId": "conv-1", + "agentId": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "requestId": "44444444-4444-4444-8444-000000000004", + "text": "run the tests", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + } + } + } + }, + { + "expect": "c1", + "type": "agentChat", + "match": { + "op": "accepted", + "requestId": "44444444-4444-4444-8444-000000000004" + } + }, + { + "expect": "c1", + "type": "agentChat", + "match": { + "op": "error", + "requestId": "44444444-4444-4444-8444-000000000004", + "code": "outcome_unknown", + "text": "write interrupted" + } + }, + { + "send": "c1", + "json": { + "type": "agentChat", + "data": { + "v": 1, + "op": "submit", + "conversationId": "conv-1", + "agentId": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "requestId": "44444444-4444-4444-8444-000000000005", + "text": "hi" + } + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "error", + "code": "unsupported_version" + } + }, + { + "send": "c1", + "json": { + "type": "terminal", + "data": { + "v": 1, + "op": "subscribe", + "terminal_id": "claude", + "generation": "22222222-2222-4222-8222-222222222222", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333" + } + } + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "error", + "code": "unsupported_version" + } + }, + { + "assert": { + "dispatch_attempts": 3, + "dispatches": 2, + "pty_writes": 0 + } + } + ], + "note": "Refused terminal-v1 requests get terminal-v1 error{terminal_id, stream_id, code, message}; a chat-v1 submit hears only chat-v1 ops mapped from its receipt (accepted/executing -> accepted, completed -> complete{outcome:done, turnId}, rejected/outcome_unknown -> error{code, text}), never a session receipt; removed and undecodable family requests get a session error." +} \ No newline at end of file diff --git a/designs/protocol/fixtures/late-renew-reply-does-not-resurrect-control.json b/designs/protocol/fixtures/late-renew-reply-does-not-resurrect-control.json new file mode 100644 index 0000000..8e71da2 --- /dev/null +++ b/designs/protocol/fixtures/late-renew-reply-does-not-resurrect-control.json @@ -0,0 +1,367 @@ +{ + "name": "late-renew-reply-does-not-resurrect-control", + "side": "client", + "setup": { + "host": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 7 + }, + "sessions": [ + { + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + } + ], + "clients": [ + { + "name": "c1", + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "permissions": [ + "view", + "control" + ], + "client_kind": "native" + } + ] + }, + "steps": [ + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "acquire", + "id": "11111111-1111-4111-8111-000000000010", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333" + }, + "takeover": false + } + } + }, + { + "deliver_to_client": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "lease", + "id": "11111111-1111-4111-8111-000000000090", + "re": "11111111-1111-4111-8111-000000000010", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + }, + "ms_left": 15000 + } + } + }, + { + "assert_client": { + "controls": true, + "deadline_ms": 15000 + } + }, + { + "advance_ms": 2000 + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "renew", + "id": "11111111-1111-4111-8111-000000000014", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + } + } + } + }, + { + "deliver_to_client": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "lease", + "id": "11111111-1111-4111-8111-000000000090", + "re": "11111111-1111-4111-8111-000000000010", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + }, + "ms_left": 15000 + } + } + }, + { + "assert_client": { + "controls": true, + "deadline_ms": 15000 + } + }, + { + "deliver_to_client": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "lease", + "id": "11111111-1111-4111-8111-000000000094", + "re": "11111111-1111-4111-8111-000000000014", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + }, + "ms_left": 15000 + } + } + }, + { + "assert_client": { + "controls": true, + "deadline_ms": 17000 + } + }, + { + "advance_ms": 3000 + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "renew", + "id": "11111111-1111-4111-8111-000000000011", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + } + } + } + }, + { + "advance_ms": 12000 + }, + { + "deliver_to_client": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "lease", + "id": "11111111-1111-4111-8111-000000000091", + "re": "11111111-1111-4111-8111-000000000011", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + }, + "ms_left": 15000 + } + } + }, + { + "assert_client": { + "controls": false + } + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "acquire", + "id": "11111111-1111-4111-8111-000000000012", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333" + }, + "takeover": false + } + } + }, + { + "deliver_to_client": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "lease", + "id": "11111111-1111-4111-8111-000000000092", + "re": "11111111-1111-4111-8111-000000000012", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 3 + }, + "ms_left": 15000 + } + } + }, + { + "assert_client": { + "controls": true, + "deadline_ms": 32000 + } + }, + { + "deliver_to_client": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "lease", + "id": "11111111-1111-4111-8111-000000000090", + "re": "11111111-1111-4111-8111-000000000010", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + }, + "ms_left": 15000 + } + } + }, + { + "assert_client": { + "controls": true, + "deadline_ms": 32000 + } + }, + { + "advance_ms": 14000 + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "acquire", + "id": "11111111-1111-4111-8111-000000000013", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333" + }, + "takeover": false + } + } + }, + { + "advance_ms": 500 + }, + { + "deliver_to_client": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "lease", + "id": "11111111-1111-4111-8111-000000000093", + "re": "11111111-1111-4111-8111-000000000013", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 3 + }, + "ms_left": 500 + } + } + }, + { + "assert_client": { + "controls": false, + "input_enabled": false + } + } + ], + "note": "Client-side fixture (t = fake monotonic clock, ms). While renew B (sent at 2000) is pending, a duplicate of the already consumed acquire reply is dropped and the deadline stays 15000; B's reply then gives 2000+15000 = 17000. The reply to the renew sent at 5000 arrives at t=17000 with no timer tick: the acknowledged deadline (17000) is reached (now >= deadline), so it is dropped. A replayed reply to an already consumed re is dropped. A repeat acquire sent at 31000 whose reply (ms_left 500) is applied at 31500 gives deadline = min(32000, 31000+500, auth) = 31500 = now, so control and input are off immediately (expiry is now >= deadline)." +} \ No newline at end of file diff --git a/designs/protocol/fixtures/lease-expires-without-renewal.json b/designs/protocol/fixtures/lease-expires-without-renewal.json new file mode 100644 index 0000000..4472211 --- /dev/null +++ b/designs/protocol/fixtures/lease-expires-without-renewal.json @@ -0,0 +1,107 @@ +{ + "name": "lease-expires-without-renewal", + "setup": { + "sessions": [ + { + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + } + ], + "clients": [ + { + "name": "c1", + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "permissions": [ + "view", + "control" + ], + "client_kind": "native" + } + ], + "host": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 7 + } + }, + "steps": [ + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "acquire", + "id": "11111111-1111-4111-8111-000000000001", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333" + }, + "takeover": false + } + } + }, + { + "expect": "c1", + "match": { + "op": "lease", + "re": "11111111-1111-4111-8111-000000000001", + "binding.lease_epoch": 1 + } + }, + { + "advance_ms": 15001 + }, + { + "expect": "c1", + "match": { + "op": "lease_changed", + "reason": "expired", + "lease_epoch": 2 + } + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "renew", + "id": "11111111-1111-4111-8111-000000000002", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + } + } + } + }, + { + "expect": "c1", + "match": { + "op": "error", + "code": "stale_lease" + } + }, + { + "assert": { + "lease_holder": null, + "pty_writes": 0 + } + } + ] +} \ No newline at end of file diff --git a/designs/protocol/fixtures/lease-expiry-at-the-deadline-needs-no-timer.json b/designs/protocol/fixtures/lease-expiry-at-the-deadline-needs-no-timer.json new file mode 100644 index 0000000..6ee9686 --- /dev/null +++ b/designs/protocol/fixtures/lease-expiry-at-the-deadline-needs-no-timer.json @@ -0,0 +1,223 @@ +{ + "name": "lease-expiry-at-the-deadline-needs-no-timer", + "setup": { + "host": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 7 + }, + "sessions": [ + { + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + } + ], + "clients": [ + { + "name": "c1", + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "permissions": [ + "view", + "control" + ], + "client_kind": "native" + } + ] + }, + "steps": [ + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "acquire", + "id": "11111111-1111-4111-8111-000000000001", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333" + }, + "takeover": false + } + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "lease", + "re": "11111111-1111-4111-8111-000000000001", + "binding.lease_epoch": 1, + "ms_left": 15000 + } + }, + { + "send": "c1", + "json": { + "type": "terminal", + "data": { + "v": 1, + "op": "subscribe", + "terminal_id": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333" + } + } + } + }, + { + "expect": "c1", + "type": "terminal", + "match": { + "op": "attached", + "session.terminal_id": "ses-aaaaaaaaaaaa" + }, + "capture": { + "s": "stream_id" + } + }, + { + "hold_pty_writes": true + }, + { + "advance_ms": 14999, + "timers": false + }, + { + "send": "c1", + "json": { + "type": "terminal", + "data": { + "v": 1, + "op": "input", + "terminal_id": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "stream_id": "${s}", + "sequence": 0, + "bytes": "aGkN", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + } + } + } + }, + { + "advance_ms": 1, + "timers": false + }, + { + "flush_pty_writes": true + }, + { + "assert": { + "pty_writes_remote": 0, + "pty_writes_dropped": 1 + } + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "renew", + "id": "11111111-1111-4111-8111-000000000002", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + } + } + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "error", + "re": "11111111-1111-4111-8111-000000000002", + "code": "stale_lease" + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "lease_changed", + "reason": "expired", + "lease_epoch": 2 + } + }, + { + "send": "c1", + "json": { + "type": "terminal", + "data": { + "v": 1, + "op": "input", + "terminal_id": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "stream_id": "${s}", + "sequence": 1, + "bytes": "aGkN", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + } + } + } + }, + { + "expect": "c1", + "type": "terminal", + "match": { + "op": "error", + "code": "stale_lease" + } + }, + { + "assert": { + "lease_holder": null, + "pty_writes": 0 + } + } + ], + "note": "Expiry is now >= deadline whether or not a timer fired: with the clock moved to exactly 15000 ms and no tick, bytes queued at 14999 are dropped at the write boundary, and renew/input are refused. The host may announce the expiry (lease_changed) when it next sees the time." +} \ No newline at end of file diff --git a/designs/protocol/fixtures/local-input-revokes-the-lease-and-drops-queued-bytes.json b/designs/protocol/fixtures/local-input-revokes-the-lease-and-drops-queued-bytes.json new file mode 100644 index 0000000..272ff08 --- /dev/null +++ b/designs/protocol/fixtures/local-input-revokes-the-lease-and-drops-queued-bytes.json @@ -0,0 +1,154 @@ +{ + "name": "local-input-revokes-the-lease-and-drops-queued-bytes", + "setup": { + "host": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 7 + }, + "sessions": [ + { + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + } + ], + "clients": [ + { + "name": "c1", + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "permissions": [ + "view", + "control" + ], + "client_kind": "native", + "holds_lease_epoch": 1 + } + ] + }, + "steps": [ + { + "send": "c1", + "json": { + "type": "terminal", + "data": { + "v": 1, + "op": "subscribe", + "terminal_id": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333" + } + } + } + }, + { + "expect": "c1", + "type": "terminal", + "match": { + "op": "attached", + "session.terminal_id": "ses-aaaaaaaaaaaa" + }, + "capture": { + "s": "stream_id" + } + }, + { + "hold_pty_writes": true + }, + { + "send": "c1", + "json": { + "type": "terminal", + "data": { + "v": 1, + "op": "input", + "terminal_id": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "stream_id": "${s}", + "sequence": 0, + "bytes": "aGkN", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + } + } + } + }, + { + "local_input": "ses-aaaaaaaaaaaa", + "bytes": "eA==" + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "lease_changed", + "reason": "local_input", + "lease_epoch": 2, + "controller": null + } + }, + { + "flush_pty_writes": true + }, + { + "assert": { + "pty_writes_local": 1, + "pty_writes_remote": 0, + "pty_writes_dropped": 1, + "lease_holder": null + } + }, + { + "send": "c1", + "json": { + "type": "terminal", + "data": { + "v": 1, + "op": "input", + "terminal_id": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "stream_id": "${s}", + "sequence": 1, + "bytes": "aGkN", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + } + } + } + }, + { + "expect": "c1", + "type": "terminal", + "match": { + "op": "error", + "code": "stale_lease" + } + } + ], + "note": "Local input wins: the remote lease is revoked (new epoch) before the local bytes are written, and remote bytes queued under the old epoch are dropped at the write boundary." +} \ No newline at end of file diff --git a/designs/protocol/fixtures/receipt-query-is-scoped-to-the-exact-target.json b/designs/protocol/fixtures/receipt-query-is-scoped-to-the-exact-target.json new file mode 100644 index 0000000..6c03a0d --- /dev/null +++ b/designs/protocol/fixtures/receipt-query-is-scoped-to-the-exact-target.json @@ -0,0 +1,368 @@ +{ + "name": "receipt-query-is-scoped-to-the-exact-target", + "setup": { + "host": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 7 + }, + "sessions": [ + { + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + { + "session_id": "ses-bbbbbbbbbbbb", + "process_gen": "22222222-2222-4222-8222-bbbbbbbbbbbb" + } + ], + "clients": [ + { + "name": "c1", + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "permissions": [ + "view", + "control" + ], + "client_kind": "native" + }, + { + "name": "v", + "attachment_gen": "77777777-7777-4777-8777-777777777777", + "permissions": [ + "view" + ], + "client_kind": "native" + }, + { + "name": "b", + "attachment_gen": "88888888-8888-4888-8888-888888888888", + "permissions": [ + "control" + ], + "client_kind": "native" + } + ] + }, + "steps": [ + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "acquire", + "id": "11111111-1111-4111-8111-000000000001", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333" + }, + "takeover": false + } + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "lease", + "re": "11111111-1111-4111-8111-000000000001" + } + }, + { + "expect": "v", + "type": "session", + "match": { + "op": "lease_changed", + "reason": "granted", + "session_id": "ses-aaaaaaaaaaaa" + } + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "acquire", + "id": "11111111-1111-4111-8111-000000000002", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-bbbbbbbbbbbb", + "process_gen": "22222222-2222-4222-8222-bbbbbbbbbbbb" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333" + }, + "takeover": false + } + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "lease", + "re": "11111111-1111-4111-8111-000000000002" + } + }, + { + "expect": "v", + "type": "session", + "match": { + "op": "lease_changed", + "reason": "granted", + "session_id": "ses-bbbbbbbbbbbb" + } + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "submit", + "id": "11111111-1111-4111-8111-000000000003", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + }, + "request_id": "44444444-4444-4444-8444-000000000001", + "kind": "prompt", + "payload": { + "text": "run the tests" + } + } + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "receipt", + "request_id": "44444444-4444-4444-8444-000000000001", + "state": "accepted" + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "receipt", + "request_id": "44444444-4444-4444-8444-000000000001", + "state": "executing" + } + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "submit", + "id": "11111111-1111-4111-8111-000000000004", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-bbbbbbbbbbbb", + "process_gen": "22222222-2222-4222-8222-bbbbbbbbbbbb" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + }, + "request_id": "44444444-4444-4444-8444-000000000002", + "kind": "prompt", + "payload": { + "text": "run the tests" + } + } + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "receipt", + "request_id": "44444444-4444-4444-8444-000000000002", + "state": "accepted" + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "receipt", + "request_id": "44444444-4444-4444-8444-000000000002", + "state": "executing" + } + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "receipt_query", + "id": "11111111-1111-4111-8111-000000000005", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333" + }, + "request_ids": [ + "44444444-4444-4444-8444-000000000001", + "44444444-4444-4444-8444-000000000002", + "44444444-4444-4444-8444-000000000099", + "44444444-4444-4444-8444-000000000001" + ] + } + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "receipts", + "re": "11111111-1111-4111-8111-000000000005", + "receipts.#": 1, + "receipts.0.request_id": "44444444-4444-4444-8444-000000000001", + "receipts.0.target.session_id": "ses-aaaaaaaaaaaa" + } + }, + { + "send": "v", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "receipt_query", + "id": "11111111-1111-4111-8111-000000000006", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-bbbbbbbbbbbb", + "process_gen": "22222222-2222-4222-8222-bbbbbbbbbbbb" + }, + "attachment_gen": "77777777-7777-4777-8777-777777777777" + }, + "request_ids": [ + "44444444-4444-4444-8444-000000000001", + "44444444-4444-4444-8444-000000000002" + ] + } + } + }, + { + "expect": "v", + "type": "session", + "match": { + "op": "receipts", + "re": "11111111-1111-4111-8111-000000000006", + "receipts.#": 1, + "receipts.0.request_id": "44444444-4444-4444-8444-000000000002" + } + }, + { + "send": "b", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "receipt_query", + "id": "11111111-1111-4111-8111-000000000007", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "88888888-8888-4888-8888-888888888888" + }, + "request_ids": [ + "44444444-4444-4444-8444-000000000001" + ] + } + } + }, + { + "expect": "b", + "type": "session", + "match": { + "op": "error", + "re": "11111111-1111-4111-8111-000000000007", + "code": "unknown_session" + } + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "receipt_query", + "id": "11111111-1111-4111-8111-000000000008", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 5, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333" + }, + "request_ids": [ + "44444444-4444-4444-8444-000000000001" + ] + } + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "error", + "re": "11111111-1111-4111-8111-000000000008", + "code": "stale_target" + } + }, + { + "assert": { + "receipts_visible": { + "c1": 2, + "v": 2, + "b": 0 + } + } + } + ], + "note": "receipt_query returns only receipts bound to exactly the binding's target (other targets' and unknown ids are absent, duplicates once); without view it is unknown_session; a wrong trust generation is stale_target." +} \ No newline at end of file diff --git a/designs/protocol/fixtures/reconnect-restarts-the-registry-sequence.json b/designs/protocol/fixtures/reconnect-restarts-the-registry-sequence.json new file mode 100644 index 0000000..a1532ae --- /dev/null +++ b/designs/protocol/fixtures/reconnect-restarts-the-registry-sequence.json @@ -0,0 +1,141 @@ +{ + "name": "reconnect-restarts-the-registry-sequence", + "setup": { + "host": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 7 + }, + "sessions": [ + { + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + } + ], + "clients": [ + { + "name": "c1", + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "permissions": [ + "view", + "control" + ], + "client_kind": "native" + } + ] + }, + "steps": [ + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "list", + "id": "11111111-1111-4111-8111-000000000001" + } + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "sessions", + "re": "11111111-1111-4111-8111-000000000001", + "seq": 1, + "runtime_epoch": 7, + "sessions.#": 1 + } + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "acquire", + "id": "11111111-1111-4111-8111-000000000002", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333" + }, + "takeover": false + } + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "lease", + "re": "11111111-1111-4111-8111-000000000002" + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "session_changed", + "seq": 2, + "descriptor.controller.lease_epoch": 1 + } + }, + { + "reconnect": "c1", + "attachment_gen": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "list", + "id": "11111111-1111-4111-8111-000000000003" + } + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "sessions", + "re": "11111111-1111-4111-8111-000000000003", + "seq": 1, + "runtime_epoch": 7, + "sessions.0.controller.lease_epoch": 1 + } + }, + { + "local_input": "ses-aaaaaaaaaaaa", + "bytes": "eA==" + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "lease_changed", + "reason": "local_input", + "lease_epoch": 2 + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "session_changed", + "seq": 2, + "descriptor.controller": null + } + } + ], + "note": "seq counts the messages one attachment received: a reconnected client (new attachment) gets a snapshot at seq 1 again and changes continue from there; the old attachment's lease is not inherited." +} \ No newline at end of file diff --git a/designs/protocol/fixtures/registry-restarts-on-a-new-attachment.json b/designs/protocol/fixtures/registry-restarts-on-a-new-attachment.json new file mode 100644 index 0000000..4726886 --- /dev/null +++ b/designs/protocol/fixtures/registry-restarts-on-a-new-attachment.json @@ -0,0 +1,177 @@ +{ + "name": "registry-restarts-on-a-new-attachment", + "side": "client", + "setup": { + "host": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 7 + }, + "sessions": [], + "clients": [ + { + "name": "c1", + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "permissions": [ + "view" + ], + "client_kind": "native" + } + ] + }, + "steps": [ + { + "deliver_to_client": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "sessions", + "id": "11111111-1111-4111-8111-000000000101", + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 7, + "seq": 5, + "sessions": [ + { + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222", + "provider": "claude", + "cwd_label": "Atem", + "state": "running", + "activity": "unknown", + "activity_since": 1760500000000, + "controller": null, + "capabilities": [ + "terminal-v1", + "chat-v1" + ], + "created_at": 1760490000000 + } + ] + } + } + }, + { + "assert_client": { + "registry": [ + "ses-aaaaaaaaaaaa" + ], + "seq": 5 + } + }, + { + "deliver_to_client": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "welcome", + "id": "11111111-1111-4111-8111-000000000102", + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "controller_session": "12121212-1212-4212-8212-121212121212", + "attachment_gen": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + "client_kind": "native", + "permissions": [ + "view" + ], + "authorization_ms_left": 86400000, + "server_time": 1760500000000 + } + } + }, + { + "assert_client": { + "registry": [], + "runtime_epoch": 7 + } + }, + { + "deliver_to_client": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "sessions", + "id": "11111111-1111-4111-8111-000000000103", + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 7, + "seq": 1, + "sessions": [] + } + } + }, + { + "assert_client": { + "registry": [], + "seq": 1 + } + }, + { + "deliver_to_client": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "session_changed", + "id": "11111111-1111-4111-8111-000000000104", + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 7, + "seq": 2, + "descriptor": { + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222", + "provider": "claude", + "cwd_label": "Atem", + "state": "running", + "activity": "unknown", + "activity_since": 1760500000000, + "controller": null, + "capabilities": [ + "terminal-v1", + "chat-v1" + ], + "created_at": 1760490000000 + } + } + } + }, + { + "assert_client": { + "registry": [ + "ses-aaaaaaaaaaaa" + ], + "seq": 2 + } + }, + { + "deliver_to_client": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "sessions", + "id": "11111111-1111-4111-8111-000000000105", + "runtime_gen": "77777777-7777-4777-8777-777777777777", + "runtime_epoch": 6, + "seq": 9, + "sessions": [] + } + } + }, + { + "assert_client": { + "registry": [ + "ses-aaaaaaaaaaaa" + ], + "runtime_epoch": 7, + "seq": 2 + } + } + ], + "note": "Client side: a welcome starts a new attachment, so the registry clears and the next snapshot applies at any seq (here 1 after 5), while the highest runtime epoch stays the floor." +} \ No newline at end of file diff --git a/designs/protocol/fixtures/removed-ops-have-no-effect.json b/designs/protocol/fixtures/removed-ops-have-no-effect.json new file mode 100644 index 0000000..76d1369 --- /dev/null +++ b/designs/protocol/fixtures/removed-ops-have-no-effect.json @@ -0,0 +1,437 @@ +{ + "name": "removed-ops-have-no-effect", + "setup": { + "host": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 7 + }, + "sessions": [ + { + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222", + "chat": true + } + ], + "clients": [ + { + "name": "up", + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "permissions": [ + "view", + "control" + ], + "client_kind": "native" + }, + { + "name": "other", + "attachment_gen": "66666666-6666-4666-8666-666666666666", + "permissions": [ + "view", + "control" + ], + "client_kind": "native" + } + ] + }, + "steps": [ + { + "send": "up", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "acquire", + "id": "11111111-1111-4111-8111-000000000001", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333" + }, + "takeover": false + } + } + }, + { + "expect": "up", + "match": { + "op": "lease", + "re": "11111111-1111-4111-8111-000000000001" + } + }, + { + "expect": "other", + "match": { + "op": "lease_changed", + "reason": "granted", + "lease_epoch": 1 + } + }, + { + "send": "other", + "json": { + "type": "terminal", + "data": { + "v": 1, + "op": "subscribe", + "terminal_id": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "66666666-6666-4666-8666-666666666666" + } + } + } + }, + { + "expect": "other", + "match": { + "op": "attached" + }, + "capture": { + "s2": "stream_id" + } + }, + { + "send": "other", + "json": { + "type": "agentInput", + "data": { + "agent_id": null, + "kind": "text", + "text": "rm -rf x" + } + } + }, + { + "expect": "other", + "match": { + "op": "error", + "code": "unsupported_version" + } + }, + { + "send": "other", + "json": { + "type": "agentInput", + "data": { + "agent_id": "ses-aaaaaaaaaaaa", + "kind": "key", + "key": "enter" + } + } + }, + { + "expect": "other", + "match": { + "op": "error", + "code": "unsupported_version" + } + }, + { + "send": "other", + "json": { + "type": "agentPrompt", + "data": { + "agent_id": "ses-aaaaaaaaaaaa", + "session_id": null, + "text": "hi" + } + } + }, + { + "expect": "other", + "match": { + "op": "error", + "code": "unsupported_version" + } + }, + { + "send": "other", + "json": { + "type": "voiceCommand", + "data": { + "text": "run the tests", + "is_final": true + } + } + }, + { + "expect": "other", + "match": { + "op": "error", + "code": "unsupported_version" + } + }, + { + "send": "other", + "json": { + "type": "userCommand", + "data": { + "command": "ls", + "action": "cli_input" + } + } + }, + { + "expect": "other", + "match": { + "op": "error", + "code": "unsupported_version" + } + }, + { + "send": "other", + "json": { + "type": "agentChat", + "data": { + "v": 1, + "op": "submit", + "conversationId": "c", + "agentId": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "requestId": "44444444-4444-4444-8444-444444444445", + "text": "hi" + } + } + }, + { + "expect": "other", + "match": { + "op": "error", + "code": "unsupported_version" + } + }, + { + "send": "other", + "json": { + "type": "terminal", + "data": { + "v": 1, + "op": "control", + "terminal_id": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "stream_id": "${s2}", + "take": true + } + } + }, + { + "expect": "other", + "match": { + "op": "error", + "code": "unsupported_version" + } + }, + { + "send": "other", + "json": { + "type": "terminal", + "data": { + "v": 1, + "op": "subscribe", + "terminal_id": "claude", + "generation": "22222222-2222-4222-8222-222222222222", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "66666666-6666-4666-8666-666666666666" + } + } + } + }, + { + "expect": "other", + "match": { + "op": "error", + "code": "unsupported_version" + } + }, + { + "assert": { + "pty_writes": 0, + "resizes": 0, + "stops": 0, + "dispatches": 0, + "lease_holder": "up" + } + }, + { + "send": "up", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "release", + "id": "11111111-1111-4111-8111-000000000002", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + } + } + } + }, + { + "expect": "up", + "match": { + "op": "lease_released" + } + }, + { + "expect": "other", + "match": { + "op": "lease_changed", + "reason": "released", + "lease_epoch": 2, + "controller": null + } + }, + { + "send": "other", + "json": { + "type": "agentInput", + "data": { + "agent_id": null, + "kind": "text", + "text": "rm -rf x" + } + } + }, + { + "expect": "other", + "match": { + "op": "error", + "code": "unsupported_version" + } + }, + { + "send": "other", + "json": { + "type": "agentInput", + "data": { + "agent_id": "ses-aaaaaaaaaaaa", + "kind": "key", + "key": "enter" + } + } + }, + { + "expect": "other", + "match": { + "op": "error", + "code": "unsupported_version" + } + }, + { + "send": "other", + "json": { + "type": "agentPrompt", + "data": { + "agent_id": "ses-aaaaaaaaaaaa", + "session_id": null, + "text": "hi" + } + } + }, + { + "expect": "other", + "match": { + "op": "error", + "code": "unsupported_version" + } + }, + { + "send": "other", + "json": { + "type": "voiceCommand", + "data": { + "text": "run the tests", + "is_final": true + } + } + }, + { + "expect": "other", + "match": { + "op": "error", + "code": "unsupported_version" + } + }, + { + "send": "other", + "json": { + "type": "userCommand", + "data": { + "command": "ls", + "action": "cli_input" + } + } + }, + { + "expect": "other", + "match": { + "op": "error", + "code": "unsupported_version" + } + }, + { + "send": "other", + "json": { + "type": "agentChat", + "data": { + "v": 1, + "op": "submit", + "conversationId": "c", + "agentId": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", + "requestId": "44444444-4444-4444-8444-444444444445", + "text": "hi" + } + } + }, + { + "expect": "other", + "match": { + "op": "error", + "code": "unsupported_version" + } + }, + { + "local_input": "ses-aaaaaaaaaaaa", + "bytes": "eA==" + }, + { + "assert": { + "pty_writes_local": 1, + "pty_writes_remote": 0, + "dispatches": 0 + } + } + ], + "note": "r6: no legacy paths. Old ops (agentInput, agentPrompt, voice, userCommand, unbound chat submit, terminal-v1 control) and provider-name terminal ids are refused with unsupported_version and no effect, whether or not a lease is held; only session-v1 acquire grants control; local input still works." +} \ No newline at end of file diff --git a/designs/protocol/fixtures/runtime-epoch-and-sequence-ordering.json b/designs/protocol/fixtures/runtime-epoch-and-sequence-ordering.json new file mode 100644 index 0000000..af943c6 --- /dev/null +++ b/designs/protocol/fixtures/runtime-epoch-and-sequence-ordering.json @@ -0,0 +1,258 @@ +{ + "name": "runtime-epoch-and-sequence-ordering", + "side": "client", + "setup": { + "host": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 7 + }, + "sessions": [], + "clients": [ + { + "name": "c1", + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "permissions": [ + "view" + ], + "client_kind": "native" + } + ] + }, + "steps": [ + { + "deliver_to_client": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "sessions", + "id": "11111111-1111-4111-8111-000000000100", + "runtime_gen": "77777777-7777-4777-8777-777777777777", + "runtime_epoch": 7, + "seq": 100, + "sessions": [ + { + "session_id": "ses-oldoldoldold", + "process_gen": "88888888-8888-4888-8888-888888888888", + "provider": "claude", + "cwd_label": "Atem", + "state": "running", + "activity": "ready", + "activity_since": 1760500000000, + "controller": null, + "capabilities": [ + "terminal-v1", + "chat-v1" + ], + "created_at": 1760490000000 + } + ] + } + } + }, + { + "deliver_to_client": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "sessions", + "id": "11111111-1111-4111-8111-000000000101", + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 8, + "seq": 1, + "sessions": [ + { + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222", + "provider": "claude", + "cwd_label": "Atem", + "state": "running", + "activity": "ready", + "activity_since": 1760500000000, + "controller": null, + "capabilities": [ + "terminal-v1", + "chat-v1" + ], + "created_at": 1760490000000 + } + ] + } + } + }, + { + "assert_client": { + "registry": [ + "ses-aaaaaaaaaaaa" + ], + "runtime_epoch": 8, + "seq": 1 + } + }, + { + "deliver_to_client": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "sessions", + "id": "11111111-1111-4111-8111-000000000102", + "runtime_gen": "77777777-7777-4777-8777-777777777777", + "runtime_epoch": 7, + "seq": 101, + "sessions": [ + { + "session_id": "ses-oldoldoldold", + "process_gen": "88888888-8888-4888-8888-888888888888", + "provider": "claude", + "cwd_label": "Atem", + "state": "running", + "activity": "ready", + "activity_since": 1760500000000, + "controller": null, + "capabilities": [ + "terminal-v1", + "chat-v1" + ], + "created_at": 1760490000000 + } + ] + } + } + }, + { + "assert_client": { + "registry": [ + "ses-aaaaaaaaaaaa" + ], + "runtime_epoch": 8 + } + }, + { + "deliver_to_client": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "session_changed", + "id": "11111111-1111-4111-8111-000000000103", + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 8, + "seq": 3, + "descriptor": { + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222", + "provider": "claude", + "cwd_label": "Atem", + "state": "exited", + "activity": "unknown", + "activity_since": 1760500000000, + "controller": null, + "capabilities": [ + "terminal-v1", + "chat-v1" + ], + "created_at": 1760490000000 + } + } + } + }, + { + "assert_client": { + "sent_list": true, + "registry_state": { + "ses-aaaaaaaaaaaa": "running" + } + } + }, + { + "deliver_to_client": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "session_changed", + "id": "11111111-1111-4111-8111-000000000104", + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 8, + "seq": 2, + "descriptor": { + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222", + "provider": "claude", + "cwd_label": "Atem", + "state": "removed", + "activity": "unknown", + "activity_since": 1760500000000, + "controller": null, + "capabilities": [ + "terminal-v1", + "chat-v1" + ], + "created_at": 1760490000000 + } + } + } + }, + { + "deliver_to_client": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "sessions", + "id": "11111111-1111-4111-8111-000000000105", + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 8, + "seq": 3, + "sessions": [] + } + } + }, + { + "assert_client": { + "registry": [], + "seq": 3 + } + }, + { + "deliver_to_client": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "session_changed", + "id": "11111111-1111-4111-8111-000000000106", + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 8, + "seq": 3, + "descriptor": { + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222", + "provider": "claude", + "cwd_label": "Atem", + "state": "exited", + "activity": "unknown", + "activity_since": 1760500000000, + "controller": null, + "capabilities": [ + "terminal-v1", + "chat-v1" + ], + "created_at": 1760490000000 + } + } + } + }, + { + "assert_client": { + "registry": [] + } + } + ], + "note": "Old-runtime snapshot after a newer epoch is discarded; a gap (seq 3 after 1) triggers list and the change is not applied; during resync changes with seq <= the snapshot's are discarded; a duplicate/late change for the removed session does not resurrect it." +} \ No newline at end of file diff --git a/designs/protocol/fixtures/schema.md b/designs/protocol/fixtures/schema.md new file mode 100644 index 0000000..64cc194 --- /dev/null +++ b/designs/protocol/fixtures/schema.md @@ -0,0 +1,58 @@ +# Fixture format (atem-session v1, r6) + +One scenario per `*.json` file; `digest-vectors.json` holds the canonical-digest +known-answer vectors (`vectors`: payload β†’ canonical string β†’ sha256; +`must_reject`: raw JSON (`payload_json`) or bytes (`payload_hex`) a parser must +refuse before canonicalising). + +Scenario: `{name, side?, setup, steps, note?}`. `side` is `host` (default: run +against a host implementation with fake clients) or `client` (run a client +implementation; the fixture plays the host with `deliver_to_client`). + +`setup`: + +- `host`: `{account_id, atem_device_id, host_trust_gen, runtime_gen, runtime_epoch}` β€” the host's own context. +- `sessions[]`: `{session_id, process_gen, chat?}`. +- `clients[]`: `{name, attachment_gen, permissions, client_kind, holds_lease_epoch?}` β€” the client's authenticated identity, permissions and connection mode, set by the harness (never derived from message fields). + +Host scenarios start with every client already welcomed: the harness connects +each client, sends `hello` itself and consumes (and checks) the `welcome` +before step 0; `reconnect` does the same on the new attachment. With +`holds_lease_epoch: n` the harness then sends a real `acquire` for the +scenario's only session and requires a `lease` at epoch `n`. None of these +setup messages is visible to the steps. + +`steps[]`, in order, on a deterministic monotonic clock starting at 0 ms: + +| step | meaning | +|---|---| +| `{"send": client, "json": msg}` | client sends `msg` to the host (send time = current clock) | +| `{"expect": client, "type"?: t, "match": {path: value}, "capture"?: {name: path}}` | takes that client's next message of envelope type `t` (when given) and op `match.op` (when given); messages of other types/ops stay queued for later `expect`s. The taken message must match every path (dotted paths into `data`, array indexes as numbers, `#` for an array's length: `receipts.#`; a path that doesn't exist never matches, not even `null`), else the step fails. With neither `type` nor `op` it takes the very next message. `capture` stores values for later steps, referenced in any string as `${name}` | +| `{"expect_all": {…}}` | `expect` with that `match` for every client | +| `{"expect_none": client, "for_ms": n}` | nothing of any type is queued for that client after the clock advances n | +| `{"advance_ms": n, "timers"?: bool}` | advance the clock; timers may fire. With `"timers": false` only the clock moves: the host gets no tick and sees the new time only with the next message, write check or event (expiry must still hold: `now >= deadline`) | +| `{"hold": {"to": client, "op": op}}` / `{"deliver": "held"}` | delay the next matching hostβ†’client message, deliver it later (reordering) | +| `{"deliver_to_client": client, "json": msg}` | client-side fixtures: the host's message arrives now | +| `{"disconnect": client}`, `{"resume": client, "attachment_gen": uuid}` | transport events | +| `{"local_input": session_id, "bytes": base64}` | local terminal input on the host | +| `{"crash_worker": session_id}`, `{"restart_runtime": {"runtime_gen": uuid, "runtime_epoch": n}}` | failures | +| `{"fail_next_fsync": true}`, `{"change_trust_gen": n}` | durability and trust events | +| `{"fail_next_pty_enqueue": true}` | the next dispatch fails before any byte reaches the PTY | +| `{"fail_next_pty_write_after_partial": true}` | the next dispatch's writer fails after possibly writing some bytes | +| `{"reconnect": client, "attachment_gen": uuid}` | a new attachment for the same client (no resume) | +| `{"close_session": session_id}` | the runtime closes the session (it no longer accepts work; the process may still run) | +| `{"turn_complete": session_id, "turn_id": t}` | the provider reports that the session's executing prompt's turn `t` ended | +| `{"hold_pty_writes": true}` / `{"flush_pty_writes": true}` | remote terminal writes queue at the PTY writer / the writer writes them now, each re-checked at the write boundary at the current clock (dropped ones count as `pty_writes_dropped`) | +| `{"assert": {…}}` | host counters: `dispatch_attempts` (every dispatch the host started, including ones refused before any effect), `dispatches` (dispatches that reached the PTY/agent: attempts minus those that failed with no effect), `pty_bytes_written` (a number or `"unknown"`), `pty_writes` (terminal input written, split as `pty_writes_local`/`pty_writes_remote`; dispatches are counted separately), `pty_writes_dropped` (remote chunks refused at the write boundary), `resizes`, `stops`, `lease_holder`, `receipts_visible: {client: n}` | +| `{"assert_client": {…}}` | client state: `controls`, `deadline_ms`, `registry` (ids), `registry_state`, `runtime_epoch`, `seq`, `sent_list`, `input_enabled` | + +A host scenario fails if, after its last step, any client still has a queued +(unexpected) message or a held message was never delivered: every message the +host sends must be expected. + +Negative scenarios assert the error and the absence of side effects +(writes, resizes, stops, dispatch, disclosure, resurrection). + +Wire messages in fixtures are complete: every documented required field is present (e.g. full session descriptors). Decoders must parse them with the real wire types. + +Expiry is `now >= deadline` everywhere. diff --git a/designs/protocol/fixtures/takeover-moves-control-and-the-old-holder-becomes-a-viewer.json b/designs/protocol/fixtures/takeover-moves-control-and-the-old-holder-becomes-a-viewer.json new file mode 100644 index 0000000..49bce79 --- /dev/null +++ b/designs/protocol/fixtures/takeover-moves-control-and-the-old-holder-becomes-a-viewer.json @@ -0,0 +1,199 @@ +{ + "name": "takeover-moves-control-and-the-old-holder-becomes-a-viewer", + "setup": { + "host": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 7 + }, + "sessions": [ + { + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + } + ], + "clients": [ + { + "name": "c1", + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "permissions": [ + "view", + "control" + ], + "client_kind": "native", + "holds_lease_epoch": 1 + }, + { + "name": "c2", + "attachment_gen": "66666666-6666-4666-8666-666666666666", + "permissions": [ + "view", + "control" + ], + "client_kind": "native" + }, + { + "name": "v", + "attachment_gen": "77777777-7777-4777-8777-777777777777", + "permissions": [ + "view" + ], + "client_kind": "native" + } + ] + }, + "steps": [ + { + "send": "c2", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "acquire", + "id": "11111111-1111-4111-8111-000000000001", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "66666666-6666-4666-8666-666666666666" + }, + "takeover": false + } + } + }, + { + "expect": "c2", + "type": "session", + "match": { + "op": "error", + "re": "11111111-1111-4111-8111-000000000001", + "code": "lease_held" + } + }, + { + "send": "c2", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "acquire", + "id": "11111111-1111-4111-8111-000000000002", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "66666666-6666-4666-8666-666666666666" + }, + "takeover": true + } + } + }, + { + "expect": "c2", + "type": "session", + "match": { + "op": "lease", + "re": "11111111-1111-4111-8111-000000000002", + "binding.lease_epoch": 2, + "ms_left": 15000 + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "lease_changed", + "reason": "takeover", + "lease_epoch": 2, + "controller.lease_epoch": 2 + } + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "renew", + "id": "11111111-1111-4111-8111-000000000003", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + } + } + } + }, + { + "expect": "c1", + "type": "session", + "match": { + "op": "error", + "re": "11111111-1111-4111-8111-000000000003", + "code": "stale_lease" + } + }, + { + "send": "v", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "acquire", + "id": "11111111-1111-4111-8111-000000000004", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "77777777-7777-4777-8777-777777777777" + }, + "takeover": true + } + } + }, + { + "expect": "v", + "type": "session", + "match": { + "op": "error", + "re": "11111111-1111-4111-8111-000000000004", + "code": "forbidden" + } + }, + { + "expect": "v", + "type": "session", + "match": { + "op": "lease_changed", + "reason": "takeover", + "lease_epoch": 2 + } + }, + { + "assert": { + "lease_holder": "c2" + } + } + ], + "note": "Without takeover a held lease is lease_held; takeover (needs control) moves it with a new epoch, the previous holder and other viewers get lease_changed{takeover}, the requester gets only its lease reply, and the old epoch is stale." +} \ No newline at end of file diff --git a/designs/session-multiplexing.md b/designs/session-multiplexing.md new file mode 100644 index 0000000..fb27bcb --- /dev/null +++ b/designs/session-multiplexing.md @@ -0,0 +1,457 @@ +# Atem Sessions, Multiplexing, and Device Trust + +Date: 2026-10-15 + +Status: product decisions and all six initial defaults approved by the user on +2026-10-15. The consolidated CLI workflow below is also approved. Architecture +recommendations and remaining engineering specifications are identified below. +This approval records the design; implementation has not started. + +## 1. Product decisions + +Enroll Atem once per machine, OS user, and account profile. Further launches and +reconnects reuse that approved identity through automatic authentication. +Account login, device verification, and key unlock are separate states; a locked +key agent or expired login must not automatically mean "pair again." + +`atem pair` is a no-op for an already paired profile: report its current +pairing/verification status without changing identity, trust bindings, keys, or +sessions, and without starting another pairing or verification exchange. +Changing or replacing a pairing requires explicit `atem unpair` first. +`atem pair verify` completes or retries verification of the existing binding +without re-pairing. Unpairing preserves account data and running agents while +invalidating the old host authorization as specified in the recovery lifecycle +below. + +Approved CLI workflow to implement: + +| Command | Purpose | +| --- | --- | +| `atem status` | Unified account, pairing, verification, credential availability, authorization freshness, connectivity, and local-agent status. | +| `atem pair` | Establish a pairing; report status without changes when already paired. | +| `atem pair verify` | Explicit safety-code verification of the existing binding with native approval. | +| `atem cred unlock` | Unlock protected keys using existing verified trust; native Touch ID or password approval may be required. | +| `atem cred lock` | Clear unlocked key material locally, including offline, without requiring verification or native approval. | +| `atem unpair` | Explicitly remove the binding and invalidate its authorization, preserving account data and running agents. | + +`atem status` is read-only, works offline, and marks cached remote information +as stale. Use the user-facing label `Credentials`, not `Keys`: + +```text +Account: signed in +Pairing: paired +Verification: verified +Credentials: locked +Authorization: fresh +Relay: connected +Local agents: 2 running +``` + +Credential availability is separate from pairing and verification. Locking clears +unlocked key material; it does not delete saved credential records. Keep +`atem cred status` as a compatibility alias to the unified status implementation; +do not add a separate `atem pair status` command. + +`atem pair verify` requires an existing pairing and is a no-op for a currently +valid verified binding. Cancellation or interruption preserves pairing and +allows an explicit safe retry. Revocation or changed identities require the +applicable recovery flow; verification cannot silently replace trusted keys. +Neither `atem cred unlock` nor `atem cred lock` starts pairing or safety-code +verification. Unlock on an unverified profile stops with an instruction to run +`atem pair verify`. + +Local `atem agent launch` remains usable without Astation pairing/verification. +Remote viewing/control requires verified, fresh authorization and its applicable +permissions/lease. Memory/vault access additionally requires its grants and, +for encrypted content, available unlocked keys. Verification does not itself +turn account encryption on; the signed migration lifecycle remains separate. + +The design supports: + +- Multiple independent Codex/Claude sessions, including several of one provider, + with their original processes, projects, conversations, and unfinished input. +- Multiple trusted native Astation clients viewing the same Atem session and + switching between sessions without re-pairing. Native iOS/Android clients can + participate alongside Macs. +- Any authorized, verified native Astation can access enrolled Atem hosts under + the same account's trust, subject to host/session permissions. Enroll each + native device and host once; do not pair every Astation-Atem combination or + each independent agent process. +- A web companion authorized by a trusted native Astation scanning its QR code. + Web is not a standalone Astation or a permanent trusted device. +- Account recovery followed by explicit local Atem unpairing, pairing, and fresh + verification when no trusted native device remains. +- Manual device removal in Settings. Recovery never automatically removes an + Astation ID. Account data and surviving encryption material remain intact. +- Keep Astation's authenticated direct WebSocket connection on port 8080, + alongside relay connections, including for Atem and Astation on the same host. + Multiplexing does not require relay-only connectivity. + +Approved initial control policy: multiple viewers, one active controller per +agent session. The accepted defaults cover enrollment authority, control transfer, +browser independence and lifetimes, retention, and offline authorization. + +`atem pair` currently establishes a relationship with an Astation through the +relay. The relay provides discovery and routing; it does not approve device +keys. Account login alone must not authorize an existing host or release its +keys. + +Direct and relay connections are transports for the same identity, session +registry, permissions, control leases, and request receipts. Keep their adapters +thin; do not introduce separate pairing, session, or control implementations for +each path. A direct connection does not grant trust or bypass authorization, +revocation, or confidentiality requirements. + +Same-host direct connections can remain usable during relay/internet outages +while locally validated authorization remains valid under the agreed freshness +policy. A connection switch authenticates the new transport and resynchronizes +the original session without re-pairing, reclaiming expired control, or replaying +input. Simultaneous direct/relay delivery uses the same submission deduplication. +Runtime-to-worker local IPC is separate from Astation's port-8080 listener. + +## 2. Identity and authorization + +| Identity or generation | Purpose | +| --- | --- | +| Account ID | Stable data ownership, independent of email, device removal, or relay routing. | +| Atem device/profile ID and keys | Persist across launches for one OS user/account profile. | +| Astation device ID and keys | Separate protected identity for each native installation. | +| Host trust generation | Changes durably on authorized unpair/rebind; invalidates old remote grants. | +| Agent session ID and process generation | Address one original session and distinguish a replacement process. | +| Runtime and transport generations | Distinguish broker restarts and socket replacements from device trust. | +| Controller session and lease epoch | Identify the attachment currently allowed to operate a session. | +| Browser grant and ephemeral key | Bind one limited companion authorization to one browser session. | + +Commands target the exact account, Atem device, host trust generation, agent +session, and process generation. Conversation and request IDs stay explicit. +Never route a delayed command to whichever agent happens to be selected. + +Only trusted native Astations explicitly authorized to manage devices may +approve new native controllers. Enrollment requires fresh Touch ID or password +approval. Browser companions and Atem sessions cannot enroll controllers. +Recovering an existing Atem host still requires local unpair, pair, and verify. + +Recommended enrollment mechanism: an authorized native device approves another +native device's actual keys and issues account-bound, scoped authorization. +Atem validates that authorization against retained trust and authenticates each +connection. This avoids pairing every controller with every agent session. +Each native device keeps its own private keys; devices do not clone one identity. +Safety codes are derived locally from the verification transcript and actual +endpoint keys. A relay-supplied code or approval cannot establish trust. + +The cryptographic trust anchor, membership/certificate format, and mechanism for +assigning management authority remain engineering specifications. View/control +permission does not imply enrollment authority. Existing pairwise pins cannot +silently become account-wide authority. A host must explicitly accept the +account's enrollment authority before account-wide native access applies; +matching relay account IDs alone is insufficient. Approved native clients should +reach Atem without the original approving Mac acting as a permanent forwarding +process. + +## 3. Runtime and process recovery + +Recommended architecture: one shared runtime per OS user/account profile owns +connection management, device/key-agent access, and the session registry. +Individual `atem` launches attach through owner-restricted local IPC. +Concurrent launches must elect one owner without displacing a live runtime. +Pairing, unlock, sync, and short-lived commands use that shared ownership. + +Each independent session worker owns its original agent process, PTY, output +collection, and submission journal. An existing Atem host can become a worker +without replacing its agent. A broker crash or frontend exit must not terminate +those workers or close their PTYs. OS users and account profiles remain isolated. + +| Failure | Recovery behavior | +| --- | --- | +| Connection loss | Authenticate a new connection and resynchronize the existing session. | +| Shared runtime crash | Surviving workers re-register through authenticated local IPC; preserve their original processes. | +| Worker/agent crash or machine reboot | Show Interrupted or Recovery Required. Offer an explicit provider-supported resume when available. | + +Worker recovery validates session identity, process generation, and process-start +metadata; a PID alone is insufficient. A registry file or saved history cannot +prove that an agent is alive. Provider resume creates a new process generation +and invalidates old leases/actions. Never replay a transcript into a new process +and present it as the original live session. + +Platform supervision and worker adoption mechanisms remain implementation choices. + +## 4. Viewing and control + +The Atem host enforces a renewable control lease per agent session, expiring +after 15 seconds without renewal or earlier when its authorization expires. +Multiple authorized clients can read output, history, and activity, but only the +current controller may submit prompts or terminal input. Interrupts, restarts, +and provider approvals also require their applicable permissions. Holding a +lease never automatically approves a provider permission request. + +Different clients may control different sessions on the same machine. A Mac +could operate Codex while a phone operates Claude, with both viewing both. + +Lease acquisition, transfer, expiry, and revocation serialize at the host. Every +mutation path, including chat, terminal, and future APIs, checks the same target, +controller identity, lease epoch, and permissions. Terminal resize follows the +shared terminal policy. Delayed input from an old owner or process is rejected. + +Take Control explicitly transfers ownership and leaves the previous controller +as a viewer. Local terminal input takes priority and transfers control locally, +invalidating the previous remote lease before accepting conflicting input. +Disconnects expire control within the 15-second renewal window while agents +continue running. Reconnect does not replay input or reclaim an expired lease +automatically. Lease transfer never restarts an agent or resubmits accepted work. + +Renewal scheduling and the UI presentation of ownership changes remain engineering +details within these approved rules. + +## 5. Command receipts and retained history + +A session worker durably records a high-level submission before acknowledging +acceptance. Its request ID binds the exact target and payload digest; duplicates +return the recorded outcome or are rejected. The same ID with different content +is rejected. Broker/socket delivery alone is not durable acceptance. + +Accepted, executing, completed, rejected, and outcome-unknown are distinct. +If dispatch occurs before a crash and the outcome cannot be recovered from the +original worker/provider, report uncertainty without resubmitting. Exactly-once +external effects cannot be guaranteed across arbitrary process crashes. +An admitted task may continue after controller disconnection or lease expiry; +future mutations and cancellation require current authorization. + +Raw terminal keystrokes are a live stream, not a durable task queue. Never replay +uncertain input after reconnect. Reattachment queries session state, history, +and request outcomes rather than starting another conversation. + +Approved retention defaults: + +| Data | Retention | +| --- | --- | +| Request IDs, payload digests, and command outcomes | Entire active session lifetime, then seven days after closure. | +| Atem-owned structured conversation history | Thirty days after closure, with configurable disk limits and export/delete controls. | +| Raw PTY output | Bounded scrollback with visible truncation. | + +Do not evict deduplication receipts while a session accepts submissions. Refuse +new admission explicitly if its receipt limit is reached. Closed/expired +sessions reject later submissions, including after old receipts are deleted. + +Protect persisted records with owner-only access and encrypt sensitive content +under the approved local key policy. Unavailable keys do not permit plaintext +fallback. Retention controls apply to Atem-owned records, not silent deletion of +provider transcripts. Keep structured user/agent/tool messages separate from +terminal repaint output. Memory, skills, vaults, and saved credentials retain +their separate preservation rules. Storage engine and disk budgets remain +engineering specifications; the retention durations above are approved. + +## 6. Web companion: native-scanned QR authorization + +1. The browser tab generates a fresh ephemeral key and a short-lived pending + request, then displays its QR code. +2. A trusted native Astation scans and validates the request. Scanning does not + automatically open arbitrary URLs or grant access. +3. Native Astation shows the request and lets the user select an authenticated + Atem host, exact agent session, permissions, and duration. Browser labels and + requested targets are untrusted hints. The user explicitly approves, using + local device authentication where required. +4. The native client authorizes the exact browser key, request/challenge, + account, host trust generation, agent/process, scope, and expiry. The browser + proves possession of its key through an authenticated bootstrap. The + receiving trusted endpoint validates the issuer and grant; relay approval + alone is insufficient. + +Approved default: view access. Operation additionally requires explicit +control permission and the current session lease. Grants do not enroll devices, +release vault keys, administer encryption, or expand to other agents. Selecting +another target requires new native authorization, not new Atem enrollment. +Remote control still permits actions within the controlled agent's authority; +a scoped grant is not a sandbox around arbitrary shell commands. + +QR requests expire two minutes after creation; approved browser grants expire +one hour after issuance, or earlier on revocation or loss of underlying +authorization. Requests and grants have separate lifetimes. Requests are consumed +atomically. Retries or repeated scans cannot mint extra grants. Changed keys/targets/accounts, +cancellation, expiry, or revocation invalidate pending approval, including late +local-authentication callbacks. Native Astation lists active companion grants +and offers revoke/disconnect. + +The reviewed bootstrap must authenticate both directions. Public request IDs +and keys alone do not authenticate the return approval. QR payloads must not +contain account credentials, long-term keys, recovery material, or usable access +grants. Any one-time out-of-band bootstrap secret belongs only in the reviewed +handshake and must stay out of relay traffic, URLs, logs, and stored history. + +Approved initial storage is tab memory only: reload/close requires a new QR +authorization. A short reconnect within the same tab may resume a still-valid +grant with fresh connection authentication, without replaying submissions. + +After approval, the browser connects through the relay using its scoped grant; +the approving native Astation need not remain online or forward traffic. Closing +that native app does not itself revoke the grant. Atem validates the grant and +its issuer, permissions, expiry, revocation state, and any current control lease. + +QR authorization limits browser authority; it does not prove that delivered +JavaScript is trustworthy. A compromised page could substitute another browser's +QR or misuse its grant. Passkeys do not fix that code-delivery boundary. + +## 7. Account recovery and data preservation + +When no trusted native device remains: + +1. Recover login to the same account and set up a new native Astation. Login + recovery does not approve that device on existing Atem hosts. +2. Use independent local OS access to each host to unpair the Atem profile. + Commit a new host trust generation and invalidate old controller sessions, + browser grants, leases, pending approvals, and input/output routes. +3. Pair the host with the new native Astation, compare safety codes, and complete + explicit approval and cryptographic verification. Remote viewing/control stay + disabled until verification is durably committed. +4. Reauthorize other native clients and companions under the new trust binding. + +This is once per host/profile, not per agent process. Interrupted recovery stays +unpaired/unverified across restart. Without independent host access, the recovered +account has no takeover path through relay administration or a browser QR. + +| Preserve | Invalidate | +| --- | --- | +| Memory, skills, vault entries, and their histories | Old host pairing/session authentication tokens | +| Saved provider/API/SSO service credentials, subject to provider expiry/revocation | Old controller memberships and leases on that host | +| Surviving account/storage keys, encrypted key blobs, and recovery metadata | Browser grants and pending remote approvals | +| Existing account ID, local agents, history, and command receipts | Old trust-bound routes and stale queued remote input | + +Unpair/re-pair must not create an empty data account, move records to a new +namespace, overwrite data, or change signed encryption state. Retain historical +signature metadata needed to validate old records without authorizing new actions. +Local agent work can continue independently of remote trust reset. + +Retained encrypted data requires its original available/unlocked keys or +cryptographic recovery material. Login recovery and fresh verification do not +reconstruct lost keys. Preserve ciphertext and report missing keys explicitly. + +## 8. Manual device management + +Recommended location: Settings > Security > Devices, linked by Manage Devices +from Accounts. Show device name, exact Astation ID, platform, current-device +badge, and last-seen/connection status. Presence is informational, not proof of +cryptographic trust. + +Provide Remove from Account for an individually selected device. Confirmation +names the device/ID and explains that service access is revoked while memory, +vault data, and saved credentials remain. Cancellation changes nothing. Require +authorized account-owner access and fresh authentication where policy requires; +no approval from a lost device is needed. + +Removal is optional and manual, during ordinary account management or after +recovery. Account recovery, login, re-pairing, and time offline never remove an +Astation ID automatically. Device removal is not a prerequisite for local host +trust repair. + +Revoke the selected registration, service routes, and associated companion +sessions. Keep a durable revocation record so deleting a registration cannot +reopen automatic first-use trust. Preserve the stable account and all data; +re-adding a device requires explicit authorization. + +This service-side operation does not replace local host unpairing or new-device +verification, including for direct connections outside relay control. + +## 9. Security boundaries + +The relay transports messages and enforces service access; endpoints authorize +device trust, control, encryption state, and key use. Relay-supplied identities, +labels, presence, counters, and acknowledgments cannot create that authority. + +Live terminal output, prompts, replies, and control messages require reviewed, +authenticated end-to-end encryption before claiming relay confidentiality. +Storage E2EE, HMAC message authentication, and relay-terminated HTTPS/WSS do not +provide that guarantee. Bind handshakes and messages to endpoint keys, account, +roles, permissions, fresh context, and exact target/generations; reject tampering, +replay, substitution, and downgrade. + +Approved offline authorization window: at most 24 hours after the last validated +trust/revocation update. During this window, direct access still requires valid +endpoint authentication, membership, permissions, and any applicable grant or +lease. Shorter authorization expiries and known revocations take effect sooner. +At the freshness deadline, pause further Astation viewing and control until a +valid refresh; keep agents and the local terminal running. A reconnect, relay +presence report, or replayed trust snapshot cannot extend the window. + +Signed membership needs bounded freshness and revocation rules. A valid old +signature does not prove no newer revocation exists. Persist version high-water +marks and freshness deadlines across restarts, and reject stale/unknown +authority for both viewing and mutation. The protocol must define an authenticated +freshness proof and conservative handling of clock changes; the relay cannot +renew authority on its own. Account recovery cannot +instantly change trust on an offline host or through a relay withholding updates; +local unpair is the definite host-side cutoff. Revocation also cannot erase +plaintext, credentials, or keys already copied to a removed device. Protecting +future data after key exposure requires a separate rotation/rekey policy. + +Multiple controllers do not automatically become enrollment or encryption-state +authorities. Initially preserve the existing approved encryption authority, +signed-state checks, and durable/idempotent migration completion lifecycle. +Authority transfer and encrypted-data recovery require separate specifications. +An accepted migration report must not depend on its sender staying connected. + +Native mobile clients protect their own keys using platform-secured storage and +hardware protection where supported. Local biometrics/device authentication +approve local key use; the enrollment protocol establishes remote endpoint trust. + +## 10. Ownership and remaining engineering specifications + +| Area | Owner | +| --- | --- | +| Shared runtime, session workers, receipts, and host control enforcement | Atem | +| Native trust approval, session/control UI, and manual device management | Astation | +| Distinct controller routing, fanout, and replica consistency | Relay | +| QR requests, scanning, and scoped companion authorization | Astation and webapp | +| Trust, revocation, encryption, and recovery wire contracts | Joint protocol design | + +All six product defaults are accepted. No product confirmation remains for the +choices recorded in this document. Before implementation, specify: + +- Cryptographic trust anchor, initial enrollment/bootstrap, management-authority + assignment, membership format, and durable host unpair/rebind protocol. +- Authenticated freshness proofs, clock/restart handling for the approved + 24-hour window, encrypted transport, and separate key recovery/rotation protocols. +- QR bootstrap and validation of scoped grants without an online native bridge, + using the approved two-minute request and one-hour grant limits. +- Platform supervision, worker recovery, journal encryption, and disk budgets + under the approved retention policy. +- Lease renewal and atomic local/remote arbitration under the approved + 15-second expiry, explicit takeover, and local-input priority rules. + +Define versioned contracts and cross-client fixtures before implementing either +side. Preserve existing identities and keys during migration; expanding old +pairwise trust requires explicit validation and approval. Unsupported clients +must fail clearly rather than downgrade security. + +## 11. Acceptance criteria + +These are requirements for future implementation, not completed test results. + +| Scenario | Required result | +| --- | --- | +| Concurrent launches and pair/unlock/sync with existing agents | One profile identity and runtime owner; preserve processes, drafts, and separately addressed sessions. | +| Repeat `atem pair` on an already paired profile, including incomplete verification | Report current status without starting pairing/verification or changing identity, keys, trust, or sessions; require explicit unpair before replacing the binding. | +| Explicit `atem pair verify`, cancellation, retry, and already-valid verification | Verify only the existing binding; preserve pairing on interruption; do nothing when already validly verified; never silently replace trusted keys. | +| `atem status`, its `atem cred status` alias, and offline execution | Use one read-only implementation with a `Credentials` label, distinct verification/lock state, and visibly stale cached remote information. | +| Credential unlock/lock on an unverified profile | Unlock instructs the user to run `atem pair verify`; lock can clear local keys offline; neither command initiates verification. | +| Same-host direct access during a relay outage, connection switching, and simultaneous direct/relay delivery | Honor valid local authorization; preserve the same sessions, leases, and receipts; authenticate replacement connections and reject duplicate submissions or stale input. | +| Runtime crash, worker failure, PID reuse, and agent restart | Reattach only to authenticated surviving workers; show interrupted sessions honestly; reject old process generations. | +| Several viewers and competing remote/local controllers | One lease holder per session; independent sessions remain independently controllable. | +| Explicit takeover, local terminal input, and missed renewals | Previous controller becomes a viewer; local input wins atomically; remote control expires after 15 seconds without renewal while agents continue. | +| Delayed input after lease transfer, revocation, or account change | Reject stale epochs and cross-account/target commands. | +| Immediate disconnect, delayed dispatch, duplicates, and crash after dispatch | Preserve accepted work and deduplicate; show unresolved outcomes without automatic replay. | +| Receipt/history limits and closed-session expiry | Preserve active deduplication; reject late submissions to closed sessions; identify truncation. | +| Closed-session retention deadlines | Retain receipts for seven days and conversation history for thirty days after closure; preserve memory, skills, vaults, and saved credentials. | +| QR approval, repeated/expired scans, target/key substitution, and late callbacks | One scoped grant for the captured request; no stale approval or secret disclosure. | +| Two-minute QR expiry, one-hour browser expiry, and approving native disconnect | Reject expired requests/grants; an approved browser remains usable within its valid authority when the native app closes. | +| Companion reload, expiry, revocation, or view-only control attempt | Require valid native authorization and control permission; never gain permanent trust or replay input. | +| Enrollment by a viewer, browser, Atem session, or native without fresh authentication | Reject enrollment; require an explicitly authorized native management device and fresh Touch ID or password approval. | +| Account recovery and interrupted host re-enrollment | No automatic host access or device removal; restart stays unverified until new approval commits. | +| Cancel/confirm manual removal of one Astation ID | Cancellation changes nothing; confirmation revokes only that device's service authority, preserving data and blocking silent re-registration. | +| Unpair/re-pair with existing memory, vaults, credentials, and missing keys | Preserve records, identities, and surviving keys; invalidate old access; report unavailable keys without resetting encryption. | +| Malicious relay alters traffic or withholds newer revocation state | Cryptographic validation and the defined freshness policy prevent forged or stale authority. | +| Offline freshness deadline, restart, clock change, or replayed trust update | Enforce the 24-hour maximum without extending stale authority; pause Astation viewing/control on expiry while agents and local terminal continue. | + +## References + +- [Atem device verification](https://github.com/Agora-Build/Astation/blob/main/docs/atem-device-verification.md) +- [Encryption migration lifecycle](https://github.com/Agora-Build/Astation/blob/main/designs/e2e-encryption.md) +- [Unified agora.build identity](https://github.com/Agora-Build/Astation/blob/main/docs/specs/2026-10-10-unified-agora-build-identity.md) diff --git a/src/main.rs b/src/main.rs index b5ed409..75b3533 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,6 +26,7 @@ mod vault_client; mod memory; mod pair_verify; mod chat; +mod session_protocol; mod websocket_client; // Agent hub β€” PTY agents launched by `atem agent launch` mod agent_client; diff --git a/src/session_protocol/canonical.rs b/src/session_protocol/canonical.rs new file mode 100644 index 0000000..05b23e9 --- /dev/null +++ b/src/session_protocol/canonical.rs @@ -0,0 +1,427 @@ +//! Strict JSON parsing and RFC 8785 (JCS) canonicalisation for atem-session v1. +//! +//! serde_json alone accepts duplicate keys (last wins), lone surrogate escapes +//! (replaced) and any number, so wire input goes through `parse_strict`, a small +//! recursive-descent parser that builds a `serde_json::Value` while refusing: +//! +//! - input longer than the caller's bound (checked before anything is built); +//! - invalid UTF-8 (`std::str::from_utf8` over the whole input first); +//! - duplicate object keys at any depth, compared after unescaping; +//! - `\u` escapes that leave a lone high or low surrogate; +//! - integers outside Β±(2^53βˆ’1), and β€” with `Numbers::IntegersOnly` β€” any +//! number with a fraction or an exponent (wire messages use +//! `Numbers::AllowFractions` so unknown fields stay ignorable; every field +//! that needs an integer is a `u64` in the typed layer, which refuses them); +//! - nesting deeper than `MAX_DEPTH` (no stack exhaustion). +//! +//! `jcs` covers the v1 digest domain: objects (keys sorted by UTF-16 code +//! units), arrays, strings (ECMAScript `JSON.stringify` escaping), booleans, +//! null and safe integers. Floats are outside the domain and refused. + +use serde_json::{Map, Number, Value}; +use sha2::{Digest, Sha256}; + +pub const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991; +/// Bound for a standalone payload parse (the message bound, Β§1). +pub const MAX_INPUT_BYTES: usize = 1024 * 1024; +pub const MAX_DEPTH: usize = 128; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Numbers { + /// Digest domain: every number must be a safe integer literal. + IntegersOnly, + /// Wire messages: fractions/exponents parse as floats (typed integer fields + /// still refuse them); integer literals must be safe. + AllowFractions, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum StrictError { + TooLarge, + InvalidUtf8, + /// Malformed JSON at this byte offset. + Syntax(usize), + DuplicateKey, + LoneSurrogate, + NonInteger, + UnsafeInteger, + TooDeep, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CanonicalError { + NonInteger, + UnsafeInteger, +} + +/// Parses `bytes` as one strict JSON value (see the module docs). +pub fn parse_strict( + bytes: &[u8], + max_bytes: usize, + numbers: Numbers, +) -> Result { + if bytes.len() > max_bytes { + return Err(StrictError::TooLarge); + } + let text = std::str::from_utf8(bytes).map_err(|_| StrictError::InvalidUtf8)?; + let mut p = Parser { + s: text.as_bytes(), + text, + pos: 0, + numbers, + }; + p.ws(); + let v = p.value(0)?; + p.ws(); + if p.pos != p.s.len() { + return Err(StrictError::Syntax(p.pos)); + } + Ok(v) +} + +/// Strict parse for the digest domain (integers only). +pub fn parse_payload(bytes: &[u8]) -> Result { + parse_strict(bytes, MAX_INPUT_BYTES, Numbers::IntegersOnly) +} + +struct Parser<'a> { + s: &'a [u8], + text: &'a str, + pos: usize, + numbers: Numbers, +} + +impl Parser<'_> { + fn err(&self) -> StrictError { + StrictError::Syntax(self.pos) + } + + fn ws(&mut self) { + while let Some(b' ' | b'\t' | b'\n' | b'\r') = self.s.get(self.pos) { + self.pos += 1; + } + } + + fn eat(&mut self, b: u8) -> Result<(), StrictError> { + if self.s.get(self.pos) == Some(&b) { + self.pos += 1; + Ok(()) + } else { + Err(self.err()) + } + } + + fn value(&mut self, depth: usize) -> Result { + match self.s.get(self.pos) { + Some(b'{') => self.object(depth + 1), + Some(b'[') => self.array(depth + 1), + Some(b'"') => self.string().map(Value::String), + Some(b'-' | b'0'..=b'9') => self.number(), + Some(b't') => self.literal("true", Value::Bool(true)), + Some(b'f') => self.literal("false", Value::Bool(false)), + Some(b'n') => self.literal("null", Value::Null), + _ => Err(self.err()), + } + } + + fn literal(&mut self, word: &str, v: Value) -> Result { + if self.s[self.pos..].starts_with(word.as_bytes()) { + self.pos += word.len(); + Ok(v) + } else { + Err(self.err()) + } + } + + fn object(&mut self, depth: usize) -> Result { + if depth > MAX_DEPTH { + return Err(StrictError::TooDeep); + } + self.eat(b'{')?; + let mut map = Map::new(); + self.ws(); + if self.s.get(self.pos) == Some(&b'}') { + self.pos += 1; + return Ok(Value::Object(map)); + } + loop { + self.ws(); + if self.s.get(self.pos) != Some(&b'"') { + return Err(self.err()); + } + let key = self.string()?; + self.ws(); + self.eat(b':')?; + self.ws(); + let v = self.value(depth)?; + if map.insert(key, v).is_some() { + return Err(StrictError::DuplicateKey); + } + self.ws(); + match self.s.get(self.pos) { + Some(b',') => self.pos += 1, + Some(b'}') => { + self.pos += 1; + return Ok(Value::Object(map)); + } + _ => return Err(self.err()), + } + } + } + + fn array(&mut self, depth: usize) -> Result { + if depth > MAX_DEPTH { + return Err(StrictError::TooDeep); + } + self.eat(b'[')?; + let mut items = Vec::new(); + self.ws(); + if self.s.get(self.pos) == Some(&b']') { + self.pos += 1; + return Ok(Value::Array(items)); + } + loop { + self.ws(); + items.push(self.value(depth)?); + self.ws(); + match self.s.get(self.pos) { + Some(b',') => self.pos += 1, + Some(b']') => { + self.pos += 1; + return Ok(Value::Array(items)); + } + _ => return Err(self.err()), + } + } + } + + fn hex4(&mut self) -> Result { + let digits = self + .s + .get(self.pos..self.pos + 4) + .ok_or_else(|| self.err())?; + let mut n = 0u16; + for &d in digits { + let v = (d as char).to_digit(16).ok_or_else(|| self.err())?; + n = n * 16 + v as u16; + } + self.pos += 4; + Ok(n) + } + + fn string(&mut self) -> Result { + self.eat(b'"')?; + let mut out = String::new(); + loop { + // Copy the longest run without quotes, escapes or control bytes. + let start = self.pos; + while let Some(&b) = self.s.get(self.pos) { + if b == b'"' || b == b'\\' || b < 0x20 { + break; + } + self.pos += 1; + } + // Input is valid UTF-8 and the run stops at ASCII bytes, so this + // slice is on character boundaries. + out.push_str(&self.text[start..self.pos]); + match self.s.get(self.pos) { + Some(b'"') => { + self.pos += 1; + return Ok(out); + } + Some(b'\\') => { + self.pos += 1; + let e = *self.s.get(self.pos).ok_or_else(|| self.err())?; + self.pos += 1; + match e { + b'"' => out.push('"'), + b'\\' => out.push('\\'), + b'/' => out.push('/'), + b'b' => out.push('\u{8}'), + b'f' => out.push('\u{c}'), + b'n' => out.push('\n'), + b'r' => out.push('\r'), + b't' => out.push('\t'), + b'u' => { + let hi = self.hex4()?; + let c = match hi { + 0xD800..=0xDBFF => { + if !self.s[self.pos..].starts_with(b"\\u") { + return Err(StrictError::LoneSurrogate); + } + self.pos += 2; + let lo = self.hex4()?; + if !(0xDC00..=0xDFFF).contains(&lo) { + return Err(StrictError::LoneSurrogate); + } + 0x10000 + + (((hi as u32) - 0xD800) << 10) + + ((lo as u32) - 0xDC00) + } + 0xDC00..=0xDFFF => return Err(StrictError::LoneSurrogate), + _ => hi as u32, + }; + out.push(char::from_u32(c).ok_or(StrictError::LoneSurrogate)?); + } + _ => return Err(StrictError::Syntax(self.pos - 1)), + } + } + // Raw control character or end of input. + _ => return Err(self.err()), + } + } + } + + fn digits(&mut self) -> usize { + let start = self.pos; + while let Some(b'0'..=b'9') = self.s.get(self.pos) { + self.pos += 1; + } + self.pos - start + } + + fn number(&mut self) -> Result { + let start = self.pos; + let negative = self.s[self.pos] == b'-'; + if negative { + self.pos += 1; + } + let int_start = self.pos; + match self.s.get(self.pos) { + Some(b'0') => self.pos += 1, + Some(b'1'..=b'9') => { + self.digits(); + } + _ => return Err(self.err()), + } + let int_end = self.pos; + let mut fractional = false; + if self.s.get(self.pos) == Some(&b'.') { + self.pos += 1; + if self.digits() == 0 { + return Err(self.err()); + } + fractional = true; + } + if let Some(b'e' | b'E') = self.s.get(self.pos) { + self.pos += 1; + if let Some(b'+' | b'-') = self.s.get(self.pos) { + self.pos += 1; + } + if self.digits() == 0 { + return Err(self.err()); + } + fractional = true; + } + if fractional { + if self.numbers == Numbers::IntegersOnly { + return Err(StrictError::NonInteger); + } + let f: f64 = self.text[start..self.pos].parse().map_err(|_| self.err())?; + return Number::from_f64(f) + .map(Value::Number) + .ok_or(StrictError::NonInteger); + } + let digits = &self.text[int_start..int_end]; + // MAX_SAFE_INTEGER has 16 digits; longer can't be safe (and can't overflow u64 below). + if digits.len() > 16 { + return Err(StrictError::UnsafeInteger); + } + let n: u64 = digits.parse().map_err(|_| self.err())?; + if n > MAX_SAFE_INTEGER { + return Err(StrictError::UnsafeInteger); + } + // `-0` becomes 0: counters and other integers have no negative zero. + Ok(if negative { + Value::from(-(n as i64)) + } else { + Value::from(n) + }) + } +} + +/// RFC 8785 canonical form of `v` over the v1 digest domain. +pub fn jcs(v: &Value) -> Result { + let mut out = String::new(); + write_jcs(v, &mut out)?; + Ok(out) +} + +fn write_jcs(v: &Value, out: &mut String) -> Result<(), CanonicalError> { + match v { + Value::Null => out.push_str("null"), + Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }), + Value::Number(n) => { + if let Some(u) = n.as_u64() { + if u > MAX_SAFE_INTEGER { + return Err(CanonicalError::UnsafeInteger); + } + out.push_str(&u.to_string()); + } else if let Some(i) = n.as_i64() { + if i.unsigned_abs() > MAX_SAFE_INTEGER { + return Err(CanonicalError::UnsafeInteger); + } + out.push_str(&i.to_string()); + } else { + return Err(CanonicalError::NonInteger); + } + } + Value::String(s) => write_string(s, out), + Value::Array(items) => { + out.push('['); + for (i, item) in items.iter().enumerate() { + if i > 0 { + out.push(','); + } + write_jcs(item, out)?; + } + out.push(']'); + } + Value::Object(map) => { + let mut keys: Vec<&String> = map.keys().collect(); + keys.sort_by(|a, b| a.encode_utf16().cmp(b.encode_utf16())); + out.push('{'); + for (i, k) in keys.into_iter().enumerate() { + if i > 0 { + out.push(','); + } + write_string(k, out); + out.push(':'); + write_jcs(&map[k], out)?; + } + out.push('}'); + } + } + Ok(()) +} + +/// ECMAScript `JSON.stringify` string escaping (RFC 8785 Β§3.2.2.2). +fn write_string(s: &str, out: &mut String) { + out.push('"'); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\u{8}' => out.push_str("\\b"), + '\u{c}' => out.push_str("\\f"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), + c => out.push(c), + } + } + out.push('"'); +} + +/// `sha256(JCS(payload))`, lowercase hex (Β§6). +pub fn payload_digest(v: &Value) -> Result { + Ok(sha256_hex(jcs(v)?.as_bytes())) +} + +pub fn sha256_hex(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|b| format!("{b:02x}")) + .collect() +} diff --git a/src/session_protocol/client.rs b/src/session_protocol/client.rs new file mode 100644 index 0000000..6f295d6 --- /dev/null +++ b/src/session_protocol/client.rs @@ -0,0 +1,1152 @@ +//! Client-side state machines for atem-session v1: the control-lease client +//! (Β§5 reply-correlation rules 1–5) and the session registry client (Β§4). +//! +//! Both are pure: the caller passes a monotonic `now_ms` with every event and +//! performs any I/O they ask for (`RegistryClient::take_list_request`). +//! Neither relies on a timer having fired: every query and every reply checks +//! `now >= deadline` itself. + +use super::wire::{Binding, Descriptor, Op, SessionMessage, SessionState, Target}; +use std::collections::{BTreeMap, HashMap}; + +// ── Control lease (Β§5) ──────────────────────────────────────────────────── + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LeaseRequest { + Acquire, + Renew, + Release, +} + +#[derive(Debug, Clone, Copy)] +struct Pending { + kind: LeaseRequest, + sent_at: u64, +} + +/// Why a request was not recorded (the caller must not send it). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RequestRefused { + /// `renew`/`release` without control (never renew after the local estimate + /// of expiry; nothing to release). + NotControlling, + /// The id is already outstanding. + DuplicateId, + /// More than `MAX_IN_FLIGHT` requests outstanding. + TooManyInFlight, +} + +/// What happened to a host reply. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReplyOutcome { + /// The reply changed the lease state (granted, renewed, released, or an + /// error that made this client a viewer). + Applied, + /// The reply was applied but the resulting deadline had already been + /// reached, so control and input are off (rule 5). + AppliedExpired, + /// Rule 1: `re` is absent, unknown, already consumed or a duplicate. + UnknownRe, + /// Rule 2: the previously acknowledged deadline had passed; this client is + /// now a viewer and only a new `acquire` regains control. + DeadlinePassed, + /// The reply doesn't fit the request (other target/attachment, wrong op, + /// or a renew answered for another epoch β€” which also ends control). + Mismatch, + /// The granted epoch is older than one already seen in `lease_changed`. + StaleEpoch, + /// Not a lease reply (`lease`, `lease_released` or `error`). + NotALeaseReply, +} + +/// One client's view of its control lease on one session. +#[derive(Debug, Clone)] +pub struct LeaseClient { + target: Target, + attachment_gen: String, + outstanding: HashMap, + /// Acknowledged local deadline (monotonic ms); `Some` while controlling. + deadline: Option, + lease_epoch: Option, + /// Highest `lease_epoch` seen for this target (grants and + /// `lease_changed`), kept while a viewer; older grants are dropped. + highest_epoch_seen: Option, + authorization_deadline: Option, +} + +// ── Session registry (Β§4) ───────────────────────────────────────────────── + +/// What happened to a `sessions` / `session_changed` message. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RegistryOutcome { + Applied, + /// From a lower `runtime_epoch` than the highest accepted one. + StaleEpoch, + /// Same epoch as the accepted one with another `runtime_gen`. + RuntimeGenMismatch, + /// A snapshot older than the last applied state of this epoch. + StaleSnapshot, + /// `seq` ≀ the last applied one. + Duplicate, + /// Held until the awaited snapshot arrives (discarded then if its `seq` is + /// ≀ the snapshot's). + Buffered, + /// A change that would revive a removed session's `process_gen`. + Resurrection, + /// Not a registry message. + NotARegistryMessage, +} + +/// Bound on changes held while awaiting a snapshot; beyond it they're dropped +/// (the snapshot carries the full state anyway). +pub const MAX_BUFFERED_CHANGES: usize = 256; + +#[derive(Debug, Clone, Default)] +pub struct RegistryClient { + /// Highest accepted `(runtime_epoch, runtime_gen)`. + epoch: Option<(u64, String)>, + /// Last applied `seq` in the accepted epoch; `None` until its snapshot. + last_seq: Option, + /// A `list` was sent (or must be) and its snapshot hasn't arrived. + awaiting_snapshot: bool, + list_wanted: bool, + lists_requested: u64, + sessions: BTreeMap, + /// session_id β†’ (seq of the removal, removed process_gen), per epoch. + removed: HashMap, + buffered: BTreeMap, +} + +impl LeaseClient { + pub fn new(target: Target, attachment_gen: String) -> Self { + Self { + target, + attachment_gen, + outstanding: HashMap::new(), + deadline: None, + lease_epoch: None, + highest_epoch_seen: None, + authorization_deadline: None, + } + } + + /// The attachment's authorization deadline on the monotonic clock (from + /// `welcome.authorization_ms_left`); every lease deadline is capped by it. + pub fn set_authorization_deadline(&mut self, deadline: Option) { + self.authorization_deadline = deadline; + if let (Some(d), Some(a)) = (self.deadline, deadline) { + self.deadline = Some(d.min(a)); + } + } + + /// Records an outgoing `acquire`/`renew`/`release` with its monotonic send + /// time. Call before sending; an `Err` means don't send it. + pub fn sent( + &mut self, + id: &str, + kind: LeaseRequest, + now_ms: u64, + ) -> Result<(), RequestRefused> { + self.tick(now_ms); + if self.outstanding.contains_key(id) { + return Err(RequestRefused::DuplicateId); + } + if self.outstanding.len() >= super::wire::MAX_IN_FLIGHT { + return Err(RequestRefused::TooManyInFlight); + } + match kind { + LeaseRequest::Acquire => {} + LeaseRequest::Renew | LeaseRequest::Release if self.deadline.is_none() => { + return Err(RequestRefused::NotControlling); + } + LeaseRequest::Renew => {} + // Input stops as soon as a release is sent, and no earlier + // acquire/renew reply may grant control again. + LeaseRequest::Release => self.become_viewer(), + } + self.outstanding.insert( + id.to_string(), + Pending { + kind, + sent_at: now_ms, + }, + ); + Ok(()) + } + + /// Applies a host reply (`lease`, `lease_released` or `error`) per Β§5 + /// rules 1–5. + pub fn on_reply(&mut self, msg: &SessionMessage, now_ms: u64) -> ReplyOutcome { + if !matches!( + msg.op, + Op::Lease { .. } | Op::LeaseReleased {} | Op::Error { .. } + ) { + return ReplyOutcome::NotALeaseReply; + } + // Rule 1: one reply per outstanding request. + let Some(pending) = msg.re.as_ref().and_then(|re| self.outstanding.remove(re)) else { + return ReplyOutcome::UnknownRe; + }; + // Rule 2: the acknowledged deadline is checked before anything is applied. + if self.deadline.is_some_and(|d| now_ms >= d) { + self.become_viewer(); + return ReplyOutcome::DeadlinePassed; + } + match &msg.op { + Op::Lease { binding, ms_left } => self.apply_lease(pending, binding, *ms_left, now_ms), + Op::LeaseReleased {} if pending.kind == LeaseRequest::Release => { + self.become_viewer(); + ReplyOutcome::Applied + } + Op::LeaseReleased {} => ReplyOutcome::Mismatch, + // Any error to a lease request leaves this client without a lease + // (fail closed: the host's state is unknown). + _ => { + self.become_viewer(); + ReplyOutcome::Applied + } + } + } + + fn apply_lease( + &mut self, + pending: Pending, + binding: &Binding, + ms_left: u64, + now_ms: u64, + ) -> ReplyOutcome { + let Some(epoch) = binding.lease_epoch else { + return ReplyOutcome::Mismatch; + }; + if binding.target != self.target || binding.attachment_gen != self.attachment_gen { + return ReplyOutcome::Mismatch; + } + if self.highest_epoch_seen.is_some_and(|h| epoch < h) { + return ReplyOutcome::StaleEpoch; + } + // Rule 3: send time + ms_left, capped by authorization. + let mut candidate = pending.sent_at.saturating_add(ms_left); + if let Some(a) = self.authorization_deadline { + candidate = candidate.min(a); + } + let new = match (pending.kind, self.deadline) { + (LeaseRequest::Release, _) => return ReplyOutcome::Mismatch, + (LeaseRequest::Renew, None) => return ReplyOutcome::Mismatch, + (LeaseRequest::Renew, Some(_)) if self.lease_epoch != Some(epoch) => { + // Fail closed: the host's lease isn't the one this client holds. + self.become_viewer(); + return ReplyOutcome::Mismatch; + } + (LeaseRequest::Renew, Some(_)) => candidate, + // Rule 4: a repeat acquire never extends. + (LeaseRequest::Acquire, Some(prev)) => prev.min(candidate), + (LeaseRequest::Acquire, None) => candidate, + }; + // Rule 5: never hold control with zero remaining lifetime. + if now_ms >= new { + self.become_viewer(); + return ReplyOutcome::AppliedExpired; + } + self.deadline = Some(new); + self.lease_epoch = Some(epoch); + self.highest_epoch_seen = Some(self.highest_epoch_seen.map_or(epoch, |h| h.max(epoch))); + ReplyOutcome::Applied + } + + /// `lease_changed` for this target: a newer epoch than ours means the + /// lease moved, expired, was released or revoked. + pub fn on_lease_changed(&mut self, session_id: &str, process_gen: &str, lease_epoch: u64) { + if session_id != self.target.session_id || process_gen != self.target.process_gen { + return; + } + self.highest_epoch_seen = Some( + self.highest_epoch_seen + .map_or(lease_epoch, |h| h.max(lease_epoch)), + ); + if self.lease_epoch.is_some_and(|e| lease_epoch > e) { + self.become_viewer(); + } + } + + /// Notices expiry (`now >= deadline`); replies to requests sent before it + /// are then dropped. + pub fn tick(&mut self, now_ms: u64) { + if self.deadline.is_some_and(|d| now_ms >= d) { + self.become_viewer(); + } + } + + fn become_viewer(&mut self) { + self.deadline = None; + self.lease_epoch = None; + self.outstanding.clear(); + } + + pub fn controls(&self, now_ms: u64) -> bool { + self.deadline.is_some_and(|d| now_ms < d) + } + + pub fn input_enabled(&self, now_ms: u64) -> bool { + self.controls(now_ms) + } + + /// The acknowledged local deadline (monotonic ms); `None` as a viewer or + /// once `now >= deadline`. + pub fn deadline_ms(&self, now_ms: u64) -> Option { + self.deadline.filter(|d| now_ms < *d) + } + + pub fn lease_epoch(&self) -> Option { + self.lease_epoch + } + + pub fn target(&self) -> &Target { + &self.target + } + + pub fn is_outstanding(&self, id: &str) -> bool { + self.outstanding.contains_key(id) + } +} + +enum EpochCheck { + Same, + New, + Reject(RegistryOutcome), +} + +impl RegistryClient { + /// The caller sent `list` (it subscribes the attachment): changes are held + /// until the snapshot arrives. + pub fn note_list_sent(&mut self) { + self.awaiting_snapshot = true; + } + + /// `true` once per needed `list` (gap, or a new epoch seen in a change); + /// the caller sends it and calls `note_list_sent`. + pub fn take_list_request(&mut self) -> bool { + std::mem::take(&mut self.list_wanted) + } + + pub fn lists_requested(&self) -> u64 { + self.lists_requested + } + + fn want_list(&mut self) { + if !self.list_wanted { + self.list_wanted = true; + self.lists_requested += 1; + } + self.awaiting_snapshot = true; + } + + fn check_epoch(&self, epoch: u64, rgen: &str) -> EpochCheck { + match &self.epoch { + None => EpochCheck::New, + Some((e, _)) if epoch < *e => EpochCheck::Reject(RegistryOutcome::StaleEpoch), + Some((e, g)) if epoch == *e && g != rgen => { + EpochCheck::Reject(RegistryOutcome::RuntimeGenMismatch) + } + Some((e, _)) if epoch == *e => EpochCheck::Same, + Some(_) => EpochCheck::New, + } + } + + /// A new attachment was welcomed (reconnect): `seq` counts per + /// attachment, so its registry starts over β€” the next snapshot is + /// awaited and applies at any `seq`. The highest accepted + /// `(runtime_epoch, runtime_gen)` stays as the floor. + pub fn new_attachment(&mut self) { + self.last_seq = None; + self.sessions.clear(); + self.removed.clear(); + self.buffered.clear(); + self.list_wanted = false; + self.awaiting_snapshot = false; + } + + fn reset(&mut self, epoch: u64, rgen: &str) { + self.epoch = Some((epoch, rgen.to_string())); + self.last_seq = None; + self.sessions.clear(); + self.removed.clear(); + self.buffered.clear(); + self.list_wanted = false; + } + + pub fn on_message(&mut self, op: &Op) -> RegistryOutcome { + match op { + Op::Sessions { + runtime_gen, + runtime_epoch, + seq, + sessions, + } => self.on_snapshot(runtime_gen, *runtime_epoch, *seq, sessions), + Op::SessionChanged { + runtime_gen, + runtime_epoch, + seq, + descriptor, + } => self.on_change(runtime_gen, *runtime_epoch, *seq, descriptor), + _ => RegistryOutcome::NotARegistryMessage, + } + } + + fn on_snapshot( + &mut self, + rgen: &str, + epoch: u64, + seq: u64, + sessions: &[Descriptor], + ) -> RegistryOutcome { + match self.check_epoch(epoch, rgen) { + EpochCheck::Reject(r) => return r, + EpochCheck::New => self.reset(epoch, rgen), + EpochCheck::Same => { + if self.last_seq.is_some_and(|l| seq < l) { + return RegistryOutcome::StaleSnapshot; + } + } + } + self.sessions.clear(); + for d in sessions { + if d.state == SessionState::Removed { + self.removed + .insert(d.session_id.clone(), (seq, d.process_gen.clone())); + } else if !self.is_removed_process(&d.session_id, &d.process_gen) { + self.sessions.insert(d.session_id.clone(), d.clone()); + } + } + self.last_seq = Some(seq); + self.awaiting_snapshot = false; + self.list_wanted = false; + // Held changes: those at or below the snapshot are discarded, the rest + // apply in order; one still ahead is a new gap. + let held = std::mem::take(&mut self.buffered); + for (s, d) in held.into_iter().filter(|(s, _)| *s > seq) { + if !self.awaiting_snapshot && self.last_seq == Some(s - 1) { + self.apply_change(s, &d); + } else { + if !self.awaiting_snapshot { + self.want_list(); + } + self.buffered.insert(s, d); + } + } + RegistryOutcome::Applied + } + + fn on_change(&mut self, rgen: &str, epoch: u64, seq: u64, d: &Descriptor) -> RegistryOutcome { + match self.check_epoch(epoch, rgen) { + EpochCheck::Reject(r) => return r, + EpochCheck::New => { + // Accept the epoch, apply nothing from it until its snapshot. + self.reset(epoch, rgen); + self.want_list(); + self.buffer(seq, d); + return RegistryOutcome::Buffered; + } + EpochCheck::Same => {} + } + if self.last_seq.is_some_and(|l| seq <= l) { + return RegistryOutcome::Duplicate; + } + if self.awaiting_snapshot || self.last_seq.is_none() { + self.buffer(seq, d); + return RegistryOutcome::Buffered; + } + let last = self.last_seq.unwrap_or(0); + if seq == last + 1 { + self.apply_change(seq, d) + } else { + self.want_list(); + self.buffer(seq, d); + RegistryOutcome::Buffered + } + } + + fn buffer(&mut self, seq: u64, d: &Descriptor) { + if self.buffered.len() < MAX_BUFFERED_CHANGES { + self.buffered.insert(seq, d.clone()); + } + } + + fn is_removed_process(&self, session_id: &str, process_gen: &str) -> bool { + self.removed + .get(session_id) + .is_some_and(|(_, pg)| pg == process_gen) + } + + fn apply_change(&mut self, seq: u64, d: &Descriptor) -> RegistryOutcome { + self.last_seq = Some(seq); + if d.state == SessionState::Removed { + self.sessions.remove(&d.session_id); + self.removed + .insert(d.session_id.clone(), (seq, d.process_gen.clone())); + return RegistryOutcome::Applied; + } + if let Some((removed_at, pg)) = self.removed.get(&d.session_id) { + if *pg == d.process_gen || seq <= *removed_at { + return RegistryOutcome::Resurrection; + } + self.removed.remove(&d.session_id); + } + self.sessions.insert(d.session_id.clone(), d.clone()); + RegistryOutcome::Applied + } + + pub fn runtime_epoch(&self) -> Option { + self.epoch.as_ref().map(|(e, _)| *e) + } + + pub fn runtime_gen(&self) -> Option<&str> { + self.epoch.as_ref().map(|(_, g)| g.as_str()) + } + + pub fn seq(&self) -> Option { + self.last_seq + } + + pub fn awaiting_snapshot(&self) -> bool { + self.awaiting_snapshot + } + + pub fn session(&self, session_id: &str) -> Option<&Descriptor> { + self.sessions.get(session_id) + } + + pub fn sessions(&self) -> impl Iterator { + self.sessions.values() + } + + pub fn session_ids(&self) -> Vec { + self.sessions.keys().cloned().collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::session_protocol::wire::{Activity, ErrorCode}; + + const PG: &str = "22222222-2222-4222-8222-222222222222"; + const PG2: &str = "22222222-2222-4222-8222-222222222223"; + const AG: &str = "33333333-3333-4333-8333-333333333333"; + const RG7: &str = "77777777-7777-4777-8777-777777777777"; + const RG8: &str = "55555555-5555-4555-8555-555555555555"; + + fn target() -> Target { + Target { + account_id: "account-a".into(), + atem_device_id: "host-a".into(), + host_trust_gen: 1, + session_id: "ses-aaaaaaaaaaaa".into(), + process_gen: PG.into(), + } + } + + fn id(n: u32) -> String { + format!("11111111-1111-4111-8111-{n:012}") + } + + fn lease(re: &str, epoch: u64, ms_left: u64) -> SessionMessage { + SessionMessage { + id: id(900), + re: Some(re.into()), + op: Op::Lease { + binding: Binding { + target: target(), + attachment_gen: AG.into(), + lease_epoch: Some(epoch), + }, + ms_left, + }, + } + } + + fn reply(re: &str, op: Op) -> SessionMessage { + SessionMessage { + id: id(901), + re: Some(re.into()), + op, + } + } + + fn client() -> LeaseClient { + LeaseClient::new(target(), AG.into()) + } + + #[test] + fn grant_sets_deadline_from_send_time_not_arrival() { + let mut c = client(); + assert!(!c.controls(0)); + c.sent(&id(1), LeaseRequest::Acquire, 1_000).unwrap(); + assert_eq!( + c.on_reply(&lease(&id(1), 1, 15_000), 1_400), + ReplyOutcome::Applied + ); + assert_eq!(c.deadline_ms(0), Some(16_000)); + assert_eq!(c.lease_epoch(), Some(1)); + assert!(c.controls(15_999) && c.input_enabled(15_999)); + assert!( + !c.controls(16_000) && !c.input_enabled(16_000), + "now >= deadline" + ); + } + + #[test] + fn each_request_consumes_one_reply_and_unknown_re_is_dropped() { + let mut c = client(); + c.sent(&id(1), LeaseRequest::Acquire, 0).unwrap(); + assert_eq!( + c.on_reply(&lease(&id(1), 1, 15_000), 0), + ReplyOutcome::Applied + ); + c.sent(&id(2), LeaseRequest::Renew, 5_000).unwrap(); + // Duplicate of the consumed acquire reply: dropped though the binding matches. + assert_eq!( + c.on_reply(&lease(&id(1), 1, 15_000), 6_000), + ReplyOutcome::UnknownRe + ); + assert_eq!( + c.on_reply(&lease(&id(77), 1, 15_000), 6_000), + ReplyOutcome::UnknownRe + ); + let mut no_re = lease(&id(2), 1, 15_000); + no_re.re = None; + assert_eq!(c.on_reply(&no_re, 6_000), ReplyOutcome::UnknownRe); + assert_eq!(c.deadline_ms(0), Some(15_000)); + assert_eq!( + c.on_reply(&lease(&id(2), 1, 15_000), 6_000), + ReplyOutcome::Applied + ); + assert_eq!(c.deadline_ms(0), Some(20_000)); + assert_eq!( + c.on_reply(&lease(&id(2), 1, 15_000), 6_000), + ReplyOutcome::UnknownRe + ); + } + + #[test] + fn late_reply_after_deadline_is_dropped_without_a_timer() { + let mut c = client(); + c.sent(&id(1), LeaseRequest::Acquire, 0).unwrap(); + c.on_reply(&lease(&id(1), 1, 15_000), 0); + c.sent(&id(2), LeaseRequest::Renew, 5_000).unwrap(); + assert_eq!( + c.on_reply(&lease(&id(2), 1, 15_000), 15_000), + ReplyOutcome::DeadlinePassed + ); + assert!(!c.controls(15_000)); + assert_eq!(c.deadline_ms(0), None); + assert_eq!(c.lease_epoch(), None); + // Renew is refused without control. + assert_eq!( + c.sent(&id(3), LeaseRequest::Renew, 15_000), + Err(RequestRefused::NotControlling) + ); + } + + #[test] + fn replies_to_requests_sent_before_expiry_never_regrant() { + let mut c = client(); + c.sent(&id(1), LeaseRequest::Acquire, 0).unwrap(); + c.on_reply(&lease(&id(1), 1, 15_000), 0); + c.sent(&id(2), LeaseRequest::Renew, 5_000).unwrap(); + // Expiry noticed when sending a new acquire. + c.sent(&id(3), LeaseRequest::Acquire, 16_000).unwrap(); + assert_eq!( + c.on_reply(&lease(&id(2), 1, 15_000), 16_100), + ReplyOutcome::UnknownRe + ); + assert!(!c.controls(16_100)); + assert_eq!( + c.on_reply(&lease(&id(3), 3, 15_000), 16_200), + ReplyOutcome::Applied + ); + assert_eq!(c.deadline_ms(0), Some(31_000)); + assert_eq!(c.lease_epoch(), Some(3)); + } + + #[test] + fn expiry_noticed_by_tick_drops_outstanding() { + let mut c = client(); + c.sent(&id(1), LeaseRequest::Acquire, 0).unwrap(); + c.on_reply(&lease(&id(1), 1, 15_000), 0); + c.sent(&id(2), LeaseRequest::Renew, 14_000).unwrap(); + c.tick(15_000); + assert_eq!(c.deadline_ms(0), None); + assert_eq!( + c.on_reply(&lease(&id(2), 1, 15_000), 15_000), + ReplyOutcome::UnknownRe + ); + } + + #[test] + fn repeat_acquire_never_extends() { + let mut c = client(); + c.sent(&id(1), LeaseRequest::Acquire, 0).unwrap(); + c.on_reply(&lease(&id(1), 1, 15_000), 0); + c.sent(&id(2), LeaseRequest::Acquire, 1_000).unwrap(); + c.on_reply(&lease(&id(2), 1, 15_000), 1_000); + assert_eq!( + c.deadline_ms(0), + Some(15_000), + "min(previous, send + ms_left)" + ); + c.sent(&id(3), LeaseRequest::Acquire, 2_000).unwrap(); + c.on_reply(&lease(&id(3), 1, 4_000), 2_500); + assert_eq!( + c.deadline_ms(0), + Some(6_000), + "a shorter remaining lifetime wins" + ); + // Near expiry: the computed deadline is already reached on arrival. + c.sent(&id(4), LeaseRequest::Acquire, 5_000).unwrap(); + assert_eq!( + c.on_reply(&lease(&id(4), 1, 500), 5_500), + ReplyOutcome::AppliedExpired + ); + assert!(!c.controls(5_500) && !c.input_enabled(5_500)); + assert_eq!(c.deadline_ms(0), None); + } + + #[test] + fn renew_sets_send_time_plus_ms_left() { + let mut c = client(); + c.sent(&id(1), LeaseRequest::Acquire, 0).unwrap(); + c.on_reply(&lease(&id(1), 1, 15_000), 0); + c.sent(&id(2), LeaseRequest::Renew, 5_000).unwrap(); + c.on_reply(&lease(&id(2), 1, 14_000), 9_000); + assert_eq!(c.deadline_ms(0), Some(19_000)); + } + + #[test] + fn authorization_deadline_caps() { + let mut c = client(); + c.set_authorization_deadline(Some(10_000)); + c.sent(&id(1), LeaseRequest::Acquire, 0).unwrap(); + c.on_reply(&lease(&id(1), 1, 15_000), 0); + assert_eq!(c.deadline_ms(0), Some(10_000)); + c.set_authorization_deadline(Some(8_000)); + assert_eq!( + c.deadline_ms(0), + Some(8_000), + "a shorter authorization caps at once" + ); + c.sent(&id(2), LeaseRequest::Renew, 7_000).unwrap(); + assert_eq!( + c.on_reply(&lease(&id(2), 1, 15_000), 7_000), + ReplyOutcome::Applied + ); + assert_eq!(c.deadline_ms(0), Some(8_000)); + } + + #[test] + fn mismatched_replies_are_dropped() { + let mut c = client(); + c.sent(&id(1), LeaseRequest::Acquire, 0).unwrap(); + let mut other = lease(&id(1), 1, 15_000); + if let Op::Lease { binding, .. } = &mut other.op { + binding.target.process_gen = PG2.into(); + } + assert_eq!(c.on_reply(&other, 0), ReplyOutcome::Mismatch); + assert!(!c.controls(0)); + // The request was consumed by the mismatching reply. + assert_eq!( + c.on_reply(&lease(&id(1), 1, 15_000), 0), + ReplyOutcome::UnknownRe + ); + let not_reply = reply(&id(1), Op::List {}); + assert_eq!(c.on_reply(¬_reply, 0), ReplyOutcome::NotALeaseReply); + } + + #[test] + fn errors_releases_and_lease_changes_make_a_viewer() { + let mut c = client(); + c.sent(&id(1), LeaseRequest::Acquire, 0).unwrap(); + c.on_reply(&lease(&id(1), 1, 15_000), 0); + c.sent(&id(2), LeaseRequest::Renew, 5_000).unwrap(); + let err = Op::Error { + code: ErrorCode::StaleLease, + detail: None, + }; + assert_eq!( + c.on_reply(&reply(&id(2), err), 5_100), + ReplyOutcome::Applied + ); + assert!(!c.controls(5_100)); + + let mut c = client(); + c.sent(&id(1), LeaseRequest::Acquire, 0).unwrap(); + c.on_reply(&lease(&id(1), 4, 15_000), 0); + // A grant notice for our own epoch keeps control; a newer epoch revokes it. + c.on_lease_changed(&target().session_id, PG, 4); + assert!(c.controls(1)); + c.on_lease_changed(&target().session_id, PG, 5); + assert!(!c.controls(1)); + + let mut c = client(); + c.sent(&id(1), LeaseRequest::Acquire, 0).unwrap(); + c.on_reply(&lease(&id(1), 1, 15_000), 0); + c.sent(&id(2), LeaseRequest::Renew, 1_000).unwrap(); + c.sent(&id(3), LeaseRequest::Release, 1_000).unwrap(); + assert!(!c.controls(1_000), "input stops as soon as release is sent"); + assert_eq!( + c.on_reply(&lease(&id(2), 1, 15_000), 1_100), + ReplyOutcome::UnknownRe + ); + assert_eq!( + c.on_reply(&reply(&id(3), Op::LeaseReleased {}), 1_200), + ReplyOutcome::Applied + ); + assert!(!c.controls(1_200)); + } + + #[test] + fn in_flight_and_duplicate_ids_are_bounded() { + let mut c = client(); + c.sent(&id(1), LeaseRequest::Acquire, 0).unwrap(); + assert_eq!( + c.sent(&id(1), LeaseRequest::Acquire, 0), + Err(RequestRefused::DuplicateId) + ); + for n in 2..=crate::session_protocol::wire::MAX_IN_FLIGHT as u32 { + c.sent(&id(n), LeaseRequest::Acquire, 0).unwrap(); + } + assert_eq!( + c.sent(&id(999), LeaseRequest::Acquire, 0), + Err(RequestRefused::TooManyInFlight) + ); + } + + #[test] + fn deadline_reads_none_once_reached() { + let mut c = client(); + c.sent(&id(1), LeaseRequest::Acquire, 0).unwrap(); + c.on_reply(&lease(&id(1), 1, 15_000), 0); + assert_eq!(c.deadline_ms(14_999), Some(15_000)); + assert_eq!(c.deadline_ms(15_000), None, "now >= deadline"); + } + + #[test] + fn renew_reply_with_another_epoch_fails_closed() { + let mut c = client(); + c.sent(&id(1), LeaseRequest::Acquire, 0).unwrap(); + c.on_reply(&lease(&id(1), 4, 15_000), 0); + c.sent(&id(2), LeaseRequest::Renew, 5_000).unwrap(); + assert_eq!( + c.on_reply(&lease(&id(2), 5, 15_000), 5_100), + ReplyOutcome::Mismatch + ); + assert!( + !c.controls(5_100), + "a renew answered for another epoch ends control" + ); + assert_eq!(c.lease_epoch(), None); + } + + #[test] + fn release_needs_control() { + let mut c = client(); + assert_eq!( + c.sent(&id(1), LeaseRequest::Release, 0), + Err(RequestRefused::NotControlling) + ); + } + + #[test] + fn lease_replies_older_than_a_seen_lease_change_are_dropped() { + let mut c = client(); + // A viewer with an acquire outstanding sees the lease move to epoch 6. + c.sent(&id(1), LeaseRequest::Acquire, 0).unwrap(); + c.on_lease_changed(&target().session_id, PG, 6); + assert_eq!( + c.on_reply(&lease(&id(1), 5, 15_000), 100), + ReplyOutcome::StaleEpoch + ); + assert!(!c.controls(100)); + // A grant at (or after) the highest seen epoch applies. + c.sent(&id(2), LeaseRequest::Acquire, 200).unwrap(); + assert_eq!( + c.on_reply(&lease(&id(2), 6, 15_000), 300), + ReplyOutcome::Applied + ); + assert!(c.controls(300)); + // Other targets' changes don't count. + c.on_lease_changed(&target().session_id, PG2, 50); + c.on_lease_changed("ses-bbbbbbbbbbbb", PG, 50); + assert!(c.controls(300)); + c.sent(&id(3), LeaseRequest::Acquire, 400).unwrap(); + assert_eq!( + c.on_reply(&lease(&id(3), 6, 15_000), 500), + ReplyOutcome::Applied + ); + } + + // ── registry ── + + fn desc(sid: &str, pg: &str, state: SessionState) -> Descriptor { + Descriptor { + session_id: sid.into(), + process_gen: pg.into(), + provider: "claude".into(), + cwd_label: "Atem".into(), + state, + activity: Activity::Ready, + activity_since: 1, + controller: None, + capabilities: vec![], + created_at: 1, + } + } + + fn snap(rgen: &str, epoch: u64, seq: u64, sessions: Vec) -> Op { + Op::Sessions { + runtime_gen: rgen.into(), + runtime_epoch: epoch, + seq, + sessions, + } + } + + fn change(rgen: &str, epoch: u64, seq: u64, d: Descriptor) -> Op { + Op::SessionChanged { + runtime_gen: rgen.into(), + runtime_epoch: epoch, + seq, + descriptor: d, + } + } + + const A: &str = "ses-aaaaaaaaaaaa"; + const B: &str = "ses-bbbbbbbbbbbb"; + + #[test] + fn a_new_attachment_restarts_seq_but_keeps_the_epoch_floor() { + let s1 = || desc("ses-aaaaaaaaaaaa", PG, SessionState::Running); + let mut r = RegistryClient::default(); + assert_eq!( + r.on_message(&snap(RG8, 8, 5, vec![s1()])), + RegistryOutcome::Applied + ); + // Without a reset, the new attachment's snapshot (seq 1) is stale. + let mut frozen = r.clone(); + assert_eq!( + frozen.on_message(&snap(RG8, 8, 1, vec![])), + RegistryOutcome::StaleSnapshot + ); + r.new_attachment(); + assert!(r.session_ids().is_empty() && r.seq().is_none()); + assert_eq!(r.runtime_epoch(), Some(8)); + // Older-epoch messages stay fenced. + assert_eq!( + r.on_message(&snap(RG7, 7, 9, vec![s1()])), + RegistryOutcome::StaleEpoch + ); + assert_eq!( + r.on_message(&snap(RG8, 8, 1, vec![s1()])), + RegistryOutcome::Applied + ); + assert_eq!(r.seq(), Some(1)); + assert_eq!( + r.on_message(&change( + RG8, + 8, + 2, + desc("ses-aaaaaaaaaaaa", PG, SessionState::Exited) + )), + RegistryOutcome::Applied + ); + } + + #[test] + fn epochs_fence_snapshots() { + let mut r = RegistryClient::default(); + let run = desc(A, PG, SessionState::Running); + assert_eq!( + r.on_message(&snap(RG7, 7, 100, vec![desc(B, PG, SessionState::Running)])), + RegistryOutcome::Applied + ); + assert_eq!( + r.on_message(&snap(RG8, 8, 1, vec![run.clone()])), + RegistryOutcome::Applied + ); + assert_eq!(r.session_ids(), vec![A.to_string()]); + assert_eq!((r.runtime_epoch(), r.seq()), (Some(8), Some(1))); + assert_eq!( + r.on_message(&snap(RG7, 7, 101, vec![])), + RegistryOutcome::StaleEpoch + ); + assert_eq!( + r.on_message(&change(RG7, 7, 2, run.clone())), + RegistryOutcome::StaleEpoch + ); + // Same epoch, other runtime_gen. + assert_eq!( + r.on_message(&snap(RG7, 8, 5, vec![])), + RegistryOutcome::RuntimeGenMismatch + ); + assert_eq!( + r.on_message(&change(RG7, 8, 2, run)), + RegistryOutcome::RuntimeGenMismatch + ); + assert_eq!(r.session_ids(), vec![A.to_string()]); + assert!(!r.take_list_request()); + } + + #[test] + fn changes_apply_in_order_and_duplicates_are_ignored() { + let mut r = RegistryClient::default(); + r.on_message(&snap(RG8, 8, 4, vec![desc(A, PG, SessionState::Running)])); + assert_eq!( + r.on_message(&change(RG8, 8, 5, desc(A, PG, SessionState::Exited))), + RegistryOutcome::Applied + ); + assert_eq!(r.session(A).unwrap().state, SessionState::Exited); + assert_eq!( + r.on_message(&change(RG8, 8, 5, desc(A, PG, SessionState::Running))), + RegistryOutcome::Duplicate + ); + assert_eq!( + r.on_message(&change(RG8, 8, 3, desc(A, PG, SessionState::Running))), + RegistryOutcome::Duplicate + ); + assert_eq!(r.session(A).unwrap().state, SessionState::Exited); + assert_eq!( + r.on_message(&snap(RG8, 8, 4, vec![])), + RegistryOutcome::StaleSnapshot + ); + assert!(!r.take_list_request()); + } + + #[test] + fn gap_requests_list_and_resync_discards_older_changes() { + let mut r = RegistryClient::default(); + r.on_message(&snap(RG8, 8, 1, vec![desc(A, PG, SessionState::Running)])); + assert_eq!( + r.on_message(&change(RG8, 8, 3, desc(A, PG, SessionState::Exited))), + RegistryOutcome::Buffered + ); + assert!(r.take_list_request()); + assert!(!r.take_list_request(), "one list per gap"); + assert_eq!(r.lists_requested(), 1); + assert_eq!(r.session(A).unwrap().state, SessionState::Running); + assert_eq!( + r.on_message(&change(RG8, 8, 2, desc(A, PG, SessionState::Removed))), + RegistryOutcome::Buffered + ); + // A further gap while awaiting the snapshot doesn't send another list. + r.on_message(&change(RG8, 8, 9, desc(A, PG, SessionState::Exited))); + assert!(!r.take_list_request()); + // Snapshot at 3: buffered 2 and 3 are discarded; 9 is still ahead (gap β†’ list). + assert_eq!( + r.on_message(&snap(RG8, 8, 3, vec![])), + RegistryOutcome::Applied + ); + assert!(r.session_ids().is_empty()); + assert_eq!(r.seq(), Some(3)); + assert!(r.take_list_request()); + assert_eq!( + r.on_message(&change(RG8, 8, 3, desc(A, PG, SessionState::Exited))), + RegistryOutcome::Duplicate + ); + assert!(r.session_ids().is_empty()); + } + + #[test] + fn buffered_changes_after_the_snapshot_apply_in_order() { + let mut r = RegistryClient::default(); + r.note_list_sent(); + assert_eq!( + r.on_message(&change(RG8, 8, 6, desc(A, PG, SessionState::Exited))), + RegistryOutcome::Buffered + ); + assert!( + r.take_list_request(), + "a change from a new epoch needs a list" + ); + assert!(r.session_ids().is_empty()); + r.on_message(&change(RG8, 8, 7, desc(B, PG, SessionState::Running))); + r.on_message(&snap(RG8, 8, 5, vec![desc(A, PG, SessionState::Running)])); + assert_eq!(r.seq(), Some(7)); + assert_eq!(r.session(A).unwrap().state, SessionState::Exited); + assert!(r.session(B).is_some()); + assert!(!r.take_list_request()); + } + + #[test] + fn new_epoch_from_a_change_applies_nothing_until_its_snapshot() { + let mut r = RegistryClient::default(); + r.on_message(&snap(RG7, 7, 100, vec![desc(B, PG, SessionState::Running)])); + assert_eq!( + r.on_message(&change(RG8, 8, 1, desc(A, PG, SessionState::Running))), + RegistryOutcome::Buffered + ); + assert_eq!(r.runtime_epoch(), Some(8)); + assert_eq!(r.seq(), None); + assert!( + r.session_ids().is_empty(), + "a higher epoch replaces all state" + ); + assert!(r.take_list_request()); + assert_eq!( + r.on_message(&snap(RG7, 7, 101, vec![])), + RegistryOutcome::StaleEpoch + ); + assert_eq!( + r.on_message(&snap(RG8, 8, 1, vec![desc(A, PG, SessionState::Exited)])), + RegistryOutcome::Applied + ); + assert_eq!(r.session(A).unwrap().state, SessionState::Exited); + } + + #[test] + fn removal_is_never_resurrected() { + let mut r = RegistryClient::default(); + r.on_message(&snap(RG8, 8, 1, vec![desc(A, PG, SessionState::Running)])); + assert_eq!( + r.on_message(&change(RG8, 8, 2, desc(A, PG, SessionState::Removed))), + RegistryOutcome::Applied + ); + assert!(r.session(A).is_none()); + assert_eq!( + r.on_message(&change(RG8, 8, 2, desc(A, PG, SessionState::Running))), + RegistryOutcome::Duplicate + ); + assert_eq!( + r.on_message(&change(RG8, 8, 3, desc(A, PG, SessionState::Running))), + RegistryOutcome::Resurrection + ); + assert!(r.session(A).is_none()); + assert_eq!(r.seq(), Some(3), "the seq was consumed"); + // A snapshot can't revive that process_gen either. + r.on_message(&snap(RG8, 8, 4, vec![desc(A, PG, SessionState::Running)])); + assert!(r.session(A).is_none()); + // A new process for the same session id is accepted with a higher seq. + assert_eq!( + r.on_message(&change(RG8, 8, 5, desc(A, PG2, SessionState::Running))), + RegistryOutcome::Applied + ); + assert_eq!(r.session(A).unwrap().process_gen, PG2); + } + + #[test] + fn snapshot_never_lists_removed_descriptors() { + let mut r = RegistryClient::default(); + r.on_message(&snap( + RG8, + 8, + 1, + vec![ + desc(A, PG, SessionState::Removed), + desc(B, PG, SessionState::Running), + ], + )); + assert_eq!(r.session_ids(), vec![B.to_string()]); + assert_eq!( + r.on_message(&Op::List {}), + RegistryOutcome::NotARegistryMessage + ); + } +} diff --git a/src/session_protocol/fake_runtime.rs b/src/session_protocol/fake_runtime.rs new file mode 100644 index 0000000..1d2864e --- /dev/null +++ b/src/session_protocol/fake_runtime.rs @@ -0,0 +1,644 @@ +//! A fake runtime around `SessionHost` for the shared fixtures and the host +//! unit tests: it performs the host's effects against a fake PTY and +//! dispatcher (with the fixtures' failure injection), keeps a `MemJournal`, +//! and plays each fixture client's transport (connect, `hello`/`welcome`, +//! reconnect). +//! +//! The fixtures describe clients that are already attached; the harness does +//! the `hello`/`welcome` itself (checking the welcome) and, for +//! `holds_lease_epoch`, a real `acquire`, so every lease and attachment comes +//! from the host's own rules. + +use super::fixtures::{HostEvent, HostUnderTest, Setup}; +use super::host::{ + Attach, ChatReply, DispatchReport, Effect, HostConfig, HostContext, Origin, SessionHost, + SessionSpec, random_ids, +}; +use super::journal::MemJournal; +use super::wire::{ + self, Binding, ClientKind, DecodeError, Envelope, Op, Permission, ReceiptKind, SessionMessage, + Target, +}; +use serde_json::{Value, json}; +use std::collections::{BTreeMap, HashMap, VecDeque}; + +/// The authenticated link's authorization in the fixtures (24 h). +pub const AUTHORIZATION_MS: u64 = 24 * 60 * 60 * 1000; + +/// The fake PTY and dispatcher. +#[derive(Debug)] +pub struct Pty { + pub fail_next_enqueue: bool, + pub fail_next_partial: bool, + /// Every `Dispatch` the host asked for. + pub dispatch_attempts: u64, + /// Dispatches that reached the PTY/agent (not refused before any effect). + pub dispatches: u64, + /// `None` once a writer failed after a possible partial write. + pub bytes_written: Option, + pub writes_local: u64, + pub writes_remote: u64, + /// Remote chunks refused at the write boundary. + pub dropped_remote: u64, + pub resizes: u64, + pub stops: u64, +} + +impl Default for Pty { + fn default() -> Self { + Self { + fail_next_enqueue: false, + fail_next_partial: false, + dispatch_attempts: 0, + dispatches: 0, + bytes_written: Some(0), + writes_local: 0, + writes_remote: 0, + dropped_remote: 0, + resizes: 0, + stops: 0, + } + } +} + +impl Pty { + fn wrote(&mut self, n: usize) { + self.bytes_written = self.bytes_written.map(|b| b + n as u64); + } +} + +#[derive(Debug, Clone)] +struct Client { + attachment_gen: String, + permissions: Vec, + client_kind: ClientKind, +} + +pub struct FakeRuntime { + pub host: SessionHost, + pub pty: Pty, + /// Hold remote PTY writes in a queue instead of writing them at once + /// (`flush_writes` writes them through the write boundary). + pub hold_writes: bool, + /// Whether the clock runs the host's timers (`advance`); off, the host + /// sees time only through `receive` and `admit_write`. + pub ticks: bool, + queued_writes: Vec, + /// How a `Reconcile` is answered. + pub reconcile_answer: DispatchReport, + /// session_id β†’ the prompt whose turn is running. + turns: HashMap, + clients: BTreeMap, + /// Every attachment ever used β†’ client name. + names: HashMap, + live: HashMap, + sessions: Vec, + out: Vec<(String, Envelope)>, + /// Every effect the host produced, in order. + pub effects: Vec, + now: u64, +} + +fn message(op: Op) -> Envelope { + Envelope::Session(SessionMessage { + id: uuid::Uuid::new_v4().to_string(), + re: None, + op, + }) +} + +/// Renders a chat reply as the chat runtime would (without its stream head). +fn render_chat(conversation_id: &str, session_id: &str, reply: ChatReply) -> Envelope { + let mut data = json!({"v": 1, "conversationId": conversation_id, "agentId": session_id}); + let body = match reply { + ChatReply::Accepted { request_id } => json!({"op": "accepted", "requestId": request_id}), + ChatReply::Complete { + request_id, + turn_id, + } => { + let mut b = json!({"op": "complete", "requestId": request_id, "outcome": "done"}); + if let Some(t) = turn_id { + b["turnId"] = Value::String(t); + } + b + } + ChatReply::Error { + request_id, + code, + text, + } => { + let mut b = json!({"op": "error", "code": code, "text": text}); + if let Some(r) = request_id { + b["requestId"] = Value::String(r); + } + b + } + }; + data.as_object_mut() + .unwrap() + .extend(body.as_object().unwrap().clone()); + Envelope::Family(wire::FamilyMessage { + family: "agentChat".into(), + data: Some(data), + binding: None, + request: None, + }) +} + +impl FakeRuntime { + pub fn new(setup: &Setup) -> Result { + Self::with_config(setup, HostConfig::default()) + } + + pub fn with_config(setup: &Setup, config: HostConfig) -> Result { + let h = &setup.host; + let ctx = HostContext { + account_id: h.account_id.clone(), + atem_device_id: h.atem_device_id.clone(), + host_trust_gen: h.host_trust_gen, + runtime_gen: h.runtime_gen.clone(), + runtime_epoch: h.runtime_epoch, + }; + let host = + SessionHost::new(ctx, config, MemJournal::default(), random_ids()).map_err(|e| e.0)?; + let mut rt = Self { + host, + pty: Pty::default(), + hold_writes: false, + ticks: true, + queued_writes: Vec::new(), + reconcile_answer: DispatchReport::Unknown, + turns: HashMap::new(), + clients: BTreeMap::new(), + names: HashMap::new(), + live: HashMap::new(), + sessions: Vec::new(), + out: Vec::new(), + effects: Vec::new(), + now: 0, + }; + for s in &setup.sessions { + let fx = rt.host.add_session(SessionSpec { + session_id: s.session_id.clone(), + process_gen: s.process_gen.clone(), + provider: "claude".into(), + cwd_label: "Atem".into(), + chat: s.chat, + rows: 24, + cols: 80, + })?; + rt.apply(fx); + rt.sessions.push(s.session_id.clone()); + } + for c in &setup.clients { + rt.clients.insert( + c.name.clone(), + Client { + attachment_gen: c.attachment_gen.clone(), + permissions: c.permissions.clone(), + client_kind: c.client_kind, + }, + ); + rt.attach(&c.name, &c.attachment_gen)?; + if let Some(epoch) = c.holds_lease_epoch { + rt.setup_lease(&c.name, epoch)?; + } + } + // Setup traffic (e.g. other viewers' `lease_changed` for a setup + // lease) is not visible to the steps. + rt.out.clear(); + Ok(rt) + } + + pub fn from_json(setup: Value, config: HostConfig) -> Self { + let setup: Setup = serde_json::from_value(setup).expect("setup"); + Self::with_config(&setup, config).expect("fake runtime") + } + + /// Connects `client` on a new attachment without `hello`. + pub fn connect_only( + &mut self, + client: &str, + attachment_gen: &str, + permissions: Vec, + authorization_ms: u64, + ) { + let c = Client { + attachment_gen: attachment_gen.to_string(), + permissions: permissions.clone(), + client_kind: ClientKind::Native, + }; + self.clients.insert(client.to_string(), c); + let fx = self + .host + .connect(Attach { + client: client.to_string(), + attachment_gen: attachment_gen.to_string(), + permissions, + client_kind: ClientKind::Native, + authorization_ms, + }) + .expect("connect"); + self.apply(fx); + self.names + .insert(attachment_gen.to_string(), client.to_string()); + self.live + .insert(attachment_gen.to_string(), client.to_string()); + } + + pub fn now(&self) -> u64 { + self.now + } + + pub fn attachment(&self, client: &str) -> String { + self.clients[client].attachment_gen.clone() + } + + /// A new attachment for `client`: connect (fencing the previous one), + /// `hello`, and the checked `welcome` taken off the queue. + pub fn attach(&mut self, client: &str, attachment_gen: &str) -> Result<(), String> { + let c = self.clients.get_mut(client).ok_or("unknown client")?; + c.attachment_gen = attachment_gen.to_string(); + let attach = Attach { + client: client.to_string(), + attachment_gen: attachment_gen.to_string(), + permissions: c.permissions.clone(), + client_kind: c.client_kind, + authorization_ms: AUTHORIZATION_MS, + }; + let fx = self.host.connect(attach)?; + self.apply(fx); + self.names + .insert(attachment_gen.to_string(), client.to_string()); + self.live + .insert(attachment_gen.to_string(), client.to_string()); + let fx = self.host.receive_decoded( + attachment_gen, + Ok(message(Op::Hello { resume: None })), + self.now, + ); + self.apply(fx); + let i = self + .out + .iter() + .rposition(|(n, m)| { + n == client + && matches!(m, Envelope::Session(s) if matches!(s.op, Op::Welcome { .. })) + }) + .ok_or("no welcome")?; + let (_, welcome) = self.out.remove(i); + match welcome { + Envelope::Session(SessionMessage { + op: Op::Welcome { + attachment_gen: g, .. + }, + .. + }) if g == attachment_gen => Ok(()), + other => Err(format!("unexpected welcome {other:?}")), + } + } + + fn only_session(&self) -> Result { + match self.sessions.as_slice() { + [only] => Ok(only.clone()), + _ => Err("this needs exactly one session".into()), + } + } + + pub fn target(&self, session_id: &str) -> Target { + let ctx = self.host.context(); + Target { + account_id: ctx.account_id.clone(), + atem_device_id: ctx.atem_device_id.clone(), + host_trust_gen: ctx.host_trust_gen, + session_id: session_id.to_string(), + process_gen: self.process_gen(session_id), + } + } + + fn process_gen(&self, session_id: &str) -> String { + self.host + .descriptor_of(session_id) + .map(|d| d.process_gen) + .unwrap_or_default() + } + + /// `holds_lease_epoch`: a real `acquire`, whose reply must carry `epoch`. + fn setup_lease(&mut self, client: &str, epoch: u64) -> Result<(), String> { + let sid = self.only_session()?; + let att = self.attachment(client); + let binding = Binding { + target: self.target(&sid), + attachment_gen: att.clone(), + lease_epoch: None, + }; + let fx = self.host.receive_decoded( + &att, + Ok(message(Op::Acquire { + binding, + takeover: false, + })), + self.now, + ); + self.apply(fx); + let i = self + .out + .iter() + .position(|(n, m)| { + n == client && matches!(m, Envelope::Session(s) if matches!(s.op, Op::Lease { .. })) + }) + .ok_or("setup acquire got no lease")?; + match self.out.remove(i).1 { + Envelope::Session(SessionMessage { + op: Op::Lease { binding, .. }, + .. + }) if binding.lease_epoch == Some(epoch) => Ok(()), + other => Err(format!("setup lease is not epoch {epoch}: {other:?}")), + } + } + + /// Performs effects until none are left. + pub fn apply(&mut self, fx: Vec) { + let mut work: VecDeque = fx.into(); + while let Some(effect) = work.pop_front() { + self.effects.push(effect.clone()); + match effect { + Effect::Send { + attachment, + message, + } => { + if let Some(name) = self.live.get(&attachment) { + self.out.push((name.clone(), message)); + } + } + Effect::Close { attachment } => { + self.live.remove(&attachment); + } + Effect::PtyWrite { ref origin, .. } => { + if self.hold_writes && matches!(origin, Origin::Remote { .. }) { + self.queued_writes.push(effect); + } else { + self.write(effect); + } + } + Effect::Resize { .. } => self.pty.resizes += 1, + Effect::Stop { .. } => self.pty.stops += 1, + Effect::Dispatch { + request_id, + kind, + payload, + target, + } => { + self.pty.dispatch_attempts += 1; + let report = if std::mem::take(&mut self.pty.fail_next_enqueue) { + Some(DispatchReport::FailedNoEffect) + } else if std::mem::take(&mut self.pty.fail_next_partial) { + self.pty.bytes_written = None; + Some(DispatchReport::FailedAfterPossibleEffect) + } else { + if kind == ReceiptKind::Prompt { + let n = payload["text"].as_str().map_or(0, str::len); + self.pty.wrote(n); + self.turns + .insert(target.session_id.clone(), request_id.clone()); + } + // Written; completion comes from the provider later. + None + }; + if report != Some(DispatchReport::FailedNoEffect) { + self.pty.dispatches += 1; + } + if let Some(report) = report { + work.extend(self.host.dispatch_report(&request_id, report)); + } + } + Effect::Reconcile { request_id, .. } => { + let answer = self.reconcile_answer.clone(); + work.extend(self.host.dispatch_report(&request_id, answer)); + } + Effect::Chat { + attachment, + conversation_id, + session_id, + reply, + } => { + if let Some(name) = self.live.get(&attachment) { + let msg = render_chat(&conversation_id, &session_id, reply); + self.out.push((name.clone(), msg)); + } + } + Effect::StreamOpened { .. } + | Effect::StreamClosed { .. } + | Effect::Forward { .. } => {} + } + } + } + + /// The write boundary: remote chunks are written only if the host still + /// admits their input epoch. + fn write(&mut self, effect: Effect) { + let Effect::PtyWrite { + session_id, + bytes, + origin, + } = effect + else { + return; + }; + match origin { + Origin::Local => { + self.pty.writes_local += 1; + self.pty.wrote(bytes.len()); + } + Origin::Remote { input_epoch, .. } => { + if self.host.admit_write(&session_id, input_epoch, self.now) { + self.pty.writes_remote += 1; + self.pty.wrote(bytes.len()); + } else { + self.pty.dropped_remote += 1; + } + } + } + } + + pub fn flush_writes(&mut self) { + for w in std::mem::take(&mut self.queued_writes) { + self.write(w); + } + } + + pub fn set_now(&mut self, now: u64) { + self.now = now; + if self.ticks { + let fx = self.host.advance(now); + self.apply(fx); + } + } + + /// `client` sends `json` on its current attachment. + pub fn send(&mut self, client: &str, json: &Value) { + let att = self.attachment(client); + self.send_on(&att, json); + } + + /// Sends `json` on a specific (possibly fenced) attachment. + pub fn send_on(&mut self, attachment: &str, json: &Value) { + let msg = wire::decode(&serde_json::to_vec(json).unwrap()); + let fx = self.host.receive_decoded(attachment, msg, self.now); + self.apply(fx); + } + + /// Messages `client` received since the last call, as wire JSON. + pub fn take(&mut self, client: &str) -> Vec { + let (mine, rest) = std::mem::take(&mut self.out) + .into_iter() + .partition(|(n, _)| n == client); + self.out = rest; + mine.into_iter() + .map(|(_, m): (String, Envelope)| wire::to_value(&m)) + .collect() + } + + pub fn lease_holder(&self) -> Result { + let sid = self.only_session()?; + Ok(self + .host + .lease_holder(&sid) + .map_or(Value::Null, |att| Value::from(self.names[att].clone()))) + } +} + +impl HostUnderTest for FakeRuntime { + /// The host sees the time with the message; no timer tick is run. + fn receive(&mut self, client: &str, msg: Result, now_ms: u64) { + self.now = now_ms; + let att = self.attachment(client); + let fx = self.host.receive_decoded(&att, msg, now_ms); + self.apply(fx); + } + + fn advance(&mut self, now_ms: u64) { + self.set_now(now_ms); + } + + /// Events don't tick the host's timers either (`advance_ms` does). + fn event(&mut self, event: &HostEvent, now_ms: u64) -> Result<(), String> { + self.now = now_ms; + let fx = match event { + HostEvent::Disconnect { client } => { + let att = self.attachment(client); + self.live.remove(&att); + self.host.disconnect(&att) + } + HostEvent::Resume { + client, + attachment_gen, + } => { + // Compatibility mode has no resume: the host refuses it and + // the new attachment stays unwelcomed. + let c = self.clients.get_mut(client).ok_or("unknown client")?; + c.attachment_gen = attachment_gen.clone(); + let attach = Attach { + client: client.clone(), + attachment_gen: attachment_gen.clone(), + permissions: c.permissions.clone(), + client_kind: c.client_kind, + authorization_ms: AUTHORIZATION_MS, + }; + let fx = self.host.connect(attach)?; + self.apply(fx); + self.names.insert(attachment_gen.clone(), client.clone()); + self.live.insert(attachment_gen.clone(), client.clone()); + let resume = Some(wire::Resume { + controller_session: uuid::Uuid::new_v4().to_string(), + }); + self.host.receive_decoded( + attachment_gen, + Ok(message(Op::Hello { resume })), + self.now, + ) + } + HostEvent::Reconnect { + client, + attachment_gen, + } => return self.attach(client, attachment_gen), + HostEvent::LocalInput { session_id, bytes } => { + self.host.local_input(session_id, bytes.clone()) + } + HostEvent::CrashWorker { session_id } => self.host.process_exited(session_id, None), + HostEvent::RestartRuntime { + runtime_gen, + runtime_epoch, + } => { + self.live.clear(); + self.queued_writes.clear(); + self.host.restart(runtime_gen, *runtime_epoch)? + } + HostEvent::FailNextFsync => { + self.host.journal_mut().fail_next_fsync(); + Vec::new() + } + HostEvent::ChangeTrustGen(n) => self.host.change_trust_gen(*n)?, + HostEvent::FailNextPtyEnqueue => { + self.pty.fail_next_enqueue = true; + Vec::new() + } + HostEvent::FailNextPtyWriteAfterPartial => { + self.pty.fail_next_partial = true; + Vec::new() + } + HostEvent::CloseSession { session_id } => self.host.close_session(session_id, false), + HostEvent::TurnComplete { + session_id, + turn_id, + } => { + let rid = self.turns.remove(session_id).ok_or("no running turn")?; + let report = DispatchReport::Completed { + detail: None, + turn_id: Some(turn_id.clone()), + }; + self.host.dispatch_report(&rid, report) + } + HostEvent::HoldPtyWrites => { + self.hold_writes = true; + Vec::new() + } + HostEvent::FlushPtyWrites => { + self.flush_writes(); + Vec::new() + } + }; + self.apply(fx); + Ok(()) + } + + fn drain_outgoing(&mut self) -> Vec<(String, Envelope)> { + std::mem::take(&mut self.out) + } + + fn counter(&self, key: &str) -> Result { + let p = &self.pty; + Ok(match key { + "dispatch_attempts" => p.dispatch_attempts.into(), + "dispatches" => p.dispatches.into(), + "pty_bytes_written" => p.bytes_written.map_or("unknown".into(), Value::from), + "pty_writes" => (p.writes_local + p.writes_remote).into(), + "pty_writes_local" => p.writes_local.into(), + "pty_writes_remote" => p.writes_remote.into(), + "pty_writes_dropped" => p.dropped_remote.into(), + "resizes" => p.resizes.into(), + "stops" => p.stops.into(), + "lease_holder" => self.lease_holder()?, + "receipts_visible" => Value::Object( + self.clients + .iter() + .map(|(name, c)| { + let n = self.host.receipts_visible(&c.attachment_gen); + (name.clone(), Value::from(n)) + }) + .collect(), + ), + other => return Err(format!("unknown host counter {other:?}")), + }) + } +} diff --git a/src/session_protocol/fixtures.rs b/src/session_protocol/fixtures.rs new file mode 100644 index 0000000..796ae18 --- /dev/null +++ b/src/session_protocol/fixtures.rs @@ -0,0 +1,1286 @@ +//! Data-driven runner for the shared atem-session v1 fixtures +//! (`designs/protocol/fixtures/*.json`, format in `fixtures/schema.md`). +//! +//! Every scenario file in the directory is read at test time, so a new fixture +//! needs no Rust change. Wire messages in both directions go through the strict +//! `wire::decode`. `side:"client"` scenarios run against `LeaseClient` and +//! `RegistryClient` with the fixture playing the host; host scenarios run +//! against a `HostUnderTest` (`SessionHost` inside `fake_runtime::FakeRuntime`) +//! with the fixture playing the clients. `run_all` without a host reports host +//! scenarios as skipped, by name; the fixture test runs them all and asserts +//! that nothing was skipped. +//! +//! Unknown actions, unknown assertion keys and unknown `setup` fields fail the +//! scenario instead of being ignored. + +use super::client::{LeaseClient, LeaseRequest, RegistryClient}; +use super::wire::{self, ClientKind, DecodeError, Envelope, Op, Permission}; +use serde::Deserialize; +use serde_json::{Map, Value}; +use std::collections::{BTreeMap, HashMap, VecDeque}; +use std::path::{Path, PathBuf}; + +// ── Scenario model ──────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct HostSetup { + pub account_id: String, + pub atem_device_id: String, + pub host_trust_gen: u64, + pub runtime_gen: String, + pub runtime_epoch: u64, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SessionSetup { + pub session_id: String, + pub process_gen: String, + #[serde(default)] + pub chat: bool, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ClientSetup { + pub name: String, + pub attachment_gen: String, + pub permissions: Vec, + pub client_kind: ClientKind, + #[serde(default)] + pub holds_lease_epoch: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Setup { + pub host: HostSetup, + #[serde(default)] + pub sessions: Vec, + pub clients: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum Side { + #[default] + Host, + Client, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Scenario { + pub name: String, + #[serde(default)] + pub side: Side, + pub setup: Setup, + pub steps: Vec, + /// Documentation for readers; not interpreted. + #[serde(default)] + #[allow(dead_code)] + pub note: Option, +} + +/// Transport, failure and durability events a host scenario injects. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HostEvent { + Disconnect { + client: String, + }, + Resume { + client: String, + attachment_gen: String, + }, + Reconnect { + client: String, + attachment_gen: String, + }, + LocalInput { + session_id: String, + bytes: Vec, + }, + CrashWorker { + session_id: String, + }, + RestartRuntime { + runtime_gen: String, + runtime_epoch: u64, + }, + FailNextFsync, + ChangeTrustGen(u64), + FailNextPtyEnqueue, + FailNextPtyWriteAfterPartial, + /// The runtime closes the session (it no longer accepts work). + CloseSession { + session_id: String, + }, + /// The provider reports the end of the session's executing prompt turn. + TurnComplete { + session_id: String, + turn_id: String, + }, + /// Remote PTY writes queue at the writer until `FlushPtyWrites`. + HoldPtyWrites, + /// The writer writes the queued chunks now (each checked at the write + /// boundary). + FlushPtyWrites, +} + +/// The host authority under test (`fake_runtime::FakeRuntime`). +pub trait HostUnderTest { + /// A client's message arrives at `now_ms` (monotonic). It already went + /// through the strict decoder; a decode failure is passed on so the host + /// can answer it. + fn receive(&mut self, client: &str, msg: Result, now_ms: u64); + /// The clock moved to `now_ms`; timers due by then fire. + fn advance(&mut self, now_ms: u64); + fn event(&mut self, event: &HostEvent, now_ms: u64) -> Result<(), String>; + /// Hostβ†’client messages produced since the last call, in send order. + fn drain_outgoing(&mut self) -> Vec<(String, Envelope)>; + /// The value of an `assert` key (`dispatch_attempts`, `pty_writes`, + /// `lease_holder` (client name or null), `receipts_visible` (object by + /// client), …); an unknown key is an `Err`. + fn counter(&self, key: &str) -> Result; +} + +pub type HostFactory<'a> = &'a dyn Fn(&Setup) -> Result, String>; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Outcome { + Passed, + Skipped, +} + +#[derive(Debug, Default)] +pub struct Report { + pub passed: Vec, + pub skipped: Vec, + pub failed: Vec<(String, String)>, +} + +pub fn fixtures_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("designs/protocol/fixtures") +} + +/// Every scenario file in `dir` (all `*.json` except the digest vectors), +/// sorted by name. +pub fn scenario_paths(dir: &Path) -> Vec { + let mut files: Vec = std::fs::read_dir(dir) + .expect("read fixtures dir") + .map(|e| e.expect("fixtures dir entry").path()) + .filter(|p| p.extension().is_some_and(|e| e == "json")) + .filter(|p| p.file_name().is_some_and(|n| n != "digest-vectors.json")) + .collect(); + files.sort(); + files +} + +/// Runs every scenario in `dir`; host scenarios are skipped when `host` is +/// `None`. +pub fn run_all(dir: &Path, host: Option) -> Report { + let mut report = Report::default(); + for path in scenario_paths(dir) { + let file = path.file_name().unwrap().to_string_lossy().into_owned(); + let parsed = std::fs::read(&path) + .map_err(|e| e.to_string()) + .and_then(|b| serde_json::from_slice::(&b).map_err(|e| e.to_string())); + let (name, result) = match parsed { + Ok(v) => { + let name = v["name"].as_str().unwrap_or(&file).to_string(); + let stem = file.trim_end_matches(".json"); + let result = if name != stem { + Err(format!("scenario name {name:?} differs from its file name")) + } else { + run_scenario(&v, host) + }; + (name, result) + } + Err(e) => (file, Err(e)), + }; + match result { + Ok(Outcome::Passed) => report.passed.push(name), + Ok(Outcome::Skipped) => report.skipped.push(name), + Err(e) => report.failed.push((name, e)), + } + } + report +} + +/// Runs one scenario (the parsed JSON of a fixture file). +pub fn run_scenario(file: &Value, host: Option) -> Result { + let scenario: Scenario = + serde_json::from_value(file.clone()).map_err(|e| format!("scenario: {e}"))?; + let mut runner = match scenario.side { + Side::Host => match host { + None => return Ok(Outcome::Skipped), + Some(factory) => { + let host = factory(&scenario.setup) + .map_err(|e| format!("{}: setup: {e}", scenario.name))?; + Runner::new(&scenario, Some(host)) + } + }, + Side::Client => { + if scenario + .setup + .clients + .iter() + .any(|c| c.holds_lease_epoch.is_some()) + { + return Err(format!( + "{}: holds_lease_epoch is a host-side setup", + scenario.name + )); + } + Runner::new(&scenario, None) + } + }; + for (i, step) in scenario.steps.iter().enumerate() { + runner + .step(step) + .map_err(|e| format!("{}: step {i} {step}: {e}", scenario.name))?; + } + runner + .all_consumed() + .map_err(|e| format!("{}: at the end: {e}", scenario.name))?; + Ok(Outcome::Passed) +} + +/// Replaces `${name}` in every string with the captured value: a string that +/// is exactly one placeholder takes the value itself (any JSON type). +pub fn substitute(v: &mut Value, vars: &HashMap) -> Result<(), String> { + match v { + Value::String(s) if s.contains("${") => { + let whole = s + .strip_prefix("${") + .and_then(|r| r.strip_suffix('}')) + .filter(|n| !n.contains(['$', '{', '}'])); + if let Some(name) = whole { + *v = vars + .get(name) + .cloned() + .ok_or(format!("${{{name}}} not captured"))?; + return Ok(()); + } + let mut out = String::new(); + let mut rest = s.as_str(); + while let Some(start) = rest.find("${") { + out.push_str(&rest[..start]); + let end = rest[start..].find('}').ok_or("unterminated ${")? + start; + let name = &rest[start + 2..end]; + match vars.get(name) { + Some(Value::String(t)) => out.push_str(t), + Some(other) => out.push_str(&other.to_string()), + None => return Err(format!("${{{name}}} not captured")), + } + rest = &rest[end + 1..]; + } + out.push_str(rest); + *s = out; + Ok(()) + } + Value::Array(a) => a.iter_mut().try_for_each(|c| substitute(c, vars)), + Value::Object(m) => m.values_mut().try_for_each(|c| substitute(c, vars)), + _ => Ok(()), + } +} + +// ── Runner ──────────────────────────────────────────────────────────────── + +/// The client machines one fixture client drives (client scenarios). +struct ClientMachines { + registry: RegistryClient, + leases: BTreeMap, + /// `list` requests the registry machine asked for (not fixture sends). + lists_requested: u64, + authorization_deadline: Option, + attachment_gen: String, +} + +struct Runner { + now: u64, + vars: HashMap, + side: Side, + client_names: Vec, + sessions: Vec, + host: Option>, + /// Hostβ†’client messages (wire JSON) not yet expected, per client. + queues: HashMap>, + holds: Vec<(String, String)>, + held: Vec<(String, Value)>, + clients: HashMap, +} + +/// Decodes `msg` strictly, as the receiving side would. +fn decode_value(msg: &Value) -> Result { + wire::decode(&serde_json::to_vec(msg).expect("JSON serializes")) +} + +fn take<'a>(step: &'a Map, key: &str) -> Result<&'a Value, String> { + step.get(key).ok_or(format!("missing {key:?}")) +} + +fn as_str<'a>(v: &'a Value, what: &str) -> Result<&'a str, String> { + v.as_str().ok_or(format!("{what} must be a string")) +} + +fn as_u64(v: &Value, what: &str) -> Result { + v.as_u64() + .ok_or(format!("{what} must be a non-negative integer")) +} + +/// Dotted path into a message's `data` (`receipts.0.state`); `#` is an +/// array's length (`receipts.#`). +fn lookup(data: &Value, path: &str) -> Option { + path.split('.').try_fold(data.clone(), |v, part| match v { + Value::Object(mut m) => m.remove(part), + Value::Array(a) if part == "#" => Some(Value::from(a.len())), + Value::Array(mut a) => part + .parse::() + .ok() + .filter(|i| *i < a.len()) + .map(|i| a.swap_remove(i)), + _ => None, + }) +} + +fn same(expected: &Value, got: Option<&Value>, what: &str) -> Result<(), String> { + match got { + Some(g) if g == expected => Ok(()), + _ => Err(format!( + "{what}: expected {expected}, got {}", + got.map_or("nothing".into(), |g| g.to_string()) + )), + } +} + +impl Runner { + fn new(s: &Scenario, host: Option>) -> Self { + let client_names: Vec = s.setup.clients.iter().map(|c| c.name.clone()).collect(); + let clients = if s.side == Side::Client { + s.setup + .clients + .iter() + .map(|c| { + let m = ClientMachines { + registry: RegistryClient::default(), + leases: BTreeMap::new(), + lists_requested: 0, + authorization_deadline: None, + attachment_gen: c.attachment_gen.clone(), + }; + (c.name.clone(), m) + }) + .collect() + } else { + HashMap::new() + }; + Self { + now: 0, + vars: HashMap::new(), + side: s.side, + queues: client_names + .iter() + .map(|n| (n.clone(), VecDeque::new())) + .collect(), + client_names, + sessions: s + .setup + .sessions + .iter() + .map(|x| x.session_id.clone()) + .collect(), + host, + holds: Vec::new(), + held: Vec::new(), + clients, + } + } + + fn client<'a>(&self, v: &'a Value) -> Result<&'a str, String> { + let name = as_str(v, "client")?; + if self.client_names.iter().any(|n| n == name) { + Ok(name) + } else { + Err(format!("unknown client {name:?}")) + } + } + + fn host(&mut self) -> Result<&mut Box, String> { + self.host + .as_mut() + .ok_or("this action needs side \"host\"".to_string()) + } + + fn step(&mut self, raw: &Value) -> Result<(), String> { + let mut step = raw.clone(); + substitute(&mut step, &self.vars)?; + let Value::Object(step) = step else { + return Err("a step must be an object".into()); + }; + // The action key, and the companion keys it allows. + const ACTIONS: &[(&str, &[&str])] = &[ + ("send", &["json"]), + ("expect", &["type", "match", "capture"]), + ("expect_all", &[]), + ("expect_none", &["for_ms"]), + ("advance_ms", &["timers"]), + ("hold", &[]), + ("deliver", &[]), + ("deliver_to_client", &["json"]), + ("disconnect", &[]), + ("resume", &["attachment_gen"]), + ("reconnect", &["attachment_gen"]), + ("local_input", &["bytes"]), + ("crash_worker", &[]), + ("restart_runtime", &[]), + ("fail_next_fsync", &[]), + ("change_trust_gen", &[]), + ("fail_next_pty_enqueue", &[]), + ("fail_next_pty_write_after_partial", &[]), + ("close_session", &[]), + ("turn_complete", &["turn_id"]), + ("hold_pty_writes", &[]), + ("flush_pty_writes", &[]), + ("assert", &[]), + ("assert_client", &[]), + ]; + let found: Vec<_> = ACTIONS + .iter() + .filter(|(a, _)| step.contains_key(*a)) + .collect(); + let [(action, companions)] = found.as_slice() else { + return Err(format!( + "expected exactly one action, found {}", + found.len() + )); + }; + if let Some(k) = step + .keys() + .find(|k| *k != action && !companions.contains(&k.as_str())) + { + return Err(format!("unexpected key {k:?} for {action}")); + } + let arg = &step[*action]; + match (*action, self.side) { + ("advance_ms", _) => { + self.now += as_u64(arg, "advance_ms")?; + match step.get("timers") { + None | Some(Value::Bool(true)) => self.pump_advance(), + // Only the clock moves: the host sees the time with the + // next message or write check, never through a tick. + Some(Value::Bool(false)) => Ok(()), + Some(_) => Err("timers must be a boolean".into()), + } + } + ("send", Side::Host) => { + let client = self.client(arg)?.to_string(); + let msg = decode_value(take(&step, "json")?); + let now = self.now; + self.host()?.receive(&client, msg, now); + self.pump() + } + ("send", Side::Client) => { + let client = self.client(arg)?.to_string(); + self.client_send(&client, take(&step, "json")?) + } + ("deliver_to_client", Side::Client) => { + let client = self.client(arg)?.to_string(); + self.client_deliver(&client, take(&step, "json")?) + } + ("assert_client", Side::Client) => self.assert_client(arg), + ("expect", Side::Host) => { + let client = self.client(arg)?.to_string(); + let empty = Value::Object(Map::new()); + let matches = step.get("match").unwrap_or(&empty); + let ty = step.get("type").map(|t| as_str(t, "type")).transpose()?; + let data = self.expect(&client, ty, matches)?; + if let Some(capture) = step.get("capture") { + let capture = capture.as_object().ok_or("capture must be an object")?; + for (name, path) in capture { + let path = as_str(path, "capture path")?; + let v = lookup(&data, path) + .ok_or(format!("capture {path:?}: not in message"))?; + self.vars.insert(name.clone(), v.clone()); + } + } + Ok(()) + } + ("expect_all", Side::Host) => { + for client in self.client_names.clone() { + self.expect(&client, None, arg) + .map_err(|e| format!("{client}: {e}"))?; + } + Ok(()) + } + ("expect_none", Side::Host) => { + let client = self.client(arg)?.to_string(); + self.now += as_u64(take(&step, "for_ms")?, "for_ms")?; + self.pump_advance()?; + match self.queues[&client].front() { + None => Ok(()), + Some(m) => Err(format!("{client} received {m}")), + } + } + ("hold", Side::Host) => { + let to = self.client(take( + arg.as_object().ok_or("hold must be an object")?, + "to", + )?)?; + let op = as_str(take(arg.as_object().unwrap(), "op")?, "hold.op")?; + if arg.as_object().unwrap().len() != 2 { + return Err("hold takes exactly to and op".into()); + } + self.holds.push((to.to_string(), op.to_string())); + Ok(()) + } + ("deliver", Side::Host) => { + if arg != "held" { + return Err("deliver must be \"held\"".into()); + } + if self.held.is_empty() { + return Err("nothing held".into()); + } + for (client, msg) in std::mem::take(&mut self.held) { + self.queues.get_mut(&client).unwrap().push_back(msg); + } + Ok(()) + } + ("assert", Side::Host) => self.assert_host(arg), + (_, Side::Host) if !matches!(*action, "deliver_to_client" | "assert_client") => { + let event = self.event(action, arg, &step)?; + let now = self.now; + self.host()?.event(&event, now)?; + self.pump() + } + (action, side) => Err(format!("{action} is not supported for side {side:?}")), + } + } + + fn event( + &self, + action: &str, + arg: &Value, + step: &Map, + ) -> Result { + let yes = |v: &Value| { + if v == &Value::Bool(true) { + Ok(()) + } else { + Err(format!("{action} must be true")) + } + }; + let uuid = |v: &Value, what: &str| -> Result { + let s = as_str(v, what)?; + if wire::is_uuid_v4(s) { + Ok(s.to_string()) + } else { + Err(format!("{what}: not a UUID v4")) + } + }; + Ok(match action { + "disconnect" => HostEvent::Disconnect { + client: self.client(arg)?.into(), + }, + "resume" => HostEvent::Resume { + client: self.client(arg)?.into(), + attachment_gen: uuid(take(step, "attachment_gen")?, "attachment_gen")?, + }, + "reconnect" => HostEvent::Reconnect { + client: self.client(arg)?.into(), + attachment_gen: uuid(take(step, "attachment_gen")?, "attachment_gen")?, + }, + "local_input" => { + use base64::{Engine, engine::general_purpose::STANDARD}; + let bytes = STANDARD + .decode(as_str(take(step, "bytes")?, "bytes")?) + .map_err(|e| format!("bytes: {e}"))?; + HostEvent::LocalInput { + session_id: as_str(arg, "local_input")?.into(), + bytes, + } + } + "crash_worker" => HostEvent::CrashWorker { + session_id: as_str(arg, "crash_worker")?.into(), + }, + "restart_runtime" => { + let o = arg.as_object().ok_or("restart_runtime must be an object")?; + if o.len() != 2 { + return Err("restart_runtime takes runtime_gen and runtime_epoch".into()); + } + HostEvent::RestartRuntime { + runtime_gen: uuid(take(o, "runtime_gen")?, "runtime_gen")?, + runtime_epoch: as_u64(take(o, "runtime_epoch")?, "runtime_epoch")?, + } + } + "fail_next_fsync" => yes(arg).map(|_| HostEvent::FailNextFsync)?, + "change_trust_gen" => HostEvent::ChangeTrustGen(as_u64(arg, "change_trust_gen")?), + "fail_next_pty_enqueue" => yes(arg).map(|_| HostEvent::FailNextPtyEnqueue)?, + "fail_next_pty_write_after_partial" => { + yes(arg).map(|_| HostEvent::FailNextPtyWriteAfterPartial)? + } + "close_session" => HostEvent::CloseSession { + session_id: as_str(arg, "close_session")?.into(), + }, + "turn_complete" => HostEvent::TurnComplete { + session_id: as_str(arg, "turn_complete")?.into(), + turn_id: as_str(take(step, "turn_id")?, "turn_id")?.into(), + }, + "hold_pty_writes" => yes(arg).map(|_| HostEvent::HoldPtyWrites)?, + "flush_pty_writes" => yes(arg).map(|_| HostEvent::FlushPtyWrites)?, + other => return Err(format!("{other} is not a host event")), + }) + } + + // ── host side ── + + /// Every host message must have been expected: nothing may be left in a + /// client's queue or held at the end of a scenario. + fn all_consumed(&self) -> Result<(), String> { + if let Some((client, msg)) = self.held.first() { + return Err(format!("held message for {client} never delivered: {msg}")); + } + let mut names: Vec<&String> = self.queues.keys().collect(); + names.sort(); + for client in names { + if let Some(m) = self.queues[client].front() { + return Err(format!( + "{client} has {} unexpected message(s), first: {m}", + self.queues[client].len() + )); + } + } + Ok(()) + } + + fn pump_advance(&mut self) -> Result<(), String> { + let now = self.now; + if let Some(h) = self.host.as_mut() { + h.advance(now); + self.pump()?; + } + Ok(()) + } + + /// Moves the host's messages into the client queues, through the strict + /// decoder, holding the first message of each armed `hold`. + fn pump(&mut self) -> Result<(), String> { + let out = self.host()?.drain_outgoing(); + for (client, env) in out { + let bytes = wire::encode(&env); + let env = wire::decode(&bytes) + .map_err(|e| format!("host sent an invalid message to {client}: {e:?}"))?; + let msg = wire::to_value(&env); + let queue = self + .queues + .get_mut(&client) + .ok_or(format!("host sent to unknown client {client:?}"))?; + let op = msg["data"]["op"].as_str().unwrap_or_default(); + if let Some(i) = self.holds.iter().position(|(c, o)| *c == client && o == op) { + self.holds.remove(i); + self.held.push((client, msg)); + } else { + queue.push_back(msg); + } + } + Ok(()) + } + + /// Takes `client`'s next message of the selected `type` (when given) and + /// `op` (`match.op`, when given) β€” messages of other types/ops stay queued + /// for later expectations β€” and checks every `path: value` against its + /// `data`; returns the `data`. A selected message that doesn't match fails. + fn expect(&mut self, client: &str, ty: Option<&str>, matches: &Value) -> Result { + let matches = matches.as_object().ok_or("match must be an object")?; + let op = matches.get("op"); + let queue = self.queues.get_mut(client).unwrap(); + let selected = queue.iter().position(|m| { + ty.is_none_or(|t| m["type"] == t) && op.is_none_or(|o| &m["data"]["op"] == o) + }); + let msg = selected.and_then(|i| queue.remove(i)).ok_or(format!( + "{client} received no {} message{}; queued: {queue:?}", + ty.unwrap_or("matching"), + op.map_or(String::new(), |o| format!(" with op {o}")) + ))?; + let data = msg.get("data").cloned().unwrap_or(Value::Null); + for (path, expected) in matches { + same(expected, lookup(&data, path).as_ref(), path) + .map_err(|e| format!("{e} in {msg}"))?; + } + Ok(data) + } + + fn assert_host(&mut self, arg: &Value) -> Result<(), String> { + let o = arg.as_object().ok_or("assert must be an object")?; + let host = self.host()?; + for (key, expected) in o { + let got = host.counter(key)?; + match expected { + Value::Object(per) => { + for (sub, exp) in per { + same(exp, got.get(sub), &format!("{key}.{sub}"))?; + } + } + _ => same(expected, Some(&got), key)?, + } + } + Ok(()) + } + + // ── client side ── + + fn client_send(&mut self, client: &str, json: &Value) -> Result<(), String> { + let env = + decode_value(json).map_err(|e| format!("client request does not decode: {e:?}"))?; + let Envelope::Session(msg) = env else { + return Err("client machines only send session messages".into()); + }; + let now = self.now; + let m = self.clients.get_mut(client).unwrap(); + let (kind, binding) = match &msg.op { + Op::List {} => { + m.registry.note_list_sent(); + return Ok(()); + } + Op::Acquire { binding, .. } => (LeaseRequest::Acquire, binding), + Op::Renew { binding } => (LeaseRequest::Renew, binding), + Op::Release { binding } => (LeaseRequest::Release, binding), + other => return Err(format!("client machines don't send {other:?}")), + }; + if binding.attachment_gen != m.attachment_gen { + return Err(format!("{client} sent with another attachment_gen")); + } + let auth = m.authorization_deadline; + let lease = m + .leases + .entry(binding.target.session_id.clone()) + .or_insert_with(|| { + let mut l = + LeaseClient::new(binding.target.clone(), binding.attachment_gen.clone()); + l.set_authorization_deadline(auth); + l + }); + if lease.target() != &binding.target { + return Err("request targets another process of a known session".into()); + } + lease + .sent(&msg.id, kind, now) + .map_err(|e| format!("client refused to send: {e:?}")) + } + + fn client_deliver(&mut self, client: &str, json: &Value) -> Result<(), String> { + let env = decode_value(json).map_err(|e| format!("host message does not decode: {e:?}"))?; + let Envelope::Session(msg) = env else { + return Err("client machines only model session messages".into()); + }; + let now = self.now; + let m = self.clients.get_mut(client).unwrap(); + match &msg.op { + Op::Lease { .. } | Op::LeaseReleased {} | Op::Error { .. } => { + // Routed by `re`; a reply nobody is waiting for is dropped (rule 1). + let re = msg.re.as_deref().unwrap_or_default(); + if let Some(l) = m.leases.values_mut().find(|l| l.is_outstanding(re)) { + l.on_reply(&msg, now); + } + } + Op::LeaseChanged { + session_id, + process_gen, + lease_epoch, + .. + } => { + if let Some(l) = m.leases.get_mut(session_id) { + l.on_lease_changed(session_id, process_gen, *lease_epoch); + } + } + Op::Sessions { .. } | Op::SessionChanged { .. } => { + m.registry.on_message(&msg.op); + if m.registry.take_list_request() { + m.lists_requested += 1; + m.registry.note_list_sent(); + } + } + Op::Welcome { + authorization_ms_left, + .. + } => { + // A new attachment: its registry `seq` starts over. + m.registry.new_attachment(); + let deadline = now.saturating_add(*authorization_ms_left); + m.authorization_deadline = Some(deadline); + m.leases + .values_mut() + .for_each(|l| l.set_authorization_deadline(Some(deadline))); + } + other => return Err(format!("client machines don't model {other:?}")), + } + Ok(()) + } + + fn assert_client(&mut self, arg: &Value) -> Result<(), String> { + let o = arg.as_object().ok_or("assert_client must be an object")?; + let now = self.now; + // Client scenarios have one fixture client. + let [name] = self.client_names.as_slice() else { + return Err("assert_client needs exactly one client".into()); + }; + let m = &self.clients[name]; + let lease = match m.leases.len() { + 0 => None, + 1 => m.leases.values().next(), + _ => match self.sessions.as_slice() { + [only] => m.leases.get(only), + _ => return Err("several leases: the assertion is ambiguous".into()), + }, + }; + for (key, expected) in o { + let got = match key.as_str() { + "controls" => Value::Bool(lease.is_some_and(|l| l.controls(now))), + "input_enabled" => Value::Bool(lease.is_some_and(|l| l.input_enabled(now))), + "deadline_ms" => lease + .and_then(|l| l.deadline_ms(now)) + .map_or(Value::Null, Value::from), + "registry" => { + let mut want: Vec = serde_json::from_value(expected.clone()) + .map_err(|_| "registry must be a list of session ids")?; + want.sort(); + let got = m.registry.session_ids(); + if got != want { + return Err(format!("registry: expected {want:?}, got {got:?}")); + } + continue; + } + "registry_state" => { + let want = expected + .as_object() + .ok_or("registry_state must be an object")?; + for (id, state) in want { + let got = m + .registry + .session(id) + .map(|d| serde_json::to_value(d.state).unwrap()); + same(state, got.as_ref(), &format!("registry_state.{id}"))?; + } + continue; + } + "runtime_epoch" => m.registry.runtime_epoch().map_or(Value::Null, Value::from), + "seq" => m.registry.seq().map_or(Value::Null, Value::from), + "sent_list" => Value::Bool(m.lists_requested > 0), + other => return Err(format!("unknown assert_client key {other:?}")), + }; + same(expected, Some(&got), key)?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn scenario_files() -> Vec<(String, Value)> { + scenario_paths(&fixtures_dir()) + .into_iter() + .map(|p| { + let v: Value = serde_json::from_slice(&std::fs::read(&p).unwrap()).unwrap(); + (p.file_name().unwrap().to_string_lossy().into_owned(), v) + }) + .collect() + } + + fn fake_runtime(setup: &Setup) -> Result, String> { + Ok(Box::new(super::super::fake_runtime::FakeRuntime::new( + setup, + )?)) + } + + #[test] + fn every_fixture_runs_and_passes_with_none_skipped() { + let files = scenario_files(); + let host: Vec = files + .iter() + .filter(|(_, v)| v.get("side").is_none_or(|s| s == "host")) + .map(|(_, v)| v["name"].as_str().unwrap().to_string()) + .collect(); + let client = files.len() - host.len(); + assert!(client >= 2 && host.len() >= 5, "fixtures missing"); + + let report = run_all(&fixtures_dir(), Some(&fake_runtime)); + assert!(report.failed.is_empty(), "failed: {:#?}", report.failed); + eprintln!( + "fixtures passed ({}): {:?}", + report.passed.len(), + report.passed + ); + assert!(report.skipped.is_empty(), "skipped: {:?}", report.skipped); + assert_eq!(report.passed.len(), files.len()); + for name in &host { + assert!(report.passed.contains(name), "host scenario {name} ran"); + } + + // Without a host, exactly the host scenarios are reported as skipped. + let hostless = run_all(&fixtures_dir(), None); + assert_eq!(hostless.skipped, host); + assert_eq!(hostless.passed.len(), client); + } + + /// Each host fixture fails when one of its expectations is changed, so + /// passing means the host produced exactly what the fixture says. + #[test] + fn host_fixtures_fail_on_a_changed_expectation() { + let mutations: &[(&str, &str, Value)] = &[ + ("lease-expires-without-renewal", "lease_epoch", json!(3)), + ("removed-ops-have-no-effect", "code", json!("stale_lease")), + ( + "dispatch-failure-before-any-effect-is-rejected", + "detail", + json!("write interrupted"), + ), + ( + "dispatch-failure-after-partial-write-is-outcome-unknown", + "state", + json!("rejected"), + ), + ( + "duplicate-submit-returns-receipt-and-reuse-is-rejected", + "code", + json!("busy"), + ), + ("family-errors-and-chat-results", "code", json!("busy")), + ( + "receipt-query-is-scoped-to-the-exact-target", + "receipts.#", + json!(2), + ), + ("reconnect-restarts-the-registry-sequence", "seq", json!(3)), + ( + "takeover-moves-control-and-the-old-holder-becomes-a-viewer", + "reason", + json!("granted"), + ), + ( + "exited-session-refuses-work-before-the-lease-check", + "code", + json!("stale_lease"), + ), + ( + "lease-expiry-at-the-deadline-needs-no-timer", + "code", + json!("lease_held"), + ), + ]; + for (name, key, to) in mutations { + let mut f = fixture(name); + assert_eq!( + run_scenario(&f, Some(&fake_runtime)), + Ok(Outcome::Passed), + "{name}" + ); + let step = f["steps"] + .as_array_mut() + .unwrap() + .iter_mut() + .rev() + .find(|s| s.get("match").is_some_and(|m| m.get(*key).is_some())) + .unwrap(); + step["match"][*key] = to.clone(); + assert!( + run_scenario(&f, Some(&fake_runtime)).is_err(), + "{name} {key}" + ); + } + // And on a changed counter. + let mut f = fixture("dispatch-failure-before-any-effect-is-rejected"); + let last = f["steps"].as_array_mut().unwrap().last_mut().unwrap(); + last["assert"]["dispatch_attempts"] = json!(2); + assert!(run_scenario(&f, Some(&fake_runtime)).is_err()); + } + + fn fixture(name: &str) -> Value { + let p = fixtures_dir().join(format!("{name}.json")); + serde_json::from_slice(&std::fs::read(p).unwrap()).unwrap() + } + + /// Sets the first `assert_client` value at `key` equal to `from` to `to`. + fn mutate_assert(v: &mut Value, key: &str, from: &Value, to: Value) { + for step in v["steps"].as_array_mut().unwrap() { + let asserts = step + .get_mut("assert_client") + .and_then(|a| a.as_object_mut()); + if let Some(a) = asserts.filter(|a| a.get(key) == Some(from)) { + a.insert(key.into(), to); + return; + } + } + panic!("no assert_client {key} = {from}"); + } + + #[test] + fn client_fixtures_pass_and_wrong_expectations_fail() { + for name in [ + "late-renew-reply-does-not-resurrect-control", + "runtime-epoch-and-sequence-ordering", + ] { + assert_eq!( + run_scenario(&fixture(name), None), + Ok(Outcome::Passed), + "{name}" + ); + } + let mut f = fixture("late-renew-reply-does-not-resurrect-control"); + mutate_assert(&mut f, "deadline_ms", &json!(32000), json!(32001)); + assert!(run_scenario(&f, None).is_err()); + let mut f = fixture("late-renew-reply-does-not-resurrect-control"); + mutate_assert(&mut f, "controls", &json!(false), json!(true)); + assert!(run_scenario(&f, None).is_err()); + + let mut f = fixture("runtime-epoch-and-sequence-ordering"); + mutate_assert(&mut f, "registry", &json!([]), json!(["ses-aaaaaaaaaaaa"])); + assert!(run_scenario(&f, None).is_err()); + let mut f = fixture("runtime-epoch-and-sequence-ordering"); + mutate_assert(&mut f, "sent_list", &json!(true), json!(false)); + assert!(run_scenario(&f, None).is_err()); + } + + fn client_scenario(steps: Value) -> Value { + let mut f = fixture("runtime-epoch-and-sequence-ordering"); + f["steps"] = steps; + f + } + + #[test] + fn unknown_actions_keys_and_bad_messages_fail() { + for steps in [ + json!([{"frobnicate": 1}]), + json!([{"assert_client": {"colour": "red"}}]), + json!([{"advance_ms": 1, "extra": 2}]), + json!([{"deliver_to_client": "c1", "json": {"type": "session", "data": {"v": 2, "op": "list", "id": "11111111-1111-4111-8111-000000000001"}}}]), + json!([{"deliver_to_client": "nobody", "json": {"type": "session", "data": {"v": 1, "op": "list", "id": "11111111-1111-4111-8111-000000000001"}}}]), + json!([{"expect": "c1", "match": {"op": "lease"}}]), + json!([{"assert_client": {"seq": "${never_captured}"}}]), + ] { + assert!( + run_scenario(&client_scenario(steps.clone()), None).is_err(), + "{steps}" + ); + } + let mut f = fixture("runtime-epoch-and-sequence-ordering"); + f["setup"]["clients"][0]["colour"] = json!("red"); + assert!(run_scenario(&f, None).is_err()); + let mut f = fixture("runtime-epoch-and-sequence-ordering"); + f["setup"]["clients"][0]["upgraded"] = json!(true); + assert!(run_scenario(&f, None).is_err(), "upgraded is gone (r6)"); + // A client scenario can't start holding a lease. + let mut f = fixture("runtime-epoch-and-sequence-ordering"); + f["setup"]["clients"][0]["holds_lease_epoch"] = json!(1); + assert!(run_scenario(&f, None).is_err()); + // Requests must come from the client's own attachment. + let mut f = fixture("late-renew-reply-does-not-resurrect-control"); + f["steps"][0]["json"]["data"]["binding"]["attachment_gen"] = + json!("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"); + assert!(run_scenario(&f, None).is_err()); + } + + #[test] + fn sent_list_counts_only_lists_the_client_machine_requested() { + let list = json!({"type": "session", "data": {"v": 1, "op": "list", + "id": "11111111-1111-4111-8111-000000000001"}}); + let steps = json!([{"send": "c1", "json": list}, {"assert_client": {"sent_list": false}}]); + assert_eq!( + run_scenario(&client_scenario(steps), None), + Ok(Outcome::Passed) + ); + } + + #[test] + fn unexpected_messages_left_at_the_end_fail_a_host_scenario() { + let mut f = fixture("takeover-moves-control-and-the-old-holder-becomes-a-viewer"); + let steps = f["steps"].as_array_mut().unwrap(); + let i = steps + .iter() + .rposition(|s| s.get("expect").is_some()) + .unwrap(); + steps.remove(i); + let err = run_scenario(&f, Some(&fake_runtime)).unwrap_err(); + assert!(err.contains("unexpected message"), "{err}"); + } + + #[test] + fn lookup_paths_index_and_count_arrays() { + let v = json!({"a": [{"b": 1}, {"b": 2}], "c": null}); + assert_eq!(lookup(&v, "a.#"), Some(json!(2))); + assert_eq!(lookup(&v, "a.1.b"), Some(json!(2))); + assert_eq!(lookup(&v, "a.5"), None); + assert_eq!(lookup(&v, "c"), Some(Value::Null)); + assert_eq!(lookup(&v, "d"), None); + } + + #[test] + fn substitution_replaces_captures_anywhere() { + let vars = HashMap::from([("s".to_string(), json!("abc")), ("n".to_string(), json!(5))]); + let mut v = json!({"a": "x-${s}-y", "b": ["${n}"], "c": {"d": "${s}"}}); + substitute(&mut v, &vars).unwrap(); + assert_eq!(v, json!({"a": "x-abc-y", "b": [5], "c": {"d": "abc"}})); + assert!(substitute(&mut json!("${missing}"), &vars).is_err()); + } + + /// A scripted host: answers every session request with `error{busy}`, + /// every other message with a terminal-v1 `exited`, and counts events, to + /// exercise the host-side plumbing. + struct EchoHost { + out: Vec<(String, Envelope)>, + events: u64, + now: u64, + } + + impl HostUnderTest for EchoHost { + fn receive(&mut self, client: &str, msg: Result, _now: u64) { + if let Ok(Envelope::Session(m)) = msg { + self.out.push(( + client.to_string(), + Envelope::Session(wire::SessionMessage { + id: "11111111-1111-4111-8111-00000000beef".into(), + re: Some(m.id), + op: Op::Error { + code: wire::ErrorCode::Busy, + detail: None, + }, + }), + )); + } else if let Ok(Envelope::Family(_)) = msg { + let exited = json!({"v": 1, "op": "exited", "terminal_id": "ses-aaaaaaaaaaaa", + "generation": "22222222-2222-4222-8222-222222222222", "status": "0"}); + self.out.push(( + client.to_string(), + Envelope::Family(wire::FamilyMessage { + family: "terminal".into(), + data: Some(exited), + binding: None, + request: None, + }), + )); + } + } + fn advance(&mut self, now: u64) { + self.now = now; + } + fn event(&mut self, _e: &HostEvent, _now: u64) -> Result<(), String> { + self.events += 1; + Ok(()) + } + fn drain_outgoing(&mut self) -> Vec<(String, Envelope)> { + std::mem::take(&mut self.out) + } + fn counter(&self, key: &str) -> Result { + match key { + "events" => Ok(json!(self.events)), + "now" => Ok(json!(self.now)), + "receipts_visible" => Ok(json!({"c1": 0, "c2": 3})), + _ => Err(format!("unknown counter {key}")), + } + } + } + + #[test] + fn host_scenarios_dispatch_to_a_host_under_test() { + let list = |id: &str| json!({"type": "session", "data": {"v": 1, "op": "list", "id": id}}); + let mut f = fixture("lease-expires-without-renewal"); + f["steps"] = json!([ + {"hold": {"to": "c1", "op": "error"}}, + {"send": "c1", "json": list("11111111-1111-4111-8111-000000000001")}, + {"expect_none": "c1", "for_ms": 10}, + {"send": "c1", "json": list("11111111-1111-4111-8111-000000000002")}, + {"expect": "c1", "match": {"re": "11111111-1111-4111-8111-000000000002"}, "capture": {"code": "code"}}, + {"deliver": "held"}, + {"expect": "c1", "match": {"op": "error", "code": "${code}", "re": "11111111-1111-4111-8111-000000000001"}}, + {"local_input": "ses-aaaaaaaaaaaa", "bytes": "eA=="}, + {"restart_runtime": {"runtime_gen": "99999999-9999-4999-8999-999999999999", "runtime_epoch": 8}}, + {"assert": {"events": 2, "now": 10, "receipts_visible": {"c2": 3}}} + ]); + let factory = |_: &Setup| -> Result, String> { + Ok(Box::new(EchoHost { + out: vec![], + events: 0, + now: 0, + })) + }; + assert_eq!(run_scenario(&f, Some(&factory)), Ok(Outcome::Passed)); + assert_eq!(run_scenario(&f, None), Ok(Outcome::Skipped)); + + for bad in [ + json!({"assert": {"events": 3}}), + json!({"assert": {"nope": 1}}), + json!({"assert": {"after": "labels belong in note"}}), + json!({"expect": "c1", "match": {"op": "lease"}}), + json!({"expect_all": {"op": "error"}}), + ] { + let mut g = f.clone(); + g["steps"].as_array_mut().unwrap().push(bad.clone()); + assert!(run_scenario(&g, Some(&factory)).is_err(), "{bad}"); + } + } + + #[test] + fn expect_selects_the_next_message_of_its_type_and_op() { + let list = json!({"type": "session", "data": {"v": 1, "op": "list", + "id": "11111111-1111-4111-8111-000000000001"}}); + let beat = json!({"type": "heartbeat", "data": {"ts": 1}}); + let factory = |_: &Setup| -> Result, String> { + Ok(Box::new(EchoHost { + out: vec![], + events: 0, + now: 0, + })) + }; + let run = |steps: Value| { + let mut f = fixture("lease-expires-without-renewal"); + f["steps"] = steps; + run_scenario(&f, Some(&factory)) + }; + // Queued: terminal exited, then session error. Each expect picks its own. + let base = json!([{"send": "c1", "json": beat}, {"send": "c1", "json": list}]); + let with = |extra: Value| { + let mut steps = base.clone(); + steps + .as_array_mut() + .unwrap() + .extend(extra.as_array().unwrap().iter().cloned()); + steps + }; + assert_eq!( + run(with(json!([ + {"expect": "c1", "match": {"op": "error", "code": "busy"}}, + {"expect": "c1", "type": "terminal", "match": {"status": "0"}}, + {"expect_none": "c1", "for_ms": 1} + ]))), + Ok(Outcome::Passed) + ); + assert_eq!( + run(with(json!([ + {"expect": "c1", "type": "session", "match": {"code": "busy"}}, + {"expect": "c1", "match": {"op": "exited"}} + ]))), + Ok(Outcome::Passed) + ); + // Without a selector the very next message is taken. + assert_eq!( + run(with(json!([{"expect": "c1", "match": {"op": "exited"}}, + {"expect": "c1", "match": {"code": "busy"}}]))), + Ok(Outcome::Passed) + ); + for bad in [ + // The selected message must match fully. + json!([{"expect": "c1", "match": {"op": "error", "code": "forbidden"}}]), + json!([{"expect": "c1", "type": "terminal", "match": {"op": "error"}}]), + // Without a selector the next message is the terminal one. + json!([{"expect": "c1", "match": {"code": "busy"}}]), + // expect_none sees every type. + json!([{"expect": "c1", "match": {"op": "error"}}, {"expect_none": "c1", "for_ms": 1}]), + json!([{"expect": "c1", "type": 7}]), + ] { + assert!(run(with(bad.clone())).is_err(), "{bad}"); + } + } +} diff --git a/src/session_protocol/host.rs b/src/session_protocol/host.rs new file mode 100644 index 0000000..52b21b1 --- /dev/null +++ b/src/session_protocol/host.rs @@ -0,0 +1,2072 @@ +//! The host authority for atem-session v1 (contract r6, compatibility mode): +//! attachments and hello/welcome (Β§3), the session registry with a +//! per-attachment `seq` (Β§4), control leases with input-epoch fencing (Β§5), +//! submissions with durable receipts (Β§6) and errors without disclosure (Β§7). +//! +//! `SessionHost` is pure: it never touches sockets, PTYs, processes or files. +//! Time is the injected monotonic clock (`advance`, and the `now` passed with +//! every message and write check, so expiry never waits for a timer tick); +//! every input returns the +//! `Effect`s the runtime must perform, in order. Durability goes through the +//! `Journal`: a receipt state is reported only after its record is durable. +//! +//! There are no legacy paths (Β§0): removed message families and ops are +//! answered `unsupported_version` and change nothing, and every remote +//! mutation is a session-v1 request (or a bound terminal-v1/chat-v1 request) +//! carrying `binding` and the current lease, after `welcome`. +//! +//! The PTY write boundary is the runtime's: right before writing a queued +//! remote chunk it calls `admit_write(session, input_epoch, now)`; transfer, +//! expiry, release, revoke, local input and the holder's attachment going +//! away bump the epoch first, so bytes queued under an older lease are +//! dropped, never written. +//! +//! Refused terminal-v1 requests are answered with terminal-v1 `error`, and a +//! chat-v1 `submit` hears about its request through chat-v1 ops +//! (`Effect::Chat`); removed forms get a session `error` without `re`. + +use super::canonical::{self, MAX_SAFE_INTEGER}; +use super::journal::{self, Journal, JournalError, Record, Stored}; +use super::wire::{ + self, Activity, Binding, ClientKind, Controller, DecodeError, Descriptor, Envelope, ErrorCode, + FamilyMessage, LeaseChangeReason, MAX_IN_FLIGHT, MAX_PROMPT_BYTES, Op, Permission, Receipt, + ReceiptKind, ReceiptState, SessionMessage, SessionState, Target, TerminalRequest, +}; +use serde_json::{Value, json}; +use std::collections::{BTreeMap, HashMap, VecDeque}; + +/// A lease lasts this long from its grant or renewal (Β§5). +pub const LEASE_MS: u64 = 15_000; +/// Removed sessions answer `unknown_session` for this long (Β§4). +pub const TOMBSTONE_MS: u64 = 10 * 60 * 1000; +/// Receipts outlive their session's close by this long (Β§6). +pub const RECEIPT_RETENTION_MS: u64 = 7 * 24 * 60 * 60 * 1000; +/// An interrupt or stop that shows no effect within this is `outcome_unknown`. +pub const OBSERVE_MS: u64 = 10_000; +/// Lease epochs are reserved durably in blocks, so an epoch is never issued +/// twice across restarts without an fsync per grant. +const LEASE_EPOCH_BLOCK: u64 = 64; +/// Default per-session receipt bound (`receipt_limit` beyond it). +pub const DEFAULT_MAX_RECEIPTS: usize = 4096; + +pub const DETAIL_APPROVAL: &str = "approval answers are not supported in v1"; +pub const DETAIL_TRUST_CHANGED: &str = "host trust changed"; +pub const DETAIL_SESSION_CLOSED: &str = "session closed"; +pub const DETAIL_NOT_RECORDED: &str = "could not record the request"; +pub const DETAIL_NO_TERMINAL_WRITE: &str = "could not write to the terminal"; +pub const DETAIL_WRITE_INTERRUPTED: &str = "write interrupted"; +pub const DETAIL_AGENT_EXITED: &str = "agent exited"; +pub const DETAIL_NO_INTERRUPT: &str = "no interrupt observed"; +pub const DETAIL_INTERRUPT_NOT_SENT: &str = "could not send the interrupt"; +pub const DETAIL_STOP_FAILED: &str = "stop failed"; +pub const DETAIL_STILL_RUNNING: &str = "process still running"; +pub const DETAIL_UNRECONCILED: &str = "outcome could not be established"; +pub const DETAIL_REPAIR: &str = "host counters exhausted; pair again"; + +// ── Inputs and outputs ──────────────────────────────────────────────────── + +/// The host's own context (Β§2), fixed per runtime start. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HostContext { + pub account_id: String, + pub atem_device_id: String, + pub host_trust_gen: u64, + pub runtime_gen: String, + pub runtime_epoch: u64, +} + +#[derive(Debug, Clone)] +pub struct HostConfig { + pub max_receipts_per_session: usize, + /// Wall clock = this + the monotonic clock (display times only). + pub wall_offset_ms: u64, +} + +impl Default for HostConfig { + fn default() -> Self { + Self { + max_receipts_per_session: DEFAULT_MAX_RECEIPTS, + wall_offset_ms: 0, + } + } +} + +/// An authenticated connection, as the transport established it: identity, +/// kind and permissions never come from message fields. +#[derive(Debug, Clone)] +pub struct Attach { + /// The authenticated client device; a new attachment fences its previous one. + pub client: String, + pub attachment_gen: String, + pub permissions: Vec, + pub client_kind: ClientKind, + /// Remaining authorization of the authenticated link. + pub authorization_ms: u64, +} + +/// An agent session the runtime registers. +#[derive(Debug, Clone)] +pub struct SessionSpec { + pub session_id: String, + pub process_gen: String, + pub provider: String, + pub cwd_label: String, + /// Offers chat-v1 (else terminal-v1 only). + pub chat: bool, + pub rows: u16, + pub cols: u16, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Origin { + /// Typed on the host's own terminal; never fenced. + Local, + /// From the lease holder; written only if `admit_write` still admits `input_epoch` + /// still holds at the write boundary. + Remote { + input_epoch: u64, + attachment: String, + stream_id: String, + sequence: u64, + }, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum Effect { + Send { + attachment: String, + message: Envelope, + }, + /// The attachment is fenced: close its transport. + Close { + attachment: String, + }, + PtyWrite { + session_id: String, + bytes: Vec, + origin: Origin, + }, + Resize { + session_id: String, + rows: u16, + cols: u16, + }, + Stop { + session_id: String, + }, + /// Start/stop streaming terminal frames to a subscribed attachment. + StreamOpened { + attachment: String, + session_id: String, + stream_id: String, + }, + StreamClosed { + attachment: String, + session_id: String, + stream_id: String, + }, + /// An authorized request the runtime serves itself (terminal-v1 `ack`, + /// chat-v1 `open`/`close`); its binding was checked. + Forward { + attachment: String, + session_id: String, + message: FamilyMessage, + }, + /// Perform the request (`executing` is already durable). Report back with + /// `dispatch_report`. + Dispatch { + request_id: String, + target: Target, + kind: ReceiptKind, + payload: Value, + }, + /// After a restart: establish what an `executing` request did. Report + /// back with `dispatch_report` (`Unknown` when it can't be established). + Reconcile { + request_id: String, + target: Target, + kind: ReceiptKind, + }, + /// A chat-v1 reply to the conversation that submitted (or was refused). + /// The runtime's chat stream renders it with its own `generation` and + /// `sequence` head. + Chat { + attachment: String, + conversation_id: String, + session_id: String, + reply: ChatReply, + }, +} + +/// chat-v1 results of a chat `submit`, mapped from its receipt (Β§6: chat-v1 +/// `accepted`/`complete` follow the receipt's `accepted`/`completed`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ChatReply { + Accepted { + request_id: String, + }, + /// The turn ended (`outcome: "done"`); `turn_id` is the provider's turn. + Complete { + request_id: String, + turn_id: Option, + }, + /// `code` is a Β§7 error code for refusals, or `rejected` / + /// `outcome_unknown` for a final receipt; `text` its bounded detail. + Error { + request_id: Option, + code: String, + text: String, + }, +} + +/// What the runtime observed for a dispatched (or reconciled) request. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DispatchReport { + /// The kind's completion (Β§6 table) was observed; for a prompt, + /// `turn_id` is the provider's turn that ended. + Completed { + detail: Option, + turn_id: Option, + }, + /// Failed with proof that nothing reached the agent (e.g. the PTY + /// reservation failed, the signal could not be delivered). + FailedNoEffect, + /// Failed after a possible partial effect (e.g. the writer errored). + FailedAfterPossibleEffect, + /// Reconciliation could not establish the outcome. + Unknown, +} + +// ── State ───────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone)] +struct Lease { + attachment: String, + controller_session: String, + deadline: u64, +} + +#[derive(Debug, Clone)] +struct Session { + spec: SessionSpec, + state: SessionState, + activity: Activity, + activity_since: u64, + created_at: u64, + exit_status: Option, + lease: Option, + lease_epoch: u64, + epochs_reserved: u64, + input_epoch: u64, + /// Accepted prompts waiting for the executing one (one paste at a time). + queued: VecDeque, +} + +#[derive(Debug, Clone)] +struct Attachment { + client: String, + controller_session: Option, + permissions: Vec, + client_kind: ClientKind, + authorization_deadline: u64, + /// Registry messages sent in this epoch, once subscribed by `list`. + registry_seq: Option, + /// session_id β†’ terminal-v1 stream id. + streams: HashMap, +} + +impl Attachment { + fn can(&self, p: Permission) -> bool { + self.permissions.contains(&p) + } + fn welcomed(&self) -> bool { + self.controller_session.is_some() + } +} + +#[derive(Debug, Clone)] +struct Entry { + stored: Stored, + /// The live attachment that submitted it (gets its state changes). + submitted_by: Option, + /// Interrupt/stop: no effect observed by then β†’ `outcome_unknown`. + observe_deadline: Option, + /// A final state is waiting for a durable write (retried on `advance`). + finishing: bool, + /// Submitted through chat-v1 in this conversation: replies use chat ops. + chat: Option, + /// The provider turn a completed prompt ended (chat `complete`). + turn_id: Option, +} + +pub type IdSource = Box String + Send>; + +/// UUID v4 ids for host messages, controller sessions and streams. +pub fn random_ids() -> IdSource { + Box::new(|| uuid::Uuid::new_v4().to_string()) +} + +pub struct SessionHost { + ctx: HostContext, + config: HostConfig, + journal: J, + ids: IdSource, + now: u64, + /// Own counters exhausted: stop admitting (Β§1). + halted: bool, + sessions: BTreeMap, + /// Removed session ids β†’ removal time. + tombstones: HashMap, + /// session_id β†’ when it closed or was removed (receipt retention). + ended: HashMap, + attachments: BTreeMap, + receipts: BTreeMap, + lease_floors: HashMap, + /// Admission order of the next new request. + next_order: u64, + /// Final transitions whose durable write failed, retried on `advance`. + retry: Vec<(String, ReceiptState, Option)>, + out: Vec, +} + +type Checked = Result; + +impl SessionHost { + /// A host over `journal`, reloading what it holds (apply the crash rules + /// with `recover` once the surviving sessions are registered). + pub fn new( + ctx: HostContext, + config: HostConfig, + journal: J, + ids: IdSource, + ) -> Result { + let mut host = Self { + ctx, + config, + journal, + ids, + now: 0, + halted: false, + sessions: BTreeMap::new(), + tombstones: HashMap::new(), + ended: HashMap::new(), + attachments: BTreeMap::new(), + receipts: BTreeMap::new(), + lease_floors: HashMap::new(), + next_order: 0, + retry: Vec::new(), + out: Vec::new(), + }; + host.reload()?; + Ok(host) + } + + pub fn context(&self) -> &HostContext { + &self.ctx + } + + pub fn journal(&self) -> &J { + &self.journal + } + + pub fn journal_mut(&mut self) -> &mut J { + &mut self.journal + } + + pub fn now(&self) -> u64 { + self.now + } + + fn take(&mut self) -> Vec { + std::mem::take(&mut self.out) + } + + fn wall(&self) -> u64 { + self.config + .wall_offset_ms + .saturating_add(self.now) + .min(MAX_SAFE_INTEGER) + } + + fn new_id(&mut self) -> String { + (self.ids)() + } + + // ── Runtime inputs ── + + /// Registers a new agent session. An id is never reused: not while live + /// or tombstoned, not while receipts name it, and not once lease epochs + /// were reserved for it. + pub fn add_session(&mut self, spec: SessionSpec) -> Result, String> { + let sid = &spec.session_id; + let known = self.lease_floors.contains_key(sid) + || self + .receipts + .values() + .any(|e| &e.stored.target.session_id == sid); + if known { + return Err("session_id already used".into()); + } + self.register(spec) + } + + /// Re-registers a worker that survived a host restart (before + /// `recover`): its receipts and lease floor carry over. + pub fn restore_session(&mut self, spec: SessionSpec) -> Result, String> { + self.register(spec) + } + + fn register(&mut self, spec: SessionSpec) -> Result, String> { + if !wire::is_session_id(&spec.session_id) || !wire::is_uuid_v4(&spec.process_gen) { + return Err("invalid session_id or process_gen".into()); + } + let sid = &spec.session_id; + if self.sessions.contains_key(sid) || self.tombstones.contains_key(sid) { + return Err("session_id already used".into()); + } + let floor = self + .lease_floors + .get(&spec.session_id) + .copied() + .unwrap_or(0); + let sid = spec.session_id.clone(); + let wall = self.wall(); + self.sessions.insert( + sid.clone(), + Session { + spec, + state: SessionState::Running, + activity: Activity::Unknown, + activity_since: wall, + created_at: wall, + exit_status: None, + lease: None, + lease_epoch: floor, + epochs_reserved: floor, + input_epoch: 0, + queued: VecDeque::new(), + }, + ); + self.registry_changed(&sid); + Ok(self.take()) + } + + /// A new authenticated attachment. Any earlier attachment of the same + /// client is fenced first (closed; anything still arriving on it is + /// refused). It holds no lease and must `hello` before anything else. + pub fn connect(&mut self, a: Attach) -> Result, String> { + if !wire::is_uuid_v4(&a.attachment_gen) || self.attachments.contains_key(&a.attachment_gen) + { + return Err("attachment_gen must be a new UUID v4".into()); + } + let old: Vec = self + .attachments + .iter() + .filter(|(_, x)| x.client == a.client) + .map(|(id, _)| id.clone()) + .collect(); + for id in old { + self.drop_attachment(&id); + self.out.push(Effect::Close { attachment: id }); + } + let deadline = self + .now + .saturating_add(a.authorization_ms) + .min(MAX_SAFE_INTEGER); + self.attachments.insert( + a.attachment_gen, + Attachment { + client: a.client, + controller_session: None, + permissions: a.permissions, + client_kind: a.client_kind, + authorization_deadline: deadline, + registry_seq: None, + streams: HashMap::new(), + }, + ); + Ok(self.take()) + } + + /// The transport closed. A held lease is not released: it expires. + pub fn disconnect(&mut self, attachment: &str) -> Vec { + self.drop_attachment(attachment); + self.take() + } + + /// Fences or forgets an attachment. A lease it holds is not released (it + /// expires), but its queued remote bytes are dropped at the write boundary. + fn drop_attachment(&mut self, id: &str) { + for s in self.sessions.values_mut() { + if s.lease.as_ref().is_some_and(|l| l.attachment == id) { + s.input_epoch += 1; + } + } + if let Some(a) = self.attachments.remove(id) { + for (session_id, stream_id) in a.streams { + self.out.push(Effect::StreamClosed { + attachment: id.to_string(), + session_id, + stream_id, + }); + } + } + for e in self.receipts.values_mut() { + if e.submitted_by.as_deref() == Some(id) { + e.submitted_by = None; + } + } + } + + /// The monotonic clock moved to `now`: leases expire (`now >= deadline`), + /// observation windows close, pending durable writes are retried and + /// tombstones/receipts past retention are dropped. + pub fn advance(&mut self, now: u64) -> Vec { + self.tick(now); + self.take() + } + + fn tick(&mut self, now: u64) { + self.now = self.now.max(now); + let now = self.now; + let expired: Vec = self + .sessions + .iter() + .filter(|(_, s)| s.lease.as_ref().is_some_and(|l| now >= l.deadline)) + .map(|(id, _)| id.clone()) + .collect(); + for sid in expired { + self.end_lease(&sid, LeaseChangeReason::Expired, None); + } + for (rid, state, detail) in std::mem::take(&mut self.retry) { + self.persist_final(&rid, state, detail); + } + let unobserved: Vec<(String, ReceiptKind)> = self + .receipts + .values() + .filter(|e| !e.finishing && e.stored.state == ReceiptState::Executing) + .filter(|e| e.observe_deadline.is_some_and(|d| now >= d)) + .map(|e| (e.stored.request_id.clone(), e.stored.kind)) + .collect(); + for (rid, kind) in unobserved { + let detail = match kind { + ReceiptKind::Stop => DETAIL_STILL_RUNNING, + _ => DETAIL_NO_INTERRUPT, + }; + self.finish(&rid, ReceiptState::OutcomeUnknown, Some(detail)); + } + self.tombstones + .retain(|_, t| now < t.saturating_add(TOMBSTONE_MS)); + let past: Vec = self + .ended + .iter() + .filter(|(_, t)| now >= t.saturating_add(RECEIPT_RETENTION_MS)) + .map(|(sid, _)| sid.clone()) + .collect(); + for sid in past { + self.evict_receipts(&sid); + } + } + + fn evict_receipts(&mut self, sid: &str) { + let ids: Vec = self + .receipts + .values() + .filter(|e| e.stored.target.session_id == sid) + .map(|e| e.stored.request_id.clone()) + .collect(); + for rid in ids { + let record = Record::Evicted { + request_id: rid.clone(), + }; + if self.journal.append(record).is_err() { + return; // retried on the next advance + } + self.receipts.remove(&rid); + } + self.ended.remove(sid); + } + + /// Local terminal input on the host: it bumps the input epoch and revokes + /// any remote lease before the bytes are written (Β§5 local input wins). + pub fn local_input(&mut self, session_id: &str, bytes: Vec) -> Vec { + let Some(s) = self.sessions.get_mut(session_id) else { + return Vec::new(); + }; + s.input_epoch += 1; + self.end_lease(session_id, LeaseChangeReason::LocalInput, None); + self.out.push(Effect::PtyWrite { + session_id: session_id.to_string(), + bytes, + origin: Origin::Local, + }); + self.take() + } + + /// Called by the PTY writer right before writing a queued remote chunk, + /// with the monotonic time of the write: the chunk's input epoch must + /// still be current and its lease unexpired (`now >= deadline` is + /// expired, whether or not `advance` ran). + pub fn admit_write(&self, session_id: &str, input_epoch: u64, now: u64) -> bool { + self.sessions.get(session_id).is_some_and(|s| { + s.input_epoch == input_epoch && s.lease.as_ref().is_some_and(|l| now < l.deadline) + }) + } + + /// The outcome of a `Dispatch` or `Reconcile`. + pub fn dispatch_report(&mut self, request_id: &str, report: DispatchReport) -> Vec { + let Some(e) = self.receipts.get(request_id) else { + return Vec::new(); + }; + if e.finishing || e.stored.state != ReceiptState::Executing { + return Vec::new(); + } + let kind = e.stored.kind; + let (state, detail) = match report { + DispatchReport::Completed { detail, turn_id } => { + if let Some(e) = self.receipts.get_mut(request_id) { + e.turn_id = turn_id; + } + let detail = detail.map(|d| bounded_detail(&d)); + (ReceiptState::Completed, detail) + } + DispatchReport::FailedNoEffect => ( + ReceiptState::Rejected, + Some( + match kind { + ReceiptKind::Prompt | ReceiptKind::Approval => DETAIL_NO_TERMINAL_WRITE, + ReceiptKind::Interrupt => DETAIL_INTERRUPT_NOT_SENT, + ReceiptKind::Stop => DETAIL_STOP_FAILED, + } + .to_string(), + ), + ), + DispatchReport::FailedAfterPossibleEffect => ( + ReceiptState::OutcomeUnknown, + Some( + match kind { + ReceiptKind::Prompt | ReceiptKind::Approval => DETAIL_WRITE_INTERRUPTED, + ReceiptKind::Interrupt => DETAIL_NO_INTERRUPT, + ReceiptKind::Stop => DETAIL_STILL_RUNNING, + } + .to_string(), + ), + ), + DispatchReport::Unknown => ( + ReceiptState::OutcomeUnknown, + Some(DETAIL_UNRECONCILED.to_string()), + ), + }; + self.finish(request_id, state, detail.as_deref()); + self.take() + } + + /// The agent process ended: its lease ends, a `stop` completes, other + /// executing work is `outcome_unknown` ("agent exited") and accepted work + /// is rejected. + pub fn process_exited(&mut self, session_id: &str, exit_status: Option) -> Vec { + let wall = self.wall(); + let Some(s) = self.sessions.get_mut(session_id) else { + return Vec::new(); + }; + if s.state != SessionState::Running { + return Vec::new(); + } + s.state = SessionState::Exited; + s.activity = Activity::Unknown; + s.exit_status = exit_status; + s.activity_since = wall; + self.end_lease(session_id, LeaseChangeReason::ProcessEnded, None); + self.process_gone(session_id); + self.registry_changed(session_id); + self.take() + } + + /// The session's agent process is gone: a `stop` completes, other + /// executing work is `outcome_unknown` ("agent exited"), accepted work is + /// rejected. + fn process_gone(&mut self, session_id: &str) { + let process_gen = self.sessions[session_id].spec.process_gen.clone(); + let work: Vec<(String, ReceiptState, ReceiptKind)> = self + .receipts + .values() + .filter(|e| { + e.stored.target.session_id == session_id + && e.stored.target.process_gen == process_gen + }) + .map(|e| (e.stored.request_id.clone(), e.stored.state, e.stored.kind)) + .collect(); + for (rid, state, kind) in work { + match (state, kind) { + (ReceiptState::Executing, ReceiptKind::Stop) => { + self.finish(&rid, ReceiptState::Completed, None) + } + (ReceiptState::Executing, _) => self.finish( + &rid, + ReceiptState::OutcomeUnknown, + Some(DETAIL_AGENT_EXITED), + ), + (ReceiptState::Accepted, _) => { + self.finish(&rid, ReceiptState::Rejected, Some(DETAIL_SESSION_CLOSED)) + } + _ => {} + } + } + } + + /// The session no longer accepts submissions; its receipts are kept for + /// `RECEIPT_RETENTION_MS`. With `process_gone` its executing work is + /// finished as after `process_exited`; otherwise it may still complete. + pub fn close_session(&mut self, session_id: &str, process_gone: bool) -> Vec { + let Some(s) = self.sessions.get_mut(session_id) else { + return Vec::new(); + }; + s.state = SessionState::Closed; + self.ended.entry(session_id.to_string()).or_insert(self.now); + self.end_lease(session_id, LeaseChangeReason::Revoked, None); + self.reject_accepted(|t| t.session_id == session_id, DETAIL_SESSION_CLOSED); + if process_gone { + self.process_gone(session_id); + } + self.registry_changed(session_id); + self.take() + } + + /// The provider's activity changed (from agent-native events; `Unknown` + /// when it can't be detected). `since` is wall-clock ms. + pub fn set_activity( + &mut self, + session_id: &str, + activity: Activity, + since: u64, + ) -> Result, String> { + let s = self + .sessions + .get_mut(session_id) + .filter(|s| s.state == SessionState::Running) + .ok_or("no running session")?; + s.activity = activity; + s.activity_since = since.min(MAX_SAFE_INTEGER); + self.registry_changed(session_id); + Ok(self.take()) + } + + /// The agent process was replaced (restart/resume): a new `process_gen`. + /// Leases end, the old process's executing work is `outcome_unknown` + /// ("agent exited") and its accepted work rejected; terminal streams of + /// the old process close. + pub fn replace_process( + &mut self, + session_id: &str, + process_gen: &str, + ) -> Result, String> { + let s = self.sessions.get(session_id).ok_or("unknown session")?; + if s.state == SessionState::Closed { + // Closed is final: its receipts are on their retention clock. + return Err("session is closed".into()); + } + if !wire::is_uuid_v4(process_gen) || s.spec.process_gen == process_gen { + return Err("process_gen must be a new UUID v4".into()); + } + let used = self.receipts.values().any(|e| { + e.stored.target.session_id == session_id && e.stored.target.process_gen == process_gen + }); + if used { + return Err("process_gen already used".into()); + } + self.end_lease(session_id, LeaseChangeReason::ProcessEnded, None); + self.reject_accepted(|t| t.session_id == session_id, DETAIL_SESSION_CLOSED); + self.process_gone(session_id); + let wall = self.wall(); + let s = self.sessions.get_mut(session_id).unwrap(); + s.spec.process_gen = process_gen.to_string(); + s.state = SessionState::Running; + s.exit_status = None; + s.activity = Activity::Unknown; + s.activity_since = wall; + s.input_epoch += 1; + s.queued.clear(); + self.close_streams(session_id); + self.registry_changed(session_id); + Ok(self.take()) + } + + /// Removes the session: subscribers get its last descriptor with + /// `state:"removed"`; late requests get `unknown_session`. + pub fn remove_session(&mut self, session_id: &str) -> Vec { + if !self.sessions.contains_key(session_id) { + return Vec::new(); + } + self.end_lease(session_id, LeaseChangeReason::Revoked, None); + self.reject_accepted(|t| t.session_id == session_id, DETAIL_SESSION_CLOSED); + self.process_gone(session_id); + self.sessions.get_mut(session_id).unwrap().state = SessionState::Removed; + self.registry_changed(session_id); + self.sessions.remove(session_id); + self.tombstones.insert(session_id.to_string(), self.now); + self.ended.entry(session_id.to_string()).or_insert(self.now); + self.close_streams(session_id); + self.take() + } + + /// Ends every terminal stream on the session. + fn close_streams(&mut self, session_id: &str) { + for (att, a) in self.attachments.iter_mut() { + if let Some(stream_id) = a.streams.remove(session_id) { + self.out.push(Effect::StreamClosed { + attachment: att.clone(), + session_id: session_id.to_string(), + stream_id, + }); + } + } + } + + /// The host trust generation moved on (unpair/rebind): every lease is + /// revoked and work not yet dispatched is rejected ("host trust changed"). + pub fn change_trust_gen(&mut self, host_trust_gen: u64) -> Result, String> { + if host_trust_gen <= self.ctx.host_trust_gen || host_trust_gen > MAX_SAFE_INTEGER { + return Err("host_trust_gen must increase".into()); + } + self.ctx.host_trust_gen = host_trust_gen; + let ids: Vec = self.sessions.keys().cloned().collect(); + for sid in &ids { + self.end_lease(sid, LeaseChangeReason::Revoked, None); + } + self.reject_accepted(|t| t.host_trust_gen != host_trust_gen, DETAIL_TRUST_CHANGED); + Ok(self.take()) + } + + fn reject_accepted(&mut self, which: impl Fn(&Target) -> bool, detail: &str) { + let ids: Vec = self + .receipts + .values() + .filter(|e| e.stored.state == ReceiptState::Accepted && which(&e.stored.target)) + .map(|e| e.stored.request_id.clone()) + .collect(); + for rid in ids { + self.finish(&rid, ReceiptState::Rejected, Some(detail)); + } + } + + /// A broker restart (new `runtime_gen`, higher `runtime_epoch`): every + /// attachment and lease is gone, receipts are reloaded from the journal + /// and the Β§6 crash rules applied (`recover`). Sessions (workers) stay. + pub fn restart( + &mut self, + runtime_gen: &str, + runtime_epoch: u64, + ) -> Result, String> { + if !wire::is_uuid_v4(runtime_gen) || runtime_gen == self.ctx.runtime_gen { + return Err("runtime_gen must be a new UUID v4".into()); + } + if runtime_epoch <= self.ctx.runtime_epoch || runtime_epoch > MAX_SAFE_INTEGER { + return Err("runtime_epoch must increase".into()); + } + // Read the journal before changing anything: a failed restart leaves + // the host as it was. + let replayed = self + .journal + .load() + .and_then(journal::replay) + .map_err(|e| e.0)?; + self.ctx.runtime_gen = runtime_gen.to_string(); + self.ctx.runtime_epoch = runtime_epoch; + self.attachments.clear(); + self.retry.clear(); + self.out.clear(); + self.install(replayed); + for (sid, s) in self.sessions.iter_mut() { + let floor = self.lease_floors.get(sid).copied().unwrap_or(0); + s.lease = None; + s.lease_epoch = floor; + s.epochs_reserved = floor; + s.input_epoch += 1; + s.queued.clear(); + } + Ok(self.recover()) + } + + fn reload(&mut self) -> Result<(), JournalError> { + let replayed = journal::replay(self.journal.load()?)?; + self.install(replayed); + Ok(()) + } + + fn install(&mut self, replayed: journal::Replayed) { + self.receipts = replayed + .receipts + .into_iter() + .map(|(rid, stored)| { + let entry = Entry { + stored, + submitted_by: None, + observe_deadline: None, + finishing: false, + chat: None, + turn_id: None, + }; + (rid, entry) + }) + .collect(); + self.lease_floors = replayed.lease_epochs; + self.next_order = self + .receipts + .values() + .map(|e| e.stored.order + 1) + .max() + .unwrap_or(0); + } + + /// Final receipt writes waiting to be retried. + pub fn pending_retries(&self) -> usize { + self.retry.len() + } + + /// Β§6 crash rules: `executing` work is never dispatched again, only + /// reconciled; `accepted` work is dispatched once under its original + /// target if the trust generation is unchanged, else rejected. + pub fn recover(&mut self) -> Vec { + let mut pending: Vec<&Entry> = self.receipts.values().collect(); + pending.sort_by_key(|e| e.stored.order); + let pending: Vec<(String, ReceiptState, ReceiptKind, Target)> = pending + .into_iter() + .map(|e| { + let s = &e.stored; + (s.request_id.clone(), s.state, s.kind, s.target.clone()) + }) + .collect(); + for (rid, state, kind, target) in pending { + match state { + ReceiptState::Executing => self.out.push(Effect::Reconcile { + request_id: rid, + target, + kind, + }), + ReceiptState::Accepted => self.schedule(&rid), + _ => {} + } + } + self.take() + } + + // ── Queries ── + + /// The attachment holding `session_id`'s lease. + pub fn lease_holder(&self, session_id: &str) -> Option<&str> { + self.sessions + .get(session_id)? + .lease + .as_ref() + .map(|l| l.attachment.as_str()) + } + + /// The session's current descriptor (as `list` would send it). + pub fn descriptor_of(&self, session_id: &str) -> Option { + self.sessions.get(session_id).map(|s| self.descriptor(s)) + } + + pub fn lease_epoch(&self, session_id: &str) -> Option { + self.sessions.get(session_id).map(|s| s.lease_epoch) + } + + pub fn input_epoch(&self, session_id: &str) -> Option { + self.sessions.get(session_id).map(|s| s.input_epoch) + } + + pub fn receipt(&self, request_id: &str) -> Option { + self.receipts.get(request_id).map(|e| to_receipt(&e.stored)) + } + + /// Receipts `attachment` could read with `receipt_query` (view + /// permission, current trust generation, a visible session). + pub fn receipts_visible(&self, attachment: &str) -> usize { + let Some(a) = self.attachments.get(attachment) else { + return 0; + }; + if !a.welcomed() || !a.can(Permission::View) { + return 0; + } + self.receipts + .values() + .filter(|e| { + let t = &e.stored.target; + self.same_host(t) && self.sessions.contains_key(&t.session_id) + }) + .count() + } + + fn same_host(&self, t: &Target) -> bool { + t.account_id == self.ctx.account_id + && t.atem_device_id == self.ctx.atem_device_id + && t.host_trust_gen == self.ctx.host_trust_gen + } + + // ── Messages ── + + /// One message from `attachment`, arriving at monotonic `now`, strictly + /// decoded here. + pub fn receive(&mut self, attachment: &str, bytes: &[u8], now: u64) -> Vec { + self.receive_decoded(attachment, wire::decode(bytes), now) + } + + /// One message from `attachment`, arriving at monotonic `now`, already + /// decoded with `wire::decode`. Timers due by `now` fire first, so expiry + /// (`now >= deadline`) never depends on `advance` having run. + pub fn receive_decoded( + &mut self, + attachment: &str, + msg: Result, + now: u64, + ) -> Vec { + self.tick(now); + let re = match &msg { + Ok(Envelope::Session(m)) => Some(m.id.clone()), + Err(e) => e.id.clone(), + Ok(Envelope::Family(_)) => None, + }; + if !self.attachments.contains_key(attachment) { + // Unknown or fenced: refused, and nothing else happens. + self.error(attachment, re, ErrorCode::Unauthorized, None); + return self.take(); + } + match msg { + // Removed ops (Β§0) and invalid representations: no effect. + Err(e) => self.error(attachment, e.id, e.code, None), + Ok(Envelope::Session(m)) => self.on_session(attachment, m), + Ok(Envelope::Family(f)) => self.on_family(attachment, f), + } + self.take() + } + + fn send(&mut self, attachment: &str, re: Option, op: Op) { + let message = Envelope::Session(SessionMessage { + id: self.new_id(), + re, + op, + }); + self.out.push(Effect::Send { + attachment: attachment.to_string(), + message, + }); + } + + fn error( + &mut self, + attachment: &str, + re: Option, + code: ErrorCode, + detail: Option<&str>, + ) { + let detail = detail.map(str::to_string); + self.send(attachment, re, Op::Error { code, detail }); + } + + /// Checks shared by every request after `welcome`. + fn admit_request(&self, att: &str) -> Checked { + let a = &self.attachments[att]; + if !a.welcomed() { + // Any session op before welcome is a removed form (Β§0). + return Err(ErrorCode::UnsupportedVersion); + } + if self.halted { + return Err(ErrorCode::Busy); + } + if self.now >= a.authorization_deadline { + return Err(ErrorCode::Paused); + } + Ok(()) + } + + fn on_session(&mut self, att: &str, m: SessionMessage) { + let re = Some(m.id.clone()); + let result = match m.op { + Op::Hello { resume } => self.hello(att, re.clone(), resume.is_some()), + Op::ResumeProof { .. } => Err(ErrorCode::Unauthorized), + op => self.admit_request(att).and_then(|()| match op { + Op::List {} => self.list(att, re.clone()), + Op::Acquire { binding, takeover } => { + self.acquire(att, re.clone(), &binding, takeover) + } + Op::Renew { binding } => self.renew(att, re.clone(), &binding), + Op::Release { binding } => self.release(att, re.clone(), &binding), + Op::Submit { + binding, + request_id, + kind, + payload, + } => self.submit_session(att, re.clone(), &binding, &request_id, kind, payload), + Op::ReceiptQuery { + binding, + request_ids, + } => self.receipt_query(att, re.clone(), &binding, &request_ids), + // Host-to-client ops are not requests. + _ => Err(ErrorCode::UnsupportedVersion), + }), + }; + if let Err(code) = result { + let detail = (code == ErrorCode::Busy && self.halted).then_some(DETAIL_REPAIR); + self.error(att, re, code, detail); + } + } + + /// Compatibility mode (Β§3): no resume; every `hello` on a fresh attachment + /// gets a new `controller_session` and holds no lease. + fn hello(&mut self, att: &str, re: Option, resume: bool) -> Checked { + if resume { + return Err(ErrorCode::Unauthorized); + } + if self.attachments[att].welcomed() { + // One welcome per attachment; a new one needs a new attachment. + return Err(ErrorCode::Unauthorized); + } + if self.now >= self.attachments[att].authorization_deadline { + return Err(ErrorCode::Paused); + } + let controller_session = self.new_id(); + let (now, wall) = (self.now, self.wall()); + let a = self.attachments.get_mut(att).unwrap(); + a.controller_session = Some(controller_session.clone()); + let op = Op::Welcome { + account_id: self.ctx.account_id.clone(), + atem_device_id: self.ctx.atem_device_id.clone(), + host_trust_gen: self.ctx.host_trust_gen, + runtime_gen: self.ctx.runtime_gen.clone(), + controller_session, + attachment_gen: att.to_string(), + client_kind: a.client_kind, + permissions: a.permissions.clone(), + authorization_ms_left: a.authorization_deadline - now, + server_time: wall, + }; + self.send(att, re, op); + Ok(()) + } + + /// The binding names this host, this attachment and a live session the + /// attachment may see. Mismatches never reveal current values. + fn check_binding(&self, att: &str, b: &Binding) -> Checked<&Session> { + let a = &self.attachments[att]; + if b.attachment_gen != att || !self.same_host(&b.target) { + return Err(ErrorCode::StaleTarget); + } + let s = self + .sessions + .get(&b.target.session_id) + .filter(|_| a.can(Permission::View)) + .ok_or(ErrorCode::UnknownSession)?; + if s.spec.process_gen != b.target.process_gen { + return Err(ErrorCode::StaleTarget); + } + Ok(s) + } + + /// A mutation needs `control`, the current lease held by this attachment + /// at exactly `lease_epoch`, and a running session. + fn check_mutation(&self, att: &str, s: &Session, lease_epoch: Option) -> Checked { + if !self.attachments[att].can(Permission::Control) { + return Err(ErrorCode::Forbidden); + } + // A session that no longer accepts work says so, whatever the lease. + if s.state != SessionState::Running { + return Err(ErrorCode::SessionClosed); + } + if !self.lease_live(att, s, lease_epoch) { + return Err(ErrorCode::StaleLease); + } + Ok(()) + } + + /// `att` holds `s`'s lease at exactly `lease_epoch` and it hasn't expired. + fn lease_live(&self, att: &str, s: &Session, lease_epoch: Option) -> bool { + s.lease.as_ref().is_some_and(|l| { + l.attachment == att && Some(s.lease_epoch) == lease_epoch && self.now < l.deadline + }) + } + + // ── Registry (Β§4) ── + + fn descriptor(&self, s: &Session) -> Descriptor { + let mut capabilities = vec!["terminal-v1".to_string()]; + if s.spec.chat { + capabilities.push("chat-v1".into()); + } + Descriptor { + session_id: s.spec.session_id.clone(), + process_gen: s.spec.process_gen.clone(), + provider: s.spec.provider.clone(), + cwd_label: s.spec.cwd_label.clone(), + state: s.state, + activity: s.activity, + activity_since: s.activity_since, + controller: s.lease.as_ref().map(|l| Controller { + controller_session: l.controller_session.clone(), + lease_epoch: s.lease_epoch, + }), + capabilities, + created_at: s.created_at, + } + } + + fn next_seq(&mut self, att: &str) -> Option { + let a = self.attachments.get_mut(att)?; + let next = a.registry_seq.unwrap_or(0).checked_add(1); + match next.filter(|n| *n <= MAX_SAFE_INTEGER) { + Some(n) => { + a.registry_seq = Some(n); + Some(n) + } + None => { + self.halted = true; + None + } + } + } + + /// `list` subscribes the attachment: a snapshot now, changes after it. + fn list(&mut self, att: &str, re: Option) -> Checked { + if !self.attachments[att].can(Permission::View) { + return Err(ErrorCode::Forbidden); + } + let sessions: Vec = + self.sessions.values().map(|s| self.descriptor(s)).collect(); + let seq = self.next_seq(att).ok_or(ErrorCode::Busy)?; + let op = Op::Sessions { + runtime_gen: self.ctx.runtime_gen.clone(), + runtime_epoch: self.ctx.runtime_epoch, + seq, + sessions, + }; + self.send(att, re, op); + Ok(()) + } + + /// Sends the session's complete new descriptor to every subscribed viewer. + fn registry_changed(&mut self, sid: &str) { + let Some(descriptor) = self.sessions.get(sid).map(|s| self.descriptor(s)) else { + return; + }; + let subscribers: Vec = self + .attachments + .iter() + .filter(|(_, a)| a.registry_seq.is_some() && a.can(Permission::View)) + .map(|(id, _)| id.clone()) + .collect(); + for att in subscribers { + let Some(seq) = self.next_seq(&att) else { + continue; + }; + let op = Op::SessionChanged { + runtime_gen: self.ctx.runtime_gen.clone(), + runtime_epoch: self.ctx.runtime_epoch, + seq, + descriptor: descriptor.clone(), + }; + self.send(&att, None, op); + } + } + + // ── Leases (Β§5) ── + + fn lease_reply(&mut self, att: &str, re: Option, b: &Binding) { + let s = &self.sessions[&b.target.session_id]; + let l = s.lease.as_ref().expect("replying for a held lease"); + let binding = Binding { + lease_epoch: Some(s.lease_epoch), + ..b.clone() + }; + let ms_left = l.deadline.saturating_sub(self.now); + self.send(att, re, Op::Lease { binding, ms_left }); + } + + fn lease_changed(&mut self, sid: &str, reason: LeaseChangeReason, except: Option<&str>) { + let s = &self.sessions[sid]; + let op = Op::LeaseChanged { + session_id: sid.to_string(), + process_gen: s.spec.process_gen.clone(), + controller: s.lease.as_ref().map(|l| Controller { + controller_session: l.controller_session.clone(), + lease_epoch: s.lease_epoch, + }), + lease_epoch: s.lease_epoch, + reason, + }; + let viewers: Vec = self + .attachments + .iter() + .filter(|(id, a)| { + a.welcomed() && a.can(Permission::View) && Some(id.as_str()) != except + }) + .map(|(id, _)| id.clone()) + .collect(); + for att in viewers { + self.send(&att, None, op.clone()); + } + } + + /// Ends the held lease (new epoch, input epoch bumped first so queued + /// remote bytes are dropped) and tells viewers. + fn end_lease(&mut self, sid: &str, reason: LeaseChangeReason, except: Option<&str>) { + let Some(s) = self.sessions.get_mut(sid) else { + return; + }; + if s.lease.take().is_none() { + return; + } + s.input_epoch += 1; + // Within the reservation: a grant reserves its own revocation epoch. + s.lease_epoch += 1; + self.lease_changed(sid, reason, except); + self.registry_changed(sid); + } + + /// Makes sure epochs up to `need` are durably reserved. + fn reserve_epochs(&mut self, sid: &str, need: u64) -> bool { + if need <= self.sessions[sid].epochs_reserved { + return true; + } + let Some(reserved) = need + .checked_add(LEASE_EPOCH_BLOCK) + .filter(|r| *r <= MAX_SAFE_INTEGER) + else { + self.halted = true; + return false; + }; + let record = Record::LeaseEpochs { + session_id: sid.to_string(), + reserved, + }; + if self.journal.append(record).is_err() { + return false; + } + self.sessions.get_mut(sid).unwrap().epochs_reserved = reserved; + self.lease_floors.insert(sid.to_string(), reserved); + true + } + + fn acquire(&mut self, att: &str, re: Option, b: &Binding, takeover: bool) -> Checked { + let s = self.check_binding(att, b)?; + if !self.attachments[att].can(Permission::Control) { + return Err(ErrorCode::Forbidden); + } + if s.state != SessionState::Running { + return Err(ErrorCode::SessionClosed); + } + let controller = self.attachments[att].controller_session.clone().unwrap(); + let held = s + .lease + .as_ref() + .map(|l| (l.attachment.clone(), l.controller_session.clone())); + // The grant's epoch and the epoch that will end it. + let need = s.lease_epoch + 2; + let previous = match held { + // Repeat acquire: the current lease, unchanged. + Some((holder, _)) if holder == att => { + self.lease_reply(att, re, b); + return Ok(()); + } + Some((_, c)) if c == controller => return Err(ErrorCode::StaleLease), + Some(_) if !takeover => return Err(ErrorCode::LeaseHeld), + Some((holder, _)) => Some(holder), + None => None, + }; + let sid = b.target.session_id.clone(); + if !self.reserve_epochs(&sid, need) { + return Err(ErrorCode::Busy); + } + let deadline = self + .now + .saturating_add(LEASE_MS) + .min(self.attachments[att].authorization_deadline); + let s = self.sessions.get_mut(&sid).unwrap(); + if previous.is_some() { + s.input_epoch += 1; + } + s.lease_epoch += 1; + s.lease = Some(Lease { + attachment: att.to_string(), + controller_session: controller, + deadline, + }); + self.lease_reply(att, re, b); + let reason = if previous.is_some() { + LeaseChangeReason::Takeover + } else { + LeaseChangeReason::Granted + }; + self.lease_changed(&sid, reason, Some(att)); + self.registry_changed(&sid); + Ok(()) + } + + fn holds(&self, att: &str, b: &Binding) -> Checked<()> { + let s = self.check_binding(att, b)?; + if self.lease_live(att, s, b.lease_epoch) { + Ok(()) + } else { + Err(ErrorCode::StaleLease) + } + } + + /// Renews only the live lease at its epoch; never re-acquires. + fn renew(&mut self, att: &str, re: Option, b: &Binding) -> Checked { + self.holds(att, b)?; + if !self.attachments[att].can(Permission::Control) { + return Err(ErrorCode::Forbidden); + } + let deadline = self + .now + .saturating_add(LEASE_MS) + .min(self.attachments[att].authorization_deadline); + let s = self.sessions.get_mut(&b.target.session_id).unwrap(); + s.lease.as_mut().unwrap().deadline = deadline; + self.lease_reply(att, re, b); + Ok(()) + } + + fn release(&mut self, att: &str, re: Option, b: &Binding) -> Checked { + self.holds(att, b)?; + self.end_lease(&b.target.session_id, LeaseChangeReason::Released, Some(att)); + self.send(att, re, Op::LeaseReleased {}); + Ok(()) + } + + // ── Submissions and receipts (Β§6) ── + + fn submit_session( + &mut self, + att: &str, + re: Option, + b: &Binding, + request_id: &str, + kind: ReceiptKind, + payload: Value, + ) -> Checked { + if kind == ReceiptKind::Approval && !self.receipts.contains_key(request_id) { + self.check_binding(att, b)?; + self.error(att, re, ErrorCode::Forbidden, Some(DETAIL_APPROVAL)); + return Ok(()); + } + self.submit(att, Reply::Session(re), b, request_id, kind, payload) + } + + /// Β§6 admission. `reply` is how the submitter hears about it: session + /// `receipt`s, or chat-v1 ops for a chat `submit`. + fn submit( + &mut self, + att: &str, + reply: Reply, + b: &Binding, + request_id: &str, + kind: ReceiptKind, + payload: Value, + ) -> Checked { + self.check_binding(att, b)?; + let digest = + canonical::payload_digest(&payload).map_err(|_| ErrorCode::UnsupportedVersion)?; + if let Some(e) = self.receipts.get(request_id) { + let st = &e.stored; + if st.target != b.target || st.kind != kind || st.payload_digest != digest { + return Err(ErrorCode::RequestIdReused); + } + // Same request: the recorded state, no lease, no re-execution. + self.report_state(att, reply, request_id); + return Ok(()); + } + let sid = &b.target.session_id; + self.check_mutation(att, &self.sessions[sid], b.lease_epoch)?; + let held = self + .receipts + .values() + .filter(|e| &e.stored.target.session_id == sid) + .count(); + if held >= self.config.max_receipts_per_session { + return Err(ErrorCode::ReceiptLimit); + } + let in_flight = self + .receipts + .values() + .filter(|e| e.submitted_by.as_deref() == Some(att)) + .filter(|e| !journal::is_final(e.stored.state)) + .count(); + if in_flight >= MAX_IN_FLIGHT { + return Err(ErrorCode::Busy); + } + let updated_at = self.wall(); + let record = Record::Accepted { + request_id: request_id.to_string(), + target: b.target.clone(), + kind, + payload_digest: digest.clone(), + payload: payload.clone(), + updated_at, + }; + // `accepted` is durable before the reply; if it can't be, nothing + // was recorded and the client may retry the same request. + self.journal.append(record).map_err(|_| ErrorCode::Busy)?; + let order = self.next_order; + self.next_order += 1; + let stored = Stored { + request_id: request_id.to_string(), + target: b.target.clone(), + kind, + payload_digest: digest, + payload: Some(payload), + state: ReceiptState::Accepted, + detail: None, + updated_at, + order, + }; + let chat = match &reply { + Reply::Chat { conversation_id } => Some(conversation_id.clone()), + Reply::Session(_) => None, + }; + self.receipts.insert( + request_id.to_string(), + Entry { + stored, + submitted_by: Some(att.to_string()), + observe_deadline: None, + finishing: false, + chat, + turn_id: None, + }, + ); + self.report_state(att, reply, request_id); + self.schedule(request_id); + Ok(()) + } + + /// Tells `att` the request's current state on its channel. + fn report_state(&mut self, att: &str, reply: Reply, rid: &str) { + let e = &self.receipts[rid]; + match reply { + Reply::Session(re) => { + let receipt = to_receipt(&e.stored); + self.send(att, re, Op::Receipt(receipt)); + } + Reply::Chat { conversation_id } => { + let reply = chat_reply(e); + self.out.push(Effect::Chat { + attachment: att.to_string(), + conversation_id, + session_id: e.stored.target.session_id.clone(), + reply, + }); + } + } + } + + /// Prompts paste one at a time per session (queued while another prompt + /// executes); interrupt and stop go at once. + fn schedule(&mut self, rid: &str) { + let e = &self.receipts[rid]; + if e.stored.kind != ReceiptKind::Prompt { + self.start(rid); + return; + } + let sid = e.stored.target.session_id.clone(); + match self.sessions.get_mut(&sid) { + Some(s) => { + s.queued.push_back(rid.to_string()); + self.pump_queue(&sid); + } + None => self.finish(rid, ReceiptState::Rejected, Some(DETAIL_SESSION_CLOSED)), + } + } + + fn prompt_executing(&self, sid: &str) -> bool { + self.receipts.values().any(|e| { + e.stored.target.session_id == sid + && e.stored.kind == ReceiptKind::Prompt + && e.stored.state == ReceiptState::Executing + }) + } + + fn pump_queue(&mut self, sid: &str) { + while !self.prompt_executing(sid) { + let Some(rid) = self + .sessions + .get_mut(sid) + .and_then(|s| s.queued.pop_front()) + else { + return; + }; + if self + .receipts + .get(&rid) + .is_some_and(|e| e.stored.state == ReceiptState::Accepted) + { + self.start(&rid); + } + } + } + + /// Why an accepted request may not be dispatched now, if it may not. + fn dispatch_blocker(&self, t: &Target) -> Option<&'static str> { + if t.host_trust_gen != self.ctx.host_trust_gen { + return Some(DETAIL_TRUST_CHANGED); + } + let running = self.sessions.get(&t.session_id).is_some_and(|s| { + s.spec.process_gen == t.process_gen && s.state == SessionState::Running + }); + (!running).then_some(DETAIL_SESSION_CLOSED) + } + + /// accepted β†’ executing (durable before the first side effect) β†’ Dispatch. + fn start(&mut self, rid: &str) { + let target = self.receipts[rid].stored.target.clone(); + if let Some(detail) = self.dispatch_blocker(&target) { + self.finish(rid, ReceiptState::Rejected, Some(detail)); + return; + } + // The payload is dropped once the request leaves `accepted`. + let payload = self.receipts[rid] + .stored + .payload + .clone() + .unwrap_or(Value::Null); + if !self.transition(rid, ReceiptState::Executing, None) { + self.finish(rid, ReceiptState::Rejected, Some(DETAIL_NOT_RECORDED)); + return; + } + let now = self.now; + let e = self.receipts.get_mut(rid).unwrap(); + if matches!(e.stored.kind, ReceiptKind::Interrupt | ReceiptKind::Stop) { + e.observe_deadline = Some(now.saturating_add(OBSERVE_MS)); + } + let s = &e.stored; + let effect = Effect::Dispatch { + request_id: rid.to_string(), + target: s.target.clone(), + kind: s.kind, + payload, + }; + self.out.push(effect); + } + + /// Persists and applies one transition, then tells the submitter. + /// `false` (nothing changed) when the durable write fails. + fn transition(&mut self, rid: &str, to: ReceiptState, detail: Option<&str>) -> bool { + let Some(e) = self.receipts.get(rid) else { + return false; + }; + let from = e.stored.state; + if !journal::valid_transition(from, to) { + return false; + } + let updated_at = self.wall(); + let detail = detail.map(str::to_string); + let record = Record::Transition { + request_id: rid.to_string(), + state: to, + detail: detail.clone(), + updated_at, + }; + if self.journal.append(record).is_err() { + return false; + } + let e = self.receipts.get_mut(rid).unwrap(); + e.stored.state = to; + e.stored.detail = detail; + e.stored.updated_at = updated_at; + e.finishing = false; + e.stored.payload = None; + let kind = e.stored.kind; + let sid = e.stored.target.session_id.clone(); + let reply = match e.chat.clone() { + // chat-v1 has no `executing`: the turn just runs. + Some(_) if to == ReceiptState::Executing => None, + Some(conversation_id) => Some(Reply::Chat { conversation_id }), + None => Some(Reply::Session(None)), + }; + if let (Some(att), Some(reply)) = (e.submitted_by.clone(), reply) { + self.report_state(&att, reply, rid); + } + if from == ReceiptState::Executing && kind == ReceiptKind::Prompt { + self.pump_queue(&sid); + } + true + } + + /// A transition to a final state: reported once durable; if the write + /// fails the receipt stays where it was and the write is retried. + fn finish(&mut self, rid: &str, to: ReceiptState, detail: Option<&str>) { + self.persist_final(rid, to, detail.map(str::to_string)); + } + + fn persist_final(&mut self, rid: &str, to: ReceiptState, detail: Option) { + let valid = self + .receipts + .get(rid) + .is_some_and(|e| journal::valid_transition(e.stored.state, to)); + if !valid { + return; + } + if !self.transition(rid, to, detail.as_deref()) { + if let Some(e) = self.receipts.get_mut(rid) { + e.finishing = true; + } + // One pending final write per request: the latest wins. + self.retry.retain(|(r, _, _)| r != rid); + self.retry.push((rid.to_string(), to, detail)); + } + } + + /// Only receipts bound to exactly the binding's target; ids of other + /// targets are simply absent. + fn receipt_query( + &mut self, + att: &str, + re: Option, + b: &Binding, + request_ids: &[String], + ) -> Checked { + let a = &self.attachments[att]; + if b.attachment_gen != att || !self.same_host(&b.target) { + return Err(ErrorCode::StaleTarget); + } + // Receipts stay addressable by session_id/process_gen (Β§3), so an + // earlier process_gen of a visible session may be queried. + if !a.can(Permission::View) || !self.sessions.contains_key(&b.target.session_id) { + return Err(ErrorCode::UnknownSession); + } + let mut seen = Vec::new(); + let receipts: Vec = request_ids + .iter() + .filter(|rid| { + let first = !seen.contains(rid); + seen.push(*rid); + first + }) + .filter_map(|rid| self.receipts.get(rid)) + .filter(|e| e.stored.target == b.target) + .map(|e| to_receipt(&e.stored)) + .collect(); + self.send(att, re, Op::Receipts { receipts }); + Ok(()) + } + + // ── terminal-v1 and chat-v1 (Β§2) ── + + fn on_family(&mut self, att: &str, f: FamilyMessage) { + let result = match f.family.as_str() { + "terminal" | "agentChat" => self.admit_request(att).and_then(|()| { + if f.family == "terminal" { + self.on_terminal(att, &f) + } else { + self.on_chat(att, &f) + } + }), + // Not session traffic: nothing to authorize here. + _ => Ok(()), + }; + if let Err(code) = result { + self.family_error(att, &f, code); + } + } + + /// A refused terminal-v1 or chat-v1 request is answered in its own family + /// (removed forms never get here: they fail decoding and get a session + /// `error`). Nothing about other targets is disclosed. + fn family_error(&mut self, att: &str, f: &FamilyMessage, code: ErrorCode) { + let field = |name: &str| { + f.data + .as_ref() + .and_then(|d| d.get(name)) + .and_then(Value::as_str) + .map(str::to_string) + }; + match f.family.as_str() { + "terminal" => { + let mut data = json!({ + "v": 1, + "op": "error", + "terminal_id": field("terminal_id"), + "code": code_name(code), + "message": code_text(code), + }); + if let Some(stream_id) = field("stream_id") { + data["stream_id"] = Value::String(stream_id); + } + self.out.push(Effect::Send { + attachment: att.to_string(), + message: Envelope::Family(FamilyMessage { + family: "terminal".into(), + data: Some(data), + binding: None, + request: None, + }), + }); + } + "agentChat" => { + let submit = field("op").as_deref() == Some("submit"); + self.out.push(Effect::Chat { + attachment: att.to_string(), + conversation_id: field("conversationId").unwrap_or_default(), + session_id: field("agentId").unwrap_or_default(), + reply: ChatReply::Error { + request_id: field("requestId").filter(|_| submit), + code: code_name(code), + text: code_text(code).to_string(), + }, + }); + } + _ => self.error(att, None, code, None), + } + } + + fn on_terminal(&mut self, att: &str, f: &FamilyMessage) -> Checked { + // Host-only terminal ops (frame, attached, …) are not requests. + let (Some(req), Some(b)) = (&f.request, &f.binding) else { + return Err(ErrorCode::UnsupportedVersion); + }; + let s = self.check_binding(att, b)?.clone(); + f.check_binding_ids()?; + let sid = s.spec.session_id.clone(); + let stream = |host: &Self, stream_id: &str| -> Checked { + let current = host.attachments[att].streams.get(&sid); + if current.map(String::as_str) == Some(stream_id) { + Ok(()) + } else { + Err(ErrorCode::StaleTarget) + } + }; + match req { + TerminalRequest::Subscribe { .. } => { + let stream_id = self.new_id(); + let info = crate::terminal_share::SessionInfo { + terminal_id: sid.clone(), + generation: s.spec.process_gen.clone(), + provider: s.spec.provider.clone(), + rows: s.spec.rows, + cols: s.spec.cols, + exit_status: s.exit_status.clone(), + rendering: "vt100-screen-diff".into(), + width_profile: "atem-unicode-width-0.1.14".into(), + }; + let attached = crate::terminal_share::Envelope::from( + crate::terminal_share::Message::Attached { + session: info, + stream_id: stream_id.clone(), + }, + ); + let replaced = self + .attachments + .get_mut(att) + .unwrap() + .streams + .insert(sid.clone(), stream_id.clone()); + if let Some(old) = replaced { + self.out.push(Effect::StreamClosed { + attachment: att.to_string(), + session_id: sid.clone(), + stream_id: old, + }); + } + let data = serde_json::to_value(attached).expect("terminal message serializes"); + self.out.push(Effect::Send { + attachment: att.to_string(), + message: Envelope::Family(FamilyMessage { + family: "terminal".into(), + data: Some(data), + binding: None, + request: None, + }), + }); + self.out.push(Effect::StreamOpened { + attachment: att.to_string(), + session_id: sid, + stream_id, + }); + } + TerminalRequest::Unsubscribe { stream_id, .. } => { + stream(self, stream_id)?; + self.attachments.get_mut(att).unwrap().streams.remove(&sid); + self.out.push(Effect::StreamClosed { + attachment: att.to_string(), + session_id: sid, + stream_id: stream_id.clone(), + }); + } + TerminalRequest::Ack { stream_id, .. } => { + stream(self, stream_id)?; + self.out.push(Effect::Forward { + attachment: att.to_string(), + session_id: sid, + message: f.clone(), + }); + } + TerminalRequest::Input { + stream_id, + sequence, + bytes, + .. + } => { + stream(self, stream_id)?; + self.check_mutation(att, &s, b.lease_epoch)?; + use base64::{Engine, engine::general_purpose::STANDARD}; + let bytes = STANDARD + .decode(bytes) + .map_err(|_| ErrorCode::UnsupportedVersion)?; + let origin = Origin::Remote { + input_epoch: s.input_epoch, + attachment: att.to_string(), + stream_id: stream_id.clone(), + sequence: *sequence, + }; + self.out.push(Effect::PtyWrite { + session_id: sid, + bytes, + origin, + }); + } + TerminalRequest::Resize { + stream_id, + rows, + cols, + .. + } => { + stream(self, stream_id)?; + self.check_mutation(att, &s, b.lease_epoch)?; + let s = self.sessions.get_mut(&sid).unwrap(); + s.spec.rows = *rows; + s.spec.cols = *cols; + self.out.push(Effect::Resize { + session_id: sid, + rows: *rows, + cols: *cols, + }); + } + TerminalRequest::Stop { stream_id, .. } => { + stream(self, stream_id)?; + self.check_mutation(att, &s, b.lease_epoch)?; + self.out.push(Effect::Stop { session_id: sid }); + } + } + Ok(()) + } + + /// chat-v1 `submit` is the Β§6 `submit` (`kind:"prompt"`, `request_id = + /// requestId`, `payload = {"text": text}`); `open`/`close` are reads the + /// runtime serves. + fn on_chat(&mut self, att: &str, f: &FamilyMessage) -> Checked { + let Some(b) = &f.binding else { + // Host-only chat ops are not requests. + return Err(ErrorCode::UnsupportedVersion); + }; + let s = self.check_binding(att, b)?; + f.check_binding_ids()?; + if !s.spec.chat { + return Err(ErrorCode::UnknownSession); + } + let sid = s.spec.session_id.clone(); + let data = f.data.as_ref().ok_or(ErrorCode::UnsupportedVersion)?; + match data["op"].as_str() { + Some("submit") => { + let text = data["text"].as_str().ok_or(ErrorCode::UnsupportedVersion)?; + let request_id = data["requestId"] + .as_str() + .ok_or(ErrorCode::UnsupportedVersion)?; + if text.len() > MAX_PROMPT_BYTES { + return Err(ErrorCode::UnsupportedVersion); + } + let payload = json!({ "text": text }); + let request_id = request_id.to_string(); + let reply = Reply::Chat { + conversation_id: data["conversationId"] + .as_str() + .unwrap_or_default() + .to_string(), + }; + self.submit(att, reply, b, &request_id, ReceiptKind::Prompt, payload) + } + Some("open" | "close") => { + self.out.push(Effect::Forward { + attachment: att.to_string(), + session_id: sid, + message: f.clone(), + }); + Ok(()) + } + _ => Err(ErrorCode::UnsupportedVersion), + } + } +} + +/// How a submitter hears about its request. +#[derive(Debug, Clone)] +enum Reply { + /// session-v1 `receipt` (with `re` for the direct reply). + Session(Option), + /// chat-v1 `accepted` / `complete` / `error`. + Chat { conversation_id: String }, +} + +/// A chat submit's current state in chat-v1 terms. +fn chat_reply(e: &Entry) -> ChatReply { + let s = &e.stored; + let request_id = s.request_id.clone(); + let error = |code: &str| ChatReply::Error { + request_id: Some(request_id.clone()), + code: code.to_string(), + text: s.detail.clone().unwrap_or_default(), + }; + match s.state { + ReceiptState::Accepted | ReceiptState::Executing => ChatReply::Accepted { + request_id: request_id.clone(), + }, + ReceiptState::Completed => ChatReply::Complete { + request_id: request_id.clone(), + turn_id: e.turn_id.clone(), + }, + ReceiptState::Rejected => error("rejected"), + ReceiptState::OutcomeUnknown => error("outcome_unknown"), + } +} + +/// The wire name of a Β§7 code. +pub fn code_name(code: ErrorCode) -> String { + match serde_json::to_value(code) { + Ok(Value::String(s)) => s, + _ => unreachable!("error codes serialize as strings"), + } +} + +/// Fixed human text per code: never paths, OS errors or other targets' ids. +pub fn code_text(code: ErrorCode) -> &'static str { + match code { + ErrorCode::UnsupportedVersion => "unsupported request", + ErrorCode::Unauthorized => "not authorized", + ErrorCode::Forbidden => "not permitted", + ErrorCode::StaleTarget => "the session changed; list again", + ErrorCode::UnknownSession => "unknown session", + ErrorCode::LeaseHeld => "another controller holds control", + ErrorCode::StaleLease => "control lease is not current", + ErrorCode::RequestIdReused => "request id already used", + ErrorCode::ReceiptLimit => "too many requests for this session", + ErrorCode::SessionClosed => "the session no longer accepts requests", + ErrorCode::Busy => "busy; try again", + ErrorCode::Paused => "authorization expired; control paused", + } +} + +fn to_receipt(s: &Stored) -> Receipt { + Receipt { + request_id: s.request_id.clone(), + target: s.target.clone(), + kind: s.kind, + state: s.state, + detail: s.detail.clone(), + updated_at: s.updated_at, + } +} + +/// `detail` is bounded (Β§1); cut at a char boundary. +fn bounded_detail(d: &str) -> String { + let mut end = d.len().min(wire::MAX_DETAIL_BYTES); + while !d.is_char_boundary(end) { + end -= 1; + } + d[..end].to_string() +} diff --git a/src/session_protocol/host_tests.rs b/src/session_protocol/host_tests.rs new file mode 100644 index 0000000..95df60c --- /dev/null +++ b/src/session_protocol/host_tests.rs @@ -0,0 +1,1605 @@ +//! Host authority rules beyond the shared fixtures (contract Β§8 first set): +//! hello/welcome, takeover, repeat acquire, stale epochs and the write +//! boundary, trust change before dispatch, closed sessions, receipt-query +//! scoping and limits, fenced attachments, runtime restarts, the registry's +//! per-attachment `seq`, durable-write failures and the crash rules. + +use super::client::{RegistryClient, RegistryOutcome}; +use super::fake_runtime::{AUTHORIZATION_MS, FakeRuntime}; +use super::host::*; +use super::journal::{MemJournal, Record}; +use super::wire::{self, Envelope, Op, Permission, ReceiptKind, ReceiptState, Target}; +use serde_json::{Value, json}; +use std::sync::atomic::{AtomicU64, Ordering}; + +const S1: &str = "ses-aaaaaaaaaaaa"; +const S2: &str = "ses-bbbbbbbbbbbb"; +const P1: &str = "22222222-2222-4222-8222-222222222222"; +const P2: &str = "22222222-2222-4222-8222-bbbbbbbbbbbb"; +const C1: &str = "33333333-3333-4333-8333-333333333333"; +const C2: &str = "66666666-6666-4666-8666-666666666666"; +const V: &str = "77777777-7777-4777-8777-777777777777"; +const C1B: &str = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; +const GEN2: &str = "99999999-9999-4999-8999-999999999999"; + +fn id() -> String { + static N: AtomicU64 = AtomicU64::new(1); + format!( + "11111111-1111-4111-8111-{:012}", + N.fetch_add(1, Ordering::Relaxed) + ) +} + +fn rid(n: u64) -> String { + format!("44444444-4444-4444-8444-{n:012}") +} + +fn setup_with(config: HostConfig) -> FakeRuntime { + let client = |name: &str, att_gen: &str, perms: Value| json!({"name": name, "attachment_gen": att_gen, "permissions": perms, "client_kind": "native"}); + FakeRuntime::from_json( + json!({ + "host": {"account_id": "account-a", "atem_device_id": "host-a", "host_trust_gen": 1, + "runtime_gen": "55555555-5555-4555-8555-555555555555", "runtime_epoch": 7}, + "sessions": [{"session_id": S1, "process_gen": P1, "chat": true}, + {"session_id": S2, "process_gen": P2}], + "clients": [client("c1", C1, json!(["view", "control"])), + client("c2", C2, json!(["view", "control"])), + client("v", V, json!(["view"]))] + }), + config, + ) +} + +fn setup() -> FakeRuntime { + setup_with(HostConfig::default()) +} + +fn pg(session: &str) -> &'static str { + if session == S1 { P1 } else { P2 } +} + +fn target_with(session: &str, trust: u64) -> Value { + json!({"account_id": "account-a", "atem_device_id": "host-a", "host_trust_gen": trust, + "session_id": session, "process_gen": pg(session)}) +} + +fn binding(rt: &FakeRuntime, client: &str, session: &str, epoch: Option) -> Value { + let mut b = json!({"target": target_with(session, 1), "attachment_gen": rt.attachment(client)}); + if let Some(e) = epoch { + b["lease_epoch"] = json!(e); + } + b +} + +fn session(op: &str, fields: Value) -> Value { + let mut data = json!({"v": 1, "op": op, "id": id()}); + data.as_object_mut() + .unwrap() + .extend(fields.as_object().unwrap().clone()); + json!({"type": "session", "data": data}) +} + +fn acquire(rt: &FakeRuntime, client: &str, s: &str, takeover: bool) -> Value { + session( + "acquire", + json!({"binding": binding(rt, client, s, None), "takeover": takeover}), + ) +} + +fn renew(rt: &FakeRuntime, client: &str, s: &str, epoch: u64) -> Value { + session( + "renew", + json!({"binding": binding(rt, client, s, Some(epoch))}), + ) +} + +fn submit(rt: &FakeRuntime, client: &str, s: &str, epoch: u64, n: u64, kind: &str) -> Value { + let payload = if kind == "prompt" { + json!({"text": "run the tests"}) + } else { + json!({}) + }; + session( + "submit", + json!({"binding": binding(rt, client, s, Some(epoch)), "request_id": rid(n), + "kind": kind, "payload": payload}), + ) +} + +fn query(rt: &FakeRuntime, client: &str, target: Value, ids: Vec) -> Value { + session( + "receipt_query", + json!({"binding": {"target": target, "attachment_gen": rt.attachment(client)}, + "request_ids": ids}), + ) +} + +fn terminal(rt: &FakeRuntime, client: &str, op: &str, epoch: Option, extra: Value) -> Value { + let mut data = json!({"v": 1, "op": op, "terminal_id": S1, "generation": P1, + "binding": binding(rt, client, S1, epoch)}); + data.as_object_mut() + .unwrap() + .extend(extra.as_object().unwrap().clone()); + json!({"type": "terminal", "data": data}) +} + +/// Takes `client`'s queued messages and returns the `data` of each. +fn got(rt: &mut FakeRuntime, client: &str) -> Vec { + rt.take(client) + .into_iter() + .map(|m| m["data"].clone()) + .collect() +} + +fn ops(msgs: &[Value]) -> Vec { + msgs.iter() + .map(|m| m["op"].as_str().unwrap_or("?").to_string()) + .collect() +} + +fn only(rt: &mut FakeRuntime, client: &str) -> Value { + let msgs = got(rt, client); + assert_eq!(msgs.len(), 1, "{client} got {msgs:#?}"); + msgs[0].clone() +} + +fn assert_error(msg: &Value, code: &str) { + assert_eq!(msg["op"], "error", "{msg}"); + assert_eq!(msg["code"], code, "{msg}"); +} + +/// Acquires `s` for `client` and returns the lease epoch. +fn hold(rt: &mut FakeRuntime, client: &str, s: &str) -> u64 { + let m = acquire(rt, client, s, false); + rt.send(client, &m); + let reply = got(rt, client) + .into_iter() + .find(|m| m["op"] == "lease") + .expect("lease"); + reply["binding"]["lease_epoch"].as_u64().unwrap() +} + +fn subscribe(rt: &mut FakeRuntime, client: &str) -> String { + let m = terminal(rt, client, "subscribe", None, json!({})); + rt.send(client, &m); + let attached = got(rt, client) + .into_iter() + .find(|m| m["op"] == "attached") + .expect("attached"); + attached["stream_id"].as_str().unwrap().to_string() +} + +fn input(rt: &FakeRuntime, client: &str, stream: &str, epoch: u64) -> Value { + terminal( + rt, + client, + "input", + Some(epoch), + json!({"stream_id": stream, "sequence": 0, "bytes": "aGkN"}), + ) +} + +fn receipts_of(rt: &mut FakeRuntime, client: &str) -> Vec<(String, String)> { + got(rt, client) + .into_iter() + .filter(|m| m["op"] == "receipt") + .map(|m| { + ( + m["state"].as_str().unwrap().to_string(), + m["detail"].as_str().unwrap_or_default().to_string(), + ) + }) + .collect() +} + +fn st(state: &str, detail: &str) -> (String, String) { + (state.to_string(), detail.to_string()) +} + +// ── hello / welcome (Β§3) ── + +#[test] +fn hello_gets_a_welcome_and_nothing_works_before_it() { + let mut rt = setup(); + let fresh = "88888888-8888-4888-8888-888888888888"; + rt.connect_only("n", fresh, vec![Permission::View], AUTHORIZATION_MS); + rt.send("n", &session("list", json!({}))); + assert_error(&only(&mut rt, "n"), "unsupported_version"); + let m = terminal(&rt, "n", "subscribe", None, json!({})); + rt.send("n", &m); + assert_error(&only(&mut rt, "n"), "unsupported_version"); + + // Compatibility mode: resume and resume_proof are refused, never trusted. + let resume = session( + "hello", + json!({"resume": {"controller_session": "12345678-1234-4234-8234-123456789012"}}), + ); + rt.send("n", &resume); + assert_error(&only(&mut rt, "n"), "unauthorized"); + rt.send("n", &session("resume_proof", json!({"proof": "x"}))); + assert_error(&only(&mut rt, "n"), "unauthorized"); + + rt.set_now(1000); + let hello = session("hello", json!({})); + rt.send("n", &hello); + let w = only(&mut rt, "n"); + assert_eq!(w["op"], "welcome"); + assert_eq!(w["re"], hello["data"]["id"]); + assert_eq!(w["attachment_gen"], fresh); + assert_eq!(w["account_id"], "account-a"); + assert_eq!(w["host_trust_gen"], 1); + assert_eq!(w["runtime_gen"], "55555555-5555-4555-8555-555555555555"); + assert_eq!(w["permissions"], json!(["view"])); + assert_eq!(w["client_kind"], "native"); + assert_eq!(w["authorization_ms_left"], AUTHORIZATION_MS - 1000); + assert!(wire::is_uuid_v4(w["controller_session"].as_str().unwrap())); + // One welcome per attachment. + rt.send("n", &session("hello", json!({}))); + assert_error(&only(&mut rt, "n"), "unauthorized"); + // Host-only ops are not requests. + rt.send("n", &session("lease_released", json!({}))); + assert_error(&only(&mut rt, "n"), "unsupported_version"); +} + +#[test] +fn every_hello_gets_its_own_controller_and_no_lease() { + let mut rt = setup(); + hold(&mut rt, "c1", S1); + rt.event_reconnect("c1", C1B); + assert_eq!(rt.host.lease_holder(S1), Some(C1)); + // The new attachment is another controller: the old lease is held. + let m = acquire(&rt, "c1", S1, false); + rt.send("c1", &m); + assert_error(&only(&mut rt, "c1"), "lease_held"); +} + +#[test] +fn authorization_bounds_leases_and_then_pauses() { + let mut rt = setup(); + let short = "88888888-8888-4888-8888-888888888888"; + rt.connect_only( + "s", + short, + vec![Permission::View, Permission::Control], + 5000, + ); + rt.send("s", &session("hello", json!({}))); + got(&mut rt, "s"); + rt.set_now(1000); + let m = acquire(&rt, "s", S1, false); + rt.send("s", &m); + let lease = only(&mut rt, "s"); + assert_eq!(lease["ms_left"], 4000); + rt.set_now(5000); + let changed = only(&mut rt, "s"); + assert_eq!(changed["reason"], "expired"); + rt.send("s", &session("list", json!({}))); + assert_error(&only(&mut rt, "s"), "paused"); +} + +// ── leases (Β§5) ── + +#[test] +fn takeover_moves_the_lease_and_the_old_holder_becomes_a_viewer() { + let mut rt = setup(); + assert_eq!(hold(&mut rt, "c1", S1), 1); + got(&mut rt, "v"); + got(&mut rt, "c2"); + let m = acquire(&rt, "c2", S1, false); + rt.send("c2", &m); + assert_error(&only(&mut rt, "c2"), "lease_held"); + + let m = acquire(&rt, "c2", S1, true); + rt.send("c2", &m); + let lease = only(&mut rt, "c2"); + assert_eq!(lease["op"], "lease"); + assert_eq!(lease["binding"]["lease_epoch"], 2); + assert_eq!(lease["ms_left"], LEASE_MS); + for viewer in ["c1", "v"] { + let c = only(&mut rt, viewer); + assert_eq!(c["op"], "lease_changed"); + assert_eq!(c["reason"], "takeover"); + assert_eq!(c["lease_epoch"], 2); + assert_eq!(c["controller"]["lease_epoch"], 2); + } + assert_eq!(rt.host.lease_holder(S1), Some(C2)); + let m = renew(&rt, "c1", S1, 1); + rt.send("c1", &m); + assert_error(&only(&mut rt, "c1"), "stale_lease"); + // Takeover needs control. + let m = acquire(&rt, "v", S1, true); + rt.send("v", &m); + assert_error(&only(&mut rt, "v"), "forbidden"); + assert_eq!(rt.host.lease_holder(S1), Some(C2)); +} + +#[test] +fn repeat_acquire_returns_the_current_lease_unchanged() { + let mut rt = setup(); + assert_eq!(hold(&mut rt, "c1", S1), 1); + rt.set_now(4000); + let m = acquire(&rt, "c1", S1, false); + rt.send("c1", &m); + let lease = only(&mut rt, "c1"); + assert_eq!(lease["binding"]["lease_epoch"], 1); + assert_eq!(lease["ms_left"], 11_000); + // Renew extends from now; expiry is now >= deadline. + rt.set_now(5000); + let m = renew(&rt, "c1", S1, 1); + rt.send("c1", &m); + assert_eq!(only(&mut rt, "c1")["ms_left"], LEASE_MS); + rt.set_now(19_999); + assert!(got(&mut rt, "c1").is_empty()); + assert_eq!(rt.host.lease_holder(S1), Some(C1)); + rt.set_now(20_000); + let expired = only(&mut rt, "c1"); + assert_eq!(expired["reason"], "expired"); + assert_eq!(expired["lease_epoch"], 2); + assert_eq!(expired["controller"], Value::Null); + let m = renew(&rt, "c1", S1, 1); + rt.send("c1", &m); + assert_error(&only(&mut rt, "c1"), "stale_lease"); +} + +#[test] +fn stale_epochs_are_refused_without_effect() { + let mut rt = setup(); + let stream = subscribe(&mut rt, "c1"); + let epoch = hold(&mut rt, "c1", S1); + for wrong in [0, epoch + 1] { + let m = input(&rt, "c1", &stream, wrong); + rt.send("c1", &m); + assert_error(&only(&mut rt, "c1"), "stale_lease"); + let m = submit(&rt, "c1", S1, wrong, 1, "prompt"); + rt.send("c1", &m); + assert_error(&only(&mut rt, "c1"), "stale_lease"); + } + // Another attachment's binding on this one is a stale target. + let mut m = input(&rt, "c1", &stream, epoch); + m["data"]["binding"]["attachment_gen"] = json!(C2); + rt.send("c1", &m); + assert_error(&only(&mut rt, "c1"), "stale_target"); + // A stream from another subscription is stale too. + let m = input(&rt, "c1", "not-the-stream", epoch); + rt.send("c1", &m); + assert_error(&only(&mut rt, "c1"), "stale_target"); + assert!( + !rt.effects + .iter() + .any(|e| matches!(e, Effect::PtyWrite { .. } | Effect::Dispatch { .. })) + ); + // The right epoch writes. + let m = input(&rt, "c1", &stream, epoch); + rt.send("c1", &m); + assert_eq!(rt.pty.writes_remote, 1); +} + +#[test] +fn queued_remote_bytes_are_dropped_at_the_write_boundary() { + type Revoke = fn(&mut FakeRuntime); + let revokes: &[(&str, Revoke)] = &[ + ("takeover", |rt| { + let m = acquire(rt, "c2", S1, true); + rt.send("c2", &m); + }), + ("local input", |rt| rt.local_input(S1, b"x")), + ("release", |rt| { + let m = session( + "release", + json!({"binding": binding(rt, "c1", S1, Some(1))}), + ); + rt.send("c1", &m); + }), + ("expiry", |rt| rt.set_now(LEASE_MS)), + ]; + for (name, revoke) in revokes { + let mut rt = setup(); + let stream = subscribe(&mut rt, "c1"); + assert_eq!(hold(&mut rt, "c1", S1), 1); + rt.hold_writes = true; + let m = input(&rt, "c1", &stream, 1); + rt.send("c1", &m); + revoke(&mut rt); + rt.flush_writes(); + assert_eq!(rt.pty.writes_remote, 0, "{name}"); + assert_eq!(rt.pty.dropped_remote, 1, "{name}"); + } +} + +#[test] +fn local_input_revokes_the_lease_before_it_is_written() { + let mut rt = setup(); + hold(&mut rt, "c1", S1); + rt.effects.clear(); + rt.local_input(S1, b"x"); + let changed = rt + .effects + .iter() + .position(|e| matches!(e, Effect::Send { message: Envelope::Session(m), .. } if matches!(m.op, Op::LeaseChanged { .. }))) + .expect("lease_changed"); + let write = rt + .effects + .iter() + .position(|e| matches!(e, Effect::PtyWrite { .. })) + .expect("write"); + assert!(changed < write); + let c = only(&mut rt, "c1"); + assert_eq!(c["reason"], "local_input"); + assert_eq!(c["lease_epoch"], 2); + assert_eq!(rt.pty.writes_local, 1); + assert_eq!(rt.host.lease_holder(S1), None); +} + +#[test] +fn terminal_mutations_need_the_lease() { + let mut rt = setup(); + let stream = subscribe(&mut rt, "c1"); + let resize = |rt: &FakeRuntime, e| { + terminal( + rt, + "c1", + "resize", + Some(e), + json!({"stream_id": stream, "rows": 40, "cols": 120}), + ) + }; + let stop = + |rt: &FakeRuntime, e| terminal(rt, "c1", "stop", Some(e), json!({"stream_id": stream})); + let m = resize(&rt, 1); + rt.send("c1", &m); + assert_error(&only(&mut rt, "c1"), "stale_lease"); + let m = stop(&rt, 1); + rt.send("c1", &m); + assert_error(&only(&mut rt, "c1"), "stale_lease"); + assert_eq!((rt.pty.resizes, rt.pty.stops), (0, 0)); + let e = hold(&mut rt, "c1", S1); + let m = resize(&rt, e); + rt.send("c1", &m); + let m = stop(&rt, e); + rt.send("c1", &m); + assert_eq!((rt.pty.resizes, rt.pty.stops), (1, 1)); + assert!(got(&mut rt, "c1").is_empty()); + // A viewer can subscribe but never mutate. + let vs = subscribe(&mut rt, "v"); + let m = terminal( + &rt, + "v", + "input", + Some(e), + json!({"stream_id": vs, "sequence": 0, "bytes": "eA=="}), + ); + rt.send("v", &m); + assert_error(&only(&mut rt, "v"), "forbidden"); +} + +// ── registry (Β§4) ── + +fn registry_feed(client: &mut RegistryClient, msgs: Vec) -> Vec { + msgs.into_iter() + .filter(|m| matches!(m["op"].as_str(), Some("sessions" | "session_changed"))) + .map(|m| { + let env = + wire::decode(&serde_json::to_vec(&json!({"type": "session", "data": m})).unwrap()) + .unwrap(); + let Envelope::Session(s) = env else { panic!() }; + client.on_message(&s.op) + }) + .collect() +} + +#[test] +fn registry_seq_is_per_attachment_and_a_gap_recovers_with_a_new_snapshot() { + let mut rt = setup(); + rt.send("c1", &session("list", json!({}))); + let snap = only(&mut rt, "c1"); + assert_eq!(snap["op"], "sessions"); + assert_eq!(snap["seq"], 1); + assert_eq!(snap["runtime_epoch"], 7); + assert_eq!(snap["sessions"].as_array().unwrap().len(), 2); + assert_eq!( + snap["sessions"][0]["capabilities"], + json!(["terminal-v1", "chat-v1"]) + ); + assert_eq!(snap["sessions"][0]["activity"], "unknown"); + let mut client = RegistryClient::default(); + registry_feed(&mut client, vec![snap]); + + // c2 never listed: it gets lease_changed but no registry changes. + hold(&mut rt, "c2", S1); + let c2_msgs = got(&mut rt, "c2"); + assert!(c2_msgs.iter().all(|m| m["op"] != "session_changed")); + + // c1 got lease_changed and a session_changed with seq 2; the change is + // lost to simulate a gap. + let c1_msgs = got(&mut rt, "c1"); + assert_eq!(ops(&c1_msgs), vec!["lease_changed", "session_changed"]); + let lost: Vec<&Value> = c1_msgs + .iter() + .filter(|m| m["op"] == "session_changed") + .collect(); + assert_eq!(lost[0]["seq"], 2); + assert_eq!(lost[0]["descriptor"]["controller"]["lease_epoch"], 1); + + rt.local_input(S1, b"x"); + let next: Vec = got(&mut rt, "c1"); + let change = next.iter().find(|m| m["op"] == "session_changed").unwrap(); + assert_eq!(change["seq"], 3); + assert_eq!(change["descriptor"]["controller"], Value::Null); + let outcomes = registry_feed(&mut client, vec![change.clone()]); + assert_eq!(outcomes, vec![RegistryOutcome::Buffered]); + assert!(client.take_list_request(), "gap β†’ list"); + rt.send("c1", &session("list", json!({}))); + let resync = only(&mut rt, "c1"); + assert_eq!(resync["seq"], 4); + registry_feed(&mut client, vec![resync]); + assert_eq!(client.seq(), Some(4)); + assert_eq!(client.session(S1).unwrap().controller, None); + + // Removal: the last descriptor with state removed, then unknown_session. + rt.remove_session(S2); + let removed = only(&mut rt, "c1"); + assert_eq!(removed["seq"], 5); + assert_eq!(removed["descriptor"]["state"], "removed"); + assert_eq!(removed["descriptor"]["session_id"], S2); + registry_feed(&mut client, vec![removed]); + assert_eq!(client.session_ids(), vec![S1.to_string()]); + got(&mut rt, "c2"); + let m = acquire(&rt, "c2", S2, false); + rt.send("c2", &m); + assert_error(&only(&mut rt, "c2"), "unknown_session"); + assert!(rt.host.add_session(spec(S2, P2)).is_err(), "tombstoned id"); + // A viewer without `view` gets no registry. + let blind = "88888888-8888-4888-8888-888888888888"; + rt.connect_only("b", blind, vec![Permission::Control], AUTHORIZATION_MS); + rt.send("b", &session("hello", json!({}))); + got(&mut rt, "b"); + rt.send("b", &session("list", json!({}))); + assert_error(&only(&mut rt, "b"), "forbidden"); + let m = acquire(&rt, "b", S1, false); + rt.send("b", &m); + assert_error(&only(&mut rt, "b"), "unknown_session"); +} + +fn spec(session_id: &str, process_gen: &str) -> SessionSpec { + SessionSpec { + session_id: session_id.into(), + process_gen: process_gen.into(), + provider: "codex".into(), + cwd_label: "Atem".into(), + chat: false, + rows: 24, + cols: 80, + } +} + +// ── attachments and restarts ── + +#[test] +fn old_attachment_input_after_reconnect_is_refused() { + let mut rt = setup(); + let stream = subscribe(&mut rt, "c1"); + hold(&mut rt, "c1", S1); + rt.event_reconnect("c1", C1B); + rt.effects.clear(); + let mut m = input(&rt, "c1", &stream, 1); + m["data"]["binding"]["attachment_gen"] = json!(C1); + rt.send_on(C1, &m); + let refused = rt.effects.iter().any(|e| matches!(e, + Effect::Send { attachment, message: Envelope::Session(s) } + if attachment == C1 && matches!(s.op, Op::Error { code: wire::ErrorCode::Unauthorized, .. }))); + assert!(refused); + assert!( + !rt.effects + .iter() + .any(|e| matches!(e, Effect::PtyWrite { .. })) + ); + assert_eq!(rt.pty.writes_remote, 0); + // The new attachment can't use the old epoch either. + let m = renew(&rt, "c1", S1, 1); + rt.send("c1", &m); + assert_error(&only(&mut rt, "c1"), "stale_lease"); + // Disconnect stops renewal; the lease then expires and can be acquired. + rt.set_now(LEASE_MS); + got(&mut rt, "c1"); + assert_eq!(hold(&mut rt, "c1", S1), 3); +} + +#[test] +fn runtime_restart_invalidates_attachments_and_leases() { + let mut rt = setup(); + assert_eq!(hold(&mut rt, "c1", S1), 1); + rt.restart(GEN2, 8); + assert_eq!(rt.host.lease_holder(S1), None); + let m = renew(&rt, "c1", S1, 1); + rt.effects.clear(); + rt.send_on(C1, &m); + assert!(rt.effects.iter().any(|e| matches!(e, + Effect::Send { message: Envelope::Session(s), .. } + if matches!(s.op, Op::Error { code: wire::ErrorCode::Unauthorized, .. })))); + rt.event_reconnect("c1", C1B); + rt.send("c1", &session("list", json!({}))); + let snap = only(&mut rt, "c1"); + assert_eq!( + (snap["runtime_epoch"].clone(), snap["seq"].clone()), + (json!(8), json!(1)) + ); + assert_eq!(snap["runtime_gen"], GEN2); + // Lease epochs never repeat across the restart. + let e = hold(&mut rt, "c1", S1); + assert!(e > 2, "epoch {e} after restart"); + // A restart needs a higher runtime epoch. + assert!(rt.host.restart(GEN2, 8).is_err()); +} + +// ── submissions and receipts (Β§6) ── + +#[test] +fn prompt_runs_to_completion_and_duplicates_return_the_receipt() { + let mut rt = setup(); + let e = hold(&mut rt, "c1", S1); + let m = submit(&rt, "c1", S1, e, 1, "prompt"); + rt.send("c1", &m); + let msgs = got(&mut rt, "c1"); + assert_eq!(msgs[0]["re"], m["data"]["id"]); + assert_eq!(msgs[0]["state"], "accepted"); + assert_eq!(msgs[1]["state"], "executing"); + assert_eq!(msgs[0]["target"], target_with(S1, 1)); + rt.dispatch_report( + &rid(1), + DispatchReport::Completed { + detail: None, + turn_id: None, + }, + ); + assert_eq!(receipts_of(&mut rt, "c1"), vec![st("completed", "")]); + // A duplicate needs no lease, only view of the target. + rt.set_now(LEASE_MS); + got(&mut rt, "c1"); + rt.send("c1", &m); + let dup = only(&mut rt, "c1"); + assert_eq!(dup["state"], "completed"); + assert_eq!(rt.pty.dispatch_attempts, 1); + // Same id on another target is reuse. + let e2 = hold(&mut rt, "c1", S2); + let mut other = submit(&rt, "c1", S2, e2, 1, "prompt"); + rt.send("c1", &other); + assert_error(&only(&mut rt, "c1"), "request_id_reused"); + other["data"]["kind"] = json!("interrupt"); + other["data"]["payload"] = json!({}); + other["data"]["binding"]["target"] = target_with(S1, 1); + rt.send("c1", &other); + assert_error(&only(&mut rt, "c1"), "request_id_reused"); +} + +#[test] +fn approval_answers_are_forbidden_in_v1() { + let mut rt = setup(); + let e = hold(&mut rt, "c1", S1); + let mut m = submit(&rt, "c1", S1, e, 1, "interrupt"); + m["data"]["kind"] = json!("approval"); + rt.send("c1", &m); + let err = only(&mut rt, "c1"); + assert_error(&err, "forbidden"); + assert_eq!(err["detail"], DETAIL_APPROVAL); + assert!(rt.host.receipt(&rid(1)).is_none()); +} + +#[test] +fn trust_change_before_dispatch_rejects_and_revokes() { + let mut rt = setup(); + let e = hold(&mut rt, "c1", S1); + let a = submit(&rt, "c1", S1, e, 1, "prompt"); + rt.send("c1", &a); + let b = submit(&rt, "c1", S1, e, 2, "prompt"); + rt.send("c1", &b); + // B waits behind the executing A. + let states: Vec<_> = receipts_of(&mut rt, "c1"); + assert_eq!( + states, + vec![st("accepted", ""), st("executing", ""), st("accepted", "")] + ); + rt.change_trust_gen(2); + let msgs = got(&mut rt, "c1"); + assert!( + msgs.iter() + .any(|m| m["op"] == "lease_changed" && m["reason"] == "revoked") + ); + let rejected = msgs.iter().find(|m| m["op"] == "receipt").unwrap(); + assert_eq!(rejected["request_id"], rid(2)); + assert_eq!(rejected["state"], "rejected"); + assert_eq!(rejected["detail"], DETAIL_TRUST_CHANGED); + assert_eq!(rt.pty.dispatch_attempts, 1); + // Bindings at the old generation are stale; A still finishes. + let m = acquire(&rt, "c1", S1, false); + rt.send("c1", &m); + assert_error(&only(&mut rt, "c1"), "stale_target"); + rt.dispatch_report( + &rid(1), + DispatchReport::Completed { + detail: None, + turn_id: None, + }, + ); + assert_eq!(receipts_of(&mut rt, "c1"), vec![st("completed", "")]); + assert_eq!(rt.pty.dispatch_attempts, 1); + assert!(rt.host.change_trust_gen(2).is_err(), "never goes back"); +} + +fn ctx(trust: u64) -> HostContext { + HostContext { + account_id: "account-a".into(), + atem_device_id: "host-a".into(), + host_trust_gen: trust, + runtime_gen: GEN2.into(), + runtime_epoch: 9, + } +} + +fn target_struct(trust: u64) -> Target { + serde_json::from_value(target_with(S1, trust)).unwrap() +} + +fn accepted_record(n: u64, trust: u64) -> Record { + Record::Accepted { + request_id: rid(n), + target: target_struct(trust), + kind: ReceiptKind::Prompt, + payload_digest: "0".repeat(64), + payload: json!({"text": "hi"}), + updated_at: 1, + } +} + +#[test] +fn crash_rules_dispatch_accepted_once_and_reconcile_executing() { + // Accepted under an older trust generation: rejected, never dispatched. + let mut j = MemJournal::default(); + j.append_ok(accepted_record(1, 1)); + let mut host = SessionHost::new(ctx(2), HostConfig::default(), j, random_ids()).unwrap(); + host.restore_session(spec(S1, P1)).unwrap(); + let fx = host.recover(); + assert!(!fx.iter().any(|e| matches!(e, Effect::Dispatch { .. }))); + let r = host.receipt(&rid(1)).unwrap(); + assert_eq!(r.state, ReceiptState::Rejected); + assert_eq!(r.detail.as_deref(), Some(DETAIL_TRUST_CHANGED)); + + // Same trust generation: dispatched once, and a second crash only + // reconciles it. + let mut j = MemJournal::default(); + j.append_ok(accepted_record(1, 1)); + let mut host = SessionHost::new(ctx(1), HostConfig::default(), j, random_ids()).unwrap(); + host.restore_session(spec(S1, P1)).unwrap(); + let fx = host.recover(); + let dispatches = fx + .iter() + .filter(|e| matches!(e, Effect::Dispatch { .. })) + .count(); + assert_eq!(dispatches, 1); + assert_eq!( + host.receipt(&rid(1)).unwrap().state, + ReceiptState::Executing + ); + let fx = host + .restart("12121212-1212-4212-8212-121212121212", 10) + .unwrap(); + assert!(!fx.iter().any(|e| matches!(e, Effect::Dispatch { .. }))); + assert!(fx.iter().any(|e| matches!(e, Effect::Reconcile { .. }))); + host.dispatch_report(&rid(1), DispatchReport::Unknown); + let r = host.receipt(&rid(1)).unwrap(); + assert_eq!(r.state, ReceiptState::OutcomeUnknown); + assert_eq!(r.detail.as_deref(), Some(DETAIL_UNRECONCILED)); + + // Accepted for a process that is gone: rejected, session closed. + let mut j = MemJournal::default(); + j.append_ok(accepted_record(1, 1)); + let mut host = SessionHost::new(ctx(1), HostConfig::default(), j, random_ids()).unwrap(); + host.restore_session(spec(S1, "33333333-3333-4333-8333-000000000001")) + .unwrap(); + host.recover(); + assert_eq!( + host.receipt(&rid(1)).unwrap().detail.as_deref(), + Some(DETAIL_SESSION_CLOSED) + ); +} + +#[test] +fn crash_after_executing_is_outcome_unknown_and_queued_work_runs_once() { + let mut rt = setup(); + let e = hold(&mut rt, "c1", S1); + for n in [1, 2] { + let m = submit(&rt, "c1", S1, e, n, "prompt"); + rt.send("c1", &m); + } + assert_eq!(rt.pty.dispatch_attempts, 1); + rt.restart(GEN2, 8); + // A is reconciled (unknown), then B β€” accepted, trust unchanged β€” runs once. + let a = rt.host.receipt(&rid(1)).unwrap(); + assert_eq!(a.state, ReceiptState::OutcomeUnknown); + assert_eq!( + rt.host.receipt(&rid(2)).unwrap().state, + ReceiptState::Executing + ); + assert_eq!(rt.pty.dispatch_attempts, 2); + rt.restart("12121212-1212-4212-8212-121212121212", 9); + assert_eq!( + rt.pty.dispatch_attempts, 2, + "executing is never dispatched again" + ); + assert_eq!( + rt.host.receipt(&rid(2)).unwrap().state, + ReceiptState::OutcomeUnknown + ); + // A reconciliation that establishes the outcome reports it. + let mut rt = setup(); + let e = hold(&mut rt, "c1", S1); + let m = submit(&rt, "c1", S1, e, 1, "prompt"); + rt.send("c1", &m); + rt.reconcile_answer = DispatchReport::Completed { + detail: None, + turn_id: None, + }; + rt.restart(GEN2, 8); + assert_eq!( + rt.host.receipt(&rid(1)).unwrap().state, + ReceiptState::Completed + ); +} + +#[test] +fn failed_durable_writes() { + // `accepted` can't be recorded: busy, nothing recorded, retry works. + let mut rt = setup(); + let e = hold(&mut rt, "c1", S1); + rt.host.journal_mut().fail_next_fsync(); + let m = submit(&rt, "c1", S1, e, 1, "prompt"); + rt.send("c1", &m); + assert_error(&only(&mut rt, "c1"), "busy"); + assert!(rt.host.receipt(&rid(1)).is_none()); + assert_eq!(rt.pty.dispatch_attempts, 0); + rt.send("c1", &m); + assert_eq!( + rt.host.receipt(&rid(1)).unwrap().state, + ReceiptState::Executing + ); + + // `executing` can't be recorded: rejected before any side effect. + let mut rt = setup(); + let e = hold(&mut rt, "c1", S1); + rt.host.journal_mut().fail_fsync_of(2); + let m = submit(&rt, "c1", S1, e, 1, "prompt"); + rt.send("c1", &m); + assert_eq!( + receipts_of(&mut rt, "c1"), + vec![st("accepted", ""), st("rejected", DETAIL_NOT_RECORDED)] + ); + assert_eq!(rt.pty.dispatch_attempts, 0); + + // A final state that can't be recorded isn't reported until it is. + let mut rt = setup(); + let e = hold(&mut rt, "c1", S1); + let m = submit(&rt, "c1", S1, e, 1, "prompt"); + rt.send("c1", &m); + got(&mut rt, "c1"); + rt.host.journal_mut().fail_next_fsync(); + rt.dispatch_report(&rid(1), DispatchReport::FailedAfterPossibleEffect); + assert!(got(&mut rt, "c1").is_empty()); + assert_eq!( + rt.host.receipt(&rid(1)).unwrap().state, + ReceiptState::Executing + ); + rt.set_now(1); + assert_eq!( + receipts_of(&mut rt, "c1"), + vec![st("outcome_unknown", DETAIL_WRITE_INTERRUPTED)] + ); + // The lease grant's epoch reservation is durable too. + let mut rt = setup(); + rt.host.journal_mut().fail_next_fsync(); + let m = acquire(&rt, "c1", S1, false); + rt.send("c1", &m); + assert_error(&only(&mut rt, "c1"), "busy"); + assert_eq!(rt.host.lease_holder(S1), None); +} + +#[test] +fn process_end_and_observation_windows() { + let mut rt = setup(); + let e = hold(&mut rt, "c1", S1); + let m = submit(&rt, "c1", S1, e, 1, "prompt"); + rt.send("c1", &m); + got(&mut rt, "c1"); + rt.process_exited(S1); + let msgs = got(&mut rt, "c1"); + let ended = msgs.iter().find(|m| m["op"] == "lease_changed").unwrap(); + assert_eq!(ended["reason"], "process_ended"); + let r = msgs.iter().find(|m| m["op"] == "receipt").unwrap(); + assert_eq!( + (r["state"].clone(), r["detail"].clone()), + (json!("outcome_unknown"), json!(DETAIL_AGENT_EXITED)) + ); + let m = submit(&rt, "c1", S1, e + 1, 2, "prompt"); + rt.send("c1", &m); + assert_error(&only(&mut rt, "c1"), "session_closed"); + + // interrupt: nothing observed within 10 s; stop: completes on exit. + let mut rt = setup(); + let e = hold(&mut rt, "c1", S2); + let m = submit(&rt, "c1", S2, e, 1, "interrupt"); + rt.send("c1", &m); + let m = submit(&rt, "c1", S2, e, 2, "stop"); + rt.send("c1", &m); + got(&mut rt, "c1"); + rt.set_now(OBSERVE_MS - 1); + assert!(receipts_of(&mut rt, "c1").is_empty()); + rt.set_now(OBSERVE_MS); + let r = rt.host.receipt(&rid(1)).unwrap(); + assert_eq!(r.detail.as_deref(), Some(DETAIL_NO_INTERRUPT)); + let r = rt.host.receipt(&rid(2)).unwrap(); + assert_eq!(r.detail.as_deref(), Some(DETAIL_STILL_RUNNING)); + + let mut rt = setup(); + let e = hold(&mut rt, "c1", S2); + let m = submit(&rt, "c1", S2, e, 1, "stop"); + rt.send("c1", &m); + rt.process_exited(S2); + assert_eq!( + rt.host.receipt(&rid(1)).unwrap().state, + ReceiptState::Completed + ); + // A stop that could not be delivered is rejected. + let mut rt = setup(); + let e = hold(&mut rt, "c1", S2); + rt.pty.fail_next_enqueue = true; + let m = submit(&rt, "c1", S2, e, 1, "stop"); + rt.send("c1", &m); + let r = rt.host.receipt(&rid(1)).unwrap(); + assert_eq!( + (r.state, r.detail.as_deref()), + (ReceiptState::Rejected, Some(DETAIL_STOP_FAILED)) + ); +} + +#[test] +fn closed_session_refuses_submissions_and_rejects_queued_work() { + let mut rt = setup(); + let e = hold(&mut rt, "c1", S1); + for n in [1, 2] { + let m = submit(&rt, "c1", S1, e, n, "prompt"); + rt.send("c1", &m); + } + got(&mut rt, "c1"); + rt.close_session(S1, false); + let msgs = got(&mut rt, "c1"); + let r = msgs.iter().find(|m| m["op"] == "receipt").unwrap(); + assert_eq!(r["request_id"], rid(2)); + assert_eq!(r["detail"], DETAIL_SESSION_CLOSED); + let m = acquire(&rt, "c1", S1, false); + rt.send("c1", &m); + assert_error(&only(&mut rt, "c1"), "session_closed"); + // Receipts stay readable while the session is retained. + let q = query(&rt, "c1", target_with(S1, 1), vec![rid(1), rid(2)]); + rt.send("c1", &q); + assert_eq!(only(&mut rt, "c1")["receipts"].as_array().unwrap().len(), 2); + // ... for 7 days after it closed. + rt.set_now(RECEIPT_RETENTION_MS); + assert!(rt.host.receipt(&rid(1)).is_none()); +} + +#[test] +fn receipt_query_is_scoped_to_the_exact_target() { + let mut rt = setup(); + let e1 = hold(&mut rt, "c1", S1); + let e2 = hold(&mut rt, "c1", S2); + let m = submit(&rt, "c1", S1, e1, 1, "prompt"); + rt.send("c1", &m); + let m = submit(&rt, "c1", S2, e2, 2, "prompt"); + rt.send("c1", &m); + got(&mut rt, "c1"); + let unknown = rid(99); + let q = query( + &rt, + "c1", + target_with(S1, 1), + vec![rid(1), rid(2), unknown, rid(1)], + ); + rt.send("c1", &q); + let r = only(&mut rt, "c1"); + let ids: Vec<&str> = r["receipts"] + .as_array() + .unwrap() + .iter() + .map(|x| x["request_id"].as_str().unwrap()) + .collect(); + assert_eq!(ids, vec![rid(1).as_str()]); + // Another client with view sees the target's receipts; no view, none. + got(&mut rt, "v"); + let q = query(&rt, "v", target_with(S2, 1), vec![rid(1), rid(2)]); + rt.send("v", &q); + assert_eq!(only(&mut rt, "v")["receipts"][0]["request_id"], rid(2)); + let blind = "88888888-8888-4888-8888-888888888888"; + rt.connect_only("b", blind, vec![Permission::Control], AUTHORIZATION_MS); + rt.send("b", &session("hello", json!({}))); + got(&mut rt, "b"); + let q = query(&rt, "b", target_with(S1, 1), vec![rid(1)]); + rt.send("b", &q); + let err = only(&mut rt, "b"); + assert_error(&err, "unknown_session"); + // Errors never carry current values or details. + assert_eq!( + err.as_object().unwrap().keys().collect::>(), + vec!["code", "id", "op", "re", "v"] + ); + let q = query(&rt, "c1", target_with(S1, 5), vec![rid(1)]); + rt.send("c1", &q); + assert_error(&only(&mut rt, "c1"), "stale_target"); + assert_eq!(rt.counter_receipts_visible(), (2, 2, 0)); + rt.remove_session(S2); + got(&mut rt, "c1"); + let q = query(&rt, "c1", target_with(S2, 1), vec![rid(2)]); + rt.send("c1", &q); + assert_error(&only(&mut rt, "c1"), "unknown_session"); +} + +#[test] +fn receipt_and_in_flight_limits() { + let mut rt = setup_with(HostConfig { + max_receipts_per_session: 2, + ..HostConfig::default() + }); + let e = hold(&mut rt, "c1", S1); + for n in [1, 2, 3] { + let m = submit(&rt, "c1", S1, e, n, "prompt"); + rt.send("c1", &m); + } + let msgs = got(&mut rt, "c1"); + assert_error(msgs.last().unwrap(), "receipt_limit"); + // Recorded receipts are still answered at the limit. + let m = submit(&rt, "c1", S1, e, 2, "prompt"); + rt.send("c1", &m); + assert_eq!(only(&mut rt, "c1")["op"], "receipt"); + + let mut rt = setup(); + let e = hold(&mut rt, "c1", S1); + for n in 0..wire::MAX_IN_FLIGHT as u64 { + let m = submit(&rt, "c1", S1, e, n, "prompt"); + rt.send("c1", &m); + } + got(&mut rt, "c1"); + let m = submit(&rt, "c1", S1, e, 1000, "prompt"); + rt.send("c1", &m); + assert_error(&only(&mut rt, "c1"), "busy"); +} + +#[test] +fn chat_submit_results_use_chat_ops() { + let mut rt = setup(); + let e = hold(&mut rt, "c1", S1); + let chat = |rt: &FakeRuntime, agent: &str, n: u64, epoch: u64| { + json!({"type": "agentChat", "data": {"v": 1, "op": "submit", "conversationId": "conv-1", + "agentId": agent, "generation": P1, "requestId": rid(n), "text": "run the tests", + "binding": binding(rt, "c1", S1, Some(epoch))}}) + }; + let m = chat(&rt, S1, 1, e); + rt.send("c1", &m); + let msgs = rt.take("c1"); + assert_eq!(msgs.len(), 1, "{msgs:#?}"); + assert_eq!(msgs[0]["type"], "agentChat"); + let a = &msgs[0]["data"]; + assert_eq!( + ( + a["op"].clone(), + a["requestId"].clone(), + a["conversationId"].clone() + ), + (json!("accepted"), json!(rid(1)), json!("conv-1")) + ); + rt.dispatch_report( + &rid(1), + DispatchReport::Completed { + detail: None, + turn_id: Some("turn-7".into()), + }, + ); + let msgs = rt.take("c1"); + assert_eq!( + msgs.len(), + 1, + "no session receipt for a chat submit: {msgs:#?}" + ); + let c = &msgs[0]["data"]; + assert_eq!(c["op"], "complete"); + assert_eq!(c["requestId"], rid(1)); + assert_eq!(c["turnId"], "turn-7"); + assert_eq!(c["outcome"], "done"); + // A session-v1 duplicate of it gets a session receipt (its own channel). + let m = submit(&rt, "c1", S1, e, 1, "prompt"); + rt.send("c1", &m); + assert_eq!(only(&mut rt, "c1")["state"], "completed"); + assert_eq!(rt.pty.dispatch_attempts, 1); + // A chat duplicate maps the recorded final state. + let m = chat(&rt, S1, 1, e); + rt.send("c1", &m); + assert_eq!(only(&mut rt, "c1")["op"], "complete"); + + // Admission refusals and failures are chat errors with requestId and code. + let m = chat(&rt, S1, 2, e + 5); + rt.send("c1", &m); + let err = only(&mut rt, "c1"); + assert_eq!( + (err["op"].clone(), err["code"].clone()), + (json!("error"), json!("stale_lease")) + ); + assert_eq!(err["requestId"], rid(2)); + assert!(err["text"].is_string()); + rt.pty.fail_next_partial = true; + let m = chat(&rt, S1, 3, e); + rt.send("c1", &m); + let msgs = got(&mut rt, "c1"); + assert_eq!(ops(&msgs), vec!["accepted", "error"]); + assert_eq!(msgs[1]["code"], "outcome_unknown"); + assert_eq!(msgs[1]["text"], DETAIL_WRITE_INTERRUPTED); + rt.pty.fail_next_enqueue = true; + let m = chat(&rt, S1, 4, e); + rt.send("c1", &m); + let msgs = got(&mut rt, "c1"); + assert_eq!(msgs[1]["code"], "rejected"); + assert_eq!(msgs[1]["text"], DETAIL_NO_TERMINAL_WRITE); + // agentId must equal the binding's session. + let m = chat(&rt, S2, 5, e); + rt.send("c1", &m); + let err = only(&mut rt, "c1"); + assert_eq!( + (err["op"].clone(), err["code"].clone()), + (json!("error"), json!("stale_target")) + ); +} + +#[test] +fn refused_terminal_requests_get_terminal_errors_and_removed_ops_session_errors() { + let mut rt = setup(); + let stream = subscribe(&mut rt, "c1"); + let m = input(&rt, "c1", &stream, 1); + rt.send("c1", &m); + let msgs = rt.take("c1"); + assert_eq!(msgs.len(), 1); + assert_eq!(msgs[0]["type"], "terminal"); + let d = &msgs[0]["data"]; + assert_eq!(d["op"], "error"); + assert_eq!(d["code"], "stale_lease"); + assert_eq!(d["terminal_id"], S1); + assert_eq!(d["stream_id"], json!(stream)); + assert!(d["message"].is_string()); + // A removed op stays a session error without re. + let control = json!({"type": "terminal", "data": {"v": 1, "op": "control", "terminal_id": S1, + "generation": P1, "stream_id": stream, "take": true}}); + rt.send("c1", &control); + let msgs = rt.take("c1"); + assert_eq!(msgs[0]["type"], "session"); + assert_eq!(msgs[0]["data"]["code"], "unsupported_version"); + assert!(msgs[0]["data"].get("re").is_none()); +} + +#[test] +fn closed_and_exited_sessions_answer_session_closed() { + for end in ["close", "exit"] { + let mut rt = setup(); + let stream = subscribe(&mut rt, "c1"); + let e = hold(&mut rt, "c1", S1); + if end == "close" { + rt.close_session(S1, false); + } else { + rt.process_exited(S1); + } + got(&mut rt, "c1"); + let m = submit(&rt, "c1", S1, e, 1, "prompt"); + rt.send("c1", &m); + assert_error(&only(&mut rt, "c1"), "session_closed"); + let m = input(&rt, "c1", &stream, e); + rt.send("c1", &m); + assert_error(&only(&mut rt, "c1"), "session_closed"); + assert_eq!(rt.pty.writes_remote + rt.pty.dispatch_attempts, 0, "{end}"); + } +} + +#[test] +fn ending_a_session_finishes_executing_work() { + type End = fn(&mut FakeRuntime); + let ends: &[(&str, End, Option<&str>)] = &[ + ( + "remove", + |rt| rt.remove_session(S1), + Some(DETAIL_AGENT_EXITED), + ), + ( + "close, process gone", + |rt| rt.close_session(S1, true), + Some(DETAIL_AGENT_EXITED), + ), + ( + "close, process alive", + |rt| rt.close_session(S1, false), + None, + ), + ]; + for (name, end, expect) in ends { + let mut rt = setup(); + let e = hold(&mut rt, "c1", S1); + let m = submit(&rt, "c1", S1, e, 1, "prompt"); + rt.send("c1", &m); + end(&mut rt); + let r = rt.host.receipt(&rid(1)).unwrap(); + match expect { + Some(d) => { + assert_eq!(r.state, ReceiptState::OutcomeUnknown, "{name}"); + assert_eq!(r.detail.as_deref(), Some(*d), "{name}"); + } + None => assert_eq!(r.state, ReceiptState::Executing, "{name}"), + } + } +} + +#[test] +fn expiry_does_not_need_a_timer_tick() { + let mut rt = setup(); + rt.ticks = false; + let stream = subscribe(&mut rt, "c1"); + assert_eq!(hold(&mut rt, "c1", S1), 1); + rt.hold_writes = true; + rt.set_now(LEASE_MS - 1); + let m = input(&rt, "c1", &stream, 1); + rt.send("c1", &m); + rt.set_now(LEASE_MS); + // The queued chunk reaches the writer at the deadline: dropped. + rt.flush_writes(); + assert_eq!((rt.pty.writes_remote, rt.pty.dropped_remote), (0, 1)); + for m in [renew(&rt, "c1", S1, 1), input(&rt, "c1", &stream, 1)] { + rt.send("c1", &m); + let msgs = got(&mut rt, "c1"); + assert_error( + msgs.iter().find(|m| m["op"] == "error").unwrap(), + "stale_lease", + ); + } + assert_eq!(rt.pty.writes_remote, 0); +} + +#[test] +fn failure_details_are_kind_specific() { + let cases = [ + ( + "stop", + DispatchReport::FailedNoEffect, + ReceiptState::Rejected, + DETAIL_STOP_FAILED, + ), + ( + "stop", + DispatchReport::FailedAfterPossibleEffect, + ReceiptState::OutcomeUnknown, + DETAIL_STILL_RUNNING, + ), + ( + "interrupt", + DispatchReport::FailedAfterPossibleEffect, + ReceiptState::OutcomeUnknown, + DETAIL_NO_INTERRUPT, + ), + ( + "prompt", + DispatchReport::FailedAfterPossibleEffect, + ReceiptState::OutcomeUnknown, + DETAIL_WRITE_INTERRUPTED, + ), + ( + "prompt", + DispatchReport::FailedNoEffect, + ReceiptState::Rejected, + DETAIL_NO_TERMINAL_WRITE, + ), + ]; + for (kind, report, state, detail) in cases { + let mut rt = setup(); + let e = hold(&mut rt, "c1", S2); + let m = submit(&rt, "c1", S2, e, 1, kind); + rt.send("c1", &m); + rt.dispatch_report(&rid(1), report.clone()); + let r = rt.host.receipt(&rid(1)).unwrap(); + assert_eq!( + (r.state, r.detail.as_deref()), + (state, Some(detail)), + "{kind} {report:?}" + ); + } +} + +#[test] +fn a_fenced_or_disconnected_holder_loses_its_queued_bytes() { + for how in ["disconnect", "reconnect"] { + let mut rt = setup(); + let stream = subscribe(&mut rt, "c1"); + hold(&mut rt, "c1", S1); + rt.hold_writes = true; + let m = input(&rt, "c1", &stream, 1); + rt.send("c1", &m); + if how == "disconnect" { + rt.disconnect("c1"); + } else { + rt.event_reconnect("c1", C1B); + } + rt.flush_writes(); + assert_eq!( + (rt.pty.writes_remote, rt.pty.dropped_remote), + (0, 1), + "{how}" + ); + // The lease itself still expires normally. + assert_eq!(rt.host.lease_holder(S1), Some(C1), "{how}"); + rt.set_now(LEASE_MS); + assert_eq!(rt.host.lease_holder(S1), None, "{how}"); + } +} + +#[test] +fn dispatches_count_only_what_reached_the_agent() { + use super::fixtures::HostUnderTest; + let mut rt = setup(); + let e = hold(&mut rt, "c1", S1); + rt.pty.fail_next_enqueue = true; + let m = submit(&rt, "c1", S1, e, 1, "prompt"); + rt.send("c1", &m); + let m = submit(&rt, "c1", S1, e, 2, "prompt"); + rt.send("c1", &m); + assert_eq!(rt.counter("dispatch_attempts").unwrap(), 2); + assert_eq!(rt.counter("dispatches").unwrap(), 1); +} + +#[test] +fn activity_and_process_replacement() { + let mut rt = setup(); + rt.send("v", &session("list", json!({}))); + got(&mut rt, "v"); + rt.set_activity(S1, wire::Activity::Working, 1_760_500_000_123); + let c = only(&mut rt, "v"); + assert_eq!(c["descriptor"]["activity"], "working"); + assert_eq!(c["descriptor"]["activity_since"], 1_760_500_000_123u64); + + let e = hold(&mut rt, "c1", S1); + for n in [1, 2] { + let m = submit(&rt, "c1", S1, e, n, "prompt"); + rt.send("c1", &m); + } + got(&mut rt, "c1"); + got(&mut rt, "v"); + let new_gen = "22222222-2222-4222-8222-000000000002"; + rt.replace_process(S1, new_gen); + let a = rt.host.receipt(&rid(1)).unwrap(); + assert_eq!( + (a.state, a.detail.as_deref()), + (ReceiptState::OutcomeUnknown, Some(DETAIL_AGENT_EXITED)) + ); + assert_eq!( + rt.host.receipt(&rid(2)).unwrap().state, + ReceiptState::Rejected + ); + let msgs = got(&mut rt, "v"); + let ended = msgs.iter().find(|m| m["op"] == "lease_changed").unwrap(); + assert_eq!(ended["reason"], "process_ended"); + assert_eq!(ended["process_gen"], P1); + let change = msgs.iter().rfind(|m| m["op"] == "session_changed").unwrap(); + assert_eq!(change["descriptor"]["process_gen"], new_gen); + assert_eq!(change["descriptor"]["state"], "running"); + assert_eq!(rt.host.lease_holder(S1), None); + got(&mut rt, "c1"); + // Old-generation bindings are stale; receipts of it stay queryable. + let m = acquire(&rt, "c1", S1, false); + rt.send("c1", &m); + assert_error(&only(&mut rt, "c1"), "stale_target"); + let q = query(&rt, "c1", target_with(S1, 1), vec![rid(1)]); + rt.send("c1", &q); + assert_eq!(only(&mut rt, "c1")["receipts"][0]["request_id"], rid(1)); + assert!(rt.host.replace_process(S1, new_gen).is_err(), "must be new"); +} + +#[test] +fn prompt_payloads_are_dropped_once_past_accepted() { + let mut rt = setup(); + let e = hold(&mut rt, "c1", S1); + let m = submit(&rt, "c1", S1, e, 1, "prompt"); + rt.send("c1", &m); + let accepted = rt + .host + .journal() + .records() + .iter() + .find_map(|r| match r { + Record::Accepted { + payload, + payload_digest, + .. + } => Some((payload.clone(), payload_digest.clone())), + _ => None, + }) + .unwrap(); + assert_eq!(accepted.0, Value::Null, "payload dropped once executing"); + assert!(wire::is_digest(&accepted.1)); + let replayed = super::journal::replay(rt.host.journal().records().to_vec()).unwrap(); + let s = &replayed.receipts[&rid(1)]; + assert_eq!(s.payload, None); + assert_eq!(s.payload_digest, accepted.1); + // The digest still identifies duplicates. + got(&mut rt, "c1"); + rt.send("c1", &m); + assert_eq!(only(&mut rt, "c1")["state"], "executing"); +} + +#[test] +fn a_closed_session_stays_closed() { + let mut rt = setup(); + let e = hold(&mut rt, "c1", S1); + let m = submit(&rt, "c1", S1, e, 1, "prompt"); + rt.send("c1", &m); + rt.close_session(S1, true); + let new_gen = "22222222-2222-4222-8222-000000000002"; + assert!(rt.host.replace_process(S1, new_gen).is_err()); + assert_eq!( + rt.host.descriptor_of(S1).unwrap().state, + wire::SessionState::Closed + ); + assert!( + rt.host + .set_activity(S1, wire::Activity::Working, 1) + .is_err() + ); + // Its receipts are evicted only after retention, and it never comes back. + rt.set_now(RECEIPT_RETENTION_MS - 1); + assert!(rt.host.receipt(&rid(1)).is_some()); + rt.set_now(RECEIPT_RETENTION_MS); + assert!(rt.host.receipt(&rid(1)).is_none()); +} + +#[test] +fn removal_closes_streams_and_the_id_is_never_reused() { + let mut rt = setup(); + subscribe(&mut rt, "c1"); + let e = hold(&mut rt, "c1", S1); + let m = submit(&rt, "c1", S1, e, 1, "prompt"); + rt.send("c1", &m); + rt.effects.clear(); + rt.remove_session(S1); + assert!(rt.effects.iter().any(|x| matches!(x, + Effect::StreamClosed { attachment, session_id, .. } if attachment == C1 && session_id == S1))); + // After the tombstone, its receipts and lease floor still block the id. + rt.set_now(TOMBSTONE_MS); + assert!(rt.host.add_session(spec(S1, P1)).is_err()); + rt.set_now(RECEIPT_RETENTION_MS); + assert!(rt.host.receipt(&rid(1)).is_none()); + assert!(rt.host.add_session(spec(S1, P1)).is_err(), "lease floor"); +} + +#[test] +fn a_restart_over_a_corrupt_journal_changes_nothing() { + use super::journal::Journal; + let mut rt = setup(); + hold(&mut rt, "c1", S1); + rt.host + .journal_mut() + .append(Record::Evicted { request_id: rid(1) }) + .unwrap(); + rt.host + .journal_mut() + .append(Record::Transition { + request_id: rid(9), + state: ReceiptState::Executing, + detail: None, + updated_at: 0, + }) + .unwrap(); + let before = rt.host.context().clone(); + assert!(rt.host.restart(GEN2, 8).is_err()); + assert_eq!(rt.host.context(), &before); + assert_eq!(rt.host.lease_holder(S1), Some(C1)); +} + +#[test] +fn durable_write_retries_are_deduplicated() { + let mut rt = setup(); + let e = hold(&mut rt, "c1", S1); + let m = submit(&rt, "c1", S1, e, 1, "prompt"); + rt.send("c1", &m); + rt.host.journal_mut().fail_next_fsync(); + rt.dispatch_report(&rid(1), DispatchReport::FailedAfterPossibleEffect); + rt.host.journal_mut().fail_next_fsync(); + rt.process_exited(S1); + assert_eq!(rt.host.pending_retries(), 1); + rt.set_now(1); + assert_eq!(rt.host.pending_retries(), 0); + assert_eq!( + rt.host.receipt(&rid(1)).unwrap().state, + ReceiptState::OutcomeUnknown + ); +} + +impl FakeRuntime { + fn event_reconnect(&mut self, client: &str, att_gen: &str) { + self.attach(client, att_gen).unwrap(); + } + + fn restart(&mut self, att_gen: &str, epoch: u64) { + use super::fixtures::{HostEvent, HostUnderTest}; + let now = self.now(); + let event = HostEvent::RestartRuntime { + runtime_gen: att_gen.into(), + runtime_epoch: epoch, + }; + self.event(&event, now).unwrap(); + } + + fn local_input(&mut self, s: &str, bytes: &[u8]) { + let fx = self.host.local_input(s, bytes.to_vec()); + self.apply(fx); + } + + fn dispatch_report(&mut self, request_id: &str, report: DispatchReport) { + let fx = self.host.dispatch_report(request_id, report); + self.apply(fx); + } + + fn process_exited(&mut self, s: &str) { + let fx = self.host.process_exited(s, Some("0".into())); + self.apply(fx); + } + + fn close_session(&mut self, s: &str, process_gone: bool) { + let fx = self.host.close_session(s, process_gone); + self.apply(fx); + } + + fn remove_session(&mut self, s: &str) { + let fx = self.host.remove_session(s); + self.apply(fx); + } + + fn disconnect(&mut self, client: &str) { + use super::fixtures::{HostEvent, HostUnderTest}; + let now = self.now(); + let event = HostEvent::Disconnect { + client: client.into(), + }; + self.event(&event, now).unwrap(); + } + + fn set_activity(&mut self, s: &str, activity: wire::Activity, since: u64) { + let fx = self.host.set_activity(s, activity, since).unwrap(); + self.apply(fx); + } + + fn replace_process(&mut self, s: &str, process_gen: &str) { + let fx = self.host.replace_process(s, process_gen).unwrap(); + self.apply(fx); + } + + fn change_trust_gen(&mut self, n: u64) { + let fx = self.host.change_trust_gen(n).unwrap(); + self.apply(fx); + } + + fn counter_receipts_visible(&self) -> (usize, usize, usize) { + let h = &self.host; + ( + h.receipts_visible(C1), + h.receipts_visible(V), + h.receipts_visible("88888888-8888-4888-8888-888888888888"), + ) + } +} + +trait AppendOk { + fn append_ok(&mut self, r: Record); +} + +impl AppendOk for MemJournal { + fn append_ok(&mut self, r: Record) { + use super::journal::Journal; + self.append(r).unwrap(); + } +} diff --git a/src/session_protocol/journal.rs b/src/session_protocol/journal.rs new file mode 100644 index 0000000..e4dac63 --- /dev/null +++ b/src/session_protocol/journal.rs @@ -0,0 +1,336 @@ +//! The host's durable receipt journal (Β§6) and lease-epoch reservations (Β§5). +//! +//! A `Journal` appends one record at a time and makes it durable (fsync) +//! before `append` returns `Ok`; the host reports a state only after its record +//! is durable. On restart the host reloads every durable record with `load` and +//! folds them with `replay`, then applies the Β§6 crash rules itself. +//! +//! `MemJournal` keeps the records in memory and can fail the next fsync +//! (`fail_next_fsync`): the failed record is lost, as after a crash. + +use super::wire::{ReceiptKind, ReceiptState, Target}; +use serde_json::Value; +use std::collections::{BTreeMap, HashMap}; + +#[derive(Debug, Clone, PartialEq)] +pub enum Record { + /// A new request admitted as `accepted`, with what a dispatch after a + /// crash needs (its payload). + Accepted { + request_id: String, + target: Target, + kind: ReceiptKind, + payload_digest: String, + payload: Value, + updated_at: u64, + }, + /// A later state of a recorded request. + Transition { + request_id: String, + state: ReceiptState, + detail: Option, + updated_at: u64, + }, + /// The receipt left the retention window and is forgotten. + Evicted { request_id: String }, + /// Lease epochs up to `reserved` may have been issued for `session_id`; + /// none of them is ever issued again. + LeaseEpochs { session_id: String, reserved: u64 }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct JournalError(pub String); + +/// Durable receipt storage. +/// +/// A file journal can't know what an fsync error left on disk (the record may +/// or may not have been written). After one, a real implementation must stop +/// admitting new records until it has truncated back to its last durable +/// record; the host treats every `Err` as "not written". +/// +/// A request's payload is only needed while it is `accepted` (a dispatch +/// after a crash); once a `Transition` moves it on, the journal may drop the +/// payload (compaction). The digest is kept forever, for duplicates. +pub trait Journal { + /// Appends `record` and makes it durable (fsync) before returning `Ok`. + /// On `Err` the record must be treated as not written. + fn append(&mut self, record: Record) -> Result<(), JournalError>; + /// Every durable record, in append order. + fn load(&self) -> Result, JournalError>; +} + +/// In-memory journal with failure injection, for tests and the fixtures. +#[derive(Debug, Default)] +pub struct MemJournal { + records: Vec, + /// Appends left before the one whose fsync fails. + fail_in: Option, + fsyncs: u64, +} + +impl MemJournal { + /// The next `append` fails its fsync; the record is lost. + pub fn fail_next_fsync(&mut self) { + self.fail_fsync_of(1); + } + + /// The `n`-th next `append` (1-based) fails its fsync. + pub fn fail_fsync_of(&mut self, n: u64) { + self.fail_in = Some(n.saturating_sub(1)); + } + + /// Successful fsyncs so far. + pub fn fsyncs(&self) -> u64 { + self.fsyncs + } + + pub fn records(&self) -> &[Record] { + &self.records + } +} + +impl Journal for MemJournal { + fn append(&mut self, record: Record) -> Result<(), JournalError> { + match self.fail_in { + Some(0) => { + self.fail_in = None; + return Err(JournalError("fsync failed".into())); + } + Some(n) => self.fail_in = Some(n - 1), + None => {} + } + if let Record::Transition { request_id, .. } = &record { + // Compaction: the payload isn't needed past `accepted`. + for r in &mut self.records { + if let Record::Accepted { + request_id: id, + payload, + .. + } = r + && id == request_id + { + *payload = Value::Null; + } + } + } + self.records.push(record); + self.fsyncs += 1; + Ok(()) + } + + fn load(&self) -> Result, JournalError> { + Ok(self.records.clone()) + } +} + +/// One recorded request as replayed from the journal. +#[derive(Debug, Clone, PartialEq)] +pub struct Stored { + pub request_id: String, + pub target: Target, + pub kind: ReceiptKind, + pub payload_digest: String, + /// Present only while `accepted` (needed to dispatch after a crash). + pub payload: Option, + pub state: ReceiptState, + pub detail: Option, + pub updated_at: u64, + /// Admission order (position of its `Accepted` record). + pub order: u64, +} + +#[derive(Debug, Default)] +pub struct Replayed { + pub receipts: BTreeMap, + pub lease_epochs: HashMap, +} + +pub fn is_final(state: ReceiptState) -> bool { + matches!( + state, + ReceiptState::Completed | ReceiptState::Rejected | ReceiptState::OutcomeUnknown + ) +} + +/// The Β§6 transition table (never into `accepted`; finals are final). +pub fn valid_transition(from: ReceiptState, to: ReceiptState) -> bool { + use ReceiptState::*; + matches!( + (from, to), + (Accepted, Executing) + | (Accepted, Rejected) + | (Executing, Completed) + | (Executing, Rejected) + | (Executing, OutcomeUnknown) + ) +} + +/// Folds durable records into the current receipts and epoch reservations. +/// A record that breaks the transition table or names an unknown request is +/// a corrupt journal. +pub fn replay(records: Vec) -> Result { + let mut out = Replayed::default(); + for (i, record) in records.into_iter().enumerate() { + match record { + Record::Accepted { + request_id, + target, + kind, + payload_digest, + payload, + updated_at, + } => { + if out.receipts.contains_key(&request_id) { + return Err(JournalError(format!("request {request_id} admitted twice"))); + } + let stored = Stored { + request_id: request_id.clone(), + target, + kind, + payload_digest, + payload: Some(payload), + state: ReceiptState::Accepted, + detail: None, + updated_at, + order: i as u64, + }; + out.receipts.insert(request_id, stored); + } + Record::Transition { + request_id, + state, + detail, + updated_at, + } => { + let s = out + .receipts + .get_mut(&request_id) + .ok_or_else(|| JournalError(format!("transition of unknown {request_id}")))?; + if !valid_transition(s.state, state) { + return Err(JournalError(format!( + "invalid transition {:?} -> {state:?} for {request_id}", + s.state + ))); + } + s.state = state; + s.payload = None; + s.detail = detail; + s.updated_at = updated_at; + } + Record::Evicted { request_id } => { + out.receipts.remove(&request_id); + } + Record::LeaseEpochs { + session_id, + reserved, + } => { + let e = out.lease_epochs.entry(session_id).or_default(); + *e = (*e).max(reserved); + } + } + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn target() -> Target { + Target { + account_id: "account-a".into(), + atem_device_id: "host-a".into(), + host_trust_gen: 1, + session_id: "ses-aaaaaaaaaaaa".into(), + process_gen: "22222222-2222-4222-8222-222222222222".into(), + } + } + + fn accepted(id: &str) -> Record { + Record::Accepted { + request_id: id.into(), + target: target(), + kind: ReceiptKind::Prompt, + payload_digest: "0".repeat(64), + payload: json!({"text": "hi"}), + updated_at: 1, + } + } + + fn to(id: &str, state: ReceiptState) -> Record { + Record::Transition { + request_id: id.into(), + state, + detail: None, + updated_at: 2, + } + } + + #[test] + fn failed_fsync_loses_only_that_record() { + let mut j = MemJournal::default(); + j.append(accepted("a")).unwrap(); + j.fail_next_fsync(); + assert!(j.append(to("a", ReceiptState::Executing)).is_err()); + j.append(to("a", ReceiptState::Rejected)).unwrap(); + assert_eq!(j.fsyncs(), 2); + let r = replay(j.load().unwrap()).unwrap(); + assert_eq!(r.receipts["a"].state, ReceiptState::Rejected); + } + + #[test] + fn a_later_fsync_can_fail() { + let mut j = MemJournal::default(); + j.fail_fsync_of(2); + j.append(accepted("a")).unwrap(); + assert!(j.append(to("a", ReceiptState::Executing)).is_err()); + j.append(to("a", ReceiptState::Rejected)).unwrap(); + assert_eq!(j.records().len(), 2); + } + + #[test] + fn replay_follows_the_transition_table() { + use ReceiptState::*; + let ok = replay(vec![ + accepted("a"), + to("a", Executing), + to("a", OutcomeUnknown), + ]) + .unwrap(); + assert_eq!(ok.receipts["a"].state, OutcomeUnknown); + for bad in [ + vec![accepted("a"), to("a", Completed)], + vec![accepted("a"), to("a", Executing), to("a", Accepted)], + vec![accepted("a"), to("a", Rejected), to("a", Executing)], + vec![to("a", Executing)], + vec![accepted("a"), accepted("a")], + ] { + assert!(replay(bad.clone()).is_err(), "{bad:?}"); + } + let gone = replay(vec![ + accepted("a"), + Record::Evicted { + request_id: "a".into(), + }, + ]) + .unwrap(); + assert!(gone.receipts.is_empty()); + } + + #[test] + fn lease_reservations_keep_the_highest() { + let r = replay(vec![ + Record::LeaseEpochs { + session_id: "s".into(), + reserved: 64, + }, + Record::LeaseEpochs { + session_id: "s".into(), + reserved: 128, + }, + ]) + .unwrap(); + assert_eq!(r.lease_epochs["s"], 128); + } +} diff --git a/src/session_protocol/mod.rs b/src/session_protocol/mod.rs new file mode 100644 index 0000000..fffcbf9 --- /dev/null +++ b/src/session_protocol/mod.rs @@ -0,0 +1,36 @@ +//! atem-session v1 (designs/protocol/atem-session-v1.md, r6, compatibility mode): +//! wire types, strict decoding and the canonical payload digest (`wire`, +//! `canonical`), the client lease/registry state machines (`client`), the host +//! authority (`host`) and its durable receipt journal (`journal`), all checked +//! against the shared fixtures (`fixtures`, run on the host through +//! `fake_runtime`). +//! +//! Pure logic: nothing here touches sockets, PTYs or files; the runtime +//! performs the host's effects (a later plan wires it in). +//! +//! Unknown fields are ignored (Β§1), but they still go through the strict +//! parser: an unsafe integer (beyond Β±(2^53βˆ’1)) or a float that overflows f64 +//! inside an unknown field rejects the whole message, as do duplicate keys or +//! lone surrogates anywhere in it. + +// Not wired into the runtime yet (a later plan does that); until then only the +// tests use most of this module. +#[allow(dead_code)] +pub mod canonical; +#[allow(dead_code)] +pub mod client; +#[allow(dead_code)] +pub mod host; +#[allow(dead_code)] +pub mod journal; +#[allow(dead_code)] +pub mod wire; + +#[cfg(test)] +mod fake_runtime; +#[cfg(test)] +mod fixtures; +#[cfg(test)] +mod host_tests; +#[cfg(test)] +mod tests; diff --git a/src/session_protocol/tests.rs b/src/session_protocol/tests.rs new file mode 100644 index 0000000..405577e --- /dev/null +++ b/src/session_protocol/tests.rs @@ -0,0 +1,979 @@ +//! Tests for atem-session v1 wire types, strict decoding and the canonical digest. +//! Fixtures are read from `designs/protocol/fixtures/` at test time. + +use super::canonical::{self, Numbers, StrictError}; +use super::wire::{self, Binding, BindingUse, Envelope, ErrorCode, Op}; +use serde_json::{Value, json}; +use std::path::PathBuf; + +fn fixtures_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("designs/protocol/fixtures") +} + +fn read_fixture(name: &str) -> Value { + let bytes = std::fs::read(fixtures_dir().join(name)).expect("read fixture"); + canonical::parse_strict(&bytes, usize::MAX, Numbers::AllowFractions) + .unwrap_or_else(|e| panic!("{name} is not strict JSON: {e:?}")) +} + +fn hex_decode(s: &str) -> Vec { + (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()) + .collect() +} + +// ── digest vectors ──────────────────────────────────────────────────────── + +#[test] +fn digest_vectors_canonicalise_and_hash() { + let file = read_fixture("digest-vectors.json"); + let vectors = file["vectors"].as_array().unwrap(); + assert!(vectors.len() >= 10); + for v in vectors { + let name = v["name"].as_str().unwrap(); + let canonical = canonical::jcs(&v["payload"]).unwrap(); + assert_eq!(canonical, v["canonical"].as_str().unwrap(), "{name}"); + let digest = canonical::payload_digest(&v["payload"]).unwrap(); + assert_eq!(digest, v["sha256"].as_str().unwrap(), "{name}"); + assert!(wire::is_digest(&digest)); + // Canonical form is a fixed point of parse + canonicalise. + let reparsed = canonical::parse_payload(canonical.as_bytes()).unwrap(); + assert_eq!(canonical::jcs(&reparsed).unwrap(), canonical, "{name}"); + } +} + +fn must_reject_inputs() -> Vec<(String, Vec)> { + let file = read_fixture("digest-vectors.json"); + let rejects = file["must_reject"].as_array().unwrap(); + assert!(rejects.len() >= 9); + rejects + .iter() + .map(|r| { + let bytes = match (r.get("payload_json"), r.get("payload_hex")) { + (Some(Value::String(s)), None) => s.as_bytes().to_vec(), + (None, Some(Value::String(h))) => hex_decode(h), + other => panic!("bad must_reject entry {other:?}"), + }; + (r["name"].as_str().unwrap().to_string(), bytes) + }) + .collect() +} + +#[test] +fn digest_must_reject_inputs_are_refused() { + for (name, bytes) in must_reject_inputs() { + assert!( + canonical::parse_payload(&bytes).is_err(), + "{name} was accepted" + ); + } +} + +fn submit_with_payload(payload: &[u8]) -> Vec { + let mut out = br#"{"type":"session","data":{"v":1,"op":"submit","id":"11111111-1111-4111-8111-000000000001","binding":{"target":{"account_id":"account-a","atem_device_id":"host-a","host_trust_gen":1,"session_id":"ses-aaaaaaaaaaaa","process_gen":"22222222-2222-4222-8222-222222222222"},"attachment_gen":"33333333-3333-4333-8333-333333333333","lease_epoch":1},"request_id":"44444444-4444-4444-8444-444444444447","kind":"approval","payload":"#.to_vec(); + out.extend_from_slice(payload); + out.extend_from_slice(b"}}"); + out +} + +#[test] +fn must_reject_payloads_are_refused_inside_a_submit() { + // Sanity: a valid payload in the same frame decodes. + assert!(wire::decode(&submit_with_payload(br#"{"text":"a"}"#)).is_ok()); + for (name, bytes) in must_reject_inputs() { + let err = wire::decode(&submit_with_payload(&bytes)) + .err() + .unwrap_or_else(|| panic!("{name} accepted in a submit")); + assert_eq!(err.code, ErrorCode::UnsupportedVersion, "{name}"); + } +} + +#[test] +fn jcs_rejects_values_outside_the_domain() { + assert!(canonical::jcs(&json!({"n": 1.5})).is_err()); + assert!(canonical::jcs(&json!({"n": 1.0})).is_err()); + assert!(canonical::jcs(&json!({"n": 9007199254740992u64})).is_err()); + assert!(canonical::jcs(&json!({"n": -9007199254740992i64})).is_err()); + assert_eq!(canonical::jcs(&json!([0, -0, -5])).unwrap(), "[0,0,-5]"); + assert_eq!( + canonical::jcs(&json!({"s": "\u{7f}\u{2028}Γ©"})).unwrap(), + "{\"s\":\"\u{7f}\u{2028}Γ©\"}" + ); +} + +// ── strict parser ───────────────────────────────────────────────────────── + +#[test] +fn strict_parser_rules() { + let p = |s: &str| canonical::parse_strict(s.as_bytes(), 1 << 20, Numbers::AllowFractions); + let i = |s: &str| canonical::parse_strict(s.as_bytes(), 1 << 20, Numbers::IntegersOnly); + assert_eq!( + p(r#"{"a":[1,"x",null,true]}"#).unwrap(), + json!({"a":[1,"x",null,true]}) + ); + assert_eq!(p(r#""\ud83d\ude00""#).unwrap(), json!("πŸ˜€")); + assert_eq!(p(" { } ").unwrap(), json!({})); + assert_eq!(p("1.5").unwrap(), json!(1.5)); + assert_eq!(i("1.5"), Err(StrictError::NonInteger)); + assert_eq!(i("1e3"), Err(StrictError::NonInteger)); + assert_eq!(i("-0"), Ok(json!(0))); + assert_eq!(p(r#"{"a":1,"a":1}"#), Err(StrictError::DuplicateKey)); + assert_eq!( + p(r#"[{"b":{"c":1,"c":2}}]"#), + Err(StrictError::DuplicateKey) + ); + // Escaped spelling of the same key is still a duplicate. + assert_eq!(p(r#"{"a":1,"\u0061":2}"#), Err(StrictError::DuplicateKey)); + assert_eq!(p("9007199254740992"), Err(StrictError::UnsafeInteger)); + assert_eq!(p("-9007199254740992"), Err(StrictError::UnsafeInteger)); + assert_eq!( + p("123456789012345678901234567890"), + Err(StrictError::UnsafeInteger) + ); + assert_eq!(p(r#""\udc00""#), Err(StrictError::LoneSurrogate)); + assert_eq!(p(r#""\ud800\u0041""#), Err(StrictError::LoneSurrogate)); + assert_eq!( + canonical::parse_strict(b"\"\xff\"", 100, Numbers::AllowFractions), + Err(StrictError::InvalidUtf8) + ); + assert_eq!( + canonical::parse_strict(b"[1, 2]", 5, Numbers::AllowFractions), + Err(StrictError::TooLarge) + ); + for bad in [ + "", + "{", + "[1,]", + "{\"a\" 1}", + "01", + "1.", + ".5", + "+1", + "tru", + "\"a\nb\"", + "1 2", + "{'a':1}", + "\"\\x\"", + ] { + assert!(matches!(p(bad), Err(StrictError::Syntax(_))), "{bad:?}"); + } + let deep = "[".repeat(10_000) + &"]".repeat(10_000); + assert_eq!(p(&deep), Err(StrictError::TooDeep)); +} + +// ── fixtures: every wire message decodes and round-trips ───────────────── + +/// Replaces harness placeholders (`${name}`) with a valid value. +fn substitute(v: &mut Value) { + match v { + Value::String(s) if s.contains("${") => *s = "stream-0001".to_string(), + Value::Array(a) => a.iter_mut().for_each(substitute), + Value::Object(m) => m.values_mut().for_each(substitute), + _ => {} + } +} + +fn collect_messages(v: &Value, out: &mut Vec) { + match v { + Value::Object(m) => { + if let Some(msg @ Value::Object(_)) = m.get("json") { + out.push(msg.clone()); + } + m.values().for_each(|c| collect_messages(c, out)); + } + Value::Array(a) => a.iter().for_each(|c| collect_messages(c, out)), + _ => {} + } +} + +fn round_trip(msg: &Value) -> Envelope { + let bytes = serde_json::to_vec(msg).unwrap(); + let env = wire::decode(&bytes).unwrap_or_else(|e| panic!("decode failed: {e:?}\n{msg}")); + let again = wire::encode(&env); + let reparsed: Value = serde_json::from_slice(&again).unwrap(); + assert_eq!(&reparsed, msg, "round trip changed the message"); + // And the re-encoded form decodes again. + wire::decode(&again).unwrap(); + env +} + +/// Does the step after a `send` expect `unsupported_version` (a message the +/// fixture means to be refused)? +fn refused_by_next(steps: &[Value], i: usize) -> bool { + steps + .get(i + 1) + .is_some_and(|n| n.get("expect").is_some() && n["match"]["code"] == "unsupported_version") +} + +#[test] +fn every_fixture_wire_message_decodes_and_round_trips() { + let mut files: Vec<_> = std::fs::read_dir(fixtures_dir()) + .unwrap() + .map(|e| e.unwrap().path()) + .filter(|p| p.extension().is_some_and(|e| e == "json")) + .filter(|p| p.file_name().unwrap() != "digest-vectors.json") + .collect(); + files.sort(); + assert!(files.len() >= 7, "fixture files missing"); + let (mut total, mut session, mut bound, mut refused) = (0, 0, 0, 0); + for path in files { + let name = path.file_name().unwrap().to_string_lossy().to_string(); + let file = read_fixture(&name); + let steps = file["steps"].as_array().unwrap(); + for (i, step) in steps.iter().enumerate() { + let mut msgs = Vec::new(); + collect_messages(step, &mut msgs); + for mut msg in msgs { + substitute(&mut msg); + total += 1; + if refused_by_next(steps, i) { + let e = wire::decode(&serde_json::to_vec(&msg).unwrap()) + .expect_err(&format!("{name}: refused message decoded: {msg}")); + assert_eq!(e.code, ErrorCode::UnsupportedVersion); + refused += 1; + continue; + } + match round_trip(&msg) { + Envelope::Session(_) => session += 1, + Envelope::Family(f) => { + assert_ne!(msg["type"], "session"); + if f.binding.is_some() { + bound += 1; + } + } + } + } + } + } + eprintln!( + "fixture wire messages: {total} ({session} session, {bound} bound terminal/chat, {refused} refused)" + ); + assert!( + total >= 40 && session >= 25 && refused >= 12, + "{total} / {session} / {refused}" + ); +} + +// ── every op round-trips ────────────────────────────────────────────────── + +const ID: &str = "11111111-1111-4111-8111-000000000001"; +const RE: &str = "11111111-1111-4111-8111-000000000002"; +const PG: &str = "22222222-2222-4222-8222-222222222222"; +const AG: &str = "33333333-3333-4333-8333-333333333333"; + +fn target() -> Value { + json!({"account_id":"account-a","atem_device_id":"host-a","host_trust_gen":1, + "session_id":"ses-aaaaaaaaaaaa","process_gen":PG}) +} + +fn binding(epoch: Option) -> Value { + let mut b = json!({"target": target(), "attachment_gen": AG}); + if let Some(e) = epoch { + b["lease_epoch"] = json!(e); + } + b +} + +fn descriptor() -> Value { + json!({"session_id":"ses-abcdefghijkl","process_gen":PG,"provider":"claude", + "cwd_label":"Atem","state":"running","activity":"ready","activity_since":1760500000000u64, + "controller":{"controller_session":RE,"lease_epoch":7}, + "capabilities":["terminal-v1","chat-v1"],"created_at":1760490000000u64}) +} + +fn session(op: &str, mut fields: Value) -> Value { + let m = fields.as_object_mut().unwrap(); + m.insert("v".into(), json!(1)); + m.insert("op".into(), json!(op)); + m.insert("id".into(), json!(ID)); + json!({"type":"session","data":fields}) +} + +fn all_ops() -> Vec { + let receipt = json!({"request_id":RE,"target":target(),"kind":"prompt","state":"completed", + "detail":"done","updated_at":1760500000000u64}); + vec![ + session("hello", json!({})), + session("hello", json!({"resume":{"controller_session":RE}})), + session( + "challenge", + json!({"re":RE,"nonce":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}), + ), + session("resume_proof", json!({"re":RE,"proof":"c2ln"})), + session( + "welcome", + json!({"re":RE,"account_id":"account-a","atem_device_id":"host-a", + "host_trust_gen":1,"runtime_gen":PG,"controller_session":RE,"attachment_gen":AG, + "client_kind":"native","permissions":["view","control","approve"], + "authorization_ms_left":86400000,"server_time":1760500000000u64}), + ), + session("list", json!({})), + session( + "sessions", + json!({"re":RE,"runtime_gen":PG,"runtime_epoch":7,"seq":1,"sessions":[descriptor()]}), + ), + session( + "sessions", + json!({"runtime_gen":PG,"runtime_epoch":7,"seq":0,"sessions":[]}), + ), + session( + "session_changed", + json!({"runtime_gen":PG,"runtime_epoch":7,"seq":2,"descriptor":descriptor()}), + ), + session("acquire", json!({"binding":binding(None),"takeover":true})), + session("renew", json!({"binding":binding(Some(7))})), + session("release", json!({"binding":binding(Some(7))})), + session( + "lease", + json!({"re":RE,"binding":binding(Some(7)),"ms_left":15000}), + ), + session("lease_released", json!({"re":RE})), + session( + "lease_changed", + json!({"session_id":"ses-aaaaaaaaaaaa","process_gen":PG, + "controller":null,"lease_epoch":8,"reason":"local_input"}), + ), + session( + "lease_changed", + json!({"session_id":"ses-aaaaaaaaaaaa","process_gen":PG, + "controller":{"controller_session":RE,"lease_epoch":9},"lease_epoch":9,"reason":"takeover"}), + ), + session( + "submit", + json!({"binding":binding(Some(7)),"request_id":RE,"kind":"prompt","payload":{"text":"run the tests"}}), + ), + session( + "submit", + json!({"binding":binding(Some(7)),"request_id":RE,"kind":"interrupt","payload":{}}), + ), + session( + "submit", + json!({"binding":binding(Some(7)),"request_id":RE,"kind":"stop","payload":{}}), + ), + session( + "submit", + json!({"binding":binding(Some(7)),"request_id":RE,"kind":"approval","payload":{"x":[1,{"y":null}]}}), + ), + session("receipt", { + let mut r = receipt.clone(); + r["re"] = json!(RE); + r + }), + session( + "receipt_query", + json!({"binding":binding(None),"request_ids":[RE, ID]}), + ), + session("receipts", json!({"re":RE,"receipts":[receipt]})), + session("error", json!({"re":RE,"code":"stale_target"})), + session( + "error", + json!({"re":RE,"code":"forbidden","detail":"approval answers are not supported in v1"}), + ), + // terminal-v1 / chat-v1 with data.binding (Β§2) + json!({"type":"terminal","data":{"v":1,"op":"subscribe","terminal_id":"ses-aaaaaaaaaaaa","generation":PG, + "binding":binding(None)}}), + json!({"type":"terminal","data":{"v":1,"op":"input","terminal_id":"ses-aaaaaaaaaaaa","generation":PG, + "stream_id":"s1","sequence":0,"bytes":"aGkN","binding":binding(Some(7))}}), + json!({"type":"terminal","data":{"v":1,"op":"resize","terminal_id":"ses-aaaaaaaaaaaa","generation":PG, + "stream_id":"s1","rows":40,"cols":120,"binding":binding(Some(7))}}), + json!({"type":"terminal","data":{"v":1,"op":"stop","terminal_id":"ses-aaaaaaaaaaaa","generation":PG, + "stream_id":"s1","binding":binding(Some(7))}}), + json!({"type":"agentChat","data":{"v":1,"op":"open","conversationId":"c1","agentId":"ses-aaaaaaaaaaaa", + "binding":binding(None)}}), + json!({"type":"agentChat","data":{"v":1,"op":"submit","conversationId":"c1","agentId":"ses-aaaaaaaaaaaa", + "generation":PG,"requestId":RE,"text":"run the tests","binding":binding(Some(7))}}), + // another family, untouched + json!({"type":"heartbeat","data":{"ts":1}}), + ] +} + +#[test] +fn every_op_decodes_and_round_trips() { + for msg in all_ops() { + round_trip(&msg); + } +} + +#[test] +fn decoded_values_are_typed() { + let env = round_trip(&all_ops()[16]); + let Envelope::Session(m) = env else { panic!() }; + assert_eq!(m.id, ID); + let Op::Submit { + binding, + kind, + payload, + .. + } = m.op + else { + panic!() + }; + assert_eq!(binding.lease_epoch, Some(7)); + assert_eq!(binding.target.session_id, "ses-aaaaaaaaaaaa"); + assert_eq!(kind, wire::ReceiptKind::Prompt); + assert_eq!( + canonical::payload_digest(&payload).unwrap(), + "51b365c654de71c69dac5afdc09dbbf06929ef6137ee7878f32937071d2923a8" + ); +} + +#[test] +fn terminal_requests_decode_typed() { + let ops = all_ops(); + let request = |msg: &Value| { + let Envelope::Family(f) = wire::decode(&serde_json::to_vec(msg).unwrap()).unwrap() else { + panic!() + }; + f.request + }; + assert_eq!( + request(&ops[26]), + Some(wire::TerminalRequest::Input { + terminal_id: "ses-aaaaaaaaaaaa".into(), + generation: PG.into(), + stream_id: "s1".into(), + sequence: 0, + bytes: "aGkN".into(), + }) + ); + assert!(matches!( + request(&ops[27]), + Some(wire::TerminalRequest::Resize { + rows: 40, + cols: 120, + .. + }) + )); + // Chat and host messages have no terminal request. + assert_eq!(request(&ops[30]), None); + let exited = json!({"type":"terminal","data":{"v":1,"op":"exited","terminal_id":"ses-aaaaaaaaaaaa", + "generation":PG,"status":"0"}}); + assert_eq!(request(&exited), None); +} + +#[test] +fn family_binding_ids_must_agree() { + let check = |msg: Value| { + let Envelope::Family(f) = wire::decode(&serde_json::to_vec(&msg).unwrap()).unwrap() else { + panic!() + }; + f.check_binding_ids() + }; + let ok = json!({"type":"terminal","data":{"v":1,"op":"subscribe","terminal_id":"ses-aaaaaaaaaaaa", + "generation":PG,"binding":binding(None)}}); + assert_eq!(check(ok), Ok(())); + let wrong_id = json!({"type":"terminal","data":{"v":1,"op":"subscribe","terminal_id":"ses-bbbbbbbbbbbb", + "generation":PG,"binding":binding(None)}}); + assert_eq!(check(wrong_id), Err(ErrorCode::StaleTarget)); + let wrong_gen = json!({"type":"agentChat","data":{"v":1,"op":"submit","conversationId":"c1", + "agentId":"ses-aaaaaaaaaaaa","generation":AG,"requestId":RE,"text":"x","binding":binding(Some(7))}}); + assert_eq!(check(wrong_gen), Err(ErrorCode::StaleTarget)); + let other = json!({"type":"heartbeat","data":{"ts":1}}); + assert_eq!(check(other), Ok(())); +} + +// ── negative cases per Β§1 row ───────────────────────────────────────────── + +fn rejects(msg: &Value) -> wire::DecodeError { + match wire::decode(&serde_json::to_vec(msg).unwrap()) { + Ok(env) => panic!("accepted: {msg}\n{env:?}"), + Err(e) => e, + } +} + +fn with(mut msg: Value, path: &str, value: Option) -> Value { + let mut parts: Vec<&str> = path.split('.').collect(); + let last = parts.pop().unwrap(); + let mut cur = &mut msg["data"]; + for p in parts { + cur = match p.parse::() { + Ok(i) => &mut cur[i], + Err(_) => &mut cur[p], + }; + } + match value { + Some(v) => match last.parse::() { + Ok(i) => cur[i] = v, + Err(_) => cur[last] = v, + }, + None => { + cur.as_object_mut().unwrap().remove(last); + } + } + msg +} + +fn submit() -> Value { + all_ops()[16].clone() +} + +#[test] +fn envelope_and_version() { + rejects(&json!({"data":{}})); + rejects(&json!({"type":"session"})); + rejects(&json!({"type":"session","data":[]})); + rejects(&json!([])); + let e = rejects(&with(submit(), "v", Some(json!(2)))); + assert_eq!(e.code, ErrorCode::UnsupportedVersion); + assert_eq!( + e.id.as_deref(), + Some(ID), + "the error can still answer the request" + ); + rejects(&with(submit(), "v", Some(json!("1")))); + rejects(&with(submit(), "v", None)); + rejects(&with(submit(), "op", Some(json!("frobnicate")))); + rejects(&with(submit(), "id", None)); + rejects(&with(submit(), "re", Some(json!(null)))); + // Unknown fields are ignored. + let extra = with(submit(), "future_field", Some(json!({"a":1.5}))); + assert!(wire::decode(&serde_json::to_vec(&extra).unwrap()).is_ok()); + let extra = with(submit(), "binding.target.future", Some(json!(true))); + assert!(wire::decode(&serde_json::to_vec(&extra).unwrap()).is_ok()); +} + +#[test] +fn uuids_are_lowercase_canonical_v4() { + for bad in [ + "11111111-1111-4111-8111-00000000000A", + "11111111-1111-1111-8111-000000000001", + "11111111-1111-4111-c111-000000000001", + "11111111-1111-4111-8111-0000000000001", + "111111111111-4111-8111-000000000001", + "{11111111-1111-4111-8111-000000000001}", + "", + ] { + assert!(!wire::is_uuid_v4(bad), "{bad}"); + rejects(&with(submit(), "id", Some(json!(bad)))); + rejects(&with(submit(), "request_id", Some(json!(bad)))); + rejects(&with(submit(), "binding.attachment_gen", Some(json!(bad)))); + rejects(&with( + submit(), + "binding.target.process_gen", + Some(json!(bad)), + )); + } + for good in [ + "11111111-1111-4111-8111-000000000001", + "aaaaaaaa-aaaa-4aaa-baaa-aaaaaaaaaaaa", + ] { + assert!(wire::is_uuid_v4(good)); + } +} + +#[test] +fn session_ids() { + assert!(wire::is_session_id("ses-abcdefghijkl")); + assert!(wire::is_session_id("ses-a2b3c4d5e6f7")); + for bad in [ + "ses-ABCDEFGHIJKL", + "ses-abcdefghijk", + "ses-abcdefghijklm", + "ses-abcdefghijk1", + "ses-abcdefghijk8", + "abc-abcdefghijkl", + "claude", + ] { + assert!(!wire::is_session_id(bad), "{bad}"); + rejects(&with( + submit(), + "binding.target.session_id", + Some(json!(bad)), + )); + } + let mut d = descriptor(); + d["session_id"] = json!("ses-abc"); + rejects(&session( + "session_changed", + json!({"runtime_gen":PG,"runtime_epoch":7,"seq":2,"descriptor":d}), + )); +} + +#[test] +fn opaque_ids() { + assert!(wire::is_opaque_id(&"a".repeat(128))); + assert!(wire::is_opaque_id("A.b_c:d-9")); + for bad in [ + String::new(), + "a".repeat(129), + "a b".into(), + "a/b".into(), + "Γ©".into(), + ] { + assert!(!wire::is_opaque_id(&bad), "{bad}"); + rejects(&with( + submit(), + "binding.target.account_id", + Some(json!(bad)), + )); + rejects(&with( + submit(), + "binding.target.atem_device_id", + Some(json!(bad)), + )); + } +} + +#[test] +fn counters_and_times() { + let max = json!(9007199254740991u64); + for path in ["binding.target.host_trust_gen", "binding.lease_epoch"] { + assert!( + wire::decode(&serde_json::to_vec(&with(submit(), path, Some(max.clone()))).unwrap()) + .is_ok() + ); + for bad in [json!(-1), json!(1.5), json!("1"), json!(null)] { + rejects(&with(submit(), path, Some(bad))); + } + } + // Exponent / fraction spellings and unsafe integers are refused before typing. + let raw = String::from_utf8(serde_json::to_vec(&submit()).unwrap()).unwrap(); + for (from, to) in [ + ("\"lease_epoch\":7", "\"lease_epoch\":7.0"), + ("\"lease_epoch\":7", "\"lease_epoch\":7e0"), + ("\"lease_epoch\":7", "\"lease_epoch\":9007199254740992"), + ( + "\"host_trust_gen\":1", + "\"host_trust_gen\":18446744073709551616", + ), + ] { + assert!(raw.contains(from)); + assert!( + wire::decode(raw.replace(from, to).as_bytes()).is_err(), + "{to}" + ); + } + let changed = |path: &str, v: Value| { + session( + "session_changed", + json!({"runtime_gen":PG,"runtime_epoch":7,"seq":2,"descriptor":descriptor()}), + ) + .pipe(|m| with(m, path, Some(v))) + }; + rejects(&changed("seq", json!(-1))); + rejects(&changed("runtime_epoch", json!(1.5))); + rejects(&changed("descriptor.activity_since", json!(1.5))); + rejects(&changed("descriptor.created_at", json!(-5))); + rejects(&changed("descriptor.controller.lease_epoch", json!("7"))); +} + +trait Pipe: Sized { + fn pipe(self, f: impl FnOnce(Self) -> T) -> T { + f(self) + } +} +impl Pipe for Value {} + +#[test] +fn digests() { + assert!(wire::is_digest(&"a".repeat(64))); + assert!(!wire::is_digest(&"A".repeat(64))); + assert!(!wire::is_digest(&"a".repeat(63))); + assert!(!wire::is_digest(&"g".repeat(64))); +} + +#[test] +fn bounds() { + // Message size is checked before parsing. + let mut big = serde_json::to_vec(&submit()).unwrap(); + big.extend(std::iter::repeat_n(b' ', wire::MAX_MESSAGE_BYTES)); + assert_eq!( + wire::decode(&big).unwrap_err().code, + ErrorCode::UnsupportedVersion + ); + // Prompt text. + let at = "a".repeat(wire::MAX_PROMPT_BYTES); + assert!( + wire::decode( + &serde_json::to_vec(&with(submit(), "payload.text", Some(json!(at)))).unwrap() + ) + .is_ok() + ); + rejects(&with( + submit(), + "payload.text", + Some(json!("a".repeat(wire::MAX_PROMPT_BYTES + 1))), + )); + // Canonical payload size (approval is the only kind with a free-form payload). + let approval = |n: usize| { + with( + with(submit(), "kind", Some(json!("approval"))), + "payload", + Some(json!({"t": "a".repeat(n)})), + ) + }; + assert!( + wire::decode(&serde_json::to_vec(&approval(wire::MAX_PAYLOAD_BYTES - 8)).unwrap()).is_ok() + ); + rejects(&approval(wire::MAX_PAYLOAD_BYTES - 7)); + // receipt_query.request_ids + let ids: Vec = (0..65) + .map(|i| format!("11111111-1111-4111-8111-{i:012}")) + .collect(); + let q = |n: usize| { + session( + "receipt_query", + json!({"binding":binding(None),"request_ids":ids[..n].to_vec()}), + ) + }; + assert!(wire::decode(&serde_json::to_vec(&q(64)).unwrap()).is_ok()); + rejects(&q(65)); + // detail + let err = |n: usize| session("error", json!({"code":"busy","detail":"d".repeat(n)})); + assert!(wire::decode(&serde_json::to_vec(&err(512)).unwrap()).is_ok()); + rejects(&err(513)); +} + +#[test] +fn payload_shapes_per_kind() { + rejects(&with(submit(), "payload", Some(json!({})))); + rejects(&with(submit(), "payload", Some(json!({"text": 1})))); + rejects(&with(submit(), "payload", Some(json!("text")))); + rejects(&with( + submit(), + "payload", + Some(json!({"text":"a","more":1})), + )); + rejects(&with(submit(), "payload", None)); + let interrupt = with(submit(), "kind", Some(json!("interrupt"))); + rejects(&interrupt); + rejects(&with(submit(), "kind", Some(json!("shell")))); +} + +#[test] +fn duplicate_keys_anywhere_in_a_message() { + let raw = String::from_utf8(serde_json::to_vec(&submit()).unwrap()).unwrap(); + for (from, to) in [ + ("\"op\":\"submit\"", "\"op\":\"submit\",\"op\":\"submit\""), + ("\"lease_epoch\":7", "\"lease_epoch\":7,\"lease_epoch\":8"), + ( + "\"host_trust_gen\":1", + "\"host_trust_gen\":1,\"host_trust_gen\":1", + ), + ( + "\"text\":\"run the tests\"", + "\"text\":\"run the tests\",\"text\":\"x\"", + ), + ( + "\"type\":\"session\"", + "\"type\":\"session\",\"type\":\"session\"", + ), + ] { + assert!(raw.contains(from), "{from}"); + assert!( + wire::decode(raw.replace(from, to).as_bytes()).is_err(), + "{to}" + ); + } + // Lone surrogates and invalid UTF-8 inside a message. + let lone = raw.replace("run the tests", "run \\ud800 tests"); + assert!(wire::decode(lone.as_bytes()).is_err()); + let mut bytes = raw.into_bytes(); + let at = bytes.windows(3).position(|w| w == b"run").unwrap(); + bytes[at] = 0xff; + assert!(wire::decode(&bytes).is_err()); +} + +#[test] +fn descriptors_need_every_field() { + for field in [ + "session_id", + "process_gen", + "provider", + "cwd_label", + "state", + "activity", + "activity_since", + "controller", + "capabilities", + "created_at", + ] { + let mut d = descriptor(); + d.as_object_mut().unwrap().remove(field); + rejects(&session( + "session_changed", + json!({"runtime_gen":PG,"runtime_epoch":7,"seq":2,"descriptor":d.clone()}), + )); + rejects(&session( + "sessions", + json!({"runtime_gen":PG,"runtime_epoch":7,"seq":2,"sessions":[d]}), + )); + } + let mut d = descriptor(); + d["state"] = json!("sleeping"); + rejects(&session( + "session_changed", + json!({"runtime_gen":PG,"runtime_epoch":7,"seq":2,"descriptor":d}), + )); + let mut d = descriptor(); + d["activity"] = json!("idle"); + rejects(&session( + "session_changed", + json!({"runtime_gen":PG,"runtime_epoch":7,"seq":2,"descriptor":d}), + )); + // lease_changed.controller is required too (null when nobody holds it). + let lc = all_ops()[14].clone(); + rejects(&with(lc.clone(), "controller", None)); + rejects(&with(lc, "reason", Some(json!("bored")))); +} + +#[test] +fn lease_epoch_presence_follows_mutation_rules() { + let ops = all_ops(); + rejects(&with(ops[9].clone(), "binding.lease_epoch", Some(json!(1)))); // acquire + rejects(&with(ops[10].clone(), "binding.lease_epoch", None)); // renew + rejects(&with(ops[11].clone(), "binding.lease_epoch", None)); // release + rejects(&with(ops[12].clone(), "binding.lease_epoch", None)); // lease + rejects(&with(ops[16].clone(), "binding.lease_epoch", None)); // submit + rejects(&with( + ops[21].clone(), + "binding.lease_epoch", + Some(json!(1)), + )); // receipt_query + rejects(&with(ops[9].clone(), "takeover", None)); + // terminal-v1 / chat-v1 bound forms + rejects(&with( + ops[25].clone(), + "binding.lease_epoch", + Some(json!(1)), + )); // subscribe + rejects(&with(ops[26].clone(), "binding.lease_epoch", None)); // input + rejects(&with(ops[27].clone(), "binding.lease_epoch", None)); // resize + rejects(&with(ops[28].clone(), "binding.lease_epoch", None)); // stop + rejects(&with( + ops[29].clone(), + "binding.lease_epoch", + Some(json!(1)), + )); // chat open + rejects(&with(ops[30].clone(), "binding.lease_epoch", None)); // chat submit + rejects(&with( + ops[26].clone(), + "binding.target.session_id", + Some(json!("claude")), + )); + rejects(&with(ops[26].clone(), "binding", Some(json!("x")))); + // A bound op still needs its own fields, in their v1 shapes. + rejects(&with(ops[26].clone(), "terminal_id", None)); + rejects(&with(ops[30].clone(), "generation", None)); + rejects(&with(ops[26].clone(), "sequence", Some(json!(-1)))); + rejects(&with(ops[26].clone(), "bytes", Some(json!("not base64!")))); + rejects(&with(ops[30].clone(), "requestId", Some(json!("r1")))); + rejects(&with(ops[29].clone(), "agentId", Some(json!("claude")))); + // Provider-name terminal ids are gone (an invalid representation, not a + // removed op). + let claude = with(ops[25].clone(), "terminal_id", Some(json!("claude"))); + assert_eq!(rejects(&claude).removed, None); + // Other families never carry a binding. + rejects(&json!({"type":"heartbeat","data":{"ts":1,"binding":binding(None)}})); + rejects(&json!({"type":"terminal","data":{"v":1,"op":"warp"}})); + rejects(&json!({"type":"terminal","data":{"v":1,"op":"frame","binding":binding(None)}})); +} + +/// r6 Β§0: removed forms fail with `removed` set (answered `unsupported_version`). +#[test] +fn removed_ops_decode_as_removed() { + let ops = all_ops(); + let removed = |msg: &Value| { + let e = rejects(msg); + assert_eq!(e.code, ErrorCode::UnsupportedVersion); + let r = e + .removed + .unwrap_or_else(|| panic!("not marked removed: {msg}")); + (r.family, r.op) + }; + for family in wire::REMOVED_FAMILIES { + let msg = json!({"type": family, "data": {"agent_id": null, "kind": "text", "text": "x"}}); + assert_eq!(removed(&msg), (family.to_string(), None)); + assert_eq!( + removed(&json!({"type": family})), + (family.to_string(), None) + ); + } + let t = |op: &str| ("terminal".to_string(), Some(op.to_string())); + let control = json!({"type":"terminal","data":{"v":1,"op":"control","terminal_id":"ses-aaaaaaaaaaaa", + "generation":PG,"stream_id":"s1","take":true}}); + assert_eq!(removed(&control), t("control")); + let controller = json!({"type":"terminal","data":{"v":1,"op":"controller","terminal_id":"ses-aaaaaaaaaaaa", + "generation":PG,"stream_id":"s1","lease_id":null}}); + assert_eq!(removed(&controller), t("controller")); + // session-v1 `list` is the only registry. + let list = json!({"type":"terminal","data":{"v":1,"op":"list"}}); + assert_eq!(removed(&list), t("list")); + let sessions = json!({"type":"terminal","data":{"v":1,"op":"sessions","sessions":[]}}); + assert_eq!(removed(&sessions), t("sessions")); + // terminal-v1 requests without their binding, or with a lease_id. + for (i, op) in [ + (25, "subscribe"), + (26, "input"), + (27, "resize"), + (28, "stop"), + ] { + assert_eq!(removed(&with(ops[i].clone(), "binding", None)), t(op)); + } + assert_eq!( + removed(&with(ops[26].clone(), "lease_id", Some(json!("l")))), + t("input") + ); + // chat-v1 requests without their binding. + let c = |op: &str| ("agentChat".to_string(), Some(op.to_string())); + assert_eq!(removed(&with(ops[29].clone(), "binding", None)), c("open")); + assert_eq!( + removed(&with(ops[30].clone(), "binding", None)), + c("submit") + ); +} + +#[test] +fn binding_validator() { + let b: Binding = serde_json::from_value(binding(Some(7))).unwrap(); + assert!(b.validate(BindingUse::Mutation).is_ok()); + assert!(b.validate(BindingUse::Read).is_err()); + assert_eq!(b.check_family_ids("ses-aaaaaaaaaaaa", Some(PG)), Ok(())); + assert_eq!(b.check_family_ids("ses-aaaaaaaaaaaa", None), Ok(())); + assert_eq!( + b.check_family_ids("ses-aaaaaaaaaaab", Some(PG)), + Err(ErrorCode::StaleTarget) + ); + assert_eq!( + b.check_family_ids("ses-aaaaaaaaaaaa", Some(AG)), + Err(ErrorCode::StaleTarget) + ); +} + +#[test] +fn enums_and_codes() { + rejects(&session("error", json!({"code":"teapot"}))); + rejects(&with( + all_ops()[4].clone(), + "client_kind", + Some(json!("web")), + )); + rejects(&with( + all_ops()[4].clone(), + "permissions", + Some(json!(["view", "root"])), + )); + rejects(&with(all_ops()[20].clone(), "state", Some(json!("done")))); + rejects(&with( + all_ops()[20].clone(), + "detail", + Some(json!("d".repeat(513))), + )); + rejects(&with(all_ops()[2].clone(), "nonce", Some(json!("short")))); + rejects(&with( + all_ops()[2].clone(), + "nonce", + Some(json!("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA+")), + )); + rejects(&with(all_ops()[3].clone(), "proof", Some(json!("")))); + rejects(&with( + all_ops()[1].clone(), + "resume.controller_session", + Some(json!("x")), + )); + assert_eq!( + serde_json::to_value(ErrorCode::RequestIdReused).unwrap(), + json!("request_id_reused") + ); +} diff --git a/src/session_protocol/wire.rs b/src/session_protocol/wire.rs new file mode 100644 index 0000000..be9af73 --- /dev/null +++ b/src/session_protocol/wire.rs @@ -0,0 +1,949 @@ +//! atem-session v1 wire types (Β§1–§7) and the strict decoder. +//! +//! `decode` runs in three layers: +//! 1. `canonical::parse_strict` β€” size bound before anything is allocated, +//! UTF-8, duplicate keys at any depth, lone surrogates, unsafe integers; +//! 2. serde into the typed messages below β€” integers are `u64`, so a fraction, +//! exponent, negative or string where an integer is required is refused, and +//! every required field must be present (unknown fields are ignored); +//! 3. `validate` β€” the Β§1 representation rules (UUID v4, `session_id`, opaque +//! ids, counter range, bounds) and per-op rules (`lease_epoch` presence, +//! payload shape per kind). +//! +//! Every decode failure is `unsupported_version` (Β§7: out-of-range +//! representation); `DecodeError::id` carries the request id when it could be +//! read, so the host can still answer with `re`. +//! +//! Contract r6 Β§0 has no legacy paths: the removed families (`agentInput`, +//! `agentPrompt`, `voiceCommand`, `voiceToggle`, `userCommand`), terminal-v1 +//! `control`/`controller`/`list`/`sessions` and any terminal-v1 or chat-v1 request without its +//! `binding` or with a `lease_id` fail with `DecodeError::removed` set, so the +//! host answers `unsupported_version` and does nothing. terminal-v1 requests +//! decode into `TerminalRequest` (terminal ids are session ids). + +use super::canonical::{self, MAX_SAFE_INTEGER, Numbers}; +use serde::{Deserialize, Deserializer, Serialize}; +use serde_json::{Map, Value}; + +pub const VERSION: u64 = 1; +pub const MAX_MESSAGE_BYTES: usize = 1024 * 1024; +pub const MAX_PAYLOAD_BYTES: usize = 64 * 1024; +pub const MAX_PROMPT_BYTES: usize = 32_768; +pub const MAX_REQUEST_IDS: usize = 64; +pub const MAX_DETAIL_BYTES: usize = 512; +pub const MAX_OPAQUE_ID_BYTES: usize = 128; +/// In-flight requests per attachment (enforced by the host; excess β†’ `busy`). +pub const MAX_IN_FLIGHT: usize = 64; + +// ── Representation helpers (Β§1) ─────────────────────────────────────────── + +/// Lowercase canonical UUID v4 (`8-4-4-4-12`, version 4, RFC 4122 variant). +pub fn is_uuid_v4(s: &str) -> bool { + let b = s.as_bytes(); + b.len() == 36 + && b.iter().enumerate().all(|(i, &c)| match i { + 8 | 13 | 18 | 23 => c == b'-', + _ => c.is_ascii_digit() || (b'a'..=b'f').contains(&c), + }) + && b[14] == b'4' + && matches!(b[19], b'8' | b'9' | b'a' | b'b') +} + +/// `ses-` + exactly 12 chars of `[a-z2-7]`. +pub fn is_session_id(s: &str) -> bool { + s.strip_prefix("ses-").is_some_and(|rest| { + rest.len() == 12 + && rest + .bytes() + .all(|c| c.is_ascii_lowercase() || (b'2'..=b'7').contains(&c)) + }) +} + +/// 1–128 bytes of `[A-Za-z0-9._:-]`. +pub fn is_opaque_id(s: &str) -> bool { + (1..=MAX_OPAQUE_ID_BYTES).contains(&s.len()) + && s.bytes() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, b'.' | b'_' | b':' | b'-')) +} + +/// SHA-256, lowercase hex, 64 chars. +pub fn is_digest(s: &str) -> bool { + s.len() == 64 + && s.bytes() + .all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c)) +} + +fn check(ok: bool, what: &str) -> Result<(), String> { + if ok { + Ok(()) + } else { + Err(format!("invalid {what}")) + } +} + +fn uuid(s: &str, what: &str) -> Result<(), String> { + check(is_uuid_v4(s), what) +} + +/// Counters and times: JSON integers 0 ≀ n ≀ 2^53βˆ’1 (Β§1). +fn safe(n: u64, what: &str) -> Result<(), String> { + check(n <= MAX_SAFE_INTEGER, what) +} + +fn detail(d: &Option) -> Result<(), String> { + check( + d.as_ref().is_none_or(|d| d.len() <= MAX_DETAIL_BYTES), + "detail", + ) +} + +// ── Codes and enums ─────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ErrorCode { + UnsupportedVersion, + Unauthorized, + Forbidden, + StaleTarget, + UnknownSession, + LeaseHeld, + StaleLease, + RequestIdReused, + ReceiptLimit, + SessionClosed, + Busy, + Paused, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ClientKind { + Native, + Browser, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Permission { + View, + Control, + Approve, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SessionState { + Running, + Exited, + Interrupted, + RecoveryRequired, + Closed, + Removed, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Activity { + Ready, + Working, + Waiting, + Unknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LeaseChangeReason { + Granted, + Takeover, + LocalInput, + Expired, + Released, + Revoked, + ProcessEnded, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReceiptKind { + Prompt, + Interrupt, + Stop, + /// Reserved for v1.1; the host rejects it with `forbidden`. + Approval, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReceiptState { + Accepted, + Executing, + Completed, + Rejected, + OutcomeUnknown, +} + +// ── Target and binding (Β§2) ─────────────────────────────────────────────── + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Target { + pub account_id: String, + pub atem_device_id: String, + pub host_trust_gen: u64, + pub session_id: String, + pub process_gen: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Binding { + pub target: Target, + pub attachment_gen: String, + /// Required for mutations, absent for reads. + #[serde( + default, + deserialize_with = "present", + skip_serializing_if = "Option::is_none" + )] + pub lease_epoch: Option, +} + +/// Whether a binding belongs to a read (no `lease_epoch`) or a mutation (required). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BindingUse { + Read, + Mutation, +} + +// ── Descriptors and receipts ────────────────────────────────────────────── + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Controller { + pub controller_session: String, + pub lease_epoch: u64, +} + +/// A session descriptor (Β§4). Every field is required; `controller` is `null` +/// when no lease is held. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Descriptor { + pub session_id: String, + pub process_gen: String, + pub provider: String, + pub cwd_label: String, + pub state: SessionState, + pub activity: Activity, + pub activity_since: u64, + #[serde(deserialize_with = "nullable")] + pub controller: Option, + pub capabilities: Vec, + pub created_at: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Receipt { + pub request_id: String, + pub target: Target, + pub kind: ReceiptKind, + pub state: ReceiptState, + #[serde( + default, + deserialize_with = "present", + skip_serializing_if = "Option::is_none" + )] + pub detail: Option, + pub updated_at: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Resume { + pub controller_session: String, +} + +// ── Ops (Β§3–§7) ─────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "op", rename_all = "snake_case")] +pub enum Op { + Hello { + #[serde( + default, + deserialize_with = "present", + skip_serializing_if = "Option::is_none" + )] + resume: Option, + }, + Challenge { + nonce: String, + }, + ResumeProof { + proof: String, + }, + Welcome { + account_id: String, + atem_device_id: String, + host_trust_gen: u64, + runtime_gen: String, + controller_session: String, + attachment_gen: String, + client_kind: ClientKind, + permissions: Vec, + authorization_ms_left: u64, + server_time: u64, + }, + List {}, + Sessions { + runtime_gen: String, + runtime_epoch: u64, + seq: u64, + sessions: Vec, + }, + SessionChanged { + runtime_gen: String, + runtime_epoch: u64, + seq: u64, + descriptor: Descriptor, + }, + Acquire { + binding: Binding, + takeover: bool, + }, + Renew { + binding: Binding, + }, + Release { + binding: Binding, + }, + Lease { + binding: Binding, + ms_left: u64, + }, + LeaseReleased {}, + LeaseChanged { + session_id: String, + process_gen: String, + #[serde(deserialize_with = "nullable")] + controller: Option, + lease_epoch: u64, + reason: LeaseChangeReason, + }, + Submit { + binding: Binding, + request_id: String, + kind: ReceiptKind, + payload: Value, + }, + Receipt(Receipt), + ReceiptQuery { + binding: Binding, + request_ids: Vec, + }, + Receipts { + receipts: Vec, + }, + Error { + code: ErrorCode, + #[serde( + default, + deserialize_with = "present", + skip_serializing_if = "Option::is_none" + )] + detail: Option, + }, +} + +/// `{"type":"session","data":{"v":1,"op":…,"id":…,"re"?:…}}`. +#[derive(Debug, Clone, PartialEq)] +pub struct SessionMessage { + pub id: String, + pub re: Option, + pub op: Op, +} + +/// A message of another family (`terminal`, `agentChat`, `agentInput`, …). +/// `data` is kept as received minus `binding`, which is parsed and validated +/// when present (Β§2: terminal-v1 and chat-v1 carry it as `data.binding`). +#[derive(Debug, Clone, PartialEq)] +pub struct FamilyMessage { + pub family: String, + /// `None` for messages without `data` (e.g. `{"type":"projectListRequest"}`). + pub data: Option, + pub binding: Option, + /// A terminal-v1 request, typed (`None` for every other message). + pub request: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum Envelope { + Session(SessionMessage), + Family(FamilyMessage), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DecodeError { + pub code: ErrorCode, + /// The request's `id`, when it could be read (to answer with `re`). + pub id: Option, + /// Internal reason, for logs only; never sent to a peer. + pub reason: String, + /// Set when the message is a form the contract removed (r6 Β§0). + pub removed: Option, +} + +/// A message type (and op) removed by contract r6 Β§0. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemovedOp { + pub family: String, + pub op: Option, +} + +/// Families removed outright (r6 Β§0): every remote mutation is session-v1. +pub const REMOVED_FAMILIES: &[&str] = &[ + "agentInput", + "agentPrompt", + "voiceCommand", + "voiceToggle", + "userCommand", +]; + +/// terminal-v1 requests from a client (Β§2). Each carries `data.binding` +/// (read for subscribe/unsubscribe/ack, mutation for input/resize/stop); +/// there is no `lease_id`. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(tag = "op", rename_all = "snake_case")] +pub enum TerminalRequest { + Subscribe { + terminal_id: String, + generation: String, + }, + Unsubscribe { + terminal_id: String, + generation: String, + stream_id: String, + }, + Ack { + terminal_id: String, + generation: String, + stream_id: String, + offset: u64, + }, + Input { + terminal_id: String, + generation: String, + stream_id: String, + sequence: u64, + bytes: String, + }, + Resize { + terminal_id: String, + generation: String, + stream_id: String, + rows: u16, + cols: u16, + }, + Stop { + terminal_id: String, + generation: String, + stream_id: String, + }, +} + +impl TerminalRequest { + fn ids(&self) -> (&str, &str) { + match self { + TerminalRequest::Subscribe { + terminal_id, + generation, + } + | TerminalRequest::Unsubscribe { + terminal_id, + generation, + .. + } + | TerminalRequest::Ack { + terminal_id, + generation, + .. + } + | TerminalRequest::Input { + terminal_id, + generation, + .. + } + | TerminalRequest::Resize { + terminal_id, + generation, + .. + } + | TerminalRequest::Stop { + terminal_id, + generation, + .. + } => (terminal_id, generation), + } + } + + fn validate(&self) -> Result<(), String> { + let (terminal_id, generation) = self.ids(); + check(is_session_id(terminal_id), "terminal_id")?; + uuid(generation, "generation")?; + if let TerminalRequest::Input { bytes, .. } = self { + use base64::{Engine, engine::general_purpose::STANDARD}; + check(STANDARD.decode(bytes).is_ok(), "bytes")?; + } + Ok(()) + } +} + +impl Target { + fn validate(&self) -> Result<(), String> { + check(is_opaque_id(&self.account_id), "account_id")?; + check(is_opaque_id(&self.atem_device_id), "atem_device_id")?; + safe(self.host_trust_gen, "host_trust_gen")?; + check(is_session_id(&self.session_id), "session_id")?; + uuid(&self.process_gen, "process_gen") + } +} + +impl Binding { + fn validate_shape(&self) -> Result<(), String> { + self.target.validate()?; + uuid(&self.attachment_gen, "attachment_gen")?; + self.lease_epoch.map_or(Ok(()), |e| safe(e, "lease_epoch")) + } + + /// Representation rules plus Β§2: `lease_epoch` is required for mutations + /// and absent for reads. + pub fn validate(&self, use_: BindingUse) -> Result<(), String> { + self.validate_shape()?; + match (use_, self.lease_epoch) { + (BindingUse::Read, Some(_)) => Err("lease_epoch on a read".into()), + (BindingUse::Mutation, None) => Err("lease_epoch missing on a mutation".into()), + _ => Ok(()), + } + } + + /// terminal-v1 `terminal_id` / chat-v1 `agentId` must equal + /// `target.session_id`, and the family's `generation` (when it has one) + /// `target.process_gen`; a disagreement is `stale_target` (Β§2). + pub fn check_family_ids( + &self, + session_id: &str, + generation: Option<&str>, + ) -> Result<(), ErrorCode> { + let gen_ok = generation.is_none_or(|g| g == self.target.process_gen); + if session_id == self.target.session_id && gen_ok { + Ok(()) + } else { + Err(ErrorCode::StaleTarget) + } + } +} + +impl Controller { + fn validate(&self) -> Result<(), String> { + uuid(&self.controller_session, "controller_session")?; + safe(self.lease_epoch, "controller.lease_epoch") + } +} + +impl Descriptor { + fn validate(&self) -> Result<(), String> { + check(is_session_id(&self.session_id), "session_id")?; + uuid(&self.process_gen, "process_gen")?; + safe(self.activity_since, "activity_since")?; + safe(self.created_at, "created_at")?; + self.controller + .as_ref() + .map_or(Ok(()), Controller::validate) + } +} + +impl Receipt { + fn validate(&self) -> Result<(), String> { + uuid(&self.request_id, "request_id")?; + self.target.validate()?; + detail(&self.detail)?; + safe(self.updated_at, "updated_at") + } +} + +/// v1 payload shapes (Β§6): `prompt` is exactly `{"text": string}` (≀ 32 768 +/// bytes), `interrupt`/`stop` exactly `{}`; `approval` is accepted here so the +/// host can answer it with `forbidden`. The canonical form is ≀ 64 KiB. +fn validate_payload(kind: ReceiptKind, payload: &Value) -> Result<(), String> { + let canonical = canonical::jcs(payload).map_err(|e| format!("payload: {e:?}"))?; + check(canonical.len() <= MAX_PAYLOAD_BYTES, "payload size")?; + let obj = payload.as_object(); + match kind { + ReceiptKind::Prompt => match obj.and_then(|o| o.get("text")) { + Some(Value::String(t)) if obj.is_some_and(|o| o.len() == 1) => { + check(t.len() <= MAX_PROMPT_BYTES, "prompt text size") + } + _ => Err("prompt payload must be {\"text\": string}".into()), + }, + ReceiptKind::Interrupt | ReceiptKind::Stop => { + check(obj.is_some_and(|o| o.is_empty()), "payload (must be {})") + } + ReceiptKind::Approval => Ok(()), + } +} + +impl Op { + fn validate(&self) -> Result<(), String> { + use BindingUse::{Mutation, Read}; + match self { + Op::Hello { resume } => resume.as_ref().map_or(Ok(()), |r| { + uuid(&r.controller_session, "controller_session") + }), + Op::Challenge { nonce } => { + use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; + let len = URL_SAFE_NO_PAD.decode(nonce).map(|b| b.len()); + check(len == Ok(32), "nonce") + } + Op::ResumeProof { proof } => check(!proof.is_empty(), "proof"), + Op::Welcome { + account_id, + atem_device_id, + host_trust_gen, + runtime_gen, + controller_session, + attachment_gen, + authorization_ms_left, + server_time, + .. + } => { + check(is_opaque_id(account_id), "account_id")?; + check(is_opaque_id(atem_device_id), "atem_device_id")?; + safe(*host_trust_gen, "host_trust_gen")?; + uuid(runtime_gen, "runtime_gen")?; + uuid(controller_session, "controller_session")?; + uuid(attachment_gen, "attachment_gen")?; + safe(*authorization_ms_left, "authorization_ms_left")?; + safe(*server_time, "server_time") + } + Op::List {} | Op::LeaseReleased {} => Ok(()), + Op::Sessions { + runtime_gen, + runtime_epoch, + seq, + sessions, + } => { + uuid(runtime_gen, "runtime_gen")?; + safe(*runtime_epoch, "runtime_epoch")?; + safe(*seq, "seq")?; + sessions.iter().try_for_each(Descriptor::validate) + } + Op::SessionChanged { + runtime_gen, + runtime_epoch, + seq, + descriptor, + } => { + uuid(runtime_gen, "runtime_gen")?; + safe(*runtime_epoch, "runtime_epoch")?; + safe(*seq, "seq")?; + descriptor.validate() + } + Op::Acquire { binding, .. } => binding.validate(Read), + Op::Renew { binding } | Op::Release { binding } => binding.validate(Mutation), + Op::Lease { binding, ms_left } => { + binding.validate(Mutation)?; + safe(*ms_left, "ms_left") + } + Op::LeaseChanged { + session_id, + process_gen, + controller, + lease_epoch, + .. + } => { + check(is_session_id(session_id), "session_id")?; + uuid(process_gen, "process_gen")?; + safe(*lease_epoch, "lease_epoch")?; + controller.as_ref().map_or(Ok(()), Controller::validate) + } + Op::Submit { + binding, + request_id, + kind, + payload, + } => { + binding.validate(Mutation)?; + uuid(request_id, "request_id")?; + validate_payload(*kind, payload) + } + Op::Receipt(r) => r.validate(), + Op::ReceiptQuery { + binding, + request_ids, + } => { + binding.validate(Read)?; + check(request_ids.len() <= MAX_REQUEST_IDS, "request_ids count")?; + request_ids.iter().try_for_each(|r| uuid(r, "request_id")) + } + Op::Receipts { receipts } => receipts.iter().try_for_each(Receipt::validate), + Op::Error { detail: d, .. } => detail(d), + } + } +} + +impl FamilyMessage { + fn field(&self, name: &str) -> Option<&str> { + self.data.as_ref()?.get(name)?.as_str() + } + + /// Applies `Binding::check_family_ids` with this family's own id fields + /// (terminal-v1 `terminal_id`/`generation`, chat-v1 `agentId`/`generation`). + /// Messages without a binding (host messages, other families) have + /// nothing to compare. + pub fn check_binding_ids(&self) -> Result<(), ErrorCode> { + let Some(b) = &self.binding else { + return Ok(()); + }; + let id_field = match self.family.as_str() { + "terminal" => "terminal_id", + "agentChat" => "agentId", + _ => return Ok(()), + }; + let id = self.field(id_field).ok_or(ErrorCode::StaleTarget)?; + b.check_family_ids(id, self.field("generation")) + } +} + +fn fail(id: Option, reason: impl Into) -> DecodeError { + DecodeError { + code: ErrorCode::UnsupportedVersion, + id, + reason: reason.into(), + removed: None, + } +} + +fn removed(family: &str, op: Option<&str>) -> DecodeError { + DecodeError { + removed: Some(RemovedOp { + family: family.to_string(), + op: op.map(str::to_string), + }), + ..fail(None, "removed by contract r6") + } +} + +/// Strictly decodes one wire message (see the module docs). +pub fn decode(bytes: &[u8]) -> Result { + // `parse_strict` checks the size bound before allocating anything. + let root = canonical::parse_strict(bytes, MAX_MESSAGE_BYTES, Numbers::AllowFractions) + .map_err(|e| fail(None, format!("{e:?}")))?; + let Value::Object(mut root) = root else { + return Err(fail(None, "message is not an object")); + }; + let family = match root.remove("type") { + Some(Value::String(t)) => t, + _ => return Err(fail(None, "missing type")), + }; + let data = root.remove("data"); + if family == "session" { + match data { + Some(Value::Object(d)) => decode_session(d).map(Envelope::Session), + _ => Err(fail(None, "session data must be an object")), + } + } else { + decode_family(family, data).map(Envelope::Family) + } +} + +fn decode_session(data: Map) -> Result { + let id = match data.get("id") { + Some(Value::String(s)) if is_uuid_v4(s) => Some(s.clone()), + _ => None, + }; + if data.get("v").and_then(Value::as_u64) != Some(VERSION) { + return Err(fail(id, "unsupported v")); + } + let Some(id) = id else { + return Err(fail(None, "missing or invalid id")); + }; + let re = match data.get("re") { + None => None, + Some(Value::String(s)) if is_uuid_v4(s) => Some(s.clone()), + Some(_) => return Err(fail(Some(id), "invalid re")), + }; + let op: Op = serde_json::from_value(Value::Object(data)) + .map_err(|e| fail(Some(id.clone()), e.to_string()))?; + op.validate().map_err(|e| fail(Some(id.clone()), e))?; + Ok(SessionMessage { id, re, op }) +} + +fn decode_family(family: String, mut data: Option) -> Result { + if REMOVED_FAMILIES.contains(&family.as_str()) { + return Err(removed(&family, None)); + } + let binding = match data.as_mut() { + Some(Value::Object(m)) => m + .remove("binding") + .map(serde_json::from_value::) + .transpose() + .map_err(|e| fail(None, format!("binding: {e}")))?, + _ => None, + }; + let request = match family.as_str() { + "terminal" => check_terminal(data.as_ref(), binding.as_ref())?, + "agentChat" => { + check_chat(data.as_ref(), binding.as_ref())?; + None + } + _ if binding.is_some() => { + return Err(fail(None, format!("{family} cannot carry a binding"))); + } + _ => None, + }; + Ok(FamilyMessage { + family, + data, + binding, + request, + }) +} + +fn family_head(data: Option<&Value>) -> Result<(&Map, &str), DecodeError> { + let Some(Value::Object(m)) = data else { + return Err(fail(None, "data must be an object")); + }; + if m.get("v").and_then(Value::as_u64) != Some(1) { + return Err(fail(None, "unsupported v")); + } + match m.get("op") { + Some(Value::String(op)) => Ok((m, op)), + _ => Err(fail(None, "missing op")), + } +} + +/// The binding a terminal-v1/chat-v1 request must carry: absent is the +/// removed unbound form; present, it must fit how the op uses it. +fn require_binding( + family: &str, + op: &str, + binding: Option<&Binding>, + use_: BindingUse, +) -> Result<(), DecodeError> { + let b = binding.ok_or_else(|| removed(family, Some(op)))?; + b.validate(use_) + .map_err(|e| fail(None, format!("binding: {e}"))) +} + +/// terminal-v1. Requests decode into `TerminalRequest` with their binding; +/// host messages must decode with `terminal_share` types and carry none. +/// terminal-v1 `list`/`sessions` are removed: session-v1 `list` is the only +/// registry (r6 Β§0). +fn check_terminal( + data: Option<&Value>, + binding: Option<&Binding>, +) -> Result, DecodeError> { + let (m, op) = family_head(data)?; + let use_ = match op { + "control" | "controller" | "list" | "sessions" => { + return Err(removed("terminal", Some(op))); + } + "subscribe" | "unsubscribe" | "ack" => BindingUse::Read, + "input" | "resize" | "stop" => BindingUse::Mutation, + _ => { + if binding.is_some() { + return Err(fail(None, format!("terminal {op} cannot carry a binding"))); + } + serde_json::from_value::(Value::Object(m.clone())) + .map_err(|e| fail(None, format!("terminal: {e}")))?; + return Ok(None); + } + }; + if m.contains_key("lease_id") { + return Err(removed("terminal", Some(op))); + } + require_binding("terminal", op, binding, use_)?; + let req: TerminalRequest = serde_json::from_value(Value::Object(m.clone())) + .map_err(|e| fail(None, format!("terminal: {e}")))?; + req.validate() + .map_err(|e| fail(None, format!("terminal: {e}")))?; + Ok(Some(req)) +} + +/// chat-v1: requests (`open`, `close`, `submit`) carry their binding and must +/// pass `chat::wire::parse_inbound`; `agentId` is a session id and a submit's +/// `requestId` is the Β§6 `request_id` (UUID v4). Host messages carry none. +fn check_chat(data: Option<&Value>, binding: Option<&Binding>) -> Result<(), DecodeError> { + let (m, op) = family_head(data)?; + let env: crate::chat::wire::ChatEnvelope = serde_json::from_value(Value::Object(m.clone())) + .map_err(|e| fail(None, format!("agentChat: {e}")))?; + let use_ = match op { + "open" | "close" => BindingUse::Read, + "submit" => BindingUse::Mutation, + _ if binding.is_some() => { + return Err(fail(None, format!("agentChat {op} cannot carry a binding"))); + } + _ => return Ok(()), + }; + require_binding("agentChat", op, binding, use_)?; + let bad = |e: String| fail(None, format!("agentChat: {e}")); + crate::chat::wire::parse_inbound(&env).map_err(bad)?; + check(is_session_id(&env.agent_id), "agentId").map_err(bad)?; + if op == "submit" { + let field = |name| { + env.body + .get(name) + .and_then(Value::as_str) + .unwrap_or_default() + }; + uuid(field("requestId"), "requestId").map_err(bad)?; + uuid(field("generation"), "generation").map_err(bad)?; + } + Ok(()) +} + +/// The wire JSON of `env`. +pub fn to_value(env: &Envelope) -> Value { + match env { + Envelope::Session(msg) => { + let Value::Object(mut data) = + serde_json::to_value(&msg.op).expect("session op serializes") + else { + unreachable!("an op serializes to an object") + }; + data.insert("v".into(), Value::from(VERSION)); + data.insert("id".into(), Value::String(msg.id.clone())); + if let Some(re) = &msg.re { + data.insert("re".into(), Value::String(re.clone())); + } + serde_json::json!({"type": "session", "data": data}) + } + Envelope::Family(f) => { + let mut root = Map::new(); + root.insert("type".into(), Value::String(f.family.clone())); + let mut data = f.data.clone(); + if let (Some(Value::Object(m)), Some(b)) = (data.as_mut(), &f.binding) { + m.insert( + "binding".into(), + serde_json::to_value(b).expect("binding serializes"), + ); + } + if let Some(d) = data { + root.insert("data".into(), d); + } + Value::Object(root) + } + } +} + +pub fn encode(env: &Envelope) -> Vec { + serde_json::to_vec(&to_value(env)).expect("wire value serializes") +} + +// ── serde helpers ───────────────────────────────────────────────────────── + +/// Optional field: absent β†’ `None`; present must be a value (`null` refused). +fn present<'de, D: Deserializer<'de>, T: Deserialize<'de>>(d: D) -> Result, D::Error> { + T::deserialize(d).map(Some) +} + +/// Required field that may be `null` (no `default`, so absence is an error). +fn nullable<'de, D: Deserializer<'de>, T: Deserialize<'de>>(d: D) -> Result, D::Error> { + Option::::deserialize(d) +}