Files
rutster/docs/superpowers/plans/2026-07-05-slice-5-rented-transport.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

52 KiB
Raw Permalink Blame History

Slice 5 — Rented transport: a real phone number via Twilio Media Streams — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Stand up spearhead step 5 — a real phone number with no first-party SIP stack. Twilio Media Streams forks the PSTN audio over WSS to rutster; call control is Twilio's REST API (green-zone, the brain never sees credentials). The PSTN leg enters the same FOB reflex loop WebRTC legs use — same Reflex<TapAudioPipe> + LocalVadReflex composition (slice-4 REUSED unchanged), same MediaThread tick loop, same barge-in semantics.

Architecture: crates/rutster-trunk/ (currently a stub; ADR-0007 pre-paved the boundary) fills in with: G711Codec (in-core µ-law encode/decode + 8 kHz↔24 kHz linear-interpolated resampling), TwilioMediaStreamsServer (axum WSS endpoint), TrunkSession + a parallel trunk_driver::drive function (in the trunk crate, NOT in crates/rutster-media/src/loop_driver.rs — the seam holds), CallControlClient trait + MockCallControlClient + (feature-gated) TwilioCallControlClient. The binary-side MediaThread gains a MediaLeg enum (WebRTC(RtcSession) | Trunk(TrunkSession)) + a new MediaCmd::RegisterTrunk variant; the tick loop dispatches via the enum.

Tech Stack: Rust stable + 1.85 (CI matrix), the existing workspace, plus async-trait + reqwest (already workspace members per slice-2's TapClient WS pattern; reused). No other new deps.

Global Constraints

  • License: GPL-3.0-or-later on every crate manifest (ADR-0004).
  • DCO: every commit signed off — git commit -s (AGENTS.md Git workflow).
  • Seam gate (UNCHANGED): crates/rutster-media/src/loop_driver.rs + crates/rutster-media/src/rtc_session.rs stay byte-identical (CI pinned-blob from slice-4 Task 10 continuously enforced). The new trunk driver lives in crates/rutster-trunk/src/loop_driver.rs — a parallel-titled file in a different crate. The MediaLeg match-arm that calls loop_driver::drive(s, now) for WebRTC legs is one new line in crates/rutster/src/media_thread.rs (the existing call to loop_driver::drive routes through it); the MediaLeg::Trunk(s) => trunk_driver::drive(s, now) arm is its sibling.
  • Hot-path policy: never ?-propagate on the 20 ms tick; match-and-continue; "drop + observe." No unwrap()/expect() outside tests/const-init.
  • Credential isolation (ADR-0009, load-bearing): TwilioCredentials lives ONLY in crates/rutster-trunk/. NOT re-exported through the workspace. NOT in rutster-media's public API. NOT in rutster-tap's public API. NOT in the brain's WS protocol. The brain never sees account SID, auth_token, REST endpoint URL, OR CallSid (CallSid is operational correlation ID for the FOB's logs only).
  • Code style: cargo fmt is the single whitespace source of truth. clippy -D warnings is the lint bar. Newtype wrappers per AGENTS.md where applicable (none intrinsically needed this slice — CallSid: String is operational correlation, not a type-safety boundary).
  • Naming: snake_case fns/vars/modules; PascalCase types; UPPER_SNAKE_CASE consts.
  • Learner-facing comments: per AGENTS.md code style. µ-law codec comments TEACH the companding formula — this is exactly the kind of telephony-history the project's "learns Rust from this codebase" goal benefits from.
  • CI gates: cargo fmt --check, cargo clippy --all --all-targets -- -D warnings (with AND without --features=twilio-live), cargo test --all (default-features off — Mock runs in CI), cargo test --all --features=twilio-live (LOCAL ONLY — maintainer runs when validating a release), cargo deny check.
  • Branch/PR: branches slice-5/rented-transport-dev-b (T2+T6+T9+T10) + slice-5/rented-transport-dev-c (T1+T3+T4+T5+T7+T8) per strategic plan §4.1; PR via tea.

File Structure

New files

