Files
rutster/docs/superpowers/specs/2026-06-28-slice-1-webrtc-loopback-design.md
adlee-was-taken 1164770d98 spec(slice-1): adversarial review fixes
API-surface corrections (verified against str0m 0.21 docs):
- str0m::Live -> str0m::Rtc (no Live type exists)
- needs_input_timeout() -> Rtc::poll_output() returning Output::Timeout(Instant)
- RtcConfig::with_dtls_cert -> RtcConfig::set_dtls_cert (also: str0m auto-gens
  a cert if none passed; slice 1 still passes explicitly for ownership clarity)
- Event::RtpPacket(pkt, rid) -> Event::RtpPacket(RtpPacket) { single struct,
  RID is a field }
- str0m::ice::IceAgent -> no public 'ice' module; ICE types at crate root:
  Candidate, CandidateKind, IceCreds, IceConnectionState
- str0m.write_rtp() on Rtclive -> Frame API path: Rtc::writer(mid) -> Writer::write

New design decision surfaced by API verification:
- Media API path: Frame API (default) vs RTP API (set_rtp_mode(true)).
  Slice 1 uses Frame API — the proof target is the codec-to-PCM boundary,
  not RTP packetization. RTP API is a candidate for step 5.

Design-level fixes from close reading:
- §3.3 AudioSource/AudioSink: clarify who drives the traits (the poll loop
  drives both; sink on inbound MediaData, source on Output::Timeout deadline)
- §3.6: clarify cert/CrossRef — fresh in-memory cert each restart is safe
  because browser fetches fresh SDP each session, no caching layer to go stale
- §4.1: add Content-Type: application/sdp to /offer (browser-side fetch needs
  it)
- §4.5: resolve 5-min-vs-60s idle-timeout contradiction (60s, not 5 min)
- §4.5: add graceful-shutdown story (drop DashMap on SIGINT, browsers see
  dead peer — acceptable for dev loop)
- §6.4: fix 'mocked str0m Rtc' — str0m's Rtc is a concrete struct, not a
  trait; sans-IO means drive real Rtc with synthetic Inputs, not mock it.
  Stronger than mocking: production path exercised exactly.

Resolves 5 load-bearing API errors + 3 design-level ambiguities. Spec is now
implementation-ready pending user review.
2026-06-28 10:41:18 -04:00

32 KiB
Raw Blame History

Rutster slice 1 — WebRTC media loopback: the media-core proof

  • Status: Draft (pending review)
  • Date: 2026-06-28
  • Spearhead step: 1 of 6 (vision-revision §10 / PORT_PLAN "Phasing")
  • Origin: brainstorming session 2026-06-28
  • Related: ADR-0002 (fused vertical), ADR-0003 (Rust-native stack stance), ADR-0004 (GPL-3.0-or-later), ADR-0006 (WebRTC-first ingress)

TL;DR

Stand up the Rutster Rust workspace and implement spearhead step 1: a browser talks WebRTC to the core; the core terminates DTLS-SRTP, decodes Opus → canonical 16-bit PCM @24 kHz mono, and echoes the PCM back through Opus → DTLS-SRTP to the browser. The user speaks and hears themselves back with no perceptible delay.

Slice 1 proves the media core: RTP/SRTP termination, codec-to-PCM boundary, the canonical PCM frame, and the sans-IO polling posture. It deliberately omits the tap, the brain, barge-in, the trunk, and the spend cap — steps 26 of the spearhead — though it pre-paves the tap by exposing the PCM boundary as a clean trait seam.


1. Scope

1.1 In scope

  • Cargo workspace scaffold shaped to ADR-0002's fused per-call vertical.
  • Implementation of spearhead step 1: WebRTC media loopback with Opus⇄PCM termination.
  • One real-but-minimal crate (rutster-call-model): the Channel leg object embryo.
  • Three stub crates (rutster-signaling-sip, rutster-tap, rutster-spend) that lock future boundaries without anticipating code.
  • axum-based signaling server + a self-contained browser test client.
  • cargo-deny, CI workflow, thorough learner-facing code comments.

1.2 Out of scope (with scheduled return)

