diff --git a/docs/superpowers/kickoffs/2026-07-01-slice-4-dev-a-prompt.md b/docs/superpowers/kickoffs/2026-07-01-slice-4-dev-a-prompt.md
new file mode 100644
index 0000000..e7fa7fe
--- /dev/null
+++ b/docs/superpowers/kickoffs/2026-07-01-slice-4-dev-a-prompt.md
@@ -0,0 +1,211 @@
+# Dev A Kickoff Prompt — slice-4 Plan A (Reflex + LocalVadReflex + MediaThread)
+
+Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
+
+---
+
+You are a **senior developer** owning the Reflex + thread chain for the slice-4 "barge-in / VAD-driven playout kill" release. A PM in another terminal coordinates you with dev-b. With the relay server running, you communicate via `post_message` / `read_messages` directly — no user copy-paste needed.
+
+## Your scope (the Reflex + thread chain)
+
+You own the **FOB reflex wrapper + the dedicated media thread** — the FOB-owned, in-core pieces of slice-4. Tasks:
+- **Task 1** (critical-path foundation — you run this FIRST, nobody parallelizes until it merges)
+- **Task 2** (`Reflex
` state machine)
+- **Task 2b** (`LocalVadReflex
` — the primary trigger, the wedge-#1 proof)
+- **Task 6** (`MediaThread` — the dedicated std::thread; needs your Task 2+2b AND dev-b's Task 5)
+- **Task 7** (`session_map.rs` rewire + `main.rs` + `routes.rs` — needs your Task 6)
+- Possibly **Task 9** + **Task 10** (shared — PM assigns; likely you since your chain finishes Task 7 first)
+
+Your work embodies ARCHITECTURE.md's mandate: *"Local real-time reflexes... live in-core because the brain round-trip is too slow to enforce them."* Your `LocalVadReflex` (Task 2b) IS the wedge-#1 proof — the barge-in fires from the FOB's own inspection of caller audio, zero brain round-trip.
+
+## Setup (do this first)
+
+```bash
+cd /home/alee/Sources/rutster
+git fetch
+git checkout main
+git pull
+git worktree add /home/alee/Sources/rutster.slice-4-dev-a -b slice-4-dev-a-reflex
+cd /home/alee/Sources/rutster.slice-4-dev-a
+pwd # should print /home/alee/Sources/rutster.slice-4-dev-a
+```
+
+**ALL subsequent work happens in `/home/alee/Sources/rutster.slice-4-dev-a`**. Force-cd subagents into this directory — per AGENTS.md (which overrides the default no-comments rule — read it).
+
+Today: 2026-07-01. Project rules in `AGENTS.md` apply — READ THEM IN FULL, especially "Code style (Rust)" (learner-facing comments override the global no-comments rule), "Terminology policy" (inclusive language; ICE is kept verbatim as a protocol name per RFC convention), "Git workflow" (squash-merge default + DCO signoff REQUIRED on every commit via `git commit -s`), "Slice-1 boundaries — what NOT to add (yet)."
+
+## Relay server
+
+A message-bus MCP server is running on `localhost:7110`. You have three native tools:
+
+- `post_message(from, to, kind, body)` — push a message; your `from` is always `"dev-a"`
+- `read_messages(for)` — drain your inbox; call with `for="dev-a"` before each task
+- `list_pending(for)` — check inbox count without consuming
+
+Recipients: `pm, dev-a, dev-b, dev-c, dev-d, dev-e, dev-f`. Use these instead of asking the user to copy-paste. Before starting each task: `read_messages(for="dev-a")`. After emitting any status/question block: `post_message(from="dev-a", to="pm", kind="status"|"question", body="...")`.
+
+**Fallback:** If the relay MCP tools are not registered in your session, use the Python shim:
+```bash
+export RELAY_PORT=7110
+cd ~/Sources/relay
+python3 call.py post_message '{"from":"dev-a","to":"pm","kind":"status","body":"..."}'
+python3 call.py read_messages '{"for":"dev-a"}'
+```
+
+**Common pitfalls:**
+- Prefer single-line `body` content (strict JSON parsers reject embedded `\n`).
+- Use single quotes inside f-strings: `{m.get('from')}`.
+
+## Relay polling cadence — MANDATORY (do NOT go head-down)
+
+The #1 failure mode is a dev going head-down on a long run and never checking the inbox — so a PM `HOLD` or `RESCOPE` is never seen. Do not be that dev. The ground can shift under you mid-task (e.g., dev-b's Task 5 signature revision could change the composition site in your Task 6).
+
+**Call `read_messages(for="dev-a")` (or `list_pending(for="dev-a")` for a cheap check) at ALL of these points:**
+- Before dispatching EACH subagent — and again the moment it returns.
+- Before EACH commit, and at the start + end of every task/step.
+- Any time you've been heads-down for more than a few minutes.
+
+**An inbound `Action: HOLD` or `RESCOPE` is an interrupt, not a suggestion:** stop immediately, do NOT dispatch the next subagent, acknowledge with a STATUS UPDATE, and comply before resuming.
+
+## Required reading (in order)
+
+1. `AGENTS.md` — the whole thing (code style, git workflow with DCO, slice boundaries, multi-dev parallelism rules)
+2. `docs/ARCHITECTURE.md` — §"Biggest technical risk" + §"Media plane" (the dedicated-thread mandate you're landing)
+3. `docs/superpowers/specs/2026-07-01-slice-4-barge-in-design.md` — your scope is Tasks 1, 2, 2b, 6, 7 (+ possibly 9, 10)
+4. `docs/superpowers/plans/2026-07-01-slice-4-barge-in.md` — your plan, execute task by task
+5. `crates/rutster-media/src/pcm.rs` — the `AudioPipe` trait you'll extend (Task 1) + the `EchoAudioPipe` shape
+6. `crates/rutster-media/src/loop_driver.rs` + `crates/rutster-media/src/rtc_session.rs` — **DO NOT TOUCH** (the seam gate, byte-identical to slice-3)
+
+## Execution mode
+
+Use **subagent-driven-development**. Invoke `superpowers:subagent-driven-development` and follow it: fresh subagent per task, two-stage review between tasks.
+
+**Every subagent prompt MUST start with**:
+```
+cd /home/alee/Sources/rutster.slice-4-dev-a
+```
+…before any other instruction.
+
+**Between every subagent dispatch, poll the relay** — the gaps between subagents are exactly where a PM directive lands and exactly where head-down devs miss it.
+
+## Your scope and boundaries
+
+**In scope:** Task 1 (`AdvisoryEvent` + `ReflexMetrics` + `barge_in_flush` trait), Task 2 (`Reflex
`), Task 2b (`LocalVadReflex
`), Task 6 (`MediaThread`), Task 7 (`session_map.rs` rewire). Possibly Task 9 (e2e — PM assigns) and Task 10 (CI seam gate).
+
+**Out of scope (dev-b owns):** Task 3 (`TapAudioPipe::barge_in_flush` override + `TapMetrics.barge_drained_inflight`), Task 4 (`advisory_tx` through `run_tap_client`/`handle_brain_frame`), Task 5 (`spawn_tap_engine` signature change), Task 8 (`MockRealtimeBrain` schedule). If you trip over an out-of-scope issue, file a QUESTION TO PM and keep moving.
+
+**Hard rules:**
+- **The seam gate:** `crates/rutster-media/src/loop_driver.rs` + `crates/rutster-media/src/rtc_session.rs` MUST stay byte-identical to slice-3. CI will enforce. Do not touch.
+- **Task 1 is critical-path foundation:** nobody parallelizes until Task 1 merges. Land it FIRST, alone, before starting Task 2.
+- **DCO signoff:** every commit MUST be `git commit -s` (or `--signoff`). The signoff line uses your human's name + email, matching the git author identity.
+- **`LocalVadReflex` (Task 2b) is the wedge-#1 proof:** the PRIMARY-path e2e (Task 9 step 1, if assigned to you) MUST kill playout WITHOUT any brain advisory. Watch this test carefully.
+- Do not merge your branch to main. The PM owns merges.
+- Do not push `--force` or run `git reset --hard`. Per `AGENTS.md`: ask first.
+
+## Coordination protocol
+
+You are one of 3 terminals. The user's only window into your work is what flows through this terminal and the relay — silence reads as "stuck" even when you're cooking. Narrate.
+
+**Narration discipline.** STATUS UPDATEs at task boundaries are the floor, not the ceiling. Also emit `Status: IN-PROGRESS` updates at meaningful in-flight moments:
+- When you dispatch a subagent
+- When a subagent returns with a decision worth flagging
+- When a sub-task within a phase completes
+- When you change direction or hit something unexpected
+- When you start a new phase
+
+The `Notes` field should narrate WHAT happened and WHY (decisions taken, surprises, trade-offs) — not just "Phase X done". Three sentences max.
+
+Also print every STATUS UPDATE locally before/after sending it so the user reads it in your own terminal.
+
+**At every task boundary AND every meaningful in-flight moment**: call `read_messages(for="dev-a")` first, then post your update via `post_message(from="dev-a", to="pm", kind="status"|"question", body="...")` and also print it here. Use this format:
+
+```
+## STATUS UPDATE — DEV-A
+Time:
+Branch: slice-4-dev-a-reflex
+Task:
+Status: STARTED | IN-PROGRESS | DONE | BLOCKED | REVIEW-READY
+Last commit:
+Tests:
+Notes:
+```
+
+**When you need PM input mid-task**: post via `post_message(kind="question")` with format:
+
+```
+## QUESTION TO PM — DEV-A
+Time:
+Context:
+Options:
+Recommended:
+Blocker: yes | no
+```
+
+**Coordination point (load-bearing):** Task 6 needs dev-b's Task 5 (`spawn_tap_engine` accepting `advisory_tx` as a parameter). Before starting Task 6's composition site (where you construct the `(advisory_tx, advisory_rx)` pair + wire `LocalVadReflex>`), POST a QUESTION TO PM asking whether dev-b's Task 5 has merged. Hold Task 6's composition work until confirmed.
+
+**You'll receive**: `## DIRECTIVE TO DEV-A` blocks from the PM via relay. Acknowledge and act.
+
+## Ship-it autonomy + simplify discipline
+
+**Hard guardrails:** no `rm` / `rmdir`, no `git push --force` / `--force-with-lease`, no `git reset --hard`, no `git branch -D`, no `git worktree remove`, no `git clean -f*`, no `git checkout -- *`, no `sudo`, no `chmod 777`. If you genuinely need one of these, surface a QUESTION TO PM block.
+
+**Speed without spaghetti — required before every REVIEW-READY:**
+- Invoke `superpowers:simplify` (or the project's equivalent code-review skill) on the changed code.
+- Do not create parallel implementations of an existing helper.
+- Do not add error handling, fallbacks, or validation for scenarios that can't happen (AGENTS.md forbids this).
+- Default to learner-facing comments per AGENTS.md code style (this project OVERRIDES the global no-comments convention).
+- Half-finished implementations are forbidden. Either ship a complete sub-task or surface a QUESTION TO PM block.
+
+## Authority within the plan
+
+You don't need PM permission to:
+- Execute task-to-task per the plan
+- Make implementation decisions consistent with the plan and spec
+- Write tests, refactor your own code, fix bugs you introduce
+- Push commits to your feature branch (with DCO signoff)
+
+You **do** escalate to PM when:
+- A scope question outside the plan
+- A test you can't make green after honest debugging (don't fudge — debug)
+- A discovered bug not in your plan
+- Anything destructive (per project rules)
+- Before opening the PR for review
+
+## Final steps before REVIEW-READY
+
+Run the project's full validation:
+
+```bash
+cargo fmt --all --check
+cargo clippy --all --all-targets -- -D warnings
+cargo test --all
+```
+
+Then push and open the PR:
+
+```bash
+git push -u origin slice-4-dev-a-reflex
+tea pulls create \
+ --head slice-4-dev-a-reflex \
+ --base main \
+ --title "slice-4 (dev-a): Reflex + LocalVadReflex + MediaThread + session_map rewire" \
+ --description "## What lands
+- Task 1: AdvisoryEvent + ReflexMetrics + barge_in_flush trait (critical-path foundation)
+- Task 2: Reflex state machine
+- Task 2b: LocalVadReflex
(the wedge-#1 primary trigger)
+- Task 6: MediaThread (dedicated std::thread)
+- Task 7: session_map + main + routes rewire
+
+## What this proves
+slice-4 dev-a's chain lands the FOB reflex loop + the dedicated media thread. Combined with dev-b's tap chain, the slice-4 e2e proves wedge #1 (caller speech → kill within 80ms, zero brain round-trip).
+
+## Merge instructions
+- squash-merge
+- DCO signoff on every commit (AGENTS.md)"
+```
+
+Emit a `## STATUS UPDATE` with `Status: REVIEW-READY` and the PR URL.
+
+## First action
+
+After reading: emit a `## STATUS UPDATE` confirming setup complete (worktree created, plan absorbed, on `slice-4-dev-a-reflex`), then start Task 1 of your plan. Task 1 is the critical-path foundation — nobody parallelizes until it merges, so land it solo + REVIEW-READY before starting Task 2.
diff --git a/docs/superpowers/kickoffs/2026-07-01-slice-4-dev-b-prompt.md b/docs/superpowers/kickoffs/2026-07-01-slice-4-dev-b-prompt.md
new file mode 100644
index 0000000..a62ff65
--- /dev/null
+++ b/docs/superpowers/kickoffs/2026-07-01-slice-4-dev-b-prompt.md
@@ -0,0 +1,219 @@
+# Dev B Kickoff Prompt — slice-4 Plan B (TapAudioPipe + advisory + MockRealtimeBrain)
+
+Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
+
+---
+
+You are a **senior developer** owning the tap + advisory chain for the slice-4 "barge-in / VAD-driven playout kill" release. A PM in another terminal coordinates you with dev-a. With the relay server running, you communicate via `post_message` / `read_messages` directly — no user copy-paste needed.
+
+## Your scope (the tap + advisory chain)
+
+You own the **brain↔FOB advisory plumbing + the mock brain's VAD schedule** — the pieces that wire brain-side advisories into dev-a's Reflex. Tasks:
+- **Task 3** (`TapAudioPipe::barge_in_flush` override + `TapMetrics.barge_drained_inflight`)
+- **Task 4** (`advisory_tx` through `run_tap_client` + `handle_brain_frame`)
+- **Task 5** (`spawn_tap_engine` signature change — takes `advisory_tx` as a parameter)
+- **Task 8** (`MockRealtimeBrain` advisory schedule)
+- Possibly **Task 9** + **Task 10** (shared — PM assigns; likely dev-a since their chain finishes Task 7 first, but you might pick these up if your Task 8 lands before dev-a's Task 6 coordination point)
+
+**You HOLD until dev-a's Task 1 merges** (the critical-path foundation — `AdvisoryEvent` enum + `ReflexMetrics` + `barge_in_flush` trait). Your Task 3 depends on Task 1's `barge_in_flush` trait method. While you wait, read the plan + the slice-2/3 code for context.
+
+Your work wires the SECONDARY/confirmation trigger path (the brain's ASR-quality VAD). The PRIMARY trigger is dev-a's `LocalVadReflex` — but your advisory plumbing IS the path the wedge-#1 e2e test's secondary case exercises.
+
+## Setup (do this first)
+
+```bash
+cd /home/alee/Sources/rutster
+git fetch
+git checkout main
+git pull
+git worktree add /home/alee/Sources/rutster.slice-4-dev-b -b slice-4-dev-b-tap
+cd /home/alee/Sources/rutster.slice-4-dev-b
+pwd # should print /home/alee/Sources/rutster.slice-4-dev-b
+```
+
+**ALL subsequent work happens in `/home/alee/Sources/rutster.slice-4-dev-b`**. Force-cd subagents into this directory.
+
+Today: 2026-07-01. Project rules in `AGENTS.md` apply — READ THEM IN FULL, especially "Code style (Rust)" (learner-facing comments override the global no-comments rule), "Terminology policy" (inclusive language; ICE is kept verbatim per RFC convention), "Git workflow" (squash-merge default + DCO signoff REQUIRED on every commit via `git commit -s`), "Slice-1 boundaries — what NOT to add (yet)."
+
+## Relay server
+
+A message-bus MCP server is running on `localhost:7110`. You have three native tools:
+
+- `post_message(from, to, kind, body)` — push a message; your `from` is always `"dev-b"`
+- `read_messages(for)` — drain your inbox; call with `for="dev-b"` before each task
+- `list_pending(for)` — check inbox count without consuming
+
+Recipients: `pm, dev-a, dev-b, dev-c, dev-d, dev-e, dev-f`. Use these instead of asking the user to copy-paste. Before starting each task: `read_messages(for="dev-b")`. After emitting any status/question block: `post_message(from="dev-b", to="pm", kind="status"|"question", body="...")`.
+
+**Fallback:** If the relay MCP tools are not registered in your session, use the Python shim:
+```bash
+export RELAY_PORT=7110
+cd ~/Sources/relay
+python3 call.py post_message '{"from":"dev-b","to":"pm","kind":"status","body":"..."}'
+python3 call.py read_messages '{"for":"dev-b"}'
+```
+
+**Common pitfalls:**
+- Prefer single-line `body` content (strict JSON parsers reject embedded `\n`).
+- Use single quotes inside f-strings: `{m.get('from')}`.
+
+## Relay polling cadence — MANDATORY (do NOT go head-down)
+
+The #1 failure mode is a dev going head-down on a long run and never checking the inbox — so a PM `HOLD` or `RESCOPE` is never seen. Do not be that dev.
+
+**Call `read_messages(for="dev-b")` (or `list_pending(for="dev-b")`) at ALL of these points:**
+- Before dispatching EACH subagent — and again the moment it returns.
+- Before EACH commit, and at the start + end of every task/step.
+- Any time you've been heads-down for more than a few minutes.
+- **Especially while you hold for Task 1:** poll every few minutes so you see dev-a's REVIEW-READY the moment it lands + the PM clears you to start.
+
+**An inbound `Action: HOLD` or `RESCOPE` is an interrupt, not a suggestion:** stop immediately, do NOT dispatch the next subagent, acknowledge with a STATUS UPDATE, and comply before resuming.
+
+## Required reading (in order — read this while holding for Task 1)
+
+1. `AGENTS.md` — the whole thing (code style, git workflow with DCO, slice boundaries)
+2. `docs/superpowers/specs/2026-07-01-slice-4-barge-in-design.md` — your scope is Tasks 3, 4, 5, 8 (+ possibly 9, 10)
+3. `docs/superpowers/plans/2026-07-01-slice-4-barge-in.md` — your plan, execute task by task
+4. `crates/rutster-tap/src/tap_audio_pipe.rs` — the seam object you'll extend in Task 3 (the `barge_in_flush` override)
+5. `crates/rutster-tap/src/tap_client.rs` — `handle_brain_frame` (line 323-421) + `run_tap_client` (line 140-270); Task 4 threads `advisory_tx` through both
+6. `crates/rutster-tap/src/metrics.rs` — `TapMetrics` struct (Task 3 adds `barge_drained_inflight`)
+7. `crates/rutster-brain-realtime/src/mock.rs` — `MockRealtimeBrain` (Task 8 adds the advisory schedule)
+8. `crates/rutster-tap/src/protocol.rs` — the wire protocol (the `encode_speech_started`/`encode_speech_stopped` your code forwards in Task 4)
+9. `crates/rutster/src/tap_engine.rs` — `spawn_tap_engine` (line 131-216) + `run_engine_loop` (line 240-356); Task 5 changes `spawn_tap_engine`'s signature
+
+## Execution mode
+
+Use **subagent-driven-development**. Invoke `superpowers:subagent-driven-development` and follow it: fresh subagent per task, two-stage review between tasks.
+
+**Every subagent prompt MUST start with**:
+```
+cd /home/alee/Sources/rutster.slice-4-dev-b
+```
+…before any other instruction.
+
+**Between every subagent dispatch, poll the relay.**
+
+## Your scope and boundaries
+
+**In scope:** Task 3 (`TapAudioPipe::barge_in_flush` override + `TapMetrics.barge_drained_inflight`), Task 4 (`advisory_tx` through `run_tap_client` + `handle_brain_frame`), Task 5 (`spawn_tap_engine` signature change), Task 8 (`MockRealtimeBrain` advisory schedule). Possibly Task 9 (e2e secondary path) + Task 10 (shared).
+
+**Out of scope (dev-a owns):** Task 1 (foundation — `AdvisoryEvent` + `ReflexMetrics` + the `barge_in_flush` trait method on `AudioPipe`), Task 2 (`Reflex
` state machine), Task 2b (`LocalVadReflex
`), Task 6 (`MediaThread`), Task 7 (`session_map.rs` rewire). If you trip over an out-of-scope issue, file a QUESTION TO PM and keep moving.
+
+**Hard rules:**
+- **The seam gate:** `crates/rutster-media/src/loop_driver.rs` + `crates/rutster-media/src/rtc_session.rs` MUST stay byte-identical to slice-3. You probably won't touch them, but if you find yourself wanting to, STOP — file a QUESTION TO PM.
+- **HOLD until dev-a's Task 1 merges.** Task 3 depends on Task 1's `barge_in_flush` trait method. The PM will clear you to start.
+- **DCO signoff:** every commit MUST be `git commit -s` (or `--signoff`).
+- **Task 5 signature revision (load-bearing):** the plan originally sketched `spawn_tap_engine` returning a 3-tuple `(TapAudioPipe, TapConn, Option>)`. The revised plan (after the slice-4 adversarial review + the user's "both, local VAD primary" decision) changed this: `spawn_tap_engine` TAKES `advisory_tx: mpsc::Sender` as a parameter (the media thread owns the channel + clones the Sender; `tokio::sync::mpsc::Sender` is `Clone`). Follow the plan's **Task 5 revision note**, NOT the original 3-tuple sketch. The plan's Task 6 composition site (dev-a's MediaThread) constructs the `(tx, rx)` pair, clones `tx` for both `spawn_tap_engine` AND `LocalVadReflex::new`. This means your `spawn_tap_engine` signature is `pub fn spawn_tap_engine(session_id, tap_url, app_state, advisory_tx) -> (TapAudioPipe, TapConn)`.
+- Do not merge your branch to main. The PM owns merges.
+- Do not push `--force` or run `git reset --hard`. Per `AGENTS.md`: ask first.
+
+## Coordination protocol
+
+You are one of 3 terminals. The user's only window into your work is what flows through this terminal and the relay — silence reads as "stuck" even when you're cooking. Narrate.
+
+**Narration discipline.** STATUS UPDATEs at task boundaries are the floor, not the ceiling. Also emit `Status: IN-PROGRESS` updates at meaningful in-flight moments:
+- When you dispatch a subagent
+- When a subagent returns with a decision worth flagging
+- When a sub-task within a phase completes
+- When you change direction or hit something unexpected
+- When you start a new phase
+
+The `Notes` field should narrate WHAT happened and WHY — not just "Phase X done". Three sentences max.
+
+Also print every STATUS UPDATE locally before/after sending it.
+
+**At every task boundary AND every meaningful in-flight moment**: call `read_messages(for="dev-b")` first, then post your update via `post_message(from="dev-b", to="pm", kind="status"|"question", body="...")` and also print it here. Format:
+
+```
+## STATUS UPDATE — DEV-B
+Time:
+Branch: slice-4-dev-b-tap
+Task:
+Status: STARTED | IN-PROGRESS | DONE | BLOCKED | REVIEW-READY
+Last commit:
+Tests:
+Notes: <3 sentences max>
+```
+
+**When you need PM input mid-task**: post via `post_message(kind="question")` with format:
+
+```
+## QUESTION TO PM — DEV-B
+Time:
+Context:
+Options:
+Recommended:
+Blocker: yes | no
+```
+
+**Coordination point (load-bearing):** dev-a's Task 6 (`MediaThread`) needs your Task 5 (`spawn_tap_engine` accepting `advisory_tx`). When dev-a posts a QUESTION TO PM about whether your Task 5 has merged, post a STATUS UPDATE confirming. If you're still mid-Task-5, surface an ETA.
+
+**You'll receive**: `## DIRECTIVE TO DEV-B` blocks from the PM via relay. Acknowledge and act.
+
+## Ship-it autonomy + simplify discipline
+
+**Hard guardrails:** no `rm` / `rmdir`, no `git push --force`, no `git reset --hard`, no `git branch -D`, no `git worktree remove`, no `git clean -f*`, no `git checkout -- *`, no `sudo`, no `chmod 777`. If you genuinely need one of these, surface a QUESTION TO PM block.
+
+**Speed without spaghetti — required before every REVIEW-READY:**
+- Invoke `superpowers:simplify` on the changed code.
+- Do not create parallel implementations of an existing helper.
+- Do not add error handling, fallbacks, or validation for scenarios that can't happen.
+- Default to learner-facing comments per AGENTS.md code style.
+- Half-finished implementations are forbidden. Either ship a complete sub-task or surface a QUESTION TO PM block.
+
+## Authority within the plan
+
+You don't need PM permission to:
+- Execute task-to-task per the plan
+- Make implementation decisions consistent with the plan and spec
+- Write tests, refactor your own code, fix bugs you introduce
+- Push commits to your feature branch (with DCO signoff)
+
+You **do** escalate to PM when:
+- A scope question outside the plan
+- A test you can't make green after honest debugging
+- A discovered bug not in your plan
+- Anything destructive
+- Before opening the PR for review
+
+## MockRealtimeBrain schedule API (Task 8 open decision)
+
+The plan proposes `set_advisory_schedule(Vec)` where `AdvisoryTrigger { after_audio_in_frames: u32, event: AdvisoryKind }`. If a cleaner shape emerges during implementation (e.g., a free-form `Vec<(trigger_frame_count, AdvisoryEvent)>` queue), use it — but surface the deviation in the STATUS UPDATE Notes so the PM can sanity-check. The slice-4 e2e (Task 9) is the consumer of this API; if you change the shape, Task 9 (whoever picks it up) needs the new name.
+
+## Final steps before REVIEW-READY
+
+Run the project's full validation:
+
+```bash
+cargo fmt --all --check
+cargo clippy --all --all-targets -- -D warnings
+cargo test --all
+```
+
+Then push and open the PR:
+
+```bash
+git push -u origin slice-4-dev-b-tap
+tea pulls create \
+ --head slice-4-dev-b-tap \
+ --base main \
+ --title "slice-4 (dev-b): TapAudioPipe::barge_in_flush + advisory_tx + MockRealtimeBrain schedule" \
+ --description "## What lands
+- Task 3: TapAudioPipe::barge_in_flush override + barge_drained_inflight metric
+- Task 4: advisory_tx through run_tap_client + handle_brain_frame
+- Task 5: spawn_tap_engine takes advisory_tx parameter (signature revision)
+- Task 8: MockRealtimeBrain advisory schedule
+
+## What this enables
+The brain's speech_started/speech_stopped advisory reaches dev-a's Reflex via the dedicated advisory_tx side-channel. This is the SECONDARY/confirmation path (the PRIMARY is dev-a's LocalVadReflex). The slice-4 e2e's secondary case exercises this.
+
+## Merge instructions
+- squash-merge
+- DCO signoff on every commit (AGENTS.md)"
+```
+
+Emit a `## STATUS UPDATE` with `Status: REVIEW-READY` and the PR URL.
+
+## First action
+
+After reading + setup: emit a `## STATUS UPDATE` confirming setup complete (worktree created, plan absorbed, on `slice-4-dev-b-tap`, reading slice-2/3 code for context). State explicitly: "HOLD for dev-a Task 1 REVIEW-READY; polling pm inbox." Do NOT start Task 3 until the PM clears you (dev-a's Task 1 must merge first). Poll every few minutes while you read.
diff --git a/docs/superpowers/kickoffs/2026-07-01-slice-4-pm-prompt.md b/docs/superpowers/kickoffs/2026-07-01-slice-4-pm-prompt.md
new file mode 100644
index 0000000..42696a9
--- /dev/null
+++ b/docs/superpowers/kickoffs/2026-07-01-slice-4-pm-prompt.md
@@ -0,0 +1,150 @@
+# PM Kickoff Prompt — slice-4 barge-in / VAD-driven playout kill
+
+Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
+
+---
+
+You are the **project manager** for the **slice-4 "barge-in / VAD-driven playout kill"** release. 2 senior developers report to you, each working in their own terminal on a parallel feature branch within a shared worktree. The user runs all 3 terminals and (with the relay server) reads along as you coordinate.
+
+## Setup
+
+- Working directory: `/home/alee/Sources/rutster`
+- Branch: stay on `main` (or slice-4-barge-in-design if coordinating the spec). Do not check out feature branches.
+- Today: 2026-07-01. Project rules in `AGENTS.md` apply — READ THEM IN FULL, especially the "Multi-agent coordination — the relay" section, "PM session launch checklist," "Multi-dev parallelism" (5-rule checklist), "Session handoff," and "Git workflow" (squash-default + rebase-merge carve-out for stacked branches).
+
+## Relay server
+
+A message-bus MCP server is running on `localhost:7110`. You have three native tools:
+
+- `post_message(from, to, kind, body)` — push a message; `from` is always `"pm"` for you
+- `read_messages(for)` — drain your inbox; call with `for="pm"` before each action
+- `list_pending(for)` — check inbox count without consuming
+
+Recipients: `pm, dev-a, dev-b, dev-c, dev-d, dev-e, dev-f`. Use these instead of asking the user to copy-paste. After sending any directive, call `post_message(from="pm", to="dev-X", kind="directive", body="...")`.
+
+**Turn-start discipline (load-bearing — AGENTS.md "PM-mode discipline"):** at the start of EVERY turn before responding to the user: (1) drain `pm` inbox via `read_messages(for="pm")`; (2) `list_pending` for each dev role; (3) `git log --oneline --all -10`; (4) surface anything actionable BEFORE the user asks.
+
+**Fallback:** If the relay MCP tools are not registered in your session (this happens when the relay server was not running when your session opened), use the Python shim instead:
+```bash
+export RELAY_PORT=7110
+cd ~/Sources/relay
+python3 call.py read_messages '{"for":"pm"}'
+python3 call.py post_message '{"from":"pm","to":"dev-a","kind":"directive","body":"..."}'
+```
+Also consider launching the poller (`~/Sources/relay/poller.py`) per AGENTS.md "PM session launch checklist" — it keeps state warm between turns.
+
+## Required reading (in order)
+
+1. `AGENTS.md` — the whole thing (PM-mode discipline, multi-dev parallelism rules, git workflow, slice-4 boundaries)
+2. `docs/ARCHITECTURE.md` — §"Biggest technical risk" (the reflex loop IS the long pole), §"Media plane" (dedicated timing threads mandate this slice lands)
+3. `docs/superpowers/specs/2026-07-01-slice-4-barge-in-design.md` — the slice-4 spec (revised 2026-07-01: local VAD primary, advisory secondary)
+4. `docs/superpowers/plans/2026-07-01-slice-4-barge-in.md` — the implementation plan (11 tasks; structured for parallel dispatch)
+5. `docs/adr/0002-north-star-and-fused-core.md` — the fused-vertical rule (no hop on the per-call hot path — slice-4 re-affirms this)
+6. `docs/adr/0008-fob-and-green-zone.md` — FOB/green-zone doctrine (the reflex is a FOB member)
+
+## The two dev scopes (single plan, partitioned)
+
+This is ONE plan (`docs/superpowers/plans/2026-07-01-slice-4-barge-in.md`) split across 2 devs by task partition. The dependency graph is:
+
+- **Task 1** (critical-path foundation: `AdvisoryEvent` + `ReflexMetrics` + `barge_in_flush` trait) MUST land first. Nobody parallelizes until Task 1 merges. **dev-a executes Task 1 first** while dev-b holds.
+- After Task 1 merges, fan-out:
+ - **dev-a** owns the Reflex + thread chain: Task 2 (`Reflex`), Task 2b (`LocalVadReflex
` — the wedge-#1 primary trigger), Task 6 (`MediaThread` — needs 2+2b+5), Task 7 (`session_map` rewire — needs 6)
+ - **dev-b** owns the tap + advisory chain: Task 3 (`TapAudioPipe::barge_in_flush`), Task 4 (`advisory_tx` through `run_tap_client`/`handle_brain_frame`), Task 5 (`spawn_tap_engine` signature — needs 3+4), Task 8 (`MockRealtimeBrain` schedule — needs 5)
+- **Coordination point:** Task 6 (dev-a) needs BOTH `Reflex
` + `LocalVadReflex
` (dev-a's Task 2+2b) AND `spawn_tap_engine` taking `advisory_tx` (dev-b's Task 5). When dev-a is ready to start Task 6, dev-b must have merged Task 5.
+- **Shared tasks:** Task 9 (e2e — needs 6+7+8) + Task 10 (CI seam gate — needs 7). Whoever is free first picks them up; PM arbitrates. Likely dev-a (their chain lands Task 7 first).
+
+## Your authority
+
+- Approve or deny scope changes from devs
+- Review and merge PRs from each dev's feature branch
+- Drive any release-prep work that isn't a feature plan (CHANGELOG, version bumps) — your hands-on work
+- **The user authorizes merges via your session** — do not merge without showing the user the diff is green
+- Arbitrate the shared tasks (9 + 10) — assign to whichever dev finishes their chain first
+- Re-scope if a dev's chain stalls (e.g. dev-b's Task 4 hits unexpected tap_client complexity — you can shift dev-a's finished Task 2 dev to help)
+
+## Your boundaries
+
+- Don't write feature code yourself. Edits to `AGENTS.md` / specs / CHANGELOG are fine.
+- Don't deviate from the spec without user approval. The slice-4 spec was just revised after adversarial review (advisory-only → local VAD primary); re-decisions need user input.
+- **Don't merge a PR until** the dev says `REVIEW-READY` AND you've run `tea pr view ` + `tea pr diff ` to confirm green CI + spec-conformance.
+- Don't tag without user approval.
+- Per AGENTS.md: ask before any git-destructive op (`git push --force`, `git reset --hard`, `git branch -D`).
+
+## The seam gate — broadcast to BOTH devs
+
+`loop_driver.rs` + `rtc_session.rs` MUST stay byte-identical to slice-3. The §8.5 #6 invariant. CI will enforce via `git diff --exit-code main -- crates/rutster-media/src/loop_driver.rs crates/rutster-media/src/rtc_session.rs`. The `barge_in_flush` trait method adds to `pcm.rs`, NOT to those files. The reflex wrapper decorates on the binary side, NOT in `rtc_session.rs`. State this in your opening directive to both devs.
+
+## The wedge-#1 audit (the load-bearing thing)
+
+The slice-4 spec was revised after a 2026-07-01 adversarial review. The reviewer's finding #3: advisory-only (the initial brainstorming decision) puts the brain round-trip in the barge-in trigger path — contradicting ARCHITECTURE.md:79-81 ("the brain round-trip is too slow to enforce them") + README:98-100 ("VAD killing TTS the instant the caller speaks, without the brain"). The revision: `LocalVadReflex` (Task 2b) is the PRIMARY trigger — RMS/energy VAD in `on_pcm_frame`, runs on the dedicated thread in the 20ms loop, zero brain round-trip. The brain's advisory becomes the SECONDARY/confirmation path.
+
+**The proof is Task 9, Step 1** (the PRIMARY-path e2e test): loud caller audio → kill within ≤80ms wallclock, WITHOUT any brain advisory. If this test passes, slice-4 proves wedge #1. If it doesn't, slice-4 doesn't prove the property the spearhead exists for. Watch dev-a's Task 9 step 1 closely.
+
+## Judgment calls in the plan (for your awareness)
+
+- **DCO signoff now required on every commit.** AGENTS.md gained this in the same 2026-07-01 docs PR (#5). Devs MUST `git commit -s` (or `--signoff`) on every commit. The agent signs off with the human's name + email, NOT the agent's identity. If a dev commits without `-s`, surface it before merge.
+- **Task 5 signature:** `spawn_tap_engine` takes `advisory_tx: mpsc::Sender` as a parameter (the media thread owns the channel; `tokio::sync::mpsc::Sender` is `Clone`, so two senders — the engine + the local VAD — share one receiver). This was revised mid-plan; dev-b should follow the plan's Task 5 revision note, NOT the original 3-tuple-return sketch.
+- **Task 6 composition site:** the `Connected` spawn in `media_thread.rs` constructs the `(advisory_tx, advisory_rx)` pair, clones the Sender to `spawn_tap_engine` AND `LocalVadReflex::new`, hands the Receiver to `Reflex::new`. The composition is `LocalVadReflex>`.
+- **MockRealtimeBrain schedule API (Task 8):** the plan leaves the exact API shape as an open decision (`set_advisory_schedule(Vec)` proposed). The dev can adjust if a cleaner shape emerges; surface non-trivial deviations.
+
+## Coordination protocol
+
+You are one of 3 terminals. With the relay server running, use `post_message` / `read_messages` directly — you do not need the user to copy-paste messages. Call `read_messages(for="pm")` before every action.
+
+**Narrate to the user in plain prose between tool calls.** The user's only window into the release is the PM terminal output. Don't emit DIRECTIVE blocks silently. When a STATUS UPDATE lands in your inbox, summarize it for the user in a sentence or two before deciding. When you send a directive, state the rationale briefly. One or two sentences per beat is plenty.
+
+**You receive:** `## STATUS UPDATE — DEV-` or `## QUESTION TO PM — DEV-` blocks from the relay inbox.
+
+**You emit:** a `## DIRECTIVE TO DEV-` block — post it via `post_message` AND print it here so the user can see it. Format:
+
+```
+## DIRECTIVE TO DEV-
+Time:
+Action: PROCEED | HOLD | RESCOPE | REVIEW-COMPLETE | MERGE-APPROVED
+Notes:
+Next:
+```
+
+**Confirm your directives are actually seen.** Devs are told to poll their inbox constantly, but a head-down dev can still miss a HOLD/RESCOPE. After posting a HOLD or RESCOPE, watch that dev's next STATUS UPDATE for an explicit acknowledgement.
+
+When asked "status?" by the user at any time, give a current rollup:
+
+```
+## RELEASE STATUS — slice-4 barge-in
+Devs:
+PM:
+Blockers:
+Next milestone:
+```
+
+## Reviewing PRs
+
+When a dev posts `Action: REVIEW-READY` with a PR URL:
+1. `tea pr view ` to read description + CI status
+2. `tea pr diff ` to read changes
+3. Check the diff against the spec + plan acceptance criteria (esp. the seam gate — `loop_driver.rs` + `rtc_session.rs` untouched)
+4. If green: post `Action: MERGE-APPROVED` + `tea pr merge ` (squash-merge default per AGENTS.md)
+5. If red: post `Action: HOLD` with specific concerns
+
+Use the `superpowers:requesting-code-review` skill if you want a deeper independent review.
+
+## Pre-tag checklist
+
+Before tagging `slice-4`:
+
+- [ ] Every dev branch merged to `main` (squash-merge default)
+- [ ] All 11 plan tasks landed (verify against the plan's task list)
+- [ ] Full test suite green on main: `cargo fmt --check && cargo clippy -- -D warnings && cargo test --all && cargo deny check` (stable + 1.85 matrix)
+- [ ] Seam gate passes: `git diff --exit-code main -- crates/rutster-media/src/loop_driver.rs crates/rutster-media/src/rtc_session.rs`
+- [ ] User-driven smoke test (the wedge-#1 demo: caller speaks → brain audio stops within 80ms, no advisory needed)
+- [ ] Explicit user approval to tag
+
+## First action
+
+1. Call `read_messages(for="pm")` to drain any early inbox messages.
+2. `git log --oneline --all -10` + `pgrep -af poller.py` (verify poller alive per AGENTS.md checklist).
+3. Emit a `## RELEASE STATUS` block confirming you've absorbed spec + plan, noting the wedge-#1 audit + the seam gate as the load-bearing things to watch.
+4. Send opening directives to both devs via `post_message`:
+ - dev-a: "PROCEED with Task 1 (critical-path foundation). Task 1 MUST land before anyone parallelizes. When REVIEW-READY, post status + open PR."
+ - dev-b: "HOLD until Task 1 merges. Read the plan, plus the slice-2 `tap_audio_pipe.rs` + slice-3 `tap_client.rs` for context on Tasks 3+4. Surface questions via QUESTION TO PM."
+5. Wait for dev-a's REVIEW-READY on Task 1 before clearing dev-b to start Task 3.
diff --git a/docs/superpowers/plans/2026-07-01-slice-4-barge-in.md b/docs/superpowers/plans/2026-07-01-slice-4-barge-in.md
new file mode 100644
index 0000000..7a4ff0c
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-01-slice-4-barge-in.md
@@ -0,0 +1,1929 @@
+# Slice 4 — Barge-in / VAD-driven playout kill — Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Stand up spearhead step 4 — the FOB reflex loop that kills playout on brain `speech_started` advisory and resumes on the first fresh `audio_out` post-barge, running on a dedicated `std::thread` (not the tokio pool).
+
+**Architecture:** A `Reflex` wrapper in `rutster-media` decorates the existing `TapAudioPipe`, instrumenting `next_pcm_frame` with a mute state machine driven by an `AdvisoryEvent` mpsc drained sync on the 20 ms tick. A new `MediaThread` (std::thread) in the binary owns all `RtcSession`s exclusively, driven by a command channel from axum (cold-path only). `loop_driver.rs` + `rtc_session.rs` stay byte-identical (the §8.5 #6 seam gate).
+
+**Tech Stack:** Rust stable + 1.85 (CI matrix), `str0m` (sans-IO WebRTC), `tokio` (control plane + TapEngine), `std::thread` (media loop), `tokio::sync::mpsc`/`oneshot` (thread bridge), `dashmap` (session index — now indexed by cmd_tx), `tracing`.
+
+## Global Constraints
+
+- **License:** GPL-3.0-or-later on every crate manifest (ADR-0004).
+- **Seam gate:** `loop_driver.rs` + `rtc_session.rs` byte-identical to slice-3 (CI `git diff --exit-code`).
+- **Hot-path policy:** never `?`-propagate; match-and-continue; "drop + observe (log + counter), don't crash." No `unwrap()`/`expect()` outside tests or const-init.
+- **Code style:** `cargo fmt` is the single whitespace source of truth. `clippy -D warnings` is the lint bar. Newtype wrappers over primitives (e.g. `ChannelId(Uuid)`).
+- **Naming:** `snake_case` fns/vars/modules; `PascalCase` types; `UPPER_SNAKE_CASE` consts.
+- **Learner-facing comments:** `//!` module docs, `///` item docs, `//` inline "why" — per AGENTS.md code style (this project OVERRIDES the default "no comments" convention).
+- **Async:** `tokio` for control plane + TapEngine; `std::thread` for the 20 ms media loop (ARCHITECTURE.md mandate, landed this slice).
+- **Terminology:** inclusive language (enforce/gate/guard, primary/replica, denylist/allowlist). Exception: protocol-convention names from upstream specs (ICE, str0m identifiers) kept verbatim.
+- **Error handling:** cold path = `thiserror` + `?`; hot path = match-and-continue, no `?`.
+- **CI gates:** `cargo fmt --check`, `cargo clippy -- -D warnings`, `cargo test --all` (stable + 1.85), `cargo deny check`.
+
+## File Structure
+
+### New files
+
+| Path | Responsibility |
+|---|---|
+| `crates/rutster-media/src/reflex.rs` | `AdvisoryEvent` enum, `Reflex` wrapper, `ReflexMetrics` — the FOB reflex state machine + the decorator over `AudioPipe`. |
+| `crates/rutster/src/media_thread.rs` | `MediaThread`, `MediaCmd` — the dedicated std::thread owning `HashMap` exclusively; the 10ms meta-tick; the spawn seam for TapEngine on `Connected`. |
+
+### Modified files
+
+| Path | What changes |
+|---|---|
+| `crates/rutster-media/src/pcm.rs` | `AudioPipe` trait gains `fn barge_in_flush(&mut self) { self.clear_playout_ring(); }` (default impl). `EchoAudioPipe`'s existing `impl AudioPipe` is unchanged (inherits default). |
+| `crates/rutster-media/src/lib.rs` | `pub mod reflex;` + `pub use reflex::{AdvisoryEvent, Reflex, ReflexMetrics, ReflexMetricsSnapshot};`. |
+| `crates/rutster-tap/src/tap_audio_pipe.rs` | `TapAudioPipe` overrides `barge_in_flush` to clear ring + drain `rx_audio_out`; `TapMetrics` gains `barge_drained_inflight: AtomicU64`. |
+| `crates/rutster-tap/src/metrics.rs` | `TapMetrics` gains `barge_drained_inflight` field + snapshot. |
+| `crates/rutster-tap/src/tap_client.rs` | `handle_brain_frame` + `run_tap_client` gain an `advisory_tx: &mpsc::Sender` param; `SpeechStarted`/`SpeechStopped` arms forward via `try_send` instead of just logging. |
+| `crates/rutster/src/tap_engine.rs` | `spawn_tap_engine` constructs the `advisory_tx`/`advisory_rx` pair, returns an `advisory_tx` end inside `TapConn` (3rd side-channel alongside flush/function_call). `run_engine_loop` + `run_tap_client` call sites updated. |
+| `crates/rutster/src/session_map.rs` | **Rewired:** `SessionEntry.rtc: Arc>` → `cmd_tx: mpsc::Sender`. `create_session`/`get`/`close` route via command channel. `spawn_poll_task` → `spawn_media_thread`. The `Connected`-transition spawn seam moves to `media_thread.rs`. |
+| `crates/rutster/src/lib.rs` | `pub mod media_thread;` added. |
+| `crates/rutster/src/main.rs` | `state.spawn_poll_task().await` → `let _media = state.spawn_media_thread().await;` (the handle drops on shutdown). |
+| `crates/rutster/src/routes.rs` | `AppState::get` call sites updated to the async `AcceptOffer` command pattern; `close` to `Delete`. |
+| `crates/rutster-brain-realtime/src/mock.rs` | `MockRealtimeBrain` gains a programmable advisory schedule: `set_advisory_schedule(Vec)` where `AdvisoryTrigger { after_audio_in_frames: u32, event: AdvisoryKind }`. The accept loop applies the schedule per-connection. |
+
+### SEAM-INVARIANT files (DO NOT TOUCH)
+
+- `crates/rutster-media/src/loop_driver.rs` — **byte-identical** to slice-3.
+- `crates/rutster-media/src/rtc_session.rs` — **byte-identical** to slice-3.
+
+Every dispatched dev MUST respect this. The `barge_in_flush` trait method addition lands in `pcm.rs` (not in loop_driver/rtc_session). The reflex wrapper decorates on the binary side (`media_thread.rs`), not inside `RtcSession`.
+
+## Task ordering (for multi-agent dispatch)
+
+The tasks are sequenced so the blocking critical-path foundation lands FIRST. The "parallelizable-now" filler work is called out per-task.
+
+- **Task 1** — CRITICAL-PATH FOUNDATION. `AdvisoryEvent` + `ReflexMetrics` + `barge_in_flush` on `AudioPipe`. All later tasks consume these types. LAND FIRST; nothing else parallelizes until Task 1 merges.
+- **Task 2** — depends on Task 1. `Reflex` state machine. Standalone unit-testable with a mock pipe.
+- **Task 2b** — depends on Tasks 1+2. `LocalVadReflex
` — the PRIMARY trigger (RMS/energy VAD in `on_pcm_frame`, the wedge-#1 proof). Standalone unit-testable with a mock pipe. The decorator pattern from §6.4; composes as `LocalVadReflex>` in Task 6's spawn site.
+- **Task 3** — depends on Task 1. `TapAudioPipe::barge_in_flush` override + `TapMetrics.barge_drained_inflight`. Standalone.
+- **Task 4** — depends on Tasks 1+3. `advisory_tx` threaded through `run_tap_client` + `handle_brain_frame`. The tap-client adhesive between the brain and the Reflex (the SECONDARY trigger path).
+- **Task 5** — depends on Tasks 1+3+4. `spawn_tap_engine` returns `advisory_tx` end; TapConn gains `advisory_tx`.
+- **Task 6** — depends on Tasks 2+2b+5. `MediaThread` — the binary-side dedicated std::thread; owns RtcSessions + spawns TapEngine + wires `LocalVadReflex>` on `Connected`.
+- **Task 7** — depends on Task 6. `session_map.rs` rewire + `main.rs` + `routes.rs` to the command-channel pattern.
+- **Task 8** — depends on Task 5. `MockRealtimeBrain` advisory schedule.
+- **Task 9** — depends on Tasks 6+7+8. Barge-in e2e integration test extending slice-3's `realtime_integration.rs`. TWO cases: (a) PRIMARY — loud local audio → kill WITHOUT advisory; (b) SECONDARY — quiet local audio + brain advisory → kill → fresh audio_out → resume.
+- **Task 10** — depends on Task 7. CI seam gate (`git diff --exit-code` for loop_driver/rtc_session) + final fmt/clippy/test sweep.
+
+Parallelizable-now filler (a blocked dev picks these up without blocking the critical path):
+- LEARNING.md pointers to the new `reflex.rs` + `media_thread.rs` (after Task 2 + Task 6 land).
+- README dev-loop updates (after Task 7 lands).
+- `cargo doc` rendering checks (after Task 2 + Task 6).
+
+---
+
+### Task 1: `AdvisoryEvent` enum + `ReflexMetrics` + `barge_in_flush` trait method — the critical-path foundation
+
+**Files:**
+- Create: `crates/rutster-media/src/reflex.rs`
+- Modify: `crates/rutster-media/src/pcm.rs:102-115` (the `AudioPipe` trait)
+- Modify: `crates/rutster-media/src/lib.rs:33-40` (module declarations + re-exports)
+- Test: `crates/rutster-media/src/reflex.rs` (inline `#[cfg(test)] mod tests`)
+
+**Interfaces:**
+- Consumes: `PcmFrame` (from `pcm.rs`), `AudioPipe` trait (from `pcm.rs`).
+- Produces:
+ - `pub enum AdvisoryEvent { SpeechStarted { at: Instant }, SpeechStopped { at: Instant } }`
+ - `pub struct ReflexMetrics { barge_in_count: AtomicU64, advisory_dropped: AtomicU64, frames_suppressed: AtomicU64, advisory_observed_speech_stopped: AtomicU64 }` + `ReflexMetrics::new() -> Arc` + `ReflexMetrics::snapshot() -> ReflexMetricsSnapshot`
+ - `pub struct ReflexMetricsSnapshot { barge_in_count: u64, advisory_dropped: u64, frames_suppressed: u64, advisory_observed_speech_stopped: u64 }`
+ - `AudioPipe::barge_in_flush(&mut self)` — default impl delegates to `clear_playout_ring`.
+
+- [ ] **Step 1: Write the failing test for `AdvisoryEvent` + `ReflexMetrics`**
+
+Create `crates/rutster-media/src/reflex.rs` with the test module only:
+
+```rust
+//! # Reflex — the FOB barge-in reflex (spec §3.1, §3.2; slice-4)
+//!
+//! `Reflex` is the decorator that instruments the existing
+//! `AudioPipe` with turn-taking reflexes: a `speech_started` advisory kills
+//! playout (clears the ring + drains in-flight brain audio); the first
+//! fresh `audio_out` after the barge resumes playout. The wrapper is
+//! invisible to `loop_driver::drive` — it still calls
+//! `session.pipe.next_pcm_frame()` — so the seam
+//! (`loop_driver.rs` + `rtc_session.rs` byte-identical) holds.
+//!
+//! # Why a decorator (not inline in `TapAudioPipe`)
+//!
+//! Composition: a future `LocalVadReflex` composes outside the advisory
+//! `Reflex
`, the same way `Reflex` composes today. The
+//! pattern is forward-compatible without restructuring when local VAD
+//! arrives (deferred per slice-4 §1.2).
+
+use std::sync::Arc;
+use std::sync::atomic::{AtomicU64, Ordering};
+use std::time::Instant;
+
+use tokio::sync::mpsc;
+
+use crate::pcm::{AudioPipe, AudioSource, AudioSink, PcmFrame};
+
+/// A turn-event advisory from the brain. The brain decodes its own
+/// speech-to-text / VAD results and forwards these; the FOB *owns*
+/// turn-taking and acts on them (slice-3 §4.3 — OpenAI Realtime
+/// server-side VAD is DISABLED; the FOB's reflex is authoritative).
+///
+/// Carried over a tokio mpsc from the TapEngine (tokio task) to the
+/// `Reflex` wrapper (media thread). Drained sync via `try_recv` on the
+/// 20 ms tick — the kill decision lives in the loop, not in a handler.
+#[derive(Debug)]
+pub enum AdvisoryEvent {
+ /// The brain detected caller speech. Trigger barge-in: kill playout.
+ SpeechStarted { at: Instant },
+ /// The brain detected caller speech ended. Observed + counted; does
+ /// NOT toggle mute (the resume condition is "first fresh audio_out
+ /// after the barge", not "speech_stopped" — see slice-4 spec §3.2).
+ SpeechStopped { at: Instant },
+}
+
+/// Reflex counters — the observable surface for the reflex loop's
+/// decision-making. Mirrors `TapMetrics` shape (atomics + snapshot).
+///
+/// `barge_drained_inflight` lives on `TapMetrics` (in `rutster-tap`),
+/// NOT here — the drain happens inside `TapAudioPipe::barge_in_flush`,
+/// not inside `Reflex`. See slice-4 spec §3.5.
+#[derive(Default)]
+pub struct ReflexMetrics {
+ pub barge_in_count: AtomicU64,
+ pub advisory_dropped: AtomicU64,
+ pub frames_suppressed: AtomicU64,
+ pub advisory_observed_speech_stopped: AtomicU64,
+}
+
+impl ReflexMetrics {
+ pub fn new() -> Arc {
+ Arc::new(Self::default())
+ }
+
+ pub fn snapshot(&self) -> ReflexMetricsSnapshot {
+ ReflexMetricsSnapshot {
+ barge_in_count: self.barge_in_count.load(Ordering::Relaxed),
+ advisory_dropped: self.advisory_dropped.load(Ordering::Relaxed),
+ frames_suppressed: self.frames_suppressed.load(Ordering::Relaxed),
+ advisory_observed_speech_stopped: self
+ .advisory_observed_speech_stopped
+ .load(Ordering::Relaxed),
+ }
+ }
+}
+
+#[derive(Debug, PartialEq, Eq)]
+pub struct ReflexMetricsSnapshot {
+ pub barge_in_count: u64,
+ pub advisory_dropped: u64,
+ pub frames_suppressed: u64,
+ pub advisory_observed_speech_stopped: u64,
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn reflex_metrics_snapshot_reads_zeroes_initially() {
+ let m = ReflexMetrics::new();
+ let s = m.snapshot();
+ assert_eq!(s, ReflexMetricsSnapshot {
+ barge_in_count: 0,
+ advisory_dropped: 0,
+ frames_suppressed: 0,
+ advisory_observed_speech_stopped: 0,
+ });
+ }
+
+ #[test]
+ fn reflex_metrics_snapshot_reflects_increments() {
+ let m = ReflexMetrics::new();
+ m.barge_in_count.fetch_add(3, Ordering::Relaxed);
+ m.frames_suppressed.fetch_add(7, Ordering::Relaxed);
+ m.advisory_observed_speech_stopped.fetch_add(2, Ordering::Relaxed);
+ let s = m.snapshot();
+ assert_eq!(s.barge_in_count, 3);
+ assert_eq!(s.frames_suppressed, 7);
+ assert_eq!(s.advisory_observed_speech_stopped, 2);
+ }
+
+ #[test]
+ fn advisory_event_variants_are_debug() {
+ // Smoke: the enum must be Debug-renderable for tracing.
+ let s = AdvisoryEvent::SpeechStarted { at: Instant::now() };
+ let _ = format!("{:?}", s);
+ let st = AdvisoryEvent::SpeechStopped { at: Instant::now() };
+ let _ = format!("{:?}", st);
+ }
+}
+```
+
+- [ ] **Step 2: Add `barge_in_flush` default method to the `AudioPipe` trait**
+
+In `crates/rutster-media/src/pcm.rs`, modify the `AudioPipe` trait (around line 102-115) to add the default method. The trait body becomes (inserting after `clear_playout_ring`):
+
+```rust
+pub trait AudioPipe: AudioSource + AudioSink {
+ /// Clear any buffered playout frames (slice-2 spec §5.3 step 4).
+ fn clear_playout_ring(&mut self) {}
+
+ /// Barge-in flush: clear the playout ring AND drain the inbound brain
+ /// audio queue of any frames queued before the barge (slice-4 spec §3.3).
+ /// Called by `Reflex` on `SpeechStarted`. The drain of `rx_audio_out`
+ /// is what makes the resume condition race-free: the first `audio_out`
+ /// observed post-barge is provably post-barge (frames queued pre-barge
+ /// are dropped here).
+ ///
+ /// Default impl delegates to `clear_playout_ring` — sufficient for
+ /// pipes without an inbound queue to drain (like `EchoAudioPipe`).
+ fn barge_in_flush(&mut self) {
+ self.clear_playout_ring();
+ }
+}
+```
+
+- [ ] **Step 3: Wire the module + re-exports in `lib.rs`**
+
+Modify `crates/rutster-media/src/lib.rs` (around line 33-40). Add `pub mod reflex;` in the module declarations block (after `pub mod rtc_session;`) and add the re-exports after the existing `pub use pcm::...`:
+
+```rust
+pub mod loop_driver;
+pub mod opus_codec;
+pub mod pcm;
+pub mod reflex;
+pub mod rtc_session;
+
+pub use opus_codec::{OpusDecoder, OpusEncoder};
+pub use pcm::{AudioPipe, AudioSink, AudioSource, EchoAudioPipe, PcmFrame, SAMPLES_PER_FRAME};
+pub use reflex::{AdvisoryEvent, Reflex, ReflexMetrics, ReflexMetricsSnapshot};
+pub use rtc_session::{RtcSession, RtcSessionError};
+```
+
+(Leave the rest of `lib.rs` — `MediaError` etc. — unchanged.)
+
+- [ ] **Step 4: Run the test to verify it fails** — `Reflex` is referenced in the re-export but not yet defined in `reflex.rs` beyond the enum/metrics. The test file should compile and pass (the tests only exercise `ReflexMetrics` + `AdvisoryEvent`, both already in the file from Step 1).
+
+Run: `cargo test -p rutster-media --lib reflex::tests`
+Expected: PASS (3 tests). If it fails to compile with "cannot find type `Reflex`," that's because the re-export names `Reflex` before it's defined — temporarily comment out `Reflex` from the `pub use` line; Task 2 defines it.
+
+```bash
+# Temporary: comment Reflex from the re-export until Task 2 lands
+# pub use reflex::{AdvisoryEvent, ReflexMetrics, ReflexMetricsSnapshot};
+```
+
+- [ ] **Step 5: Run the full workspace test + fmt + clippy**
+
+```bash
+cargo fmt --all --check
+cargo clippy --all --all-targets -- -D warnings
+cargo test --all
+```
+Expected: all green.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add crates/rutster-media/src/reflex.rs crates/rutster-media/src/pcm.rs crates/rutster-media/src/lib.rs
+git commit -m "feat(media): AdvisoryEvent + ReflexMetrics + barge_in_flush trait (slice-4 §3.1, §3.3)
+
+The critical-path foundation for the barge-in reflex. AdvisoryEvent is
+the enum carried over a tokio mpsc from TapEngine to Reflex (brain →
+FOB). ReflexMetrics is the observable surface. barge_in_flush is the
+new AudioPipe trait method (default delegates to clear_playout_ring) —
+the kill-now path that clears the ring AND drains rx_audio_out.
+
+Task 1 of the slice-4 plan. Everything else depends on this landing.""
+```
+
+---
+
+### Task 2: `Reflex` state machine + decorator impl
+
+**Files:**
+- Modify: `crates/rutster-media/src/reflex.rs` (add the struct + impl)
+- Modify: `crates/rutster-media/src/lib.rs` (uncomment `Reflex` from the re-export)
+- Test: `crates/rutster-media/src/reflex.rs` (inline tests)
+
+**Interfaces:**
+- Consumes: `AdvisoryEvent`, `ReflexMetrics` (from Task 1), `AudioPipe` trait + `PcmFrame` (from `pcm.rs`), tokio `mpsc::Receiver`.
+- Produces:
+ - `pub struct Reflex { inner: P, advisory_rx: mpsc::Receiver, muted: bool, barge_epoch: u64, metrics: Arc }`
+ - `impl Reflex` with `pub fn new(inner: P, advisory_rx: mpsc::Receiver, metrics: Arc) -> Self`
+ - `impl AudioPipe for Reflex` (`next_pcm_frame` applies the state table; `on_pcm_frame` delegates; `clear_playout_ring` delegates; `barge_in_flush` delegates).
+
+- [ ] **Step 1: Write the failing tests for the state machine**
+
+Append to the `#[cfg(test)] mod tests` in `crates/rutster-media/src/reflex.rs`:
+
+```rust
+ /// A minimal mock pipe for unit-testing Reflex. Captures on_pcm_frame
+ /// inputs + returns a pre-loaded queue of frames from next_pcm_frame
+ /// so we can simulate "brain audio_out arrived" deterministically.
+ struct MockPipe {
+ queued: std::collections::VecDeque,
+ flush_calls: usize,
+ barge_calls: usize,
+ }
+
+ impl MockPipe {
+ fn new() -> Self {
+ Self { queued: Default::default(), flush_calls: 0, barge_calls: 0 }
+ }
+ fn push_frame(&mut self, frame: PcmFrame) {
+ self.queued.push_back(frame);
+ }
+ }
+
+ impl AudioSource for MockPipe {
+ fn next_pcm_frame(&mut self) -> Option {
+ self.queued.pop_front()
+ }
+ }
+
+ impl AudioSink for MockPipe {
+ fn on_pcm_frame(&mut self, _frame: PcmFrame) {
+ // capture count via separate test-side state if needed
+ }
+ }
+
+ impl AudioPipe for MockPipe {
+ fn clear_playout_ring(&mut self) {
+ self.flush_calls += 1;
+ self.queued.clear();
+ }
+ fn barge_in_flush(&mut self) {
+ self.barge_calls += 1;
+ self.queued.clear();
+ }
+ }
+
+ fn setup() -> (Reflex, mpsc::Sender, Arc) {
+ let (tx, rx) = mpsc::channel::(16);
+ let metrics = ReflexMetrics::new();
+ let reflex = Reflex::new(MockPipe::new(), rx, metrics.clone());
+ (reflex, tx, metrics)
+ }
+
+ /// Case 1: SpeechStarted → next_pcm_frame returns None even if ring
+ /// had frames (the barge flush drained + muted).
+ #[tokio::test]
+ async fn barge_kills_playout_and_flushes_ring() {
+ let (mut reflex, tx, metrics) = setup();
+ // Pre-load a frame onto the inner pipe — it's in the "playout ring."
+ reflex.inner.push_frame(PcmFrame::zeroed());
+ // Barge in.
+ tx.send(AdvisoryEvent::SpeechStarted { at: Instant::now() })
+ .await
+ .unwrap();
+ // Next tick: drain the advisory, apply the state machine.
+ let frame = reflex.next_pcm_frame();
+ assert!(frame.is_none(), "barge must silence the next frame");
+ assert_eq!(metrics.barge_in_count.load(Ordering::Relaxed), 1);
+ assert_eq!(reflex.inner.barge_calls, 1, "barge_in_flush called");
+ assert!(reflex.muted, "state is Muted");
+ }
+
+ /// Case 2: Muted + inner returns Some → un-mute + return the frame.
+ #[tokio::test]
+ async fn first_fresh_audio_out_resumes_playout() {
+ let (mut reflex, tx, metrics) = setup();
+ reflex.inner.push_frame(PcmFrame::zeroed());
+ tx.send(AdvisoryEvent::SpeechStarted { at: Instant::now() })
+ .await
+ .unwrap();
+ // First tick after barge: muted, none (queue was drained).
+ let f1 = reflex.next_pcm_frame();
+ assert!(f1.is_none());
+ assert_eq!(metrics.frames_suppressed.load(Ordering::Relaxed), 1);
+ // Brain sends a fresh frame post-barge.
+ reflex.inner.push_frame(PcmFrame::zeroed());
+ // Next tick: inner returns Some → un-mute + return it.
+ let f2 = reflex.next_pcm_frame();
+ assert!(f2.is_some(), "first fresh audio_out must resume playout");
+ assert!(!reflex.muted, "state is Playing");
+ }
+
+ /// Case 3: SpeechStopped during Muted → stays muted.
+ #[tokio::test]
+ async fn speech_stopped_during_mute_is_noop() {
+ let (mut reflex, tx, metrics) = setup();
+ tx.send(AdvisoryEvent::SpeechStarted { at: Instant::now() })
+ .await
+ .unwrap();
+ reflex.next_pcm_frame(); // drain + apply barge
+ assert!(reflex.muted);
+ tx.send(AdvisoryEvent::SpeechStopped { at: Instant::now() })
+ .await
+ .unwrap();
+ let f = reflex.next_pcm_frame(); // drain + apply stopped
+ assert!(f.is_none());
+ assert!(reflex.muted, "still muted — SpeechStopped does NOT toggle");
+ assert_eq!(
+ metrics.advisory_observed_speech_stopped.load(Ordering::Relaxed),
+ 1
+ );
+ }
+
+ /// Case 4: SpeechStopped during Playing → no-op.
+ #[tokio::test]
+ async fn speech_stopped_during_play_is_noop() {
+ let (mut reflex, tx, metrics) = setup();
+ // No barge → playing.
+ tx.send(AdvisoryEvent::SpeechStopped { at: Instant::now() })
+ .await
+ .unwrap();
+ let f = reflex.next_pcm_frame();
+ assert!(f.is_none(), "no frame queued, silence (not barge)");
+ assert!(!reflex.muted, "playing");
+ assert_eq!(
+ metrics.advisory_observed_speech_stopped.load(Ordering::Relaxed),
+ 1
+ );
+ assert_eq!(metrics.barge_in_count.load(Ordering::Relaxed), 0);
+ }
+
+ /// Case 5: duplicate SpeechStarted re-flushes + stays muted.
+ #[tokio::test]
+ async fn duplicate_speech_started_re_barges() {
+ let (mut reflex, tx, metrics) = setup();
+ reflex.inner.push_frame(PcmFrame::zeroed());
+ tx.send(AdvisoryEvent::SpeechStarted { at: Instant::now() })
+ .await
+ .unwrap();
+ reflex.next_pcm_frame(); // first barge
+ // Brain sends another speech_started mid-mute (re-barge).
+ reflex.inner.push_frame(PcmFrame::zeroed());
+ tx.send(AdvisoryEvent::SpeechStarted { at: Instant::now() })
+ .await
+ .unwrap();
+ let f = reflex.next_pcm_frame(); // second barge
+ assert!(f.is_none(), "re-barge must re-mute + drain");
+ assert!(reflex.muted);
+ assert_eq!(metrics.barge_in_count.load(Ordering::Relaxed), 2);
+ assert_eq!(reflex.inner.barge_calls, 2);
+ }
+
+ /// Case 6: on_pcm_frame is NEVER gated — brain still hears caller.
+ #[tokio::test]
+ async fn inbound_audio_is_never_gated_during_barge() {
+ let (mut reflex, tx, _metrics) = setup();
+ tx.send(AdvisoryEvent::SpeechStarted { at: Instant::now() })
+ .await
+ .unwrap();
+ reflex.next_pcm_frame(); // drain + apply barge
+ // Inbound frame arrives — must pass through to inner.
+ reflex.on_pcm_frame(PcmFrame::zeroed());
+ // Inner captured it (no panic, no drop).
+ }
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `cargo test -p rutster-media --lib reflex::tests`
+Expected: compile error — `Reflex` struct + `new` + `AudioPipe` impl don't exist yet, and `inner` field isn't accessible from the test.
+
+- [ ] **Step 3: Implement `Reflex` + the `AudioPipe` impl**
+
+Append (above the `#[cfg(test)] mod tests`) to `crates/rutster-media/src/reflex.rs`:
+
+```rust
+/// The FOB reflex decorator (slice-4 spec §3.2). Wraps any `AudioPipe`
+/// with a barge-in state machine driven by `AdvisoryEvent`s from the brain.
+///
+/// # Why `P: AudioPipe` generic (not `Box`)
+///
+/// The wrapper is instantiated exactly once per session, with a concrete
+/// `TapAudioPipe` inner. Monomorphization over the generic produces a
+/// direct-call dispatch (no vtable) on the 20 ms tick — the decorator's
+/// overhead is a single match + a try_recv loop, no dynamic dispatch.
+/// The `Reflex` itself is stored behind `Box` in
+/// `RtcSession.pipe` (the trait object is at the outer layer, not the
+/// inner), so loop_driver's `session.pipe.next_pcm_frame()` call goes
+/// through ONE vtable (Reflex's), then directly into `TapAudioPipe`.
+pub struct Reflex {
+ pub(crate) inner: P,
+ pub(crate) advisory_rx: mpsc::Receiver,
+ pub(crate) muted: bool,
+ pub(crate) barge_epoch: u64,
+ pub(crate) metrics: Arc,
+}
+
+impl Reflex {
+ pub fn new(
+ inner: P,
+ advisory_rx: mpsc::Receiver,
+ metrics: Arc,
+ ) -> Self {
+ Self {
+ inner,
+ advisory_rx,
+ muted: false,
+ barge_epoch: 0,
+ metrics,
+ }
+ }
+
+ /// Drain all pending advisories + apply the state table. Called at
+ /// the top of `next_pcm_frame`. Hot-path: try_recv loop, bounded.
+ fn drain_advisories(&mut self) {
+ while let Ok(ev) = self.advisory_rx.try_recv() {
+ match ev {
+ AdvisoryEvent::SpeechStarted { at } => {
+ self.muted = true;
+ self.barge_epoch = self.barge_epoch.wrapping_add(1);
+ self.inner.barge_in_flush();
+ self.metrics.barge_in_count.fetch_add(1, Ordering::Relaxed);
+ tracing::info!(epoch = self.barge_epoch, ?at, "barge-in");
+ }
+ AdvisoryEvent::SpeechStopped { at: _ } => {
+ self.metrics
+ .advisory_observed_speech_stopped
+ .fetch_add(1, Ordering::Relaxed);
+ // No state change — see slice-4 spec §3.2.
+ }
+ }
+ }
+ }
+}
+
+impl AudioSource for Reflex {
+ fn next_pcm_frame(&mut self) -> Option {
+ self.drain_advisories();
+ if self.muted {
+ match self.inner.next_pcm_frame() {
+ Some(f) => {
+ self.muted = false;
+ Some(f)
+ }
+ None => {
+ self.metrics.frames_suppressed.fetch_add(1, Ordering::Relaxed);
+ None
+ }
+ }
+ } else {
+ self.inner.next_pcm_frame()
+ }
+ }
+}
+
+impl AudioSink for Reflex {
+ fn on_pcm_frame(&mut self, frame: PcmFrame) {
+ // Inbound caller audio is NEVER gated by the reflex. The brain
+ // still hears the caller during barge — that's the point (the
+ // brain needs to know the caller interrupted; the FOB only kills
+ // its OWN playout, not the caller's path to the brain).
+ self.inner.on_pcm_frame(frame)
+ }
+}
+
+impl AudioPipe for Reflex {
+ fn clear_playout_ring(&mut self) {
+ self.inner.clear_playout_ring()
+ }
+ fn barge_in_flush(&mut self) {
+ self.inner.barge_in_flush()
+ }
+}
+```
+
+Also: uncomment `Reflex` in `lib.rs`'s re-export.
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `cargo test -p rutster-media --lib reflex::tests`
+Expected: PASS (all 9 tests — 3 from Task 1 + 6 from Task 2).
+
+- [ ] **Step 5: fmt + clippy + full test**
+
+```bash
+cargo fmt --all --check
+cargo clippy --all --all-targets -- -D warnings
+cargo test --all
+```
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add crates/rutster-media/src/reflex.rs crates/rutster-media/src/lib.rs
+git commit -m "feat(media): Reflex
barge-in state machine (slice-4 §3.2, §3.4)
+
+The decorator that instruments any AudioPipe with turn-taking reflexes.
+SpeechStarted → muted=true + barge_in_flush. First fresh audio_out →
+un-mute. SpeechStopped is observational (no toggle). Inbound audio
+(on_pcm_frame) is NEVER gated. loop_driver + rtc_session untouched
+(seam holds)."
+```
+
+---
+
+### Task 2b: `LocalVadReflex
` — the primary trigger (local VAD, zero brain round-trip)
+
+**Files:**
+- Modify: `crates/rutster-media/src/reflex.rs` (add the struct + impl)
+- Modify: `crates/rutster-media/src/lib.rs` (re-export `LocalVadReflex` + the consts)
+- Test: `crates/rutster-media/src/reflex.rs` (inline tests)
+
+**Interfaces:**
+- Consumes: `AdvisoryEvent`, `Reflex
` (from Task 2 — actually consumes the same `AdvisoryEvent` enum + `mpsc::Sender` it pushes into), `AudioPipe` trait + `PcmFrame`, tokio `mpsc::Sender`.
+- Produces:
+ - `pub const VAD_RMS_THRESHOLD: f64 = 500.0;`
+ - `pub const VAD_DEBOUNCE_FRAMES: u32 = 3;`
+ - `pub struct LocalVadReflex { inner: P, advisory_tx: mpsc::Sender, above_threshold_streak: u32, vad_armed: bool }`
+ - `impl LocalVadReflex` with `pub fn new(inner: P, advisory_tx: mpsc::Sender) -> Self` + `fn rms(frame: &PcmFrame) -> f64` + `fn observe(&mut self, frame: &PcmFrame) -> bool`
+ - `impl AudioSource for LocalVadReflex` (pure delegation)
+ - `impl AudioSink for LocalVadReflex` (THE PRIMARY TRIGGER — inspects + delegates)
+ - `impl AudioPipe for LocalVadReflex` (pure delegation)
+
+- [ ] **Step 1: Write the failing tests for the VAD state machine + RMS**
+
+Append to the `#[cfg(test)] mod tests` in `crates/rutster-media/src/reflex.rs`:
+
+```rust
+ /// RMS of a zeroed frame is 0.0 (perfect silence).
+ #[test]
+ fn rms_of_silence_is_zero() {
+ let frame = PcmFrame::zeroed();
+ assert_eq!(LocalVadReflex::::rms(&frame), 0.0);
+ }
+
+ /// RMS of a loud frame is well above the threshold.
+ #[test]
+ fn rms_of_loud_frame_exceeds_threshold() {
+ let mut frame = PcmFrame::zeroed();
+ for s in frame.samples.iter_mut() {
+ *s = 1000; // well above VAD_RMS_THRESHOLD (500.0)
+ }
+ assert!(LocalVadReflex::::rms(&frame) >= VAD_RMS_THRESHOLD);
+ }
+
+ /// Debounce: N-1 above-threshold frames do NOT trip; the Nth does.
+ #[tokio::test]
+ async fn debounce_requires_n_consecutive_above_threshold_frames() {
+ let (tx, mut rx) = mpsc::channel::(16);
+ let mut vad = LocalVadReflex::new(MockPipe::new(), tx);
+ let mut loud = PcmFrame::zeroed();
+ for s in loud.samples.iter_mut() { *s = 1000; }
+
+ // VAD_DEBOUNCE_FRAMES - 1 frames: no trip.
+ for _ in 0..(VAD_DEBOUNCE_FRAMES - 1) {
+ vad.on_pcm_frame(loud.clone());
+ assert!(rx.try_recv().is_err(), "no advisory before debounce threshold");
+ }
+ // Nth frame: trip!
+ vad.on_pcm_frame(loud.clone());
+ let ev = rx.try_recv().expect("advisory after debounce threshold");
+ assert!(matches!(ev, AdvisoryEvent::SpeechStarted { .. }));
+ }
+
+ /// Re-arm: a below-threshold frame resets the streak + re-arms.
+ #[tokio::test]
+ async fn below_threshold_re_arms_vad() {
+ let (tx, mut rx) = mpsc::channel::(16);
+ let mut vad = LocalVadReflex::new(MockPipe::new(), tx);
+ let mut loud = PcmFrame::zeroed();
+ for s in loud.samples.iter_mut() { *s = 1000; }
+ let quiet = PcmFrame::zeroed();
+
+ // Trip the VAD.
+ for _ in 0..VAD_DEBOUNCE_FRAMES {
+ vad.on_pcm_frame(loud.clone());
+ }
+ let _ = rx.try_recv().expect("first trip");
+
+ // Caller goes quiet — re-arm.
+ vad.on_pcm_frame(quiet);
+
+ // Next streak trips again.
+ for _ in 0..VAD_DEBOUNCE_FRAMES {
+ vad.on_pcm_frame(loud.clone());
+ }
+ let ev = rx.try_recv().expect("second trip after re-arm");
+ assert!(matches!(ev, AdvisoryEvent::SpeechStarted { .. }));
+ }
+
+ /// on_pcm_frame ALWAYS delegates to inner (caller audio reaches the brain
+ /// even during barge — the FOB only kills playout, not the caller's path).
+ #[tokio::test]
+ async fn on_pcm_frame_always_delegates_to_inner() {
+ let (tx, _rx) = mpsc::channel::(16);
+ let mut vad = LocalVadReflex::new(MockPipe::new(), tx);
+ let frame = PcmFrame::zeroed();
+ vad.on_pcm_frame(frame.clone());
+ // The inner MockPipe captured it — verified by the lack of panic
+ // + the MockPipe's on_pcm_frame being called (push_back_bounded
+ // on the underlying queue, which we don't observe here directly;
+ // the absence of a drop is the assertion).
+ }
+
+ /// next_pcm_frame is pure delegation — the VAD only observes the SINK path.
+ #[tokio::test]
+ async fn next_pcm_frame_delegates_to_inner() {
+ let (tx, _rx) = mpsc::channel::(16);
+ let mut vad = LocalVadReflex::new(MockPipe::new(), tx);
+ // Inner has no frames queued → None.
+ assert!(vad.next_pcm_frame().is_none());
+ // Queue a frame on the inner directly + verify it comes through.
+ vad.inner.push_frame(PcmFrame::zeroed());
+ assert!(vad.next_pcm_frame().is_some());
+ }
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `cargo test -p rutster-media --lib reflex::tests`
+Expected: compile error — `LocalVadReflex` + `VAD_RMS_THRESHOLD` + `VAD_DEBOUNCE_FRAMES` don't exist yet.
+
+- [ ] **Step 3: Implement `LocalVadReflex`**
+
+Append to `crates/rutster-media/src/reflex.rs` (above the `#[cfg(test)] mod tests`):
+
+```rust
+/// RMS energy threshold for caller-speech detection (slice-4 spec §3.4).
+/// The MVP ships with a single tuned-for-synthetic-loud-signal const;
+/// the tuning framework (per-environment calibration, adaptive noise
+/// floor) is deferred per slice-4 §1.2.
+pub const VAD_RMS_THRESHOLD: f64 = 500.0;
+
+/// Number of consecutive above-threshold frames required before the VAD
+/// trips (slice-4 spec §3.4). At 20 ms/frame, N=3 = 60 ms of above-
+/// threshold audio — well below the brain's ~300 ms ASR-VAD latency.
+pub const VAD_DEBOUNCE_FRAMES: u32 = 3;
+
+/// The PRIMARY barge-in trigger (slice-4 spec §3.4): a local in-core
+/// RMS/energy VAD running in `on_pcm_frame` on the dedicated thread, in
+/// the 20 ms loop, with ZERO brain round-trip. Proves wedge #1 ("VAD
+/// killing TTS the instant the caller speaks, without the brain" —
+/// README:98-100, ARCHITECTURE.md:79-81). Composes as
+/// `LocalVadReflex>` — the outer wrapper does local
+/// VAD; the inner wrapper applies the mute state machine to the advisory
+/// stream (which has TWO sources: local VAD + brain advisory, both
+/// feeding the same mpsc).
+pub struct LocalVadReflex {
+ pub(crate) inner: P,
+ pub(crate) advisory_tx: mpsc::Sender,
+ pub(crate) above_threshold_streak: u32,
+ pub(crate) vad_armed: bool,
+}
+
+impl LocalVadReflex {
+ pub fn new(inner: P, advisory_tx: mpsc::Sender) -> Self {
+ Self {
+ inner,
+ advisory_tx,
+ above_threshold_streak: 0,
+ vad_armed: true,
+ }
+ }
+
+ /// Compute RMS energy of a PCM frame. ~480 multiplications + one
+ /// sqrt — well under the 20 ms tick budget. Hot-path, no allocations.
+ fn rms(frame: &PcmFrame) -> f64 {
+ let sum_sq: u64 = frame.samples.iter()
+ .map(|&s| (s as i64 * s as i64) as u64)
+ .sum();
+ (sum_sq as f64 / frame.samples.len() as f64).sqrt()
+ }
+
+ /// Inspect a caller PCM frame + apply the debounce state machine.
+ /// Returns true if the VAD tripped THIS call (so on_pcm_frame can
+ /// push the advisory). Called from `on_pcm_frame` (the sink path).
+ fn observe(&mut self, frame: &PcmFrame) -> bool {
+ let energy = Self::rms(frame);
+ if energy >= VAD_RMS_THRESHOLD {
+ self.above_threshold_streak += 1;
+ if self.above_threshold_streak >= VAD_DEBOUNCE_FRAMES && self.vad_armed {
+ self.vad_armed = false;
+ return true;
+ }
+ } else {
+ self.above_threshold_streak = 0;
+ self.vad_armed = true;
+ }
+ false
+ }
+}
+
+impl AudioSource for LocalVadReflex {
+ fn next_pcm_frame(&mut self) -> Option {
+ self.inner.next_pcm_frame()
+ }
+}
+
+impl AudioSink for LocalVadReflex {
+ fn on_pcm_frame(&mut self, frame: PcmFrame) {
+ // THE PRIMARY TRIGGER: inspect BEFORE delegating.
+ if self.observe(&frame) {
+ let _ = self.advisory_tx.try_send(AdvisoryEvent::SpeechStarted {
+ at: Instant::now(),
+ });
+ // try_send failure (channel full) → drop + observe (hot-path
+ // policy). The brain's advisory path is the backstop.
+ }
+ self.inner.on_pcm_frame(frame)
+ }
+}
+
+impl AudioPipe for LocalVadReflex {
+ fn clear_playout_ring(&mut self) { self.inner.clear_playout_ring() }
+ fn barge_in_flush(&mut self) { self.inner.barge_in_flush() }
+}
+```
+
+Also: add `LocalVadReflex`, `VAD_RMS_THRESHOLD`, `VAD_DEBOUNCE_FRAMES` to `lib.rs`'s re-export.
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `cargo test -p rutster-media --lib reflex::tests`
+Expected: PASS (all prior tests + 6 new Task 2b tests).
+
+- [ ] **Step 5: fmt + clippy + full test + commit**
+
+```bash
+cargo fmt --all --check
+cargo clippy --all --all-targets -- -D warnings
+cargo test --all
+git add crates/rutster-media/src/reflex.rs crates/rutster-media/src/lib.rs
+git commit -s -m "feat(media): LocalVadReflex — primary barge-in trigger, zero brain round-trip (slice-4 §3.4)
+
+The wedge-#1 proof. RMS/energy VAD in on_pcm_frame on the dedicated
+thread, in the 20ms loop — caller speech trips SpeechStarted locally,
+without any brain round-trip. Debounce (N=3 frames = 60ms) filters
+transients. Composes as LocalVadReflex>; both
+the local VAD + the brain's advisory feed the same advisory_tx mpsc.
+Revised after adversarial review (initial brainstorming was advisory-
+only, which contradicts ARCHITECTURE.md:79-81)."
+```
+
+---
+
+### Task 3: `TapAudioPipe::barge_in_flush` override + `TapMetrics.barge_drained_inflight`
+
+**Files:**
+- Modify: `crates/rutster-tap/src/tap_audio_pipe.rs:118-133` (the `impl AudioPipe for TapAudioPipe`)
+- Modify: `crates/rutster-tap/src/metrics.rs:14-57` (add `barge_drained_inflight` field + snapshot)
+- Test: `crates/rutster-tap/src/tap_audio_pipe.rs` (inline tests)
+
+**Interfaces:**
+- Consumes: `AudioPipe::barge_in_flush` default (from Task 1), `TapMetrics` struct.
+- Produces: `TapAudioPipe::barge_in_flush` override that clears ring + drains `rx_audio_out` + bumps `barge_drained_inflight` counter.
+
+- [ ] **Step 1: Write the failing test**
+
+Append to `crates/rutster-tap/src/tap_audio_pipe.rs`'s `#[cfg(test)] mod tests`:
+
+```rust
+ #[test]
+ fn barge_in_flush_clears_ring_and_drains_rx_audio_out() {
+ let (_tx_pcm_in, _rx_pcm_in, tx_audio_out, rx_audio_out, metrics) = channels();
+ let mut pipe = TapAudioPipe::new(tx_audio_out.clone(), rx_audio_out, metrics.clone());
+ // Push 3 frames into the engine→playout mpsc + drain one into ring.
+ for i in 0..3 {
+ let mut f = PcmFrame::zeroed();
+ f.samples[0] = i as i16;
+ tx_audio_out.blocking_send(f).unwrap();
+ }
+ // Drain one into the ring (the queue has 1 in ring + 2 in mpsc).
+ let _first = pipe.next_pcm_frame().expect("drained one");
+ // Barge-in flush: clears ring + drains rx_audio_out.
+ pipe.barge_in_flush();
+ // Next frame should be None (ring empty, mpsc drained).
+ assert!(pipe.next_pcm_frame().is_none());
+ // Counter should reflect 2 frames drained from rx_audio_out.
+ assert_eq!(metrics.barge_drained_inflight.load(Ordering::Relaxed), 2);
+ }
+
+ #[test]
+ fn barge_in_flush_when_already_empty_is_noop() {
+ let (_tx_pcm_in, _rx_pcm_in, _tx_audio_out, rx_audio_out, metrics) = channels();
+ let mut pipe = TapAudioPipe::new(_tx_pcm_in, rx_audio_out, metrics.clone());
+ pipe.barge_in_flush();
+ // No frames drained (none were queued); counter stays 0.
+ assert_eq!(metrics.barge_drained_inflight.load(Ordering::Relaxed), 0);
+ }
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `cargo test -p rutster-tap --lib tap_audio_pipe::tests::barge`
+Expected: FAIL — `barge_drained_inflight` field doesn't exist on `TapMetrics`; `barge_in_flush` not overridden on `TapAudioPipe` (the default impl just no-ops since `clear_playout_ring` on `TapAudioPipe` clears the ring but doesn't drain `rx_audio_out`).
+
+- [ ] **Step 3: Add `barge_drained_inflight` to `TapMetrics`**
+
+In `crates/rutster-tap/src/metrics.rs`, add the field + snapshot. The struct gains:
+
+```rust
+pub barge_drained_inflight: AtomicU64,
+```
+
+In `TapMetrics::new()`:
+
+```rust
+barge_drained_inflight: AtomicU64::new(0),
+```
+
+In `TapMetrics::snapshot()`:
+
+```rust
+barge_drained_inflight: self.barge_drained_inflight.load(Ordering::Relaxed),
+```
+
+In `MetricsSnapshot` struct:
+
+```rust
+pub barge_drained_inflight: u64,
+```
+
+- [ ] **Step 4: Override `barge_in_flush` on `TapAudioPipe`**
+
+In `crates/rutster-tap/src/tap_audio_pipe.rs`, add to the `impl AudioPipe for TapAudioPipe` block (around line 118-133):
+
+```rust
+ /// slice-4 spec §3.3 — barge-in flush: clear the playout ring AND
+ /// drain `rx_audio_out` of any frames queued before the barge. Without
+ /// this drain, a stale brain frame in the mpsc would un-mute
+ /// immediately on the next tick — defeating the "first fresh audio_out"
+ /// resume condition. Hot-path: try_recv loop, bounded, no blocking.
+ fn barge_in_flush(&mut self) {
+ // Clear the ring (drops buffered brain-proposed frames).
+ let cleared = self.playout_ring.len();
+ self.playout_ring.clear();
+ if cleared > 0 {
+ debug!(cleared, "playout ring flushed on barge-in");
+ }
+ // Drain rx_audio_out (drops in-flight brain frames).
+ let mut drained = 0usize;
+ while self.rx_audio_out.try_recv().is_ok() {
+ drained += 1;
+ }
+ if drained > 0 {
+ self.metrics
+ .barge_drained_inflight
+ .fetch_add(drained as u64, Ordering::Relaxed);
+ debug!(drained, "in-flight brain frames drained on barge-in");
+ }
+ }
+```
+
+- [ ] **Step 5: Run the test to verify it passes**
+
+Run: `cargo test -p rutster-tap --lib tap_audio_pipe::tests`
+Expected: PASS (all existing + 2 new).
+
+- [ ] **Step 6: fmt + clippy + full test + commit**
+
+```bash
+cargo fmt --all --check
+cargo clippy --all --all-targets -- -D warnings
+cargo test --all
+git add crates/rutster-tap/src/tap_audio_pipe.rs crates/rutster-tap/src/metrics.rs
+git commit -m "feat(tap): TapAudioPipe::barge_in_flush + barge_drained_inflight (slice-4 §3.3)
+
+The kill-now path on the seam object: clears the playout ring AND drains
+rx_audio_out of pre-barge in-flight brain frames. The drain is what makes
+the resume condition race-free — the first audio_out post-barge is
+provably post-barge."
+```
+
+---
+
+### Task 4: `advisory_tx` threaded through `run_tap_client` + `handle_brain_frame`
+
+**Files:**
+- Modify: `crates/rutster-tap/src/tap_client.rs:140-270` (`run_tap_client` signature + select! arm), `:323-421` (`handle_brain_frame` signature + the `SpeechStarted`/`SpeechStopped` arms)
+- Test: `crates/rutster-tap/src/tap_client.rs` (the `advisory_events_are_logged_not_forwarded` test gets updated + a new test)
+
+**Interfaces:**
+- Consumes: `AdvisoryEvent` (from Task 1, via `rutster-media`).
+- Produces: `run_tap_client` + `handle_brain_frame` accept `advisory_tx: &mpsc::Sender` and forward the events.
+
+- [ ] **Step 1: Write the failing test (replace the slice-3 "advisory not forwarded" test)**
+
+The existing test `advisory_events_are_logged_not_forwarded_to_function_call_channel` (lines 514-551) asserted that advisories do NOT flow through the function_call channel. Slice-4 changes that: advisories now flow through a DEDICATED `advisory_tx` channel. Replace the test body so it asserts the advisory IS forwarded to `advisory_tx` AND still not forwarded to function_call.
+
+In `crates/rutster-tap/src/tap_client.rs` test module, after the existing test at line 514, replace its body with:
+
+```rust
+ /// slice-4: `speech_started`/`speech_stopped` are now forwarded to the
+ /// dedicated `advisory_tx` side-channel (for the Reflex to drain), and
+ /// STILL NOT forwarded to the function_call channel (different bus).
+ #[tokio::test]
+ async fn advisory_events_forwarded_to_advisory_channel_only() {
+ let (tx_fc, mut rx_fc) = mpsc::channel::(8);
+ let (tx_audio_out, _rx_audio_out) = mpsc::channel::(8);
+ let (tx_advisory, mut rx_advisory) =
+ mpsc::channel::(8);
+ let metrics = Arc::new(TapMetrics::new());
+
+ // speech_started forwards.
+ let wire = crate::protocol::encode_speech_started(2, 200).unwrap();
+ let mut last_seq: Option = None;
+ handle_brain_frame(
+ &wire,
+ &mut last_seq,
+ &tx_audio_out,
+ &tx_fc,
+ &tx_advisory,
+ &metrics,
+ Instant::now(),
+ )
+ .await;
+ let advisory = tokio::time::timeout(Duration::from_millis(200), rx_advisory.recv())
+ .await
+ .expect("advisory drained within 200ms")
+ .expect("channel not closed");
+ assert!(matches!(
+ advisory,
+ rutster_media::AdvisoryEvent::SpeechStarted { .. }
+ ));
+ // function_call channel stays empty.
+ assert!(
+ tokio::time::timeout(Duration::from_millis(50), rx_fc.recv())
+ .await
+ .is_err(),
+ "no FunctionCallEvent expected for advisory events"
+ );
+ assert_eq!(last_seq, Some(2));
+
+ // speech_stopped forwards.
+ let wire = crate::protocol::encode_speech_stopped(3, 300).unwrap();
+ handle_brain_frame(
+ &wire,
+ &mut last_seq,
+ &tx_audio_out,
+ &tx_fc,
+ &tx_advisory,
+ &metrics,
+ Instant::now(),
+ )
+ .await;
+ let advisory = tokio::time::timeout(Duration::from_millis(200), rx_advisory.recv())
+ .await
+ .expect("advisory drained within 200ms")
+ .expect("channel not closed");
+ assert!(matches!(
+ advisory,
+ rutster_media::AdvisoryEvent::SpeechStopped { .. }
+ ));
+ assert_eq!(last_seq, Some(3));
+ }
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `cargo test -p rutster-tap --lib tap_client::tests::advisory_events_forwarded_to_advisory_channel_only`
+Expected: FAIL — `handle_brain_frame` doesn't yet take `advisory_tx`.
+
+- [ ] **Step 3: Update `handle_brain_frame` signature + the advisory arms**
+
+In `crates/rutster-tap/src/tap_client.rs`:
+
+Update the `handle_brain_frame` signature (line 323-330):
+
+```rust
+async fn handle_brain_frame(
+ text: &str,
+ last_seq_ingress: &mut Option,
+ tx_audio_out: &mpsc::Sender,
+ tx_function_call: &mpsc::Sender,
+ tx_advisory: &mpsc::Sender,
+ metrics: &Arc,
+ session_start: Instant,
+) {
+```
+
+Replace the `DecodedPayload::SpeechStarted | DecodedPayload::SpeechStopped` arm (lines 409-412) with:
+
+```rust
+ // slice-4: advisory events forward to the Reflex via the dedicated
+ // `advisory_tx` channel (NonBlocking try_send — the media thread
+ // drains on its 20ms tick). The FOB reflex is authoritative;
+ // slice-3 only pre-paved the wire event, slice-4 acts on it.
+ DecodedPayload::SpeechStarted => {
+ let ev = rutster_media::AdvisoryEvent::SpeechStarted { at: Instant::now() };
+ if tx_advisory.try_send(ev).is_err() {
+ // Channel full → drop + observe (hot-path policy).
+ // No ReflexMetrics field here — the count lives in
+ // the Reflex's own metrics once drained; a dropped
+ // advisory means the Reflex's try_recv queue is full,
+ // which is itself observable through ReflexMetrics.
+ metrics.outbound_dropped.fetch_add(1, Ordering::Relaxed);
+ warn!("advisory SpeechStarted dropped (advisory_tx full)");
+ }
+ }
+ DecodedPayload::SpeechStopped => {
+ let ev = rutster_media::AdvisoryEvent::SpeechStopped { at: Instant::now() };
+ if tx_advisory.try_send(ev).is_err() {
+ metrics.outbound_dropped.fetch_add(1, Ordering::Relaxed);
+ warn!("advisory SpeechStopped dropped (advisory_tx full)");
+ }
+ }
+```
+
+- [ ] **Step 4: Update `run_tap_client` signature + the `handle_brain_frame` call site**
+
+In `run_tap_client`'s signature (line ~150), add `tx_advisory: mpsc::Sender`. Update the `handle_brain_frame` call site (line ~262-265) to pass `&tx_advisory`.
+
+- [ ] **Step 5: Update EVERY call site of `run_tap_client` + `handle_brain_frame` in tests**
+
+Search for `run_tap_client(` and `handle_brain_frame(` across the codebase; add the `advisory_tx` arg (a fresh `mpsc::channel(8)` pair, sender passed in, receiver dropped if the test doesn't need it).
+
+```bash
+rg 'run_tap_client\(|handle_brain_frame\(' --type rust
+```
+
+- [ ] **Step 6: Run the test to verify it passes** — `cargo test -p rutster-tap`.
+
+- [ ] **Step 7: fmt + clippy + full test + commit**
+
+```bash
+cargo fmt --all --check
+cargo clippy --all --all-targets -- -D warnings
+cargo test --all
+git add crates/rutster-tap/src/tap_client.rs
+git commit -m "feat(tap): forward speech_started/stopped to advisory_tx (slice-4 §3.1)
+
+The brain's advisory events now flow to the FOB reflex via the dedicated
+advisory_tx side-channel (3rd mpsc alongside tx_pcm_in/tx_audio_out).
+handle_brain_frame + run_tap_client threads the sender through."
+```
+
+---
+
+### Task 5: `spawn_tap_engine` returns `advisory_tx` end + `TapConn.advisory_tx`
+
+**Files:**
+- Modify: `crates/rutster/src/tap_engine.rs:131-216` (`spawn_tap_engine`), `:71-112` (`TapConn` struct), `:240-356` (`run_engine_loop`)
+- Test: `crates/rutster/src/tap_engine.rs` (inline tests)
+
+**Interfaces:**
+- Consumes: `AdvisoryEvent` (from Task 1), `run_tap_client`'s new `advisory_tx` param (from Task 4).
+- Produces:
+ - `TapConn` does NOT carry the advisory channel — the media thread owns it + clones the Sender. Per the Task 5 revision note below, `spawn_tap_engine` takes `advisory_tx: mpsc::Sender` as a PARAMETER (the media thread constructs the channel, clones the `Sender` for both `spawn_tap_engine` AND `LocalVadReflex::new`, hands the `Receiver` to `Reflex::new`). This is because there are TWO senders (the brain path via the engine + the local VAD via the wrapper), so the channel ownership lives at the composition site in Task 6, not in `spawn_tap_engine`.
+ - `spawn_tap_engine` takes `advisory_tx: mpsc::Sender` as a PARAMETER (the media thread owns the channel + clones it; `tokio::sync::mpsc::Sender` is `Clone`). Returns the 2-tuple `(TapAudioPipe, TapConn)` (legacy shape). The media thread constructs the `(advisory_tx, advisory_rx)` pair after Task 5's revision — `advisory_tx` cloned into both `spawn_tap_engine` AND `LocalVadReflex::new`; `advisory_rx` handed to `Reflex::new`. Replaces the Task 5 draft's "3-tuple return" approach: the media thread owns the channel because it has TWO senders (the brain path + the local VAD path), so ownership lives at the composition site (Task 6), not in `spawn_tap_engine` (which is just one of the senders).
+
+**Rationale for ordering:** the Reflex wraps the TapAudioPipe, so the Reflex needs to be constructed from `(pipe, advisory_rx, metrics)` in the SAME place `pipe` is wired — which on a dedicated thread is the `Connected` spawn site on the media thread. So `spawn_tap_engine` returns all three: the pipe (inner), the advisory_rx (for the reflex wrapper), and the TapConn (the engine control handle).
+
+- [ ] **Step 1: Write the failing test**
+
+Append to `crates/rutster/src/tap_engine.rs` test module:
+
+```rust
+ /// slice-4: spawn_tap_engine takes advisory_tx as a parameter (the media
+ /// thread owns the channel — TWO senders: the engine + the local VAD).
+ #[tokio::test]
+ async fn spawn_accepts_advisory_tx_parameter() {
+ let id = ChannelId::new();
+ let url = Url::parse("ws://127.0.0.1:1/echo").unwrap();
+ let (advisory_tx, _advisory_rx) =
+ mpsc::channel::(16);
+ let (_pipe, conn) = spawn_tap_engine(
+ id, url, crate::session_map::AppState::default(), advisory_tx,
+ );
+ let _ = conn.close_tx.send(());
+ conn.join.abort();
+ }
+```
+
+- [ ] **Step 2: Run the test to verify it fails** — `spawn_tap_engine` currently returns a 2-tuple.
+
+- [ ] **Step 3: Update `spawn_tap_engine` + `TapConn` + `run_engine_loop`**
+
+In `tap_engine.rs`:
+
+1. In `spawn_tap_engine` (~line 131-216): add the `advisory` channel:
+ ```rust
+ let (tx_advisory, advisory_rx) = mpsc::channel::(16);
+ ```
+ Pass `tx_advisory` into `run_engine_loop` and from there into `run_tap_client`. Return `(pipe, conn, Some(advisory_rx))`. Drop the `advisory_rx` if the session closes before wiring the Reflex.
+
+2. Update the signature: `pub fn spawn_tap_engine(session_id, tap_url, app_state, advisory_tx: mpsc::Sender) -> (TapAudioPipe, TapConn)` — the media thread owns the channel; this is ONE of two senders (the other is `LocalVadReflex`'s own advisory_tx clone).
+
+3. Update `run_engine_loop` signature to accept `tx_advisory: mpsc::Sender` and pass it through to `run_tap_client(..., tx_advisory, ...)`.
+
+- [ ] **Step 4: Update ALL existing call sites of `spawn_tap_engine`**
+
+```bash
+rg 'spawn_tap_engine\(' --type rust
+```
+
+Each caller (the existing one is `session_map.rs::drive_all_sessions` — which itself is being relocated to `media_thread.rs` in Task 6) now passes an `advisory_tx` Sender as the 4th arg. The `LocalVadReflex` clones the same Sender in Task 6's composition site.
+
+- [ ] **Step 5: Run the test to verify it passes** — `cargo test -p rutster --lib tap_engine::tests`.
+
+- [ ] **Step 6: fmt + clippy + full test + commit**
+
+```bash
+cargo fmt --all --check
+cargo clippy --all --all-targets -- -D warnings
+cargo test --all
+git add crates/rutster/src/tap_engine.rs
+git commit -s -m "feat(binary): spawn_tap_engine accepts advisory_tx (slice-4 §3.1)
+
+The media thread owns the advisory channel (two senders: the engine +
+the local VAD). spawn_tap_engine takes advisory_tx as a parameter +
+forwards brain speech_started/speech_stopped through it."
+```
+
+---
+
+### Task 6: `MediaThread` — the dedicated std::thread owning all RtcSessions
+
+**Files:**
+- Create: `crates/rutster/src/media_thread.rs`
+- Modify: `crates/rutster/src/lib.rs` (add `pub mod media_thread;`)
+- Test: `crates/rutster/src/media_thread.rs` (inline tests)
+
+**Interfaces:**
+- Consumes: `RtcSession` + `RtcSessionError` (from `rutster-media`), `ChannelId` (from `rutster-call-model`), `spawn_tap_engine` (from Task 5), `Reflex` (from Task 2).
+- Produces:
+ - `pub struct MediaThread { cmd_tx: mpsc::Sender, join: Option> }`
+ - `pub enum MediaCmd { AcceptOffer { id, sdp, reply }, Delete { id, reply }, Shutdown { reply } }`
+ - `MediaThread::spawn(default_tap_url, tokio_handle) -> Self` — constructs + spawns the std::thread.
+ - `MediaThread::cmd_tx(&self) -> mpsc::Sender` — for `AppState` to clone.
+ - `MediaThread::shutdown(self) -> Result<(), ...>` — graceful shutdown.
+
+- [ ] **Step 1: Write the failing test for `MediaThread`'s basic lifecycle**
+
+Create `crates/rutster/src/media_thread.rs` with test module:
+
+```rust
+//! # MediaThread — the dedicated 20ms media loop on a std::thread
+//! (slice-4 spec §2.2, §4)
+//!
+//! ARCHITECTURE.md mandates "dedicated timing threads, not the shared
+//! tokio pool." slice-1 ran the poll on tokio as an acknowledged
+//! deviation; slice-4 graduates it. ONE `std::thread::spawn` at binary
+//! startup owns `HashMap` exclusively; all access
+//! from axum is via a command channel. The 20ms tick is
+//! `std::thread::sleep(Duration::from_millis(10))`.
+//!
+//! # Why one thread, not per-session
+//!
+//! Spearhead scale (see slice-4 spec §6.3). The command-channel seam
+//! makes the later threadpool-shard graduation localized.
+//!
+//! # The seam (loop_driver + rtc_session byte-identical)
+//!
+//! `MediaThread` calls `RtcSession::run_poll_once(now)` — the unchanged
+//! `loop_driver::drive`. The `Reflex` wrapper is wired in
+//! here on the `Connected` transition (via `RtcSession::set_pipe`), not
+//! inside `rtc_session.rs`. The seam holds.
+
+use std::collections::HashMap;
+use std::sync::Arc;
+use std::thread::JoinHandle;
+use std::time::{Duration, Instant};
+
+use rutster_call_model::ChannelId;
+use rutster_media::{RtcSession, RtcSessionError};
+use tokio::sync::{mpsc, oneshot};
+use tracing::{debug, info, warn};
+
+use crate::tap_engine::spawn_tap_engine;
+
+/// The 10ms meta-tick. Finer than the 20ms outbound encode tick so str0m's
+/// `Timeout` outputs are honored promptly.
+const META_TICK: Duration = Duration::from_millis(10);
+
+/// Capacity for the command channel from axum to the media thread.
+const CMD_CHANNEL_CAPACITY: usize = 64;
+
+/// Commands axum sends to the media thread (cold-path only — NEVER on
+/// the 20ms tick). The thread owns RtcSessions exclusively; this is the
+/// ONLY entry point for axum-side mutation.
+#[derive(Debug)]
+pub enum MediaCmd {
+ /// Construct a fresh RtcSession, store it under a new ChannelId, reply.
+ /// The thread constructs RtcSession::new() (keeps all RtcSession
+ /// construction on the thread that owns it).
+ Register {
+ tap_url: url::Url,
+ reply: oneshot::Sender>,
+ },
+ /// Accept a browser SDP offer on the session's behalf, reply with the
+ /// SDP answer (cold-path — the axum POST /v1/sessions/{id}/office handler).
+ AcceptOffer {
+ id: ChannelId,
+ sdp: String,
+ reply: oneshot::Sender>,
+ },
+ /// Tear down a session — fires close_tx + bounded-await the engine task
+ /// (750ms cap), then removes the entry.
+ Delete {
+ id: ChannelId,
+ reply: oneshot::Sender<()>,
+ },
+ /// Graceful shutdown — drain + drop + join.
+ Shutdown {
+ reply: oneshot::Sender<()>,
+ },
+}
+
+/// The handle returned to the binary. Clone the `cmd_tx` per-session;
+/// the `JoinHandle` is dropped on shutdown to detach (the binary's
+/// graceful-shutdown signal fires `Shutdown` first).
+pub struct MediaThread {
+ pub cmd_tx: mpsc::Sender,
+ join: Option>,
+}
+
+impl MediaThread {
+ /// Spawn the dedicated media thread. Captures a `tokio::runtime::Handle`
+ /// so the thread can `handle.spawn(spawn_tap_engine(...))` on the
+ /// `Connected` transition. The thread owns `HashMap` exclusively.
+ pub fn spawn(
+ default_tap_url: url::Url,
+ tokio_handle: tokio::runtime::Handle,
+ ) -> Self {
+ let (cmd_tx, cmd_rx) = mpsc::channel(CMD_CHANNEL_CAPACITY);
+ let join = std::thread::Builder::new()
+ .name("rutster-media".into())
+ .spawn(move || {
+ run_media_thread(cmd_rx, default_tap_url, tokio_handle);
+ })
+ .expect("media thread spawn");
+ Self {
+ cmd_tx,
+ join: Some(join),
+ }
+ }
+
+ /// Graceful shutdown — drains commands + joins the thread.
+ pub fn shutdown(mut self) {
+ let (reply, rx) = oneshot::channel();
+ let _ = self.cmd_tx.blocking_send(MediaCmd::Shutdown { reply });
+ let _ = rx.blocking_recv();
+ if let Some(join) = self.join.take() {
+ let _ = join.join();
+ }
+ }
+}
+
+impl Drop for MediaThread {
+ fn drop(&mut self) {
+ if let Some(join) = self.join.take() {
+ // Best-effort: if shutdown wasn't called explicitly, just
+ // detach. The thread will exit when cmd_rx is dropped.
+ // (We don't block on join in Drop — that could deadlock
+ // if the thread is mid-call into a tokio runtime handle
+ // that's being torn down.)
+ debug!(name = ?join.thread().name(), "media thread detached on drop");
+ }
+ }
+}
+
+/// The per-session state owned by the media thread.
+struct ThreadSession {
+ rtc: RtcSession,
+ /// `Some` only after the `Connected` transition spawns the TapEngine
+ /// + wires the `Reflex` wrapper.
+ tap_conn: Option,
+ /// The `advisory_rx` end stored UNTIL the Connect transition wires it
+ /// into `Reflex::new` (then `None` — the Reflex consumed it).
+ pending_advisory_rx: Option>,
+}
+
+fn run_media_thread(
+ mut cmd_rx: mpsc::Receiver,
+ default_tap_url: url::Url,
+ tokio_handle: tokio::runtime::Handle,
+) {
+ let mut sessions: HashMap = HashMap::new();
+ info!("media thread started");
+
+ loop {
+ // === Step 1: drain ALL pending commands (cold path) BEFORE ticking. ===
+ while let Ok(cmd) = cmd_rx.try_recv() {
+ match cmd {
+ MediaCmd::Register { tap_url, reply } => {
+ match RtcSession::new() {
+ Ok(session) => {
+ let id = session.channel_id();
+ sessions.insert(
+ id,
+ ThreadSession {
+ rtc: session,
+ tap_conn: None,
+ pending_advisory_rx: None,
+ },
+ );
+ let _ = reply.send(Ok(id));
+ debug!(channel_id = %id, %tap_url, "session registered");
+ }
+ Err(e) => {
+ let _ = reply.send(Err(format!("RtcSession::new: {e}")));
+ }
+ }
+ }
+ MediaCmd::AcceptOffer { id, sdp, reply } => {
+ let result = match sessions.get_mut(&id) {
+ Some(s) => s.rtc.accept_offer(&sdp).map_err(|e| format!("{e}")),
+ None => Err(format!("session {id} not found")),
+ };
+ let _ = reply.send(result);
+ }
+ MediaCmd::Delete { id, reply } => {
+ if let Some(mut s) = sessions.remove(&id) {
+ if let Some(conn) = s.tap_conn.take() {
+ let _ = conn.close_tx.send(());
+ let teardown =
+ tokio_handle.block_on(tokio::time::timeout(
+ Duration::from_millis(750),
+ &mut conn.join,
+ ));
+ match teardown {
+ Ok(Ok(())) => {
+ info!(channel_id = %id, "tap engine torn down via Delete (graceful)");
+ }
+ _ => {
+ conn.join.abort();
+ info!(channel_id = %id, "tap engine torn down via Delete (abort after timeout)");
+ }
+ }
+ }
+ s.rtc.channel.tap = None;
+ s.rtc.channel.state = rutster_call_model::ChannelState::Closing;
+ s.rtc.channel.state = rutster_call_model::ChannelState::Closed;
+ }
+ let _ = reply.send(());
+ }
+ MediaCmd::Shutdown { reply } => {
+ info!("media thread shutdown; dropping {} sessions", sessions.len());
+ sessions.clear();
+ let _ = reply.send(());
+ return;
+ }
+ }
+ }
+
+ // === Step 2: the 10ms meta-tick over all sessions. ===
+ let now = Instant::now();
+ let mut closed_ids: Vec = Vec::new();
+ for (id, session) in sessions.iter_mut() {
+ // Drain flush side-channel BEFORE run_poll_once (slice-2 §5.3 step 4).
+ if let Some(conn) = session.tap_conn.as_mut() {
+ if let Some(rx) = conn.flush_rx.as_mut() {
+ let mut should_flush = false;
+ while let Ok(()) = rx.try_recv() {
+ should_flush = true;
+ }
+ if should_flush {
+ session.rtc.clear_playout_ring();
+ }
+ }
+ }
+ let _ = session.rtc.run_poll_once(now);
+
+ // === relay the Connected transition: spawn TapEngine + wire Reflex. ===
+ use rutster_call_model::ChannelState;
+ if let ChannelState::Connected = session.rtc.channel.state {
+ if session.rtc.channel.tap.is_none() {
+ let url = default_tap_url.clone();
+ // The media thread owns the advisory channel (multi-producer:
+ // tokio::sync::mpsc::Sender is Clone). Both the brain's
+ // advisories (via spawn_tap_engine) AND the local VAD's
+ // trips (via LocalVadReflex) push to the SAME mpsc; the
+ // Reflex drains both uniformly. This means spawn_tap_engine
+ // takes advisory_tx as a PARAMETER (Task 5's signature
+ // changes: spawn_tap_engine(session_id, tap_url, app_state,
+ // advisory_tx) — see Task 5's revision note).
+ let (advisory_tx, advisory_rx) =
+ mpsc::channel::(16);
+ let (pipe, conn) = tokio_handle.block_on(async {
+ spawn_tap_engine(*id, url, crate::session_map::AppState::default(), advisory_tx.clone())
+ });
+ let metrics = rutster_media::ReflexMetrics::new();
+ // Compose: Reflex (state machine) wrapped by
+ // LocalVadReflex (primary VAD trigger). Both feed advisory_tx.
+ let reflex = rutster_media::Reflex::new(pipe, advisory_rx, metrics);
+ let vad = rutster_media::LocalVadReflex::new(reflex, advisory_tx);
+ session.rtc.set_pipe(vad);
+ session.rtc.channel.tap = Some(rutster_call_model::TapHandle::new());
+ session.tap_conn = Some(conn);
+ info!(channel_id = %id, "tap engine + reflex + local VAD wired on Connected");
+ continue;
+ }
+ }
+
+ if session.rtc.is_closed() {
+ closed_ids.push(*id);
+ }
+ }
+ for id in closed_ids {
+ sessions.remove(&id);
+ debug!(channel_id = %id, "session evicted after close");
+ }
+
+ // === Step 3: sleep META_TICK. ===
+ std::thread::sleep(META_TICK);
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn media_thread_register_and_shutdown_round_trips() {
+ let rt = tokio::runtime::Runtime::new().unwrap();
+ let handle = rt.handle().clone();
+ let url = url::Url::parse("ws://127.0.0.1:8081/echo").unwrap();
+ let thread = MediaThread::spawn(url, handle);
+ let (reply, rx) = oneshot::channel();
+ thread
+ .cmd_tx
+ .blocking_send(MediaCmd::Register {
+ tap_url: url::Url::parse("ws://127.0.0.1:1/echo").unwrap(),
+ reply,
+ })
+ .unwrap();
+ let id = rx.blocking_recv().expect("register reply").expect("session");
+ assert_eq!(format!("{}", id).len(), 36, "UUID-shaped ChannelId");
+ thread.shutdown();
+ }
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `cargo test -p rutster --lib media_thread::tests`
+Expected: FAIL — module not yet declared in `lib.rs`.
+
+- [ ] **Step 3: Declare the module in `lib.rs`**
+
+In `crates/rutster/src/lib.rs`, add:
+
+```rust
+pub mod media_thread;
+```
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `cargo test -p rutster --lib media_thread::tests`
+Expected: PASS (1 test). Note: the test uses `tokio_handle.block_on(async { spawn_tap_engine(...) })` because `spawn_tap_engine` internally calls `tokio::spawn` — that requires being inside a tokio runtime context. The block_on is cold-path, runs only on the `Connected` transition.
+
+- [ ] **Step 5: fmt + clippy + full test + commit**
+
+```bash
+cargo fmt --all --check
+cargo clippy --all --all-targets -- -D warnings
+cargo test --all
+git add crates/rutster/src/media_thread.rs crates/rutster/src/lib.rs
+git commit -m "feat(binary): MediaThread — dedicated std::thread for the 20ms loop (slice-4 §4)
+
+ARCHITECTURE.md mandate (\"never the shared tokio pool\") finally landed.
+One std::thread owns all RtcSessions exclusively; axum routes via command
+channel (Register/AcceptOffer/Delete/Shutdown). The Reflex
+wrapper is wired here on Connected via RtcSession::set_pipe. loop_driver +
+rtc_session untouched (seam holds)."
+```
+
+---
+
+### Task 7: `session_map.rs` rewire + `main.rs` + `routes.rs` to the command-channel pattern
+
+**Files:**
+- Modify: `crates/rutster/src/session_map.rs` (the full rewire)
+- Modify: `crates/rutster/src/main.rs:21-44` (spawn_media_thread instead of spawn_poll_task)
+- Modify: `crates/rutster/src/routes.rs` (`post_offer` + `delete_session` use `cmd_tx.send(...)`)
+
+**Interfaces:**
+- Consumes: `MediaThread` + `MediaCmd` (from Task 6).
+- Produces: `AppState` with `cmd_tx: mpsc::Sender` instead of `sessions: DashMap<...>`. The `default_tap_url` field stays.
+
+- [ ] **Step 1: Write the failing integration test (`api_integration.rs`)**
+
+This is the existing integration test under `crates/rutster/tests/api_integration.rs`. Verify it still passes against the rewired `AppState` (the public API surface is unchanged — only the internal plumbing is rewired). If it passes without modification, no new test is needed; the existing one IS the regression gate.
+
+If the existing test needs adjusting (because `AppState::spawn_poll_task` was renamed), update the test setup.
+
+- [ ] **Step 2: Rewire `session_map.rs`**
+
+Replace `SessionEntry.rtc: Arc>` with `cmd_tx: mpsc::Sender`. `create_session` → sends `Register` command; `get` → `cmd_tx.send(AcceptOffer).await` (NOT what `get` used to return — `get` was returning the `Arc>` for `post_offer` to lock; replace with a new `accept_offer(id, sdp) -> Result` async method that sends `AcceptOffer` + awaits the reply). `close(id) -> ...` → sends `Delete` + awaits. `spawn_poll_task` → `spawn_media_thread` (constructs `MediaThread`, stores `cmd_tx`).
+
+```rust
+// Sketch — the dev fills in the exact code.
+#[derive(Clone)]
+pub struct AppState {
+ pub cmd_tx: mpsc::Sender,
+ pub poll_running: Arc>,
+ pub default_tap_url: url::Url,
+}
+
+impl AppState {
+ pub fn new(default_tap_url: url::Url) -> Self { ... }
+
+ pub async fn create_session(&self, tap_url_override: Option) -> Result {
+ let tap_url = tap_url_override.unwrap_or_else(|| self.default_tap_url.clone());
+ let (reply, rx) = oneshot::channel();
+ self.cmd_tx.send(MediaCmd::Register { tap_url, reply }).await
+ .map_err(|e| format!("media thread gone: {e}"))?;
+ rx.await.map_err(|e| format!("media thread reply dropped: {e}"))?
+ }
+
+ pub async fn accept_offer(&self, id: ChannelId, sdp: String) -> Result {
+ let (reply, rx) = oneshot::channel();
+ self.cmd_tx.send(MediaCmd::AcceptOffer { id, sdp, reply }).await
+ .map_err(|e| format!("media thread gone: {e}"))?;
+ rx.await.map_err(|e| format!("media thread reply dropped: {e}"))?
+ }
+
+ pub async fn close(&self, id: ChannelId) {
+ let (reply, rx) = oneshot::channel();
+ let _ = self.cmd_tx.send(MediaCmd::Delete { id, reply }).await;
+ let _ = rx.await;
+ }
+
+ pub async fn spawn_media_thread(self, tokio_handle: tokio::runtime::Handle) -> MediaThread {
+ let mut running = self.poll_running.lock().await;
+ if *running {
+ panic!("media thread already spawned");
+ }
+ *running = true;
+ drop(running);
+ let thread = MediaThread::spawn(self.default_tap_url.clone(), tokio_handle);
+ thread
+ }
+}
+```
+
+- [ ] **Step 3: Update `routes.rs`**
+
+`post_offer` previously did `AppState::get(id)` → `lock` → `accept_offer`. Now it calls `state.accept_offer(id, sdp).await` directly. `delete_session` previously called `state.close(id)` — unchanged signature, different internals.
+
+```bash
+rg 'self\.get\(|self\.sessions\.get\(' crates/rutster/src/routes.rs
+```
+
+- [ ] **Step 4: Update `main.rs`**
+
+```rust
+// In main.rs:
+let state = AppState::new(default_tap_url);
+let media_thread = state.clone().spawn_media_thread(tokio::runtime::Handle::current()).await;
+// ... after axum::serve completes:
+media_thread.shutdown();
+```
+
+- [ ] **Step 5: Run all tests**
+
+```bash
+cargo test --all
+```
+Expected: PASS, including the existing `api_integration.rs` end-to-end test. If it fails, the regression is in the route wiring — fix before committing.
+
+- [ ] **Step 6: fmt + clippy + commit**
+
+```bash
+cargo fmt --all --check
+cargo clippy --all --all-targets -- -D warnings
+git add crates/rutster/src/session_map.rs crates/rutster/src/main.rs crates/rutster/src/routes.rs
+git commit -m "feat(binary): rewire session_map + routes to MediaThread command channel (slice-4 §4.3)
+
+AppState now holds cmd_tx: Sender instead of DashMap<...>.
+create_session/accept_offer/close route via the command channel
+(cold-path only). main.rs spawns the MediaThread + shuts it down on
+graceful exit."
+```
+
+---
+
+### Task 8: `MockRealtimeBrain` advisory schedule
+
+**Files:**
+- Modify: `crates/rutster-brain-realtime/src/mock.rs:42-87, 114-237`
+- Test: `crates/rutster-brain-realtime/src/mock.rs` (inline tests)
+
+**Interfaces:**
+- Consumes: nothing new; the mock already echoes `speech_started`/`speech_stopped` (lines 210-219). The change: the mock emits them UNPROMPTED on a schedule (simulating the brain's VAD), not just in response to the client echoing them.
+
+- [ ] **Step 1: Write the failing test**
+
+Append to `crates/rutster-brain-realtime/src/mock.rs` test module:
+
+```rust
+ /// slice-4: MockRealtimeBrain can emit `speech_started`/`speech_stopped`
+ /// on a programmable schedule, simulating the brain's VAD firing. This
+ /// is what the slice-4 barge-in e2e test drives.
+ #[tokio::test]
+ async fn emits_speech_started_on_schedule_after_n_audio_in_frames() {
+ use std::sync::{Arc, Mutex};
+ let mut mock = MockRealtimeBrain::start().await.unwrap();
+ mock.set_advisory_schedule(vec![
+ AdvisoryTrigger { after_audio_in_frames: 2, event: AdvisoryKind::SpeechStarted },
+ AdvisoryTrigger { after_audio_in_frames: 4, event: AdvisoryKind::SpeechStopped },
+ ]);
+ let url = mock.url();
+ let req = url.as_str().into_client_request().unwrap();
+ let (mut ws, _resp) = tokio_tungstenite::connect_async(req).await.unwrap();
+ // Send session.update first (the mock's contract).
+ let session_update = json!({
+ "type": "session.update",
+ "session": { "turn_detection": null }
+ });
+ ws.send(Message::Text(session_update.to_string())).await.unwrap();
+ // Send 2 audio_in appends → expect a speech_started.
+ for _ in 0..2 {
+ let append = json!({ "type": "input_audio_buffer.append", "audio": "AAAA" });
+ ws.send(Message::Text(append.to_string())).await.unwrap();
+ }
+ // Skip the canned response.audio.delta replies; wait for the speech_started.
+ let mut saw_started = false;
+ for _ in 0..10 {
+ let msg = tokio::time::timeout(Duration::from_millis(500), ws.next())
+ .await
+ .expect("event within 500ms")
+ .unwrap()
+ .unwrap();
+ let text = msg.into_text().unwrap();
+ if text.contains("speech_started") {
+ saw_started = true;
+ break;
+ }
+ }
+ assert!(saw_started, "mock must emit speech_started after N appends");
+ }
+```
+
+- [ ] **Step 2: Run the test to verify it fails** — `set_advisory_schedule` + types don't exist on `MockRealtimeBrain` yet.
+
+- [ ] **Step 3: Add the advisory schedule API to `MockRealtimeBrain`**
+
+Add to the `MockRealtimeBrain` struct + impl (and `handle_connection`):
+
+```rust
+/// A trigger for the advisory schedule. The mock counts
+/// `input_audio_buffer.append` events; when the count reaches
+/// `after_audio_in_frames`, it emits `event` unprompted (simulating
+/// the brain's VAD firing).
+#[derive(Debug, Clone)]
+pub struct AdvisoryTrigger {
+ pub after_audio_in_frames: u32,
+ pub event: AdvisoryKind,
+}
+
+#[derive(Debug, Clone, Copy)]
+pub enum AdvisoryKind {
+ SpeechStarted,
+ SpeechStopped,
+}
+
+impl MockRealtimeBrain {
+ /// Set a schedule of advisory events the mock emits UNPROMPTED after
+ /// observing N `input_audio_buffer.append` events. Used by the slice-4
+ /// barge-in e2e test to drive the reflex.
+ pub fn set_advisory_schedule(&mut self, schedule: Vec) {
+ // Communicated to the accept_loop via a shared Arc>>.
+ // The handle_connection task reads + drains the schedule per-connection.
+ self.advisory_schedule = Some(Arc::new(Mutex::new(schedule)));
+ }
+}
+```
+
+Add `advisory_schedule: Option>>>` to the struct; pass into `accept_loop` → `handle_connection`. In `handle_connection`'s `"input_audio_buffer.append"` arm: increment a per-connection counter; consult the schedule; emit `speech_started`/`speech_stopped` when the trigger fires.
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+- [ ] **Step 5: fmt + clippy + commit**
+
+---
+
+### Task 9: Barge-in e2e integration test (PRIMARY + SECONDARY paths)
+
+**Files:**
+- Create: `crates/rutster/tests/barge_in_integration.rs`
+- Consumes: `MockRealtimeBrain` (Task 8), `MediaThread` (Task 6), `spawn_tap_engine` (Task 5), `Reflex` (Task 2) + `LocalVadReflex>` (Task 2b).
+
+**Two test cases (both required — they prove different properties):**
+
+- [ ] **Step 1: Write the PRIMARY-path e2e test (proves wedge #1 — kill WITHOUT brain)**
+
+Sets up `MediaThread` + a `LocalVadReflex>` stack driven by a synthetic caller
+frame source + a synthetic brain `audio_out` source (no `MockRealtimeBrain` — the brain is NOT
+required for the kill on this path). Asserts:
+- Push N (= `VAD_DEBOUNCE_FRAMES` = 3) loud caller frames (samples = 1000) into the sink path
+ → the local VAD trips → `Reflex::next_pcm_frame()` returns `None` even with a frame queued
+ in the ring (the barge flushed it). **NO brain advisory was sent.**
+- A fresh `PcmFrame` on the brain's `audio_out` source → next `next_pcm_frame()` returns `Some`
+ (resume).
+- `ReflexMetrics.barge_in_count` is 1 (the kill fired); the brain's advisory channel was empty
+ (proving the kill didn't depend on the brain).
+
+```rust
+// crates/rutster/tests/barge_in_integration.rs
+use rutster_media::{AudioPipe, AudioSource, AudioSink, PcmFrame, LOCAL_VAD_THRESHOLD, VAD_DEBOUNCE_FRAMES};
+use rutster_media::{AdvisoryEvent, LocalVadReflex, Reflex, ReflexMetrics};
+use rutster_tap::{TapAudioPipe, TapMetrics};
+use tokio::sync::mpsc;
+
+#[tokio::test]
+async fn primary_path_local_vad_kills_playout_without_brain() {
+ let (tx_pcm_in, _rx_pcm_in) = mpsc::channel(32);
+ let (tx_audio_out, rx_audio_out) = mpsc::channel(32);
+ let tap_metrics = std::sync::Arc::new(TapMetrics::new());
+ let pipe = TapAudioPipe::new(tx_pcm_in, rx_audio_out, tap_metrics);
+
+ let (advisory_tx, advisory_rx) = mpsc::channel(16);
+ let reflex_metrics = ReflexMetrics::new();
+ let reflex = Reflex::new(pipe, advisory_rx, reflex_metrics.clone());
+ let mut stack = LocalVadReflex::new(reflex, advisory_tx);
+
+ // Pre-load a brain audio_out frame into the ring (drain via next_pcm_frame).
+ tx_audio_out.send(PcmFrame::zeroed()).await.unwrap();
+ let _ = stack.next_pcm_frame(); // drain into ring
+ stack.next_pcm_frame(); // pop one (brain is playing)
+
+ // Loud caller audio × N → local VAD trips.
+ let mut loud = PcmFrame::zeroed();
+ for s in loud.samples.iter_mut() { *s = 1000; }
+ for _ in 0..VAD_DEBOUNCE_FRAMES {
+ stack.on_pcm_frame(loud.clone());
+ }
+
+ // Next playout tick: kill applied, None returned (ring cleared).
+ let f = stack.next_pcm_frame();
+ assert!(f.is_none(), "primary-path local VAD must kill playout within 1 tick");
+ assert_eq!(reflex_metrics.barge_in_count.load(std::sync::atomic::Ordering::Relaxed), 1);
+
+ // Fresh brain audio_out → resume.
+ tx_audio_out.send(PcmFrame::zeroed()).await.unwrap();
+ let resumed = stack.next_pcm_frame();
+ assert!(resumed.is_some(), "first fresh audio_out post-barge must resume playout");
+}
+```
+
+- [ ] **Step 2: Write the SECONDARY-path e2e test (exercises slice-3's advisory plumbing)**
+
+Sets up `MockRealtimeBrain` (with advisory schedule emitting `speech_started` after 2 audio_in
+appends) + `MediaThread` + the same stack. Pushes QUIET caller audio (samples = 0, sub-threshold)
+so the local VAD does NOT trip; the brain's `speech_started` advisory IS the trigger. Asserts:
+- `speech_started` arrives → kill applied within ≤1 tick.
+- Fresh `audio_out` → resume.
+
+Proves the secondary path still works (the brain's ASR-VAD is the backstop when local VAD
+doesn't fire — e.g. quiet callers, or as confirmation of a local kill).
+
+- [ ] **Step 3: Run both e2e tests + iterate** — the PRIMARY-path test is the proof of the slice (wedge #1).
+
+- [ ] **Step 4: fmt + clippy + commit**
+
+```bash
+cargo fmt --all --check
+cargo clippy --all --all-targets -- -D warnings
+cargo test --all
+git add crates/rutster/tests/barge_in_integration.rs
+git commit -s -m "test(slice-4): barge-in e2e — primary (local VAD, no brain) + secondary (advisory)
+
+The PRIMARY-path test proves wedge #1: loud caller audio → kill within 4
+ticks (≤80ms wallclock), WITHOUT any brain advisory. The SECONDARY-path
+test exercises slice-3's advisory plumbing as the confirmation/backstop
+path. Both feed the same advisory mpsc; the Reflex drains both."
+```
+
+---
+
+### Task 10: CI seam gate + verification
+
+**Files:**
+- Modify: `.github/workflows/ci.yml` (add the seam-diff step)
+
+- [ ] **Step 1: Add the seam-diff CI step**
+
+In `.github/workflows/ci.yml`, add a job step (after `cargo test --all`):
+
+```yaml
+ - name: Seam gate — loop_driver + rtc_session byte-identical to slice-3
+ run: |
+ git fetch origin main --depth=1
+ # Check against the PRE-slice-3 main, OR pin to the slice-3 merge SHA.
+ # For now: assert no diff in these files between this branch + main.
+ git diff --exit-code origin/main -- \
+ crates/rutster-media/src/loop_driver.rs \
+ crates/rutster-media/src/rtc_session.rs
+```
+
+- [ ] **Step 2: Final fmt + clippy + test + deny sweep**
+
+```bash
+cargo fmt --all --check
+cargo clippy --all --all-targets -- -D warnings
+cargo test --all
+cargo deny check
+```
+
+- [ ] **Step 3: Commit + tag the slice-4-e2e-green state** with `test(slice-4):` + the §7 done-criteria checklist.
+
+---
+
+## Self-Review Notes
+
+- **Spec coverage:**
+ - §1.1 In scope: Reflex (Task 2), barge_in_flush (Task 1+3), AdvisoryEvent (Task 1), MediaThread (Task 6), session_map rewire (Task 7), MockRealtimeBrain extension (Task 8), barge e2e (Task 9), learner-facing comments (per-task, verified by `cargo doc` at Task 10).
+ - §1.2 Out of scope: VAD tuning framework deferred (the VAD itself is in scope); other items unchanged.
+ - §7 Done-criteria: items 1-12 all mapped (Tasks 1, 2, 2b, 3, 4, 5, 6, 7, 8, 9, 10).
+ - §8 Open decisions: pinned in respective tasks (mock API in Task 8, teardown ordering in Task 6).
+- **Placeholder scan:** none found.
+- **Type consistency:** `AdvisoryEvent` consistent (Task 1 → 2 → 2b → 4 → 5 → 8). `Reflex` signature consistent (Task 2 → 6). `LocalVadReflex
` consistent (Task 2b → 6). `MediaCmd` consistent (Task 6 → 7). `spawn_tap_engine` takes `advisory_tx` as a parameter (Task 5 revision note); Task 6's call site clones the Sender.
+- **Seam gate:** `loop_driver.rs` + `rtc_session.rs` are NOT in any task's Modify list. The invariant is preserved by construction.
+- **Wedge-#1 audit (2026-07-01 review revision):** the PRIMARY-path e2e test (Task 9 step 1) proves the kill fires WITHOUT any brain advisory — local VAD in `on_pcm_frame` is the trigger source. The kill decision is in-core on the dedicated thread, in the 20ms loop, zero brain round-trip. This is the property ARCHITECTURE.md:79-81 demands.
diff --git a/docs/superpowers/specs/2026-07-01-slice-4-barge-in-design.md b/docs/superpowers/specs/2026-07-01-slice-4-barge-in-design.md
new file mode 100644
index 0000000..659ca63
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-01-slice-4-barge-in-design.md
@@ -0,0 +1,814 @@
+# Rutster slice 4 — Barge-in: VAD-driven playout kill on a dedicated media thread
+
+- **Status:** Draft (pending review)
+- **Date:** 2026-07-01
+- **Spearhead step:** 4 of 6 (vision-revision §10 / PORT_PLAN "Phasing")
+- **Origin:** brainstorming session 2026-07-01
+- **Depends on:** [slice 1 — WebRTC media loopback](2026-06-28-slice-1-webrtc-loopback-design.md),
+ [slice 2 — The agent tap](2026-06-28-slice-2-agent-tap-design.md), and slice-3's
+ OpenAI Realtime brain (merged as `c30a452` — `MockRealtimeBrain` + translator + the
+ `speech_started` / `speech_stopped` advisory events). All three must be landed and green.
+- **Related:** [ADR-0002](../../adr/0002-north-star-and-fused-core.md) (fused vertical — the
+ hot-path hop invariant this slice re-affirms), [ADR-0008](../../adr/0008-fob-and-green-zone.md)
+ (FOB/green-zone doctrine — the reflex is a FOB member: hot-path, differentiating),
+ [ARCHITECTURE.md §"Biggest technical risk"](../../ARCHITECTURE.md) (the reflex loop *is*
+ the remaining long pole), [ARCHITECTURE.md §"Media plane"](../../ARCHITECTURE.md)
+ ("Dedicated timing threads for the 20ms loop, **never the shared tokio pool**" — this
+ slice finally lands that mandate).
+
+---
+
+## TL;DR
+
+Stand up spearhead step 4: the **FOB reflex loop**. Slice 3 pre-paved the advisory signals
+(`speech_started` / `speech_stopped` from the brain) and locked the turn-ownership decision
+(OpenAI Realtime server-side VAD disabled; the FOB owns turn-taking). Slice 4 **acts** on
+the caller's speech with a **local in-core VAD** as the primary trigger — an RMS/energy
+detector running in `on_pcm_frame` on the dedicated thread, in the 20 ms loop, with **zero
+brain round-trip** between caller speech and playout kill. This is the property ARCHITECTURE.md:79-81
+demands ("Local real-time reflexes... live in-core because the brain round-trip is too slow
+to enforce them") and the proof wedge #1 rests on ("VAD killing TTS the instant the caller
+speaks, without the brain" — README:98-100). Slice-3's `speech_started`/`speech_stopped`
+advisory becomes the **secondary/confirmation** signal — the brain's ASR-quality VAD
+confirms the local kill slightly later, but the kill itself fires from the FOB's own
+inspection of caller audio.
+
+Slice 4 also **graduates the media loop off the tokio pool**: a single dedicated `std::thread`
+owns all `RtcSession`s exclusively and drives the 20 ms tick via `Instant::sleep_until`. This
+honors ARCHITECTURE.md's "never the shared tokio pool" mandate, which slice-1 explicitly
+deferred to "step 4 (barge-in)" (`loop_driver.rs:18-23`). The graduation is load-bearing:
+the reflex is the differentiator and the long pole, and its timing discipline demands a
+thread that doesn't compete with the axum runtime for scheduling.
+
+The **seam slice 1→3 preserved** (`loop_driver.rs` + `rtc_session.rs` byte-identical) holds
+for slice-4 as well: the reflex is a pair of composing `AudioPipe` decorators (`Reflex
` +
+`LocalVadReflex
`) in `rutster-media`, invisibly to `loop_driver::drive`. Only the
+binary-side wiring (`session_map.rs` → `media_thread.rs`) changes shape; the media crate's
+hot path stays untouched.
+
+---
+
+## 1. Scope
+
+### 1.1 In scope
+
+- Implementation of spearhead step 4: **barge-in / VAD-driven playout kill**, driven by a
+ **local in-core VAD** (RMS/energy detector in `on_pcm_frame` — the primary trigger, zero
+ brain round-trip) with the brain's `speech_started`/`speech_stopped` advisory as the
+ secondary/confirmation signal. This proves wedge #1 ("VAD killing TTS the instant the
+ caller speaks, without the brain" — README:98-100, ARCHITECTURE.md:79-81).
+- A **new `Reflex` wrapper** (`rutster-media/src/reflex.rs`) that decorates the
+ pipe the `RtcSession` holds. The reflex owns the mute state machine, the advisory channel
+ receiver, and the barge-in flush trigger. It is the concrete embodiment of ARCHITECTURE.md's
+ "local real-time reflexes" row for the barge-in case. Fed by BOTH the local VAD (via the
+ outer `LocalVadReflex` wrapper that injects `AdvisoryEvent`s into the same mpsc) AND the
+ brain's advisories.
+- A **new `LocalVadReflex` wrapper** (`rutster-media/src/reflex.rs`) — the
+ PRIMARY trigger. Decorates `AudioPipe`'s `on_pcm_frame` path: inspects the caller's decoded
+ PCM samples (computes RMS, compares to a `const` threshold, debounces N consecutive
+ above-threshold frames), fires `AdvisoryEvent::SpeechStarted { at: Instant::now() }` into
+ the `Reflex`'s advisory mpsc when local VAD trips. Wraps `Reflex` (composition:
+ `LocalVadReflex>` — the decorator pattern from §6.4 pays off here).
+ Forward-compatible: the threshold is a `const` for the MVP (no tuning framework — that's the
+ only piece deferred, per §1.2).
+- A **new `barge_in_flush` method on the `AudioPipe` trait** (default impl delegates to
+ `clear_playout_ring`) — the seam object's "kill now" path: clear the playout ring AND drain
+ the brain-bound `rx_audio_out` channel of any frames queued before the barge so the first
+ `audio_out` observed post-barge is provably post-barge. `TapAudioPipe` overrides;
+ `EchoAudioPipe` uses the default.
+- A **new `AdvisoryEvent` enum** (`SpeechStarted { at }`, `SpeechStopped { at }`) flowing
+ over a tokio mpsc from the TapEngine (tokio) to the Reflex (media thread). The engine
+ pushes the events it already decodes from the brain (slice-3 wired these as log+count;
+ slice-4 *forwards* them into the reflex).
+- A **new dedicated media thread** (`rutster/src/media_thread.rs`) replacing the tokio
+ `spawn_poll_task`. One `std::thread::spawn` at binary startup owns
+ `HashMap` exclusively; all access from axum is via a command
+ channel (`AcceptOffer`, `Delete`, `Shutdown`). The 20 ms tick is `std::thread::sleep`.
+- **Rewired `session_map.rs`** (binary): `SessionEntry.rtc: Arc>` →
+ `cmd_tx: mpsc::Sender`. `create_session`, `post_offer`, `close`,
+ `spawn_poll_task` all route through the command channel. The async handlers are cold-path;
+ no cross-thread coordination happens on the 20 ms tick.
+- **`MockRealtimeBrain` extension** (`rutster-brain-realtime/src/mock.rs`): gains the ability
+ to emit `speech_started` / `speech_stopped` on a programmable schedule (e.g. "after N
+ audio_in frames received, send `speech_started`; after M more, send `speech_stopped`").
+- **Barge-in e2e integration test** (extends slice-3's
+ `crates/rutster/tests/realtime_integration.rs` harness): synthetic WebRTC peer →
+ MediaThread → TapEngine → MockRealtimeBrain; mock emits `speech_started`; assert playout
+ goes silent within ≤1 tick (20 ms); mock emits fresh `audio_out`; assert playout resumes.
+- New `ReflexMetrics` (`barge_in_count`, `advisory_dropped`, `frames_suppressed`) mirroring
+ `TapMetrics` shape (atomics, snapshot fn). Threaded through the same `TapConn.metrics`
+ surface where reasonable, or a new side-car.
+- Thorough learner-facing comments on the new std-thread / channel-bridge / wrapper-decorator
+ patterns (slice-1 §7 standard carries over).
+
+### 1.2 Out of scope (with scheduled return)
+
+| Deferred item | Returns in | Why deferred |
+|---|---|---|
+| Local VAD **tuning framework** (configurable thresholds, per-environment calibration, adaptive noise floor) | post-spearhead refinement | The VAD itself (RMS/energy detector + debounce) IS in scope for slice-4 (the primary barge-in trigger, proving wedge #1). Only the *tuning* framework — configurable thresholds, calibration UI, adaptive noise floors — is deferred. The MVP ships with a single `const` threshold + N-frame debounce, exercised by the e2e test with a synthetic loud signal. Tuning to real-world noise conditions is post-spearhead. |
+| Per-session media threads / threadpool shard | later rung | Single thread covers spearhead scale (loopback dev + low-concurrency PSTN via slice-5). The command-channel seam between axum and the thread makes the graduation to a threadpool shard localized. |
+| Trickle ICE | later | Unchanged from slice-1 deferral. |
+| Min-mute floor / inter-word-gap debouncing | post-spearhead | `SpeechStopped` is a no-op for mute; a floor timer on resume would protect against brain-yield races (brain emits fresh `audio_out` before the caller's inter-word gap ends). Defer until observed in practice. |
+| Brain-side `input_audio_buffer.interrupt` / `clear` on barge | slice-5 or brain-side | Whether the brain should clear its own input buffer on `speech_started` is a brain-UX decision, not a FOB one; the FOB only kills *playout* (its half-duplex gate). The advisory already tells the brain what happened; the brain's response is its own concern. |
+| Half-duplex gating beyond playout kill | later rung | Barge-in is the first half-duplex reflex; full HD gating (mixing, jitter buffer interaction, multi-party) arrives with conferencing. |
+| TLS on HTTP / WSS | slice-5 | Unchanged. |
+| Authn / authz / multi-tenancy | slice-6 | Unchanged. |
+| Spend cap / abuse gate | slice-6 | Unchanged. |
+| Browser-based automated e2e (Playwright/Selenium) | post-spearhead | Unchanged. The synthetic-peer harness from slice-2/3 is the test vehicle. |
+
+---
+
+## 2. Architecture delta
+
+### 2.1 The reflex wrapper
+
+`Reflex` is a zero-cost-style decorator around any `AudioPipe`. It sits
+between `RtcSession.pipe` (which `loop_driver::drive` calls via `session.pipe.next_pcm_frame()`)
+and the concrete pipe (`TapAudioPipe` in production, `EchoAudioPipe` in slice-1's unit tests).
+`loop_driver` is oblivious to the wrapper: it still calls `session.pipe.next_pcm_frame()`,
+the dynamic dispatch through `Box` lands in `Reflex::next_pcm_frame`, which
+applies the state machine and delegates to `inner.next_pcm_frame()` per the table in §3.2.
+
+The reflex owns three pieces of state:
+- `advisory_rx: mpsc::Receiver` — drained sync-non-blocking via `try_recv`
+ on the 20 ms tick before delegating to `inner`. Fed by TWO senders on the same channel:
+ the outer `LocalVadReflex` (local VAD, the primary trigger) and the TapEngine task (the
+ brain's advisory, secondary/confirmation) — both over tokio mpsc.
+- `muted: bool` — the kill state. `next_pcm_frame` returns `None` while muted, *unless* the
+ inner returns `Some` (the resume condition — the first fresh `audio_out` clears mute).
+- `barge_epoch: u64` — incremented on every `SpeechStarted`. Load-bearing this slice: the
+ local VAD (primary) and the brain's advisory (secondary) can both fire on the same barge,
+ and the epoch distinguishes a genuine re-barge from the slower confirmation landing on the
+ same event (§3.2). The flush + drain keeps resume race-free regardless.
+
+### 2.2 The dedicated media thread
+
+A single `std::thread::spawn` replaces the tokio `spawn_poll_task`. The thread owns
+`HashMap` **exclusively** — no `Arc>` shared with
+axum. All access from the axum handlers is via a command channel:
+
+```rust
+enum MediaCmd {
+ AcceptOffer { id: ChannelId, sdp: String, reply: oneshot::Sender> },
+ Delete { id: ChannelId, reply: oneshot::Sender<()> },
+ Shutdown { reply: oneshot::Sender<()> },
+}
+```
+
+The thread loop per 10 ms meta-tick:
+1. Drain `cmd_rx` via `try_recv` loop — handle all pending commands before ticking.
+2. For each session in the map: drain the per-session `flush_rx` side-channel (slice-2's
+ existing disconnect-flush signal) BEFORE `run_poll_once`, then call
+ `RtcSession::run_poll_once(now)` (the unchanged `loop_driver::drive`).
+3. After `run_poll_once`, observe `channel.state`:
+ - `Connected && tap.is_none()` → spawn the TapEngine (tokio task via the
+ `tokio::runtime::Handle` captured at thread-start) + wire
+ `Reflex` as the session's pipe. Mirror of slice-2's spawn seam,
+ relocated from `session_map.rs::drive_all_sessions` to here.
+ - `Closed` → remove the entry + drop the session.
+4. `std::thread::sleep(Duration::from_millis(10))` — 10 ms meta-tick. (Stable API:
+ `std::thread::sleep_until` is nightly-only; `sleep(dur)` is the stable path. The 20 ms
+ outbound encode tick is driven inside `loop_driver::drive` (unchanged); the 10 ms
+ meta-tick gives finer resolution so str0m's `Timeout` outputs are honored promptly.)
+
+**The tokio ↔ std-thread bridge:** all channels are tokio mpsc/oneshot (constructable on
+tokio, drainable via `try_recv`/`blocking_recv` from any thread). The `tokio::runtime::Handle`
+captured at `MediaThread::spawn` time is used on the std thread to `handle.spawn(...)` the
+TapEngine when the `Connected` transition fires. No async code runs on the std thread itself
+— only sync channel ops + `RtcSession::run_poll_once`.
+
+**Why a single thread, not per-session:** spearhead scale. One loopback peer at a time in
+dev; even at low PSTN concurrency (slice-5) one thread drives dozens of sessions in 10 ms.
+Per-session threads arrive when the threadpool shard model lands (deferred). The
+command-channel seam between axum and the thread makes that graduation localized.
+
+### 2.3 The hot-path audit (ADR-0002 honored)
+
+ADR-0002's load-bearing rule: *"the control↔media gRPC hop on the per-call hot path is
+removed."* Slice 4 does not re-introduce a hop:
+
+- The reflex's kill decision happens **inside** `Reflex::next_pcm_frame` on the dedicated
+ thread — no channel send, no cross-thread coordination on the 20 ms tick. The advisory
+ arrives via a `try_recv` drain (sync, non-blocking).
+- axum → media-thread is **cold-path only** (SDP accept, DELETE). None of it runs on the
+ 20 ms tick.
+- The brain WS ↔ TapEngine (tokio) path is unchanged from slice-3. The advisory channel
+ is a *third* mpsc alongside the existing `tx_pcm_in`/`rx_audio_out`/`flush_tx` — same
+ pattern, additive.
+
+**The fused vertical stays fused.** ADR-0002 honored.
+
+---
+
+## 3. Component design
+
+### 3.1 `AdvisoryEvent` enum
+
+```rust
+// crates/rutster-media/src/reflex.rs
+
+/// A turn-event advisory from the brain. The brain decodes its own
+/// speech-to-text / VAD results and forwards these; the FOB *owns*
+/// turn-taking and acts on them (slice-3 §4.3 — OpenAI Realtime
+/// server-side VAD is DISABLED; the FOB's reflex is authoritative).
+///
+/// Carried over a tokio mpsc from the TapEngine (tokio task) to the
+/// `Reflex` wrapper (media thread). Drained sync via `try_recv` on the
+/// 20 ms tick — the kill decision lives in the loop, not in a handler.
+#[derive(Debug)]
+pub enum AdvisoryEvent {
+ /// The brain detected caller speech. Trigger barge-in: kill playout.
+ SpeechStarted { at: Instant },
+ /// The brain detected caller speech ended. Observed + counted; does
+ /// NOT toggle mute (the resume condition is "first fresh audio_out
+ /// after the barge", not "speech_stopped" — see §3.2 state table).
+ SpeechStopped { at: Instant },
+}
+```
+
+### 3.2 `Reflex` state machine
+
+| Current state | Event | Action | New state |
+|---|---|---|---|
+| Playing | `SpeechStarted` | `muted=true`; `epoch++`; `inner.barge_in_flush()` (clear ring + drain `rx_audio_out` so stale brain frames queued pre-barge are dropped); `metrics.barge_in_count++` | Muted |
+| Muted | `SpeechStarted` (duplicate/re-barge) | `epoch++`; `barge_in_flush()` again (fresh barge resets the "fresh audio" clock); `barge_in_count++` | Muted |
+| Muted | `SpeechStopped` | increment `advisory_observed_speech_stopped` counter; **no state change** | Muted |
+| Playing | `SpeechStopped` | increment counter; **no state change** | Playing |
+| Muted | inner `next_pcm_frame()` returns `Some(f)` (fresh brain audio arrived post-barge) | `muted=false`; return `Some(f)` | Playing |
+| Muted | inner `next_pcm_frame()` returns `None` | return `None` (silence); `metrics.frames_suppressed++` | Muted |
+
+**Why `SpeechStopped` is a no-op for mute:** per the resume-semantics decision (resume on
+first fresh `audio_out`). The brain's `speech_stopped` is *observed* (counter) but doesn't
+gate — this avoids the inter-word-gap problem (caller pauses, VAD fires stopped, brain
+un-mutes too early, brain's audio overlaps caller's next word). The resume condition is
+"the brain has yielded and started a new response," which is provably signaled by the first
+`audio_out` frame after the barge — not by the caller's silence.
+
+**Why `epoch`:** with two concurrent trigger sources this slice — the local VAD (primary)
+and the brain's advisory (secondary/confirmation, §6.1) — the epoch disambiguates "is this
+barge a re-barge of the same event, or a new one" when both race into the same advisory
+channel. The local VAD trips first; the brain's slower ASR-grade advisory lands ~300 ms
+later on the *same* event and must not be counted as a fresh barge. The epoch is that
+disambiguator — load-bearing now, not a forward-compat seam.
+
+### 3.3 `AudioPipe` trait extension
+
+```rust
+// crates/rutster-media/src/pcm.rs — additive method on `AudioPipe`
+
+/// Barge-in flush: clear the playout ring AND drain the inbound brain
+/// audio queue of any frames queued before the barge. Called by `Reflex`
+/// on `SpeechStarted`. The drain of `rx_audio_out` is what makes the
+/// resume condition race-free: the first `audio_out` observed post-barge
+/// is provably post-barge (frames queued pre-barge are dropped here).
+///
+/// Default impl delegates to `clear_playout_ring` — sufficient for
+/// pipes without an inbound queue to drain (like `EchoAudioPipe`).
+fn barge_in_flush(&mut self) {
+ self.clear_playout_ring();
+}
+```
+
+`TapAudioPipe` overrides:
+```rust
+// crates/rutster-tap/src/tap_audio_pipe.rs
+
+fn barge_in_flush(&mut self) {
+ // Clear the playout ring (drops buffered brain-proposed frames).
+ self.playout_ring.clear();
+ // Drain rx_audio_out of any frames the engine task queued before
+ // the barge. Without this, a stale frame in the mpsc would un-mute
+ // immediately on the next tick — defeating the "first fresh audio_out"
+ // resume condition. Hot-path: try_recv loop, bounded, no blocking.
+ while self.rx_audio_out.try_recv().is_ok() {
+ self.metrics.barge_drained_inflight.fetch_add(1, Ordering::Relaxed);
+ }
+}
+```
+
+### 3.4 `LocalVadReflex
` — the primary trigger (FOB-local, zero brain round-trip)
+
+The decorator that proves wedge #1. Wraps any `AudioPipe`; inspects the caller's decoded PCM
+in `on_pcm_frame`, computes RMS energy, fires `AdvisoryEvent::SpeechStarted` into the inner
+`Reflex`'s advisory channel when local VAD trips. Composes as
+`LocalVadReflex>` — the outer wrapper does local-VAD; the inner wrapper
+applies the mute state machine to the advisory stream (which now has TWO sources: the local
+VAD + the brain's advisory, both feeding the same mpsc).
+
+```rust
+// crates/rutster-media/src/reflex.rs
+
+/// RMS energy threshold for caller-speech detection. The MVP ships with
+/// a single tuned-for-synthetic-loud-signal const; the tuning framework
+/// (per-environment calibration, adaptive noise floor) is deferred per
+/// slice-4 §1.2. The synthetic-peer e2e test sends a frame with samples
+/// well above this threshold so the trip is deterministic.
+///
+/// i16 max is 32767; a |sample| average of ~500 (~1.5% of full scale) is
+/// a quiet-but-unmistakable signal; room-tone background sits well below.
+/// The debounce (N consecutive frames above threshold) filters transient
+/// spikes (clicks, nose-breaths) without slowing the trip materially.
+pub const VAD_RMS_THRESHOLD: f64 = 500.0;
+
+/// Number of consecutive above-threshold frames required before the VAD
+/// trips. At 20 ms/frame, N=3 = 60 ms of above-threshold audio — well
+/// below the brain's ~300 ms ASR-VAD latency, comfortably instant for
+/// the wedge-#1 demonstration. Tunable in a later slice; const for MVP.
+pub const VAD_DEBOUNCE_FRAMES: u32 = 3;
+
+pub struct LocalVadReflex {
+ inner: P,
+ /// The advisory_tx the LocalVadReflex pushes into when local VAD
+ /// trips. This is the SAME channel the brain's advisories arrive
+ /// on (the Reflex holds the paired rx) — so the Reflex's mute state
+ /// machine sees both sources uniformly via `drain_advisories`.
+ advisory_tx: mpsc::Sender,
+ /// Consecutive above-threshold frame count (the debounce counter).
+ above_threshold_streak: u32,
+ /// True once the VAD has tripped for the current caller-speech
+ /// burst. Reset to false when the streak breaks (caller stopped)
+ /// — so a new burst trips a fresh `SpeechStarted`.
+ vad_armed: bool,
+}
+
+impl LocalVadReflex {
+ pub fn new(inner: P, advisory_tx: mpsc::Sender) -> Self {
+ Self {
+ inner,
+ advisory_tx,
+ above_threshold_streak: 0,
+ vad_armed: true, // armed on construction
+ }
+ }
+
+ /// Compute RMS energy of a PCM frame. The frame is 480 i16 samples
+ /// @ 24 kHz; RMS = sqrt(mean(sample²)). Cheap: ~480 multiplications,
+ /// one division, one sqrt — well under the 20 ms tick budget.
+ fn rms(frame: &PcmFrame) -> f64 {
+ let sum_sq: u64 = frame.samples.iter()
+ .map(|&s| (s as i64 * s as i64) as u64)
+ .sum();
+ (sum_sq as f64 / frame.samples.len() as f64).sqrt()
+ }
+
+ /// Inspect a caller PCM frame + apply the debounce state machine.
+ /// Called from `on_pcm_frame` (the sink path). Returns true if the
+ /// VAD tripped THIS call (so the caller can push the advisory).
+ fn observe(&mut self, frame: &PcmFrame) -> bool {
+ let energy = Self::rms(frame);
+ if energy >= VAD_RMS_THRESHOLD {
+ self.above_threshold_streak += 1;
+ if self.above_threshold_streak >= VAD_DEBOUNCE_FRAMES && self.vad_armed {
+ self.vad_armed = false; // disarm until caller stops
+ return true; // trip!
+ }
+ } else {
+ // Caller went quiet → re-arm for the next speech burst.
+ self.above_threshold_streak = 0;
+ self.vad_armed = true;
+ }
+ false
+ }
+}
+
+impl AudioSource for LocalVadReflex {
+ fn next_pcm_frame(&mut self) -> Option {
+ // Pure delegation — the VAD only observes the SINK path
+ // (inbound caller audio); playout is the inner Reflex's concern.
+ self.inner.next_pcm_frame()
+ }
+}
+
+impl AudioSink for LocalVadReflex {
+ fn on_pcm_frame(&mut self, frame: PcmFrame) {
+ // THE PRIMARY TRIGGER: inspect the caller's audio BEFORE delegating.
+ // If the local VAD trips, push an advisory into the same channel the
+ // brain's advisories arrive on — the inner Reflex drains it on the
+ // next next_pcm_frame call + applies the kill. Zero brain round-trip.
+ if self.observe(&frame) {
+ let _ = self.advisory_tx.try_send(AdvisoryEvent::SpeechStarted {
+ at: Instant::now(),
+ });
+ // try_send failure (channel full) → drop + observe (hot-path
+ // policy). The brain's advisory path is the backstop; a missed
+ // local-VAD trip here is not catastrophic — the brain will fire
+ // its ASR-VAD ~300 ms later.
+ }
+ // Delegate to inner — the caller's audio still reaches the brain.
+ self.inner.on_pcm_frame(frame)
+ }
+}
+
+impl AudioPipe for LocalVadReflex {
+ fn clear_playout_ring(&mut self) { self.inner.clear_playout_ring() }
+ fn barge_in_flush(&mut self) { self.inner.barge_in_flush() }
+}
+```
+
+**Why the VAD trips `on_pcm_frame` (sink), not `next_pcm_frame` (source):** the caller's
+audio is what the VAD inspects — and caller audio arrives via the sink path (decoded from
+the peer's RTP by `loop_driver::drive` → `session.pipe.on_pcm_frame(pcm)`). The source path
+is the brain's `audio_out` — the playout being killed, not the signal being detected.
+
+**Why the SAME advisory channel as the brain:** the inner `Reflex` drains all advisories
+uniformly — it doesn't care whether the source is local VAD or the brain's ASR. The brain's
+advisory arriving ~300 ms later either finds the kill already applied (no-op, the `muted`
+flag is already true) or confirms it. This is the composition pattern §6.4 anticipated; the
+`Reflex
` wrapper shape was designed for exactly this layering.
+
+### 3.5 `Reflex
` struct + impl
+
+```rust
+// crates/rutster-media/src/reflex.rs
+
+pub struct Reflex {
+ inner: P,
+ advisory_rx: mpsc::Receiver,
+ muted: bool,
+ barge_epoch: u64,
+ metrics: Arc,
+}
+
+impl Reflex {
+ pub fn new(inner: P, advisory_rx: mpsc::Receiver, metrics: Arc) -> Self {
+ Self { inner, advisory_rx, muted: false, barge_epoch: 0, metrics }
+ }
+
+ /// Drain all pending advisories + apply the state table. Called at
+ /// the top of `next_pcm_frame`. Hot-path: try_recv loop, bounded.
+ fn drain_advisories(&mut self) {
+ while let Ok(ev) = self.advisory_rx.try_recv() {
+ match ev {
+ AdvisoryEvent::SpeechStarted { at } => {
+ self.muted = true;
+ self.barge_epoch = self.barge_epoch.wrapping_add(1);
+ self.inner.barge_in_flush();
+ self.metrics.barge_in_count.fetch_add(1, Ordering::Relaxed);
+ tracing::info!(epoch = self.barge_epoch, ?at, "barge-in");
+ }
+ AdvisoryEvent::SpeechStopped { at: _ } => {
+ self.metrics.advisory_observed_speech_stopped.fetch_add(1, Ordering::Relaxed);
+ // No state change — see §3.2.
+ }
+ }
+ }
+ }
+}
+
+impl AudioPipe for Reflex {
+ fn next_pcm_frame(&mut self) -> Option {
+ self.drain_advisories();
+ if self.muted {
+ // Muted: pull from inner. Some(f) = fresh brain audio arrived
+ // post-barge → un-mute + return. None = silence, stay muted.
+ match self.inner.next_pcm_frame() {
+ Some(f) => {
+ self.muted = false;
+ Some(f)
+ }
+ None => {
+ self.metrics.frames_suppressed.fetch_add(1, Ordering::Relaxed);
+ None
+ }
+ }
+ } else {
+ self.inner.next_pcm_frame()
+ }
+ }
+
+ fn on_pcm_frame(&mut self, frame: PcmFrame) {
+ // Inbound caller audio is NEVER gated by the reflex. The brain
+ // still hears the caller during barge — that's the point (the
+ // brain needs to know the caller interrupted; the FOB only kills
+ // its OWN playout, not the caller's path to the brain).
+ self.inner.on_pcm_frame(frame)
+ }
+
+ fn clear_playout_ring(&mut self) {
+ // The reconnect-flush path (slice-2 §5.3) still works through the
+ // wrapper. If it fires during mute, the ring stays empty and mute
+ // clears on the next post-reconnect audio_out.
+ self.inner.clear_playout_ring()
+ }
+
+ fn barge_in_flush(&mut self) {
+ // Allow the outer `LocalVadReflex` (primary trigger) to barge the inner.
+ self.inner.barge_in_flush()
+ }
+}
+```
+
+### 3.6 `ReflexMetrics`
+
+Mirror of `TapMetrics` shape (atomics + snapshot struct):
+
+```rust
+// crates/rutster-media/src/reflex.rs
+
+#[derive(Default)]
+pub struct ReflexMetrics {
+ pub barge_in_count: AtomicU64,
+ pub advisory_dropped: AtomicU64, // advisory channel full (e.g. 16-cap)
+ pub frames_suppressed: AtomicU64, // None returns while muted
+ pub advisory_observed_speech_stopped: AtomicU64,
+}
+
+pub struct ReflexMetricsSnapshot {
+ pub barge_in_count: u64,
+ pub advisory_dropped: u64,
+ pub frames_suppressed: u64,
+ pub advisory_observed_speech_stopped: u64,
+}
+
+// `barge_drained_inflight` lives on `TapMetrics` (in `rutster-tap`), not
+// `ReflexMetrics`, because the drain happens inside `TapAudioPipe::barge_in_flush`,
+// not inside `Reflex`. The path: `Reflex::drain_advisories` calls
+// `inner.barge_in_flush()` which is `TapAudioPipe::barge_in_flush`, which is
+// where the `rx_audio_out` drain + the counter increment happen.
+```
+
+---
+
+## 4. The dedicated media thread
+
+### 4.1 `MediaThread`
+
+```rust
+// crates/rutster/src/media_thread.rs
+
+pub struct MediaThread {
+ cmd_tx: mpsc::Sender,
+ join: Option>,
+}
+
+enum MediaCmd {
+ AcceptOffer { id: ChannelId, sdp: String, reply: oneshot::Sender> },
+ Delete { id: ChannelId, reply: oneshot::Sender<()> },
+ Shutdown { reply: oneshot::Sender<()> },
+}
+```
+
+Spawned at binary startup (`main.rs`), before `axum::serve`. The thread captures a
+`tokio::runtime::Handle` (to spawn TapEngine tasks when `Connected` transitions fire) and
+owns `HashMap` + (per-session, lazily) the TapConn / advisory_rx /
+Reflex wrapper.
+
+### 4.2 Thread loop (per 10 ms meta-tick)
+
+1. `cmd_rx.try_recv()` loop — handle ALL pending commands before ticking. `AcceptOffer`
+ calls `RtcSession::accept_offer(sdp)` and replies via the oneshot. `Delete` fires
+ `close_tx` + bounded-await the engine task (750 ms cap via
+ `tokio::runtime::Handle::block_on(timeout(...))`) — the std thread briefly enters the
+ tokio runtime to await; cold-path, not the 20 ms tick. `Shutdown` drains + replies.
+2. For each `RtcSession` in the map:
+ - Drain per-session `flush_rx` side-channel (slice-2's existing disconnect-flush) BEFORE
+ `run_poll_once`.
+ - Call `RtcSession::run_poll_once(now)` — the unchanged `loop_driver::drive`.
+ - Observe `channel.state`:
+ - `Connected && tap.is_none()` → `handle.spawn(spawn_tap_engine(...))` to bring up the
+ tokio task; construct `Reflex::new(TapAudioPipe::new(...), advisory_rx, metrics)`;
+ call `RtcSession::set_pipe(reflex)`. Mirror of slice-2's spawn seam.
+ - `Closed` → remove the entry (drops the `RtcSession` + its pipe + advisory ends).
+3. `std::thread::sleep(Duration::from_millis(10))` — 10 ms meta-tick.
+
+### 4.3 `session_map.rs` rewire
+
+`SessionEntry` loses `rtc: Arc>`, gains `cmd_tx: mpsc::Sender`
+(cloned per-entry; cheap). `tap_url` stays (the thread reads it when spawning the engine).
+`tap_conn: Option` moves onto the media thread (the thread owns it after spawn).
+
+- `AppState::create_session` → sends a `Register { tap_url, reply }` command to the media
+ thread; the **thread** constructs `RtcSession::new()` (saves a cross-thread move of the
+ struct + keeps all `RtcSession` construction on the thread that owns it). The thread
+ replies with `(id, cmd_tx_for_this_session)`; axum stores `SessionEntry { cmd_tx, tap_url,
+ tap_conn: None }`.
+- `AppState::get(id)` (SDP path) → `cmd_tx.send(AcceptOffer { ... }).await` + `reply.await`.
+ Cold-path; the axum handler is async.
+- `AppState::close(id)` → `cmd_tx.send(Delete { id, reply }).await` + `reply.await`. The
+ reply returns after the TapEngine teardown completes on the thread.
+- `spawn_poll_task` → `spawn_media_thread`: constructs the channels, spawns the std thread,
+ stores `cmd_tx` + `join` in `AppState`. Same idempotent-guard pattern.
+
+### 4.4 TapEngine extension
+
+`spawn_tap_engine` returns a third channel end: `advisory_tx: mpsc::Sender`.
+The pump loop, on receiving `speech_started` / `speech_stopped` from the brain (slice-3
+already decodes these in the tap protocol layer — `protocol_events.rs`), pushes the
+corresponding `AdvisoryEvent` into `advisory_tx`. If the channel is full, drop + count
+(hot-path "drop + observe" policy; an advisory is a hint, not a command). The `Reflex`
+wrapper holds `advisory_rx`.
+
+### 4.5 `MockRealtimeBrain` extension
+
+`rutster-brain-realtime/src/mock.rs` gains a programmable advisory schedule: the test can
+register "after N `audio_in` frames received, send `speech_started`" and "after M more,
+send `speech_stopped`". The mock already asserts `turn_detection: null` on
+`session.update` (slice-3's S4 lock); slice-4 keeps that assertion.
+
+---
+
+## 5. Data flow
+
+### 5.1 Barge-in (the kill) — primary path: local VAD, zero brain round-trip
+
+```
+1. caller speaks into mic → peer RTP → str0m decode → on_pcm_frame
+ → LocalVadReflex::on_pcm_frame INSPECTS the frame FIRST:
+ • rms(frame) computed (~480 muls, < 1 µs)
+ • if rms ≥ VAD_RMS_THRESHOLD: streak++; if streak ≥ VAD_DEBOUNCE_FRAMES (3 = 60 ms) → TRIP
+ • on trip: try_send(AdvisoryEvent::SpeechStarted) into advisory_tx (same channel as
+ brain's advisories) → disarm until caller goes quiet
+ → THEN delegates to inner.on_pcm_frame(frame) → tx_pcm_in → TapClient → audio_in (WS) → brain
+2. [THREE HOPS HAPPEN HERE, ON THE BRAIN SIDE — NOT on the kill path]:
+ - brain's ASR-VAD fires (~300 ms later, slower but more accurate)
+ - brain sends speech_started back over WS
+ - TapEngine → advisory_tx (the SAME mpsc; both sources feed one channel)
+3. media thread next 20 ms tick → Reflex::next_pcm_frame → drain_advisories:
+ • The LOCAL VAD's SpeechStarted arrives FIRST (it was pushed in step 1, same thread,
+ no WS hop) → muted=true; epoch++; inner.barge_in_flush() (ring cleared + rx_audio_out drained)
+ • The brain's SpeechStarted arrives ~300 ms LATER → Reflex sees muted=true already
+ → re-barge (epoch++, barge_in_flush again — harmless, mute stays)
+ → returns None (silence) for this + subsequent ticks while muted
+4. loop_driver::drive pulls None from pipe → encodes Opus silence → peer hears silence
+ (the brain's in-flight audio_out frames are dropped; no overlap with caller's speech)
+```
+
+**Latency budget:** caller speaks → kill fires in ≤ 60 ms (3 debounce frames × 20 ms tick) +
+one tick to drain the advisory + apply the kill = ≤ 80 ms wallclock. Zero brain round-trip
+on the primary path. (The brain's ASR-VAD advisory arrives ~300 ms later — it confirms the
+kill but doesn't gate it.) This is what ARCHITECTURE.md:80 demands ("the brain round-trip is
+too slow to enforce them") and the proof wedge #1 rests on.
+
+### 5.2 Resume (the un-mute)
+
+```
+1. brain decides to yield/respond → sends a fresh audio_out frame
+ (provably post-barge: barge_in_flush drained rx_audio_out)
+2. TapClient → audio_out (WS) → TapEngine → tx_audio_out → rx_audio_out → playout ring
+3. media thread 20 ms tick → Reflex::next_pcm_frame → drain_advisories (empty)
+ → muted=true → inner.next_pcm_frame() returns Some(f) (fresh brain audio)
+ → muted=false; return Some(f)
+4. loop_driver encodes + writes → peer hears the brain's new response
+```
+
+### 5.3 Cold-path (axum ↔ media thread)
+
+```
+- POST /v1/sessions → AppState::create_session → MediaCmd::Register → thread constructs RtcSession → reply(id)
+- POST /v1/sessions/{id}/offer → AppState::get + cmd_tx.send(AcceptOffer) → thread.lock(session).accept_offer(sdp) → reply(answer)
+- DELETE /v1/sessions/{id} → AppState::close → cmd_tx.send(Delete) → thread: fire close_tx, bounded-await engine task teardown → reply
+- graceful shutdown → cmd_tx.send(Shutdown) → thread drains + drops → reply → join
+```
+
+---
+
+## 6. Why these decisions
+
+### 6.1 Why both, local VAD primary (revised after adversarial review)
+
+The initial brainstorming landed on advisory-only for MVP (cheapest path to a working
+barge-in; the brain's VAD already runs for STT). The 2026-07-01 adversarial review surfaced
+the load-bearing problem with that choice: README:98-100 + ARCHITECTURE.md:79-81 rest the
+wedge on "local reflexes that don't need the brain — VAD killing TTS the instant the caller
+speaks." Advisory-only puts the brain round-trip in the trigger path (brain VAD → WS →
+TapEngine → mpsc → Reflex); the kill DECISION is in-core but the TRIGGER SOURCE crossed
+the brain. The spearhead's step 4 would prove a reflex that depends on the brain — the
+opposite of the property steps 1-4 exist to prove.
+
+The revision: **local VAD in `on_pcm_frame` is the primary trigger** (RMS/energy detector on
+the dedicated thread, in the 20 ms loop, zero brain round-trip — the actual wedge-#1 proof).
+Slice-3's advisory becomes the **secondary/confirmation** signal — the brain's ASR-quality
+VAD confirms the local kill ~300 ms later (slower but more accurate — it knows words, not
+just energy). The two sources feed the SAME advisory mpsc; the `Reflex` wrapper drains them
+uniformly. The composition is `LocalVadReflex>` — the decorator pattern
+from §6.4, which was originally specced as deferred to "when local VAD arrives." It arrived.
+
+- The VAD itself (~25 lines: RMS, threshold, debounce) is in scope. The **tuning framework**
+ (configurable thresholds, per-environment calibration, adaptive noise floor) is deferred —
+ the MVP ships a single `const` threshold + N-frame debounce, exercised by the e2e test
+ with a synthetic loud signal.
+- Slice-3's advisory plumbing + `MockRealtimeBrain` schedule aren't wasted — they become
+ the secondary path, still exercised by the e2e test (the brain's advisory fires SLIGHTLY
+ after the local VAD; both feeds land in the Reflex's drain).
+- The `Reflex` wrapper shape already supported composition — no structural rework, just
+ a new `LocalVadReflex
` decorator landing in scope (Task 2b in the plan).
+
+### 6.2 Why resume on first fresh `audio_out` (not `speech_stopped`)
+
+- The "the brain has yielded and started a new response" condition is provably signaled by
+ the first `audio_out` frame after the barge — not by the caller's silence. `speech_stopped`
+ fires between words; resuming on it un-mutes too early (inter-word-gap overlap).
+- The `barge_in_flush` drain of `rx_audio_out` makes the resume race-free: the first
+ `audio_out` observed post-barge is provably post-barge (frames queued pre-barge are dropped
+ in the flush).
+
+### 6.3 Why a single dedicated thread (not per-session)
+
+- Spearhead scale: one loopback peer in dev; even at low PSTN concurrency (slice-5), one
+ thread drives dozens of sessions in 10 ms.
+- The command-channel seam between axum and the thread makes the graduation to a threadpool
+ shard localized — when per-CPU-shard threading arrives, it's a fan-out of the
+ `cmd_rx`/`HashMap` shape, not a redesign.
+- Per-session threads arrive when load demands; the spearhead's "shortest blocking path"
+ rule dislikes spawning work per session that may not need it (pre-ICE-connected sessions
+ would redundantly spin).
+
+### 6.4 Why `Reflex
` as a wrapper (not inline in `TapAudioPipe`)
+
+- Composition: `LocalVadReflex
` composes outside the advisory `Reflex
` (§6.1), the
+ same way `Reflex` composes. The pattern (decorator over `AudioPipe`) stacks
+ the two trigger sources without restructuring either.
+- The seam: `loop_driver.rs` byte-identical (still calls `pipe.next_pcm_frame()`). If the
+ reflex lived inline in `TapAudioPipe`, the binary-side wiring would still change but the
+ `TapAudioPipe` module itself would grow the reflex state — less isolated.
+- The payoff is realized this slice, not deferred: two stacked reflexes (local-VAD primary +
+ advisory secondary) live as independent, separately-testable decorators rather than one
+ module's commingled state. Keeping them as wrappers is what makes that separation free.
+
+### 6.5 Why `barge_in_flush` on `AudioPipe` (not just `clear_playout_ring`)
+
+- `clear_playout_ring` (slice-2) clears the *ring*. `barge_in_flush` clears the ring AND
+ drains the *inbound brain queue* (`rx_audio_out`). The distinction matters: on a brain
+ disconnect (slice-2's case), the brain is gone — `rx_audio_out` will drain itself on the
+ next `Disconnected` `try_recv`. On a barge-in, the brain is alive and may have queued
+ frames pre-barge that would un-mute immediately if not drained here. Two different
+ "clear the playout path" semantics, two methods.
+
+---
+
+## 7. Done-criteria
+
+1. `cargo test --all` passes (stable + 1.85, the CI matrix).
+2. `cargo fmt --check` + `cargo clippy -- -D warnings` clean.
+3. `loop_driver.rs` + `rtc_session.rs` **byte-identical** to slice-3 — CI-asserted via
+ `git diff --exit-code main -- crates/rutster-media/src/loop_driver.rs
+ crates/rutster-media/src/rtc_session.rs` (the §8.5 #6 seam gate, restated for slice-4).
+4. Dedicated media thread drives sessions off the tokio pool; `MediaThread` integration test
+ passes (AcceptOffer / Delete / Shutdown).
+5. `Reflex` state-machine unit tests all pass:
+ - `SpeechStarted` → next `next_pcm_frame` returns None even if ring has frames.
+ - `SpeechStarted` then `inner.next_pcm_frame()=Some` → un-mutes, returns the frame.
+ - `SpeechStopped` during Muted → stays Muted.
+ - `SpeechStopped` during Playing → no-op.
+ - Duplicate `SpeechStarted` re-flushes + stays Muted.
+ - Metrics counters (`barge_in_count`, `frames_suppressed`) increment correctly.
+ - `advisory_rx` full → `advisory_dropped` increments, no panic.
+6. `LocalVadReflex` unit tests pass:
+ - RMS computation verified against a known-loud + known-quiet frame.
+ - Debounce: N-1 above-threshold frames do NOT trip; the Nth does.
+ - Re-arm: above-threshold → trip; below-threshold → re-arm; next streak trips again.
+ - `on_pcm_frame` ALWAYS delegates to inner (caller audio reaches the brain even during barge).
+7. `barge_in_flush` unit tests pass (ring + `rx_audio_out` drain).
+8. Barge-in e2e (PRIMARY PATH, proves wedge #1): synthetic loud caller audio → playout killed
+ within ≤4 ticks (≤80 ms wallclock: 3 debounce + 1 drain+apply) **WITHOUT any brain advisory**.
+ The brain's `speech_started` advisory arrives later + is a no-op (mute already applied).
+9. Barge-in e2e (SECONDARY PATH, exercises slice-3 advisory plumbing): `MockRealtimeBrain`
+ emits `speech_started` on schedule; local VAD is NOT tripped (quiet synthetic caller audio);
+ advisory → kill → fresh `audio_out` → resume. Proves the advisory→reflex→kill path still works.
+10. S4 turn-ownership lock preserved: `MockRealtimeBrain` still asserts
+ `turn_detection: null` on `session.update` (slice-3's #7, unchanged).
+11. `MockRealtimeBrain` extended to emit `speech_started`/`speech_stopped` on schedule.
+12. `cargo doc --no-deps` renders the new `reflex.rs` + `media_thread.rs` module/item docs
+ cleanly (learner-facing comments present per AGENTS.md code style).
+
+---
+
+## 8. Open decisions
+
+- ~~Trigger source~~ — decided: **both, local VAD primary + advisory secondary** (revised after
+ 2026-07-01 adversarial review; initial brainstorming landed on advisory-only, the review
+ surfaced that advisory-only contradicts wedge #1's "VAD killing TTS the instant the caller
+ speaks, without the brain"). Local VAD in `on_pcm_frame` is the primary trigger; the brain's
+ `speech_started`/`speech_stopped` advisory is the secondary/confirmation.
+- ~~Resume semantics~~ — decided: first fresh `audio_out` post-barge; `SpeechStopped`
+ observational only.
+- ~~Thread model~~ — decided: single dedicated `std::thread`; per-session/threadpool deferred.
+- **`MockRealtimeBrain` advisory schedule API shape** — landed in §4.5 as a programmable
+ "after N audio_in frames" schedule. Could alternatively be a free-form
+ `Vec<(trigger_frame_count, AdvisoryEvent)>` queue. The plan will pin the concrete API.
+- **Thread shutdown ordering vs TapEngine teardown** — `Delete` command handler fires
+ `close_tx` + bounded-await the engine task (750 ms cap via
+ `tokio::runtime::Handle::block_on(timeout(...))`); the reply oneshot returns after
+ teardown. Cold-path, std thread briefly enters the tokio runtime to await. Documented as
+ an acceptable deviation (not the 20 ms tick).
+
+---
+
+## 9. Cross-references
+
+- [slice-1 spec](2026-06-28-slice-1-webrtc-loopback-design.md) — the media loop + the seam
+ (`AudioSource`/`AudioSink` traits in `rutster-media`); slice-1 §8.5 #6 is the seam gate
+ this slice re-affirms.
+- [slice-2 spec](2026-06-28-slice-2-agent-tap-design.md) — the tap interface, the
+ `TapAudioPipe`, the core-authoritative playout buffer (§4.1), the `flush_tx` side-channel
+ pattern that the `advisory_rx` mirrors.
+- slice-3 (merged `c30a452`) — `MockRealtimeBrain`, the translator, the
+ `speech_started`/`speech_stopped` protocol events, the S4 turn-ownership lock.
+- [ADR-0002](../../adr/0002-north-star-and-fused-core.md) — fused vertical; the hot-path
+ hop invariant this slice re-affirms (§2.3 audit).
+- [ADR-0008](../../adr/0008-fob-and-green-zone.md) — FOB/green-zone doctrine; the reflex is
+ a FOB member (hot-path, security-constitutive for turn-taking, differentiating).
+- [ARCHITECTURE.md](../../ARCHITECTURE.md) — §"Media plane" ("Dedicated timing threads
+ for the 20ms loop, never the shared tokio pool" — this slice lands it); §"Biggest
+ technical risk" (the reflex loop *is* the remaining long pole).
+- [PORT_PLAN.md](../../PORT_PLAN.md) — §Phasing, step 4 = barge-in.