docs: advance audio exclusion plan into phase 6

This commit is contained in:
2026-08-21 15:40:12 -04:00
parent 9ba42c4cda
commit 9e52acf9d3
3 changed files with 242 additions and 56 deletions
+93 -34
View File
@@ -1,14 +1,13 @@
# Design v3: whole-desktop screen-share audio without self-echo
# Design v3.8: whole-desktop screen-share audio without self-echo
**Status:** 🟢 **v3.7 — round 10: the §5.1 matrix PASSED in full and the architecture is
unchanged for the third consecutive measured round.** Round 8 revised the **observation
boundary** (§6.7), round 9 revised what stickiness may remember (§6.8), and round 10 deletes
the pipewire-pulse PID **derivation heuristic** (§6.1.2) after measuring that WirePlumber
repeats a `sec_pid` too — which had switched key 4's suppression off permanently. All three
were found by running code, not by reading it, and all three were at the *observation*
boundary rather than in the design.
**Date:** 2026-07-26 (v1: 07-19 · v2: 07-20 · Option C 07-20 · v3.1 r4 · v3.2 r5 · v3.3 r6 ·
v3.4 r7 · v3.5 r8 · v3.6 r9 · v3.7 r10)
**Status:** 🟢 **v3.8 — round 11: both pre-Phase-6 design gates are closed.** The graph now
models an unobservable playback-to-capture route as a conservative same-`device.id` hardware
edge (§6.9), and the 2 s readiness budget is retained after repeated baseline, inflated-graph,
and live-churn calibration. The targeted live dry-run partition passed. The first pure Phase 6
channel-planning prerequisite is committed locally in PixelPass `781defc`; the live fan-out
remains the first graph mutation.
**Date:** 2026-08-21 (v1: 07-19 · v2: 07-20 · Option C 07-20 · v3.1 r4 · v3.2 r5 · v3.3 r6 ·
v3.4 r7 · v3.5 r8 · v3.6 r9 · v3.7 r10 · v3.8 r11 2026-08-21)
**Origin:** Joe's suggestion — "whitelist all audio except audio coming from peerspeak."
**Scope:** a new capture mode in pixelpass (`src/host/pipeline.rs`, `src/host/audio.rs`),
playback tagging + AEC-identity export + teardown-ordering invariants in peerspeak.
@@ -29,6 +28,7 @@ v1/v2 remain in git history at `88ad5a0` and `10203e1`.
| fan-out spike | `~/Documents/handoff-docs/Claude/peerspeak/fanout-spike-results-2026-07-20.md` + Codex rounds 3/4 | **Option C adopted**, ratified |
| AEC identity gate | `~/Documents/handoff-docs/Claude/peerspeak/aec-playback-leg-identity-2026-07-20.md` | **🟢 gate passed**, both models agree after 2 adversarial rounds |
| **phase 5 dry-run gate (r8)** | `docs/screenshare-audio-exclusion-phase5-results.md` | **🚦 GATE FAILED** — the observation boundary is wrong (§6.7); architecture unaffected |
| **pre-Phase-6 closure (r11)** | `docs/screenshare-audio-exclusion-phase5-results.md` addendum | **PASSED** — same-device bridge, different-device negative control, and readiness calibration (§6.9) |
---
@@ -907,21 +907,74 @@ through a teardown, and it is unaffected by this change.
Cost: two fixpoints per graph event. Measured 80 µs worst case against a 47 Hz event rate,
so the O5 headroom absorbs it without argument.
⚠️ **Owed, from the round-9 review (Codex, P1 "worth checking"): hardware
playback-to-capture paths.** A card offering "Stereo Mix" / "Digital Loopback" presents an
⚠️ **Round-9 open item — CLOSED in round 11 (§6.9): hardware playback-to-capture
paths.** A card offering "Stereo Mix" / "Digital Loopback" presents an
ordinary driver name (`snd_hda_intel`), so both its sink and its source classify
`session_device` — and audio written to the sink reappears on the source through a hop the
Link graph cannot see. This is the `snd_aloop` hazard (§6.1.1, phase-3 review finding 2) in
a form the driver denylist cannot detect. It is **not new in round 9** and not introduced by
either recent round; distinguishing it needs ALSA control inspection, a new I/O surface and
therefore a design decision. Until then a card with that path enabled can carry the call
from sink to source untainted, and a capture app reading it can re-emit: **echo**.
either recent round. Round 11 closes it without relying on a driver denylist or control name.
⚠️ **Also owed: a calibration argument for the readiness budget.** The observer times out
⚠️ **Round-9 open item — CLOSED in round 11 (§6.9): calibration of the readiness
budget.** The observer times out
after 2 s and `TimedOut` is sticky by design, so a process that never sees one
obligation-free instant during initial enumeration is silent for its lifetime. Measured on
this host: readiness at ~3 ms with 19 binds. The margin is three orders of magnitude, which
is an argument, but it is one measurement on one idle desktop.
obligation-free instant during initial enumeration is silent for its lifetime. The original
~3 ms observation on one idle desktop was not enough; round 11 adds repeated starts, an
inflated graph, and concurrent graph churn.
### 6.9 🟢 Same-device hardware bridge + readiness calibration (round 11, MEASURED)
**The hardware rule.** For every positively classified passive hardware terminal, retain the
Node's snapshot-local `device.id`. The taint walk adds a directed synthetic edge from an
`Audio/Sink` (or output side of `Audio/Duplex`) to every `Audio/Source` (or input side of
`Audio/Duplex`) carrying the **same** `device.id`:
```
tainted stream -> hardware sink ~[private mixer / firmware]~> same-device source -> reader
```
Both predicates are load-bearing. `session_device=true` limits the rule to the observer's
positive passive-hardware allowlist; `device.id` limits it to one physical Device instead of
fusing every card exported by WirePlumber. The id never enters sticky identity and never
survives its snapshot.
**Why this is unconditional rather than an ALSA-control probe.** Measured 2026-08-21: the
ALC897 exposes `Loopback Mixing` (disabled) and two `Input Source` controls containing Rear
Mic, Front Mic and Line, but no `Stereo Mix`. Linux HDA treats analog loopback monitoring and
the optional `Stereo Mix` capture source as distinct mechanisms (kernel
[`hda_generic.c`](https://code.googlesource.com/linux/torvalds/linux/+/master/sound/pci/hda/hda_generic.c)
and [HDA control documentation](https://cdn.kernel.org/doc/html/latest/sound/hd-audio/controls.html)).
More importantly, ALSA/HDA
control spelling cannot prove the absence of USB, vendor-DSP or firmware loopback paths. A
control-name allowlist would therefore be precise on this card and unsound as a portable
absence proof. The graph rule closes every such hidden same-device hop without a new runtime
ALSA dependency.
**Accepted cost.** An app capturing a microphone from the same Device that is receiving
tainted playback is excluded even when that particular microphone path is clean. A source on
a different Device remains eligible. This is deliberate fail-closed over-exclusion, pinned by
pure exact-partition tests and a live dry-run negative control.
**Live partition, 2026-08-21.** Tagged playback was routed to the ALC897 sink. A controlled
reader/re-emitter on the ALC897 source was excluded `tainted-owner-bridge`; the identical
reader/re-emitter on the Arctis source remained eligible. The audit was graph-ready, all
temporary modules were unloaded by exact module id, and the configured default sink/source
were unchanged.
**Readiness budget — keep 2 s for v1.** Fresh observer startup measurements on the same live
desktop, using the observer's own monotonic `at_ms` clock:
| arm | runs | p50 | p95 | max | timeout |
| --- | ---: | ---: | ---: | ---: | ---: |
| ambient graph | 30 | 5 ms | 10 ms | 11 ms | 0 |
| inflated graph: 24 null sinks + 24 loopbacks | 30 | 111 ms | 113 ms | 114 ms | 0 |
| 250 create/remove cycles concurrent with fresh starts | 20 | 5 ms | 6 ms | 8 ms | 0 |
The deliberately inflated maximum leaves 17.5x headroom to the sticky 2 s deadline. This is
not a universal latency promise; it is a calibration argument that exercises enumeration
volume and graph instability, rather than extrapolating from one idle start. Revisit the
budget if a supported target measures startup p95 above 500 ms or produces a real timeout;
do not weaken `TimedOut`'s fail-closed/sticky semantics to hide one.
## 7. Lifecycle and teardown invariants
@@ -1227,6 +1280,11 @@ requirement to assert what must remain **eligible**: §6.1.2's pulse-PID derivat
Three rows passed with recorded substitutions (8 EasyEffects, 9 Firefox's own mic/monitor
paths, 13 a real `Audio/Duplex` device) and the third-party samples stay owed.
**Round-11 supersession (2026-08-21):** the row above is the round-10 snapshot, not current
status. F11-1 and phases 0b/0c/0d are satisfied, and §6.9 closes the hardware-path decision
plus readiness calibration. S4/S5/0d, round 11, and Phase 6's first pure channel planner are
committed locally in PixelPass `781defc`; the first live fan-out mutation remains pending.
**Round 9 (2026-07-25).** Phase 3r shipped §6.7 and the audit was re-run
immediately; it found a *second* measured defect within minutes — a permanent sticky taint
on a hardware sink (§6.8). Both rounds share a shape worth naming: **the architecture was
@@ -1286,8 +1344,8 @@ How the blockers closed:
| **9** | **a fail-closed unresolved mark became permanent sticky taint (measured, phase 5 re-run)** | **fixed** — §6.8 evidence-only sticky pass |
| **9** | `device_props` tested for one live *Device* rather than one live *global* on the id (Codex, certain) | **fixed** in phase 3r — stale `session_device` on a contested id is an echo path |
| **9** | `device.api` corroborated by presence, so `v4l2` under an ALSA factory passed (Codex) | **fixed** in phase 3r — the API must equal the allowlist's own |
| **9** | hardware playback-to-capture ("Stereo Mix") defeats the `session_device` classifier (Codex, P1 worth checking) | **OPEN — design decision owed**, §6.8; pre-existing, needs ALSA control inspection |
| **9** | the 2 s readiness budget has no calibration argument (Codex) | **OPEN — measurement owed**, §6.8; 12 ms observed on this host with 18 binds (phase-5 run 2) |
| **9 → 11** | hardware playback-to-capture ("Stereo Mix") defeats the `session_device` classifier (Codex, P1 worth checking) | **CLOSED — §6.9.** Conservative `Sink → Source` edge for passive terminals sharing `device.id`; pure and targeted live exact partitions pass. No runtime control-name guess. |
| **9 → 11** | the 2 s readiness budget has no calibration argument (Codex) | **CLOSED — §6.9.** 30 baseline starts, 30 starts with 48 temporary modules, and 20 starts during 250 create/remove cycles; inflated max 114 ms, zero timeouts. Keep 2 s. |
| **10** | **the pulse-PID derivation required a *single* repeated `sec_pid`; WirePlumber repeats one too, so it returned `None` permanently and key 4's suppression never fired (measured, phase-5 run 2)** | **fixed** — §6.1.2 round-10 box: probe every distinct `sec_pid`, let `/proc/<pid>/comm` decide |
| **10** | the audit's `sticky` flag means "is in the remembered set", so it is true for nearly every tainted node and does not answer "excluded only because remembered" | **OPEN — reporting only**; the evidence-only pass §6.8 already computes what is needed |
| **10** | a bridge's named key is lost when a leg reappears under a new serial (sticky `reason_for` falls back to keyless, and `raise` will not replace a same-rank reason) | **OPEN — reporting only**; verdict unaffected |
@@ -1297,7 +1355,8 @@ How the blockers closed:
Option C fan-out · explicit `--aec=off|pulse-module:<idx>` · peerspeak playback and child
tagging · exact AEC module validation · graph taint with the owner-key union and the
pipewire-pulse PID exception · sticky taint · readiness epoch · fail-closed unresolved
ancestry · owned non-lingering links · §10 items 1, 4 and 5 landed first.
ancestry · conservative same-device hardware bridge · owned non-lingering links · §10 items
1, 4 and 5 landed first.
### Deliberately OUT of v1
@@ -1311,20 +1370,20 @@ binding, so `port.exclusive` is never observed and the §6.2 row it guards relie
create failing cleanly · **(r8)** no serial-continuity signal for the AEC validator's
no-coalescing contract.
### Next step (round 9)
### Current front after round 11
Phases 0a, 2, 3, 3r, 4 and 5 are built; §6.7 and §6.8 are implemented and merged. What
remains before phase 6 unblocks:
Phases 0a5 and 3r are built; the full phase-5 matrix passed; 0b is merged; and 0c/0d plus
the S4/S5 ownership work are built, validated, and committed locally. Round 11 closes the last
two pre-Phase-6 design decisions with pure, observer, live PipeWire and targeted dry-run
evidence. Phase 6's channel-aware pure planner is also committed in PixelPass `781defc`.
1. ~~**Revise phase 3** to §6.7~~**done**, phase 3r merged, four-part gate passed
including the live prop-recovery row and an added live gate for the Device-side path.
2. **Revise phase 1** to emit both carriers (§5.1), literals pinned in plan §3. Unblocked
and next.
3. **Re-run the whole phase-5 §5.1 matrix** — no row was completable under the round-8
defect, so nothing carries over — and **re-measure O5** with bind I/O *and* the round-9
second fixpoint in it. Phase 6 stays blocked until that results file passes.
4. Decide the two items §6.8 leaves open: hardware playback-to-capture paths (a real echo
path, needs a design call) and the readiness-budget calibration.
1. **Continue Phase 6:** implement the owned, non-lingering fan-out link manager and causal
status events through the real host path. This is still the first exclusion-related graph
mutation.
2. Carry the revised row-9 partition forward: music-only and a different-device microphone
stay eligible; same-device capture and a tainted-monitor capture are excluded.
3. Build the next bounded mutation slice from PixelPass checkpoint `781defc`, preserving its
ownership, fail-closed, and exact-channel planning gates.
Still owed beyond that, unchanged: the §9.2 rig upgrade before any exclusion claim is
published, and **field-test §12** — nothing in this design has been tested over the real