Commit Graph

56 Commits

Author SHA1 Message Date
opencode controller
d2ef53b090 fmt: apply rustfmt across slice-2 + slice-1-review-fixes code
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.
2026-06-29 20:38:14 -04:00
opencode controller
8a3c9b08c7 docs(slice-1): F9 — explicit 20-ms assumption on OpusDecoder::pcm_buf
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.
2026-06-29 20:36:35 -04:00
opencode controller
0f3ee99354 fix(slice-1): F1+F3 — feed Input::Timeout + honor drive() return in sleep
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).
2026-06-29 20:36:35 -04:00
opencode controller
433682f71e docs(reviews): file GTM / positioning / pivots review from Claude
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.
2026-06-29 20:33:55 -04:00
opencode controller
87fe496671 fix(slice-1): F4 — idle timer keys on MediaData not every datagram
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.)
2026-06-29 20:33:55 -04:00
opencode controller
e70e784835 refactor(slice-1): PacketIo trait — test seam for RtcSession.socket
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).
2026-06-29 20:33:55 -04:00
opencode controller
36bb4caa3f fix(slice-1): F6 — drop dead Channel.created_at field
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.
2026-06-29 20:31:36 -04:00
opencode controller
ede94a14b6 docs(reviews): file vision sanity-check from Claude
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.
2026-06-29 20:31:09 -04:00
opencode controller
19cbe4b5e3 fix(slice-1): F7 — remove unreachable needs_redrain arm in Step 2
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.
2026-06-29 20:31:09 -04:00
opencode controller
3f79a81dcf fix(slice-1): F2 — second offer returns 409 Conflict not panic
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.
2026-06-29 20:31:09 -04:00
opencode controller
2076b39191 spec(slice-1): adversarial review patches
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.
2026-06-29 20:28:16 -04:00
opencode controller
42060b68e3 spec(ui): default operator console design (later-rung, not yet scheduled)
Some checks failed
CI / fmt (pull_request) Failing after 58s
CI / clippy (pull_request) Failing after 33s
CI / test (1.85) (pull_request) Failing after 32s
CI / test (stable) (pull_request) Failing after 47s
CI / deny (pull_request) Failing after 41s
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).
2026-06-29 20:27:25 -04:00
opencode controller
4b0f53eb2d tooling: gitignore *.kate-swp + .superpowers/ scratch dir
- *.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.
2026-06-29 20:27:11 -04:00
opencode controller
3b7b0d6459 docs: propagate rutster-trunk rename + repo URL through plans/specs/DEVELOPMENT
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.
2026-06-29 20:27:01 -04:00
opencode controller
7616878bb1 docs: propagate ADR-0007/0008 to README, ARCHITECTURE, PORT_PLAN, AGENTS
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.'
2026-06-29 20:26:51 -04:00
opencode controller
3c3197e57a refactor(workspace): rutster-signaling-sip → rutster-trunk; edition 2024; repo URL = git.adlee.work
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.
2026-06-29 20:26:37 -04:00
opencode controller
272aa07acd adr: +0007 rent trunk transport +0008 FOB/green-zone doctrine; supersede 0003
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.
2026-06-29 20:26:24 -04:00
5a193871d8 Merge pull request 'Slice 2 — The agent tap: splicing the brain seam' (#2) from slice-2-agent-tap into main
Some checks failed
CI / fmt (push) Failing after 14m55s
CI / clippy (push) Failing after 1m16s
CI / test (1.85) (push) Failing after 27s
CI / test (stable) (push) Failing after 30s
CI / deny (push) Failing after 1m14s
Reviewed-on: #2
2026-06-28 22:35:27 +00:00
opencode controller
b2aaf149b0 fix(slice-2): widen outer close-bound + strengthen reconnect-test assertion
Some checks failed
CI / clippy (pull_request) Failing after 43s
CI / test (1.85) (pull_request) Failing after 39s
CI / test (stable) (pull_request) Failing after 42s
CI / deny (pull_request) Failing after 39s
CI / fmt (pull_request) Failing after 10m45s
- 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.
2026-06-28 18:21:52 -04:00
opencode controller
0d666b5ba7 fix(slice-2): §5.1 teardown seq + §5.3 playout flush + §8.4 reconnect test + §7.2 index.html
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.
2026-06-28 18:17:31 -04:00
opencode controller
411276bfde test(slice-2): integration test + Python echo brain + LEARNING pointers
- Integration test: smoke test against in-process EchoServer (hello
  handshake round-trip). Full end-to-end + reconnect-path tests adapt
  to slice-1's existing integration-test harness.
- examples/echo_brain/: Python reference brain (~80 lines, websockets lib).
  README documents runtime posture + why a Python brain complements the
  Rust one (language-agnosticism vs wire-types-reusability).
- LEARNING.md: 5 new pointers (mpsc/oneshot, VecDeque ring, async WS,
  Box<dyn Trait> field widening, zero-sized marker newtype).

Spec ref: 2026-06-28-slice-2-agent-tap-design.md §8.4, §8.5 #7.
2026-06-28 17:52:16 -04:00
opencode controller
84fc30591e fix(binary): spec-exact backoff staircase + remove dead Closing branch
- 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.
2026-06-28 17:46:50 -04:00
opencode controller
6bc6f6426a feat(binary): wire TapEngine into session lifecycle (spec §5.1, §7)
- tap_engine.rs: spawn_tap_engine(session_id, tap_url) — dials the brain,
  runs TapClient with bounded exponential backoff (250ms→5s cap, infinite
  retries). Cold-path network I/O on tokio's pool, NOT a timing thread
  (spec §4.3 deviation boundary).
- session_map.rs: +tap_registry (DashMap<ChannelId, TapConn>); +default_tap_url.
  drive_all_sessions observes Connected+tap.is_none() → spawn TapEngine, set
  channel.tap = Some(TapHandle). Observes Closing+tap.is_some() → fire close
  oneshot, abort task, clear tap BEFORE state advances to Closed.
- routes.rs: POST /v1/sessions accepts optional {"tap_url":...} body;
  resolve_tap_url validates ws:// 127.0.0.1/localhost, rejects wss:// + non-
  loopback ws:// + bad schemes with 400 Bad Request (fail-fast per spec §4.4).
- main.rs: reads RUTSTER_TAP_URL env (default ws://127.0.0.1:8081/echo).
- rtc_session.rs (rutster-media): pipe field changed from EchoAudioPipe to
  Box<dyn AudioPipe + Send> — the seam test (§8.5 #6):
  loop_driver's call sites (sink.on_pcm_frame / source.next_pcm_frame)
  are byte-identical; only the field type widens. RtcSession::new keeps
  EchoAudioPipe default (slice-1 tests unchanged); binary uses
  RtcSession::set_pipe(tap_audio_pipe) to swap on the Connected transition.
- 4 unit tests for tap_url validation (loopback accepted, non-loopback
  rejected, wss rejected fail-fast, default fallback).

Spec ref: 2026-06-28-slice-2-agent-tap-design.md §4.3, §4.4, §5.1, §7.
2026-06-28 17:39:52 -04:00
opencode controller
e0616fe42d feat(call-model): +tap field on Channel + TapHandle marker (spec §5.2, §6)
- 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.
2026-06-28 17:24:11 -04:00
opencode controller
237a1388a4 feat(tap-echo): Rust reference echo brain + test server (spec §2.3)
- start_echo_server(addr): in-process WS server for integration tests;
  returns EchoHandle { shutdown, join, addr }.
- echo_one_connection: the per-connection echo loop — hello handshake,
  audio_in → audio_out (same PCM), bye/session_end graceful close.
- Reuses rutster-tap's protocol types — the wire-types-reusable contract
  test (spec §2.3).
- Stateless across reconnects (spec §5.3) — every hello starts fresh.
- Standalone binary: binds ws://127.0.0.1:8081, runs forever.
- 1 unit test exercises full hello/ack/audio_in/audio_out/bye over a
  TCP loopback pair.

Spec ref: 2026-06-28-slice-2-agent-tap-design.md §2.3, §5.3.
2026-06-28 17:20:00 -04:00
opencode controller
1bb5b7203c feat(tap): TapClient WSS pump loop (spec §4.2)
- 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.
2026-06-28 14:25:15 -04:00
opencode controller
51d5e6b01e feat(tap): TapAudioPipe + TapMetrics (spec §4.1)
- TapMetrics: atomic counters (drop + observe per slice-1 §3.8).
- TapAudioPipe: AudioSource + AudioSink impl over mpsc + VecDeque ring.
  Drop-oldest on overflow (lowest-latency-correct); silence on underflow.
- TAP_PLAYOUT_FRAMES = 5 (100ms @ 20ms/frame; tunable constant per spec §4.1).
- 6 unit tests cover underflow, overflow drops-oldest, sink-full drops,
  disconnected engine, frame round-trip, empty ring.

Spec ref: 2026-06-28-slice-2-agent-tap-design.md §4.1.
2026-06-28 14:18:57 -04:00
opencode controller
5b931d447b feat(tap): versioned JSON event wire protocol (spec §3)
- protocol.rs: Envelope + FrameKind + Payload types, serde-derived.
- Explicit little-endian PCM codec (to_le_bytes / from_le_bytes) —
  spec §3, §9. No host-endian silent hazard.
- encode_* helpers for hello, audio_in, audio_out, session_end, bye, error.
- decode_envelope validates protocol version + samples count; unknown
  type values decode to DecodedPayload::Unknown (log + count + drop
  per spec §3.4).
- 10 unit tests cover round-trips, LE byte order, sample-count
  mismatch, unknown-type drop, and version mismatch.

Spec ref: 2026-06-28-slice-2-agent-tap-design.md §3.
2026-06-28 14:12:48 -04:00
opencode controller
0cccf77faf build(slice-2): workspace deps + rutster-tap-echo skeleton
- 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.
2026-06-28 14:01:12 -04:00
opencode controller
22d3f03b8c plan(slice-2): agent tap implementation plan (spec §1.1)
8 TDD tasks, 53 checkbox steps, sequentially dependent:
1. Workspace deps + rutster-tap-echo skeleton (build foundation)
2. Tap wire protocol (spec §3) — JSON envelope, explicit LE PCM codec
3. TapMetrics + TapAudioPipe (spec §4.1) — the seam object
4. TapClient WSS pump loop (spec §4.2) — async WS pump + seq gap detection
5. Rust echo brain crate (spec §2.3) — stereotype binary + in-process EchoServer
6. Call-model TapHandle + Channel field (spec §5.2, §6)
7. Binary wiring — TapEngine + session_map + routes + main (spec §5.1, §7)
8. Integration test + Python echo brain + LEARNING.md (spec §8.4, §8.5 #7)

Plan self-reviewed: no placeholders; TapHandle::new visibility fixed
(pub(crate) → pub); spawn_tap_engine return shape fixed (returns
(TapAudioPipe, TapConn) tuple, not TapConn holding the pipe's mpsc).

Spec ref: 2026-06-28-slice-2-agent-tap-design.md (commit f83bca9).
2026-06-28 13:54:57 -04:00
opencode controller
f83bca98db spec(slice-2): adversarial review patches
Some checks failed
CI / fmt (push) Failing after 41s
CI / clippy (push) Failing after 45s
CI / test (1.85) (push) Failing after 36s
CI / test (stable) (push) Failing after 39s
CI / deny (push) Failing after 31s
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).
2026-06-28 13:41:54 -04:00
opencode controller
11e72fa733 spec(slice-2): agent tap design
Some checks failed
CI / fmt (push) Failing after 31s
CI / clippy (push) Failing after 44s
CI / test (1.85) (push) Failing after 39s
CI / test (stable) (push) Failing after 35s
CI / deny (push) Has been cancelled
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).
2026-06-28 13:37:57 -04:00
9fcdb60548 Merge pull request 'slice-1: WebRTC media loopback — the media-core proof' (#1) from slice-1-webrtc-loopback into main
Some checks failed
CI / fmt (push) Failing after 35s
CI / clippy (push) Failing after 39s
CI / test (1.85) (push) Failing after 41s
CI / test (stable) (push) Failing after 44s
CI / deny (push) Failing after 34s
Reviewed-on: #1
2026-06-28 17:20:23 +00:00
opencode controller
52462723d3 Merge remote-tracking branch 'origin/main' into slice-1-webrtc-loopback
Some checks failed
CI / fmt (pull_request) Failing after 35s
CI / clippy (pull_request) Failing after 41s
CI / test (1.85) (pull_request) Failing after 36s
CI / test (stable) (pull_request) Failing after 39s
CI / deny (pull_request) Failing after 44s
# Conflicts:
#	README.md
2026-06-28 13:19:16 -04:00
adlee-was-taken
3041036d94 fix: restore doc bar on RtcSession accessors; refresh stale README status
Some checks failed
CI / fmt (pull_request) Failing after 42s
CI / clippy (pull_request) Failing after 1m19s
CI / test (1.85) (pull_request) Failing after 49s
CI / test (stable) (pull_request) Failing after 29s
CI / deny (pull_request) Failing after 46s
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.
2026-06-28 13:13:27 -04:00
adlee-was-taken
85a58ab880 deny: workaround cargo-deny 0.18.x GPL-3.0-or-later parse bug
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.
2026-06-28 13:08:19 -04:00
adlee-was-taken
f765fc2b27 docs: LEARNING.md + fuzz/ placeholder + README dev-loop (spec §7, §6.3)
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.
2026-06-28 13:06:37 -04:00
adlee-was-taken
f92e6f467b ci: cargo-deny + GitHub Actions workflow (spec §6.1, §6.2)
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).
2026-06-28 12:59:29 -04:00
adlee-was-taken
1f0f64c1c6 binary: axum signaling + DashMap session store + browser test client
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.
2026-06-28 12:48:02 -04:00
adlee-was-taken
34c590b48a media: RtcSession + str0m poll loop (the media core)
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).
2026-06-28 12:39:57 -04:00
adlee-was-taken
27fb64aad5 lock: pin transitive rcgen/time deps for rust 1.85
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.
2026-06-28 12:39:44 -04:00
adlee-was-taken
4c0898cd49 docs: QUICKSTART + DEVELOPMENT + CONTRIBUTING, polish README index
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.
2026-06-28 12:32:12 -04:00
adlee-was-taken
e09af55e00 media: PcmFrame + AudioSource/Sink + Opus codec pair
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.
2026-06-28 12:22:29 -04:00
adlee-was-taken
8260c76824 toolchain: bump pinned Rust 1.80 → 1.85 (edition 2024 floor)
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.
2026-06-28 11:29:46 -04:00
adlee-was-taken
a5d0f90fe2 call-model: Channel + ChannelId + ChannelState (signaling embryo)
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.
2026-06-28 11:27:55 -04:00
adlee-was-taken
39245a6553 workspace: scaffold + three stub crates (sip/tap/spend)
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.
2026-06-28 11:22:52 -04:00
adlee-was-taken
470a22a6b7 git: ignore local worktree directories
Per using-git-worktrees skill — local .worktrees/ is for isolated
development workspaces, not tracked content. .
2026-06-28 11:20:58 -04:00
adlee-was-taken
3c5cc75015 plan(slice-1): adversarial review patches
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.
2026-06-28 11:19:41 -04:00
adlee-was-taken
1164770d98 spec(slice-1): adversarial review fixes
API-surface corrections (verified against str0m 0.21 docs):
- str0m::Live -> str0m::Rtc (no Live type exists)
- needs_input_timeout() -> Rtc::poll_output() returning Output::Timeout(Instant)
- RtcConfig::with_dtls_cert -> RtcConfig::set_dtls_cert (also: str0m auto-gens
  a cert if none passed; slice 1 still passes explicitly for ownership clarity)
- Event::RtpPacket(pkt, rid) -> Event::RtpPacket(RtpPacket) { single struct,
  RID is a field }
- str0m::ice::IceAgent -> no public 'ice' module; ICE types at crate root:
  Candidate, CandidateKind, IceCreds, IceConnectionState
- str0m.write_rtp() on Rtclive -> Frame API path: Rtc::writer(mid) -> Writer::write

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

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

Resolves 5 load-bearing API errors + 3 design-level ambiguities. Spec is now
implementation-ready pending user review.
2026-06-28 10:41:18 -04:00
adlee-was-taken
504d0020f4 docs: add AGENTS.md for opencode
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).
2026-06-28 09:34:04 -04:00