From bf908adbf01dc9df29b199c9a435e5b6f29d87f5 Mon Sep 17 00:00:00 2001 From: Mollusk Date: Sun, 26 Jul 2026 02:59:43 -0400 Subject: [PATCH] =?UTF-8?q?docs:=20phase=205=20matrix=20PASSED=20(13/13)?= =?UTF-8?q?=20=E2=80=94=20design=20round=2010,=20results=20run=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The §5.1 dry-run audit gate passes. All 13 rows completed with a non-empty eligible half in every one, and O5 is re-measured on the fixed graph: worst recompute 67 µs across every run, 10 µs mean under deliberate churn, busy fraction 0.0006, readiness 1-2 ms with 18 binds against a 2000 ms budget. Round 10's finding, and it is the third of exactly the same shape: the pipewire-pulse PID derivation required a SINGLE repeated pipewire.sec.pid. WirePlumber repeats one too (two Clients, both sec_pid 1747), so the derivation returned None permanently on a stock desktop, key 4's suppression never fired, and every Pulse-emulated node fused into one owner. Row 1's CLEAN control forwarder and Firefox were both excluded. Fixed in pixelpass 91c4ded by deleting the heuristic: probe every distinct sec_pid and let /proc//comm decide. Three measured rounds now, all at the observation boundary, none in the architecture -- and all three were fail-closed and silent, caught only because §5.1 requires asserting what must remain ELIGIBLE. An exclusion-only checklist would have passed every one of these builds. Rows 4-6 are closed through peerspeak's REAL tagging sites (call, mpv, notify, plus clip) with a hand-launched mpv staying eligible, so the cross-repo contract is proven end to end on live nodes. Row 10 covers the full sticky lifecycle including retirement; row 11 is provably non-vacuous (the recycled node.link-group came back byte-identical and did not inherit taint). Recorded and NOT claimed as passes: substitutions in rows 8, 9 and 13, and two reporting-only findings (the audit's sticky flag is uninformative; a bridge's named key is lost when a leg reappears under a new serial). Phase 6 remains blocked by F11-1, phases 0b/0c/0d and the Stereo Mix design call -- this file removes one gate, not all of them. --- docs/screenshare-audio-exclusion-impl-plan.md | 32 +- ...eenshare-audio-exclusion-phase5-results.md | 677 +++++++++++------- docs/screenshare-audio-exclusion-plan.md | 81 ++- 3 files changed, 519 insertions(+), 271 deletions(-) diff --git a/docs/screenshare-audio-exclusion-impl-plan.md b/docs/screenshare-audio-exclusion-impl-plan.md index a9d4789..6da13ae 100644 --- a/docs/screenshare-audio-exclusion-impl-plan.md +++ b/docs/screenshare-audio-exclusion-impl-plan.md @@ -458,17 +458,31 @@ observable in Phase 5 before they gate anything real. ## 5. Phase 5 — dry-run audit mode 🚦 MAJOR GATE -> **🚦 STATUS 2026-07-25: BUILT, RUN, AND THE GATE FAILED.** Results: -> `docs/screenshare-audio-exclusion-phase5-results.md`. The machinery is correct and needs no -> rework — **it found the defect on its first live run**, which is the phase working exactly as -> designed. What failed is the observer beneath it (v3.5 §6.7). **Phase 6 does not start.** The -> matrix re-runs after phase 3r and phase 1's second carrier land; no row was completable -> under the defect, so none of it carries over. O5's numbers do not carry over either. +> **🚦 STATUS 2026-07-26: GATE PASSED on run 2.** Results: +> `docs/screenshare-audio-exclusion-phase5-results.md`. All 13 rows completed, the eligible +> half of every row is non-empty, and O5 is re-measured on the fixed graph (worst recompute +> 67 µs; readiness 1–2 ms with 18 binds). Three rows carry recorded substitutions (8, 9, 13) +> and three findings are recorded as non-blocking. > -> Read this before re-running: `PIXELPASS_AUDIO_AUDIT_FILE=… PIXELPASS_AUDIO_AUDIT_AEC=off -> pixelpass --audit-audio`. **Every partition row must run with `AEC=off`** — a +> **Run 2 found and fixed a third defect of the F2 class, F13-1:** pipewire-pulse's PID was +> unresolvable on this host *permanently*, because stage 1 of the derivation required exactly +> one repeated `sec_pid` and **WirePlumber repeats one too** (two Clients, one PID). Key 4's +> suppression therefore never fired and every Pulse-emulated node fused into one owner. Fixed +> in pixelpass `91c4ded`: probe every distinct `sec_pid` and let `/proc//comm` decide. +> **The eligible half of row 1 is the only thing that exposed it** — the verdict was +> fail-closed and silent. +> +> ⚠️ **Phase 6 is NOT unblocked by this file alone.** F11-1 is a separate open gate (see §4's +> owner-union note and `owner_is_bounded`'s doc), as are phases 0b/0c/0d and the "Stereo Mix" +> design call. +> +> Two things to keep when re-running: **every partition row must run with `AEC=off`** (a > configured-but-unvalidated AEC shuts the fan-out gate and empties the eligible half of every -> row, which reads as a failure that is really a harness error. +> row, which reads as a failure that is really a harness error), and **start the audit BEFORE +> building the fixture**. Fixture-first makes the whole graph arrive as one enumeration burst, +> so every node is first tainted while `graph_ready` is false; that partial-graph taint enters +> sticky state and the keyless sticky reason then wins over the evidence-derived one, so a row +> cannot assert its own key. Read keys at *derivation* (first non-sticky appearance). **Adds no capability. Its entire purpose is to be wrong loudly and safely.** diff --git a/docs/screenshare-audio-exclusion-phase5-results.md b/docs/screenshare-audio-exclusion-phase5-results.md index 4d4884d..03894c8 100644 --- a/docs/screenshare-audio-exclusion-phase5-results.md +++ b/docs/screenshare-audio-exclusion-phase5-results.md @@ -1,35 +1,403 @@ # Phase 5 — dry-run audit gate: results -**Status: 🚦 GATE FAILED. Phase 6 does not start.** Two defects found, one of them -fatal to the whole mechanism. Both go to the design doc as **round 8** per impl -plan §5.3. +**Status: 🟢 GATE PASSED (run 2, 2026-07-26). All 13 §5.1 rows completed; the +eligible half of every row is non-empty. O5 re-measured on the fixed graph and +stays closed.** One new defect was found and fixed during the run (F13-1); three +findings are recorded as non-blocking, and three rows carry recorded +substitutions. Phase 6 is unblocked **by this file**, but see "What still blocks +phase 6" — F11-1 is a separate gate and is still open. -- **Run date:** 2026-07-25 +- **Run date:** 2026-07-26 (run 1: 2026-07-25, gate FAILED — see history below) - **Host:** `cazen` — PipeWire 1.6.8, WirePlumber 0.5.15, CachyOS -- **Audit build:** pixelpass branch `phase5-dry-run-audit`, release profile -- **Ambient load during the runs:** FINAL FANTASY XIV playing audio (`client.id` - 88, pid 14651), Arctis 1 Wireless as an active sink - -The audit itself worked exactly as designed: it observed the live graph, ran -phases 2–4 on every registry event, created no links, and reported a complete -eligible/excluded partition with stable reason codes. **It found the defects on -the first live run.** That is the phase doing its job — §5's argument was that a -fixture proves the code matches my model of PipeWire while only a live run proves -my model matches PipeWire, and my model was wrong. +- **Audit build:** pixelpass `main` @ `91c4ded`, release profile +- **peerspeak build:** `main` @ `b68fca6` (phase 1 merged) +- **Ambient load:** Firefox playing audio throughout (a live, uncontrived + candidate); Sunshine running (pid 3838); Arctis 1 Wireless as active sink +- **Graph size:** 14 Nodes, 4 Devices, 57 Ports, 4 Links, 24 Clients --- -## F1 🔴 FATAL — the registry `global` event delivers only a filtered subset of node properties +## What changed since run 1 -**The phase-3 adapter reads eight node properties that the PipeWire registry -never announces.** They are parsed off `obj.props` in the registry `global` -callback (`pixelpass/src/host/observer/adapter.rs`), where they are silently -absent, so every one of them is permanently `None`/`false`. +Run 1 failed on two defects, both fixed before this run: -### Measured +- **F1** (fatal): the registry `global` event delivers only a filtered subset of + node properties, so eight properties the engine depends on were permanently + absent. Fixed by design round 8 / **phase 3r** — bind every Node and Device + and read properties from `info`. +- **F2**: a machine-wide over-exclusion cascade downstream of F1. -The complete set of keys the registry announces for a `Node` global on this host -(union over every node, via `pw-cli ls Node`): +Both are gone: the baseline run (no fixture at all) reports **1 candidate, +eligible, empty taint set**. + +### 🔴 F13-1 — FOUND AND FIXED DURING THIS RUN + +**Row 1 failed on its first attempt, and the cause was a third defect of exactly +the F2 class from a new source: pipewire-pulse's PID was unresolvable on this +host, permanently.** + +`pulse_pid::candidate` returned the single `pipewire.sec.pid` shared by two or +more Clients, on the stated reasoning that "native PipeWire clients carry their +own distinct PID; only the Pulse shim repeats one value". Measured: **WirePlumber +repeats one too.** It holds two Clients — `WirePlumber` and +`WirePlumber [export]` — both `sec_pid` 1747. Two values repeated (1747 and +pipewire-pulse's 2528), the rule called that ambiguous, and returned `None`. + +With the daemon PID unknown, `owner::keys_of`'s documented fail-closed asymmetry +takes over: key 4's suppression never fires, every Pulse-emulated node fuses into +one owner, and the cascade follows. Row 1's observed failure: + +``` +ELIGIBLE (1): r1_plain_app +EXCLUDED: Firefox tainted-owner-bridge key=application.process.id + r1_c_play tainted-owner-bridge <- the CLEAN control half +TAINT: ... + both sound cards, all three sunshine sinks, sunshine itself +``` + +The rule was wrong in **both** directions, so the prefilter was removed rather +than patched: + +- **False ambiguity** — any second process holding two Clients defeats it. + WirePlumber always does, so this was permanent, not a corner case. +- **False absence** — a session where pipewire-pulse holds exactly one Client + (one Pulse app running) repeats nothing, so the candidate is missed and the + same cascade follows. + +`comm` was always the authoritative check; repetition was a heuristic standing in +front of it, and it was a guess about other processes' Client counts. Fixed in +pixelpass `91c4ded`: `candidates()` lists every distinct `sec_pid`, `resolve()` +picks the unique one whose `/proc//comm` is exactly `pipewire-pulse`, and +several matches still fail closed (a single `Option` cannot suppress two +daemons — recorded, not approximated). The adapter probes only PIDs *entering* +the candidate set, and `retain_probed_comms` bounds the map to live PIDs so a PID +that leaves and returns is re-probed instead of answered from a stale `comm`. + +**This is the §5.1 exact-partition requirement earning its keep for the second +time.** The verdict was fail-closed and silent; only the asserted *eligible* half +exposed it. An exclusion-only checklist would have passed this build too. + +--- + +## §5.1 — the matrix + +Every row ran with `PIXELPASS_AUDIO_AUDIT_AEC=off` except row 12. Every row ran +in its **own** audit process, so nothing carries over (sticky taint is +per-process state). + +⚠️ **Methodology change from run 1, and it is load-bearing.** Run 1 built each +fixture *before* starting the audit. On this host the entire graph then arrives +as one enumeration burst (~122 events in 1–2 ms), so every node is first tainted +while `graph_ready` is still false, that partial-graph taint is recorded into +sticky state, and on the single ready record the sticky pass raises +`TaintedOwnerBridge { key: None }` before the evidence pass can name a key — +`raise` will not replace a same-rank reason. Verdicts were still correct but rows +could not assert their key. This run starts the audit first, waits for readiness, +then builds the fixture, so taint is derived from real topology *changes* against +a ready graph — which is also the dynamic path §6.3 cares about. Keys are read at +**derivation** (first non-sticky appearance), not from the final record. + +| # | scenario | status | +| --- | --- | --- | +| 1 | null-sink + loopback forwarder, owner bridge | ✅ **pass** (after F13-1 fixed) | +| 1b | Sunshine's topology (opportunistic, non-gating) | 🟡 observed, nothing to exclude — see below | +| 2 | gst split clients, tainted input | ✅ **pass**, key 4 named at derivation | +| 3 | two Pulse modules, one tainted | ✅ **pass** | +| 4 | peerspeak native call playback | ✅ **pass** — real tagging site | +| 5 | peerspeak-spawned mpv | ✅ **pass** — real tagging site, hand-launched mpv eligible | +| 6 | peerspeak notification sound | ✅ **pass** — real tagging site | +| 7 | second host's capture sink + forwarder | ✅ **pass**, eligible half non-empty | +| 8 | EasyEffects | 🟡 **pass with substitution** — echo-cancel stood in | +| 9 | Firefox three cases | ✅ **pass** (cases 2–3 via gst; see substitution) | +| 10 | sticky taint across teardown | ✅ **pass**, all four phases incl. retirement | +| 11 | recycled serial / index / link-group | ✅ **pass**, and provably non-vacuous | +| 12 | AEC loaded → unloaded → Revoked | ✅ **pass** | +| 13 | `Audio/Duplex` device | 🟡 **pass with synthetic node** — over-taint confirmed | + +### Row 1 — owner bridge, key named + +``` +ELIGIBLE (3): Firefox · r1_c_play · r1_plain_app +EXCLUDED (2): peerspeak_owned_call_4242 peerspeak-owned + r1_t_play tainted-owner-bridge key=node.link-group +TAINT (5): the tagged producer, r1_t_src, r1_t_cap, r1_t_play, r1_t_dest +``` + +The clean half is an **identically shaped** forwarder — same module type, same +monitor-read, same re-emit — differing only in whether anything tainted feeds it. +`r1_c_play` eligible is the assertion an exclude-everything build cannot satisfy. +The key is `node.link-group`, a strong key, not a link walk. + +### Row 2 — GStreamer split clients, key 4 + +Measured props confirm the shape is the real refutation: `r2_gst_tainted_src` +(client 188) and `r2_gst_tainted_sink` (client 191) are **different Clients** of +**one process**, pid 235628, with no `link-group` and no `pulse.module.id`. So +`application.process.id` is the only key that can relate them. + +Derivation record (seq 209): `r2_gst_tainted_sink` → `tainted-owner-bridge`, +**`owner_key=application.process.id`**. `r2_gst_clean_sink`, reading an untainted +monitor in a second process, is eligible. + +### Rows 4–6 — peerspeak's own paths, through the real call sites + +Driven by peerspeak's phase-1 live gate tests (`--ignored`), i.e. the real +tagging sites, not a hand-rolled env: "emission alone proves only that peerspeak +talks, not that pixelpass listens" (impl plan §3). + +| node | verdict | +| --- | --- | +| `peerspeak_owned_call_238172` | EXCLUDED `peerspeak-owned` | +| `peerspeak_owned_mpv_238196` | EXCLUDED `peerspeak-owned` | +| `peerspeak_owned_notify_238231` | EXCLUDED `peerspeak-owned` | +| `peerspeak_owned_clip_238249` | EXCLUDED `peerspeak-owned` (bonus — chat clips) | +| `mpv` (launched by hand, untagged) | **ELIGIBLE** | + +This is the cross-repo contract closed end to end on live nodes. + +### Row 9 — the over-exclusion promise + +``` +ELIGIBLE: Firefox (music only) · r9_mic_out (captures an untainted real device) +EXCLUDED: r9_mon_out tainted-owner-bridge key=application.process.id +``` + +`r9_mic_out` is the row that defends §6.1.1: an app that captures a real +`session_device` source and also plays audio stays shareable. The device source +itself never entered the taint set. + +### Row 10 — the full sticky lifecycle + +| phase | topology | verdict | +| --- | --- | --- | +| A | tainted producer + forwarder | `r10_play_out` EXCLUDED, key `node.link-group` | +| B | **tagged producer killed**, forwarder lives | **still EXCLUDED** (sticky) — current topology alone no longer justifies it | +| C | forwarder owner replaced, tainted sink kept | fresh forwarder EXCLUDED — correct: a sink that received call audio is still a hazard while it lives | +| D | **every** tainted object torn down, then restart | taint set **empty** at 16.3 s; `r10_new_out` **ELIGIBLE** at 20.3 s | + +Phase B proves stickiness works; phase D proves it is not permanent. Phase C is +worth keeping in mind when reading any future report: partial teardown legitimately +does *not* retire taint, and that is easy to mistake for over-exclusion. + +### Row 11 — recycled identifiers, provably non-vacuous + +| generation | `node.link-group` | global id (`r11_src`) | `object.serial` (`r11_play`) | pulse module | +| --- | --- | --- | --- | --- | +| 1 (tainted) | `loopback-2528-14` | 168 | 4702 | 536870919 | +| 2 (after teardown) | **`loopback-2528-14`** | **168** | 4746 | 536870920 | + +The `node.link-group` came back **byte-identical** — and it is the very key that +carried the taint in generation 1 — and the global id was reused. Generation 2's +`r11_play` is **ELIGIBLE** with an empty taint set. `object.serial` correctly did +not recycle, which is why the model keys everything by it. + +### Row 12 — AEC lifecycle + +| stage | `aec_state` | `fan_out_permitted` | candidates | +| --- | --- | --- | --- | +| module live, configured | `validated` | `true` | Firefox + `r12_plain_app` ELIGIBLE; `echo-cancel-playback` EXCLUDED `aec-identity` | +| module unloaded | `revoked` | `false` (`gate_reason=aec-revoked`) | every candidate EXCLUDED `aec-revoked` | + +All **four** link-group siblings (`sink`, `source`, `capture`, `playback`) carry +`aec-identity`; only `echo-cancel-playback` is a candidate, so it is the only one +in the excluded partition. Ordinary apps staying eligible *while validated* is +what makes "the gate is open" observable rather than inferred. + +### Row 13 — `Audio/Duplex` over-taint (known accepted) + +No real duplex device exists on this host, so one was synthesised by overriding +`media.class=Audio/Duplex` on a null sink. Its playback side was tainted and its +capture-side consumer was dragged down with it (`r13_dup_play` EXCLUDED), with +the eligible half intact. **Fixture limit, stated plainly:** on a null sink the +capture side *is* the monitor, so this cannot separate the duplex smear from the +ordinary sink→monitor edge. The accepted over-taint is confirmed as *behaviour*; +a real duplex device is still the only way to isolate the mechanism. + +### Row 1b — Sunshine (opportunistic, non-gating) + +Sunshine ran throughout. Its three null sinks stayed SUSPENDED and it read the +**hardware** monitor instead, exactly as §5.3 warned. It appears consistently and +correctly as `sunshine` / `tainted-upstream` whenever the monitor it reads is +tainted (rows 8, 12, o5). It has **no re-emitting output leg** — it sends over +the network — so it is never a candidate and there is nothing to exclude. Recorded +as observed; the "if a re-emitting leg exists" clause did not apply. A real +third-party forwarder sample remains owed. + +--- + +## §5.2 — O5 re-measured + +The run-1 numbers do not carry over: they were measured on the graph F1 degraded, +and phase 3r adds a bind plus an `info` round-trip **per node**, which is new I/O +that run never exercised. + +Per-run, across all 13 rows (`recompute` in µs): + +| run | events | ev/s | max | mean | emit max | busy fraction | ready@ms | +| --- | --- | --- | --- | --- | --- | --- | --- | +| baseline | 123 | 21.4 | 20 | 3 | 6 | 0.0001 | 1 | +| o5 (churn) | 407 | 44.0 | 32 | 10 | 9 | 0.0006 | 1 | +| row01 | 219 | 41.7 | 53 | 10 | 9 | 0.0006 | 1 | +| row02 | 241 | 45.9 | **67** | 11 | 10 | 0.0006 | 1 | +| row03 | 206 | 48.5 | 54 | 8 | 8 | 0.0005 | 1 | +| row0456 | 185 | 20.0 | 38 | 7 | 10 | 0.0002 | 2 | +| row07 | 184 | 43.3 | 40 | 7 | 9 | 0.0004 | 1 | +| row08 | 172 | 32.6 | 44 | 6 | 7 | 0.0003 | 2 | +| row09 | 224 | 30.9 | 52 | 9 | 9 | 0.0004 | 1 | +| row10 | 332 | 14.3 | 41 | 11 | 15 | 0.0002 | 1 | +| row11 | 298 | 24.1 | 41 | 9 | 11 | 0.0003 | 1 | +| row12 | 188 | 25.9 | 39 | 7 | 8 | 0.0003 | 1 | +| row13 | 193 | 36.8 | 43 | 8 | 7 | 0.0004 | 1 | + +The dedicated churn run (five load/unload cycles of null-sink + loopback, the +same shape as run 1's measurement): + +```json +{"kind":"metrics","graph_events":407,"tick_events":37,"emitted_records":407, + "span_us":9249639,"graph_events_per_sec":44.0, + "recompute_max_us":32,"recompute_mean_us":10, + "recompute_p50":"<50us","recompute_p90":"<50us","recompute_p99":"<50us", + "recompute_distribution":[["<50us",444]], + "emit_max_us":9,"emit_mean_us":1, + "busy_us":5240,"busy_fraction":0.0006, + "queued_events":292,"queue_threshold_us":100} +``` + +**O5 stays closed on the real graph.** Worst recompute across every run is +**67 µs**; every single recompute in the churn run finished under 50 µs, against +a 44 Hz event rate under churn heavier than a desktop produces at rest. The +observer thread spent **0.06 %** of wall time working. Node binding roughly +doubled the per-event cost (run 1: 15 µs max / 4 µs mean; now 32 µs / 10 µs on +the same churn shape) and that is the honest cost of the F1 fix — it buys three +orders of magnitude of remaining headroom, not one. + +**Readiness with node binds: 1–2 ms**, with ~122 enumeration events and 18 binds +(14 Nodes + 4 Devices), against the 2000 ms budget. `queued_events` is high +(292) for the same benign reason as run 1: PipeWire delivers enumeration and +teardown in bursts, and a 32 µs recompute drains a burst faster than it forms. +`busy_fraction` is the number to trust. + +⚠️ **The readiness budget still has no calibration argument.** 1–2 ms against +2000 ms is three orders of magnitude of slack on *this* host with 18 binds; it is +not an argument about a host with a large USB interface, many virtual devices, or +a cold cache. Carried forward as open, unchanged. + +--- + +## Findings recorded, not blocking + +### R2-1 — the audit's `sticky` flag is nearly always true, so it says little + +As emitted, `sticky` means "this node is in the remembered set", which +`seed_sticky` populates for any node whose current reason the sticky pass agrees +with — i.e. essentially every currently-tainted node. It does **not** mean +"excluded *only* because remembered", which is what its doc comment implies and +what a reader diagnosing "why is this still excluded?" wants. + +The information exists: round 9 already computes a second, **evidence-only** pass +(that is the whole provenance mechanism). Emitting "excluded by memory alone" +would make row 10 phase B assertable from a single record instead of from a +sequence. Not fixed here — it is a reporting change to a merged phase in the +middle of a gate run. Row 10 was asserted behaviourally instead, which is +stronger anyway. + +### R2-2 — a bridge key is lost when a leg reappears under a new serial + +Row 2 named `application.process.id` at derivation (seq 209), then gst re-created +that node; the sticky owner re-seeded the new serial through `reason_for`, whose +documented fallback is `TaintedOwnerBridge { key: None }`, and `raise` will not +replace a same-rank reason with a better-informed one. The verdict is unaffected; +only the diagnosis degrades. The fallback is honest when the owner has no live +tainted receiver, and stale when it does — which is the case worth improving. + +### R2-3 — `owner_key` had to be added to the record to run row 1 at all + +Row 1 asserts "reason = owner bridge, **naming the key**", and the record could +not express it: `Reason::code` collapses `TaintedOwnerBridge { key }` to one +string. `OwnerKey::code` already documented itself as ending up in the phase 5 +audit output; it was simply never wired to it. Added in pixelpass `d462754` +(read-only, diagnostic-only, mutation-verified test). Worth noting as a gate-spec +lesson: the row could not have been asserted from any previous build's output. + +--- + +## Substitutions, stated so they are not mistaken for passes + +| row | asked for | used instead | why | +| --- | --- | --- | --- | +| 8 | EasyEffects | `module-echo-cancel` with `AEC=off` | EasyEffects makes itself the default sink on start and the user had live audio playing. `module-filter-chain` cannot stand in either — it is a PipeWire module, so `pactl load-module` answers "No such entity" (measured). The stand-in produces the same shape (four nodes, one `node.link-group`) and exercises `foreign-echo-cancel` (decision D3), a reason code no other row reaches. | +| 9 | Firefox's mic + monitor capture | `gst-launch` pipelines | Firefox's mic and monitor-capture paths need interactive GUI permission grants. Firefox is present live as case 1 in every row. Case 2 captures the motherboard's **analog input**, not the headset mic the user is wearing — identical to the engine (both `session_device` sources), and nothing of the user is recorded. | +| 13 | a real `Audio/Duplex` device | synthetic `media.class` override | None on this host. See row 13 above for what the fixture cannot show. | + +--- + +## What still blocks phase 6 + +This file passing removes **one** of the two gates. Still outstanding: + +1. **🔴 F11-1 — the owner-key union can *reduce* taint.** Unchanged and still + open; it was to be decided *with* this matrix data. The data now exists: the + suppression path is live and correct (F13-1 fixed), and rows 2/9 show key 4 + doing real work, which means the interaction F11-1 describes is reachable in + principle. The rule to implement is written in `owner_is_bounded`'s doc in + pixelpass `src/host/taint/owner.rs`, sharpened by Codex round 12: "resolved" + must mean **an unambiguous Client yielding `Some(pipewire.sec.pid)` before + pipewire-pulse suppression**. A five-case Client matrix (absent · ambiguous · + unique-but-pid-less · resolved-native · resolved-to-pipewire-pulse) is owed. +2. **Hardware playback-to-capture paths ("Stereo Mix")** defeat `session_device` + and are a real echo path — needs ALSA control inspection; user design call owed. +3. **Phases 0b / 0c / 0d** are untouched and all precede phase 6. +4. **The readiness budget calibration argument** (above). +5. **Owed samples:** a real third-party forwarder (row 1b), EasyEffects (row 8), + a real `Audio/Duplex` device (row 13). + +--- + +## Reproducing this run + +Scripts live in the session scratchpad (not committed — they hard-code paths): +one per row, plus `lib.sh`, `summarize.py` and `keys.py`. The shape of every row: + +```sh +audit_start out.jsonl off # start FIRST, wait for graph_ready +... build fixture ... # taint arrives as topology CHANGES +audit_stop # SIGTERM: flushes the O5 summary +python3 summarize.py out.jsonl # final partition + derivations + metrics +``` + +``` +env PIXELPASS_AUDIO_AUDIT_FILE=/path/out.jsonl PIXELPASS_AUDIO_AUDIT_AEC=off \ + ./target/release/pixelpass --audit-audio +``` + +Rig notes that cost time: + +- A tagged producer: `env PIPEWIRE_ALSA='{ "peerspeak.owned": "1", "node.name": + "peerspeak_owned_call_4242", "target.object": "" }' aplay -c 2 -r 48000 + -f S16_LE -t raw -d 30 /dev/zero`. Both carriers land, and `target.object` + routes it. +- ⚠️ `pactl load-module module-echo-cancel --help` **loads the module** with + `--help` as its argument instead of printing help. It was loaded accidentally + during this session and unloaded again; check `pactl list short modules` after + any such probe. +- ⚠️ `pkill -f ` matches the harness's own shell command line and kills + the script. Use `pkill -x` or an exact pid. +- ⚠️ Under `set -e`, `kill` on an already-exited pid aborts the row before its + modules are unloaded; and `timeout` exiting 124 is *success* for the audit. + +--- + +## History — run 1 (2026-07-25): GATE FAILED + +Kept because the reasoning is still the record of why the observation boundary +was redesigned. + +### F1 🔴 FATAL — the registry `global` event delivers only a filtered subset of node properties + +The phase-3 adapter read eight node properties the registry never announces. +Parsed off `obj.props` in the registry `global` callback, they were silently +absent, so every one was permanently `None`/`false`. + +The complete set the registry announces for a `Node` on this host: ``` application.name client.api client.id device.id factory.id media.class @@ -37,246 +405,51 @@ node.description node.name node.nick object.path object.serial priority.driver priority.session ``` -Against what the adapter tries to read: - -| property | announced? | what dies without it | +| property | announced? | what died without it | | --- | --- | --- | -| `object.serial` | ✅ | — | -| `node.name` | ✅ | — | -| `media.class` | ✅ | — | -| `client.id` | ✅ | — | -| `device.id` | ✅ | — | -| **`peerspeak.owned`** | ❌ | **the primary taint root (v3.4 §5.1, all of phase 1)** | +| `object.serial`, `node.name`, `media.class`, `client.id`, `device.id` | ✅ | — | +| **`peerspeak.owned`** | ❌ | **the primary taint root (all of phase 1)** | | **`pulse.module.id`** | ❌ | **AEC identity exclusion + phase 4 validation** | -| **`node.link-group`** | ❌ | the link-group owner key (echo-cancel, EasyEffects, loopback siblings) | -| **`application.process.id`** | ❌ | the process owner key (GStreamer split clients, §5.1 row 2) | -| **`node.passthrough`** | ❌ | the passthrough local exclusion (a second link corrupts an encoded stream) | -| **`device.api`** | ❌ | `session_device` classification | -| **`factory.name`** | ❌ | `session_device` classification — the discriminator itself | -| **`alsa.driver_name`** | ❌ | `session_device` classification (the `snd_aloop` denylist) | +| **`node.link-group`** | ❌ | the link-group owner key | +| **`application.process.id`** | ❌ | the process owner key | +| **`node.passthrough`** | ❌ | the passthrough local exclusion | +| **`device.api`**, **`factory.name`**, **`alsa.driver_name`** | ❌ | `session_device` classification | -Ports and Links are also affected, one materially: +Ports lost `port.exclusive`; Links and Clients were fine — notably +`pipewire.sec.pid` **is** announced, so pulse-PID derivation was reachable. -| object | announced | missing | -| --- | --- | --- | -| Port | `node.id`, `object.serial`, `port.direction`, `port.monitor`, `port.physical`, `port.terminal`, `port.group`, `port.alias`, `port.name`, `port.id`, `audio.channel`, `format.dsp` | **`port.exclusive`** — the `port-exclusive` local exclusion never fires | -| Link | `object.serial`, `link.output.node`, `link.input.node`, `link.output.port`, `link.input.port`, `client.id`, `factory.id` | nothing the engine needs | -| Client | `object.serial`, **`pipewire.sec.pid`**, `application.name`, `module.id`, `pipewire.access`, `pipewire.protocol`, `pipewire.sec.{uid,gid,socket}` | nothing the engine needs | +Demonstrated end to end: a null sink carrying `peerspeak.owned=true` whose +monitor a `module-loopback` re-emitted was reported **eligible** with an **empty +taint set**. In phase 6 that is an echo. -**Links and Clients are fine.** Notably the pulse-PID derivation (v3.4 §6.1.2) -works: `pipewire.sec.pid` is announced. Also notable: the Link endpoint props are -*always* present, which confirms the phase-3 exit-gate worry that the -bind-`LinkInfoRef` fallback is dead code in practice — it is correctness -insurance, never exercised on this host. +The fix became design round 8 (v3.5 §6.7) and phase 3r: bind each Node and read +props off its `info`, exactly how `pw-dump` obtains them. `factory.id` is not a +shortcut (`factory.id=19` resolves to `factory.name = "adapter"`), and +`device.api` is on the *Device* global. -### Demonstrated end to end - -A null sink carrying `peerspeak.owned=true`, its monitor read by a -`module-loopback` whose playback leg is a fan-out candidate — the exact shape the -tag exists to exclude: - -``` -pactl load-module module-null-sink sink_name=ppgate_src \ - sink_properties="peerspeak.owned=true" -pactl load-module module-loopback source=ppgate_src.monitor sink=ppgate_dest \ - source_output_properties=node.name=ppgate_cap \ - sink_input_properties=node.name=ppgate_play -``` - -Audit verdict: - -```json -{"kind":"audit","graph_ready":true,"epoch":"complete","aec_state":"not-configured", - "fan_out_permitted":true, - "candidates":[{"serial":280,"name":"FINAL FANTASY XIV","eligible":true,"sticky":false}, - {"serial":309,"name":"ppgate_play","eligible":true,"sticky":false}], - "eligible_count":2,"excluded_count":0,"taint":[]} -``` - -`ppgate_play` **eligible**, and the `taint` set **empty** — the tagged sink was -not even recognised as a root. In phase 6 this is an echo: peerspeak's own call -playback carries `peerspeak.owned` and would be fanned straight into the share. - -The AEC path fails in the other direction. With -`PIXELPASS_AUDIO_AUDIT_AEC=pulse-module:536870918` (a real live module index): - -``` -aec_state = failed fan_out_permitted = false gate_reason = aec-failed -``` - -Correct behaviour given its inputs — `pulse.module.id` never arrives, so the -identity can never be observed and the validator times out fail-closed — but it -means **§5.1 row 12 cannot be run as written**, and that with a real AEC -configured phase 6 would refuse to share any audio at all. - -### The fix (for round 8) - -The full property set *is* reachable: **bind each Node global and read the props -off its `info` event**, which is exactly how `pw-dump` obtains them. Verified on -the same objects that were missing them from the registry: - -``` -alsa_output.usb-SteelSeries… factory.name = 'api.alsa.pcm.sink' - device.api = 'alsa' - alsa.driver_name = 'snd_usb_audio' -ppgate_src peerspeak.owned = True - pulse.module.id = 536870917 -ppgate_play pulse.module.id = 536870918 - node.link-group = 'loopback-2528-13' -FINAL FANTASY XIV application.process.id = 14651 -``` - -Two notes for whoever designs that change: - -- **The pattern already exists.** Phase 3 built exactly this for Links (bind → - `LinkInfoRef` → `LinkEndpointsResolved`, "the optimisation is the props, the - bind is the correctness path"). Nodes need the same, but as the *only* path - rather than a fallback, and the readiness epoch must hold an obligation per - unbound node — which the model already supports (`withheld` / `pending_links`). -- **`factory.id` is not a shortcut.** The Factory global for `factory.id=19` - (which every ALSA node claims) resolves to `factory.name = "adapter"`, not - `api.alsa.pcm.sink`. The node's own `factory.name` is a different property and - binding is the only way to it. - -Also relevant: **`device.api` is announced on the *Device* global** even though it -is absent from the Node. That is the phase-3 review's owed fix ("read the ALSA -driver from the backing Device global, authoritative") — now not merely better -but load-bearing, though `factory.name` and `alsa.driver_name` are absent from -the Device global too, so node binding is still required. - ---- - -## F2 🟠 Machine-wide over-exclusion cascade, downstream of F1 +### F2 🟠 Machine-wide over-exclusion cascade, downstream of F1 With F1 in force, `pixelpass_capture_*` (matched on `node.name`, which *is* -announced) is the only taint root that still fires. Running §5.1 row 7 — -a capture sink plus a controlled forwarder reading its monitor: +announced) was the only surviving taint root. Row 7 then excluded every +`Stream/Output/Audio` on the machine: with no strong owner keys, every tainted +capture stream was an **unbounded tainted reader**, tripping phase 2's +fail-closed backstop, while WirePlumber's shared `client.id = 42` fused the +device layer into one owner. -``` -candidates: - FINAL FANTASY XIV | eligible: false | reason: unresolved-owner - ppgate7_play | eligible: false | reason: tainted-owner-bridge -taint: - Midi-Bridge | tainted-owner-bridge - bluez_midi.server | tainted-owner-bridge - alsa_output.pci-0000_03_00.1.hdmi-stereo-… | tainted-owner-bridge - alsa_output.usb-SteelSeries_…-analog-stereo | tainted-upstream - alsa_input.usb-SteelSeries_…-mono-fallback | tainted-owner-bridge - alsa_output.pci-0000_10_00.6.analog-stereo | tainted-owner-bridge - alsa_input.pci-0000_10_00.6.analog-stereo | tainted-owner-bridge - FINAL FANTASY XIV | unresolved-owner - ppgate_dest | tainted-upstream - pixelpass_capture_ppgate7 | pixelpass-owned - ppgate7_play | tainted-owner-bridge - ppgate7_cap | tainted-upstream -``` +Net live behaviour: exclude everything, always, as soon as pixelpass's own +capture sink existed. Fail-closed, so silence rather than echo — but entirely +non-functional, and non-functional in a way that would have looked like "working +safely" to any test that asserted only exclusions. -Row 7's own assertion held — `ppgate7_play` is excluded via the owner bridge, so -the cycle-prevention mechanism works. But the row **fails the §5.1 exact-partition -requirement**, because the eligible half is empty: FFXIV should have been -eligible and was not. +### What run 1's machinery got right -The mechanism: with `node.link-group`, `application.process.id` and -`pulse.module.id` all absent, no node has a *strong* owner key — `client.id` is -explicitly not one (v3.4 §6.1.3). So every tainted capture stream is an -**unbounded tainted reader**, which trips phase 2's documented fail-closed -backstop (`taint/mod.rs`, `an_unbounded_tainted_reader_excludes_every_output`) -and excludes every `Stream/Output/Audio` on the machine. Every device node -separately keeps its coarse keys (`session_device` is universally false, also from -F1) and they all share WirePlumber's `client.id = 42`, which fuses them into a -single owner and spreads the taint across the whole device layer. - -So the engine's *net* live behaviour today is: exclude everything, always, as soon -as pixelpass's own capture sink exists. Fail-closed, so silence rather than echo — -but the feature is entirely non-functional, and it is non-functional in a way that -would have looked like "working safely" to any test that only asserted exclusions. - -**This is the §5.1 argument vindicated in the most direct possible way.** The -current build *is* the degenerate exclude-everything implementation the plan -warned about, and it is the eligible half of the partition — asserted, per §5.1 — -that caught it. An exclusion-only checklist would have passed this build. - ---- - -## §5.2 — O5 measurements - -Recorded under deliberate churn: five load/unload cycles of -`module-null-sink` + `module-loopback`, 6.5 s wall. - -```json -{"kind":"metrics","graph_events":308,"tick_events":26,"emitted_records":308, - "span_us":6499634,"graph_events_per_sec":47.39, - "recompute_max_us":15,"recompute_mean_us":4, - "recompute_p50":"<50us","recompute_p90":"<50us","recompute_p99":"<50us", - "recompute_distribution":[["<50us",334]], - "emit_max_us":12,"emit_mean_us":2,"emit_distribution":[["<50us",308]], - "busy_us":2331,"busy_fraction":0.0004, - "queued_events":198,"queue_threshold_us":100} -``` - -**O5 is closed: full recompute per graph event has roughly four orders of -magnitude of headroom.** Every one of 334 recomputes finished in under 50 µs, the -worst at 15 µs, against a 47 Hz event rate under churn far heavier than a desktop -produces at rest. The observer thread spent 0.04 % of wall time working. - -`queued_events: 198` looks alarming and is not: PipeWire delivers enumeration and -teardown as back-to-back bursts, so most events do begin within 100 µs of the -previous one completing. With a 15 µs worst-case recompute the backlog drains -faster than it forms. `busy_fraction` is the number to trust here — it needs no -inference, and it is 0.0004. - -**Caveat, and it is a real one.** These numbers were measured on the *degraded* -graph F1 produces. The recompute cost is over the same node and link count so the -taint-engine figure is representative, but the F1 fix adds a bind and an `info` -round-trip **per node**, which is new I/O this run did not measure at all. O5 -should be re-measured after round 8 rather than inherited from here. - ---- - -## Matrix status (§5.1) - -| # | scenario | status | -| --- | --- | --- | -| 1 | null-sink + loopback forwarder, owner bridge | ⛔ blocked by F1 — needs a taint root (`peerspeak.owned`) | -| 1b | Sunshine's topology (opportunistic, non-gating) | not attempted | -| 2 | gst split clients, tainted input | ⛔ blocked by F1 — needs `application.process.id` | -| 3 | two Pulse modules, one tainted | ⛔ blocked by F1 | -| 4–6 | peerspeak playback / mpv / notification | ⛔ blocked by F1 — all three are `peerspeak.owned` tags | -| 7 | second host's capture sink + forwarder | 🟠 mechanism verified, **partition fails** (F2) | -| 8 | EasyEffects | ⛔ blocked by F1 — needs `node.link-group` | -| 9 | Firefox three cases | ⛔ blocked by F2 (everything excluded) | -| 10 | sticky taint across teardown | ⛔ blocked by F1 | -| 11 | recycled serial / index / link-group | ⛔ blocked by F1 | -| 12 | AEC loaded → unloaded → Revoked | ⛔ blocked by F1 — `pulse.module.id` never arrives; validator goes `failed` | -| 13 | `Audio/Duplex` device | not attempted (none present on this host) | - -**No row can be completed until F1 is fixed.** The matrix is not re-runnable in a -meaningful sense before then — every row's eligible half is empty for the same -reason. - ---- - -## What the audit machinery got right - -Worth recording, because none of it needs revisiting in round 8: +None of this needed revisiting: - Running the recompute **inline on the observer thread**, once per applied - registry event, upholds phase 4's no-coalescing contract and put the cost - exactly where O5 could measure it. -- The **complete-partition record** is what caught F2. A record of only the - interesting nodes would have shown row 7 passing. -- **Reason codes survived the trip** and were immediately diagnostic: - `unresolved-owner` on FFXIV named the backstop, not a symptom, and pointed - straight at the missing strong keys. -- The **`peerspeak.owned` / `pulse.module.id` fixtures were right** — phase 2's - engine does the correct thing when handed correct properties. The defect is - entirely at the observation boundary, which is where phase 5 was designed to - look. - -## Next - -1. **Design round 8** on F1: node binding in the observer, readiness obligations - per unbound node, and where `session_device` reads its inputs from. -2. Re-run this matrix in full afterwards. Rows 4–6 additionally need peerspeak - running; rows 8, 9 and 1b need EasyEffects, Firefox and Sunshine respectively. -3. Re-measure O5 with node binding in place. + registry event, upheld phase 4's no-coalescing contract and put the cost where + O5 could measure it. +- The **complete-partition record** is what caught F2 — and, in run 2, F13-1. +- **Reason codes survived the trip** and were immediately diagnostic. +- The **`peerspeak.owned` / `pulse.module.id` fixtures were right**: the engine + does the correct thing when handed correct properties. Both failures were at + the observation boundary, which is where phase 5 was designed to look. diff --git a/docs/screenshare-audio-exclusion-plan.md b/docs/screenshare-audio-exclusion-plan.md index 211a849..87918fb 100644 --- a/docs/screenshare-audio-exclusion-plan.md +++ b/docs/screenshare-audio-exclusion-plan.md @@ -1,11 +1,14 @@ # Design v3: whole-desktop screen-share audio without self-echo -**Status:** 🟠 **v3.6 — round 9, opened by a second MEASURED finding, this time from a live -audit run of the *fixed* observer.** v3.4's architecture is still unchanged and converged. -Round 8 revised the **observation boundary** (§6.7); round 9 revises what stickiness is -allowed to remember (new §6.8). Both were found by running code, not by reading it. -**Date:** 2026-07-25 (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) +**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) **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. @@ -502,11 +505,47 @@ derive this itself; it cannot assume a value, and peerspeak can supply only a *h - Read `pipewire.sec.pid` from the **Client** objects of Pulse-emulated streams. Measured: it is `2541` for Firefox, Steam, KDE Connect, sunshine and libcanberra alike, while each node's own `application.process.id` differs (Firefox `11114`, sunshine `4119`). -- Require a **single consistent** value across those clients, and validate it by reading +- ~~Require a **single consistent** value across those clients~~ and validate it by reading `/proc//comm` (or cmdline) and confirming it is `pipewire-pulse`. - "This PID owns implausibly many unrelated streams" is a **diagnostic**, never correctness logic. +> #### 🔴 Round 10 (MEASURED, phase-5 run 2): "a repeated `sec_pid`" does not identify pulse +> +> The struck rule above was implemented as *the single `sec_pid` shared by two or more +> Clients*, on the reasoning that native clients each carry their own distinct PID so only the +> Pulse shim repeats a value. **Measured on this host: WirePlumber repeats one too** — it holds +> two Clients, `WirePlumber` and `WirePlumber [export]`, both `sec_pid` 1747. Two values +> repeated, "single consistent" was unsatisfiable, and the derivation returned `None` +> **permanently, on a stock desktop**. +> +> The consequence was not a missing optimisation. With the daemon PID unknown the key-4 +> exception never fires, every Pulse-emulated node fuses into one owner, and the result is the +> machine-wide over-exclusion cascade of phase 5's F2 — reached again from a new cause, and +> caught again only by the §5.1 requirement to assert the **eligible** half of a row. +> +> The rule failed in both directions, so the repetition test is **deleted** rather than +> tightened: +> +> - **False ambiguity** — any second process holding two Clients defeats it, and WirePlumber +> always does. +> - **False absence** — a session in which pipewire-pulse holds exactly one Client (one Pulse +> app running) repeats nothing at all, so the candidate is never even considered. +> +> `comm` was always the authoritative check; repetition was a heuristic standing in front of it, +> and what it actually encoded was an assumption about *other* processes' Client counts. +> **The rule is now: every distinct `pipewire.sec.pid` is a candidate; the daemon is the unique +> one whose `/proc//comm` is exactly `pipewire-pulse`.** Zero matches ⇒ `None` (nothing we +> can prove to suppress). **Several** matches ⇒ also `None`: two live pipewire-pulse daemons (a +> nested or sandboxed session) cannot both be suppressed by a single `Option`, and failing +> closed there lands on the over-exclusion side, consistent with the failure-mode paragraph +> below. Suppressing a *set* of daemon PIDs is the real answer if a multi-daemon host ever turns +> up; it is out of v1 and recorded rather than silently approximated. +> +> The lesson generalises past this key: **a property of the objects we are trying to identify is +> evidence; a property of everyone else's object count is a guess.** The `/proc` read was already +> there and already authoritative — the heuristic in front of it only added a way to be wrong. + Failure modes: if pixelpass fails to identify the real pipewire-pulse PID, the result is broad **over-exclusion** (annoying, safe). If it wrongly suppresses a genuine app PID, the result is over-exclusion **for that app** — safe *only* because unresolved ancestry is @@ -1143,9 +1182,28 @@ Both reviewers agree on all seven. Recorded as decided; reopen only with new evi - **D7 — no materially simpler design exists** that still meets Joe's ask. The available simplification is to *narrow v1 scope*, not to change architecture. ✅ -## 14. Readiness — 🟠 v3.6 (round 9): architecture converged; observation boundary and sticky provenance REVISED. +## 14. Readiness — 🟢 v3.7 (round 10): the §5.1 matrix PASSED; architecture unchanged. -**Round 9 (2026-07-25, same day).** Phase 3r shipped §6.7 and the audit was re-run +**Round 10 (2026-07-26).** The phase-5 matrix ran in full and **passed all 13 rows with a +non-empty eligible half in every one** — results in +`screenshare-audio-exclusion-phase5-results.md`. It also found a third measured defect on +first contact, and once again the failure was fail-closed and *silent*, exposed only by the +requirement to assert what must remain **eligible**: §6.1.2's pulse-PID derivation returned +`None` permanently on this host, so key 4 fused every Pulse-emulated node into one owner. + +| | verdict | +| --- | --- | +| Architecture — Option C, taint as a graph property, owner-key union, sticky taint, AEC identity state machine | **unchanged, three times vindicated** | +| §6.1.2 | **revised** — the derivation heuristic is deleted; `comm` alone decides | +| §5.1 matrix | **PASSED** — 13/13, incl. the full sticky lifecycle (row 10) and provable identifier recycling (row 11) | +| O5 | **closed on the real graph** — worst recompute 67 µs, churn mean 10 µs, busy fraction 0.0006 | +| Phase 5 | **machinery unchanged and correct** — three real defects caught on first contact with the live graph, none of them in the engine | +| Phase 6 | **still blocked** — by F11-1, phases 0b/0c/0d, and the "Stereo Mix" design call, *not* by this matrix | + +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 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 right and the instrumentation was wrong**, and only running the code against a live daemon @@ -1205,7 +1263,10 @@ How the blockers closed: | **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; ~3 ms observed on this host | +| **9** | the 2 s readiness budget has no calibration argument (Codex) | **OPEN — measurement owed**, §6.8; 1–2 ms observed on this host with 18 binds (phase-5 run 2) | +| **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//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 | ### v1 scope — agreed