# Rutster slice 5 — Rented transport: a real phone number via Twilio Media Streams - **Status:** Draft (pending review) - **Date:** 2026-07-05 - **Spearhead step:** 5 of 6 ([ADR-0007](../../adr/0007-trunk-rented-transport.md) — rent the transport, no first-party SIP) - **Origin:** [ADR-0007](../../adr/0007-trunk-rented-transport.md) (2026-06 strategic-relevance review — owns no SIP stack; carrier/PSTN reach is rented transport in three layers; rutster owns only the top one). The spearhead list (PORT_PLAN §Phasing) puts step 5 as the "real phone number" demo. - **Depends on (already merged):** - [slice 1 — WebRTC media loopback](2026-06-28-slice-1-webrtc-loopback-design.md) — the media core + `AudioPipe` trait - [slice 2 — The agent tap](2026-06-28-slice-2-agent-tap-design.md) — `TapAudioPipe` (REUSED as the trunk leg's brain-side AudioPipe) and the "core-authoritative playout buffer" invariant - 2026-06-30 slice-3 realtime brain — `MockRealtimeBrain`, the `speech_started` / `speech_stopped` advisory events - [slice 4 — Barge-in / VAD-driven playout kill](2026-07-01-slice-4-barge-in-design.md) — `Reflex

` + `LocalVadReflex

