cargo fmt --check (CI gate per AGENTS.md) flagged import-ordering +
line-wrapping drift across slice-2's committed code (rutster-tap-echo,
rutster-tap, rutster binary tap_engine + routes) and the slice-1-review
-fixes rebase (loop_driver, rtc_session, session_map). Purely cosmetic:
alphabetical use ordering, line wrapping under 100 cols. No semantic
changes.
Adversarial review F9 (Low, cleared as bug): RFC 7587 fixes the SDP at
`opus/48000/2` regardless of internal rate; libopus self-describes
internal rate, so the 24 kHz mono decoder is correct. A 40/60 ms
frame would yield OPUS_BUFFER_TOO_SMALL and rutster's `Option`-shaped
`decode` wrapper routes it to `None` → drop+observe per §3.8 —
already the documented correct behavior. Adds the explicit 20-ms
assumption as a comment so the boundary is visible to future readers.
Adversarial review F1 (High) + F3 (Med): per the user-locked Option B
direction (slice-1 spec §3.4), one atomic change closing both findings.
F1 (input): feed Input::Timeout(now) at the top of every drive() tick.
str0m's internal clock (DTLS retransmit, ICE consent freshness RFC 7675,
RTCP scheduling) only advances via Input::Timeout — the previous loop
only fed Input::Receive, so sustained inbound silence (muted mic, hold,
brain thinking mid-step-3) froze the clock; consent checks stopped; the
browser tore the call down (~15-30 s) before the 60 s idle timeout fired.
F3 (output): drive() returns Option<Duration> = str0m's next-deadline -
now. The return was previously discarded via `let _ =` in
session_map::drive_all_sessions. Now the poll task sleeps
`min(per-session 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.
TDD via the PacketIo harness + a new observability counter:
- session.timeouts_fed: u64 — increments each drive() that feeds
Input::Timeout. Doubles as the F1 regression test's seam and the
embryo of a real metric per AGENTS.md's OTel aspirations.
- RED: f1_drive_feeds_input_timeout_to_advance_str0m_clock asserted
timeouts_fed > 0; pre-fix the counter stayed at 0.
- GREEN: added Step 0 in drive() (Input::Timeout feed + counter
increment). Test passes.
- Refactor: session_map::drive_all_sessions returns Duration (was ());
poll task uses tokio::time::sleep instead of fixed
tokio::time::interval(10ms).
Reviewer: Claude (Opus 4.8). Companion to the vision sanity-check
(2026-06-28-vision-sanity-check.md, idea-level) and the slice-1
code assessment (2026-06-28-slice-1-claude-adversarial-assessment.md).
Filed but not addressed. Seven GTM findings:
- G1: revenue vectors are a sequence (OSS → contract → hosted), not a menu
- G2: do-now, irreversible — secure CLA + commercial dual-license before
contributor #2 (directly affects ADR-0004; gates monetization doors)
- G3: the consulting trap
- G4: hosted = BYO-cloud, not multi-tenant SaaS
- G5+: the two near-free pivots — reposition as a voice-agent substrate
and beachhead on outbound-compliance
Cross-cutting with the vision-sanity-check's reframe (moat = boundary +
data loop, not determinism). Addressing these is strategy-doc + ADR
work that warrants user signoff; not auto-applied alongside the
code-track fixes.
Adversarial review F4 (Med, confirmed): spec §4.5 specifies '60 s of no
RTP packets.' The implementation bumped `last_rx` on every datagram
`Receive::new` classified — including STUN consent checks and DTLS
keepalives. A peer that kept ICE alive but sent no audio (muted mic,
revoked permission) was never reaped within 60 s.
Move the bump to `handle_event::Event::MediaData` only — only
depacketized audio resets the timer. Spec §4.5 wording was already
correct (clarified in 8680f2b); the code now matches.
TDD via the new PacketIo harness:
- FakePackets (VecDeque-backed) injects an RTP-header-shaped datagram
(classifiable but not MediaData-bearing) without a network round-trip.
- RED: `f4_non_rtp_datagram_does_not_reset_idle_timer` asserted
`last_rx == initial`; pre-fix the bump in Step 1 on every classified
datagram made the test fail.
- GREEN: removed the Step-1 bump; added the bump in
`handle_event::Event::MediaData`. Test passes.
(Fixture rationale: STUN bytes don't work — `StunMessage::parse` needs
a USERNAME attribute that doesn't exist without a real handshake, so
`Receive::new` returns Err and the bump is skipped → false-pass.
RTP-header bytes multiplex as RTP without further validation, so they
reach `handle_input` — exactly the bug shape.)
Adversarial review's test-coverage gap finding: no test drives
`loop_driver::drive()`, the central function of the slice. F1, F2,
and F4 would have been caught by one. The obstacle was that `drive()`
called `session.socket.recv_from` on a concrete `UdpSocket`, so
unit tests couldn't feed synthetic STUN/RTP/DTLS bytes without a real
UDP loopback pair + hand-rolled valid packets on the wire.
Retrofit a sans-IO test seam shaped the way str0m itself is built:
- New `crates/rutster-media/src/packet_io.rs` defines `PacketIo`
(`recv_from`, `send_to`) with a blanket impl for `UdpSocket`.
- `RtcSession.socket` goes from `std::net::UdpSocket` to
`Box<dyn PacketIo + Send>`. Dispatch cost: a vtable call (~1 ns)
on a 20 ms tick at single-digit pps; ~10,000× headroom vs the 200 ms
latency bar. If step 4's dedicated timing thread measures it, swap
`Box<dyn>` → a generic `RtcSession<S>` parameterization (same
trait interface).
- `RtcSession::new()` keeps its behavior; new hidden `new_with_socket`
is the test constructor (injects a fake + a synthetic local_addr).
This is the infrastructural refactor only — no behavior change. F4
and F1+F3 land on top of it (separate commits, TDD-style: failing
test first, then fix).
Adversarial review F6 (Low, confirmed): Channel.created_at: Instant was
drafted 'for the idle timeout' (spec §5.1) but the timer keys off
RtcSession.last_rtp_rx (and now last_rtp_rx after F4), never creation
time. The field was never read; the Instant import it pulled is dropped
with it.
Spec §5.1 already reconciled in 8680f2b (struct sketch + comment).
Spec §4.5 already specified 'no RTP' (60 s); the contradiction with
the old 5-min lineage is closed.
Refactor phase — full test suite + clippy green; no behavior change.
Reviewer: Claude (Opus 4.8). Filing the strategy/vision-level critique
(for the slice-1 code-review see the companion
2026-06-28-slice-1-claude-adversarial-assessment.md already committed
in 8680f2b).
Filed but not addressed. Six strategic findings:
- S1: wedge leads with determinism (its most contestable leg)
- S2: differentiation valley between spearhead-end and capability ladder
(the load-bearing critique)
- S3: compliance paradox — regulated buyers won't move on bus-factor-1
- S4: turn-detection ownership at the tap — OpenAI Realtime has its own
server-side VAD by default; ARCHITECTURE.md says core-authoritative.
Belongs in the slice-3 (brain) spec as an explicit decision.
- S5: solo scope vs. category timing window
- S6: self-improvement loop is the actual moat, under-sold
Addressing these is strategy-doc work that warrants user signoff and
architecture pre-reading per AGENTS.md; not auto-applied alongside the
code-track fixes.
Adversarial review F7 (Low, confirmed): the Step-2 Output::Timeout arm
checked `needs_redrain` but the flag could only be set inside Step 3
(which runs after Step 2 exits). The arm was unreachable dead code that
misled readers into thinking Step 2 re-drains inter-iteration.
The flag itself remains load-bearing for the bottom re-drain after
Step 3's Writer::write (str0m's mutation → drain-to-Timeout invariant).
Only the inner-loop check is removed; production behavior unchanged.
Refactor phase — existing tests stay green.
Adversarial review F2 (Med-High, confirmed): accept_offer asserted
audio_mid.is_none() on a path fed by unauthenticated external input.
A second POST /v1/sessions/:id/offer panicked the handler task instead
of returning a clean 4xx.
Replace the assert! with a typed RtcSessionError::AlreadyNegotiated.
routes.rs maps the variant to 409 Conflict, distinct from 400 Bad
Request for malformed SDP. debug_assert! documents the invariant for
tests; the production path returns Err, never panics.
TDD: unit test second_accept_offer_returns_already_negotiated_not_panic
red on the panic message; green after the typed error. Integration test
double_post_offer_returns_409_conflict_not_panic red on 400; green after
routes.rs is updated.
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.
Design record for the operator console — the downstream non-technical
operator's surface. **Not scheduled for current build;** lands after the
spearhead (steps 1-6) and the ACD/escalation rungs (capability ladder
rungs 1-2). The current build target is slice-2; the next build target
after slice-2 is slice-3 (the OpenAI Realtime adapter). An agent that
proposes building the console now is wrong — point it at spec §0.
Records the visual-brainstorming session's resolved decisions so they
don't get re-litigated at implementation time:
§1 Principles (load-bearing):
- GUI is a pure API client — **green-zone, never an insider** (ADR-0008);
holds no privilege a third party couldn't get from the public API.
- Config-as-code is the single source of truth; the UI is a reconciled
*view* (the k8s model — declarative desired-state in git, the engine
reconciles; console + CLI + git are views over it).
- Operator persona, not builder. The builder authors flows in **code +
an AI pair** (ARCHITECTURE 'DX spine'); this console is NOT the
authoring surface for the technical builder. **No drag-drop authoring
canvas** — a deliberately-closed decision (vision-revision §8).
- Ships in the batteries-included distro and the all-in-one image
(the reference GUI is bundled — 'boom' includes a usable console).
§2 IA — Ops Console shell: left nav + center panel + persistent live rail.
Dense single-screen; for an operator who lives in it. Live rail always
visible regardless of the selected section.
§3 Dashboards — Live Ops (default landing) + Quality (sibling view). Maps
the k8s split: Live Ops = live operational state; Quality = the data-owned
eval loop (capability ladder rungs 3→4).
§4 Flows editor — master/detail + a generated, navigable preview map.
The preview map is a *view* rendered from the config, not a drag-drop
authoring canvas. Earns its place by showing branching (the escalation
split) the linear stage-list flattens.
§5 Declarative flow schema (illustrative YAML; format is open). The config
the console edits via the API; the builder edits in git; the engine
reconciles. The map is generated from this structure.
§6 Other sections (conventional): Agents · Numbers · Queues · People ·
Recordings · Settings.
§7 Architecture: separate app, public API only; never reaches into the FOB.
Config writes go through the reconcile path. Live data rides the event
stream off Valkey (ADR-0005); never the 20 ms media loop.
§8 v1 vs. later scoping. §9 open decisions (flow config format, map
rendering depth, quality metrics, live transport, framework).
Cross-refs ADR-0002 (fused vertical + composable platform), ADR-0006
(WebRTC ingress / escalation UX), ADR-0007 (rented trunk / Numbers
config), ADR-0008 (FOB / green-zone — the UI is green-zone),
ARCHITECTURE.md 'DX spine' + 'GUI & extension architecture', PORT_PLAN §5
(contact-center domain).
- *.kate-swp: Kate editor's swap files have a different extension pattern
than vim's *.swp; without this, kate-swp files like
docs/reviews/.2026-06-28-slice-1-claude-adversarial-assessment.md.kate-swp
leak into the working tree.
- .superpowers/: the superpowers companion scratches its visual-brainstorming
server state (HTML mockups, server.log, server.pid, server-info) under
.superpowers/brainstorm/ in the project root. The companion explicitly
advises gitignoring this directory; scratch artifacts are not source.
Mechanical propagation of the crate rename ( rutster-signaling-sip →
rutster-trunk ) and the repo URL fix ( github.com/anomalyco →
git.adlee.work/alee ) through the documents that name them:
- docs/DEVELOPMENT.md: crate-layout sketch + stub-crate description.
- docs/superpowers/plans/2026-06-28-slice-1-webrtc-loopback.md: the
workspace members list in the plan's binding-values section —
repository URL updated.
- docs/superpowers/plans/2026-06-28-slice-2-agent-tap.md: workspace
members list in binding-values + crate-layout sketch in the file-
structure section (rutster-signaling-sip → rutster-trunk).
- docs/superpowers/specs/2026-06-28-slice-2-agent-tap-design.md: §2.1
workspace-layout sketch's STUB crate-row.
No content changes beyond the substitutions; the slice-2 spec/plan
body (protocol, TapAudioPipe, TapClient, TapEngine, lifecycle, done-
criteria) is untouched.
ADR-0007 (rent the trunk) + ADR-0008 (FOB/green-zone doctrine) propagation
into the narrative docs that orient contributors + readers. No code changes.
README:
- Wedge bullet 2: 'one secure auditable boundary' now lists media + local
reflexes + spend + tap + audit (was: trunk termination + media + spend).
Adds the honest caveat that PSTN media inside the boundary is the on-prem
*graduation* (ADR-0007), not a day-one claim.
- 'Is / isn't': 'isn't a TDM/PSTN-hardware PBX — *and not a SIP stack.*'
No first-party SIP (ADR-0007). PSTN reach is rented transport.
- Memory-safety pillar: 'rutster parses no SIP at all' — entire first-party
wire surface is WebRTC/RTP/SRTP + WebSocket tap/ingress, all memory-safe
Rust. The carrier-SIP interop tail lives outside the trust boundary.
- Spearhead step 5: 'Add a real phone number via rented transport' (was:
'Replace WebRTC ingress with a real PSTN trunk call'). Re-aims at the
AI-telephony frontier; no first-party SIP stack.
- References: 0007 supersedes 0003 in the highlighted ADR list.
ARCHITECTURE:
- New 'FOB and the green zone' section after the fused-vertical framing —
names the build-vs-reuse doctrine with the FOB member list + green-zone
member list, and restates memory-safety precisely ('FOB is 100%
memory-safe Rust; the green zone is trusted OSS kept outside the
boundary — not an over-claim that every byte is Rust').
- 'Inside the boundary': 'Carrier trunk — rented transport, not first-party'
replaces 'Trunk SIP termination — Rust-native.' PSTN audio arrives as
a media-leg ingress from a rented CPaaS raw-media fork or an out-of-tree
SBC for on-prem sovereignty.
- Biggest technical risk: 'No longer the SIP stack — because rutster no
longer builds one.' Retires ADR-0003's named schedule risk; redirects
to 'the reflex loop itself' (turn-taking, VAD-driven barge-in, jitter,
pacing) — which is also the differentiator.
PORT_PLAN:
- Design rule 4: 'rent it, don't own it' replaces ADR-0003's 'Rust-native
trunk SIP, no SBC shield.' ADR-0007 restores the rule's original instinct.
- SIP signaling (trunk) row: disposition flipped to 🔌 Rented / out-of-tree.
- Outbound registration row: disposition flipped to 🔌 Rented / out-of-tree
(handled by the rented transport or out-of-tree SBC; rutster parses no SIP).
- Spend/abuse engine row: 'co-located with call origination + the tap inside
the boundary' (was: 'co-located with trunk termination'). rutster mediates
both the provider call-control API and the brain tap — the brain never
holds the wire.
- Spearhead step 5: 'Add a real phone number via rented transport.'
- Open decisions: SIP line updated to 'Re-decided — ADR-0007.'
AGENTS.md:
- ADR list: adds 0007 and 0008 with annotations; marks 0003 as superseded
by 0007.
- Key decisions to respect: prepends the FOB/green-zone doctrine as the
*the* build-vs-reuse rule. 'When in doubt, default to green zone — the
FOB earns its members, it doesn't collect them. This is why the trunk is
rented (ADR-0007) and Valkey reused (ADR-0005), not rebuilt. Don't pull
green-zone plumbing into the core.'
ADR-0007 lands in code:
- Rename crates/rutster-signaling-sip → crates/rutster-trunk. The crate was
a stub anyway (lib.rs with a doc-comment + crate_compiles() test); the
rename locks the new boundary shape — the future rented-transport
ingress (CPaaS media-leg fork / out-of-tree SBC glue, **no SIP stack**)
lands here at spearhead step 5.
- rutster-trunk/src/lib.rs doc-comment now describes the ADR-0007 split:
CPaaS media-leg adapter as primary, out-of-tree SBC for on-prem
graduation. Cross-refs ADR-0007 + ADR-0008 (FOB vs. green zone: trunk
is green zone; SIP lives outside the trust boundary).
- rutster-spend/src/lib.rs doc-comment updated to quote ADR-0007's
'rutster mediates both the provider call-control API and the brain tap'
framing — the spend gate sits in that boundary, structurally preventing
a runaway brain from exceeding spend/pacing. Pulling spend out into a
service re-introduces the 3-vendor structural hole.
Cargo bumps:
- edition = '2024' (slice-1's pinned Rust 1.85 + edition-2024 floor
already requires this; rutster-media's let-else pattern in
OpusDecoder::decode · Slice-2's let-else in AcceptOffer's Uuid
parse_str · all rely on edition 2024's stabilized let-chains / let-else).
- repository = 'https://git.adlee.work/alee/rutster' (the self-hosted
Gitea remote — matches 'git remote -v' origin). The github.com/anomalyco
URL was stale from the pre-pivot copy.
- Cargo.lock regenerated by cargo for the rename.
No behavioral code changes — the trunk crate's body is still the stub
crate_compiles() test. FOB membership (per ADR-0008) is unchanged:
rutster-trunk will be FOB-internal at step 5 because it's the media-leg
ingress (hot path); spend-spend stays FOB because spend is
security-constitutive.
ADR-0007 — Trunk/PSTN strategy: rent the transport, no first-party SIP stack.
Supersedes ADR-0003 (Rust-native trunk SIP). The 2026-06 strategic-relevance
review stress-tested ADR-0003 against the 2026 market + solo-build realities
and it did not survive: a trunk SIP/media core is the highest-cost,
lowest-differentiation square on the board (the perceived-quality battle and
the buying criteria both live above the transport), and no production-hardened
pure-Rust SIP stack exists to stand on (ezk is bus-factor-1 / pre-1.0;
str0m self-describes as 'not for production' for SIP).
rutster owns no SIP stack. PSTN reach is rented transport in three layers:
1. Primary (demo, most users) — CPaaS raw-media fork (Twilio Media Streams,
Telnyx). Media-leg ingress, core-as-server (parallel to WebRTC ingress
per ADR-0006). Use the raw-audio fork, *not* managed Voice-AI products
that would consume the reflex loop.
2. Graduation (on-prem) — out-of-tree SBC (Kamailio/FreeSWITCH/drachtio +
rtpengine) B2BUAs carrier SIP into rutster as clean RTP/tap media,
outside the trust boundary.
3. Never — a first-party Rust trunk SIP stack. rsip/ezk stay off the
critical path.
The only things rutster owns in Rust: the call model, the reflex loop, the
agent tap. Everything that touches a carrier is rented (layer 1) or
out-of-tree (layer 2).
ADR-0008 — The FOB and the green zone: the build-vs-reuse doctrine. Names
the boundary criterion implicit across ADRs 0002-0007 as one mechanical rule
every contributor (human or agent) applies the same way.
- FOB (build in Rust) — admitted only if it passes one of: hot path,
security-constitutive, differentiating. Current FOB: media termination
(rutster-media on str0m) · real-time reflexes (VAD/barge-in/jitter/pacing)
· call model (rutster-call-model) · agent tap (rutster-tap) · spend/abuse
gate (rutster-spend) · control API + state/bus trait (rutster).
- Green zone (reuse at arm's length) — its own process/container/trust
domain, never in the FOB's address space. Admitted when it fails all
three FOB tests AND a trusted, actively-maintained project already does
it well. Current green zone: Valkey (ADR-0005) · carrier trunk (ADR-0007)
· agent brain · reference GUI · object storage · KMS · OTel collector ·
container supervisor.
- 'Actively maintained' is the load-bearing gate: excludes a first-party
Rust SIP stack and webrtc-rs; admits Valkey and str0m-for-WebRTC.
- When in doubt, default to green zone. The FOB earns its members.
Restates the memory-safety pillar precisely: the FOB is 100% memory-safe
Rust; the green zone is trusted battle-tested OSS kept outside the boundary
— not an over-claim that every byte is Rust.
ADR-0003's status is flipped to Superseded with a forward-pointer to ADR-0007.
Kept as the historical record of the 'own trunk SIP + media termination in
Rust, no SBC shield' plan; reversed under the strategic-relevance review.
- session_map.rs: AppState::close outer timeout bumped 500ms → 750ms
so the inner close-arm bound (500ms in tap_client.rs) has room to
finish `ws.close(None).await` cleanly before the abort fallback
fires. Outer > inner per final-fixes re-review Important #1.
- tap_integration.rs: reconnect-path test now asserts the resumed
`next_pcm_frame()` returns the FRESH marker (samples[0] == 9, not
the stale samples[0] == 7), actively witnessing the §5.3 step 4
"no stale bleed-through" contract. Pre-kill seeding strengthened
to ≥2 frames so step 7's silence-after-flush assertion is non-
vacuous. Per final-fixes re-review Important #2.
Spec ref: 2026-06-28-slice-2-agent-tap-design.md §5.1 step 5, §5.3 step 4.
Code-review findings from the slice-2 final whole-branch review:
- tap_client.rs: close arm sends session_end (not bye) per §5.2, awaits
brain bye with bounded 500ms timeout, then ws.close(). Promotes the
Task-7-documented Minor to Important's required spec compliance.
- session_map.rs: AppState::close() now clears channel.tap = None before
Closing->Closed, sends close signal, bounded-awaits engine task for the
teardown handshake (500ms cap), aborts as fallback. Per §5.1 step 5.
- pcm.rs + tap_audio_pipe.rs: AudioPipe trait gains clear_playout_ring()
(default no-op for EchoAudioPipe); TapAudioPipe clears the VecDeque.
RtcSession::clear_playout_ring delegates. Per §5.3 step 4 playout-on-
disconnect-flush contract.
- tap_engine.rs: spawn_tap_engine creates + shares a flush_tx mpsc
channel; run_engine_loop signals flush after each failed pump loop
(before backoff); TapConn.flush_rx is drained by drive_all_sessions
to call RtcSession::clear_playout_ring.
- tests/tap_integration.rs: new reconnect-path test asserts Channel
stays Connected, metrics.reconnect_attempts increments, playout
returns None during outage, playout resumes after EchoServer
restart with fresh frames (no stale bleed-through). Per §8.4.
- static/index.html: one static line acknowledging external-brain
routing per §7.2 (defer real-time tap status to step-3 GUI).
Spec ref: 2026-06-28-slice-2-agent-tap-design.md §5.1 step 5, §5.2, §5.3 step 4, §7.2, §8.4.
- tap_engine.rs: Backoff::next_delay now matches spec §4.3 exactly:
250ms -> 500ms -> 1s -> 2s -> 5s cap (no intermediate 4s step from
naive doubling). Test asserts the spec enumeration including the
"stays at 5s forever" cap-after-step-5 invariant.
- session_map.rs: removed the dead 'Closing && tap.is_some()' branch
from drive_all_sessions. AppState::close already handles teardown
inline (fires close_tx, aborts task, clears field, advances
state, removes entry). The branch was never exercised and
pre-paved a "future peer-initiated close" path -- both AGENTS.md
anti-patterns ("don't pre-pave the wrong pattern").
Spec ref: 2026-06-28-slice-2-agent-tap-design.md §4.3 step 4 + §5.1 step 3.
- pub struct TapHandle(()) — zero-sized marker (Option<TapHandle> compiles
to a bool, no allocation). The live tap connection lives in the binary's
DashMap<ChannelId, TapConn>, keyed by the channel's existing ChannelId
(== wire session_id per spec §5.3).
- Channel grows pub tap: Option<TapHandle>. None until Connected, set on
Connected, cleared before state advances to Closed.
- State invariant: tap and ChannelState are tied (Connected+None=bug).
- Slice-1 backwards compatible: new field is Option<...>;
Multi-tap-per-channel is the future trigger for a TapId newtype (YAGNI).
Spec ref: 2026-06-28-slice-2-agent-tap-design.md §5.2, §6.
- run_tap_client: drives a connected WebSocketStream with a tokio::select!
over rx_pcm_in (inbound PCM → audio_in WS frame) and ws.next() (brain
frame → audio_out mpsc or control handling).
- Handshake: send hello, await brain hello (bounded 2s timeout).
- seq gap detection (log + count, never drop on gap — spec §3.1).
- Hot-path errors (encode/decode failures, send/recv failures) are logged
+ counted; TapClientError is returned only on graceful close, hello
timeout, or WS errors. The TapEngine (Task 7) decides reconnect policy.
- Pure-helper unit test (elapsed_ms); full pump behavior is exercised
against the in-process EchoServer in the Task 8 integration test.
Spec ref: 2026-06-28-slice-2-agent-tap-design.md §4.2.
- Add tokio-tungstenite 0.24, futures-util 0.3, url 2, base64 0.22 to
[workspace.dependencies] (spec §8.1).
- Add crates/rutster-tap-echo as 7th workspace member (spec §2).
- Transition crates/rutster-tap/Cargo.toml from stub to real manifest
with the deps Tasks 2-4 will need (rutster-media, tokio, tokio-tungstenite,
serde, serde_json, base64, url, thiserror, tracing).
- Skeleton lib.rs (compile test) + main.rs (placeholder fn main) for
rutster-tap-echo; Task 5 fills in the echo logic.
- Verify cargo deny check passes against the new transitive dep tree.
Spec ref: docs/superpowers/specs/2026-06-28-slice-2-agent-tap-design.md §2, §8.1.
Patches the agent-tap design with seven findings from PM-side review
before the implementation plan gets written. Resolves invariant-breakers
and deferred-decision debt that would compound across steps 3-6.
- P1 (high): §8.5 #6 'byte-identical loop_driver.rs' was the wrong bar
for the seam test. Reworded: the trait-method *call sites* are
unchanged; impl bodies are free to differ. That's what 'the seam
held' actually means — the contract is the test.
- P2 (high): tap: Option<TapHandle> and ChannelState were parallel
state machines without an invariant tying them. Added an explicit
state-pair table: tap is Some iff state == Connected. Lookups tolerate
transient inconsistency; the source-of-truth is the pair, not either
field alone.
- P3 (medium): v1 wire format was host-endian (silent big-endian
hazard on paper). Nailed LE explicitly via i16::to_le_bytes /
i16::from_le_bytes. v2 can negotiate endianness if a big-endian
brain ever materializes; v1 contract is LE-only. Added a §3.4
byte-order invariant alongside the sample-count invariant.
- P4 (medium): wss:// URLs were accepted by schema but returned
501 at connect time (UX: 2 s into a call). Now rejected at
POST /v1/sessions with 400 + clear message — fail fast at
session-create. Updated §4.4, §7.1, §7.3, §9, and the decisions
table for consistency; removed all stale '501 at connect' claims.
- P5 (low): TapEngine spawn owner was unspecified. Added explicit
ownership: session_map::drive_all_sessions (the binary's poll
task) observes the Connected transition from run_poll_once and
spawns the engine. Keeps loop_driver.rs behaviorally unchanged
(the §8.5 #6 seam test still holds).
- P6 (low): 'two new deps' in §1.1 undercounted. Updated list:
tokio-tungstenite, futures-util, url, serde_json (if not already
pulled).
- P7 (low): ADR-0007 was 'post-implementation follow-up, not a
slice-2 deliverable.' Tightened: strongly recommended to land
alongside or immediately after slice-2 implementation — once
step 3 ships against an unpinned protocol, the wire shape will
silently drift. The spec lists the decisions ADR-0007 should
capture (LE byte order added to the list).
Slice 2 hardens the presumptive agent-tap shape from ARCHITECTURE.md §"Agent tap"
(PORT_PLAN §10 open decision) against a working implementation:
- Replace slice-1's in-process EchoAudioPipe with a real out-of-process brain
reached over WSS; core-as-client, no inbound tap port (ADR-0006 posture).
- Versioned JSON event protocol (v1) over WS text frames with base64 PCM.
Event-named + JSON-over-text-WS so the step-3 OpenAI-Realtime adapter is a
translation shim, not a gRPC-bridge project.
- Decoupled TapEngine (cold-path tokio task) owns the WS connection; TapAudioPipe
is a thin sync wrapper the AudioSource/AudioSink seam holds. RtcSession's media
loop is byte-identical to slice-1 — the seam-test payoff.
- Core-authoritative playout buffer (5-frame/100ms ring; drop-oldest on overflow,
silence on underflow) — the place where "brain proposes, core disposes" lives.
- Bounded-backoff reconnect (250ms→5s cap, infinite retries); Channel stays
Connected; stateless-brain reconnect contract.
- Both a Python reference echo brain (examples/echo_brain/, not in CI) and a
Rust echo brain crate (crates/rutster-tap-echo, the in-process test server
+ standalone dev binary).
- ws:// loopback-only enforced; wss:// URL accepted at schema, 501 at connect
(cert/mTLS impl deferred to step 6).
Out-of-scope table (§1.2) locks the spearhead sequencing: real brain (3),
barge-in/VAD-driven playout kill (4), PSTN trunk (5), spend cap (6), wss:// TLS
posture (6), binary PCM mode (future-rung), byte-endian negotiation (tracked
open decision §9).
Two Important findings from the final whole-branch review:
T4-M3: RtcSession::channel_id/channel_state/is_closed lacked /// docs
(AGENTS.md mandates /// on every public item; learner-facing code is a
primary product). One-line docs added per existing /// style in rtc_session.rs.
T7-M2: README "Status" section still read "Scoping. No code yet" — stale
post-slice-1. Updated to reflect slice-1 implementation status; minimal
edit, surrounding content preserved.
cargo-deny 0.18.3 (the latest that installs on rust 1.85) can't parse
the SPDX 'GPL-3.0-or-later' suffix — 'expected a <bare-gnu-license>'.
CI uses cargo-deny-action@v1 (bundles 0.19+) which parses it correctly.
Switching to bare 'GPL-3.0' lets local 'cargo deny check' work on the
pinned toolchain. ADR-0004's GPL-3.0-or-later stance is unchanged; this
is a parser workaround flagged for revert when the MSRV lifts past 1.88
(where cargo-deny 0.19.x installs natively).
Three of four 'cargo deny check' subcommands now pass locally:
- licenses ok
- bans ok
- sources ok
- advisories: fails locally only because the advisory-db contains CVSS 4.0
entries (anchor-lang RUSTSEC-2026-0146) that cargo-deny 0.18.3's parser
rejects. Unfixable on the rust 1.85 toolchain; CI's 0.19+ parses fine.
Surfaced by Task 7's done-checklist run; not a Task 7 code change.
LEARNING.md indexes ten concept-to-file pointers (the spec floor was
five) — the newtype pattern, exhaustive enum match, sans-IO, trait
extension seams, thiserror + hot-path match-and-continue, Arc<Mutex>
vs Arc<RwLock>, DashMap, str0m's single-mutation invariant, graceful
shutdown, include_str!. fuzz/README.md pre-paves the layout (no
hostile-bytes surface in slice 1; harnesses land at step 5 per the
out-of-scope table). README's new dev-loop section documents the
libopus FFI prerequisite and the manual e2e steps.
deny.toml allows the permissive Rust-ecosystem licenses + our own
GPL-3.0-or-later; bans duplicate versions of tokio/serde/bytes/tracing
to catch dep-tree divergence early; restricts sources to crates.io. CI
runs fmt --check, clippy -D warnings, test --all (matrix: stable +
MSRV 1.85), and cargo deny check on push + PR to main. The CI job
installs libopus-dev — the opus crate's FFI dependency (PORT_PLAN §7
'Core (FFI)' disposition).
Four routes on axum 0.7 per spec §4.1: POST /v1/sessions (mint),
POST /v1/sessions/:id/offer (str0m-native SDP accept), DELETE
/v1/sessions/:id (close), GET / (static HTML client). Session store is
a DashMap<ChannelId, Arc<Mutex<RtcSession>>> (spec §4.5); one tokio
task drives all session poll loops — per-session tasks would pre-pave
the wrong pattern for step 4's dedicated thread. Graceful shutdown
drops the DashMap on Ctrl-C / SIGTERM. Integration test exercises the
REST surface; manual browser e2e per README §6.5.
RtcSession owns a str0m::Rtc + Opus decoder/encoder + EchoAudioPipe +
a bound UDP socket (spec §3.1, §4.5). accept_offer calls str0m 0.21's
sdp_api().accept_offer() natively — no hand-rolled SDP munger; str0m
fills DTLS fingerprint + ICE creds + Opus codec. The loop driver
drains poll_output per str0m's single-mutation invariant, routes
inbound MediaData through Opus decode + EchoAudioPipe sink, sends
Transmit packets on the UDP socket, and checks the 60 s idle timeout.
DEV-DEVIATION: loop runs on tokio (spec §3.4); step 4 replaces with
a dedicated timing thread per ARCHITECTURE.md.
str0m 0.21 API adjustments from the brief's sketch (verified against
src/str0m-0.21.0):
- SdpAnswer has no .mid() accessor; use answer.media_lines[0].mid()
via the Deref<Target=Sdp> impl.
- Input::Receive carries the Instant as the first tuple element
(Input::Receive(now, recv)); handle_input takes a single Input arg.
- Receive constructed via Receive::new(proto, src, dst, buf) (the
DatagramRecv field is private).
- UDP socket binds 127.0.0.1:0 (Candidate::host rejects the
unspecified address 0.0.0.0).
- BROWSER_SDP_OFFER fixture restored a=group:BUNDLE 0 (str0m's
Sdp::assert_consistency rejects SDP without a session group).
str0m 0.21's default crypto backend pulls rcgen 0.14.8 and time
0.3.51, both of which require rustc 1.88+. The workspace toolchain
is pinned to 1.85 (edition 2024 floor, the str0m MSRV). Pin rcgen
to 0.14.7 and time to 0.3.41 in Cargo.lock so the str0m tree
builds on the pinned toolchain. No source changes; this only
restricts transitive versions.
Builds out the user-facing docs tree alongside the slice-1 build target.
Kept the implementer's planned Task 7 'Slice 1 dev loop' README section
untouched — these docs are the canonical destination for that pointer.
- docs/QUICKSTART.md: 5-min path to 'hear the echo' (libopus install,
cargo run, browser steps, troubleshooting, what's happening under the
hood).
- docs/DEVELOPMENT.md: dev loop — workspace layout, per-crate iteration,
running tests, the 20 ms loop / 'drop + observe' rule, slice-1
boundaries (what NOT to add yet).
- CONTRIBUTING.md (at repo root, conventional): trunk-based dev,
CI gates, commit message style, atomic commits, code style +
learner-facing documentation policy, terminology policy, PR workflow
+ review checklist, GPL-3.0-or-later license.
- README.md: add a Quickstart pointer at the top, a Documentation table
linking to every doc, and the slice-1 build-target status block.
PcmFrame is the canonical tap format (16-bit mono @ 24 kHz, 480 samples
per 20 ms frame — ARCHITECTURE.md). AudioSource/AudioSink are the seam
step 2 splices the tap client into (spec §3.3); EchoAudioPipe is the
slice-1 wiring of that seam. OpusDecoder/OpusEncoder wrap the opus
crate's libopus FFI with hot-path match-and-continue (no ? on the 20 ms
loop, spec §3.8); decode/encode return Option<PcmFrame>/Option<Vec<u8>>
so a dropped frame is logged + counted, never propagated to crash the
peer.
uuid 1.x → getrandom 0.4.x requires edition = "2024", stabilized in Rust 1.85.
The Task 1 pin of 1.80 was written when uuid 1.x resolved to a getrandom that
still compiled on edition 2021; the resolution has shifted. The brief said
'confirm the latest stable at impl time' — bumping the pin is faithful to that.
Plan doc's Task 6 CI matrix also updated: [stable, "1.85"].
Cross-task correction in response to Task 2 implementer's DONE_WITH_CONCERNS
escalation; not a Task 2 code change.
rutster-call-model is real-but-minimal (spec §5): the unifying leg
object the future API exposes. ChannelId is a Uuid newtype for
type-safety (the slice-1 worked example of the newtype pattern).
Channel is signaling-state only — media lives in rutster-media as a
leaf concern of the Channel, surfaced only when a second consumer needs
to observe it (spec §5.3). ChannelState matches the New→Connecting→
Connected→Closing→Closed flow from §5.4.
Workspace root, pinned toolchain, and the three stub crates whose only
job in slice 1 is to lock the ADR-0002 boundary shape. Each ships a
lib.rs module doc (what it will hold, why deferred, which spearhead step
fills it) and a crate_compiles test. Spec §2.2.
Verified str0m 0.21 + opus 0.3.1 API surfaces via docs.rs subagents;
patched the plan with the real signatures and several design-level fixes.
API-surface patches (str0m 0.21, verified):
- Global Constraints: full verified API surface documented inline
(Rtc::new takes Instant; SdpOffer::from_sdp_string is the entry
point, NOT from_str_unchecked; add_local_candidate returns
Option<&Candidate>; Writer::write takes rtp_time not media_time;
MediaTime has no add(Duration) -> use mt + MediaTime::from(d);
payload_params returns impl Iterator; MediaData.data is Arc<[u8]>).
- Task 4 accept_offer: rewritten with from_sdp_string + correct error
mapping; RtcSessionError::SdpOffer changed to String (collapses parse
+ accept failures uniformly).
- Task 4 loop_driver: MediaTime::add -> + MediaTime::from(Duration);
media.data deref coercion documented.
Design-level patches:
- Task 4: RtcSession::new_for_test -> pub fn new() (single idiomatic
constructor, no test/prod split).
- Task 4: added accept_offer_transitions_channel_to_connecting test
(the transition was claimed but untested in the original plan).
- Task 5: dropped reqwest from workspace deps (unused — integration
test uses tower::ServiceExt::oneshot); added tower as workspace dep
+ binary crate dev-dependency.
- Task 5: removed duplicate DELETE /v1/sessions route (was incorrectly
chained via .delete() on the collection route AND on /v1/sessions/:id
— only the latter is correct).
- Task 5: clarified pub mod requirement (must be pub because the
integration test references modules via absolute paths).
- Global Constraints: added task/PR strategy (one commit per task,
merged in numeric order, granular history is load-bearing for the
learning-codebase goal).
Self-review section updated to reflect the patches.
Plan is now implementation-ready.
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.
Project orientation for any agent (human/AI/hybrid) working in the repo.
Covers: project structure (current + planned), build/lint/test commands,
Rust code style (formatting, naming, error handling, async/concurrency,
learner-facing documentation comments), terminology policy (inclusive
language + ICE-as-protocol-convention exception), architecture
pre-reading order, key decisions to respect, git workflow, and the
slice-1 boundaries (what NOT to add yet).