- Clarify RtcSession/Channel ownership: ChannelId IS the session id; RtcSession owns both str0m Live + codecs and the Channel. Fixes ambiguity. - Drop the separate /ice endpoint: non-trickle ICE bundles candidates into the SDP offer/answer, so the endpoint was redundant. Fixes contradiction. - Clarify idle timeout = 'no RTP for 60s' (not 5 min blanket), and that no per-session tokio task is spawned (pre-paves the wrong pattern for step 4's dedicated thread). - 'reflex to police' -> 'reflex to enforce' (terminology: avoid authoritarian verbs).
28 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. |
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 a str0mLiveinstance 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 → RTP packet payload.
3.2 Loop shape (Approach A from brainstorming)
str0m Live (sans-IO, polled on tokio — see §3.4)
↓ Event::RtpPacket(pkt, rid)
decode Opus → 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 → str0m.write_rtp()
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.
3.4 Polling & the tokio-vs-dedicated-thread deviation
str0m is sans-IO; its Live API exposes handle_input / needs_input_timeout to drive
the poll. 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:
- 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 once fed a certificate; configured via RtcConfig::with_dtls_cert at
startup.
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. No cert file in slice 1 (cert rotation / persistence is a step-5 / trunk-integration concern). The cert is regenerated on every server restart — fine for a dev loop.
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 from str0m).
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, 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 str0mLive+ codecs and theChannel(signaling state); see §3.1 and §5. - Idle timeout: 5 min hard cutoff. "Idle" = no RTP packets received from the peer in the last 60 s (a longer window than the strict "no RTCP" definition, because RTC quiet periods are normal; no-RTP-for-60s is a real "the browser tab is dead" signal). 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).
- 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 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 mocked str0mLiveby injecting fake events (str0m is sans-IO — itsLiveis fully testable without a network. This is the sans-IO payoff we chose in Approach A.)
- 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 Live + 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. |
| 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