# 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 1–4, gated by the slice-4 Task 10
pinned-blob CI step. Extending it with a Twilio path would either (a) re-open the slice-4 seam gate
and weaken the architectural claim that the WebRTC media loop is untouched; or (b) fork into
"loop_driver has a Trunk variant" which puts trunk-specific concerns in the media crate (violating
ADR-0008's FOB-member criterion — trunk's µ-law + WSS framing isn't media-layer concern).

A separate `trunk_driver::drive` in `crates/rutster-trunk/` honors both: the slice-4 seam stays
green (`loop_driver.rs` byte-identical), and trunk concerns stay in the trunk crate. The
`MediaLeg` enum (in `crates/rutster/src/media_thread.rs`, the binary-side bridge) is the only
addition the existing code sees; the WebRTC match arm preserves the existing call site 1:1.

### 6.3 Why Twilio Media Streams is the MVP (not Telnyx, raw SIP fork, or a custom raw-audio shim)

- **Documented application protocol** (JSON envelope; one of Twilio's most-stable public APIs).
- **Operational foothold**: the maintainer likely already has a Twilio account for personal
  telephony hacks; the dev loop falls out of one phone call. Telnyx would require a separate
  account onboarding step.
- **Generic at the seam**: the `CallControlClient` trait locks the abstraction; the next provider
  (Telnyx, a custom raw-audio shim) is an `impl` of the trait, not a refactor. We're betting on
  the architecture's flexibility, not on Twilio being the right carrier forever.

### 6.4 Why the WSS server runs on axum (not a separate tokio listener)

- Reuses the binary's existing `axum::serve` runtime — same tokio pool, same graceful-shutdown
  plumbing. No new `TcpListener` acceptor to wire through `main.rs`.
- The WSS endpoint (`/twilio/media-stream`) sits alongside the existing HTTP routes (`POST
  /v1/sessions`, etc.); a reverse proxy / load balancer at the production edge handles TLS / WS
  upgrade uniformly.
- Axum's WebSocketUpgrade extractor handles the WS handshake natively — no need to plumb a
  separate `tokio-tungstenite::accept_async` path.

### 6.5 Why in-core µ-law (not a `g711` crate dep)

- ~30 lines of table-driven code; static table is 256 + 65536 = ~66 KB binary size increase
  (irrelevant on any modern platform; the tables fit in L1/L2).
- µ-law is a 1972 ITU-T standard — the decode table has not changed in 50 years; there is no
  "actively maintained" dep that materially improves on a constant array (ADR-0008's "actively
  maintained" gate is about addressing bus-factor / unsanctioned changes; a const table has no
  bus factor).
- The decode table is a piece of telephony history — learner-commented, it teaches the µ-law
  companding formula that AGENTS.md says this codebase is supposed to teach (the project
  OVERRIDES the no-comments convention; this is exactly the kind of thing the override is for).

### 6.6 Why 3× linear interpolation for 8 kHz ↔ 24 kHz resampling (not `rubato`)

- The downstream consumer of the resampled 24 kHz PCM is the `LocalVadReflex` RMS-detector (the
  barge-in trigger), which only cares about ENERGY (RMS over 480 samples). Linear interpolation
  introduces harmonic-distortion artifacts at the high-frequency end that are well below the
  RMS-detection threshold; the barge-in trigger is unaffected.
- The brain's audio (when sent back to Twilio) is encoded µ-law + played over PSTN, which has a
  3.4 kHz cutoff anyway — Twilio's PSTN callee can't hear distortion above the µ-law bandlimit,
  so the resampler's high-frequency artifacts are inaudible by construction.
- `rubato` adds a dep + non-trivial CPU cost (FIR filter + windowing). The spearhead doesn't need
  it; per AGENTS.md ADR-0008: "When in doubt, default to green zone" / "The FOB earns its
  members, it doesn't collect them." Defer `rubato` to a post-spearhead tier when (a) the
  resampler artifacts matter for some downstream consumer (transcription accuracy, recording
  quality), OR (b) the test corpus catches a measurable regression.

---

## 7. Done-criteria

1. `cargo test --all` passes (stable + 1.85); the routine gate.
2. `cargo fmt --check` + `cargo clippy -- -D warnings` clean on the new code.
3. `cargo deny check` passes — `reqwest` and `async-trait` are acknowledged in `deny.toml`.
4. `cargo doc --no-deps` renders the new `crates/rutster-trunk/` cleanly with learner-facing
   comments per AGENTS.md code style.
5. Seam gate STILL holds: `loop_driver.rs` + `rtc_session.rs` byte-identical to slice-3 (CI
   pinned-blob from slice-4 Task 10 unchanged because this slice never touches the media-crate
   seam files).
6. **`MediaLeg::WebRTC(s) => loop_driver::drive(s, now)` is the unchanged call path for WebRTC
   legs.** The new `MediaLeg::Trunk(s) => trunk_driver::drive(s, now)` is the only
   newly-introduced tick dispatch.
7. `Reflex<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`.
