diff --git a/docs/superpowers/specs/2026-06-28-slice-2-agent-tap-design.md b/docs/superpowers/specs/2026-06-28-slice-2-agent-tap-design.md new file mode 100644 index 0000000..51a5bc6 --- /dev/null +++ b/docs/superpowers/specs/2026-06-28-slice-2-agent-tap-design.md @@ -0,0 +1,738 @@ +# Rutster slice 2 — The agent tap: splicing the brain seam + +- **Status:** Draft (pending review) +- **Date:** 2026-06-28 +- **Spearhead step:** 2 of 6 (vision-revision §10 / PORT_PLAN "Phasing") +- **Origin:** brainstorming session 2026-06-28 +- **Depends on:** [slice 1 — WebRTC media loopback](2026-06-28-slice-1-webrtc-loopback-design.md) + (all slice-1 code must be landed and green) +- **Related:** [ADR-0002](../../adr/0002-north-star-and-fused-core.md) (fused vertical), + [ADR-0004](../../adr/0004-license.md) (GPL-3.0-or-later), + [ADR-0006](../../adr/0006-ingress-posture.md) (core-as-client tap posture), + [ARCHITECTURE.md §"Agent tap"](../../ARCHITECTURE.md) (the presumptive tap shape this + slice hardens) + +--- + +## TL;DR + +Stand up spearhead step 2: rip out the in-process `EchoAudioPipe` from slice 1 and +splice in a real **external brain** reached over WebSocket. The core dials out +(core-as-client; brain-as-server; **no inbound tap port on the core**), speaks a small +versioned JSON event protocol, and owns a core-authoritative playout buffer where the +brain *proposes* audio (`AudioOut` frames) and the core *disposes* (drops-oldest on +overflow, emits silence on underflow). + +Slice 2 proves the **tap interface**: the same WSS plumbing that today echoes will, in +step 3, carry a real STT/LLM/TTS brain. It deliberately omits the real brain (step 3), +barge-in / VAD-driven playout kill (step 4), the PSTN trunk (step 5), and spend control +(step 6) — but it pre-paves the OpenAI-Realtime adapter shape by choosing an event-named +JSON protocol that translates cleanly to OpenAI's event taxonomy. + +The seam slice 1 pre-paved (`AudioSource` / `AudioSink` traits in `rutster-media`) is the +**test of this slice**: `RtcSession`'s media-loop path changes by exactly one line — swap +`EchoAudioPipe` for `TapAudioPipe` — and `loop_driver.rs` does not change at all. + +--- + +## 1. Scope + +### 1.1 In scope + +- Implementation of spearhead step 2: WebRTC WebRTC peer → core terminates DTLS-SRTP, + decodes Opus → canonical PCM @ 24 kHz mono, ships PCM **over WSS** to an external echo + brain, receives PCM back, encodes + plays out via str0m. The user speaks and hears + themselves back, **routed through an out-of-process brain**, with no perceptible delay + (~≤250 ms; slice-1's 200 ms + tap round-trip + 100 ms playout buffer headroom). +- A small **versioned JSON event protocol** for the tap wire (envelope + event types: + `hello`, `audio_in`, `audio_out`, `session_end`, `bye`, `error`). +- **`TapEngine`**: a cold-path tokio task per session that owns the WSS connection and + shovels PCM between WS frames and the playout buffer. +- **`TapAudioPipe`**: a thin sync wrapper that the existing `AudioSource` / `AudioSink` + seam holds — `RtcSession` swaps it in for `EchoAudioPipe`, nothing else changes there. +- **Core-authoritative playout buffer** (bounded ring; drop-oldest on overflow; silence + on underflow) — the concrete embodiment of "brain proposes, core disposes." +- `tap: Option` field on `Channel` (locked-in by slice-1 §5.2). `TapHandle` + is a zero-cost marker newtype; the binary looks up the live tap connection by the + channel's existing `ChannelId` in an internal `DashMap`, so + `rutster-call-model` stays a leaf (no tokio dep). +- Two-source tap URL config: `RUTSTER_TAP_URL` env default + optional per-call + `tap_url` in `POST /v1/sessions` body. `ws://` loopback-only enforced; `wss://` + URL accepted by the schema but cert/mTLS impl deferred to step 6. +- **Bounded-backoff reconnect** on brain disconnect: 250 ms → 500 ms → 1 s → 2 s → cap at + 5 s, infinite retries. `Channel` stays `Connected` throughout; playout falls to silence + during outage; reconnect re-`hello`s with the same `session_id` (stateless brain + contract). +- **Python reference echo brain** (`examples/echo_brain/`) — the canonical foreign-language + brain demo (README-documented, runnable, **not in CI**). +- **Rust echo brain crate** (`crates/rutster-tap-echo`) — a dual-purpose binary crate: + a standalone dev-loop binary (`cargo run -p rutster-tap-echo`) and an in-process + `EchoServer` used by integration tests. +- Two new deps: `tokio-tungstenite` (WS client + server), `serde_json` if not already + pulled. New workspace member crate. Thorough learner-facing comments on the new + async/mpsc/ring-buffer patterns (slice-1 §7 standard carries over). + +### 1.2 Out of scope (with scheduled return) + +| Deferred item | Returns in | Why deferred | +|---|---|---| +| `wss://` cert validation / mTLS / brain cert pinning | Step 6 (spend cap) | Same rationale as slice-1 HTTP TLS — TLS needs the cert story from ARCHITECTURE.md (Vault/KMS), which lands with the real trust boundary + authz. `ws://` 127.0.0.1 dev loop is sufficient to prove the tap interface. Slice-2 accepts `wss://` URLs at the schema level but the connect attempt returns a clear `501 NOT IMPLEMENTED` so the seam is reserved without ratifying a half-impl. | +| Authn / authz on the `tap_url` override | Step 6 | Inherits slice-1's "no auth yet" posture; authz on who-may-set-`tap_url` lands with the spend gate. Spec §4.5 flags this as a known gap. | +| Barge-in / VAD-driven playout kill | Step 4 | No reflex to enforce yet; the playout buffer in slice-2 just queues + drops on overflow, doesn't kill on caller speech. | +| Real brain (STT / LLM / TTS, OpenAI Realtime, etc.) | Step 3 | Slice-2 proves the *interface* and the core-authoritative playout posture; step 3 swaps echo → real brain. | +| OpenAI-Realtime adapter | Step 3 | The translation shim from our protocol → OpenAI Realtime's event schema is step-3 work. Slice-2's protocol is designed with that translation in mind (event-named, versioned, JSON-over-text-WS — same shape OpenAI Realtime uses). | +| Re-INVITE / session migration / resumability | Later | Refresh the page → new session, same as slice-1. Tap reconnect reuses the `ChannelId` as `session_id`, but there's no in-flight call preservation across a server restart. | +| CDR / event bus / OTel beyond per-Channel `tracing` spans | Step 5 | Single peer, single brain; no fanout yet. Tap reconnect + error counters go to logs + counters only. | +| Binary PCM mode (raw LE i16 over WS binary frames) | Future-rung | Base64 inside text JSON is ~33% overhead (960 B PCM → ~1.3 KB JSON → ~65 KB/s). Acceptable for dev loop; the wire format reserves `v: 2` for a binary mode. | +| Byte-endian negotiation | Tracked in §9 (Open decisions) | v1 emits the host's native endian as raw bytes inside base64. Documented as a v1 simplification; v2 should nail explicit LE byte order. | +| Per-tenant tap routing / multi-brain selection | Step 6 | One tap URL per call in slice-2 (env default + per-call override). Multi-brain routing is a deployment-posture concern. | +| Predictive dialer / spend cap / abuse gate | Step 6 | No spend surface yet; brain is an in-loopback echo, no metering to gate. | +| Trickle ICE, transfer / park / pickup, browser automation, fuzz harnesses | (unchanged from slice-1 §1.2) | Still deferred for the same reasons. | + +### 1.3 What this slice does NOT prove + +It does **not** prove: a real brain (only an echo process), barge-in playout kill, latency +determinism under reflex timing, PSTN trunking, spending controls, multi-tenancy on the +tap URL, or wss:// TLS posture in production. It proves **only** the tap interface: the +WS dial-out, the versioned event protocol, the playout buffer posture, the reconnect +behavior, and the seam-test (slice-1's `RtcSession` accepts a new pipe at the +`AudioSource`/`AudioSink` boundary with zero internal change). + +--- + +## 2. Workspace layout (delta on slice-1) + +One new workspace member, one new `examples/` dir, new `[workspace.dependencies]` entries. + +``` +rutster/ +├── Cargo.toml # +[workspace.dependencies]: tokio-tungstenite, futures-util (and serde_json if not already pulled) +├── crates/ +│ ├── rutster/ # binary: wires TapEngine per session +│ │ ├── src/main.rs +│ │ ├── src/session_map.rs # unchanged shape; ChannelId → RtcSession +│ │ ├── src/routes.rs # POST /v1/sessions body gains optional tap_url +│ │ ├── src/tap_engine.rs # NEW: spawns + supervises the per-session WSS task +│ │ └── static/index.html # minor: surface tap connection status in the
+│   ├── rutster-media/                   # swap EchoAudioPipe → TapAudioPipe at construction (in the binary, not here)
+│   │   ├── src/pcm.rs                   # PcmFrame stays; EchoAudioPipe stays (slice-1 unit tests + dev-loop fallback)
+│   │   ├── src/rtc_session.rs           # UNCHANGED — the seam test
+│   │   └── src/loop_driver.rs           # UNCHANGED — calls sink/source, no awareness of tap
+│   ├── rutster-call-model/              # +tap: Option field; +TapHandle(()) marker newtype
+│   │   └── src/lib.rs
+│   ├── rutster-tap/                     # FILLED IN (was stub): protocol + TapClient + TapAudioPipe
+│   │   ├── src/lib.rs                   # module docs, error enum, re-export PcmFrame from rutster-media
+│   │   ├── src/protocol.rs              # JSON event schema, version field, frame codec
+│   │   ├── src/tap_client.rs            # WS connection driver (runs inside the TapEngine task)
+│   │   └── src/tap_audio_pipe.rs        # AudioSource + AudioSink impl over mpsc + playout ring
+│   ├── rutster-tap-echo/                # NEW crate: the Rust reference echo brain + test server
+│   │   ├── src/lib.rs                   # EchoServer::start(addr) -> JoinHandle + EchoHandle (test driver)
+│   │   └── src/main.rs                  # standalone binary: ws://127.0.0.1:, echo audio_in → audio_out
+│   ├── rutster-signaling-sip/           # STUB (unchanged)
+│   └── rutster-spend/                   # STUB (unchanged)
+└── examples/
+    └── echo_brain/
+        ├── README.md                    # how to run, what the protocol is, pointer to this spec
+        ├── echo_brain.py                # canonical foreign-language brain (websockets lib, ~80 lines)
+        └── requirements.txt              # websockets
+```
+
+### 2.1 Dependency direction (delta from slice-1 §2.3)
+
+- `rutster-tap` → `rutster-media` (for `PcmFrame`), per slice-1 §3.1's promise that
+  "`rutster-tap` will re-export it once that crate fills in (step 2)." `PcmFrame` re-export
+  preserved from `rutster-media`; one canonical home remains.
+- `rutster` (binary) → `rutster-tap` (new; for `TapAudioPipe`/`TapClient` types) and
+  → `rutster-tap-echo` (dev-binary + integration-test `EchoServer`).
+- `rutster-tap-echo` → `rutster-tap` (reuses the protocol types — proves the wire types
+  are reusable from a separate brain implementation, the contract-test for "anyone can
+  write a brain in Rust").
+- `rutster-call-model` **stays a leaf**; `TapHandle` is a zero-sized marker newtype
+  there (no tokio dep). The binary maps the channel's `ChannelId → mpsc::Sender` / `Receiver` via an internal
+  `DashMap` — the call model carries only the marker, not the
+  connections.
+- `rutster-media` ↔ `rutster-tap`: **only via the trait seam** — `rutster-media` defines
+  `AudioSource` / `AudioSink` / `PcmFrame`; `rutster-tap` implements the traits.
+  `rutster-media` does **not** depend on `rutster-tap` (and never will — that would
+  invert the canonical-home of `PcmFrame` and pull the loopback peer into the tap story).
+
+### 2.2 Why keep `EchoAudioPipe` in `rutster-media`
+
+Slice-1's in-process echo pipe isn't deleted in slice-2. Rationale:
+
+1. `rutster-media`'s slice-1 unit tests use it directly to exercise the codec + loop
+   driver without a network. Deleting it would break slice-1's tests-as-learning-aids.
+2. A `--features=echo` dev mode on the binary (routes audio through `EchoAudioPipe`
+   instead of `TapAudioPipe`) keeps the dev loop fully zero-network-dependency when the
+   tap isn't needed (e.g. reproducing a slice-1 bug). Default = tap; feature = echo.
+3. Two impls of the same trait is the cleanest possible documentation that the seam
+   *is* a seam.
+
+### 2.3 Why one new crate for the Rust echo brain (not an `examples/` file)
+
+Both artifacts ship (Python in `examples/`, Rust as a crate) — this dual is the
+brainstorming resolution of the "showcase Rust binaries + external Python" goal:
+
+- **`crates/rutster-tap-echo`** is a real workspace member. It runs `cargo fmt`,
+  `cargo clippy -D warnings`, `cargo test`, and `cargo deny check` like every other
+  crate. It reuses `rutster-tap`'s protocol types — **the contract-test that the wire
+  types are reusable from outside the core**. It doubles as the in-process `EchoServer`
+  for integration tests (with hooks to inject deliberate disconnects, malformed frames,
+  underflow, overflow).
+- **`examples/echo_brain/echo_brain.py`** is the canonical foreign-language brain demo,
+  hand-rolled from the documented protocol text. It proves the wire format is
+  language-agnostic and matches the "brain is a Python script" persona from
+  ARCHITECTURE.md. **Not in CI** (Python would violate the zero-non-Rust-dev-deps dev
+  loop). README-documented runnable: `pip install websockets && python
+  examples/echo_brain/echo_brain.py`.
+
+---
+
+## 3. Tap wire protocol (`rutster-tap/src/protocol.rs`)
+
+A minimal **versioned JSON event protocol over WS text frames**. Every frame is one JSON
+object. PCM payloads are base64-encoded raw bytes of the `PcmFrame`'s `[i16; 480]`
+in-memory repr (host-endian — see §9 Open Decisions for the v1 simplification; on a
+little-endian host this is LE).
+
+### 3.1 Envelope (common to all messages)
+
+```jsonc
+{
+  "v": 1,                  // protocol_version (integer; this slice ships v1)
+  "type": "",  // string, one of the names in §3.2 / §3.3
+  "seq": ,           // per-direction monotonic counter, starts at 0; gaps = loss
+  "ts":              // monotonic ms since the direction's session_start (clock = sender's; advisory)
+}
+```
+
+- `seq` is **per-direction** (core maintains its own egress counter; brain maintains its
+  own). The receiver detects gaps by tracking the last-seen `seq` and counting skips.
+  Out-of-order frames are treated as loss (slice-2 has no reorder buffer — WS guarantees
+  per-connection ordering anyway, so out-of-order would only happen across a reconnect;
+  the reconnect path resets both `seq` counters to 0). A mismatch → logged + counter
+  incremented; the frame is **not** dropped on `seq` gap (latency > perfect-ordering here).
+- `ts` is advisory; no wall-clock sync assumed between core and brain.
+
+### 3.2 Messages — core → brain (egress from core's POV)
+
+| `type` | payload fields | when |
+|---|---|---|
+| `hello` | `{ "session_id": "", "sample_rate": 24000, "channels": "mono", "frame_ms": 20 }` | first message after WS connect; declares the canonical PCM format (will not change mid-session; re-sent on reconnect with the same `session_id` — see §5.3) |
+| `audio_in` | `{ "pcm": "", "samples": 480 }` | on each decoded `PcmFrame` from the peer — the peer's mic → brain direction |
+| `session_end` | `{ "reason": "hangup" \| "idle_timeout" \| "shutdown" }` | core tearing down the call; brain should expect a WS close frame to follow |
+| `bye` | `{ "reason": "..." }` | graceful protocol-level close initiated by the core before the WS close frame |
+| `error` | `{ "code": "", "message": "..." }` | protocol-level error from the core (e.g. a malformed brain frame was received); the call stays up, this is an FYI |
+
+### 3.3 Messages — brain → core (the "brain proposes" direction)
+
+| `type` | payload fields | when |
+|---|---|---|
+| `hello` | `{ "session_id": "" }` (echo back) | brain acks the session handshake |
+| `audio_out` | `{ "pcm": "", "samples": 480 }` | brain-proposed outbound audio — **advisory**; core enqueues in the playout ring (§4.2) |
+| `bye` | `{ "reason": "..." }` | graceful brain-initiated exit; core enters the reconnect path (§5.2) |
+| `error` | `{ "code": "", "message": "..." }` | brain errors; the call stays up; core logs + counter |
+
+### 3.4 Invariants and forward-compat
+
+- **Sample-count invariant:** every `audio_in` / `audio_out` declares `samples: 480`
+  (20 ms @ 24 kHz mono). The receiver validates; mismatched frames are logged + counted
+  + dropped (hot-path "drop + observe" policy from slice-1 §3.8 — *not* a
+  connection-terminating error).
+- **Versioning:** `v: 1`. Unknown envelope fields are ignored (forwards-compat for
+  additive changes). Unknown `type` values are logged + counted + dropped (not fatal).
+  A future v2 negotiates via a `v` upgrade on `hello`.
+- **Single text-JSON mode in v1** — base64 inside text JSON is the only mode. ~33% wire
+  overhead is acceptable at 24 kHz mono i16 (960 B PCM/frame → ~1.3 KB JSON → ~65 KB/s).
+  The protocol asserts `v: 1` in every envelope so a future `v: 2` binary mode is a
+  clean break, not a legacy compat hazard.
+
+### 3.5 Why JSON + base64 over binary length-prefixed framing
+
+ARCHITECTURE.md names WSS as the presumptive transport because "the consumer is a Python
+script / a browser / an OpenAI-Realtime-style speech-to-speech API for which
+event-framed WSS is already the de-facto protocol." A JSON event envelope is the natural
+mapping onto that ecosystem — both OpenAI Realtime's events and a hand-rolled Python
+brain's `json.loads` want the same shape. A binary length-prefixed framing would be
+cheaper on the wire (~33% smaller, ~50 µs less encode/decode) but would force every
+brain — including the canonical Python reference and the step-3 OpenAI adapter — to
+implement a byte-parser instead of `json.loads`. The wire overhead (65 KB/s) is
+negligible at slice-2's scale and the brain-authoring ergonomics dominate. The Open
+Decisions entry (§9) tracks the binary-mode re-evaluation for a later rung.
+
+---
+
+## 4. Tap plumbing (`rutster-tap`)
+
+Three modules, three responsibilities. The split is the "Approach B — Decoupled
+TapEngine" decision from the brainstorming session: keep `TapAudioPipe` a thin sync
+wrapper over mpsc, isolate all WSS awareness in the `TapClient` (which the `TapEngine`
+task runs), and let the binary spawn + supervise the task.
+
+### 4.1 The seam: `TapAudioPipe` (`src/tap_audio_pipe.rs`)
+
+The sync object `RtcSession` holds and the `loop_driver` calls via the trait seam.
+
+```rust
+pub struct TapAudioPipe {
+    // Core → brain (inbound decoded PCM from peer):
+    tx_pcm_in: mpsc::Sender,     // fed by AudioSink::on_pcm_frame; drained by TapClient (audio_in WS frames)
+
+    // Brain → core (playout buffer for outbound PCM to encode + push to str0m):
+    playout_ring: std::collections::VecDeque,  // bounded at TAP_PLAYOUT_FRAMES (5)
+    rx_audio_out: mpsc::Receiver,              // fed by TapClient (audio_out WS frames → ring)
+
+    // Optional counters (loss, overflow, underflow) — hot-path drop+observe posture.
+    metrics: TapMetrics,
+}
+
+impl AudioSource for TapAudioPipe {
+    /// Take the next brain-proposed PCM frame to send to the peer. None = silence.
+    /// Drains the playout ring; underflow returns None (silence), overflow dropped earlier at enqueue.
+    fn next_pcm_frame(&mut self) -> Option {
+        match self.rx_audio_out.try_recv() {
+            Ok(frame) => Some(frame),         // happy path: brain-proposed audio
+            Err(mpsc::TryRecvError::Empty) => None,  // underflow → loop_driver emits Opus silence
+            Err(mpsc::TryRecvError::Disconnected) => None,  // engine task gone → silence; reconnect is the engine's job
+        }
+    }
+}
+
+impl AudioSink for TapAudioPipe {
+    /// Receive a decoded PCM frame from the peer. Must not block (slice-1 §3.3 contract).
+    /// Forwards to the engine task via mpsc; if the channel is full (engine task slow / gone),
+    /// drops + counts (hot-path policy: drop + observe, don't crash, don't block).
+    fn on_pcm_frame(&mut self, frame: PcmFrame) {
+        if self.tx_pcm_in.try_send(frame).is_err() {
+            self.metrics.inbound_dropped.fetch_add(1, Ordering::Relaxed);
+        }
+    }
+}
+```
+
+**Playout ring policy:**
+
+- **Capacity**: `TAP_PLAYOUT_FRAMES = 5` (= 100 ms at 20 ms / frame). Enough to absorb
+  brain jitter without introducing perceptible delay. Documented as a tunable *constant*
+  for slice-2 (no runtime config; a future-rung concern).
+- **Overflow** (brain pushes faster than 20 ms / tick): drop **oldest**, log + counter.
+  Drop-oldest is the lowest-latency-correct posture — a brain pushing too fast means the
+  late frames are staler than the fresh ones; shedding the late frames keeps the buffer
+  at-or-behind real-time. (Drop-newest would accumulate growing latency — wrong
+  posture for a real-time media path.)
+- **Underflow** (tick fires, ring empty): `next_pcm_frame` returns `None`;
+  `loop_driver` emits an Opus silence frame (already what slice-1 does on `None`).
+
+### 4.2 TapClient (`src/tap_client.rs`)
+
+The async object that owns the WSS connection. Lives only inside the `TapEngine` task —
+the media loop never sees it.
+
+```rust
+pub struct TapClient {
+    ws: WebSocketStream<...>,                        // tokio_tungstenite client WS
+    session_id: ChannelId,                           // re-sent in hello on reconnect
+    rx_pcm_in: mpsc::Receiver,             // drains inbound PCM → audio_in frames
+    tx_audio_out: mpsc::Sender,            // feeds playout ring from audio_out frames
+    seq_egress: u64,                                 // per-direction counter, starts at 0
+    last_seq_ingress: Option,                   // for gap detection
+    metrics: TapMetrics,                             // shared with TapAudioPipe
+}
+```
+
+The pump loop (simplified): `tokio::select!` over (a) `rx_pcm_in.recv()` → build
+`audio_in` JSON → `ws.send()`; (b) `ws.next()` → deserialize → on `audio_out`, push
+to `tx_audio_out`; on `hello`, ack-tracking; on `bye` / `error`, log + counter; on
+unknown `type`, log + counter + drop. Every send bumps `seq_egress`; every receive
+checks `seq` against `last_seq_ingress` (gap → counter).
+
+The TapClient never decides to reconnect itself — reconnect is the `TapEngine`'s job
+(§4.3). On any WS close / error, TapClient returns from its pump loop; the engine
+rebuilds it. This keeps "the connection" and "the reconnect policy" as separate concerns
+— the one knows the wire, the other knows the backoff.
+
+### 4.3 `TapEngine` (in `crates/rutster/src/tap_engine.rs`, lives in the binary)
+
+The task supervisor. Spawned by the binary at the `Channel::Connected` transition; aborted
+on `Channel::Closing`.
+
+```rust
+pub fn spawn_tap_engine(
+    session_id: ChannelId,
+    tap_url: Url,                                   // validated ws:// 127.0.0.1 OR wss:// (which 501s in slice-2)
+    tx_pcm_in: mpsc::Receiver,             // inbound PCM (drained from peer via TapAudioPipe::on_pcm_frame)
+    tx_audio_out: mpsc::Sender,            // outbound PCM (playout ring feed)
+    close: oneshot::Receiver<()>,                    // aborted on Channel::Closing
+) -> JoinHandle<()>
+```
+
+Loop:
+
+1. `tokio_tungstenite::connect_async(tap_url)` with bounded timeout (2 s). On failure →
+   exponential backoff (`250 ms → 500 ms → 1 s → 2 s → cap 5 s`, infinite retries) and
+   retry. (Playout ring stays empty → `TapAudioPipe::next_pcm_frame` returns `None`
+   → silence; the call survives.)
+2. On connect: send `hello`, await brain `hello` (bounded 2 s; on timeout → close + retry).
+3. On handshake: enter the `TapClient` pump loop. The pump runs until WS close, WS error,
+   or the `close` oneshot fires.
+4. On any close/error (not the `close` oneshot): flush the playout buffer (drains
+   `tx_audio_out`'s outstanding queue — the `TapAudioPipe` end will see `Disconnected`
+   and emit silence until the new TapClient reconnects via a fresh mpsc), reset both
+   `seq` counters, re-enter step 1.
+
+**Why this isn't the step-4 forbidden "dedicated timing thread":** the TapEngine task
+does cold-path network I/O on tokio's shared runtime pool. It is *not* the 20 ms media
+loop (which slice-1 §3.4 already runs on tokio as a scoped deviation; step 4 lands the
+dedicated-timing-thread swap there, not here). ARCHITECTURE.md's "dedicated timing
+threads, not the shared tokio pool" applies to the *timed media work* — adding a network
+I/O supervisor task in slice-2 doesn't widen slice-1's documented deviation.
+
+### 4.4 Wire-validation posture
+
+`ws://` schemes must resolve to `127.0.0.1` or `localhost` — enforced as a hard runtime
+check at session-create time (returns `400 Bad Request` from `POST /v1/sessions` if
+violated, with a clear error message). `wss://` URLs are accepted by the schema and
+rejected at connect time with a logged `501 NOT IMPLEMENTED` and a counter — the seam
+is reserved for step 6 without ratifying a half-impl.
+
+---
+
+## 5. Lifecycle & failure mode
+
+### 5.1 Session lifecycle (slice-2 delta on slice-1)
+
+1. `POST /v1/sessions` — body now *optionally* carries `{"tap_url": "ws://..."}`. If
+   absent, falls back to `RUTSTER_TAP_URL` env (default `ws://127.0.0.1:8081/echo`).
+   Core validates the scheme (§4.4).
+2. `POST /v1/sessions/:id/offer` — unchanged from slice-1; SDP answer returned.
+3. On ICE+DTLS `Connected` (the slice-1 transition): the binary spawns a **`TapEngine`
+   task** for this `ChannelId`. The task dials the brain URL per §4.3.
+   `Channel.tap = Some(TapHandle)` is set; the binary maps the `ChannelId` to the
+   engine's mpsc handles in an internal `DashMap`.
+4. `Channel.state` transitions still drive the loopback peer (slice-1's machine
+   unchanged). The `Connected→Closing→Closed` path is unchanged except for the additional
+   tap teardown step in step 5 below.
+5. `DELETE /v1/sessions/:id` or peer-close → `Closing`: the binary sends `session_end`
+   over the tap WS, awaits brain `bye` (bounded 500 ms), closes the WS, drops the engine
+   task (the `close` oneshot fires + `JoinHandle::abort`). `Channel.tap = None`. Then
+   slice-1's `Closing → Closed` path runs.
+
+### 5.2 Failure mode
+
+- **WS connect failure / brain unreachable at session-Connect:** the `TapEngine` task
+  retries with bounded exponential backoff (250 ms → 500 ms → 1 s → 2 s → cap at 5 s;
+  infinite retries; a live call must self-heal). During retries: playout ring stays
+  empty → silence. **Channel stays `Connected`** throughout. A counter tracks retry
+  count.
+- **Brain `bye` or WS close mid-call:** same as connect-failure — enter the backoff /
+  reconnect loop. On reconnect: re-send `hello` with the same `session_id` (the
+  `ChannelId` — §5.3). Playout ring is flushed on disconnect (drops stale brain audio);
+  resumes filling as the brain sends `audio_out` again.
+- **Brain protocol error** (malformed frame, unknown `type`, bad `samples` count):
+  log + counter + drop the frame; **do not** disconnect. Hot-path "drop + observe"
+  policy from slice-1 §3.8, extended to the tap wire.
+- **Core-side teardown (DELETE / peer-close / SIGTERM):** the TapEngine sends
+  `session_end`, awaits `bye` (bounded 500 ms), closes the WS, aborts the task. A
+  brain that doesn't `bye` back in time just gets a WS close — acceptable.
+
+### 5.3 The stateless-brain reconnect contract
+
+Slice-2's brain contract is **stateless**: both the Python and Rust echo brains hold no
+per-call state across reconnects. On reconnect:
+
+1. Core sends a fresh `hello` with the **same `session_id` (== the `ChannelId`)**.
+2. Brain acks with `hello`.
+3. Both sides reset `seq` counters to 0.
+4. Playout ring has already been flushed on disconnect; the first `audio_out` from the
+  brain starts a fresh playout.
+
+A real brain (step 3) is free to use `session_id` to resume state (e.g. an LLM
+conversation context) but slice-2 does **not** require or test that: the contract is
+"the brain may have forgotten everything; the core survives." This is the right
+resilience posture for the eventual real brain (which may also crash / restart) and
+the simplest thing to prove the tap interface with.
+
+---
+
+## 6. Call-model delta (`rutster-call-model`)
+
+Slice-1 §5.2 promised `Channel` grows `tap: Option` "with step 2." Slice 2
+delivers that field add — a backwards-compatible field add, no slice-1 code is thrown
+away.
+
+```rust
+pub struct Channel {
+    pub id: ChannelId,
+    pub state: ChannelState,
+    pub direction: Direction,
+    pub created_at: Instant,
+    pub tap: Option,            // NEW (slice-2). None until Connected, set on Connected, cleared on Closing.
+}
+
+pub struct TapHandle(());                  // zero-cost marker: a tap is attached. The binary looks up the live
+                                           //   connection by channel.id (ChannelId == session_id per §5.3) in its DashMap.
+                                           //   Zero-sized so Option compiles to a bool; no extra new UUID minted.
+```
+
+The `Channel` stays signaling-state only — it holds a `TapHandle` (a marker), not the
+connection. The mpsc connections live in the binary's tap registry
+(`DashMap`), keyed by the channel's existing `ChannelId`. This keeps
+`rutster-call-model` a leaf with no tokio dep, and matches slice-1's "media state lives
+internal to `rutster-media`, not on the `Channel`" framing — the tap connection is
+similarly internal to the binary, not the `Channel`.
+
+`TapHandle` is `Option<...>` (not always-`Some`) so a `Channel` can exist before the tap
+attaches (New, Connecting states) and after it detaches (Closing, Closed). The None
+transition on Closing is the tap teardown signal the binary acts on.
+
+The `Channel`'s `id` field *is* the `session_id` carried in the tap's `hello` messages
+(§5.3) and the lookup key for the binary's tap registry — no separate `TapId` newtype.
+This means slice-2's design assumes a **1:1 mapping** between a `Channel` and its tap
+connection (one tap per call). Multi-tap-per-channel (e.g. recording taps beside a brain
+tap) is a future-rung concern — when it appears, that's the trigger to mint a separate
+`TapId` newtype. For slice-2 (one brain per call), the existing `ChannelId` is sufficient
+and avoiding the extra newtype is the right YAGNI call.
+
+The `ChannelState` machine and `Direction` enum are unchanged from slice-1. The tap
+attach/detach is a side-effect of the existing `Connecting → Connected` and `Connected
+→ Closing` transitions, not a new state.
+
+---
+
+## 7. HTTP API delta (`rutster` binary)
+
+### 7.1 `POST /v1/sessions` (delta on slice-1 §4.1)
+
+Body now optionally carries a `tap_url`:
+
+```json
+{
+  "tap_url": "ws://127.0.0.1:8081/echo"
+}
+```
+
+- Body is optional; absent body → `tap_url = RUTSTER_TAP_URL` env default.
+- Body present, no `tap_url` field → same as absent body (env default).
+- Body present, `tap_url` field → env default overridden; scheme validated per §4.4.
+  Returns `400 Bad Request` on a non-loopback `ws://` URL or an unparseable URL.
+- `wss://` URLs pass schema validation and `501 NOT IMPLEMENTED` at connect time
+  (deferred to step 6).
+
+Response unchanged from slice-1: `{ "session_id": "" }`.
+
+### 7.2 Other routes
+
+Unchanged from slice-1: `POST /v1/sessions/:id/offer`, `DELETE /v1/sessions/:id`,
+`GET /`. The static `index.html` gets a minor update to surface tap connection status
+(`Connecting → Connected → Reconnecting`, with retry count) in the existing `
`
+debug area.
+
+### 7.3 The `tap_url` authn gap (flagged)
+
+Slice-2 inherits slice-1's "no authn/authz" posture. The `tap_url` override means any
+caller can point the core's tap at an arbitrary URL — a privilege that will require
+authn/authz in step 6. Slice-2's segment is local dev loop only (no production
+deployment); the gap is documented, not closed. The spec's `wss://` reservation (accept
+URL, 501 at connect) and `127.0.0.1`-only `ws://` enforcement bound the surface — a
+malicious local caller is on a trusted host.
+
+---
+
+## 8. CI, dev loop, testing (delta on slice-1 §6)
+
+### 8.1 New `[workspace.dependencies]` (Cargo.toml)
+
+- `tokio-tungstenite = "0.24"` (WS client + server; the binary's `TapEngine` and
+  `rutster-tap-echo`'s standalone server both use this).
+- `futures-util = "0.3"` (the `Sink`/`Stream` traits for `WebSocketStream`).
+- `serde_json = "1"` (if not already pulled by slice-1's axum dep tree; verify at impl
+  time — if a duplicate-version ban trips in `cargo deny`, prefer the version axum
+  already pulls).
+
+Member crates reference these with `dep.workspace = true`.
+
+### 8.2 CI (`.github/workflows/ci.yml`)
+
+Unchanged structure from slice-1: `cargo fmt --check`, `cargo clippy -D warnings`,
+`cargo test --all`, `cargo deny check`. The new `rutster-tap` and `rutster-tap-echo`
+crates join `--all`. **No Python in CI** — the Python brain is README-documented only.
+The Rust `rutster-tap-echo`'s in-process `EchoServer` powers the integration test;
+no network service is launched by CI.
+
+### 8.3 Dev loop
+
+- `cargo run -p rutster-tap-echo` → starts the Rust echo brain on `127.0.0.1:8081`. Or:
+  `python examples/echo_brain/echo_brain.py` (after `pip install websockets`) for the
+  foreign-language brain.
+- `cargo run` (or `cargo run -p rutster`) → starts axum on `0.0.0.0:8080`, dials out to
+  `$RUTSTER_TAP_URL` (default `ws://127.0.0.1:8081/echo`) on each session.
+- Browser → `http://localhost:8080/` → click "Start call" → grant mic → speak → hear
+  yourself back, routed through the external brain.
+- `RUST_LOG=rutster=debug cargo run` for verbose tracing including tap connect /
+  reconnect / counter events.
+- `--features=echo` on the binary (§2.2): bypasses the tap entirely, routes audio
+  through `EchoAudioPipe` (zero-network-dependency dev mode for slice-1 reproduction).
+
+### 8.4 Testing strategy
+
+- **Unit tests in `rutster-tap`:**
+  - Message (de)serialization round-trips for every `type` (golden JSON fixtures in
+    `tests/fixtures/`).
+  - `samples != 480` validation drops the frame; counter increments.
+  - Unknown `type` dropped + counter increments.
+  - Playout ring: overflow drops oldest (not newest); underflow returns `None`.
+  - `seq` gap detection increments a loss counter.
+  - `TapAudioPipe` end-to-end under a mock TapClient (no network): push PCM via
+    `on_pcm_frame`, assert it lands on the `tx_pcm_in` mpsc; push PCM via the
+    `tx_audio_out` mpsc, assert `next_pcm_frame` returns it.
+- **Unit tests in `rutster-tap-echo`:**
+  - The standalone binary is thin; its echo logic is a `pub fn echo_frame(...) -> ...`
+    on the lib, independently unit-tested (recv `audio_in` → send `audio_out` with
+    same PCM; on `bye`/`session_end` → close cleanly).
+- **Unit tests in `rutster-media` (unchanged from slice-1):** Opus⇄PCM roundtrip; SDP
+  munger; `RtcSession` driven by synthetic str0m `Input`. The slice-1 `EchoAudioPipe`
+  is still exercised here — `TapAudioPipe` is integration-tested via
+  `rutster-tap-echo`.
+- **Integration test in `rutster` binary crate:** spin up the axum server (ephemeral
+  port) + the in-process `EchoServer` (ephemeral port) — set `RUTSTER_TAP_URL`. Drive
+  a synthetic WebRTC peer (extending slice-1's `reqwest` + hand-rolled SDP, or
+  `webrtc-rs` client if slice-1 landed it): push PCM into the core via the WebRTC peer
+  → assert echo frames come back through the tap (`EchoServer` exposes its sent /
+  received frames for inspection) → assert they're re-encoded and pushed to str0m.
+  Plus: delete the channel → assert `session_end` / `bye` handshake. Plus (reconnect
+  path test): kill the `EchoServer` mid-test → assert `Channel` stays `Connected`,
+  tap `Reconnecting` counter increments, playout goes silent; restart the
+  `EchoServer` → assert reconnect succeeds and audio resumes.
+- **Manual e2e test plan (README):**
+  1. `cargo run -p rutster-tap-echo` (the Rust echo brain on `:8081`).
+  2. `cargo run` (core on `:8080`).
+  3. Browser → `http://localhost:8080/` → speak → hear yourself echoed **through the
+     external brain** within ~250 ms (slice-1's 200 ms + tap round-trip + playout
+     headroom).
+  4. Kill the echo brain → server logs `tap disconnected, reconnecting`, audio goes
+     silent, browser shows `Reconnecting (attempt N)`; restart the echo brain → audio
+     resumes; `Channel` stayed `Connected` throughout.
+  5. Repeat steps 1–3 with the Python brain (`python
+     examples/echo_brain/echo_brain.py`) → same outcome (proves language-agnostic
+     protocol).
+  6. `cargo test --all` green; `cargo fmt --check` / `cargo clippy -D warnings` /
+     `cargo deny check` green.
+
+### 8.5 Slice 2 "done" criteria
+
+The slice is complete when, on a clean checkout:
+
+1. `cargo test --all` passes (unit + integration). The new `rutster-tap` and
+   `rutster-tap-echo` crates test green alongside slice-1's suite.
+2. `cargo fmt --check`, `cargo clippy -D warnings`, `cargo deny check` all pass.
+3. `cargo run` (with `rutster-tap-echo` running) → browser, speak, hear echo
+   **through the external brain** within ~250 ms.
+4. Kill echo brain mid-call → server reconnects with bounded backoff (visible in
+   logs + browser `
`), audio resumes on brain restart, `Channel` never left
+   `Connected`.
+5. **Both** `rutster-tap-echo` (Rust) **and** `examples/echo_brain/echo_brain.py`
+   (Python) successfully interop against the core — proves the protocol is
+   language-agnostic.
+6. `rutster-media`'s `loop_driver.rs` and `rtc_session.rs` are **byte-identical in
+   their media-loop paths** to slice-1 (the seam-test: the only diff in `rutster-media`
+   is that `EchoAudioPipe` is retained alongside `TapAudioPipe`, not replacing it). A
+   `git diff v -- crates/rutster-media/src/loop_driver.rs
+   crates/rutster-media/src/rtc_session.rs` shows no hunks in the media-loop
+   functions (doc-comment edits are permitted; no behavior change).
+7. `LEARNING.md` grows ≥3 new pointers: `mpsc`/`oneshot` patterns →
+   `crates/rutster/src/tap_engine.rs`; `VecDeque` as a bounded ring →
+   `crates/rutster-tap/src/tap_audio_pipe.rs`; async WS connect + `Sink`/`Stream` →
+   `crates/rutster-tap/src/tap_client.rs`.
+
+---
+
+## 9. Open decisions (tracked)
+
+- **Binary PCM mode (v: 2).** Base64-in-text-JSON is the v1 wire format. ~33% overhead
+  is acceptable for the dev loop; the protocol reserves `v: 2` for a binary length-
+  prefixed mode (raw LE i16 over WS binary frames) for a later rung. Re-evaluate when
+  (a) a real brain (step 3) hits bandwidth ceilings, or (b) the fuzz harness (step 5)
+  wants to fuzz a binary parser.
+- **Byte-endian negotiation.** v1 emits the host's native endian as raw bytes inside
+  the base64 payload. On a little-endian host (today's typical target) this is LE; on
+  a hypothetical big-endian host it would be BE without warning. The protocol should
+  nail explicit LE byte order in v2; for v1 it's documented as a simplification.
+  Today: every dev-loop brain (Python `websockets` lib, Rust on x86_64/aarch64) is
+  little-endian, so the risk is theoretical.
+- **Tap protocol ADR.** PORT_PLAN §10 lists the agent-tap protocol as "presumptively
+  WSS + core-as-client + clean PCM + core-authoritative playout," *not* a decided ADR
+  yet. Slice-2 **hardens** the presumptive shape against a working implementation. The
+  spec's "Implementation lands, then ADR ratifies the wire shape" is the deliberate
+  sequence — an ADR-0007 (or similar) capturing slice-2's ratified decisions (JSON
+  envelope, base64 v1, core-authoritative playout, stateless reconnect contract) is
+  a post-implementation follow-up, not a slice-2 deliverable. The spec's existence is
+  the ratification for slice-2; the ADR is the durable form.
+- **`wss://` cert/mTLS posture.** Slice-2 reserves the `wss://` URL scheme (accepted at
+  schema validation, `501 NOT IMPLEMENTED` at connect) so step 6 doesn't need a schema
+  change. The actual cert-validation / mTLS / brain-cert-pinning impl is step-6 work.
+
+---
+
+## 10. Out-of-scope re-check (against AGENTS.md slice-2 expectations)
+
+AGENTS.md's "Slice-1 boundaries — what NOT to add (yet)" lists items deferred to specific
+later spearhead steps. For slice 2 the equivalent table is §1.2 above. The cross-check:
+
+- ❌ Dedicated timing thread for media loop → still step 4. Slice-2 adds the TapEngine
+  task but it's a cold-path I/O supervisor, not a timed media loop; slice-1's scoped
+  deviation for the 20 ms loop is unchanged.
+- ❌ TLS on HTTP signaling surface → still step 5.
+- ❌ Authn/authz / multi-tenancy on `/v1/sessions` → step 6. Slice-2 inherits slice-1's
+  no-auth posture (§7.3 flags the new `tap_url` override gap).
+- ❌ Trickle ICE → unchanged.
+- ❌ The brain itself (STT/LLM/TTS) → step 3. Slice-2 ships only echo brains.
+- ❌ Barge-in / VAD-driven playout kill → step 4. Slice-2's playout buffer queues +
+  drops on overflow; doesn't kill on caller speech.
+- ❌ PSTN trunk → still step 5.
+- ❌ Spend cap → still step 6.
+- ❌ CDR / event bus / OTel beyond per-Channel tracing → still step 5.
+- ❌ Browser automation / Playwright → still post-slice-1.
+- ❌ Docker / compose → still later-rung.
+- ❌ Transfer / park / pickup / barge → still escalation rung 2.
+
+If an agent proposes adding any of these in slice 2, the right answer is "no, see the
+slice-2 spec §1.2."
+
+---
+
+## 11. Key design decisions (summary of the brainstorming session)
+
+| Decision | Choice | Rejected alternatives | Why |
+|---|---|---|---|
+| Tap architecture | **B. Decoupled TapEngine** — `TapAudioPipe` is a thin sync wrapper over mpsc + ring; `TapClient` (inside the engine task) owns the WSS connection; `RtcSession` only swaps `EchoAudioPipe` → `TapAudioPipe`. | A. In-pipe tap (WSS task owned by the AudioPipe); C. Explicit `tap` field on `RtcSession` bypassing the seam | Honors slice-1 §3.3's promise verbatim ("no code changes to `RtcSession` itself in step 2"); keeps the 20 ms loop pure (only mpsc + ring-touch); cold-path I/O task ≠ the step-4 forbidden "dedicated timing thread"; reconnect is localized to the engine. |
+| Wire protocol | **Own minimal versioned JSON event protocol**, base64 PCM in text WS frames. | Adopt OpenAI Realtime event schema verbatim; binary length-prefixed framing | ARCHITECTURE.md names WSS as presumptive transport because the consumer is a Python script / OpenAI-Realtime-style API; JSON event envelope is the natural mapping onto that ecosystem. Avoids vendor lock-in at our central interface; the step-3 OpenAI adapter translates. ~33% wire overhead is acceptable at 65 KB/s. |
+| Reference echo brain | **Both**: Python `examples/echo_brain/` (canonical foreign-language) + Rust `crates/rutster-tap-echo` (showcase + integration-test `EchoServer`). | Python-only; Rust-only | Python proves the protocol is language-agnostic and matches the "brain is a Python script" persona; Rust proves the wire types are reusable from outside the core and doubles as the in-process test server. The dual is the user's "showcase Rust binaries + external scripts" goal. |
+| TLS on tap | **`ws://` loopback only, `wss://` deferred to step 6.** Hard runtime check on `127.0.0.1`/`localhost`; `wss://` URL accepted by schema, `501 NOT IMPLEMENTED` at connect. | Always-`wss://` even for localhost (self-signed CA burden); plaintext-`ws://`-only with no `wss://` codepath | Matches slice-1's "TLS needs a cert story" stance; keeps the dev loop zero-cert. Reserves the `wss://` seam so step 6 doesn't need a schema change. |
+| Tap URL config | **Env default + per-call override.** `RUTSTER_TAP_URL` env (default `ws://127.0.0.1:8081/echo`); `POST /v1/sessions` body optional `tap_url` overrides. | Env-only; per-call-only | Env default = simplest dev loop; per-call override demonstrates the routing seam (the precursor to multi-brain routing in step 6); authn on the override is a flagged step-6 gap (§7.3). |
+| Brain failure | **Silence + bounded-backoff reconnect (infinite retries).** 250 ms → 500 ms → 1 s → 2 s → cap 5 s; Channel stays Connected; playout flushed on disconnect; reconnect re-`hello`s with same `session_id`; stateless brain contract. | Silence + give-up; tear-down-the-call | Proves "tap is advisory; core disposes" hard — the call outlives the brain. Self-healing is the right posture for a real brain (which may also crash / restart). |
+| Playout buffer policy | **Drop-oldest on overflow, silence on underflow.** Capacity 5 frames (100 ms). | Drop-newest; larger / smaller capacity | Drop-oldest is lowest-latency-correct (sheds stale frames, keeps buffer at-or-behind real-time); 5 frames absorbs brain jitter without introducing perceptible delay. Capacity is a tunable *constant* in slice-2 (no runtime config). |
+| `EchoAudioPipe` fate | **Retained** in `rutster-media` alongside `TapAudioPipe`; `--features=echo` dev-mode on the binary uses it. | Delete it | Slice-1 unit tests still use it (no network); dev-loop zero-network-dep fallback for slice-1 bug repro; two impls of the same trait is the cleanest documentation that the seam *is* a seam. |
+| `TapHandle` on the `Channel` | **Zero-cost marker newtype `TapHandle(())`; binary looks up the live connection by the channel's existing `ChannelId` in a `DashMap`.** `rutster-call-model` stays a leaf (no tokio dep). | Embed the mpsc handles directly on the Channel; mint a separate `TapId(Uuid)` newtype for the lookup key | Matches slice-1's framing: the `Channel` carries signaling state + markers to media-state-holders, not the media state itself. Keeps the call-model crate pure (no runtime deps). The 1:1 mapping of channel↔tap in slice-2 means `ChannelId` *is* the right lookup key — a separate `TapId` is YAGNI until multi-tap-per-channel appears. |
+
+---
+
+## 12. References
+
+- [README.md](../../../README.md) — north star, capability ladder
+- [ARCHITECTURE.md §"Agent tap"](../../ARCHITECTURE.md) — the presumptive tap shape this
+  slice hardens
+- [PORT_PLAN.md](../../PORT_PLAN.md) — capability checklist + thin-slice phasing;
+  §10 "WASM demoted, agent tap is the extension point"; §10 open decision on the tap
+  protocol
+- [Slice 1 — WebRTC media loopback](2026-06-28-slice-1-webrtc-loopback-design.md) —
+  this slice's foundation; §1.2 out-of-scope table schedules the tap for step 2;
+  §3.3 promises the seam; §5.2 promises the `tap` field
+- [Vision-revision spec](2026-06-26-vision-revision-design.md) — the pressure-test that
+  produced the architecture
+- [ADR-0002](../../adr/0002-north-star-and-fused-core.md) — fused vertical; agent tap
+  as extension point
+- [ADR-0004](../../adr/0004-license.md) — GPL-3.0-or-later
+- [ADR-0006](../../adr/0006-ingress-posture.md) — core-as-client tap posture
+  (tap is egress, opposite security posture to inbound ingress)
+- [AGENTS.md](../../../AGENTS.md) — code style, error handling, slice-boundaries
+  cross-check (§ "Slice-1 boundaries — what NOT to add (yet)")