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

586 lines
32 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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](../../adr/0002-north-star-and-fused-core.md) (fused vertical),
[ADR-0003](../../adr/0003-sip-rust-native-trunk.md) (Rust-native stack stance),
[ADR-0004](../../adr/0004-license.md) (GPL-3.0-or-later),
[ADR-0006](../../adr/0006-ingress-posture.md) (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 pair** — `opus` 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 `PcmFrame`s emerge from the inbound side and where `PcmFrame`s
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:
```rust
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
```rust
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 `thiserror``crates/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
- [README.md](../../../README.md) — north star, capability ladder
- [ARCHITECTURE.md](../../ARCHITECTURE.md) — fused per-call vertical, agent tap
- [PORT_PLAN.md](../../PORT_PLAN.md) — capability checklist + thin-slice phasing
- [Vision-revision spec](2026-06-26-vision-revision-design.md) — the pressure-test
that produced the architecture this slice implements
- [ADR-0002](../../adr/0002-north-star-and-fused-core.md) — fused vertical
- [ADR-0003](../../adr/0003-sip-rust-native-trunk.md) — Rust-native stack stance
- [ADR-0004](../../adr/0004-license.md) — GPL-3.0-or-later
- [ADR-0006](../../adr/0006-ingress-posture.md) — WebRTC-first ingress