Files
rutster/docs/superpowers/plans/2026-07-05-slice-5-rented-transport.md
Aaron D. Lee c31e8a5069
Some checks failed
CI / clippy (push) Has been cancelled
CI / test (1.85) (push) Has been cancelled
CI / test (stable) (push) Has been cancelled
CI / deny (push) Has been cancelled
CI / fmt (push) Has started running
docs: spearhead-4half-and-5 specs + plans + kickoff prompts + AGENTS.md auto-spawn update (#20)
Co-authored-by: Aaron D. Lee <himself@adlee.work>
Co-committed-by: Aaron D. Lee <himself@adlee.work>
2026-07-05 16:01:55 +00:00

1079 lines
52 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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-14 + 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 14½ + 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."