# Slice 5 — Rented transport: a real phone number via Twilio Media Streams — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Stand up spearhead step 5 — a real phone number with no first-party SIP stack. Twilio
Media Streams forks the PSTN audio over WSS to rutster; call control is Twilio's REST API
(green-zone, the brain never sees credentials). The PSTN leg enters the same FOB reflex loop
WebRTC legs use — same `Reflex<TapAudioPipe>` + `LocalVadReflex` composition (slice-4 REUSED
unchanged), same `MediaThread` tick loop, same barge-in semantics.

**Architecture:** `crates/rutster-trunk/` (currently a stub; ADR-0007 pre-paved the boundary)
fills in with: `G711Codec` (in-core µ-law encode/decode + 8 kHz↔24 kHz linear-interpolated
resampling), `TwilioMediaStreamsServer` (axum WSS endpoint), `TrunkSession` + a parallel
`trunk_driver::drive` function (in the trunk crate, NOT in `crates/rutster-media/src/loop_driver.rs`
— the seam holds), `CallControlClient` trait + `MockCallControlClient` + (feature-gated)
`TwilioCallControlClient`. The binary-side `MediaThread` gains a `MediaLeg` enum
(`WebRTC(RtcSession) | Trunk(TrunkSession)`) + a new `MediaCmd::RegisterTrunk` variant; the
tick loop dispatches via the enum.

