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

61 KiB
Raw Permalink Blame History

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 — rent the transport, no first-party SIP)
  • Origin: ADR-0007 (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 — the media core + AudioPipe trait
    • slice 2 — The agent tapTapAudioPipe (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 killReflex<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 (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 (provider credentials never reach the brain), ARCHITECTURE.md §"Media plane" (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).
  • 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>, 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 PcmFrames 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)

// 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)

// 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

// 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>,
}
// 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)

// 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 */);
// 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 */ }
// 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)

// 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

// 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)

// 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 — 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 — FOB/green-zone doctrine; this slice's split (FOB owns WSS server + codec + TrunkSession; green-zone owns REST call-control).
  • ADR-0009 — provider credentials never reach the brain; this slice pre-paves the spend-token seam for step 6.
  • slice-2 spec §4.1 — 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.3TapAudioPipe::barge_in_flush semantics; this slice REUSES them unchanged.
  • slice-4 spec §3.4LocalVadReflex<P> RMS-detector; this slice REUSES it unchanged.
  • slice-5/seams plan — config.rs, event_sink.rs, MediaCmd::Stats, non-blocking tap teardown, advertise media address — every seam this slice inherits.
  • PORT_PLAN.md §Phasing — step 5 = real phone number (this slice).
  • ARCHITECTURE.md §"Media plane" — "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.