Apply findings from the Claude adversarial assessment (docs/reviews/2026-06-28-slice-1-claude-adversarial-assessment.md) after GLM-5.2 verification against the codebase + str0m 0.21 source. Spec-only patches (code fixes follow as atomic commits): - §1.2: add F5 (resource ceiling) to out-of-scope (deferred to step 5). - §3.4: pin both str0m inputs as mandatory (Input::Receive + Input::Timeout) and the deadline-honoring sleep semantics (Option B from brainstorming). Without Input::Timeout, str0m's clock froze during inbound silence; consent checks stopped; the browser tore the call down before the 60s idle timeout (F1). - §4.5: clarify the idle timeout keys on MediaData only. STUN/DTLS keepalives do not reset it. Spec already said 'no RTP'; wording now makes the intent unambiguous so the deviation cannot return (F4). - §5.1: drop dead Channel.created_at field. The idle timeout keys on RtcSession.last_rtp_rx, not creation time. The field was never read (F6). - §10 (new): record the review patches lineage + cleared findings (F8, F9). Findings F8 and F9 cleared by reading str0m 0.21 source (packet/payload.rs:91 rebases MediaTime to the negotiated Opus clock; RFC 7587 fixes SDP at opus/48000/2 regardless of internal rate). Cleared with the same standard the review used for 'Browser ICE gathering'. See AGENTS.md slice-1 boundaries — no scope additions; this is ratification of fixes for bugs the spec already prohibited or that the dev-loop surfaced.
37 KiB
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 2–6 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): theChannelleg 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. |
Session / FD resource ceiling on POST /v1/sessions |
Spearhead step 5 (PSTN trunk + deployment posture) | A resource ceiling is a separate concern from authn/authz but isn't slice-1's wedge. Each session binds a UDP socket; orphan sessions are reaped after 60 s idle, so a tight create loop can hold bounded FDs at (rate × 60 s). Acceptable for the loopback dev build; lands with the deployment posture / spend-gate step. |
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.rswith 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 onrutster-media,rutster-call-model.rutster-mediadepends onrutster-call-model(theChannelId/Channeltypes).rutster-call-modeldepends on nothing in the workspace (leaf).rutster-tap,rutster-spend,rutster-signaling-sipdepend on nothing in slice 1 (their future dependency direction is documented in their//!comments).
3. Media core (rutster-media)
3.1 Components
RtcSession— owns astr0m::Rtcinstance (the main sans-IO driver type; str0m has noLivestruct) 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-tapwill re-export it once that crate fills in (step 2 — keeps one canonical home).- Codec pair —
opuscrate (libopus via FFI; PORT_PLAN §7 disposition for Opus is 🦀 Core (FFI)). Decoder:opus_decode→ fills aPcmFrame; Encoder:opus_encodefrom 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::Rtcis the main sans-IO driver; there is noLivetype.Rtc::handle_input(Input)feeds network input;Rtc::poll_output() -> Outputis the poll interface, whereOutput::Timeout(Instant)gives the next-deadline signal we sleep tokio until.Event::RtpPacket(RtpPacket)is a single-struct variant (RID is a field onRtpPacket, 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 isEvent::MediaData(MediaData)containing decoded media.- Outbound:
StreamTx::write_rtpexists in RTP mode only; under the Frame API the path isRtc::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 or
advance the internal clock via Input::Timeout(Instant)) and poll_output() -> Output
(the poll interface, where Output::Timeout(Instant) gives str0m's next deadline).
Slice 1 runs this poll on a fixed 10 ms tokio tick.
Both inputs are mandatory. Inbound packets carry Input::Receive(now, recv), but
str0m's internal clock (DTLS retransmit, ICE consent freshness RFC 7675, RTCP) only
advances via Input::Timeout(now). The 10 ms tick feeds Input::Timeout(now) at the
top of each drive() call before draining the UDP socket. Without this, sustained inbound
silence (muted mic, hold, peer paused brain-side) freezes str0m's clock, stops consent
checks, and the browser tears the call down (~15–30 s) before the 60 s idle timeout
triggers. (Adversarial review F1 surfaced this; pinned here so the deviation cannot
return.)
Deadline-honoring sleep. drive() returns Option<Duration> = str0m's next deadline
− now. The session-map's poll task sleeps min(deadline, 10 ms) between ticks: honoring
the deadline saves wakeups when str0m has nothing pending; the 10 ms cap guarantees the
outbound 20 ms encode tick fires on time. (F3: the return value is load-bearing — it is
wired into the sleep, not discarded.)
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:
- Slice 1 has no reflex to time against (no VAD, no barge-in).
- A dedicated timing thread now would be theater — there is nothing whose determinism needs defending.
- 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 aRtcSession(which owns a freshChannel; theChannelIdis 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 asapplication/sdp.DELETE /v1/sessions/:id→ tear down: transitionChanneltoClosing → Closed, drop theRtcSession, 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:8080plaintext HTTP for the dev loop only. No TLS in slice 1 — see the out-of-scope rationale.- No authn/authz on
/v1/sessionsin 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:
getUserMedia({ audio: true }).new RTCPeerConnectionwith a STUN config of[](host candidates only — no STUN server needed for local loopback, keeping the dev loop zero-dependency).- Create offer, POST to
/v1/sessions, get back the answer, set as remote. - Play the echoed audio back via an
<audio>element. - Log ICE state + connection state to a
<pre>for debugging. - "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. TheChannelId(a UUID newtype fromrutster-call-model) is the session id surfaced in the REST API.RtcSessionowns both the str0mRtc+ codecs and theChannel(signaling state); see §3.1 and §5. - Idle timeout: 60 s of no RTP packets received from the peer → close the session.
STUN consent checks and DTLS keepalives do not reset the timer — only
Event::MediaData(depacketized audio) advanceslast_rtp_rx. A peer that keeps ICE alive but sends no audio (muted mic, revoked permission) is reaped on schedule. (RTC quiet periods are normal but 60 s of dead air — particularly STUN-only keepalives without any media flow — is a real "the browser tab is dead" signal —browser-refresh, network drop, mic-permission-revoked, 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 everyRtcSession) 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 str0mRtcis 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 enum ChannelState { New, Connecting, Connected, Closing, Closed }
pub enum Direction { Inbound } // Outbound lands with the dialer (later)
That's the whole crate. ~70 lines. (Review-patch F6: a created_at: Instant
field was originally drafted "for the idle timeout," but the timer keys off
RtcSession.last_rtp_rx, not the channel's creation time. The field is dead;
the Instant import it pulled is dropped with it.)
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 toRtcSessioninrutster-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 runningcargo deny check licensesafter the firstcargo 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 on0.0.0.0:8080, logslistening 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 runfor verbose tracing.- No docker, no compose, no external deps beyond Rust. The batteries-included
compose upis 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.
RtcSessionstate transitions: drive a realstr0m::Rtcinstance with syntheticInputevents (str0m'sRtcis 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
rutsterbinary crate:- Spin up the axum server on an ephemeral port, use
reqwest+ awebrtc-rsclient (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.
- Spin up the axum server on an ephemeral port, use
- Manual e2e test plan (documented in
README.mdbecause no browser automation in slice 1):cargo run→ server starts, logs ready.- Browser to
http://localhost:8080/→ mic prompt. - Speak → hear yourself back within ~200 ms (no perceptible delay).
- Hang up via button → server logs
ChannelState::Closing → Closed, session dropped. - All unit + integration tests pass.
cargo deny checkclean.cargo clippy -D warningsclean.
6.5 Slice 1 "done" criteria
The slice is complete when, on a clean checkout:
cargo test --allpasses (unit + integration).cargo fmt --checkpasses.cargo clippy -- -D warningspasses.cargo deny checkpasses.cargo run, openhttp://localhost:8080/, speak, hear the echo within ~200 ms.- Hang-up button triggers
Closing → Closedin server logs. - Every stub crate compiles and its doc-comment describes its scheduled step.
LEARNING.mdindexes 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 everylib.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 incargo doc— a learner can runcargo doc --openand read the architecture. - Inline comments (
//) on the mechanism, not the what:- Why
Pin<Box<dyn Future>>instead ofasync fn. - Why
Arc<Mutex<...>>vsArc<RwLock<...>. - Why
&[u8]instead ofVec<u8>in a function signature. - What
PhantomDatais doing. - Why an
enumwas chosen over astructwith akindfield. - 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.
- Why
- 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-mediaexplains thethiserror-based error-enum pattern, why?overmatch, and the "drop + observe, don't crash" hot-path policy from §3.8. LEARNING.mdat 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/AudioSinktraits incrates/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)
- Rust ownership of shared state →
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 1–2; 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 — north star, capability ladder
- ARCHITECTURE.md — fused per-call vertical, agent tap
- PORT_PLAN.md — capability checklist + thin-slice phasing
- Vision-revision spec — the pressure-test that produced the architecture this slice implements
- ADR-0002 — fused vertical
- ADR-0003 — Rust-native stack stance
- ADR-0004 — GPL-3.0-or-later
- ADR-0006 — WebRTC-first ingress
10. Review patches (adversarial assessment 2026-06-28)
The slice-1 spec was patched per the Claude adversarial assessment (filed at
docs/reviews/2026-06-28-slice-1-claude-adversarial-assessment.md) after GLM-5.2
verification of each finding against the codebase + str0m 0.21 source.
- F1 (High, confirmed):
Input::Timeoutwas never fed; str0m's clock froze during inbound silence. §3.4 now pins both inputs (Input::Receive+Input::Timeout) as mandatory and the deadline-honoring sleep semantics. Code fix: feedInput::Timeout(now)at the top ofdrive(); route the return value intosession_map's sleep. - F2 (Med-High, confirmed):
accept_offerasserted on a client-reachable path (double-POST /offerpanic). Code fix: typedRtcSessionError::AlreadyNegotiated→409 Conflict. - F3 (Med, confirmed):
next_timeoutfield written, return value discarded. Folded into the F1 fix (the return becomes load-bearing in the sleep). - F4 (Med, confirmed):
last_rxbumped on every datagram, not RTP-specific. §4.5 already specified "no RTP"; the code now matches (bumplast_rtp_rxonEvent::MediaDataonly). - F5 (Low-Med, confirmed): No cap / rate limit on
POST /v1/sessions. Deferred to step 5 — added to §1.2 out-of-scope. - F6 (Low, confirmed):
Channel.created_atdead + spec contradicted itself on "5-min" vs "60 s". §5.1 drops the field; spec wording reconciled to 60 s in §4.5. - F7 (Low, confirmed): Unreachable
needs_redrainbranch in Step-2 drain loop. Removed. - F8 (Low, cleared): str0m 0.21
packet/payload.rs:91callsrtp_time.rebase(self.clock_rate).numer()— rebasesMediaTimeto the negotiated Opus 48 kHz wire clock. 20 ms → 960 ticks. Correct; no fix. - F9 (Low, cleared as bug): RFC 7587 fixes SDP at
opus/48000/2regardless of internal rate; libopus self-describes internal rate. A 40/60 ms frame yieldsOPUS_BUFFER_TOO_SMALL→ rutster routes toNone→ drop+observe per §3.8 (already the documented correct behavior). One-line comment added toOpusDecoder::pcm_bufnoting the 20-ms assumption. - Test-gap (confirmed): No test drives
loop_driver::drive()— the central function of the slice. Retrofitted a sans-IO harness (syntheticInput::Receive/Input::Timeoutin, assertOutput::Transmit+ state transitions out). F1, F2, F4 each carry regression tests on the new harness.