Deferred item Returns in Why deferred
Dedicated timing thread for the 20 ms media loop Spearhead step 4 (barge-in) ARCHITECTURE.md mandates "never the shared tokio pool"; honored once a reflex needs determinism. Slice 1 has no reflex to time against — a dedicated thread now would be theater.
TLS on the HTTP signaling surface Spearhead step 5 (PSTN trunk) Dev loop is loopback-only; the real security surface (DTLS-SRTP media) is already mandatory. TLS needs a cert story (Vault/KMS per ARCHITECTURE.md) with no home in slice 1.
Authn / authz / multi-tenancy on /v1/sessions Spearhead step 6 (spend cap) The boundary's auth posture lands when the spend gate does; together they constitute the trust boundary.
Trickle ICE When real-world NATs demand it (likely step 5) Non-trickle (one POST with offer+candidates, one response with answer+candidates) suffices for local loopback and keeps the dev loop zero-dependency.
The tap itself (audio routing to an external echo process) Spearhead step 2 Slice 1 pre-paves the tap by exposing the PCM boundary as AudioSource/AudioSink traits in rutster-media; step 2 implements the WSS tap client behind that seam.
The brain (STT/LLM/TTS) Spearhead step 3 Slice 1 echoes; step 2 swaps echo for an external process, step 3 swaps echo-process for a real brain.
Barge-in / VAD-driven playout kill Spearhead step 4 No reflex to enforce yet; no VAD even on the inbound side.
PSTN trunk (SIP client) Spearhead step 5 WebRTC-only ingress in slice 1; ADR-0003's Rust-native trunk SIP lands with the trunk integration.
Spend cap / abuse gate Spearhead step 6 No trunk yet to gate spend against.
CDR emission, event bus, OTel traces beyond the per-channel tracing span Later rungs PORT_PLAN keeps these as services around the core; slice 1 has one peer, one channel, no fanout needed.
Transfer / park / pickup / barge (call features) Escalation rung 2 Channel is shaped to accept Option<MediaLeg>, audiohooks: Vec<AudiohookHandle> by addition, so these slot in without breaking slice 1. High on post-slice-1 list.
Browser-based automated e2e test (Selenium/Playwright) Post-slice-1 when latency matters Would balloon the dev loop for a thin-slice proof. Manual test plan documents success criteria instead.
Latency benchmarking harness Spearhead step 4 Latency matters when barge-in needs to beat the brain round-trip; slice 1's bar is "no perceptible delay" (~≤200 ms), not sub-10 ms.
Fuzz harnesses for wire parsers Spearhead step 5 (SIP/SDP/RTP) PORT_PLAN §10 mandates continuous fuzzing of every wire parser; slice 1 has no hostile-bytes surface (browser is trusted) and the fuzz story lands with the SIP trunk. A fuzz/ placeholder dir pre-paves the layout.
Resumability / re-INVITE / session migration Later Refresh the page → new session. Acceptable for dev loop.

1.3 What this slice does NOT prove

It does not prove: barge-in, latency determinism, the tap interface itself (only its seam), PSTN trunking, or spend control. It proves only the media-core termination + the codec-to-PCM boundary. Every "is X done?" question is answered by the out-of-scope table above.


2. Workspace layout

Cargo workspace at the repo root. One binary crate and five library crates shaped to the ADR-0002 fused vertical. Every crate manifest sets license = "GPL-3.0-or-later" and carries an SPDX header (ADR-0004).

rutster/
├── Cargo.toml                 # [workspace], shared deps via [workspace.dependencies]
├── deny.toml                  # cargo-deny config: licenses, advisories, bans, sources
├── rust-toolchain.toml         # pinned stable; MSRV confirmed against str0m at impl time
├── LEARNING.md                # index of "to learn concept X, read file Y"
├── .github/workflows/ci.yml   # fmt, clippy -D warnings, test --all, cargo deny check
├── crates/
│   ├── rutster/               # binary: axum signaling server + media driver + static page
│   ├── rutster-media/         # REAL slice-1 code: str0m WebRTC + Opus⇄PCM boundary
│   ├── rutster-call-model/    # REAL-but-minimal: the Channel/Leg object embryo
│   ├── rutster-signaling-sip/ # STUB: doc comment + compile test (step 5 fills in)
│   ├── rutster-tap/           # STUB: doc comment + compile test (step 2 fills in)
│   └── rutster-spend/         # STUB: doc comment + compile test (step 6 fills in)
└── fuzz/                      # placeholder cargo-fuzz harness dir (real harnesses: step 5)