` decorators (decorating `TapAudioPipe` reused identically on the trunk leg) + the `MediaThread` std-thread graduation - 2026-07-04 slice-5/seams (the *infra* slice, NOT this slice despite the same number): `config.rs` env-parser pattern, `event_sink.rs`, `MediaCmd::Stats`, non-blocking tap teardown, the drain lifecycle, the advertised media address (`MediaAddressConfig`) — every one of these seams this slice inherits - **Naming note:** this is spearhead "step 5" (real phone number). It is NOT the merged "slice-5/seams" infra plan (which *pre-paves* this slice per its "Naming note"). See the strategic plan §5.5. - **Related:** [ADR-0008](../../adr/0008-fob-and-green-zone.md) (FOB/green-zone doctrine — split for this slice: FOB owns the WebSocket ingress + codec + the trunk-side tick driver; green-zone owns the REST call-control client), [ADR-0009](../../adr/0009-spend-gate-honest-rescope.md) (provider credentials never reach the brain), [ARCHITECTURE.md §"Media plane"](../../ARCHITECTURE.md) (the trunk leg participates in the same 20 ms tick on the dedicated thread) --- ## TL;DR Stand up spearhead step 5: **a real phone number** with no first-party SIP stack. The MVP is **Twilio Media Streams** as a CPaaS raw-audio fork (layer 1 of ADR-0007's three-layer doctrine): Twilio answers the PSTN call + forks its audio over a WebSocket to rutster; call control (answer / hangup / originate) is Twilio's REST API, completely external to the FOB. The PSTN audio enters the FOB reflex loop as a **second-leg-kind** alongside WebRTC — but the reflex stack + AudioPipe are reused unchanged. The architectural simplification this slice deliberately exploits: a `MediaThread` session is the wrapped stack `LocalVadReflex>`, where the inner `TapAudioPipe` was introduced in slice-2 as an abstraction over "the brain's WS audio." That SAME TapAudioPipe serves a trunk leg identically — the only difference is *what fills its inbound caller-PCM mpsc*: str0m's `MediaData` event (for WebRTC) versus the `TwilioMediaStreamsServer`'s WSS-pump task (for trunk). The leg-kind decides the **tick driver function**; the wrapped pipe + the brain wiring are reused verbatim. A new `trunk_driver::drive(&mut TrunkSession, now)` parallels `loop_driver::drive(&mut RtcSession, now)` — same shape, no str0m/Opus/RTP footprint. The slice-4 seam gate stays green: `loop_driver.rs` + `rtc_session.rs` byte-identical because the trunk leg never enters that code path. **Five things this slice deliberately is NOT:** - **Not a SIP stack.** Not even a thinnest sliver of one. rutster parses zero SIP bytes ([ADR-0007](../../adr/0007-trunk-rented-transport.md)). - **Not a managed Voice-AI product integration** (Twilio ConversationRelay, Telnyx Voice AI). These would consume the reflex loop that is rutster's differentiation; ADR-0007 explicitly forbids. - **Not a Layer-2 out-of-tree SBC adapter** (the on-prem graduation case). ADR-0007 layer 2 is deferred to a graduation rung, not the spearhead's MVP. - **Not a multi-tenant carrier management surface.** One Twilio account; one provider config; one active media-streams endpoint per binary. Multi-tenant is later rung. - **Not an inbound endpoint registration server (desk phones).** ADR-0007 explicitly out-of-tree and never first-party. --- ## 1. Scope ### 1.1 In scope - `crates/rutster-trunk/` filled in (currently a stub; ADR-0007 + slice-1 §2.2 pre-paved the crate boundary). Now contains the rented-transport ingress. - **`G711Codec`** — an in-core µ-law (G.711) codec: encode/decode (table-driven, learner-facing explanation of the µ-law companding formula — a fascinating piece of telephony history) + the 8 kHz ↔ 24 kHz resampling (linear interpolation; rutster's `PcmFrame` is 24 kHz, Twilio's raw-audio fork is 8 kHz µ-law). No new dep; pure std. - **`TwilioMediaStreamsServer`** — a tokio WebSocket server (axum; routable on the existing binary HTTP router) that accepts inbound Twilio Media Streams WSS connections, parses the provider's JSON envelope (`connected` / `start` / `media` / `stop` per Twilio's documented protocol), and ferries base64-encoded µ-law audio into a `tokio::sync::mpsc::Sender` consumed by the FOB media thread. - **`TrunkSession`** — the per-trunk-leg session struct. Parallels `RtcSession` minus str0m/Opus/ UDP-socket: holds the wrapped pipe `LocalVadReflex>` (REUSED from slice-4), the inbound-from-Twilio mpsc Receiver, the outbound-to-Twilio mpsc Sender, + bookkeeping (`last_idle_rx`, `next_timeout`). - **`trunk_driver::drive(&mut TrunkSession, now) -> Option`** — the trunk-leg tick function. Parallels `loop_driver::drive` minus the str0m/Opus/RTP machinery: drains inbound mpsc → calls `session.pipe.on_pcm_frame(frame)` (which feeds into the wrapped LocalVadReflex>, which forwards to the brain via tx_pcm_in as in slice-2); on the outbound boundary pulls `session.pipe.next_pcm_frame()` + pushes to the outbound-to-Twilio mpsc. Function lives in `crates/rutster-trunk/src/loop_driver.rs` (NOT in `crates/rutster-media/src/loop_driver.rs` — the binary-side trunk crate owns its own driver). - **`CallControlClient` trait + `MockCallControlClient` + `TwilioCallControlClient`** — the provider call-control abstraction. Answer/hangup/originate operations REST against Twilio's Call Control API (green-zone); the trait locks the seam so the next provider (Telnyx later) is an implementation, not a refactor. - **`TwilioCredentials` config struct + env parser** — added to `crates/rutster/src/config.rs` (slice-5/seams established the pattern). Account SID + auth token + media-streams bind URL + signing-key validation (the MVP validates the connection, not the REST signature — env-configurable to upgrade). - **`MediaCmd::RegisterTrunk` variant** — extends slice-5/seams' existing `MediaCmd` enum with a variant the binary's REST/routes layer uses when a Twilio Media Streams WSS connection arrives. The MediaThread constructs a `TrunkSession`, wraps its inner `TapAudioPipe` in `Reflex` then `LocalVadReflex>` (same composition as slice-4 Task 6 for WebRTC legs), and inserts it in the session map keyed by `ChannelId`. - **`MediaLeg` enum** — in `crates/rutster/src/media_thread.rs` (binary-side, not media-crate): `enum MediaLeg { WebRTC(RtcSession), Trunk(TrunkSession) }`. The MediaThread's tick loop dispatches on this enum + calls the corresponding driver. The slice-4 WebRTC code path stays byte-identical because `loop_driver::drive` is called only from the `WebRTC` variant's match arm (not changed). - **NEW HTTP routes:** - `POST /v1/trunk/sessions` — initiates an outbound call (green-zone path: routes via `TwilioCallControlClient` REST; credentials in config, never passed through to the brain). - `POST /v1/trunk/webhook` — Twilio's webhook receiver for inbound-call signaling ("Twilio is calling you back; here's the CallSid — open a Media Streams WebSocket for it"). Optional: if no webhook configured, inbound calls are accept-all. - **Reflex-on-trunk-leg verification test** — proves slice-4's `Reflex` + `LocalVadReflex` decorate the trunk leg identically to a WebRTC leg; barge-in fires on PSTN caller speech the same as on WebRTC caller speech. The reflex stack IS the same code path, so this test asserts "[trunk leg] behaves like [WebRTC leg] modulo the inbound-PCM-source channel" — a meaningful invariant, not a tautology. - **PSTN sim e2e integration test** — an in-process Twilio simulator (mock Twilio Media Streams server + MockCallControlClient) drives a synthetic PSTN caller through the FOB reflex loop; asserts barge-in fires + CDR/EventSink emission. - **QUICKSTART + README updates** — env-var table for Twilio credentials; "make a real phone call" walkthrough. ### 1.2 Out of scope (with scheduled return) | Deferred item | Returns in | Why deferred | |---|---|---| | Layer-2 out-of-tree SBC adapter (Kamailio / FreeSWITCH / drachtio + rtpengine) | graduation rung | ADR-0007 layer 2; on-prem sovereignty case; not spearhead-scale. | | Telnyx (or other provider) raw-media-fork impl | post-step-5 refinement | The trait locks the seam; the second impl is a project, not a refactor. ADR-0007 explicitly lists Twilio OR Telnyx as MVP candidates; we ship Twilio first. | | Twilio ConversationRelay or other managed Voice-AI product integration | never | ADR-0007 explicit; consuming the reflex loop is a competitive-architecture betrayal. | | Inbound endpoint registration (desk phones, soft phones) | never first-party | ADR-0007 explicit green-zone / out-of-tree; defeats the WebRTC-first / SSO UX. | | Outbound registration to a carrier SIP trunk | never first-party | ADR-0007: the rented transport or the out-of-tree SBC owns carrier registration. | | Multi-tenant Twilio account management | later rung | One Twilio account; SaaS multi-tenant carrier layer is post-rung-2-escalation. | | Media Streams TwiML voice preview / `` / `` | never in core | Twilio-managed Voice-AI redirect — explicit ADR-0007 violation. | | Outbound origination beyond `` semantics (simultaneous ring, etc.) | later rung | The MVP supports "originate one outbound call" via the REST API; richer origination is contact-center work. | | Authn/authz on the new `/v1/trunk/*` routes | slice-6 (spend cap) | Same deferral as slice-1/slice-2 — authn was deferred to step 6. The new routes are not pre-production exposed. | | TLS / WSS termination on the Twilio Media Streams server binding | post-step-5 | Media Streams sends audio over WSS; Twilio accepts reverse-proxy TLS termination. Localhost dev keeps plaintext WS; production termination is a deployment concern. | | Spend cap enforcement on originated calls | step-6 (spearhead 6) | The spend/abuse gate (ADR-0009) is spearhead 6; this slice leaves the seam open (`CallControlClient::originate` accepts an optional `spend_token: Option` arg as the pre-paved hook). Not enforced in this slice. | | DTMF tones through the trunk leg | later rung | Twilio Media Streams does deliver DTMF events; the FOB doesn't yet parse them. Banks/IVR menus need them; that's contact-center-domain work. | | Fax / T.38 | never | Modern telephony; out of spearhead. | | Recording on the trunk leg (CDR audio capture) | rung-2 escalation (warm-handoff artifact) | Same deferral as slice 4½'s "no per-call audio recording" call. | | Concurrent trunk legs beyond proof-of-concept scale (dozens+) | later rung | Same single-thread media-loop cap as WebRTC legs; defer threadpool shard work to the slice 4½ / slice-5-debt graduation tier. | | `rubato` / speexdsp resampler for 8 kHz ↔ 24 kHz | post-spearhead | Linear interpolation is the cheap, correct-enough answer for the spearhead MVP — the wedge claim is "the reflex loop works against real phone audio"; the resampler artifacts are below the barge-in trigger threshold (RMS energy). | --- ## 2. Architecture delta ### 2.1 What changes vs slice-4 + slice-5/seams Slice 5 adds ONE crate-internal fillin (`crates/rutster-trunk/` already exists as a stub; this slice populates it) + ONE new `MediaLeg` enum + ONE new `MediaCmd` variant + TWO new HTTP routes. The fused vertical's existing hot path (`loop_driver.rs` + `rtc_session.rs` + `MediaThread` + `Reflex

` + `TapAudioPipe` + `LocalVadReflex

`) is **untouched** — the trunk leg shares the same reflex stack, the same `TapAudioPipe` composition, the same playout buffer mechanics. ``` ┌─ Twilio (cloud; green zone) ────────────┐ │ PSTN caller → Twilio Media Streams │ │ JSON "Start" + "Media" + "Stop" frames │ │ (base64 µ-law @ 8kHz) + REST Call Control │ └────────────────────────┬─────────────────┘ │ inbound WSS (their push) ▼ ┌─────────── Rutster trust boundary (FOB) ──────────────────────────────────────────────────┐ │ │ │ axum router (existing) + NEW routes: │ │ POST /v1/trunk/sessions POST /v1/trunk/webhook │ │ │ │ │ │ ▼ ▼ │ │ TwilioCallControlClient TwilioMediaStreamsServer (NEW, FOB) │ │ (green-zone): REST call (axum WebSocket handler) │ │ control via reqwest • accepts Twilio's WSS │ │ • parses JSON envelope │ │ • decodes base64 µ-law via G711Codec → 24kHz PcmFrame │ │ • pushes PcmFrame to a per-call `inbound_from_twilio_tx` │ │ mpsc (Receiver handed to MediaThread via RegisterTrunk) │ │ • credentials NEVER reach the brain (ADR-0009 amendment) │ │ │ │ │ │ │ │ ▼ ▼ │ │ MediaCmd::Originate (NEW) MediaCmd::RegisterTrunk (NEW) │ │ └────────────┐ ┌─────────────┘ │ │ ▼ ▼ │ │ MediaThread's session map: HashMap │ │ │ │ MediaLeg enum (NEW, in crates/rutster/src/media_thread.rs): │ │ enum MediaLeg { │ │ WebRTC(RtcSession), // unchanged code path │ │ Trunk(TrunkSession), // new │ │ } │ │ │ │ The std thread tick loop dispatches: │ │ match leg { │ │ MediaLeg::WebRTC(s) => loop_driver::drive(s, now), // UNCHANGED │ │ MediaLeg::Trunk(s) => trunk_driver::drive(s, now), // NEW │ │ } │ │ │ │ trunk_driver::drive (NEW, in crates/rutster-trunk/src/loop_driver.rs): │ │ 1. Drain session.inbound_from_twilio_rx via try_recv │ │ → for each PcmFrame: session.pipe.on_pcm_frame(frame) (hits LocalVadReflex │ │ → Reflex → TapAudioPipe → tx_pcm_in → TapEngine → brain WS) │ │ 2. Outbound tick (every 20 ms): pull session.pipe.next_pcm_frame() │ │ → push to session.outbound_to_twilio_tx (the WSS pump task drains + │ │ encodes µ-law via G711Codec + sends back to Twilio) │ │ 3. Idle timeout check │ │ │ │ TrunkSession (NEW, in crates/rutster-trunk/src/session.rs): │ │ • pipe: LocalVadReflex> ← REUSED from slice-4 verbatim │ │ • inbound_from_twilio_rx: tokio::sync::mpsc::Receiver │ │ • outbound_to_twilio_tx: tokio::sync::mpsc::Sender │ │ • last_idle_rx, next_timeout, channel-state bookkeeping │ │ │ │ ▼ │ │ loop_driver.rs + rtc_session.rs (byte-identical, untouched — slice-4 seam HOLD) │ │ │ │ CallControlClient trait (NEW, green-zone seam): │ │ async fn originate(&self, to, from, spend_token: Option) │ │ async fn hangup(&self, correlation_id) │ │ • TwilioCallControlClient: reqwest POST to Twilio REST │ │ • MockCallControlClient: in-process test double │ │ │ └──────────────────────────────────────────────────────────────────────────────────────────┘ ``` ### 2.2 The FOB / green-zone split (load-bearing — ADR-0008 application) Per ADR-0008, every capability passes one of three tests: hot-path, security-constitutive, or differentiating. Slice 5 splits cleanly: **FOB members (this slice):** - `TwilioMediaStreamsServer` — hot path (its decoded `PcmFrame`s feed the 20 ms tick); the WSS acceptor runs on the tokio runtime but produces PCM for the std thread via mpsc (same cold-path vs hot-path split as slice-2's `TapEngine`). - `G711Codec` (µ-law encode/decode + 8 kHz ↔ 24 kHz resampling) — hot path; runs on the 20 ms tick inside the WSS pump task (decode) + inside the WSS pump task's send loop (encode). - `TrunkSession` + `trunk_driver::drive` — hot path; the per-tick trunk driver is the vtable for the FOB's loop touching the trunk leg. - `Reflex` + `LocalVadReflex>` (REUSED from slice-4, instantiated against the trunk leg's TapAudioPipe instance, NO new code). **Green-zone member (this slice):** - `TwilioCallControlClient` — REST API client; not on the per-call hot path (only fires on originate + on inbound-call webhook receipt); arm's length via trait + reqwest dep; **the brain never holds Twilio credentials** (the slice-5/seams-merged ADR-0009 amendment demands). ### 2.3 The reflex loop applies symmetrically (the architecture's load-bearing claim) Slice 4 introduced `Reflex

` + `LocalVadReflex

` as **AudioPipe-agnostic decorators** (slice-4 §3.2 + §6.4). The wedge argument: the FOB's reflex kills playout when the caller speaks, regardless of which ingress path the audio came in on. Step 5 tests this by reusing the EXACT slice-4 stack against a trunk leg: a PSTN caller through a Twilio Media Streams leg must be barge-in-killed the same way a WebRTC caller is — same `Reflex` state machine, same `LocalVadReflex` RMS-detector input, same `barge_in_flush` semantics. If the trunk leg somehow required a parallel "trunk-specific" barge code path, that would be an architectural smell that the slice-4 reflex trait wasn't actually pipe-agnostic — surface it as a question to PM, don't fork the design silently. ### 2.4 The seam invariant (re-affirmed) `crates/rutster-media/src/loop_driver.rs` + `crates/rutster-media/src/rtc_session.rs` stay byte-identical through this slice. The slice-4 Task 10 pinned-blob CI gate continues guarding unchanged. The trunk leg's tick lives entirely in `crates/rutster-trunk/src/loop_driver.rs` (a DIFFERENT but parallel-titled file in a different crate). The existing `RtcSession` codepath (for WebRTC legs) is untouched. The `MediaLeg` enum lives in `crates/rutster/src/media_thread.rs` (binary-side, not media-crate) — same locality that already houses the bridge between the binary's axum/runtime and the std thread. The match arm dispatching `MediaLeg::WebRTC(s) => loop_driver::drive(s, now)` is one new line in `media_thread.rs` (the existing call site for `loop_driver::drive` redirects through it); `MediaLeg::Trunk(s) => trunk_driver::drive(s, now)` is its sibling. ### 2.5 ADR-0007 honored: rutster parses zero SIP The hot path's wire surface in this slice is: 1. WebRTC RTP/SRTP (slice-1, unchanged — only on WebRTC legs) 2. WebSocket TCP frames carrying JSON envelope (Twilio Media Streams protocol — a documented application protocol, NOT SIP) 3. HTTP/REST JSON (Twilio Call Control API — outbound only, green-zone) **Zero SIP bytes are parsed.** The memory-safety claim (ARCHITECTURE.md §"Memory-safe by construction") remains literally true at the wire: every byte the FOB parses is JSON or RTP, never SIP. ADR-0007's central promise holds for step 5 definitionally, not as a stretch. ### 2.6 ADR-0009 honored: provider credentials never reach the brain Per the slice-5/seams-merged ADR-0009 amendment ("enforcement locality vs accounting locality"): the spend gate + provider credentials live INSIDE the trust boundary; the brain is OUTSIDE. Concretely: - `TwilioCredentials` (account_sid + auth_token) live in the binary's process env, scoped to `crates/rutster-trunk/`. The `TwilioCallControlClient` reads them at REST-call time. - The actual outbound REST call is made by the binary (`POST /v1/trunk/sessions` → triggers `TwilioCallControlClient::originate` → Twilio REST). The brain never sees the URL. - The brain holds ONLY the media fork's WebSocket URL (the address Twilio tells you to listen on for the audio stream) + the per-call `CallSid` (an opaque correlation ID). Neither is a credential. This slice does NOT enforce spend-caps yet (step 6 lands that) but pre-paves the seam: `CallControlClient::originate` takes an `Option` parameter (passed as `None` in this slice; step 6's spend gate will populate it for real before any originate). The signature locks the seam so step 6 is additive, not a refactor. --- ## 3. Component design ### 3.1 `G711Codec` (µ-law encode/decode + resampling) ```rust // crates/rutster-trunk/src/g711.rs //! # G.711 µ-law codec + 8 kHz ↔ 24 kHz resampling //! //! ## Why in-core, not a `g711` crate dep //! //! µ-law is ~30 lines of table-driven code. The codec has been standard since //! 1972 (ITU-T G.711); it has not changed. Pulling a dependency for a //! constant-mapping table would be a FOB hygiene violation (ADR-0008 — //! link a maintained lib for *internals* a mature library already solves, //! but only if the lib is healthy + the complexity is non-trivial; this //! is neither). The implementation is also learner-facing: the µ-law //! companding formula is a piece of telephony history worth teaching. use rutster_media::PcmFrame; /// 8-bit µ-law to 16-bit linear decode table. Generated from the ITU-T /// G.711 standard's piecewise-linear decoding curve. Twilio Media Streams /// delivers µ-law'd 8-bit samples at 8 kHz; we decode each to i16 before /// resampling. The 256-byte table fits in L1; no memory-pressure concern. static MULAW_TO_LINEAR: [i16; 256] = include!("./mulaw_decode_table.rs"); /// 16-bit linear to 8-bit µ-law encode table. Used when sending audio /// BACK to Twilio — the FOB encodes its 24 kHz PCM output → 8 kHz µ-law. static LINEAR_TO_MULAW: [u8; 65536] = include!("./mulaw_encode_table.rs"); pub struct G711Codec; impl G711Codec { /// Decode a Twilio Media Streams "Media" frame payload (already /// base64-decoded bytes of µ-law samples at 8 kHz) into a 24 kHz /// PcmFrame (the slice-1 canonical format). 3× linear upsample. /// /// # Why 3× upsample by linear interpolation /// /// 24 kHz / 8 kHz = 3. Each input sample becomes 3 output samples: /// out[3i] = input[i] /// out[3i + 1] = (input[i] + input[i+1]) / 2 /// out[3i + 2] = (2*input[i] + input[i+1]) / 3 /// /// Linear interpolation is the cheap correct-enough answer for the /// spearhead MVP; `rubato` (or speexdsp resampler) would be the /// production-grade answer ADR-0008 admits. The wedge claim is /// "the reflex loop works against real phone audio"; the resampler /// artifacts are below the audibility threshold for the barge-in /// trigger (which only needs RMS energy above `VAD_RMS_THRESHOLD`). pub fn decode_mulaw_to_pcm(mulaw: &[u8]) -> PcmFrame { let linear_8k: Vec = mulaw.iter().map(|&b| MULAW_TO_LINEAR[b as usize]).collect(); // SAFETY: Twilio Media Streams sends 160 µ-law bytes per 20 ms // (8000 samples/sec * 0.020 sec = 160). debug_assert to detect // protocol drift; the function still returns a zeroed frame on // undersized input (hot-path policy). debug_assert_eq!(linear_8k.len(), 160, "expected 160 µ-law bytes per 20ms frame"); let mut samples = [0i16; 480]; // PcmFrame.samples is fixed-size [i16; 480] let n = linear_8k.len().min(159); for i in 0..n { samples[3*i] = linear_8k[i]; samples[3*i + 1] = ((linear_8k[i] as i32 + linear_8k[i+1] as i32) / 2) as i16; samples[3*i + 2] = ((2*linear_8k[i] as i32 + linear_8k[i+1] as i32) / 3) as i16; } PcmFrame { samples } } /// Encode a 24 kHz PcmFrame to 8 kHz µ-law bytes for Twilio Media Streams. /// Inverse of `decode_mulaw_to_pcm`: 3× downsample by decimation (every /// 3rd sample) + linear-to-µ-law table lookup. pub fn encode_pcm_to_mulaw(frame: &PcmFrame) -> Vec { let mut mulaw = Vec::with_capacity(160); for i in 0..160 { let sample = frame.samples[3 * i]; mulaw.push(LINEAR_TO_MULAW[(sample as u16) as usize]); } mulaw } } ``` ### 3.2 `TwilioMediaStreamsServer` (the WebSocket ingress) ```rust // crates/rutster-trunk/src/twilio_media_streams.rs //! The inbound WSS server accepting Twilio Media Streams forks. //! //! ## Topology //! //! Twilio opens an outbound WSS connection TO us when a PSTN call is //! answered. The connection carries JSON-encoded frames: `connected` //! (handshake), `start` (metadata: CallSid, stream_sid, custom params), //! `media` (base64-encoded µ-law audio, both directions on the SAME WS), //! and `stop` (the call ended). Audio flows in real-time; the JSON //! envelope is the entire protocol. //! //! ## Why tokio, not the std thread //! //! The WSS connection is async by nature (TcpListener::accept + WS upgrade //! + JSON parse + base64 decode per frame). The dedicated 20 ms std-thread //! is for the LOOP, not for IO. Same split as slice-2's TapEngine: tokio //! task receives the WS, ferries µ-law → decoded PCM over mpsc to the std //! thread, which consumes via `TrunkSession::on_pcm_frame` (called from //! `trunk_driver::drive`). use axum::{ extract::ws::{Message, WebSocket, WebSocketUpgrade}, routing::get, Router, }; use tokio::sync::mpsc; use rutster_media::PcmFrame; pub struct TwilioMediaStreamsServer; impl TwilioMediaStreamsServer { pub fn router( register_tx: mpsc::Sender, ) -> Router { Router::new() .route("/twilio/media-stream", get(handle_media_stream)) .with_state(register_tx) } } /// The mpsc abstraction handed from the WSS pump task to MediaThread: /// a pair of mpsc ends (caller→FOB inbound + FOB→caller outbound) that /// the TrunkSession::drive uses to talk to the WSS pump task. pub struct RegisterTrunkInboundChannel { pub call_sid: String, pub inbound_from_twilio_rx: mpsc::Receiver, pub outbound_to_twilio_tx: mpsc::Sender, /// The brain's WS URL for THIS call's TapEngine (the operator configures /// where the brain listens). The TapEngine wiring is exactly like the /// WebRTC spawn seam from slice-2; the trunk leg's session reveals its /// tap_url when handed to RegisterTrunk. pub tap_url: url::Url, } async fn handle_media_stream( ws: WebSocketUpgrade, axum::extract::State(register_tx): axum::extract::State>, ) -> impl axum::response::IntoResponse { ws.on_upgrade(|socket| run_media_stream(socket, register_tx)) } async fn run_media_stream(mut socket: WebSocket, register_tx: mpsc::Sender) { // 1. Wait for "start" frame; extract CallSid + StreamSid + custom params. // 2. Construct the two mpsc pairs (inbound + outbound, capacity 16 each). // 3. Send a RegisterTrunkInboundChannel to MediaThread via register_tx. // Wait for MediaThread's RegisterTrunk reply (a oneshot confirming the // ChannelId) — the WSS pump then continues. // 4. Loop: // a. Read next WS message. If "media" frame, base64-decode the payload, // G711Codec::decode_mulaw_to_pcm, push PcmFrame into inbound_tx. // b. Concurrently drain outbound_rx (PcmFrame produced by the std // thread via trunk_driver::drive): G711Codec::encode_pcm_to_mulaw, // wrap in a JSON "media" envelope, send WS Text frame. // c. On "stop" frame OR outbound_rx closed: tear down. // (Implementation detail; learner-commented per AGENTS.md code style.) } ``` ### 3.3 `TrunkSession` + `trunk_driver::drive` ```rust // crates/rutster-trunk/src/session.rs //! The per-trunk-leg session struct. Parallels `RtcSession` minus the //! str0m/Opus/UDP-socket footprint. The wrapped pipe is REUSED from slice-4 //! verbatim — `LocalVadReflex>` — because a trunk //! leg's brain audio is the same mpsc pattern as a WebRTC leg's. use std::time::{Duration, Instant}; use tokio::sync::mpsc; use rutster_media::{PcmFrame, Reflex, LocalVadReflex, ReflexMetrics}; use rutster_tap::TapAudioPipe; use rutster_call_model::{Channel, ChannelId, ChannelState}; pub const IDLE_TIMEOUT: Duration = Duration::from_secs(60); pub struct TrunkSession { pub channel: Channel, // { id: ChannelId, state: ChannelState, started_at, ... } /// The wrapped reflex stack. REUSED from slice-4 Task 6 composition. pub pipe: LocalVadReflex>, /// Caller PCM from Twilio (decoded at 24 kHz by the WSS pump task). pub inbound_from_twilio_rx: mpsc::Receiver, /// FOB outbound PCM for Twilio (the WSS pump encodes μ-law + sends back). pub outbound_to_twilio_tx: mpsc::Sender, pub last_idle_rx: Instant, pub next_timeout: Option, } ``` ```rust // crates/rutster-trunk/src/loop_driver.rs //! The trunk-leg tick function. Parellels `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. The caller→FOB direction is a //! pure mpsc drain; the FOB→caller direction is a mpsc push. //! //! ## The seam (sacred — slice-4 Task 10 pinned-blob CI gate) //! //! This function is a SEPARATE file in a SEPARATE crate. `loop_driver.rs` //! in `rutster-media` stays byte-identical because the trunk leg NEVER //! enters that code path. The MediaThread's tick dispatches via the //! `MediaLeg` enum (in `crates/rutster/src/media_thread.rs`): //! //! ```ignore //! match leg { //! MediaLeg::WebRTC(s) => loop_driver::drive(s, now), // UNCHANGED from slice-4 //! MediaLeg::Trunk(s) => trunk_driver::drive(s, now), // NEW this slice //! } //! ``` use std::time::{Duration, Instant}; use crate::session::{TrunkSession, IDLE_TIMEOUT}; use rutster_media::SAMPLES_PER_FRAME; // Match slice-1's frame size /// 20 ms tick for outbound encoding (matches slice-1's PCM frame size: /// 480 samples @ 24 kHz = 20 ms). pub const OUTBOUND_TICK: Duration = Duration::from_millis(20); /// One iteration of the trunk-leg driver. Parallels /// `rutster_media::loop_driver::drive` minus str0m. pub fn drive(session: &mut TrunkSession, now: Instant) -> Option { // === Step 1: drain inbound caller PCM from Twilio === // (The WebRTC equivalent reads UDP + str0m::Input::Receive; the trunk // equivalent drains a tokio mpsc the WSS pump task fills.) while let Ok(frame) = session.inbound_from_twilio_rx.try_recv() { // Push the caller's PCM through the wrapped AudioPipe stack: // LocalVadReflex>::on_pcm_frame will inspect // for VAD (slice-4 §3.4 — primary trigger path) + delegate inward // → Reflex::on_pcm_frame → TapAudioPipe::on_pcm_frame → mpsc // to TapEngine → brain WS as `audio_in`. session.pipe.on_pcm_frame(frame); session.last_idle_rx = now; } // === Step 2: outbound encode tick (every 20 ms) === if now.duration_since(session.last_outbound_at) >= OUTBOUND_TICK { if let Some(reply_frame) = session.pipe.next_pcm_frame() { // Brain reply PCM; push to the WSS pump task for µ-law encode // + Twilio Media Streams JSON Media frame emission. try_send; // drop + observe on full (hot-path policy). if session.outbound_to_twilio_tx.try_send(reply_frame).is_err() { // Log + bump a counter (TrunkMetrics, slice-5/seams pattern). tracing::warn!("outbound_to_twilio_tx full; dropping brain reply frame"); } } session.last_outbound_at = now; } // === Step 3: idle timeout (matches slice-1 §4.5) === if now.duration_since(session.last_idle_rx) > IDLE_TIMEOUT { tracing::info!(channel_id = %session.channel.id, "trunk idle timeout; closing"); session.channel.state = ChannelState::Closed; return None; } // === Step 4: sleep budget === // The std thread's meta-tick is 10 ms; this duration tells the next // tick's worth of sleep (matches loop_driver::drive's contract). session.next_timeout = Some(now + OUTBOUND_TICK); Some(OUTBOUND_TICK) } ``` ### 3.4 `CallControlClient` trait + impls (the green-zone seam) ```rust // crates/rutster-trunk/src/provider/mod.rs //! Provider abstraction: the trait that locks the seam so the NEXT //! provider (Telnyx later) is an implementation, not a refactor. //! //! ## Green zone (ADR-0008) //! //! The trait + impls in this module run ARM'S-LENGTH: the call-control //! client talks to Twilio's REST API over HTTPS (reqwest). The brain //! never holds Twilio credentials; the FOB never parses SIP; the //! trait locks both invariants mechanically. use rutster_call_model::ChannelId; #[async_trait::async_trait] pub trait CallControlClient: Send + Sync { /// Originate an outbound call to `to_phone`. The provider answers /// itself, opens a Media Streams fork back to us, + returns a /// provider-assigned correlation ID (Twilio: CallSid). The /// `spend_token: Option` is the pre-paved seam for /// spearhead step 6 (the spend cap); this slice passes `None`. async fn originate( &self, to_phone: &str, from_phone: &str, spend_token: Option, ) -> Result; /// Hang up an in-progress call. Idempotent. Returns after the /// provider acknowledges the terminate. async fn hangup(&self, correlation_id: &str) -> Result<(), CallControlError>; } #[derive(Debug)] pub struct CallControlError(pub String); /// A placeholder for the slice-6 spend-gate token. The trait's `originate` /// takes Option now; this slice passes None everywhere; step 6 /// populates it before any REST origination is dispatched. pub struct SpendToken(/* opaque */); ``` ```rust // crates/rutster-trunk/src/provider/mock.rs //! In-process test double for `CallControlClient`. CI tests use this; the //! live TwilioCallControlClient is feature-gated behind `--features=twilio-live`. pub struct MockCallControlClient { /* in-process queues */ } ``` ```rust // crates/rutster-trunk/src/provider/twilio.rs //! Live Twilio Call Control client. Performs REST API calls via reqwest. //! NOT enabled in CI by default; the maintainer runs `cargo test --features=twilio-live` //! when validating a release. pub struct TwilioCallControlClient { pub credentials: TwilioCredentials, pub http: reqwest::Client, } pub struct TwilioCredentials { pub account_sid: String, pub auth_token: String, // NEVER logged; NEVER reaches the brain /// URL Twilio redirects to once a call connects; the binary's axum server. pub media_streams_bind: std::net::SocketAddr, pub webhook_base: url::Url, } ``` ### 3.5 `MediaLeg` enum + `MediaCmd::RegisterTrunk` (the seam extensions) ```rust // crates/rutster/src/media_thread.rs (modified this slice) // ...existing MediaCmd enum from slice-5/seams + slice-4½ (if it lands first): // Register, AcceptOffer, Delete, Shutdown, Stats, Drain, [RegisterSim] pub enum MediaCmd { // ... existing variants unchanged from prior slices ... /// slice-5: register a trunk-side session. The MediaThread: /// 1. Allocates a ChannelId. /// 2. Constructs the TapAudioPipe + TapConn (spawn_tap_engine, REUSED /// from slice-2/slice-4; the tap_url is the brain's WS URL). /// 3. Wraps as `Reflex::new(tap_pipe, advisory_rx, metrics)` then /// `LocalVadReflex::new(reflex, advisory_tx)` — same Task-6-style /// composition as slice-4 for WebRTC legs. /// 4. Inserts a TrunkSession 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, outbound_to_twilio_tx: tokio::sync::mpsc::Sender, tap_url: url::Url, reply: oneshot::Sender, }, } // The per-session-storage enum (NEW, binary-side). pub enum MediaLeg { /// Existing WebRTC leg. Ticked by `rutster_media::loop_driver::drive`. /// Untouched code path from slice-4. WebRTC(RtcSession), /// New trunk leg. Ticked by `rutster_trunk::loop_driver::drive`. Trunk(TrunkSession), } // In the std thread loop: fn run_per_leg_tick(leg: &mut MediaLeg, now: Instant) -> Option { match leg { MediaLeg::WebRTC(s) => rutster_media::loop_driver::drive(s, now), // UNCHANGED MediaLeg::Trunk(s) => rutster_trunk::loop_driver::drive(s, now), // NEW this slice } } ``` ### 3.6 New HTTP routes ```rust // crates/rutster/src/routes.rs (extended this slice) // POST /v1/trunk/sessions // Body: { to: "+1...", from: "+1..." } // Handler: calls CallControlClient::originate(to, from, None). // Returns: { channel_id, call_sid } // // POST /v1/trunk/webhook // Twilio's webhook receiver for inbound calls. Twilio calls this when a // PSTN caller dials the Twilio number configured to point at us; the // handler responds with TwiML instructing Twilio to open a Media // Streams WebSocket against . // Localhost development can omit the webhook; Twilio's Media Streams // can be opened with a direct WSS POST in test mode (see QUICKSTART). ``` ### 3.7 `TwilioCredentials` env parser (config seam) ```rust // crates/rutster/src/config.rs (extended this slice) /// 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 { /* ... */ } ``` --- ## 4. Data flow ### 4.1 Inbound PSTN call (Twilio calling us) ``` 1. PSTN caller dials the Twilio phone number. 2. Twilio fires the configured webhook (POST /v1/trunk/webhook) with the inbound call details. 3. axum handler responds with TwiML instructing Twilio to open a Media Streams fork against our /twilio/media-stream endpoint. 4. Twilio opens WSS to our TwilioMediaStreamsServer. 5. The server's WS handler: a. Parses the "start" frame; extracts CallSid + StreamSid. b. Constructs the two mpsc pairs (inbound + outbound, capacity 16 each). c. Sends a RegisterTrunkInboundChannel to MediaThread via the register_tx mpsc. 6. MediaThread receives RegisterTrunk: a. Allocates a ChannelId. b. Constructs TapAudioPipe + TapConn via spawn_tap_engine REUSED from slice-2 (with tap_url = the brain's WS URL for this call). c. Wraps as Reflex::new(tap_pipe, advisory_rx, metrics) → LocalVadReflex::new(reflex, advisory_tx) — same composition as slice-4 Task 6 for WebRTC legs. d. Constructs TrunkSession { channel, pipe, inbound_from_twilio_rx, outbound_to_twilio_tx, ... } and inserts into the session map under MediaLeg::Trunk. e. Replies with the ChannelId. 7. The 20 ms media loop dispatches the trunk leg to trunk_driver::drive: - Drain inbound_from_twilio_rx → caller PCM frames → session.pipe.on_pcm_frame(frame) — hits LocalVadReflex first. `LocalVadReflex::on_pcm_frame` inspects RMS; if above threshold after debounce, fires AdvisoryEvent::SpeechStarted → Reflex::drain_advisories applies barge_in_flush → TapAudioPipe::barge_in_flush clears ring + drains rx_audio_out (slice-4 §3.3 — UNCHANGED); the next call to `next_pcm_frame` returns None → brain reply suppressed. - After the LocalVadReflex inspects, Reflex delegates to TapAudioPipe → TapAudioPipe::on_pcm_frame pushes the caller PCM to tx_pcm_in → the TapEngine task drains → brain WS receive audio_in JSON event. 8. On the outbound tick (every 20 ms): `trunk_driver::drive` pulls `session.pipe.next_pcm_frame()`: - If muted (post-barge): returns None; no brain reply sent to Twilio this tick. Caller hears silence. - If not muted: returns Some(reply_frame). The frame is pushed to `outbound_to_twilio_tx` mpsc; the WSS pump task drains it, encodes µ-law via G711Codec::encode_pcm_to_mulaw, wraps in a JSON "media" envelope, sends as a WS Text frame back to Twilio. Caller hears the brain's reply. 9. Caller hangs up → Twilio sends "stop" frame → WS handler closes the socket → MediaCmd::Delete → MediaThread removes the TrunkSession from the map + tears down the TapEngine (slice-5/seams non-blocking teardown path). ``` ### 4.2 Outbound PSTN call (us calling out) ``` 1. axum handler receives POST /v1/trunk/sessions { to, from }. 2. Handler calls TwilioCallControlClient::originate(to, from, None) [None because spend gate is step 6]. 3. TwilioCallControlClient: a. POSTs to Twilio's REST API: /Calls.json with the `to` + `from` + a `twiml` parameter telling Twilio to open a Media Streams fork against our /twilio/media-stream endpoint. b. Twilio returns the new CallSid; the REST call returns immediately (the actual fork will arrive later as an inbound WSS). 4. The remaining flow waits for Twilio's inbound WSS connection (step 4 onwards in §4.1). The originate call returns once Twilio accepts. 5. The handler responds to the caller (the operator hitting the REST endpoint) with the assigned ChannelId + CallSid for correlation; the caller can poll MediaCmd::Stats or wait for an EventSink emission when the leg connects. ``` ### 4.3 Barge-in on the trunk leg (the wedge verification) ``` Identical to slice-4 §5.1, applied to the trunk leg's wrapped pipe: 1. Caller speaks loudly over PSTN → Twilio's µ-law-encoded audio arrives via the WSS pump task. 2. The pump task base64-decodes the JSON "media" payload, decodes µ-law via G711Codec::decode_mulaw_to_pcm → 24 kHz PcmFrame. 3. Pump pushes PcmFrame into the leg's inbound_from_twilio_tx mpsc. 4. Loop tick: trunk_driver::drive drains inbound_from_twilio_rx → calls session.pipe.on_pcm_frame(decoded_frame). 5. The wrapper is LocalVadReflex>: - LocalVadReflex::on_pcm_frame sees the caller PCM FIRST, computes RMS (slice-4 §3.4 unchanged). If ≥500.0 + debounce streak of 3 → try_send(AdvisoryEvent::SpeechStarted) into advisory_tx. - Then delegates inward to Reflex::on_pcm_frame → Reflex delegates (Reflex is observational on the sink side, slice-4 §3.5) → TapAudioPipe::on_pcm_frame pushes the caller PCM to tx_pcm_in → TapEngine → brain WS as `audio_in` JSON event (slice-3 plumbing, unchanged). 6. Reflex::next_pcm_frame drains advisory_rx → SpeechStarted applies barge_in_flush (slice-4 §3.3 unchanged: clears playout ring + drains rx_audio_out). The next call to next_pcm_frame returns None → no brain reply sent to Twilio this tick → caller hears silence. 7. Brain yields + sends a fresh audio_out post-barge → TapEngine → tx_audio_out mpsc → TapAudioPipe::next_pcm_frame (the wrapped inner the Reflex inspects inside its next_pcm_frame). Reflex sees Some → muted=false; returns Some → trunk_driver pushes to outbound_to_twilio_tx → WSS pump encodes µ-law + sends JSON Media frame back to Twilio → caller hears the brain's new reply. ``` The reflex loop is INGRESS-AGNOSTIC: the only difference between WebRTC's loop_driver::drive + trunk's trunk_driver::drive is where the caller PCM arrives (str0m's MediaData event vs an mpsc the WSS pump fills). --- ## 5. ADR-0009 honoring — the credential isolation invariant The slice-5/seams-merged ADR-0009 amendment says: the spend gate + provider credentials live INSIDE the trust boundary; the brain is OUTSIDE. Concrete enforcement in this slice: - `TwilioCredentials` is defined in `crates/rutster-trunk/`, scoped to the trunk crate. It is `pub` to the binary crate (`crates/rutster/`), but NOT re-exported through the workspace. - The brain WS protocol carries ONLY: the audio (PCM in / PCM out), the function-call events (slice-3), + the `speech_started`/`speech_stopped` advisory events (slice-4). It does NOT carry the Twilio account SID, the Twilio auth_token, the REST endpoint URL, or the CallSid. (Wait — the brain might benefit from the CallSid for correlation in its own logs. Adjust: CallSid is treated as an opaque correlation ID, NOT a credential; the `TapConn` carries a `provider_correlation_id: Option` field for the operator's debug-logging convenience. The brain only sees it if it's routed through the tap protocol — which it ISN'T in this slice. The CallSid lives on `TrunkSession` for log correlation in the FOB only.) - The `CallControlClient` trait lives in `crates/rutster-trunk/`. The brain's interface surface doesn't even IMPORT it. - The `TwilioCallControlClient::originate` function reads credentials only from `self.credentials` (passed in at construction via env parser). It NEVER logs the auth_token; tracing fields use only the account_sid's last 4 chars. These are invariants the slice-6 spend-cap work depends on. --- ## 6. Why these decisions ### 6.1 Why reuse `TapAudioPipe` for the trunk leg (NOT a new `TrunkAudioPipe`) The slice-4 stack `LocalVadReflex>` is **brain-pipe-specific**, not leg-kind-specific. The `TapAudioPipe` exists precisely to bridge the FOB loop and a brain-side WebSocket; slice-4's `Reflex