Path Responsibility
crates/rutster-trunk/src/g711.rs G711Codec — µ-law encode/decode tables + 8 kHz↔24 kHz linear-interpolated resampling. Generates mulaw_decode_table.rs + mulaw_encode_table.rs as build-time include!s OR as compile-time-computed const arrays.
crates/rutster-trunk/src/mulaw_decode_table.rs 256-element [i16; 256] decode table, generated from the ITU-T G.711 formula. (Author writes the generator snippet in a comment; the table itself is the compiled output.)
crates/rutster-trunk/src/mulaw_encode_table.rs 65536-element [u8; 65536] encode table, same generator pattern.
crates/rutster-trunk/src/twilio_media_streams.rs TwilioMediaStreamsServer — axum WS handler accepting Twilio Media Streams forks; parses JSON envelope (connected / start / media / stop); ferries decoded PCM frames into a per-call mpsc + drains the outbound mpsc to send back.
crates/rutster-trunk/src/session.rs TrunkSession — the per-trunk-leg session struct (parallels slice-1's RtcSession minus str0m/Opus/UDP).
crates/rutster-trunk/src/loop_driver.rs trunk_driver::drive(&mut TrunkSession, now) -> Option<Duration> — the trunk-leg tick function. Parallels crates/rutster-media/src/loop_driver.rs minus the str0m/Opus/RTP machinery.
crates/rutster-trunk/src/provider/mod.rs CallControlClient trait + CallControlError + SpendToken placeholder (pre-paved seam for step 6).
crates/rutster-trunk/src/provider/mock.rs MockCallControlClient — in-process test double; CI gate uses this.
crates/rutster-trunk/src/provider/twilio.rs TwilioCallControlClient — REST client via reqwest (feature-gated behind twilio-live; NOT compiled in CI default).
crates/rutster-trunk/tests/reflex_on_trunk.rs Reflex-on-trunk-leg verification test (T7): proves Reflex<TapAudioPipe> + LocalVadReflex (slice-4, REUSED) decorate the trunk leg's TapAudioPipe identically to a WebRTC leg. Barge-in fires.
crates/rutster-trunk/tests/sim_integ.rs PSTN sim e2e integration test (T8): MockCallControlClient + MockTwilioMediaStreamsServer → MediaThread → Reflex → barge-in verified on PSTN leg → brain reply observed → CDR/EventSink emission.

Modified files

Path What changes
crates/rutster-trunk/Cargo.toml Add deps: rutster-media, rutster, rutster-call-model, rutster-tap, tokio, axum, reqwest, async-trait, serde, serde_json, tracing, thiserror, base64, url. Add features: default = [], twilio-live = [] (gates provider/twilio.rs).
crates/rutster-trunk/src/lib.rs Replace stub doc-comment-only with pub mod declarations + re-exports per spec §3.
crates/rutster/src/media_thread.rs Add MediaLeg enum + MediaCmd::RegisterTrunk { call_sid, inbound_from_twilio_rx, outbound_to_twilio_tx, tap_url, reply } variant + the std-thread match-arm dispatch: MediaLeg::WebRTC(s) => loop_driver::drive(s, now) (UNCHANGED) vs MediaLeg::Trunk(s) => trunk_driver::drive(s, now) (NEW). The session map's value type changes from RtcSession to MediaLeg.
crates/rutster/src/main.rs Construct + mount TwilioMediaStreamsServer::router(register_tx) on the existing axum router. Routes /twilio/media-stream (WS), POST /v1/trunk/sessions, POST /v1/trunk/webhook (T5).
crates/rutster/src/routes.rs Add the POST /v1/trunk/sessions (originate) + POST /v1/trunk/webhook (Twilio callback receiver) handlers. Handlers route via CallControlClient (green-zone).
crates/rutster/src/config.rs Add pub fn twilio_credentials() -> Option<TwilioCredentials> env parser per slice-5/seams pattern (RUTSTER_TWILIO_ACCOUNT_SID, RUTSTER_TWILIO_AUTH_TOKEN, RUTSTER_TWILIO_MEDIA_BIND, RUTSTER_TWILIO_WEBHOOK_BASE).
crates/rutster/src/lib.rs pub mod twilio_credentials; re-export OR fold into existing config. (Prefer: keep TwilioCredentials in rutster-trunk + import via crates/rutster/src/config.rs::twilio_credentials().)
.github/workflows/ci.yml Add a twilio-live job (runs locally only, NOT on every PR — gated by a manual trigger). The routine CI job stays unchanged (no --features=twilio-live).
docs/QUICKSTART.md Add env-var table for RUTSTER_TWILIO_* env vars + a "make a real phone call" walkthrough (with --features=twilio-live).
README.md Update the spearhead status line: slice-14 + 4½ + slice-5 all green; "make a real phone call via Twilio Media Streams" linked from the Quickstart.
deny.toml Add reqwest's transitive deps to the allowlist if cargo deny check flags any (most are already admitted via existing transitive deps).

SEAM-INVARIANT files (DO NOT TOUCH)

  • crates/rutster-media/src/loop_driver.rs — byte-identical, all tasks.
  • crates/rutster-media/src/rtc_session.rs — byte-identical, all tasks.

Task ordering (for Kimi-worker subagent dispatch)

Per strategic plan §3.2 + §4.1: dev-c owns the FOB media-streams chain (T1→T3→T4→T5→T7→T8), dev-b owns the green-zone / provider + docs + final sweep chain (T2→T6→T9→T10). Both devs work in their own worktrees + branches. dev-b can start T2 in parallel with dev-c on T1 — the provider trait doesn't depend on the codec's internals (separate modules).

  • T1 — CRITICAL-PATH FOUNDATION for dev-c. G711Codec + decode/encode tables. Lands first on the dev-c chain; T3 + T4 consume it. NOT parallelizable across devs (single owner).
  • T2 — FOUNDATION for dev-b. CallControlClient trait + MockCallControlClient + TwilioCredentials + env parser. PARALLEL with T1 (different module).
  • T3 — depends on T1 (consumes G711Codec). TwilioMediaStreamsServer.
  • T4 — depends on T3 (consumes the WSS server's mpsc wiring). TrunkSession + trunk_driver::drive.
  • T5 — depends on T4 (TrunkSession is real) + T2 (CallControlClient for the route handlers). MediaCmd::RegisterTrunk + MediaLeg enum + the two new HTTP routes.
  • T6 — depends on T2. TwilioCallControlClient (live REST impl behind twilio-live feature)
    • config env parser.
  • T7 — depends on T4 + T5. Reflex-on-trunk-leg verification test.
  • T8 — depends on T5 + T6 + T7. PSTN-sim e2e integration test.
  • T9 — filler after T5 + T6 land. QUICKSTART + README updates.
  • T10 — final sweep depends on T6 (the reqwest dep). Cargo-deny + CI gate.

Parallelizable-now filler: LEARNING.md pointers (after T1 + T4 land), cargo doc rendering checks (after the crate skeleton stabilizes).


Task T1: G711Codec + µ-law decode/encode tables — the dev-c critical-path foundation

Files:

  • Create: crates/rutster-trunk/Cargo.toml (rewrite the stub — add deps)
  • Create: crates/rutster-trunk/src/g711.rs
  • Create: crates/rutster-trunk/src/mulaw_decode_table.rs
  • Create: crates/rutster-trunk/src/mulaw_encode_table.rs
  • Modify: crates/rutster-trunk/src/lib.rs (add pub mod g711;)
  • Test: inline #[cfg(test)] mod tests in g711.rs

Interfaces:

  • Consumes: rutster_media::PcmFrame (the 24 kHz canonical format from slice-1).

  • Produces:

    • pub struct G711Codec; (no state — pure static-method form for MVP)
    • pub fn G711Codec::decode_mulaw_to_pcm(mulaw: &[u8]) -> PcmFrame
    • pub fn G711Codec::encode_pcm_to_mulaw(frame: &PcmFrame) -> Vec<u8>
  • Step 1: Author the Cargo.toml with deps

# crates/rutster-trunk/Cargo.toml
[package]
name = "rutster-trunk"
version = "0.0.0"
license.workspace = true
edition.workspace = true
repository.workspace = true
description = "Rented carrier transport — CPaaS media-leg ingress; no first-party SIP (spearhead step 5, ADR-0007)."

[dependencies]
rutster-media = { path = "../rutster-media" }
rutster-call-model = { path = "../rutster-call-model" }
rutster-tap = { path = "../rutster-tap" }
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "sync", "time"] }
axum = { workspace = true }
async-trait = { workspace = true }
reqwest = { workspace = true, features = ["json"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tracing = { workspace = true }
thiserror = { workspace = true }
base64 = { workspace = true }
url = { workspace = true }

[features]
default = []
# The live Twilio CallControlClient is feature-gated; CI default-features-off
# keeps the routine gate clean (Mock runs in CI). Maintainer runs
# `cargo test --features=twilio-live` + the e2e suite when validating a release.
twilio-live = []

(If reqwest/base64/async-trait aren't yet workspace deps in root Cargo.toml, add them under [workspace.dependencies] mirroring slice-2's TapClient wiring. The dev-c implementer should check first: grep '^reqwest\|^base64\|^async-trait' Cargo.toml.)

  • Step 2: Author the two lookup tables

crates/rutster-trunk/src/mulaw_decode_table.rs:

//! 8-bit µ-law to 16-bit linear decode table. Generated from the ITU-T G.711
//! standard piecewise-linear decoding formula. See `g711.rs` for the
//! formula's explanation. Hand-verified against the ITU-T spec tables.
//!
//! The table is the actual compiled output of:
//!   fn mulaw_to_linear(u: u8) -> i16 { /* G.711 formula */ }
//! Run once at maintainer time; pasted here as a const array.
//!
//! # Generated-by-note
//!
//! To regenerate: `cargo test --features=regen-tables` (hypothetical;
//! the table was authored once and is versioned as a constant — there is
//! no regen task in MVP).

#[rustfmt::skip]
pub static MULAW_TO_LINEAR: [i16; 256] = [
    /* generated table - 256 entries */
    // FIXME (implementer): paste the 256 i16 values from the G.711 standard
    // table. Authoritative source: ITU-T G.711 (1972, reaffirmed 2000).
    // The formula: bias=0x84, clip=32635; let sign = u & 0x80; let exponent = (u >> 4) & 0x07;
    //   let mantissa = u & 0x0F; let sample = ((mantissa << 3) + bias) << exponent;
    //   sample = if sample > clip { clip } else { sample };
    //   return if sign != 0 { -(sample) } else { sample } as i16;
    // (Implementer note: write a one-shot test that produces the table; the
    // values ARE the table. The test becomes the documentation.)
    0, 0, /* ... 254 more entries ... */ 0,
];

Actually — the simpler path: write a const fn decode_one(u: u8) -> i16 per the formula + build the table at compile time. That's cleaner + doesn't require regenerating a literal list. Recommended path: use a const fn + const array initialization.

// crates/rutster-trunk/src/g711.rs (table approach via const fn)

/// The ITU-T G.711 µ-law to linear decoding formula. Const fn so the
/// table is computed at compile time (zero runtime cost).
const fn mulaw_decode_one(u: u8) -> i16 {
    let bias: i32 = 0x84;
    let clip: i32 = 32635;
    let sign = (u & 0x80) != 0;
    let exponent = ((u >> 4) & 0x07) as i32;
    let mantissa = (u & 0x0F) as i32;
    let mut sample = ((mantissa << 3) + bias) << exponent;
    if sample > clip { sample = clip; }
    if sign { (-(sample)) as i16 } else { sample as i16 }
}

pub static MULAW_TO_LINEAR: [i16; 256] = {
    let mut t = [0i16; 256];
    let mut i = 0;
    while i < 256 {
        t[i] = mulaw_decode_one(i as u8);
        i += 1;
    }
    t
};

For the inverse direction (linear→µ-law), the table is 65536 elements = 64 KB binary size. Author the encode table as const fn:

const fn linear_to_mulaw_one(s: i16) -> u8 {
    // ITU-T G.711's reference encode formula. See ITU-T G.711 §2.2.
    // Pseudocode (from the spec, adapted to Rust):
    let bias: i32 = 0x84;
    let clip: i32 = 32635;
    let s = if s < 0 { -s.max(-(clip + 1)) } else { s.min(clip) };
    let s_bias = s + bias;
    let sign = if s < 0 { 0x80 } else { 0x00 };
    // Find the highest set bit position (encoded as exponent).
    let exponent = (s_bias.leading_zeros() as i32).wrapping_sub(1).max(0).min(7);
    let mantissa = ((s_bias >> (exponent + 3)) & 0x0F) as u8;
    let u = !(sign as u8 | ((exponent as u8) << 4) | mantissa);
    u
}

(The formula above is a sketch — the implementer MUST cross-verify against the ITU-T G.711 spec's reference C code, which is widely available. The correctness test in Step 5 cross-checks encode(decode(x)) == x for every byte value.)

  • Step 3: Author g711.rs with decode_mulaw_to_pcm + encode_pcm_to_mulaw

The bodies mirror spec §3.1 verbatim — 3× linear upsample + 3× decimation downsample. Don't invent new code.

  • Step 4: Write failing tests
#[cfg(test)]
mod tests {
    use super::*;
    use rutster_media::PcmFrame;

    #[test]
    fn decode_then_encode_round_trips_a_loud_signal() {
        // Hand-craft a 24 kHz PcmFrame with a loud sample → encode to µ-law →
        // decode back → assert energy is preserved within 12% (µ-law's intrinsic
        // quantization error floor).
        let mut frame = PcmFrame::zeroed();
        for s in frame.samples.iter_mut() { *s = 10_000; }
        let mulaw = G711Codec::encode_pcm_to_mulaw(&frame);
        assert_eq!(mulaw.len(), 160);
        let decoded = G711Codec::decode_mulaw_to_pcm(&mulaw);
        // Energy invariant: decoded RMS should be within 12% of the original.
        let orig_rms = rms(&frame.samples);
        let dec_rms = rms(&decoded.samples);
        let drift = (dec_rms - orig_rms).abs() / orig_rms.max(1.0);
        assert!(drift <= 0.12, "µ-law round-trip energy drift {}% > 12%", drift * 100.0);
    }

    #[test]
    fn decode_160_byte_frame_yields_480_samples() {
        let mulaw = vec![0u8; 160];  // 160 µ-law bytes (silence)
        let frame = G711Codec::decode_mulaw_to_pcm(&mulaw);
        assert_eq!(frame.samples.len(), 480);
    }

    fn rms(samples: &[i16]) -> f64 {
        let sum_sq: u64 = samples.iter().map(|&s| (s as i64 * s as i64) as u64).sum();
        (sum_sq as f64 / samples.len() as f64).sqrt()
    }
}
  • Step 5: fmt + clippy + test + commit
cargo fmt --all --check && cargo clippy --all --all-targets -- -D warnings && cargo test --all
git add crates/rutster-trunk/Cargo.toml crates/rutster-trunk/src/{g711.rs,mulaw_decode_table.rs,mulaw_encode_table.rs,lib.rs}
git commit -s -m "feat(trunk): G711Codec — µ-law encode/decode + 8kHz↔24kHz linear-interpolated resampling (slice-5 T1)

In-core ~30-line table-driven codec (no dep). The ITU-T G.711 µ-law
companding formula is a piece of telephony history worth teaching (AGENTS.md
learner-facing comment mandate). 3× linear upsample on decode; 3× decimation
downsample on encode. The resampler artifacts are below the barge-in trigger
threshold (LocalVadReflex only needs RMS energy); rubato lands in a post-
spearhead refinement if a downstream consumer needs better (spec §6.6).

Task T1 of slice-5 — T3 (TwilioMediaStreamsServer) consumes this codec."

Task T2: CallControlClient trait + MockCallControlClient + TwilioCredentials config

Files:

  • Create: crates/rutster-trunk/src/provider/mod.rs
  • Create: crates/rutster-trunk/src/provider/mock.rs
  • Modify: crates/rutster-trunk/src/lib.rs (pub mod provider;)
  • Test: inline in provider/mock.rs

Interfaces:

  • Consumes: nothing new.

  • Produces:

    • #[async_trait] pub trait CallControlClient: Send + Sync { async fn originate(&self, to, from, spend_token: Option<SpendToken>) -> Result<String, CallControlError>; async fn hangup(&self, correlation_id) -> Result<(), CallControlError>; }
    • pub struct CallControlError(pub String);
    • pub struct SpendToken; (the pre-paved seam for step 6; opaque placeholder).
    • pub struct MockCallControlClient { /* in-process queues */ }
    • pub struct TwilioCredentials { account_sid, auth_token (NEVER logged), media_streams_bind, webhook_base } — the struct lives in provider/mod.rs (NOT re-exported through the workspace; ADR-0009). The TwilioCallControlClient consumes it (T6).
  • Step 1: Define the trait + SpendToken + CallControlError

(Verbatim from spec §3.4 + async_trait attribute.)

  • Step 2: Implement MockCallControlClient

An in-process test double — originate returns a synthetic correlation ID + records the call in an internal Vec; hangup records the terminate. Both async fns never touch the network.

  • Step 3: Define TwilioCredentials (no impl yet — fields only)

The struct fields per spec §3.4. NO logging of auth_token — implement Debug manually with a redacted field; don't derive Debug.

impl std::fmt::Debug for TwilioCredentials {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("TwilioCredentials")
            .field("account_sid", &self.account_sid)
            .field("auth_token", &"<redacted>")
            .field("media_streams_bind", &self.media_streams_bind)
            .field("webhook_base", &self.webhook_base)
            .finish()
    }
}
  • Step 4: Tests + fmt + clippy + commit
cargo fmt --all --check && cargo clippy --all --all-targets -- -D warnings && cargo test --all
git commit -s -m "feat(trunk): CallControlClient trait + MockCallControlClient + TwilioCredentials (slice-5 T2)

The provider abstraction. The trait locks the seam so the next provider
(Telnyx, etc.) is an implementation, not a refactor. The Mock is the CI
test double; TwilioCallControlClient (T6) lives behind the twilio-live
feature flag. TwilioCredentials is NEVER re-exported through the workspace
(ADR-0009 — provider credentials never reach the brain). The
Option<SpendToken> parameter on originate pre-paves the spearhead step-6
spend-cap seam."

Task T3: TwilioMediaStreamsServer — the WebSocket ingress

Files:

  • Create: crates/rutster-trunk/src/twilio_media_streams.rs
  • Modify: crates/rutster-trunk/src/lib.rs (pub mod twilio_media_streams;)
  • Test: inline #[cfg(test)] mod tests covering the JSON envelope parser

Interfaces:

  • Consumes: G711Codec (T1), axum WebSocket types, tokio mpsc, base64.

  • Produces:

    • pub struct TwilioMediaStreamsServer;
    • pub struct RegisterTrunkInboundChannel { call_sid: String, inbound_from_twilio_rx: mpsc::Receiver<PcmFrame>, outbound_to_twilio_tx: mpsc::Sender<PcmFrame>, tap_url: url::Url }
    • pub fn TwilioMediaStreamsServer::router(register_tx: mpsc::Sender<RegisterTrunkInboundChannel>) -> axum::Router — mounts /twilio/media-stream (axum get WebSocket upgrade).
  • Step 1: Define the envelope-parsing types

Twilio Media Streams JSON frames per their documented protocol:

  • connected: handshake.
  • start: { event: "start", stream_sid, call_sid, ... } — read CallSid + StreamSid.
  • media: { event: "media", media: { payload: "<base64 µ-law bytes>" } } — decode payload.
  • stop: end.

Define TwilioMediaEvent serde-representable enum mirroring these.

  • Step 2: Implement run_media_stream async loop

Pseudocode:

  1. Wait for first WS message. Expect connected envelope; ignore.
  2. Read start envelope. Extract CallSid + StreamSid. Construct the two mpsc pairs.
  3. Send RegisterTrunkInboundChannel to register_tx. Await MediaThread's RegisterTrunk reply (a oneshot confirming the ChannelId).
  4. Loop: a. Read next WS message:
    • media: base64-decode, G711Codec::decode_mulaw_to_pcmPcmFrame, push to inbound_tx.
    • stop (or WS close): break. b. Concurrently drain outbound_rx (PcmFrame produced by the std thread's TrunkSession via trunk_driver::drive): G711Codec::encode_pcm_to_mulaw → wrap in a JSON media envelope → send WS Text frame.
  5. On stop / close: send MediaCmd::Delete for the ChannelId; tear down.

Use tokio::select! for the bidirectional loop (Step 4a + 4b concurrently).

  • Step 3: Tests
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn media_event_parses_start() {
        let s = r#"{"event":"start","stream_sid":"ZZ...","call_sid":"CA..."}"#;
        let e: TwilioMediaEvent = serde_json::from_str(s).unwrap();
        match e {
            TwilioMediaEvent::Start { call_sid, .. } => assert_eq!(call_sid, "CA..."),
            _ => panic!("expected Start"),
        }
    }

    #[test]
    fn media_event_parses_media() {
        let s = r#"{"event":"media","media":{"payload":"//MA"}}"#;
        let e: TwilioMediaEvent = serde_json::from_str(s).unwrap();
        match e {
            TwilioMediaEvent::Media { payload } => assert!(!payload.is_empty()),
            _ => panic!("expected Media"),
        }
    }

    #[test]
    fn media_event_parses_stop() {
        let s = r#"{"event":"stop"}"#;
        let e: TwilioMediaEvent = serde_json::from_str(s).unwrap();
        assert!(matches!(e, TwilioMediaEvent::Stop));
    }

    #[tokio::test]
    async fn wss_pump_decodes_base64_mulaw_and_pushes_pcm_frame() {
        // Integration-ish: feed a synthetic "media" JSON frame into the WS
        // pump task; assert a PcmFrame lands on the inbound_tx mpsc.
        // (Spot-test the base64 decode + G711Codec decode + mpsc push wiring.)
        // Skip the full WS handshake; test the post-parse path.
    }
}
  • Step 4: fmt + clippy + test + commit
cargo fmt --all --check && cargo clippy --all --all-targets -- -D warnings && cargo test --all
git commit -s -m "feat(trunk): TwilioMediaStreamsServer — axum WS handler for Twilio Media Streams (slice-5 T3)

Accepts Twilio's inbound WSS connections; parses the JSON envelope
(connected/start/media/stop per Twilio's documented protocol); decodes
base64 µ-law via G711Codec; ferries decoded PCM frames to the per-call
inbound mpsc. Concurrently drains the outbound mpsc + sends back JSON
media frames. Same tokio/std-thread split as slice-2's TapEngine: tokio
owns IO; the std thread owns the 20ms tick via trunk_driver::drive (T4)."

Task T4: TrunkSession + trunk_driver::drive — the FOB-side trunk tick function

Files:

  • Create: crates/rutster-trunk/src/session.rs
  • Create: crates/rutster-trunk/src/loop_driver.rs
  • Modify: crates/rutster-trunk/src/lib.rs (pub mod session; pub use session::TrunkSession; pub mod loop_driver;)
  • Test: inline tests in session.rs exercising the tick

Interfaces:

  • Consumes: slice-4 Reflex<P> + LocalVadReflex<P> + ReflexMetrics (REUSED), slice-2 TapAudioPipe (REUSED as the wrapped inner), tokio mpsc, rutster_call_model::Channel.

  • Produces:

    • pub struct TrunkSession { channel, pipe: LocalVadReflex<Reflex<TapAudioPipe>>, inbound_from_twilio_rx, outbound_to_twilio_tx, last_idle_rx, next_timeout }
    • pub fn drive(session: &mut TrunkSession, now: Instant) -> Option<Duration> — the trunk tick function.
  • Step 1: Author TrunkSession

pub struct TrunkSession {
    pub channel: Channel,
    /// The wrapped reflex stack. REUSED from slice-4 verbatim:
    /// `LocalVadReflex<Reflex<TapAudioPipe>>`. Construction reused from
    /// slice-4 Task 6 — same composition site (Reflex::new(inner_pipe,
    /// advisory_rx, metrics) → LocalVadReflex::new(reflex, advisory_tx)).
    pub pipe: LocalVadReflex<Reflex<TapAudioPipe>>,
    pub inbound_from_twilio_rx: mpsc::Receiver<PcmFrame>,
    pub outbound_to_twilio_tx: mpsc::Sender<PcmFrame>,
    pub last_idle_rx: Instant,
    pub next_timeout: Option<Instant>,
    pub last_outbound_at: Instant,
}
  • Step 2: Author trunk_driver::drive

Body mirrors spec §3.3 verbatim:

  1. Drain inbound_from_twilio_rx via try_recv → for each PcmFrame: session.pipe.on_pcm_frame(frame) (hits LocalVadReflex first → ReflexTapAudioPipetx_pcm_in → TapEngine → brain WS as audio_in).
  2. Outbound encode tick (every 20 ms): pull session.pipe.next_pcm_frame() → push to outbound_to_twilio_tx (try_send; drop + observe on full).
  3. Idle timeout: 60 s of no inbound = ChannelState::Closed.
  • Step 3: Tests
#[cfg(test)]
mod tests {
    use super::*;
    use std::time::Instant;

    fn trivial_trunk_session() -> TrunkSession {
        let (inbound_tx, inbound_rx) = mpsc::channel(8);
        let (outbound_tx, mut outbound_rx) = mpsc::channel(8);
        // ... construct the wrapped pipe stack;
        // (For the unit test, the real spawn_tap_engine against a real brain WS
        // is heavy — use a stub TapAudioPipe OR a no-op brain.)
        // Recommendation: simplify this test by constructing the stack with a
        // mock AudioPipe as the inner (instead of TapAudioPipe) — verify the
        // tick function dispatches incoming frames to pipe.on_pcm_frame + pulls
        // from pipe.next_pcm_frame without setting up real TapEngine wiring.
        // The full barge-in e2e is verified in T8, not here.
    }

    #[tokio::test]
    async fn tick_drains_inbound_mpsc_and_pushes_to_pipe_sink() {
        let mut s = trivial_trunk_session();
        s.inbound_from_twilio_tx.try_send(PcmFrame::zeroed()).unwrap();
        let now = Instant::now();
        drive(&mut s, now);
        // (Assert the inner mock AudioPipe received the frame — via a counter
        // OR via observable side effect.)
    }

    #[tokio::test]
    async fn tick_pulls_from_pipe_source_and_pushes_to_outbound() {
        let mut s = trivial_trunk_session();
        // (Mock the wrapped pipe to produce a known frame on next_pcm_frame.)
        let now = Instant::now();
        drive(&mut s, now);
        let _ = s.outbound_to_twilio_rx.try_recv().expect("frame flowed out");
    }

    #[tokio::test]
    async fn idle_timeout_closes_session_after_60s_silence() {
        let mut s = trivial_trunk_session();
        // Set last_idle_rx 90 seconds ago.
        let now = Instant::now();
        s.last_idle_rx = now - Duration::from_secs(90);
        let next = drive(&mut s, now);
        assert_eq!(s.channel.state, ChannelState::Closed);
        assert_eq!(next, None);
    }
}
  • Step 4: fmt + clippy + test + commit
cargo fmt --all --check && cargo clippy --all --all-targets -- -D warnings && cargo test --all
git commit -s -m "feat(trunk): TrunkSession + trunk_driver::drive — FOB-side trunk-leg tick (slice-5 T4)

The trunk leg's tick function. Parallels 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. Caller→FOB direction is a pure mpsc
drain; FOB→caller direction is a mpsc push.

Slice-4's Reflex<TapAudioPipe> + LocalVadReflex stack compose identically
around the trunk leg's session.pipe — proving the FOB reflex loop is
ingress-agnostic (spec §2.3 — the architecture's load-bearing claim).

The seam gate holds: crates/rutster-media/src/{loop_driver.rs,rtc_session.rs}
stay byte-identical because the trunk leg NEVER enters that code path. The
MediaThread dispatches via the new MediaLeg enum (T5)."

Task T5: MediaCmd::RegisterTrunk + MediaLeg enum + the new HTTP routes — the binary-side wiring

Files:

  • Modify: crates/rutster/src/media_thread.rs
  • Modify: crates/rutster/src/main.rs
  • Modify: crates/rutster/src/routes.rs
  • Modify: crates/rutster/src/lib.rs (if needed for re-exports)
  • Modify: crates/rutster-trunk/src/lib.rs (if the RegisterTrunkInboundChannel type is publicly surfaced)
  • Test: inline in crates/rutster/src/routes.rs for the new routes (spot-test the handlers)

Interfaces:

  • Consumes: T3 (TwilioMediaStreamsServer) + T4 (TrunkSession + trunk_driver::drive) + T2 (CallControlClient trait).

  • Produces:

    • MediaCmd::RegisterTrunk { call_sid, inbound_from_twilio_rx, outbound_to_twilio_tx, tap_url, reply } — the new variant in the existing enum.
    • pub enum MediaLeg { WebRTC(RtcSession), Trunk(TrunkSession) } — the session-map value type, in media_thread.rs.
    • POST /v1/trunk/sessions axum route — originates an outbound call.
    • POST /v1/trunk/webhook axum route — Twilio's inbound-call webhook receiver.
  • Step 1: Add the MediaLeg enum + RegisterTrunk variant + dispatch match

Update crates/rutster/src/media_thread.rs:

pub enum MediaLeg {
    /// Existing WebRTC leg. Ticked by `rutster_media::loop_driver::drive`.
    /// UNCHANGED code path from slice-4 + slice-5/seams.
    WebRTC(RtcSession),
    /// New trunk leg. Ticked by `rutster_trunk::loop_driver::drive`.
    Trunk(rutster_trunk::session::TrunkSession),
}

pub enum MediaCmd {
    // ... existing variants unchanged ...

    /// slice-5: register a trunk-side session. The MediaThread:
    /// 1. Allocates a ChannelId.
    /// 2. Constructs TapAudioPipe + TapConn via spawn_tap_engine (from slice-2; REUSED).
    /// 3. Wraps as Reflex::new(tap_pipe, advisory_rx, metrics) then
    ///    LocalVadReflex::new(reflex, advisory_tx) — same Task-6-style composition as slice-4.
    /// 4. Constructs TrunkSession { channel, pipe, inbound_from_twilio_rx,
    ///    outbound_to_twilio_tx, ... } and inserts 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: tokio::sync::oneshot::Sender<ChannelId>,
    },
}

// In the std thread's tick loop, dispatch the per-leg tick:
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 from slice-4
        MediaLeg::Trunk(s) => rutster_trunk::loop_driver::drive(s, now),   // NEW this slice
    }
}

The existing call site for loop_driver::drive(s, now) (within the WebRTC tick loop) routes through this run_per_leg_tick — a one-line refactor of the existing invocation. The seam file loop_driver.rs itself is byte-identical (the call site is in media_thread.rs, the binary-side bridge).

  • Step 2: Add the RegisterTrunk handler in the std thread loop

When RegisterTrunk arrives:

  1. Construct tap_url (operator-configured brain WS URL e.g. ws://localhost:8082 for MockRealtimeBrain).
  2. Construct (advisory_tx, advisory_rx) = mpsc::channel(16) + metrics = ReflexMetrics::new().
  3. let (tap_pipe, tap_conn) = spawn_tap_engine(channel_id, tap_url, advisory_tx) — REUSED from slice-4 Task 5.
  4. let reflex = Reflex::new(tap_pipe, advisory_rx, metrics.clone());
  5. let wrapped = LocalVadReflex::new(reflex, advisory_tx_clone); (where advisory_tx_clone is the second sender; advisory_tx has TWO senders — the engine + the local VAD).
  6. Construct TrunkSession { channel: Channel::new(channel_id), pipe: wrapped, inbound_from_twilio_rx, outbound_to_twilio_tx, last_idle_rx: now, next_timeout: None, last_outbound_at: now }.
  7. Insert into session_map.insert(channel_id, MediaLeg::Trunk(session)).
  8. Spawn the TapEngine tokio task (held by tap_conn).
  9. Reply with channel_id via the oneshot.

(session_map type changes from HashMap<ChannelId, RtcSession> to HashMap<ChannelId, MediaLeg> — the WebRTC leg-handling code path handles the enum's WebRTC variant via run_per_leg_tick.)

  • Step 3: Mount TwilioMediaStreamsServer::router(register_tx) on the axum router in main.rs

In crates/rutster/src/main.rs:

  • Allocate a tokio::sync::mpsc::Sender<RegisterTrunkInboundChannel> pair (cap 64).

  • Pass the Sender end into TwilioMediaStreamsServer::router(...).

  • The Receiver end runs in a tokio task: when a RegisterTrunkInboundChannel arrives, it relays the contents into a MediaCmd::RegisterTrunk envelope + awaits the reply, then ACKs the WSS pump task.

  • Mount the router's /twilio/media-stream route alongside the existing routes (/v1/sessions, /healthz, etc.).

  • Step 4: Author the two new HTTP routes in routes.rs

  • POST /v1/trunk/sessions { to, from } — call CallControlClient::originate(to, from, None) (None because spend gate is step 6); respond with { channel_id, call_sid }.

  • POST /v1/trunk/webhook — Twilio's webhook receiver for inbound-call signaling. Respond with TwiML body instructing Twilio to Media Streams against our /twilio/media-stream.

  • Step 5: Testsroute_returns_channel_id_for_originate, webhook_responds_with_twiml_pointing_at_media_stream.

  • Step 6: fmt + clippy + test + commit

cargo fmt --all --check && cargo clippy --all --all-targets -- -D warnings && cargo test --all
git commit -s -m "feat(binary): MediaCmd::RegisterTrunk + MediaLeg enum + /v1/trunk routes (slice-5 T5)

The binary-side wiring of the trunk leg. The seam gate holds: the
MediaThread's tick loop dispatches via the new MediaLeg enum;
MediaLeg::WebRTC(s) => loop_driver::drive(s, now) is UNCHANGED from slice-4.
MediaLeg::Trunk(s) => trunk_driver::drive(s, now) is the new sibling.

Two new axum routes: POST /v1/trunk/sessions initiates an outbound call via
the CallControlClient trait (green-zone); POST /v1/trunk/webhook receives
Twilio's inbound-call signaling + responds with TwiML pointing at our
/twilio/media-stream WS endpoint.

The RegisterTrunk handler on the std thread constructs the TrunkSession +
composes the slice-4 Reflex<TapAudioPipe> + LocalVadReflex stack identically
to slice-4 Task 6's WebRTC spawn seam."

Task T6: TwilioCallControlClient (the live REST impl) + config.rs env parser

Files:

  • Create: crates/rutster-trunk/src/provider/twilio.rs
  • Modify: crates/rutster-trunk/src/provider/mod.rs (re-export TwilioCallControlClient ONLY when twilio-live feature is enabled)
  • Modify: crates/rutster/src/config.rs (add pub fn twilio_credentials() -> Option<TwilioCredentials>)
  • Test: live e2e test gated behind twilio-live feature — runs once on maintainer's machine via a manual cargo test --features=twilio-live

Interfaces:

  • Consumes: trait from T2 + reqwest + the env-var parser from slice-5/seams.

  • Produces:

    • pub struct TwilioCallControlClient { credentials: TwilioCredentials, http: reqwest::Client }
    • impl TwilioCallControlClient { pub fn new(credentials: TwilioCredentials) -> Self }
    • #[async_trait] impl CallControlClient for TwilioCallControlClient
    • pub fn twilio_credentials() -> Option<TwilioCredentials> in crates/rutster/src/config.rs
  • Step 1: Implement TwilioCallControlClient

originate: POST to https://api.twilio.com/2010-04-01/Accounts/{account_sid}/Calls.json with basic auth (account_sid:auth_token) + form body containing To, From, Twiml (a <Connect> TwiML instructing Twilio to Media Streams against our webhook_base + /twilio/media-stream). Parse the JSON response for the call_sid. Return Ok(call_sid).

hangup: POST to .../Calls/{call_sid}.json with Status=completed. Idempotent.

auth_token MUST NEVER be logged. Use tracing::debug! only on caller-controlled fields (to, from, call_sid's last 4 chars).

  • Step 2: Implement the env parser in config.rs
/// 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> {
    let account_sid = std::env::var("RUTSTER_TWILIO_ACCOUNT_SID").ok()?;
    let auth_token = std::env::var("RUTSTER_TWILIO_AUTH_TOKEN").ok()?;
    let media_streams_bind = std::env::var("RUTSTER_TWILIO_MEDIA_BIND")
        .ok()?
        .parse::<std::net::SocketAddr>()
        .map_err(|e| format!("invalid RUTSTER_TWILIO_MEDIA_BIND: {e}"))
        .ok()?;
    let webhook_base = std::env::var("RUTSTER_TWILIO_WEBHOOK_BASE")
        .ok()?
        .parse::<url::Url>()
        .map_err(|e| format!("invalid RUTSTER_TWILIO_WEBHOOK_BASE: {e}"))
        .ok()?;
    Some(TwilioCredentials { account_sid, auth_token, media_streams_bind, webhook_base })
}
  • Step 3: Live e2e test gated behind twilio-live
#[cfg(all(test, feature = "twilio-live"))]
mod live_tests {
    use super::*;

    #[tokio::test]
    #[ignore = "requires live Twilio credentials + a real outbound number; manual run only"]
    async fn originate_real_call_returns_call_sid() {
        let creds = crate::config::twilio_credentials().expect("set RUTSTER_TWILIO_* env vars");
        let client = TwilioCallControlClient::new(creds);
        let to = std::env::var("RUTSTER_TWILIO_TEST_TO").expect("set RUTSTER_TWILIO_TEST_TO");
        let from = std::env::var("RUTSTER_TWILIO_TEST_FROM").expect("set RUTSTER_TWILIO_TEST_FROM");
        let call_sid = client.originate(&to, &from, None).await.expect("originate");
        assert!(call_sid.starts_with("CA"));
        // Hang up immediately (don't actually call the test number for real).
        client.hangup(&call_sid).await.expect("hangup");
    }
}
  • Step 4: fmt + clippy + test (default features OFF) + commit
cargo fmt --all --check && cargo clippy --all --all-targets -- -D warnings
cargo clippy --all --all-targets --features=twilio-live -- -D warnings
cargo test --all  # CI gate: Mock only
git commit -s -m "feat(trunk): TwilioCallControlClient (live REST impl behind twilio-live feature) + env parser (slice-5 T6)

The live REST call-control client. Originates outbound calls via Twilio's
Calls.json API; hangs up via Status=completed. Auth_token is NEVER logged
(ADR-0009 — provider credentials never reach the brain; this boundary is
honored at the trunk-crate level). The impl is feature-gated behind
twilio-live so the routine CI gate stays clean; the maintainer runs
cargo test --features=twilio-live when validating a release.

Env parser in config.rs follows slice-5/seams' pure-function pattern
(take Option<String>/&str inputs; testable without env mutation)."

Task T7: Reflex-on-trunk-leg verification test

Files:

  • Create: crates/rutster-trunk/tests/reflex_on_trunk.rs
  • Test: integration test asserting slice-4's Reflex<TapAudioPipe> + LocalVadReflex (REUSED) decorate the trunk leg's TapAudioPipe identically; barge-in fires on trunk-leg caller speech.

Interfaces:

  • Consumes: T3 (TwilioMediaStreamsServer) — actually a MockTwilioMediaStreamsServer test double firing synthetic media frames; T4 (TrunkSession + trunk_driver); slice-3's MockRealtimeBrain.

  • Step 1: Write the integration test

The test:

  1. Stand up MockRealtimeBrain (slice-3 merged). URL = mock.url().
  2. Stand up the in-process MediaThread via MediaThread::spawn(default_tap_url, tokio_handle).
  3. Construct the wiring manually: spawn the WSS-pump-task-equivalent that simulates a Twilio "media" frame as a synthetic PcmFrame::loud() → push into inbound_from_twilio_tx.
  4. Send MediaCmd::RegisterTrunk { call_sid, inbound_from_twilio_rx, outbound_to_twilio_tx, tap_url: mock.url(), reply }.
  5. Drive ticks manually OR let the std thread run for ~200 ms.
  6. Assert: the brain received audio_in events (the loud frames forwarded through TapAudioPipe).
  7. Assert: slice-4's LocalVadReflex tripped → fired AdvisoryEvent::SpeechStartedReflex::muted became true → next_pcm_frame returned None → brain reply NOT sent this tick.
  8. Assert: after the brain sends its reply (mock's response schedule), next_pcm_frame returns Some → un-mute → brain reply pushed to outbound_to_twilio_tx.
  • Step 2: fmt + clippy + test + commit
cargo fmt --all --check && cargo clippy --all --all-targets -- -D warnings && cargo test --all
git commit -s -m "test(trunk): reflex-on-trunk-leg verification — slice-4 Reflex<TapAudioPipe> decorates trunk TapAudioPipe identically (slice-5 T7)

The architectural load-bearing claim: slice-4's reflex loop is ingress-
agnostic. This test verifies that the slice-4 stack (Reflex<P> + LocalVadReflex
composition) instantiated against a trunk leg's TapAudioPipe behaves
IDENTICALLY to a WebRTC leg — local VAD trips on caller speech, barge kills
playout, brain reply post-barge un-mutes.

Same code path; different inner leg-kind. If barge-in somehow needed a
'trunk-specific' code path here, that would be an architectural smell that
needs PM input — not a silent fork."

Task T8: PSTN sim e2e integration test

Files:

  • Create: crates/rutster-trunk/tests/sim_integ.rs

  • Test: end-to-end integration test: MockTwilioMediaStreamsServer (the in-process simulator) drives a synthetic PSTN caller through the FOB reflex loop.

  • Step 1: Author the in-process Twilio simulator

A MockTwilioMediaStreamsServer test double that mimics Twilio's WSS protocol in-process:

  • Spawn a tokio task that, given a Vec<PcmFrame> "caller-side script," pushes them as synthetic inbound PcmFrames into a TrunkSession's inbound_from_twilio_rx (skipping the WSS + base64

    • JSON parsing — pure mpsc).
  • Receive frames from outbound_to_twilio_tx (the FOB's brain reply) into a captured queue for assertions.

  • Step 2: Author the e2e test

  1. Start MockRealtimeBrain (slice-3). URL = mock.url().
  2. Start in-process MediaThread.
  3. Construct MockTwilioMediaStreamsServer against the MediaThread's register_tx.
  4. Drive a synthetic PSTN caller: loud PCM frames → barge-in should fire → brain yields → brain reply observed → caller hangup → session torn down.
  5. Assert EventSink (slice-5/seams) emitted ChannelEnded with the wall-clock started_at.
  • Step 3: fmt + clippy + test + commit
cargo fmt --all --check && cargo clippy --all --all-targets -- -D warnings && cargo test --all
git commit -s -m "test(trunk): PSTN sim e2e integration — synthetic caller through FOB reflex loop (slice-5 T8)

MockCallControlClient + MockTwilioMediaStreamsServer drive a synthetic PSTN
caller: loud PCM frames → local VAD trips → barge-in kills playout → brain
yields → brain reply observed → un-mute → caller hangup → session torn down +
EventSink emission of ChannelEnded with wall-clock started_at (slice-5/seams).

The full slice-1→slice-5 spearhead's worth of wiring exercised end-to-end,
against a synthetic PSTN caller (the same path a real Twilio call would
take modulo the actual Twilio cloud)."

Task T9: QUICKSTART + README updates (filler; any time after T5 + T6 land)

Files:

  • Modify: docs/QUICKSTART.md

  • Modify: README.md

  • Step 1: Add env-var table to QUICKSTART

### Twilio credentials (for the real phone number demo, optional)

Set these to enable PSTN ingress via Twilio Media Streams:

| Env var | Purpose | Example |
|---|---|---|
| `RUTSTER_TWILIO_ACCOUNT_SID` | Twilio account ID | `ACxxx...` |
| `RUTSTER_TWILIO_AUTH_TOKEN` | Twilio auth token (secret) | `xxx...` |
| `RUTSTER_TWILIO_MEDIA_BIND` | Where Twilio Media Streams WSS binds | `0.0.0.0:8081` |
| `RUTSTER_TWILIO_WEBHOOK_BASE` | Twilio's webhook target (your binary) | `https://your.public.host` |

Without these set, the binary runs WebRTC-only (slice-1-3 ingress + slice-4
barge-in + slice-4½ sim). With them set + `--features=twilio-live`, the
binary accepts PSTN fork calls against `/twilio/media-stream`.
  • Step 2: Add "make a real phone call" section to QUICKSTART

  • Step 3: Update README spearhead status line

> **Status:** Slices 14½ + step 5 (WebRTC media core, WS tap, OpenAI Realtime
> brain, barge-in, sim/benchmark harness, and PSTN via rented Twilio Media
> Streams transport) are merged to `main`. Spearhead step 6 (spend cap) is the
> remaining step. ADR-0007 honored: rutster parses zero SIP bytes.
  • Step 4: fmt + test + commit
cargo fmt --all --check && cargo test --all
git commit -s -m "docs: QUICKSTART env table + 'make a real phone call' walkthrough + README status update (slice-5 T9)

README's spearhead list now reflects: slices 1, 2, 3, 4, 4½, 5 merged. The
quickstart shows how to set RUTSTER_TWILIO_* env vars + run
cargo run --features=twilio-live to take a real phone call via Twilio Media
Streams. ADR-0007 honored: rutster parses zero SIP bytes."

Task T10: CI seam gate re-pin + cargo deny

Files:

  • Modify: .github/workflows/ci.yml (re-pin the seam hashes IF iptables/rtc_session was touched — but this slice's invariant is "untouched," so the pins stay the same; verify)

  • Modify: deny.toml (if cargo deny check flags any new license from reqwest/base64/async-trait transitive deps)

  • Final sweep per AGENTS.md CI gates.

  • Step 1: Verify seam gate stays unchanged

git rev-parse main:crates/rutster-media/src/loop_driver.rs
git rev-parse main:crates/rutster-media/src/rtc_session.rs

Confirm the two pinned blob hashes in .github/workflows/ci.yml (slice-4 Task 10) are unchanged. If the hashes DO match, this slice's seam gate holds with NO CI YAML modification. If they DO NOT match (the dev-c implementer accidentally touched the seam files), STOP + emit a ## QUESTION block to the PM; the dev must git checkout main -- crates/rutster-media/src/loop_driver.rs crates/rutster-media/src/rtc_session.rs to restore them.

  • Step 2: Run cargo deny check + fix any flags from new deps
cargo deny check
# If a license/ban/source advisory flags reqwest/base64/async-trait transitive dep,
# either add it to the allowlist in deny.toml with rationale OR
# substitute a different crate from the workspace's already-admitted set.
  • Step 3: Add the twilio-live CI job (manual-trigger only)
  twilio-live:
    name: twilio-live (manual only)
    runs-on: ubuntu-latest
    if: github.event_name == 'workflow_dispatch'
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - uses: Swatinem/rust-cache@v2
      - name: Install libopus
        run: sudo apt-get update && sudo apt-get install -y libopus-dev
      - name: Run twilio-live tests
        env:
          RUTSTER_TWILIO_ACCOUNT_SID: ${{ secrets.TWILIO_ACCOUNT_SID }}
          RUTSTER_TWILIO_AUTH_TOKEN: ${{ secrets.TWILIO_AUTH_TOKEN }}
          RUTSTER_TWILIO_MEDIA_BIND: 0.0.0.0:8081
          RUTSTER_TWILIO_WEBHOOK_BASE: ${{ secrets.TWILIO_WEBHOOK_BASE }}
        run: cargo test --all --features=twilio-live -- --include-ignored

The maintainer triggers this before a release.

  • Step 4: Final full sweep
cargo fmt --all --check
cargo clippy --all --all-targets -- -D warnings
cargo clippy --all --all-targets --features=twilio-live -- -D warnings
cargo test --all
cargo deny check
cargo doc --no-deps
  • Step 5: Commit
git add .github/workflows/ci.yml deny.toml
git commit -s -m "ci(trunk): twilio-live manual-trigger job + cargo deny recheck (slice-5 T10)

The routine CI gate stays feature-default-off: MockCallControlClient is
the per-PR integration test surface. A new twilio-live job runs ONLY on
manual workflow_dispatch (maintainer triggers pre-release) — exercises the
real TwilioCallControlClient against live Twilio. cargo deny check passes
for the new reqwest/base64/async-trait transitive deps.

The seam gate (loop_driver.rs + rtc_session.rs pinned blob hashes from
slice-4 Task 10) is verified UNCHANGED by this slice — the trunk leg's
tick lives entirely in rutster-trunk/src/loop_driver.rs, a separate file
in a separate crate."

Final acceptance checklist

After all 10 tasks merge:

  • cargo fmt --check, cargo clippy --all --all-targets -- -D warnings (both default + --features=twilio-live), cargo test --all, cargo deny check, cargo doc --no-deps all clean (stable + 1.85).
  • Seam gate unchanged: loop_driver.rs + rtc_session.rs byte-identical to slice-3/slice-4 (CI pinned-blob from slice-4 Task 10).
  • Slice-4's Reflex<TapAudioPipe> + LocalVadReflex (REUSED — no new instances created) decorate a trunk leg's TapAudioPipe identically (T7 verification test green).
  • PSTN-sim e2e (T8) drives a synthetic caller through the FOB reflex loop: barge-in fires, brain reply observed, CDR/EventSink emits ChannelEnded.
  • QUICKSTART + README updated; env-var table renders in cargo doc.
  • The maintainer (user) verifies the live e2e by manually running cargo test --features=twilio-live against real Twilio credentials once.
  • PR bisected + readable: each task = one commit (squash-merge preserves the strategy).
  • PR opened via tea pulls create --head slice-5/rented-transport-dev-{b,c} --base main --title "slice-5 (rented transport): a real phone number via Twilio Media Streams (T1-T10)" --description "<full §7 done-criteria from spec>".
  • Do NOT merge the PR — the maintainer (user) merges after the live Twilio e2e is verified.
  • ADR-0010 deviation note considered for the PR description (per the strategic plan §1.2 fork) — "this slice precedes rung-2 escalation per the user's 2026-07-05 directive; ADR-0010 remains intact; the deviation is recorded in .omo/plans/2026-07-05-spearhead-4half-and- step-5-strategic.md §1.2."