2.1 Workspace dependency pattern

[workspace.dependencies] in the root manifest pins versions; member crates reference them with dep.workspace = true. Keeps versions unified as crates fill in and prevents accidental version drift caught late.

2.2 Stub crate policy

rutster-signaling-sip, rutster-tap, rutster-spend each ship as:

  • lib.rs with a //! module doc comment: what the crate will hold, why it's deferred, and which spearhead step fills it in.
  • A #[cfg(test)] mod tests { #[test] fn crate_compiles() {} } so CI exercises them.

Stubs do not anticipate code. They lock the boundary shape; that is their only job.

2.3 Dependency direction

  • rutster (binary) depends on rutster-media, rutster-call-model.
  • rutster-media depends on rutster-call-model (the ChannelId / Channel types).
  • rutster-call-model depends on nothing in the workspace (leaf).
  • rutster-tap, rutster-spend, rutster-signaling-sip depend on nothing in slice 1 (their future dependency direction is documented in their //! comments).

3. Media core (rutster-media)

3.1 Components

  • RtcSession — owns a str0m::Rtc instance (the main sans-IO driver type; str0m has no Live struct) plus the per-peer codec state (an Opus decoder + an Opus encoder). One per WebRTC peer.
  • PcmFrame — the canonical tap format from ARCHITECTURE.md: 16-bit signed mono PCM @ 24 kHz, fixed frame size (20 ms = 480 samples). This is the single format every future brain/tap consumer speaks. Defined here in slice 1; rutster-tap will re-export it once that crate fills in (step 2 — keeps one canonical home).
  • Codec pairopus crate (libopus via FFI; PORT_PLAN §7 disposition for Opus is 🦀 Core (FFI)). Decoder: opus_decode → fills a PcmFrame; Encoder: opus_encode from ring-buffered PCM → Opus payload for str0m's Frame API.

3.2 Loop shape (Approach A from brainstorming)

str0m exposes two API surfaces: the Frame API (default; str0m handles packetization internally, applications push/pop decoded media frames) and the RTP API (opt-in via RtcConfig::set_rtp_mode(true); raw RTP packets, the app does packetization). Slice 1 uses the Frame API because slice 1's goal is to prove the codec-to-PCM boundary, not to prove RTP packetization. The Frame API gives us decoded audio frames as MediaData and accepts encoded audio back; str0m does the RTP/SRTP framing underneath. RTP API is a candidate for step 5 (PSTN trunk) where raw-packet control over the SIP/RTP boundary may matter.

str0m Rtc (sans-IO, polled on tokio — see §3.4)
  ↓ Event::RtpPacket(RtpPacket) { …, payload }   (single struct; RID is a field if present)
  ↓ str0m delivers decoded audio as MediaData via the Frame API
decode Opus payload → PcmFrame (24 kHz mono, 20 ms)
  ↓
buffer PCM into playout ring (slice 1: echo; step 2 taps here)
  ↓ on each 20 ms tick
take 480 samples → encode Opus → push to str0m via Rtc::writer(mid)->Writer::write

API-surface notes (verified against str0m 0.21 docs):

  • str0m::Rtc is the main sans-IO driver; there is no Live type. Rtc::handle_input(Input) feeds network input; Rtc::poll_output() -> Output is the poll interface, where Output::Timeout(Instant) gives the next-deadline signal we sleep tokio until.
  • Event::RtpPacket(RtpPacket) is a single-struct variant (RID is a field on RtpPacket, not a second variant payload).
  • Inbound/Event::RtpPacket: in RTP mode you get raw packets; in slice 1's Frame-mode the canonical inbound event is Event::MediaData(MediaData) containing decoded media.
  • Outbound: StreamTx::write_rtp exists in RTP mode only; under the Frame API the path is Rtc::writer(mid) -> Writer::write(...).

3.3 The PCM-tap seam

The point where decoded PcmFrames emerge from the inbound side and where PcmFrames get enqueued on the outbound side is the exact point step 2 splices the tap. Slice 1 echoes both ends together; the seam is made explicit via two traits in rutster-media, not buried inside RtcSession internals:

pub trait AudioSource: Send + Sync {
    /// Take the next PCM frame to send to the peer. None = silence.
    fn next_pcm_frame(&mut self) -> Option<PcmFrame>;
}

pub trait AudioSink: Send + Sync {
    /// Receive a decoded PCM frame from the peer. Must not block.
    fn on_pcm_frame(&mut self, frame: PcmFrame);
}

Slice 1 wires an EchoAudioPipe (implements both traits) between sink and source. Step 2 replaces EchoAudioPipe with a real tap client. No code changes to RtcSession itself in step 2 — that's the test of the seam.

Who drives the traits: the poll loop (§3.4) drives both. On each poll_output() cycle: inbound Event::MediaData → decode → sink.on_pcm_frame(PcmFrame); the outbound side runs on the Output::Timeout(Instant) deadline → source.next_pcm_frame() → encode → push to str0m. RtcSession owns the Arc<Mutex<dyn AudioSource + AudioSink>> (or a concrete EchoAudioPipe in slice 1 — no trait object needed yet; step 2 introduces indirection when a real tap client replaces the pipe).

3.4 Polling & the tokio-vs-dedicated-thread deviation

str0m is sans-IO; its Rtc API exposes handle_input(Input) (feed network input) and poll_output() -> Output (the poll interface, where Output::Timeout(Instant) gives the next deadline we sleep tokio until). Slice 1 runs this poll on the tokio runtime.

This is an explicit, documented deviation from ARCHITECTURE.md, which mandates dedicated timing threads, never the shared tokio pool. The deviation is scoped to slice 1 only and is acceptable because:

  1. Slice 1 has no reflex to time against (no VAD, no barge-in).
  2. A dedicated timing thread now would be theater — there is nothing whose determinism needs defending.
  3. Step 4 (barge-in) is the scheduled landing point for the dedicated thread. It is listed in the out-of-scope table. The code shape (a single poll function called from a tokio task) makes the step-4 swap to a dedicated thread a localized change, not a re-architecture.

The deviation is called out in code (// DEV-DEVIATION: tokio polling accepted for slice 1; step 4 replaces with dedicated timing thread per ARCHITECTURE.md.) and in this spec so it cannot be forgotten.

3.5 SRTP / DTLS

DTLS-SRTP is mandatory from slice 1 (Security-as-product pillar). str0m handles DTLS+ SRTP natively; configured via RtcConfig::set_dtls_cert at startup. If no cert is passed, str0m auto-generates one — in slice 1 we explicitly pass our self-signed DTLS cert (see §3.6) to make the cert ownership clear, but the auto-gen path is also acceptable for a dev loop.

No plaintext RTP fallback path exists in code or config. This is a deliberate floor, not a default.

3.6 DTLS certificate

Self-signed DTLS cert generated at startup, held in memory, passed explicitly to RtcConfig::set_dtls_cert (per §3.5 — making cert ownership clear in code rather than relying on str0m's auto-gen default). No cert file on disk in slice 1 (cert rotation / persistence is a step-5 / trunk-integration concern). The cert is regenerated on every server restart — acceptable for slice 1 because the browser fetches a fresh SDP answer each session, so the DTLS fingerprint in the SDP always matches the current cert. No caching layer exists to go stale.

3.7 Codec negotiation

Answer-only (browser initiates the offer). We advertise Opus in SDP for our single audio m-line; reject video m-lines.

The slice's SDP answer is the embryo of the future SIP SDP path (PORT_PLAN §1 res_pjsip_session + _sdp_rtp rows). The SDP code lives in rutster-media as a private module (not in rutster-signaling-sip) because the SDP we manipulate is WebRTC-ICE-coupled, not SIP-coupled. The future SIP/SDP negotiation lives in rutster-signaling-sip (step 5) and may extract shared code from this module at that point.

Slice 1's SDP module is a focused ~50-line mapper: parse the browser offer, extract the audio m-line, munge into our answer (Opus only, recv+send, DTLS fingerprint from our cert, ICE ufrag/pwd via str0m's IceCreds / Candidate types at the crate root — str0m has no public str0m::ice module; the ICE-relevant public surface lives at str0m::: Candidate, CandidateKind, IceCreds, IceConnectionState).

3.8 Error handling on the hot path

Decode/encode failures are logged + counted via a minimal metric counter. A dropped packet does not terminate the peer. Panic-on-bad-bytes is never the policy. The slice is short of the fuzzed-parser bar (see out-of-scope) but the posture is "drop + observe, don't crash" so the eventual fuzz harness has clean semantics to test against.

Hot-path errors use thiserror-derived error enums; the ? operator is used liberally on the cold path (signaling, setup) but never on the 20 ms loop itself (where the policy is match-and-continue).

3.9 Default PCM format choice

24 kHz mono, 16-bit signed. Reasons:

  • 24 kHz is a speech-model-friendly rate (matches Opus's typical wideband mode and common STT input expectations).
  • ARCHITECTURE.md names 24 kHz default, 16 kHz fallback — slice 1 ships the default only; the fallback is a future-rung concern when the first brain needs it.
  • Mono: telephony is mono. Stereo costs 2x for no contact-center value.

4. Signaling & browser ingress (rutster binary)

4.1 HTTP surface (slice 1)

  • POST /v1/sessions → mint a RtcSession (which owns a fresh Channel; the ChannelId is the session id). Returns { "session_id": "<uuid>" }.
  • POST /v1/sessions/:id/offer (body: browser SDP offer, Content-Type: application/sdp, including all ICE candidates — non-trickle) → core produces SDP answer (including its ICE candidates), feeds candidates to str0m, returns the answer as application/sdp.
  • DELETE /v1/sessions/:id → tear down: transition Channel to Closing → Closed, drop the RtcSession, close the peer connection cleanly via str0m.
  • GET / → serve the static HTML test client.

There is no separate /ice endpoint in slice 1. Non-trickle ICE bundles all candidates into the SDP offer/answer exchange, so one POST (/offer) carries everything. A separate /ice endpoint is a step-5 concern (trickle ICE — see the out-of-scope table).

4.2 ICE strategy

Non-trickle ICE. Browser gathers all candidates, sends offer+candidates in one POST, core returns answer+candidates in one response. One round-trip, simpler server.

Trickle ICE is deferred (see out-of-scope table) until real-world NATs demand it, likely with the PSTN trunk in step 5.

4.3 Binding & security posture

  • 0.0.0.0:8080 plaintext HTTP for the dev loop only. No TLS in slice 1 — see the out-of-scope rationale.
  • No authn/authz on /v1/sessions in slice 1. No multi-tenancy. Both land with the spend-gate step (step 6) and a real deployment posture.
  • Access-Control-Allow-Origin: * on the static page only; the API is same-origin by virtue of being served from the same host. (No CORS preflight needed because the test client is same-origin.)

4.4 Browser test client (GET /)

A single self-contained HTML file with inline JS, no build step. Behavior:

  1. getUserMedia({ audio: true }).
  2. new RTCPeerConnection with a STUN config of [] (host candidates only — no STUN server needed for local loopback, keeping the dev loop zero-dependency).
  3. Create offer, POST to /v1/sessions, get back the answer, set as remote.
  4. Play the echoed audio back via an <audio> element.
  5. Log ICE state + connection state to a <pre> for debugging.
  6. "Mute mic" toggle + "Hang up" button (latter POSTs to DELETE /v1/sessions/:id).

4.5 Session lifecycle

  • Sessions held in an in-process DashMap<ChannelId, RtcSession> in the binary crate. The ChannelId (a UUID newtype from rutster-call-model) is the session id surfaced in the REST API. RtcSession owns both the str0m Rtc + codecs and the Channel (signaling state); see §3.1 and §5.
  • Idle timeout: 60 s of no RTP packets received from the peer → close the session. (RTC quiet periods are normal but 60 s of dead air is a real "the browser tab is dead" signal —browser-refresh, network drop, etc. 5 min was originally considered but rejected as too long to hold dead state; 60 s is tight enough to reclaim resources promptly while tolerating natural inter-speech silences.) Implemented as a per-session deadline checked on each poll cycle; no per-session tokio task spawned (which would clutter the runtime and pre-pave the wrong pattern for the dedicated timing thread in step 4).
  • Graceful shutdown (Ctrl-C / SIGTERM): the slice installs a tokio signal handler that drops the DashMap (and thus every RtcSession) on shutdown. Browsers see a dead peer connection — acceptable for the dev loop; no in-flight call preservation story in slice 1. Cleanly closing each str0m Rtc is a nice-to-have — we attempt it but don't block shutdown on a peer that won't ack. POST/DELETE callers get TCP RST, also acceptable for dev-loop.
  • Restart on browser refresh: new session. No resumability.

4.6 API posture

The slice's REST shape is a small embryo of the future ARI-style API (PORT_PLAN §9 — "ARI becomes THE API"). We do not model it as ARI yet — that abstraction lands when there is a second resource type to model (queues, channels-as-resources, etc.). Using /v1/ as the path prefix leaves room for that evolution without a future rename.


5. Call model (rutster-call-model)

Real-but-minimal. The loopback peer is a Channel/leg; this crate earns its existence in slice 1.

5.1 The Channel struct

pub struct Channel {
    pub id: ChannelId,           // UUID newtype for type-safety
    pub state: ChannelState,    // New | Connecting | Connected | Closing | Closed
    pub direction: Direction,    // Inbound (browser-initiated in slice 1)
    pub created_at: Instant,     // for the 5-min idle timeout
}

pub enum ChannelState { New, Connecting, Connected, Closing, Closed }
pub enum Direction { Inbound }  // Outbound lands with the dialer (later)

That's the whole crate. ~80 lines.

5.2 Why this is the Channel, not a throwaway peer type

The Channel is the object the future API exposes (PORT_PLAN §3 — "the unifying leg object"). Building a one-off LoopbackPeer for slice 1 and refactoring it into a Channel later is the exact failure mode the design rules warn against. A thin real Channel grows by addition:

  • media: Option<MediaLeg> — when the second consumer appears.
  • audiohooks: Vec<AudiohookHandle> — with the audiohook primitive (escalation rung 2).
  • tap: Option<TapHandle> — with step 2.

Each is a backwards-compatible field add. No slice-1 code is thrown away.

5.3 Crate boundary

rutster-call-model depends on nothing in the workspace (leaf). rutster-media depends on it (the ChannelId / Channel types). This keeps the call-model direction correct: media is a leaf concern of a Channel, not the reverse — matching ARCHITECTURE.md's framing of the call model as the unifying object.

5.4 State machine (slice 1, signaling-only)

New          (POST /v1/sessions creates it)
  → Connecting   (offer received, ICE gathering)
  → Connected    (ICE+DTLS connected, RTP flowing, audio echoing)
  → Closing      (DELETE /v1/sessions/:id or peerconnectionclose from browser)
  → Closed       (resources dropped, entry removed from DashMap)

The ChannelState enum is the signaling state machine embryo. No media-state machine (jitter/playout states are internal to rutster-media); that split matches "call model as the unifying object, media as an internal concern of a Channel."

5.5 Observability

State transitions emit a tracing span per ChannelId — the embryo of OTel cross-call tracing (PORT_PLAN §10). No event-bus emission in slice 1; events go to logs only. The bus client lands with step 5 (PSTN trunk), the first place fanout matters.

5.6 The Channel does NOT carry in slice 1

  • media: Option<MediaLeg> field (media is internal to RtcSession in rutster-media).
  • audiohooks: Vec<AudiohookHandle> (escalation rung 2).
  • tap: Option<TapHandle> (step 2).
  • Transfer/park/pickup (PORT_PLAN §3 call features — later rungs; explicitly noted as high-priority post-slice-1 work).
  • Stasis-app event subscriptions (PORT_PLAN §4 — land with the real API).
  • Multi-leg channels (single leg = single peer; multi-leg lands with bridging/mixing).
  • CDR emission (held by the recording/CDR service per PORT_PLAN §6 — explicitly out of the per-call boundary).

The split "Channel is signaling-state only; media is internal to rutster-media" is deliberate and is the answer to "where does media state live in slice 1." Media state gets pulled into the Channel only when a second consumer (the API, the tap, the audiohook primitive) needs to observe it — not before.


6. CI, dev loop, testing

6.1 deny.toml (cargo-deny config)

  • Licenses: allow GPL-3.0-or-later (ours), MIT, Apache-2.0, BSD-3-Clause, ISC, Zlib, Unicode-DFS-2016, Unicode-3.0. Final list confirmed at implementation time by running cargo deny check licenses after the first cargo fetch; adjusted to whatever str0m/opus/axum actually pull in.
  • Advisories: deny warnings — vulnerabilities fail CI.
  • Bans: no multiple-versions of tokio, serde, bytes, tracing (catches accidental dep-tree divergence early).
  • Sources: crates.io only. No git deps. Keeps the build reproducible (a PORT_PLAN supply-chain goal).

6.2 CI (.github/workflows/ci.yml)

  • Matrix: latest stable + MSRV (pinned in rust-toolchain.toml, exact version confirmed against str0m at impl time).
  • Steps: cargo fmt --check, cargo clippy -- -D warnings, cargo test --all, cargo deny check.
  • Runs on push + PR to main.
  • No coverage gate in slice 1. No fuzzing CI yet (the fuzz/ dir is a placeholder; real harnesses land when wire parsers exist, per PORT_PLAN §10).

6.3 Dev loop

  • cargo run → starts axum on 0.0.0.0:8080, logs listening on http://0.0.0.0:8080.
  • Open browser to http://localhost:8080/ → click "Start call" → grant mic → hear yourself back.
  • RUST_LOG=rutster=debug cargo run for verbose tracing.
  • No docker, no compose, no external deps beyond Rust. The batteries-included compose up is a later-rung concern once there's a Valkey + trunk to compose.

6.4 Testing strategy

  • Unit tests in rutster-media:
    • Opus⇄PCM roundtrip: encode known PCM → decode → assert RMS within tolerance.
    • SDP answer munger: feed a captured browser offer fixture, assert the answer is well-formed and Opus-only.
    • RtcSession state transitions: drive a real str0m::Rtc instance with synthetic Input events (str0m's Rtc is a concrete struct, not a trait — there's nothing to mock. Sans-IO means we drive the real type with fake inputs, not a test double. This is the sans-IO payoff we chose in Approach A, and it's stronger than mocking: the production code path is exercised exactly.)
  • Integration test in rutster binary crate:
    • Spin up the axum server on an ephemeral port, use reqwest + a webrtc-rs client (or a minimal hand-rolled SDP answer parser) to simulate a peer.
    • This is also a great place for learner comments — the client-side WebRTC code is shorter than the server-side.
  • Manual e2e test plan (documented in README.md because no browser automation in slice 1):
    1. cargo run → server starts, logs ready.
    2. Browser to http://localhost:8080/ → mic prompt.
    3. Speak → hear yourself back within ~200 ms (no perceptible delay).
    4. Hang up via button → server logs ChannelState::Closing → Closed, session dropped.
    5. All unit + integration tests pass. cargo deny check clean. cargo clippy -D warnings clean.

6.5 Slice 1 "done" criteria

The slice is complete when, on a clean checkout:

  1. cargo test --all passes (unit + integration).
  2. cargo fmt --check passes.
  3. cargo clippy -- -D warnings passes.
  4. cargo deny check passes.
  5. cargo run, open http://localhost:8080/, speak, hear the echo within ~200 ms.
  6. Hang-up button triggers Closing → Closed in server logs.
  7. Every stub crate compiles and its doc-comment describes its scheduled step.
  8. LEARNING.md indexes at least five "to learn X, read Y" pointers.

7. Code documentation standard (learner-facing)

The user is learning Rust from this codebase. Thorough educational comments are a first-class requirement, not an afterthought. This overrides the default "no comments" convention.

  • Module-level doc comments (//!) at the top of every lib.rs / main.rs / sub-module: what the module does, why it exists in the architecture (cross-ref the relevant ADR / PORT_PLAN row), the key types a reader will meet.
  • Item docs (///) on every public struct, enum, fn, trait: purpose + a short example where non-obvious. These show up in cargo doc — a learner can run cargo doc --open and read the architecture.
  • Inline comments (//) on the mechanism, not the what:
    • Why Pin<Box<dyn Future>> instead of async fn.
    • Why Arc<Mutex<...>> vs Arc<RwLock<...>.
    • Why &[u8] instead of Vec<u8> in a function signature.
    • What PhantomData is doing.
    • Why an enum was chosen over a struct with a kind field.
    • The first time a non-obvious lifetime appears, why that lifetime is needed.
    • Aim: a Rust learner reads the comment and learns a specific Rust concept they wouldn't have inferred from the code alone.
  • str0m-specifics flagged: str0m's sans-IO / poll-based API is unusual. Every str0m interaction gets a comment explaining "here's what str0m is doing, here's why we drive it this way, here's what would change if this were a blocking-IO API."
  • Ownership / borrowing decisions called out: the first time each non-obvious ownership pattern appears, why it's needed.
  • Error-handling pattern documented once: a module-level comment in rutster-media explains the thiserror-based error-enum pattern, why ? over match, and the "drop + observe, don't crash" hot-path policy from §3.8.
  • LEARNING.md at repo root: a short index of "if you want to learn X in this codebase, read file Y" — e.g.:
    • Rust ownership of shared state → crates/rutster/src/session_map.rs
    • Sans-IO pattern → crates/rutster-media/src/loop_driver.rs
    • Trait design for extension points → the AudioSource/AudioSink traits in crates/rutster-media/src/tap.rs
    • Error enums with thiserrorcrates/rutster-media/src/error.rs
    • Newtype pattern → crates/rutster-call-model/src/lib.rs (ChannelId)

Trade-off acknowledged: more verbose code, slower to skim. Acceptable for slice 1 (educational value compounds — once patterns are established and the reader has learned them, later slices can be sparser on the well-trodden patterns).


8. Key design decisions (summary of the brainstorming session)

Decision Choice Rejected alternatives Why
Slice 1 scope Workspace scaffold + spearhead step 1 Single binary crate; steps 12; whole spearhead Proves the media core (the hardest thing) without piling on trunk/brain/reflex complexity. Each step its own proof.
WebRTC stack str0m webrtc-rs; defer-and-compare Sans-IO, Rust-native, designed for embedding in a custom media loop. Maps directly onto ARCHITECTURE.md's "dedicated timing threads, not the shared tokio pool." Smaller community but the right shape.
Workspace shape Full architecture-shaped workspace Workspace: media lib + binary; single binary crate Locks the ADR-0002 fused-vertical boundary from day one. Speculative crate boundaries risk not surviving real code, but stub crates cost ~zero and prevent a future rename churn.
Loopback scope + Terminate codec to PCM Bare RTP passthrough; audio-only echo Proves the canonical PCM tap format, not just RTP passthrough. Pre-paves step 2 (the tap).
Media-loop structure A. str0m Rtc + codec hook B. Full sans-IO decomposition; C. Opaque Peer + dedicated thread now Leans on str0m's pacing for slice 1; dedicated timing thread arrives in step 4 when barge-in needs determinism. Smallest code that proves the codec boundary.
Media API path str0m Frame API (default) RTP API (set_rtp_mode(true)) Frame API handles RTP packetization internally; slice 1's proof target is the codec-to-PCM boundary, not packetization. RTP API is a candidate for step 5 (PSTN trunk).
Poll execution tokio (slice 1 deviation) Dedicated thread now No reflex to time against; dedicated thread now is theater. Step 4 lands the dedicated thread. Explicit, documented deviation.
HTTP TLS None (dev-only plaintext) Self-signed TLS now Dev loop is loopback; the real security surface (DTLS-SRTP) is already mandatory. TLS needs the cert story from ARCHITECTURE.md, which lands with deployment posture.
ICE Non-trickle Trickle Simpler server; one round-trip. Real-world NATs (likely step 5) trigger the switch.
Channel media split Channel = signaling state; media internal to rutster-media Pull media state up into Channel from the start Split matches "call model as unifying object, media as leaf concern." Media state moves up only when a second consumer needs to observe it.
Test e2e Manual test plan; integration test uses webrtc-rs client Browser automation (Selenium/Playwright) Keeps the dev loop zero-dependency. Browser automation is step-4 territory when latency matters.

9. References