` is a generic over `P: AudioPipe`. A trunk leg's brain audio is the same mpsc pattern as a WebRTC leg's brain audio: the TapEngine task produces PCM frames in `tx_audio_out` (for `next_pcm_frame` to pull from) and consumes PCM frames from `tx_pcm_in` (for `on_pcm_frame` to push to). The leg-kind decides ONLY the source of caller PCM (str0m's RTP-decoded MediaData event vs the WSS-pump-supplied mpsc). Reusing `TapAudioPipe`: - Avoids duplicating the slice-2 TapAudioPipe's mpsc pair + ring buffer logic. - Makes the leg-kind distinction pure-binary-side (a new driver + a new session struct), not per-crate. - Asserts (architecturally) that the FOB reflex loop is ingress-agnostic at theInnermost layer. If we made TrunkAudioPipe a separate type, it would be 95% duplicate code, and the barge_in_flush semantics would have to be re-tested for a structurally-identical mpsc-and-ring pair. That's the opposite of "the seam holds." ### 6.2 Why a separate `trunk_driver::drive` (NOT extending `loop_driver.rs`) `loop_driver.rs` is the seam-file: byte-identical through slices 1–4, gated by the slice-4 Task 10 pinned-blob CI step. Extending it with a Twilio path would either (a) re-open the slice-4 seam gate and weaken the architectural claim that the WebRTC media loop is untouched; or (b) fork into "loop_driver has a Trunk variant" which puts trunk-specific concerns in the media crate (violating ADR-0008's FOB-member criterion — trunk's µ-law + WSS framing isn't media-layer concern). A separate `trunk_driver::drive` in `crates/rutster-trunk/` honors both: the slice-4 seam stays green (`loop_driver.rs` byte-identical), and trunk concerns stay in the trunk crate. The `MediaLeg` enum (in `crates/rutster/src/media_thread.rs`, the binary-side bridge) is the only addition the existing code sees; the WebRTC match arm preserves the existing call site 1:1. ### 6.3 Why Twilio Media Streams is the MVP (not Telnyx, raw SIP fork, or a custom raw-audio shim) - **Documented application protocol** (JSON envelope; one of Twilio's most-stable public APIs). - **Operational foothold**: the maintainer likely already has a Twilio account for personal telephony hacks; the dev loop falls out of one phone call. Telnyx would require a separate account onboarding step. - **Generic at the seam**: the `CallControlClient` trait locks the abstraction; the next provider (Telnyx, a custom raw-audio shim) is an `impl` of the trait, not a refactor. We're betting on the architecture's flexibility, not on Twilio being the right carrier forever. ### 6.4 Why the WSS server runs on axum (not a separate tokio listener) - Reuses the binary's existing `axum::serve` runtime — same tokio pool, same graceful-shutdown plumbing. No new `TcpListener` acceptor to wire through `main.rs`. - The WSS endpoint (`/twilio/media-stream`) sits alongside the existing HTTP routes (`POST /v1/sessions`, etc.); a reverse proxy / load balancer at the production edge handles TLS / WS upgrade uniformly. - Axum's WebSocketUpgrade extractor handles the WS handshake natively — no need to plumb a separate `tokio-tungstenite::accept_async` path. ### 6.5 Why in-core µ-law (not a `g711` crate dep) - ~30 lines of table-driven code; static table is 256 + 65536 = ~66 KB binary size increase (irrelevant on any modern platform; the tables fit in L1/L2). - µ-law is a 1972 ITU-T standard — the decode table has not changed in 50 years; there is no "actively maintained" dep that materially improves on a constant array (ADR-0008's "actively maintained" gate is about addressing bus-factor / unsanctioned changes; a const table has no bus factor). - The decode table is a piece of telephony history — learner-commented, it teaches the µ-law companding formula that AGENTS.md says this codebase is supposed to teach (the project OVERRIDES the no-comments convention; this is exactly the kind of thing the override is for). ### 6.6 Why 3× linear interpolation for 8 kHz ↔ 24 kHz resampling (not `rubato`) - The downstream consumer of the resampled 24 kHz PCM is the `LocalVadReflex` RMS-detector (the barge-in trigger), which only cares about ENERGY (RMS over 480 samples). Linear interpolation introduces harmonic-distortion artifacts at the high-frequency end that are well below the RMS-detection threshold; the barge-in trigger is unaffected. - The brain's audio (when sent back to Twilio) is encoded µ-law + played over PSTN, which has a 3.4 kHz cutoff anyway — Twilio's PSTN callee can't hear distortion above the µ-law bandlimit, so the resampler's high-frequency artifacts are inaudible by construction. - `rubato` adds a dep + non-trivial CPU cost (FIR filter + windowing). The spearhead doesn't need it; per AGENTS.md ADR-0008: "When in doubt, default to green zone" / "The FOB earns its members, it doesn't collect them." Defer `rubato` to a post-spearhead tier when (a) the resampler artifacts matter for some downstream consumer (transcription accuracy, recording quality), OR (b) the test corpus catches a measurable regression. --- ## 7. Done-criteria 1. `cargo test --all` passes (stable + 1.85); the routine gate. 2. `cargo fmt --check` + `cargo clippy -- -D warnings` clean on the new code. 3. `cargo deny check` passes — `reqwest` and `async-trait` are acknowledged in `deny.toml`. 4. `cargo doc --no-deps` renders the new `crates/rutster-trunk/` cleanly with learner-facing comments per AGENTS.md code style. 5. Seam gate STILL holds: `loop_driver.rs` + `rtc_session.rs` byte-identical to slice-3 (CI pinned-blob from slice-4 Task 10 unchanged because this slice never touches the media-crate seam files). 6. **`MediaLeg::WebRTC(s) => loop_driver::drive(s, now)` is the unchanged call path for WebRTC legs.** The new `MediaLeg::Trunk(s) => trunk_driver::drive(s, now)` is the only newly-introduced tick dispatch. 7. `Reflex` + `LocalVadReflex` (slice-4) instantiate against the trunk leg's TapAudioPipe without code change (the generic composition is the proof point). 8. The `MockCallControlClient` + an in-process Twilio Media Streams simulator drive a synthetic PSTN caller through the FOB reflex loop: barge-in fires on PSTN caller speech within slice-4's ≤80 ms kill budget (CI gate from slice 4½ if it lands; otherwise slice-4's manual e2e budget applies witness, not asserted in this slice). 9. PSTN sim e2e integration test: `MockCallControlClient` + `MockTwilioMediaStreamsServer` → MediaThread → Reflex → barge-in verified on PSTN leg → brain reply observed → caller hangup → session tear down + EventSink emission of `ChannelEnded` (with wall-clock `started_at` from slice-5/seams). 10. Credential isolation: a static assertion (a unit test that imports only `rutster-trunk`'s public API + asserts that `TwilioCredentials` is NOT re-exported from the workspace, NOT in `rutster-media`'s public API, NOT in `rutster-tap`'s public API). The brain never sees a Twilio credential. 11. QUICKSTART + README updated with: env-var table for Twilio credentials, "make a real phone call" walkthrough (local dev with `--features=twilio-live` OR a direct WSS POST test mode). 12. Outbound origination (us calling out) is plumbed through `TwilioCallControlClient::originate` + the rest of the wiring flows identically to inbound (the Twilio fork arrives inbound). --- ## 8. Open decisions ### 8.1 Should the `MockTwilioMediaStreamsServer` (test double) live in `crates/rutster-trunk/` or in `crates/rutster/tests/`? **Decision (slice 5):** in `crates/rutster-trunk/src/twilio_media_streams.rs`'s `#[cfg(test)]` module + a `pub(crate)` simulator API exposed only to in-process tests. Reason: the simulator IS the FOB testable surface for the trunk leg; it's NOT a test of the binary's plumbing (which `crates/rutster/tests/sim_integ.rs` covers). The simulator's JSON-frame parser is structurally the same code as the live impl, just driven by in-memory queues instead of a real WSS. ### 8.2 Should the WSS pump task be ONE tokio task per call (read+write in one task) or TWO (read + write separately)? **Decision (slice 5):** ONE task, with a `tokio::select!` loop. The read + write directions share the WS socket (Twilio's protocol interleaves inbound + outbound frames on the same WSS); separating them into two tasks adds channel overhead for no real concurrency benefit (the read side is rate-limited by µ-law frame arrival; the write side is rate-limited by the FOB's 20 ms tick). One task is simpler. ### 8.3 When the brain is unreachable mid-call (WS drops), should the trunk leg hang up the PSTN call or keep playing silence? **Decision (slice 5):** keep playing silence for up to 5 seconds (the existing tap-engine reconnect path from slice-2 §5.3 isn't implemented for trunk legs in this slice — it's a deferred refinement). After 5 s of silence: trigger `CallControlClient::hangup(call_sid)`. This matches what a human caller would want (5 s of silence is "did the line drop?") and is predictable. The reconnect-after-drop feature is a slice-6 / post-step-5 refinement paired with the spend gate (which has to know "is this call still going?"). ### 8.4 Should the WSS acceptor authenticate Twilio's connection (HMAC signature validation)? **Decision (slice 5):** NO. The MVP runs on `localhost` for dev + behind a reverse proxy for production. The reverse proxy / SSR-firewall layer enforces "Twilio's WSS connections come from Twilio's documented IP ranges"; the binary doesn't re-authenticate. (A future hardening rung adds HMAC validation per Twilio's documented signature scheme; it's tracked in cargo-deny / architecture but not spearhead-scale.) ### 8.5 Should the trunk leg support `barge_in_flush` semantics over the WSS path (i.e. flush in-flight frames in `outbound_to_twilio_tx` AND `inbound_from_twilio_rx`)? **Decision (slice 5):** YES for the wrap-side `Reflex::barge_in_flush`-equivalent call. The inner TapAudioPipe's `barge_in_flush` (slice-4 §3.3) clears its OWN ring + its OWN `rx_audio_out` mpsc; the trunk leg inherits this. The trunk-specific NEW mpsc ( `inbound_from_twilio_rx` + `outbound_to_twilio_tx`) is NOT part of the bar's atomicity — the inbound mpsc drains naturally on the next tick; the outbound mpsc is bounded small (capacity 4), so a stale outbound frame would arrive at Twilio as at most one extra 20 ms of audio (a perceptual cost below the barge-in triggering cadence). If integration tests show audible pre-barge leakage, this decision gets revisited. ### 8.6 Should the harness from slice 4½ (if it lands first) exercise the trunk leg? **Decision (slice 5):** YES via future-extension. Slice 4½'s `SimAudioPipe` is a separate `AudioPipe` impl; if it lands first, slice 4½ tests against the WebRTC ingress only. Once the trunk leg lands (this slice), a refinement adds a `TrunkSimAudioPipe` to slice 4½'s harness OR refactors `SimAudioPipe` to be leg-agnostic. The decision is deferred to the harness-implementation phase, NOT this slice. --- ## 9. Cross-references - [ADR-0007](../../adr/0007-trunk-rented-transport.md) — the central rationale: rutster owns no SIP stack; carrier transport is rented (layer 1 = CPaaS raw-media fork, this slice; layer 2 = out-of-tree SBC, deferred; layer 3 = never first-party). - [ADR-0008](../../adr/0008-fob-and-green-zone.md) — FOB/green-zone doctrine; this slice's split (FOB owns WSS server + codec + TrunkSession; green-zone owns REST call-control). - [ADR-0009](../../adr/0009-spend-gate-honest-rescope.md) — provider credentials never reach the brain; this slice pre-paves the spend-token seam for step 6. - [slice-2 spec §4.1](2026-06-28-slice-2-agent-tap-design.md) — the core-authoritative playout buffer; trunk leg inherits the invariant (output comes from the FOB's pipe, not from the brain). - [slice-4 spec §3.3](2026-07-01-slice-4-barge-in-design.md) — `TapAudioPipe::barge_in_flush` semantics; this slice REUSES them unchanged. - [slice-4 spec §3.4](2026-07-01-slice-4-barge-in-design.md) — `LocalVadReflex

` RMS-detector; this slice REUSES it unchanged. - [slice-5/seams plan](../plans/2026-07-04-slice-5-scalability-seams.md) — config.rs, event_sink.rs, MediaCmd::Stats, non-blocking tap teardown, advertise media address — every seam this slice inherits. - [PORT_PLAN.md §Phasing](../../PORT_PLAN.md) — step 5 = real phone number (this slice). - [ARCHITECTURE.md §"Media plane"](../../ARCHITECTURE.md) — "dedicated timing threads for the 20ms loop, never the shared tokio pool"; the trunk leg participates in the same dedicated thread via `trunk_driver::drive`.