Co-authored-by: Aaron D. Lee <himself@adlee.work> Co-committed-by: Aaron D. Lee <himself@adlee.work>
52 KiB
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) orsuperpowers:executing-plansto 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.rsstay byte-identical (CI pinned-blob from slice-4 Task 10 continuously enforced). The new trunk driver lives incrates/rutster-trunk/src/loop_driver.rs— a parallel-titled file in a different crate. TheMediaLegmatch-arm that callsloop_driver::drive(s, now)for WebRTC legs is one new line incrates/rutster/src/media_thread.rs(the existing call toloop_driver::driveroutes through it); theMediaLeg::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." Nounwrap()/expect()outside tests/const-init. - Credential isolation (ADR-0009, load-bearing):
TwilioCredentialslives ONLY incrates/rutster-trunk/. NOT re-exported through the workspace. NOT inrutster-media's public API. NOT inrutster-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 fmtis the single whitespace source of truth.clippy -D warningsis the lint bar. Newtype wrappers per AGENTS.md where applicable (none intrinsically needed this slice —CallSid: Stringis operational correlation, not a type-safety boundary). - Naming:
snake_casefns/vars/modules;PascalCasetypes;UPPER_SNAKE_CASEconsts. - 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 viatea.
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-1–4 + 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.
CallControlClienttrait +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+MediaLegenum + the two new HTTP routes. - T6 — depends on T2.
TwilioCallControlClient(live REST impl behindtwilio-livefeature)- 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
reqwestdep). 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(addpub mod g711;) - Test: inline
#[cfg(test)] mod testsing711.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]) -> PcmFramepub fn G711Codec::encode_pcm_to_mulaw(frame: &PcmFrame) -> Vec<u8>
-
Step 1: Author the
Cargo.tomlwith 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.rswithdecode_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 inprovider/mod.rs(NOT re-exported through the workspace; ADR-0009). TheTwilioCallControlClientconsumes 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 testscovering 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(axumgetWebSocket 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_streamasync loop
Pseudocode:
- Wait for first WS message. Expect
connectedenvelope; ignore. - Read
startenvelope. Extract CallSid + StreamSid. Construct the two mpsc pairs. - Send
RegisterTrunkInboundChannelto register_tx. AwaitMediaThread'sRegisterTrunkreply (a oneshot confirming the ChannelId). - Loop:
a. Read next WS message:
media: base64-decode,G711Codec::decode_mulaw_to_pcm→PcmFrame, push toinbound_tx.stop(or WS close): break. b. Concurrently drainoutbound_rx(PcmFrame produced by the std thread's TrunkSession viatrunk_driver::drive):G711Codec::encode_pcm_to_mulaw→ wrap in a JSONmediaenvelope → send WS Text frame.
- On stop / close: send
MediaCmd::Deletefor 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.rsexercising the tick
Interfaces:
-
Consumes: slice-4
Reflex<P>+LocalVadReflex<P>+ReflexMetrics(REUSED), slice-2TapAudioPipe(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:
- Drain
inbound_from_twilio_rxviatry_recv→ for eachPcmFrame:session.pipe.on_pcm_frame(frame)(hitsLocalVadReflexfirst →Reflex→TapAudioPipe→tx_pcm_in→ TapEngine → brain WS asaudio_in). - Outbound encode tick (every 20 ms): pull
session.pipe.next_pcm_frame()→ push tooutbound_to_twilio_tx(try_send; drop + observe on full). - 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 theRegisterTrunkInboundChanneltype is publicly surfaced) - Test: inline in
crates/rutster/src/routes.rsfor 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, inmedia_thread.rs.POST /v1/trunk/sessionsaxum route — originates an outbound call.POST /v1/trunk/webhookaxum route — Twilio's inbound-call webhook receiver.
-
Step 1: Add the
MediaLegenum +RegisterTrunkvariant + 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
RegisterTrunkhandler in the std thread loop
When RegisterTrunk arrives:
- Construct
tap_url(operator-configured brain WS URL e.g.ws://localhost:8082for MockRealtimeBrain). - Construct
(advisory_tx, advisory_rx) = mpsc::channel(16)+metrics = ReflexMetrics::new(). let (tap_pipe, tap_conn) = spawn_tap_engine(channel_id, tap_url, advisory_tx)— REUSED from slice-4 Task 5.let reflex = Reflex::new(tap_pipe, advisory_rx, metrics.clone());let wrapped = LocalVadReflex::new(reflex, advisory_tx_clone);(whereadvisory_tx_cloneis the second sender; advisory_tx has TWO senders — the engine + the local VAD).- 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 }. - Insert into
session_map.insert(channel_id, MediaLeg::Trunk(session)). - Spawn the TapEngine tokio task (held by
tap_conn). - Reply with
channel_idvia 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 inmain.rs
In crates/rutster/src/main.rs:
-
Allocate a
tokio::sync::mpsc::Sender<RegisterTrunkInboundChannel>pair (cap 64). -
Pass the
Senderend intoTwilioMediaStreamsServer::router(...). -
The
Receiverend runs in a tokio task: when aRegisterTrunkInboundChannelarrives, it relays the contents into aMediaCmd::RegisterTrunkenvelope + awaits the reply, then ACKs the WSS pump task. -
Mount the router's
/twilio/media-streamroute 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 }— callCallControlClient::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: Tests —
route_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-exportTwilioCallControlClientONLY whentwilio-livefeature is enabled) - Modify:
crates/rutster/src/config.rs(addpub fn twilio_credentials() -> Option<TwilioCredentials>) - Test: live e2e test gated behind
twilio-livefeature — runs once on maintainer's machine via a manualcargo 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 TwilioCallControlClientpub fn twilio_credentials() -> Option<TwilioCredentials>incrates/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
MockTwilioMediaStreamsServertest double firing synthetic media frames; T4 (TrunkSession + trunk_driver); slice-3's MockRealtimeBrain. -
Step 1: Write the integration test
The test:
- Stand up MockRealtimeBrain (slice-3 merged). URL =
mock.url(). - Stand up the in-process MediaThread via
MediaThread::spawn(default_tap_url, tokio_handle). - Construct the wiring manually: spawn the WSS-pump-task-equivalent that simulates a Twilio
"media" frame as a synthetic
PcmFrame::loud()→ push intoinbound_from_twilio_tx. - Send
MediaCmd::RegisterTrunk { call_sid, inbound_from_twilio_rx, outbound_to_twilio_tx, tap_url: mock.url(), reply }. - Drive ticks manually OR let the std thread run for ~200 ms.
- Assert: the brain received
audio_inevents (the loud frames forwarded through TapAudioPipe). - Assert: slice-4's
LocalVadReflextripped → firedAdvisoryEvent::SpeechStarted→Reflex::mutedbecame true →next_pcm_framereturned None → brain reply NOT sent this tick. - Assert: after the brain sends its reply (mock's response schedule),
next_pcm_framereturns Some → un-mute → brain reply pushed tooutbound_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 inboundPcmFrames into a TrunkSession'sinbound_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
- Start MockRealtimeBrain (slice-3). URL =
mock.url(). - Start in-process MediaThread.
- Construct
MockTwilioMediaStreamsServeragainst the MediaThread'sregister_tx. - Drive a synthetic PSTN caller: loud PCM frames → barge-in should fire → brain yields → brain reply observed → caller hangup → session torn down.
- Assert
EventSink(slice-5/seams) emittedChannelEndedwith the wall-clockstarted_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 1–4½ + 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(ifcargo deny checkflags any new license fromreqwest/base64/async-traittransitive 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-liveCI 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-depsall clean (stable + 1.85).- Seam gate unchanged:
loop_driver.rs+rtc_session.rsbyte-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-liveagainst 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."