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

1085 lines
61 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<P>` + `LocalVadReflex<P>` 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<Reflex<TapAudioPipe>>`, 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<PcmFrame>` 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<Reflex<TapAudioPipe>>` (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<Duration>`** — 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<Reflex<TapAudioPipe>>, 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<TapAudioPipe>` then `LocalVadReflex<Reflex<TapAudioPipe>>` (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<TapAudioPipe>` + `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 / `<Gather>` / `<Say>` | never in core | Twilio-managed Voice-AI redirect — explicit ADR-0007 violation. |
| Outbound origination beyond `<Dial>` 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<SpendToken>` 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<P>` + `TapAudioPipe` + `LocalVadReflex<P>`) 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<ChannelId, MediaLeg> │
│ │
│ 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<Reflex<TapAudioPipe>> ← REUSED from slice-4 verbatim │
│ • inbound_from_twilio_rx: tokio::sync::mpsc::Receiver<PcmFrame> │
│ • outbound_to_twilio_tx: tokio::sync::mpsc::Sender<PcmFrame> │
│ • 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<SpendToken>) │
│ 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<TapAudioPipe>` + `LocalVadReflex<Reflex<TapAudioPipe>>` (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<P>` + `LocalVadReflex<P>` 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<SpendToken>` 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<i16> = 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<u8> {
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<RegisterTrunkInboundChannel>,
) -> 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<PcmFrame>,
pub outbound_to_twilio_tx: mpsc::Sender<PcmFrame>,
/// 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<mpsc::Sender<RegisterTrunkInboundChannel>>,
) -> 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<RegisterTrunkInboundChannel>) {
// 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<Reflex<TapAudioPipe>>` — 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<Reflex<TapAudioPipe>>,
/// Caller PCM from Twilio (decoded at 24 kHz by the WSS pump task).
pub inbound_from_twilio_rx: mpsc::Receiver<PcmFrame>,
/// FOB outbound PCM for Twilio (the WSS pump encodes μ-law + sends back).
pub outbound_to_twilio_tx: mpsc::Sender<PcmFrame>,
pub last_idle_rx: Instant,
pub next_timeout: Option<Instant>,
}
```
```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<Duration> {
// === 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<Reflex<TapAudioPipe>>::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<SpendToken>` 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<SpendToken>,
) -> Result<String, CallControlError>;
/// 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<SpendToken> 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<PcmFrame>,
outbound_to_twilio_tx: tokio::sync::mpsc::Sender<PcmFrame>,
tap_url: url::Url,
reply: oneshot::Sender<ChannelId>,
},
}
// 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<Duration> {
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 <media_streams_bind>.
// 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<TwilioCredentials> { /* ... */ }
```
---
## 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: <account>/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<Reflex<TapAudioPipe>>:
- 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<String>`
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<Reflex<TapAudioPipe>>` 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<P>` 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 14, 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<TapAudioPipe>` + `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<TrunkAudioPipe>::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<P>` 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`.