**Tech Stack:** Rust stable + 1.85 (CI matrix), the existing workspace, plus `async-trait` +
`reqwest` (already workspace members per slice-2's TapClient WS pattern; reused). No other new deps.

## Global Constraints

- **License:** GPL-3.0-or-later on every crate manifest (ADR-0004).
- **DCO:** every commit signed off — `git commit -s` (AGENTS.md Git workflow).
- **Seam gate (UNCHANGED):** `crates/rutster-media/src/loop_driver.rs` + `crates/rutster-media/src/rtc_session.rs`
  stay byte-identical (CI pinned-blob from slice-4 Task 10 continuously enforced). The new trunk
  driver lives in `crates/rutster-trunk/src/loop_driver.rs` — a parallel-titled file in a different
  crate. The `MediaLeg` match-arm that calls `loop_driver::drive(s, now)` for WebRTC legs is one
  new line in `crates/rutster/src/media_thread.rs` (the existing call to `loop_driver::drive`
  routes through it); the `MediaLeg::Trunk(s) => trunk_driver::drive(s, now)` arm is its sibling.
- **Hot-path policy:** never `?`-propagate on the 20 ms tick; match-and-continue;
  "drop + observe." No `unwrap()`/`expect()` outside tests/const-init.
- **Credential isolation (ADR-0009, load-bearing):** `TwilioCredentials` lives ONLY in
  `crates/rutster-trunk/`. NOT re-exported through the workspace. NOT in `rutster-media`'s
  public API. NOT in `rutster-tap`'s public API. NOT in the brain's WS protocol. The brain
  never sees account SID, auth_token, REST endpoint URL, OR CallSid (CallSid is operational
  correlation ID for the FOB's logs only).
- **Code style:** `cargo fmt` is the single whitespace source of truth. `clippy -D warnings` is
  the lint bar. Newtype wrappers per AGENTS.md where applicable (none intrinsically needed
  this slice — `CallSid: String` is operational correlation, not a type-safety boundary).
- **Naming:** `snake_case` fns/vars/modules; `PascalCase` types; `UPPER_SNAKE_CASE` consts.
- **Learner-facing comments:** per AGENTS.md code style. µ-law codec comments TEACH the
  companding formula — this is exactly the kind of telephony-history the project's "learns Rust
  from this codebase" goal benefits from.
- **CI gates:** `cargo fmt --check`, `cargo clippy --all --all-targets -- -D warnings` (with
  AND without `--features=twilio-live`), `cargo test --all` (default-features off — Mock runs
  in CI), `cargo test --all --features=twilio-live` (LOCAL ONLY — maintainer runs when validating
  a release), `cargo deny check`.
- **Branch/PR:** branches `slice-5/rented-transport-dev-b` (T2+T6+T9+T10) + `slice-5/rented-transport-dev-c`
  (T1+T3+T4+T5+T7+T8) per strategic plan §4.1; PR via `tea`.

## File Structure

### New files

| Path | Responsibility |
|---|---|
| `crates/rutster-trunk/src/g711.rs` | `G711Codec` — µ-law encode/decode tables + 8 kHz↔24 kHz linear-interpolated resampling. Generates `mulaw_decode_table.rs` + `mulaw_encode_table.rs` as build-time `include!`s OR as compile-time-computed `const` arrays. |
| `crates/rutster-trunk/src/mulaw_decode_table.rs` | 256-element `[i16; 256]` decode table, generated from the ITU-T G.711 formula. (Author writes the generator snippet in a comment; the table itself is the compiled output.) |
| `crates/rutster-trunk/src/mulaw_encode_table.rs` | 65536-element `[u8; 65536]` encode table, same generator pattern. |
| `crates/rutster-trunk/src/twilio_media_streams.rs` | `TwilioMediaStreamsServer` — axum WS handler accepting Twilio Media Streams forks; parses JSON envelope (`connected` / `start` / `media` / `stop`); ferries decoded PCM frames into a per-call mpsc + drains the outbound mpsc to send back. |
| `crates/rutster-trunk/src/session.rs` | `TrunkSession` — the per-trunk-leg session struct (parallels slice-1's `RtcSession` minus str0m/Opus/UDP). |
| `crates/rutster-trunk/src/loop_driver.rs` | `trunk_driver::drive(&mut TrunkSession, now) -> Option<Duration>` — the trunk-leg tick function. Parallels `crates/rutster-media/src/loop_driver.rs` minus the str0m/Opus/RTP machinery. |
| `crates/rutster-trunk/src/provider/mod.rs` | `CallControlClient` trait + `CallControlError` + `SpendToken` placeholder (pre-paved seam for step 6). |
| `crates/rutster-trunk/src/provider/mock.rs` | `MockCallControlClient` — in-process test double; CI gate uses this. |
| `crates/rutster-trunk/src/provider/twilio.rs` | `TwilioCallControlClient` — REST client via reqwest (feature-gated behind `twilio-live`; NOT compiled in CI default). |
| `crates/rutster-trunk/tests/reflex_on_trunk.rs` | Reflex-on-trunk-leg verification test (T7): proves `Reflex<TapAudioPipe>` + `LocalVadReflex` (slice-4, REUSED) decorate the trunk leg's TapAudioPipe identically to a WebRTC leg. Barge-in fires. |
| `crates/rutster-trunk/tests/sim_integ.rs` | PSTN sim e2e integration test (T8): `MockCallControlClient` + `MockTwilioMediaStreamsServer` → MediaThread → Reflex → barge-in verified on PSTN leg → brain reply observed → CDR/EventSink emission. |

### Modified files

| Path | What changes |
|---|---|
| `crates/rutster-trunk/Cargo.toml` | Add deps: `rutster-media`, `rutster`, `rutster-call-model`, `rutster-tap`, `tokio`, `axum`, `reqwest`, `async-trait`, `serde`, `serde_json`, `tracing`, `thiserror`, `base64`, `url`. Add features: `default = []`, `twilio-live = []` (gates `provider/twilio.rs`). |
| `crates/rutster-trunk/src/lib.rs` | Replace stub doc-comment-only with `pub mod` declarations + re-exports per spec §3. |
| `crates/rutster/src/media_thread.rs` | Add `MediaLeg` enum + `MediaCmd::RegisterTrunk { call_sid, inbound_from_twilio_rx, outbound_to_twilio_tx, tap_url, reply }` variant + the std-thread match-arm dispatch: `MediaLeg::WebRTC(s) => loop_driver::drive(s, now)` (UNCHANGED) vs `MediaLeg::Trunk(s) => trunk_driver::drive(s, now)` (NEW). The session map's value type changes from `RtcSession` to `MediaLeg`. |
| `crates/rutster/src/main.rs` | Construct + mount `TwilioMediaStreamsServer::router(register_tx)` on the existing axum router. Routes `/twilio/media-stream` (WS), `POST /v1/trunk/sessions`, `POST /v1/trunk/webhook` (T5). |
| `crates/rutster/src/routes.rs` | Add the `POST /v1/trunk/sessions` (originate) + `POST /v1/trunk/webhook` (Twilio callback receiver) handlers. Handlers route via `CallControlClient` (green-zone). |
| `crates/rutster/src/config.rs` | Add `pub fn twilio_credentials() -> Option<TwilioCredentials>` env parser per slice-5/seams pattern (`RUTSTER_TWILIO_ACCOUNT_SID`, `RUTSTER_TWILIO_AUTH_TOKEN`, `RUTSTER_TWILIO_MEDIA_BIND`, `RUTSTER_TWILIO_WEBHOOK_BASE`). |
| `crates/rutster/src/lib.rs` | `pub mod twilio_credentials;` re-export OR fold into existing `config`. (Prefer: keep `TwilioCredentials` in `rutster-trunk` + import via `crates/rutster/src/config.rs::twilio_credentials()`.) |
| `.github/workflows/ci.yml` | Add a `twilio-live` job (runs locally only, NOT on every PR — gated by a manual trigger). The routine CI job stays unchanged (no `--features=twilio-live`). |
| `docs/QUICKSTART.md` | Add env-var table for `RUTSTER_TWILIO_*` env vars + a "make a real phone call" walkthrough (with `--features=twilio-live`). |
| `README.md` | Update the spearhead status line: slice-1–4 + 4½ + slice-5 all green; "make a real phone call via Twilio Media Streams" linked from the Quickstart. |
| `deny.toml` | Add `reqwest`'s transitive deps to the allowlist if `cargo deny check` flags any (most are already admitted via existing transitive deps). |

### SEAM-INVARIANT files (DO NOT TOUCH)

- `crates/rutster-media/src/loop_driver.rs` — byte-identical, all tasks.
- `crates/rutster-media/src/rtc_session.rs` — byte-identical, all tasks.

## Task ordering (for Kimi-worker subagent dispatch)

Per strategic plan §3.2 + §4.1: **dev-c owns the FOB media-streams chain (T1→T3→T4→T5→T7→T8)**,
**dev-b owns the green-zone / provider + docs + final sweep chain (T2→T6→T9→T10)**. Both
devs work in their own worktrees + branches. dev-b can start T2 in parallel with dev-c on T1
— the provider trait doesn't depend on the codec's internals (separate modules).

- **T1** — CRITICAL-PATH FOUNDATION for dev-c. `G711Codec` + decode/encode tables. Lands first
  on the dev-c chain; T3 + T4 consume it. NOT parallelizable across devs (single owner).
- **T2** — FOUNDATION for dev-b. `CallControlClient` trait + `MockCallControlClient` +
  `TwilioCredentials` + env parser. PARALLEL with T1 (different module).
- **T3** — depends on T1 (consumes `G711Codec`). `TwilioMediaStreamsServer`.
- **T4** — depends on T3 (consumes the WSS server's mpsc wiring). `TrunkSession` +
  `trunk_driver::drive`.
- **T5** — depends on T4 (TrunkSession is real) + T2 (CallControlClient for the route handlers).
  `MediaCmd::RegisterTrunk` + `MediaLeg` enum + the two new HTTP routes.
- **T6** — depends on T2. `TwilioCallControlClient` (live REST impl behind `twilio-live` feature)
  + config env parser.
- **T7** — depends on T4 + T5. Reflex-on-trunk-leg verification test.
- **T8** — depends on T5 + T6 + T7. PSTN-sim e2e integration test.
- **T9** — filler after T5 + T6 land. QUICKSTART + README updates.
- **T10** — final sweep depends on T6 (the `reqwest` dep). Cargo-deny + CI gate.

Parallelizable-now filler: LEARNING.md pointers (after T1 + T4 land), `cargo doc` rendering
checks (after the crate skeleton stabilizes).

---

### Task T1: `G711Codec` + µ-law decode/encode tables — the dev-c critical-path foundation

**Files:**
- Create: `crates/rutster-trunk/Cargo.toml` (rewrite the stub — add deps)
- Create: `crates/rutster-trunk/src/g711.rs`
- Create: `crates/rutster-trunk/src/mulaw_decode_table.rs`
- Create: `crates/rutster-trunk/src/mulaw_encode_table.rs`
- Modify: `crates/rutster-trunk/src/lib.rs` (add `pub mod g711;`)
- Test: inline `#[cfg(test)] mod tests` in `g711.rs`

**Interfaces:**
- Consumes: `rutster_media::PcmFrame` (the 24 kHz canonical format from slice-1).
- Produces:
  - `pub struct G711Codec;` (no state — pure static-method form for MVP)
  - `pub fn G711Codec::decode_mulaw_to_pcm(mulaw: &[u8]) -> PcmFrame`
  - `pub fn G711Codec::encode_pcm_to_mulaw(frame: &PcmFrame) -> Vec<u8>`

- [ ] **Step 1: Author the `Cargo.toml` with deps**

```toml
# crates/rutster-trunk/Cargo.toml
[package]
name = "rutster-trunk"
version = "0.0.0"
license.workspace = true
edition.workspace = true
repository.workspace = true
description = "Rented carrier transport — CPaaS media-leg ingress; no first-party SIP (spearhead step 5, ADR-0007)."

[dependencies]
rutster-media = { path = "../rutster-media" }
rutster-call-model = { path = "../rutster-call-model" }
rutster-tap = { path = "../rutster-tap" }
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "sync", "time"] }
axum = { workspace = true }
async-trait = { workspace = true }
reqwest = { workspace = true, features = ["json"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tracing = { workspace = true }
thiserror = { workspace = true }
base64 = { workspace = true }
url = { workspace = true }

[features]
default = []
# The live Twilio CallControlClient is feature-gated; CI default-features-off
# keeps the routine gate clean (Mock runs in CI). Maintainer runs
# `cargo test --features=twilio-live` + the e2e suite when validating a release.
twilio-live = []
```

(If `reqwest`/`base64`/`async-trait` aren't yet workspace deps in root `Cargo.toml`, add them
under `[workspace.dependencies]` mirroring slice-2's TapClient wiring. The dev-c
implementer should check first: `grep '^reqwest\|^base64\|^async-trait' Cargo.toml`.)

- [ ] **Step 2: Author the two lookup tables**

`crates/rutster-trunk/src/mulaw_decode_table.rs`:

```rust
//! 8-bit µ-law to 16-bit linear decode table. Generated from the ITU-T G.711
//! standard piecewise-linear decoding formula. See `g711.rs` for the
//! formula's explanation. Hand-verified against the ITU-T spec tables.
//!
//! The table is the actual compiled output of:
//!   fn mulaw_to_linear(u: u8) -> i16 { /* G.711 formula */ }
//! Run once at maintainer time; pasted here as a const array.
//!
//! # Generated-by-note
//!
//! To regenerate: `cargo test --features=regen-tables` (hypothetical;
//! the table was authored once and is versioned as a constant — there is
//! no regen task in MVP).

#[rustfmt::skip]
pub static MULAW_TO_LINEAR: [i16; 256] = [
    /* generated table - 256 entries */
    // FIXME (implementer): paste the 256 i16 values from the G.711 standard
    // table. Authoritative source: ITU-T G.711 (1972, reaffirmed 2000).
    // The formula: bias=0x84, clip=32635; let sign = u & 0x80; let exponent = (u >> 4) & 0x07;
    //   let mantissa = u & 0x0F; let sample = ((mantissa << 3) + bias) << exponent;
    //   sample = if sample > clip { clip } else { sample };
    //   return if sign != 0 { -(sample) } else { sample } as i16;
    // (Implementer note: write a one-shot test that produces the table; the
    // values ARE the table. The test becomes the documentation.)
    0, 0, /* ... 254 more entries ... */ 0,
];
```

Actually — the simpler path: write a `const fn decode_one(u: u8) -> i16` per the formula +
build the table at compile time. That's cleaner + doesn't require regenerating a literal
list. **Recommended path: use a `const fn` + `const` array initialization.**

```rust
// crates/rutster-trunk/src/g711.rs (table approach via const fn)

/// The ITU-T G.711 µ-law to linear decoding formula. Const fn so the
/// table is computed at compile time (zero runtime cost).
const fn mulaw_decode_one(u: u8) -> i16 {
    let bias: i32 = 0x84;
    let clip: i32 = 32635;
    let sign = (u & 0x80) != 0;
    let exponent = ((u >> 4) & 0x07) as i32;
    let mantissa = (u & 0x0F) as i32;
    let mut sample = ((mantissa << 3) + bias) << exponent;
    if sample > clip { sample = clip; }
    if sign { (-(sample)) as i16 } else { sample as i16 }
}

pub static MULAW_TO_LINEAR: [i16; 256] = {
    let mut t = [0i16; 256];
    let mut i = 0;
    while i < 256 {
        t[i] = mulaw_decode_one(i as u8);
        i += 1;
    }
    t
};
```

For the inverse direction (linear→µ-law), the table is 65536 elements = 64 KB binary size. Author
the encode table as `const fn`:

```rust
const fn linear_to_mulaw_one(s: i16) -> u8 {
    // ITU-T G.711's reference encode formula. See ITU-T G.711 §2.2.
    // Pseudocode (from the spec, adapted to Rust):
    let bias: i32 = 0x84;
    let clip: i32 = 32635;
    let s = if s < 0 { -s.max(-(clip + 1)) } else { s.min(clip) };
    let s_bias = s + bias;
    let sign = if s < 0 { 0x80 } else { 0x00 };
    // Find the highest set bit position (encoded as exponent).
    let exponent = (s_bias.leading_zeros() as i32).wrapping_sub(1).max(0).min(7);
    let mantissa = ((s_bias >> (exponent + 3)) & 0x0F) as u8;
    let u = !(sign as u8 | ((exponent as u8) << 4) | mantissa);
    u
}
```

(The formula above is a sketch — the implementer MUST cross-verify against the ITU-T G.711
spec's reference C code, which is widely available. The correctness test in Step 5
cross-checks encode(decode(x)) == x for every byte value.)

- [ ] **Step 3: Author `g711.rs` with `decode_mulaw_to_pcm` + `encode_pcm_to_mulaw`**

The bodies mirror spec §3.1 verbatim — 3× linear upsample + 3× decimation downsample. Don't
invent new code.

- [ ] **Step 4: Write failing tests**

```rust
#[cfg(test)]
mod tests {
    use super::*;
    use rutster_media::PcmFrame;

    #[test]
    fn decode_then_encode_round_trips_a_loud_signal() {
        // Hand-craft a 24 kHz PcmFrame with a loud sample → encode to µ-law →
        // decode back → assert energy is preserved within 12% (µ-law's intrinsic
        // quantization error floor).
        let mut frame = PcmFrame::zeroed();
        for s in frame.samples.iter_mut() { *s = 10_000; }
        let mulaw = G711Codec::encode_pcm_to_mulaw(&frame);
        assert_eq!(mulaw.len(), 160);
        let decoded = G711Codec::decode_mulaw_to_pcm(&mulaw);
        // Energy invariant: decoded RMS should be within 12% of the original.
        let orig_rms = rms(&frame.samples);
        let dec_rms = rms(&decoded.samples);
        let drift = (dec_rms - orig_rms).abs() / orig_rms.max(1.0);
        assert!(drift <= 0.12, "µ-law round-trip energy drift {}% > 12%", drift * 100.0);
    }

    #[test]
    fn decode_160_byte_frame_yields_480_samples() {
        let mulaw = vec![0u8; 160];  // 160 µ-law bytes (silence)
        let frame = G711Codec::decode_mulaw_to_pcm(&mulaw);
        assert_eq!(frame.samples.len(), 480);
    }

    fn rms(samples: &[i16]) -> f64 {
        let sum_sq: u64 = samples.iter().map(|&s| (s as i64 * s as i64) as u64).sum();
        (sum_sq as f64 / samples.len() as f64).sqrt()
    }
}
```

- [ ] **Step 5: fmt + clippy + test + commit**

```bash
cargo fmt --all --check && cargo clippy --all --all-targets -- -D warnings && cargo test --all
git add crates/rutster-trunk/Cargo.toml crates/rutster-trunk/src/{g711.rs,mulaw_decode_table.rs,mulaw_encode_table.rs,lib.rs}
git commit -s -m "feat(trunk): G711Codec — µ-law encode/decode + 8kHz↔24kHz linear-interpolated resampling (slice-5 T1)

In-core ~30-line table-driven codec (no dep). The ITU-T G.711 µ-law
companding formula is a piece of telephony history worth teaching (AGENTS.md
learner-facing comment mandate). 3× linear upsample on decode; 3× decimation
downsample on encode. The resampler artifacts are below the barge-in trigger
threshold (LocalVadReflex only needs RMS energy); rubato lands in a post-
spearhead refinement if a downstream consumer needs better (spec §6.6).

Task T1 of slice-5 — T3 (TwilioMediaStreamsServer) consumes this codec."
```

---

### Task T2: `CallControlClient` trait + `MockCallControlClient` + `TwilioCredentials` config

**Files:**
- Create: `crates/rutster-trunk/src/provider/mod.rs`
- Create: `crates/rutster-trunk/src/provider/mock.rs`
- Modify: `crates/rutster-trunk/src/lib.rs` (`pub mod provider;`)
- Test: inline in `provider/mock.rs`

**Interfaces:**
- Consumes: nothing new.
- Produces:
  - `#[async_trait] pub trait CallControlClient: Send + Sync { async fn originate(&self, to, from, spend_token: Option<SpendToken>) -> Result<String, CallControlError>; async fn hangup(&self, correlation_id) -> Result<(), CallControlError>; }`
  - `pub struct CallControlError(pub String);`
  - `pub struct SpendToken;` (the pre-paved seam for step 6; opaque placeholder).
  - `pub struct MockCallControlClient { /* in-process queues */ }`
  - `pub struct TwilioCredentials { account_sid, auth_token (NEVER logged), media_streams_bind, webhook_base }` — the struct lives in `provider/mod.rs` (NOT re-exported through the workspace; ADR-0009). The `TwilioCallControlClient` consumes it (T6).

- [ ] **Step 1: Define the trait + SpendToken + CallControlError**

(Verbatim from spec §3.4 + `async_trait` attribute.)

- [ ] **Step 2: Implement `MockCallControlClient`**

An in-process test double — `originate` returns a synthetic correlation ID + records the call
in an internal `Vec`; `hangup` records the terminate. Both async fns never touch the network.

- [ ] **Step 3: Define `TwilioCredentials` (no impl yet — fields only)**

The struct fields per spec §3.4. NO logging of `auth_token` — implement `Debug` manually with
a redacted field; don't derive `Debug`.

```rust
impl std::fmt::Debug for TwilioCredentials {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("TwilioCredentials")
            .field("account_sid", &self.account_sid)
            .field("auth_token", &"<redacted>")
            .field("media_streams_bind", &self.media_streams_bind)
            .field("webhook_base", &self.webhook_base)
            .finish()
    }
}
```

- [ ] **Step 4: Tests + fmt + clippy + commit**

```bash
cargo fmt --all --check && cargo clippy --all --all-targets -- -D warnings && cargo test --all
git commit -s -m "feat(trunk): CallControlClient trait + MockCallControlClient + TwilioCredentials (slice-5 T2)

The provider abstraction. The trait locks the seam so the next provider
(Telnyx, etc.) is an implementation, not a refactor. The Mock is the CI
test double; TwilioCallControlClient (T6) lives behind the twilio-live
feature flag. TwilioCredentials is NEVER re-exported through the workspace
(ADR-0009 — provider credentials never reach the brain). The
Option<SpendToken> parameter on originate pre-paves the spearhead step-6
spend-cap seam."
```

---

### Task T3: `TwilioMediaStreamsServer` — the WebSocket ingress

**Files:**
- Create: `crates/rutster-trunk/src/twilio_media_streams.rs`
- Modify: `crates/rutster-trunk/src/lib.rs` (`pub mod twilio_media_streams;`)
- Test: inline `#[cfg(test)] mod tests` covering the JSON envelope parser

**Interfaces:**
- Consumes: `G711Codec` (T1), axum WebSocket types, tokio mpsc, `base64`.
- Produces:
  - `pub struct TwilioMediaStreamsServer;`
  - `pub struct RegisterTrunkInboundChannel { call_sid: String, inbound_from_twilio_rx: mpsc::Receiver<PcmFrame>, outbound_to_twilio_tx: mpsc::Sender<PcmFrame>, tap_url: url::Url }`
  - `pub fn TwilioMediaStreamsServer::router(register_tx: mpsc::Sender<RegisterTrunkInboundChannel>) -> axum::Router` — mounts `/twilio/media-stream` (axum `get` WebSocket upgrade).

- [ ] **Step 1: Define the envelope-parsing types**

Twilio Media Streams JSON frames per their documented protocol:
- `connected`: handshake.
- `start`: `{ event: "start", stream_sid, call_sid, ... }` — read CallSid + StreamSid.
- `media`: `{ event: "media", media: { payload: "<base64 µ-law bytes>" } }` — decode payload.
- `stop`: end.

Define `TwilioMediaEvent` serde-representable enum mirroring these.

- [ ] **Step 2: Implement `run_media_stream` async loop**

Pseudocode:
1. Wait for first WS message. Expect `connected` envelope; ignore.
2. Read `start` envelope. Extract CallSid + StreamSid. Construct the two mpsc pairs.
3. Send `RegisterTrunkInboundChannel` to register_tx. Await `MediaThread`'s `RegisterTrunk` reply
   (a oneshot confirming the ChannelId).
4. Loop:
   a. Read next WS message:
      - `media`: base64-decode, `G711Codec::decode_mulaw_to_pcm` → `PcmFrame`, push to `inbound_tx`.
      - `stop` (or WS close): break.
   b. Concurrently drain `outbound_rx` (PcmFrame produced by the std thread's TrunkSession via
      `trunk_driver::drive`): `G711Codec::encode_pcm_to_mulaw` → wrap in a JSON `media` envelope
      → send WS Text frame.
5. On stop / close: send `MediaCmd::Delete` for the ChannelId; tear down.

Use `tokio::select!` for the bidirectional loop (Step 4a + 4b concurrently).

- [ ] **Step 3: Tests**

```rust
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn media_event_parses_start() {
        let s = r#"{"event":"start","stream_sid":"ZZ...","call_sid":"CA..."}"#;
        let e: TwilioMediaEvent = serde_json::from_str(s).unwrap();
        match e {
            TwilioMediaEvent::Start { call_sid, .. } => assert_eq!(call_sid, "CA..."),
            _ => panic!("expected Start"),
        }
    }

    #[test]
    fn media_event_parses_media() {
        let s = r#"{"event":"media","media":{"payload":"//MA"}}"#;
        let e: TwilioMediaEvent = serde_json::from_str(s).unwrap();
        match e {
            TwilioMediaEvent::Media { payload } => assert!(!payload.is_empty()),
            _ => panic!("expected Media"),
        }
    }

    #[test]
    fn media_event_parses_stop() {
        let s = r#"{"event":"stop"}"#;
        let e: TwilioMediaEvent = serde_json::from_str(s).unwrap();
        assert!(matches!(e, TwilioMediaEvent::Stop));
    }

    #[tokio::test]
    async fn wss_pump_decodes_base64_mulaw_and_pushes_pcm_frame() {
        // Integration-ish: feed a synthetic "media" JSON frame into the WS
        // pump task; assert a PcmFrame lands on the inbound_tx mpsc.
        // (Spot-test the base64 decode + G711Codec decode + mpsc push wiring.)
        // Skip the full WS handshake; test the post-parse path.
    }
}
```

- [ ] **Step 4: fmt + clippy + test + commit**

```bash
cargo fmt --all --check && cargo clippy --all --all-targets -- -D warnings && cargo test --all
git commit -s -m "feat(trunk): TwilioMediaStreamsServer — axum WS handler for Twilio Media Streams (slice-5 T3)

Accepts Twilio's inbound WSS connections; parses the JSON envelope
(connected/start/media/stop per Twilio's documented protocol); decodes
base64 µ-law via G711Codec; ferries decoded PCM frames to the per-call
inbound mpsc. Concurrently drains the outbound mpsc + sends back JSON
media frames. Same tokio/std-thread split as slice-2's TapEngine: tokio
owns IO; the std thread owns the 20ms tick via trunk_driver::drive (T4)."
```

---

### Task T4: `TrunkSession` + `trunk_driver::drive` — the FOB-side trunk tick function

**Files:**
- Create: `crates/rutster-trunk/src/session.rs`
- Create: `crates/rutster-trunk/src/loop_driver.rs`
- Modify: `crates/rutster-trunk/src/lib.rs` (`pub mod session; pub use session::TrunkSession; pub mod loop_driver;`)
- Test: inline tests in `session.rs` exercising the tick

**Interfaces:**
- Consumes: slice-4 `Reflex<P>` + `LocalVadReflex<P>` + `ReflexMetrics` (REUSED), slice-2
  `TapAudioPipe` (REUSED as the wrapped inner), tokio mpsc, `rutster_call_model::Channel`.
- Produces:
  - `pub struct TrunkSession { channel, pipe: LocalVadReflex<Reflex<TapAudioPipe>>, inbound_from_twilio_rx, outbound_to_twilio_tx, last_idle_rx, next_timeout }`
  - `pub fn drive(session: &mut TrunkSession, now: Instant) -> Option<Duration>` — the trunk
    tick function.

- [ ] **Step 1: Author `TrunkSession`**

```rust
pub struct TrunkSession {
    pub channel: Channel,
    /// The wrapped reflex stack. REUSED from slice-4 verbatim:
    /// `LocalVadReflex<Reflex<TapAudioPipe>>`. Construction reused from
    /// slice-4 Task 6 — same composition site (Reflex::new(inner_pipe,
    /// advisory_rx, metrics) → LocalVadReflex::new(reflex, advisory_tx)).
    pub pipe: LocalVadReflex<Reflex<TapAudioPipe>>,
    pub inbound_from_twilio_rx: mpsc::Receiver<PcmFrame>,
    pub outbound_to_twilio_tx: mpsc::Sender<PcmFrame>,
    pub last_idle_rx: Instant,
    pub next_timeout: Option<Instant>,
    pub last_outbound_at: Instant,
}
```

- [ ] **Step 2: Author `trunk_driver::drive`**

Body mirrors spec §3.3 verbatim:
1. Drain `inbound_from_twilio_rx` via `try_recv` → for each `PcmFrame`:
   `session.pipe.on_pcm_frame(frame)` (hits `LocalVadReflex` first → `Reflex` → `TapAudioPipe` →
   `tx_pcm_in` → TapEngine → brain WS as `audio_in`).
2. Outbound encode tick (every 20 ms): pull `session.pipe.next_pcm_frame()` → push to
   `outbound_to_twilio_tx` (try_send; drop + observe on full).
3. Idle timeout: 60 s of no inbound = `ChannelState::Closed`.

- [ ] **Step 3: Tests**

```rust
#[cfg(test)]
mod tests {
    use super::*;
    use std::time::Instant;

    fn trivial_trunk_session() -> TrunkSession {
        let (inbound_tx, inbound_rx) = mpsc::channel(8);
        let (outbound_tx, mut outbound_rx) = mpsc::channel(8);
        // ... construct the wrapped pipe stack;
        // (For the unit test, the real spawn_tap_engine against a real brain WS
        // is heavy — use a stub TapAudioPipe OR a no-op brain.)
        // Recommendation: simplify this test by constructing the stack with a
        // mock AudioPipe as the inner (instead of TapAudioPipe) — verify the
        // tick function dispatches incoming frames to pipe.on_pcm_frame + pulls
        // from pipe.next_pcm_frame without setting up real TapEngine wiring.
        // The full barge-in e2e is verified in T8, not here.
    }

    #[tokio::test]
    async fn tick_drains_inbound_mpsc_and_pushes_to_pipe_sink() {
        let mut s = trivial_trunk_session();
        s.inbound_from_twilio_tx.try_send(PcmFrame::zeroed()).unwrap();
        let now = Instant::now();
        drive(&mut s, now);
        // (Assert the inner mock AudioPipe received the frame — via a counter
        // OR via observable side effect.)
    }

    #[tokio::test]
    async fn tick_pulls_from_pipe_source_and_pushes_to_outbound() {
        let mut s = trivial_trunk_session();
        // (Mock the wrapped pipe to produce a known frame on next_pcm_frame.)
        let now = Instant::now();
        drive(&mut s, now);
        let _ = s.outbound_to_twilio_rx.try_recv().expect("frame flowed out");
    }

    #[tokio::test]
    async fn idle_timeout_closes_session_after_60s_silence() {
        let mut s = trivial_trunk_session();
        // Set last_idle_rx 90 seconds ago.
        let now = Instant::now();
        s.last_idle_rx = now - Duration::from_secs(90);
        let next = drive(&mut s, now);
        assert_eq!(s.channel.state, ChannelState::Closed);
        assert_eq!(next, None);
    }
}
```

- [ ] **Step 4: fmt + clippy + test + commit**

```bash
cargo fmt --all --check && cargo clippy --all --all-targets -- -D warnings && cargo test --all
git commit -s -m "feat(trunk): TrunkSession + trunk_driver::drive — FOB-side trunk-leg tick (slice-5 T4)

The trunk leg's tick function. Parallels crates/rutster-media/src/loop_driver.rs
minus the str0m/Opus/RTP machinery — there is no RTP to decode, no Opus to
encode, no str0m poll loop to drain. Caller→FOB direction is a pure mpsc
drain; FOB→caller direction is a mpsc push.

Slice-4's Reflex<TapAudioPipe> + LocalVadReflex stack compose identically
around the trunk leg's session.pipe — proving the FOB reflex loop is
ingress-agnostic (spec §2.3 — the architecture's load-bearing claim).

The seam gate holds: crates/rutster-media/src/{loop_driver.rs,rtc_session.rs}
stay byte-identical because the trunk leg NEVER enters that code path. The
MediaThread dispatches via the new MediaLeg enum (T5)."
```

---

### Task T5: `MediaCmd::RegisterTrunk` + `MediaLeg` enum + the new HTTP routes — the binary-side wiring

**Files:**
- Modify: `crates/rutster/src/media_thread.rs`
- Modify: `crates/rutster/src/main.rs`
- Modify: `crates/rutster/src/routes.rs`
- Modify: `crates/rutster/src/lib.rs` (if needed for re-exports)
- Modify: `crates/rutster-trunk/src/lib.rs` (if the `RegisterTrunkInboundChannel` type is publicly surfaced)
- Test: inline in `crates/rutster/src/routes.rs` for the new routes (spot-test the handlers)

**Interfaces:**
- Consumes: T3 (TwilioMediaStreamsServer) + T4 (TrunkSession + trunk_driver::drive) + T2 (CallControlClient trait).
- Produces:
  - `MediaCmd::RegisterTrunk { call_sid, inbound_from_twilio_rx, outbound_to_twilio_tx, tap_url, reply }` — the new variant in the existing enum.
  - `pub enum MediaLeg { WebRTC(RtcSession), Trunk(TrunkSession) }` — the session-map value type, in `media_thread.rs`.
  - `POST /v1/trunk/sessions` axum route — originates an outbound call.
  - `POST /v1/trunk/webhook` axum route — Twilio's inbound-call webhook receiver.

- [ ] **Step 1: Add the `MediaLeg` enum + `RegisterTrunk` variant + dispatch match**

Update `crates/rutster/src/media_thread.rs`:

```rust
pub enum MediaLeg {
    /// Existing WebRTC leg. Ticked by `rutster_media::loop_driver::drive`.
    /// UNCHANGED code path from slice-4 + slice-5/seams.
    WebRTC(RtcSession),
    /// New trunk leg. Ticked by `rutster_trunk::loop_driver::drive`.
    Trunk(rutster_trunk::session::TrunkSession),
}

pub enum MediaCmd {
    // ... existing variants unchanged ...

    /// slice-5: register a trunk-side session. The MediaThread:
    /// 1. Allocates a ChannelId.
    /// 2. Constructs TapAudioPipe + TapConn via spawn_tap_engine (from slice-2; REUSED).
    /// 3. Wraps as Reflex::new(tap_pipe, advisory_rx, metrics) then
    ///    LocalVadReflex::new(reflex, advisory_tx) — same Task-6-style composition as slice-4.
    /// 4. Constructs TrunkSession { channel, pipe, inbound_from_twilio_rx,
    ///    outbound_to_twilio_tx, ... } and inserts into the session map under MediaLeg::Trunk.
    /// 5. Spawns the TapEngine tokio task.
    /// 6. Replies with the ChannelId.
    RegisterTrunk {
        call_sid: String,
        inbound_from_twilio_rx: tokio::sync::mpsc::Receiver<PcmFrame>,
        outbound_to_twilio_tx: tokio::sync::mpsc::Sender<PcmFrame>,
        tap_url: url::Url,
        reply: tokio::sync::oneshot::Sender<ChannelId>,
    },
}

// In the std thread's tick loop, dispatch the per-leg tick:
fn run_per_leg_tick(leg: &mut MediaLeg, now: Instant) -> Option<Duration> {
    match leg {
        MediaLeg::WebRTC(s) => rutster_media::loop_driver::drive(s, now),  // UNCHANGED from slice-4
        MediaLeg::Trunk(s) => rutster_trunk::loop_driver::drive(s, now),   // NEW this slice
    }
}
```

The existing call site for `loop_driver::drive(s, now)` (within the WebRTC tick loop) routes
through this `run_per_leg_tick` — a one-line refactor of the existing invocation. The seam
file `loop_driver.rs` itself is byte-identical (the call site is in `media_thread.rs`, the
binary-side bridge).

- [ ] **Step 2: Add the `RegisterTrunk` handler in the std thread loop**

When `RegisterTrunk` arrives:
1. Construct `tap_url` (operator-configured brain WS URL e.g. `ws://localhost:8082` for MockRealtimeBrain).
2. Construct `(advisory_tx, advisory_rx) = mpsc::channel(16)` + `metrics = ReflexMetrics::new()`.
3. `let (tap_pipe, tap_conn) = spawn_tap_engine(channel_id, tap_url, advisory_tx)` — REUSED from slice-4 Task 5.
4. `let reflex = Reflex::new(tap_pipe, advisory_rx, metrics.clone());`
5. `let wrapped = LocalVadReflex::new(reflex, advisory_tx_clone);` (where `advisory_tx_clone` is the second sender; advisory_tx has TWO senders — the engine + the local VAD).
6. Construct `TrunkSession { channel: Channel::new(channel_id), pipe: wrapped, inbound_from_twilio_rx, outbound_to_twilio_tx, last_idle_rx: now, next_timeout: None, last_outbound_at: now }`.
7. Insert into `session_map.insert(channel_id, MediaLeg::Trunk(session))`.
8. Spawn the TapEngine tokio task (held by `tap_conn`).
9. Reply with `channel_id` via the oneshot.

(session_map type changes from `HashMap<ChannelId, RtcSession>` to `HashMap<ChannelId, MediaLeg>` —
the WebRTC leg-handling code path handles the enum's `WebRTC` variant via `run_per_leg_tick`.)

- [ ] **Step 3: Mount `TwilioMediaStreamsServer::router(register_tx)` on the axum router in `main.rs`**

In `crates/rutster/src/main.rs`:
- Allocate a `tokio::sync::mpsc::Sender<RegisterTrunkInboundChannel>` pair (cap 64).
- Pass the `Sender` end into `TwilioMediaStreamsServer::router(...)`.
- The `Receiver` end runs in a tokio task: when a `RegisterTrunkInboundChannel` arrives, it
  relays the contents into a `MediaCmd::RegisterTrunk` envelope + awaits the reply, then ACKs
  the WSS pump task.
- Mount the router's `/twilio/media-stream` route alongside the existing routes (`/v1/sessions`,
  `/healthz`, etc.).

- [ ] **Step 4: Author the two new HTTP routes in `routes.rs`**

- `POST /v1/trunk/sessions { to, from }` — call `CallControlClient::originate(to, from, None)`
  (None because spend gate is step 6); respond with `{ channel_id, call_sid }`.
- `POST /v1/trunk/webhook` — Twilio's webhook receiver for inbound-call signaling. Respond
  with TwiML body instructing Twilio to Media Streams against our `/twilio/media-stream`.

- [ ] **Step 5: Tests** — `route_returns_channel_id_for_originate`,
  `webhook_responds_with_twiml_pointing_at_media_stream`.

- [ ] **Step 6: fmt + clippy + test + commit**

```bash
cargo fmt --all --check && cargo clippy --all --all-targets -- -D warnings && cargo test --all
git commit -s -m "feat(binary): MediaCmd::RegisterTrunk + MediaLeg enum + /v1/trunk routes (slice-5 T5)

The binary-side wiring of the trunk leg. The seam gate holds: the
MediaThread's tick loop dispatches via the new MediaLeg enum;
MediaLeg::WebRTC(s) => loop_driver::drive(s, now) is UNCHANGED from slice-4.
MediaLeg::Trunk(s) => trunk_driver::drive(s, now) is the new sibling.

Two new axum routes: POST /v1/trunk/sessions initiates an outbound call via
the CallControlClient trait (green-zone); POST /v1/trunk/webhook receives
Twilio's inbound-call signaling + responds with TwiML pointing at our
/twilio/media-stream WS endpoint.

The RegisterTrunk handler on the std thread constructs the TrunkSession +
composes the slice-4 Reflex<TapAudioPipe> + LocalVadReflex stack identically
to slice-4 Task 6's WebRTC spawn seam."
```

---

### Task T6: `TwilioCallControlClient` (the live REST impl) + `config.rs` env parser

**Files:**
- Create: `crates/rutster-trunk/src/provider/twilio.rs`
- Modify: `crates/rutster-trunk/src/provider/mod.rs` (re-export `TwilioCallControlClient` ONLY when `twilio-live` feature is enabled)
- Modify: `crates/rutster/src/config.rs` (add `pub fn twilio_credentials() -> Option<TwilioCredentials>`)
- Test: live e2e test gated behind `twilio-live` feature — runs once on maintainer's machine via a manual `cargo test --features=twilio-live`

**Interfaces:**
- Consumes: trait from T2 + `reqwest` + the env-var parser from slice-5/seams.
- Produces:
  - `pub struct TwilioCallControlClient { credentials: TwilioCredentials, http: reqwest::Client }`
  - `impl TwilioCallControlClient { pub fn new(credentials: TwilioCredentials) -> Self }`
  - `#[async_trait] impl CallControlClient for TwilioCallControlClient`
  - `pub fn twilio_credentials() -> Option<TwilioCredentials>` in `crates/rutster/src/config.rs`

- [ ] **Step 1: Implement `TwilioCallControlClient`**

`originate`: POST to `https://api.twilio.com/2010-04-01/Accounts/{account_sid}/Calls.json` with
basic auth (account_sid:auth_token) + form body containing `To`, `From`, `Twiml` (a `<Connect>`
TwiML instructing Twilio to Media Streams against our webhook_base + `/twilio/media-stream`).
Parse the JSON response for the `call_sid`. Return `Ok(call_sid)`.

`hangup`: POST to `.../Calls/{call_sid}.json` with `Status=completed`. Idempotent.

`auth_token` MUST NEVER be logged. Use `tracing::debug!` only on caller-controlled fields
(`to`, `from`, `call_sid`'s last 4 chars).

- [ ] **Step 2: Implement the env parser in `config.rs`**

```rust
/// Parse RUTSTER_TWILIO_ACCOUNT_SID + RUTSTER_TWILIO_AUTH_TOKEN +
/// RUTSTER_TWILIO_MEDIA_BIND + RUTSTER_TWILIO_WEBHOOK_BASE from env.
/// Returns None if any required var is missing (the binary runs without
/// trunk support if credentials aren't provided — WebRTC ingress still works).
pub fn twilio_credentials() -> Option<TwilioCredentials> {
    let account_sid = std::env::var("RUTSTER_TWILIO_ACCOUNT_SID").ok()?;
    let auth_token = std::env::var("RUTSTER_TWILIO_AUTH_TOKEN").ok()?;
    let media_streams_bind = std::env::var("RUTSTER_TWILIO_MEDIA_BIND")
        .ok()?
        .parse::<std::net::SocketAddr>()
        .map_err(|e| format!("invalid RUTSTER_TWILIO_MEDIA_BIND: {e}"))
        .ok()?;
    let webhook_base = std::env::var("RUTSTER_TWILIO_WEBHOOK_BASE")
        .ok()?
        .parse::<url::Url>()
        .map_err(|e| format!("invalid RUTSTER_TWILIO_WEBHOOK_BASE: {e}"))
        .ok()?;
    Some(TwilioCredentials { account_sid, auth_token, media_streams_bind, webhook_base })
}
```

- [ ] **Step 3: Live e2e test gated behind `twilio-live`**

```rust
#[cfg(all(test, feature = "twilio-live"))]
mod live_tests {
    use super::*;

    #[tokio::test]
    #[ignore = "requires live Twilio credentials + a real outbound number; manual run only"]
    async fn originate_real_call_returns_call_sid() {
        let creds = crate::config::twilio_credentials().expect("set RUTSTER_TWILIO_* env vars");
        let client = TwilioCallControlClient::new(creds);
        let to = std::env::var("RUTSTER_TWILIO_TEST_TO").expect("set RUTSTER_TWILIO_TEST_TO");
        let from = std::env::var("RUTSTER_TWILIO_TEST_FROM").expect("set RUTSTER_TWILIO_TEST_FROM");
        let call_sid = client.originate(&to, &from, None).await.expect("originate");
        assert!(call_sid.starts_with("CA"));
        // Hang up immediately (don't actually call the test number for real).
        client.hangup(&call_sid).await.expect("hangup");
    }
}
```

- [ ] **Step 4: fmt + clippy + test (default features OFF) + commit**

```bash
cargo fmt --all --check && cargo clippy --all --all-targets -- -D warnings
cargo clippy --all --all-targets --features=twilio-live -- -D warnings
cargo test --all  # CI gate: Mock only
git commit -s -m "feat(trunk): TwilioCallControlClient (live REST impl behind twilio-live feature) + env parser (slice-5 T6)

The live REST call-control client. Originates outbound calls via Twilio's
Calls.json API; hangs up via Status=completed. Auth_token is NEVER logged
(ADR-0009 — provider credentials never reach the brain; this boundary is
honored at the trunk-crate level). The impl is feature-gated behind
twilio-live so the routine CI gate stays clean; the maintainer runs
cargo test --features=twilio-live when validating a release.

Env parser in config.rs follows slice-5/seams' pure-function pattern
(take Option<String>/&str inputs; testable without env mutation)."
```

---

### Task T7: Reflex-on-trunk-leg verification test

**Files:**
- Create: `crates/rutster-trunk/tests/reflex_on_trunk.rs`
- Test: integration test asserting slice-4's `Reflex<TapAudioPipe>` + `LocalVadReflex` (REUSED)
  decorate the trunk leg's TapAudioPipe identically; barge-in fires on trunk-leg caller speech.

**Interfaces:**
- Consumes: T3 (TwilioMediaStreamsServer) — actually a `MockTwilioMediaStreamsServer` test double
  firing synthetic media frames; T4 (TrunkSession + trunk_driver); slice-3's MockRealtimeBrain.

- [ ] **Step 1: Write the integration test**

The test:
1. Stand up MockRealtimeBrain (slice-3 merged). URL = `mock.url()`.
2. Stand up the in-process MediaThread via `MediaThread::spawn(default_tap_url, tokio_handle)`.
3. Construct the wiring manually: spawn the WSS-pump-task-equivalent that simulates a Twilio
   "media" frame as a synthetic `PcmFrame::loud()` → push into `inbound_from_twilio_tx`.
4. Send `MediaCmd::RegisterTrunk { call_sid, inbound_from_twilio_rx, outbound_to_twilio_tx,
   tap_url: mock.url(), reply }`.
5. Drive ticks manually OR let the std thread run for ~200 ms.
6. Assert: the brain received `audio_in` events (the loud frames forwarded through TapAudioPipe).
7. Assert: slice-4's `LocalVadReflex` tripped → fired `AdvisoryEvent::SpeechStarted` →
   `Reflex::muted` became true → `next_pcm_frame` returned None → brain reply NOT sent this tick.
8. Assert: after the brain sends its reply (mock's response schedule), `next_pcm_frame`
   returns Some → un-mute → brain reply pushed to `outbound_to_twilio_tx`.

- [ ] **Step 2: fmt + clippy + test + commit**

```bash
cargo fmt --all --check && cargo clippy --all --all-targets -- -D warnings && cargo test --all
git commit -s -m "test(trunk): reflex-on-trunk-leg verification — slice-4 Reflex<TapAudioPipe> decorates trunk TapAudioPipe identically (slice-5 T7)

The architectural load-bearing claim: slice-4's reflex loop is ingress-
agnostic. This test verifies that the slice-4 stack (Reflex<P> + LocalVadReflex
composition) instantiated against a trunk leg's TapAudioPipe behaves
IDENTICALLY to a WebRTC leg — local VAD trips on caller speech, barge kills
playout, brain reply post-barge un-mutes.

Same code path; different inner leg-kind. If barge-in somehow needed a
'trunk-specific' code path here, that would be an architectural smell that
needs PM input — not a silent fork."
```

---

### Task T8: PSTN sim e2e integration test

**Files:**
- Create: `crates/rutster-trunk/tests/sim_integ.rs`
- Test: end-to-end integration test: `MockTwilioMediaStreamsServer` (the in-process simulator)
  drives a synthetic PSTN caller through the FOB reflex loop.

- [ ] **Step 1: Author the in-process Twilio simulator**

A `MockTwilioMediaStreamsServer` test double that mimics Twilio's WSS protocol in-process:
- Spawn a tokio task that, given a `Vec<PcmFrame>` "caller-side script," pushes them as synthetic
  inbound `PcmFrame`s into a TrunkSession's `inbound_from_twilio_rx` (skipping the WSS + base64
  + JSON parsing — pure mpsc).
- Receive frames from `outbound_to_twilio_tx` (the FOB's brain reply) into a captured queue for
  assertions.

- [ ] **Step 2: Author the e2e test**

1. Start MockRealtimeBrain (slice-3). URL = `mock.url()`.
2. Start in-process MediaThread.
3. Construct `MockTwilioMediaStreamsServer` against the MediaThread's `register_tx`.
4. Drive a synthetic PSTN caller: loud PCM frames → barge-in should fire → brain yields → brain
   reply observed → caller hangup → session torn down.
5. Assert `EventSink` (slice-5/seams) emitted `ChannelEnded` with the wall-clock `started_at`.

- [ ] **Step 3: fmt + clippy + test + commit**

```bash
cargo fmt --all --check && cargo clippy --all --all-targets -- -D warnings && cargo test --all
git commit -s -m "test(trunk): PSTN sim e2e integration — synthetic caller through FOB reflex loop (slice-5 T8)

MockCallControlClient + MockTwilioMediaStreamsServer drive a synthetic PSTN
caller: loud PCM frames → local VAD trips → barge-in kills playout → brain
yields → brain reply observed → un-mute → caller hangup → session torn down +
EventSink emission of ChannelEnded with wall-clock started_at (slice-5/seams).

The full slice-1→slice-5 spearhead's worth of wiring exercised end-to-end,
against a synthetic PSTN caller (the same path a real Twilio call would
take modulo the actual Twilio cloud)."
```

---

### Task T9: QUICKSTART + README updates (filler; any time after T5 + T6 land)

**Files:**
- Modify: `docs/QUICKSTART.md`
- Modify: `README.md`

- [ ] **Step 1: Add env-var table to QUICKSTART**

```markdown
### Twilio credentials (for the real phone number demo, optional)

Set these to enable PSTN ingress via Twilio Media Streams:

| Env var | Purpose | Example |
|---|---|---|
| `RUTSTER_TWILIO_ACCOUNT_SID` | Twilio account ID | `ACxxx...` |
| `RUTSTER_TWILIO_AUTH_TOKEN` | Twilio auth token (secret) | `xxx...` |
| `RUTSTER_TWILIO_MEDIA_BIND` | Where Twilio Media Streams WSS binds | `0.0.0.0:8081` |
| `RUTSTER_TWILIO_WEBHOOK_BASE` | Twilio's webhook target (your binary) | `https://your.public.host` |

Without these set, the binary runs WebRTC-only (slice-1-3 ingress + slice-4
barge-in + slice-4½ sim). With them set + `--features=twilio-live`, the
binary accepts PSTN fork calls against `/twilio/media-stream`.
```

- [ ] **Step 2: Add "make a real phone call" section to QUICKSTART**

- [ ] **Step 3: Update README spearhead status line**

```markdown
> **Status:** Slices 1–4½ + step 5 (WebRTC media core, WS tap, OpenAI Realtime
> brain, barge-in, sim/benchmark harness, and PSTN via rented Twilio Media
> Streams transport) are merged to `main`. Spearhead step 6 (spend cap) is the
> remaining step. ADR-0007 honored: rutster parses zero SIP bytes.
```

- [ ] **Step 4: fmt + test + commit**

```bash
cargo fmt --all --check && cargo test --all
git commit -s -m "docs: QUICKSTART env table + 'make a real phone call' walkthrough + README status update (slice-5 T9)

README's spearhead list now reflects: slices 1, 2, 3, 4, 4½, 5 merged. The
quickstart shows how to set RUTSTER_TWILIO_* env vars + run
cargo run --features=twilio-live to take a real phone call via Twilio Media
Streams. ADR-0007 honored: rutster parses zero SIP bytes."
```

---

### Task T10: CI seam gate re-pin + cargo deny

**Files:**
- Modify: `.github/workflows/ci.yml` (re-pin the seam hashes IF iptables/rtc_session was touched — but this slice's invariant is "untouched," so the pins stay the same; verify)
- Modify: `deny.toml` (if `cargo deny check` flags any new license from `reqwest`/`base64`/`async-trait` transitive deps)
- Final sweep per AGENTS.md CI gates.

- [ ] **Step 1: Verify seam gate stays unchanged**

```bash
git rev-parse main:crates/rutster-media/src/loop_driver.rs
git rev-parse main:crates/rutster-media/src/rtc_session.rs
```

Confirm the two pinned blob hashes in `.github/workflows/ci.yml` (slice-4 Task 10) are unchanged.
If the hashes DO match, this slice's seam gate holds with NO CI YAML modification. If they
DO NOT match (the dev-c implementer accidentally touched the seam files), STOP + emit a
`## QUESTION` block to the PM; the dev must `git checkout main -- crates/rutster-media/src/loop_driver.rs
crates/rutster-media/src/rtc_session.rs` to restore them.

- [ ] **Step 2: Run cargo deny check + fix any flags from new deps**

```bash
cargo deny check
# If a license/ban/source advisory flags reqwest/base64/async-trait transitive dep,
# either add it to the allowlist in deny.toml with rationale OR
# substitute a different crate from the workspace's already-admitted set.
```

- [ ] **Step 3: Add the `twilio-live` CI job (manual-trigger only)**

```yaml
  twilio-live:
    name: twilio-live (manual only)
    runs-on: ubuntu-latest
    if: github.event_name == 'workflow_dispatch'
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - uses: Swatinem/rust-cache@v2
      - name: Install libopus
        run: sudo apt-get update && sudo apt-get install -y libopus-dev
      - name: Run twilio-live tests
        env:
          RUTSTER_TWILIO_ACCOUNT_SID: ${{ secrets.TWILIO_ACCOUNT_SID }}
          RUTSTER_TWILIO_AUTH_TOKEN: ${{ secrets.TWILIO_AUTH_TOKEN }}
          RUTSTER_TWILIO_MEDIA_BIND: 0.0.0.0:8081
          RUTSTER_TWILIO_WEBHOOK_BASE: ${{ secrets.TWILIO_WEBHOOK_BASE }}
        run: cargo test --all --features=twilio-live -- --include-ignored
```

The maintainer triggers this before a release.

- [ ] **Step 4: Final full sweep**

```bash
cargo fmt --all --check
cargo clippy --all --all-targets -- -D warnings
cargo clippy --all --all-targets --features=twilio-live -- -D warnings
cargo test --all
cargo deny check
cargo doc --no-deps
```

- [ ] **Step 5: Commit**

```bash
git add .github/workflows/ci.yml deny.toml
git commit -s -m "ci(trunk): twilio-live manual-trigger job + cargo deny recheck (slice-5 T10)

The routine CI gate stays feature-default-off: MockCallControlClient is
the per-PR integration test surface. A new twilio-live job runs ONLY on
manual workflow_dispatch (maintainer triggers pre-release) — exercises the
real TwilioCallControlClient against live Twilio. cargo deny check passes
for the new reqwest/base64/async-trait transitive deps.

The seam gate (loop_driver.rs + rtc_session.rs pinned blob hashes from
slice-4 Task 10) is verified UNCHANGED by this slice — the trunk leg's
tick lives entirely in rutster-trunk/src/loop_driver.rs, a separate file
in a separate crate."
```

---

## Final acceptance checklist

After all 10 tasks merge:
- [ ] `cargo fmt --check`, `cargo clippy --all --all-targets -- -D warnings` (both default +
      `--features=twilio-live`), `cargo test --all`, `cargo deny check`, `cargo doc --no-deps`
      all clean (stable + 1.85).
- [ ] Seam gate unchanged: `loop_driver.rs` + `rtc_session.rs` byte-identical to slice-3/slice-4
      (CI pinned-blob from slice-4 Task 10).
- [ ] Slice-4's `Reflex<TapAudioPipe>` + `LocalVadReflex` (REUSED — no new instances created)
      decorate a trunk leg's TapAudioPipe identically (T7 verification test green).
- [ ] PSTN-sim e2e (T8) drives a synthetic caller through the FOB reflex loop: barge-in fires,
      brain reply observed, CDR/EventSink emits ChannelEnded.
- [ ] QUICKSTART + README updated; env-var table renders in cargo doc.
- [ ] The maintainer (user) verifies the live e2e by manually running
      `cargo test --features=twilio-live` against real Twilio credentials once.
- [ ] PR bisected + readable: each task = one commit (squash-merge preserves the strategy).
- [ ] PR opened via `tea pulls create --head slice-5/rented-transport-dev-{b,c} --base main
      --title "slice-5 (rented transport): a real phone number via Twilio Media Streams
      (T1-T10)" --description "<full §7 done-criteria from spec>"`.
- [ ] Do NOT merge the PR — the maintainer (user) merges after the live Twilio e2e is verified.
- [ ] ADR-0010 deviation note considered for the PR description (per the strategic plan §1.2
      fork) — "this slice precedes rung-2 escalation per the user's 2026-07-05 directive; ADR-0010
      remains intact; the deviation is recorded in `.omo/plans/2026-07-05-spearhead-4half-and-
      step-5-strategic.md` §1.2."
