# 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`.