docs: phase 5 matrix PASSED (13/13) — design round 10, results run 2

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/<pid>/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.
This commit is contained in:
2026-07-26 02:59:43 -04:00
parent b68fca689e
commit bf908adbf0
3 changed files with 519 additions and 271 deletions
+23 -9
View File
@@ -458,17 +458,31 @@ observable in Phase 5 before they gate anything real.
## 5. Phase 5 — dry-run audit mode 🚦 MAJOR GATE ## 5. Phase 5 — dry-run audit mode 🚦 MAJOR GATE
> **🚦 STATUS 2026-07-25: BUILT, RUN, AND THE GATE FAILED.** Results: > **🚦 STATUS 2026-07-26: GATE PASSED on run 2.** Results:
> `docs/screenshare-audio-exclusion-phase5-results.md`. The machinery is correct and needs no > `docs/screenshare-audio-exclusion-phase5-results.md`. All 13 rows completed, the eligible
> rework — **it found the defect on its first live run**, which is the phase working exactly as > half of every row is non-empty, and O5 is re-measured on the fixed graph (worst recompute
> designed. What failed is the observer beneath it (v3.5 §6.7). **Phase 6 does not start.** The > 67 µs; readiness 12 ms with 18 binds). Three rows carry recorded substitutions (8, 9, 13)
> matrix re-runs after phase 3r and phase 1's second carrier land; no row was completable > and three findings are recorded as non-blocking.
> under the defect, so none of it carries over. O5's numbers do not carry over either.
> >
> Read this before re-running: `PIXELPASS_AUDIO_AUDIT_FILE=… PIXELPASS_AUDIO_AUDIT_AEC=off > **Run 2 found and fixed a third defect of the F2 class, F13-1:** pipewire-pulse's PID was
> pixelpass --audit-audio`. **Every partition row must run with `AEC=off`** — a > 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/<pid>/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 > 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.** **Adds no capability. Its entire purpose is to be wrong loudly and safely.**
+425 -252
View File
@@ -1,35 +1,403 @@
# Phase 5 — dry-run audit gate: results # Phase 5 — dry-run audit gate: results
**Status: 🚦 GATE FAILED. Phase 6 does not start.** Two defects found, one of them **Status: 🟢 GATE PASSED (run 2, 2026-07-26). All 13 §5.1 rows completed; the
fatal to the whole mechanism. Both go to the design doc as **round 8** per impl eligible half of every row is non-empty. O5 re-measured on the fixed graph and
plan §5.3. 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 - **Host:** `cazen` — PipeWire 1.6.8, WirePlumber 0.5.15, CachyOS
- **Audit build:** pixelpass branch `phase5-dry-run-audit`, release profile - **Audit build:** pixelpass `main` @ `91c4ded`, release profile
- **Ambient load during the runs:** FINAL FANTASY XIV playing audio (`client.id` - **peerspeak build:** `main` @ `b68fca6` (phase 1 merged)
88, pid 14651), Arctis 1 Wireless as an active sink - **Ambient load:** Firefox playing audio throughout (a live, uncontrived
candidate); Sunshine running (pid 3838); Arctis 1 Wireless as active sink
The audit itself worked exactly as designed: it observed the live graph, ran - **Graph size:** 14 Nodes, 4 Devices, 57 Ports, 4 Links, 24 Clients
phases 24 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.
--- ---
## 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 Run 1 failed on two defects, both fixed before this run:
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`.
### 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 Both are gone: the baseline run (no fixture at all) reports **1 candidate,
(union over every node, via `pw-cli ls Node`): 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/<pid>/comm` is exactly `pipewire-pulse`, and
several matches still fail closed (a single `Option<u32>` 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 12 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 23 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 46 — 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: 12 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.** 12 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": "<sink>" }' 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 <pattern>` 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 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 priority.driver priority.session
``` ```
Against what the adapter tries to read: | property | announced? | what died without it |
| property | announced? | what dies without it |
| --- | --- | --- | | --- | --- | --- |
| `object.serial` | ✅ | — | | `object.serial`, `node.name`, `media.class`, `client.id`, `device.id` | ✅ | — |
| `node.name` | | | | **`peerspeak.owned`** | | **the primary taint root (all of phase 1)** |
| `media.class` | ✅ | — |
| `client.id` | ✅ | — |
| `device.id` | ✅ | — |
| **`peerspeak.owned`** | ❌ | **the primary taint root (v3.4 §5.1, all of phase 1)** |
| **`pulse.module.id`** | ❌ | **AEC identity exclusion + phase 4 validation** | | **`pulse.module.id`** | ❌ | **AEC identity exclusion + phase 4 validation** |
| **`node.link-group`** | ❌ | the link-group owner key (echo-cancel, EasyEffects, loopback siblings) | | **`node.link-group`** | ❌ | the link-group owner key |
| **`application.process.id`** | ❌ | the process owner key (GStreamer split clients, §5.1 row 2) | | **`application.process.id`** | ❌ | the process owner key |
| **`node.passthrough`** | ❌ | the passthrough local exclusion (a second link corrupts an encoded stream) | | **`node.passthrough`** | ❌ | the passthrough local exclusion |
| **`device.api`** | ❌ | `session_device` classification | | **`device.api`**, **`factory.name`**, **`alsa.driver_name`** | ❌ | `session_device` classification |
| **`factory.name`** | ❌ | `session_device` classification — the discriminator itself |
| **`alsa.driver_name`** | ❌ | `session_device` classification (the `snd_aloop` denylist) |
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 | Demonstrated end to end: a null sink carrying `peerspeak.owned=true` whose
| --- | --- | --- | monitor a `module-loopback` re-emitted was reported **eligible** with an **empty
| 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 | taint set**. In phase 6 that is an echo.
| 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 |
**Links and Clients are fine.** Notably the pulse-PID derivation (v3.4 §6.1.2) The fix became design round 8 (v3.5 §6.7) and phase 3r: bind each Node and read
works: `pipewire.sec.pid` is announced. Also notable: the Link endpoint props are props off its `info`, exactly how `pw-dump` obtains them. `factory.id` is not a
*always* present, which confirms the phase-3 exit-gate worry that the shortcut (`factory.id=19` resolves to `factory.name = "adapter"`), and
bind-`LinkInfoRef` fallback is dead code in practice — it is correctness `device.api` is on the *Device* global.
insurance, never exercised on this host.
### Demonstrated end to end ### F2 🟠 Machine-wide over-exclusion cascade, downstream of F1
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
With F1 in force, `pixelpass_capture_*` (matched on `node.name`, which *is* 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 — announced) was the only surviving taint root. Row 7 then excluded every
a capture sink plus a controlled forwarder reading its monitor: `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.
``` Net live behaviour: exclude everything, always, as soon as pixelpass's own
candidates: capture sink existed. Fail-closed, so silence rather than echo — but entirely
FINAL FANTASY XIV | eligible: false | reason: unresolved-owner non-functional, and non-functional in a way that would have looked like "working
ppgate7_play | eligible: false | reason: tainted-owner-bridge safely" to any test that asserted only exclusions.
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
```
Row 7's own assertion held — `ppgate7_play` is excluded via the owner bridge, so ### What run 1's machinery got right
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.
The mechanism: with `node.link-group`, `application.process.id` and None of this needed revisiting:
`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 |
| 46 | 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:
- Running the recompute **inline on the observer thread**, once per applied - Running the recompute **inline on the observer thread**, once per applied
registry event, upholds phase 4's no-coalescing contract and put the cost registry event, upheld phase 4's no-coalescing contract and put the cost where
exactly where O5 could measure it. O5 could measure it.
- The **complete-partition record** is what caught F2. A record of only the - The **complete-partition record** is what caught F2 — and, in run 2, F13-1.
interesting nodes would have shown row 7 passing. - **Reason codes survived the trip** and were immediately diagnostic.
- **Reason codes survived the trip** and were immediately diagnostic: - The **`peerspeak.owned` / `pulse.module.id` fixtures were right**: the engine
`unresolved-owner` on FFXIV named the backstop, not a symptom, and pointed does the correct thing when handed correct properties. Both failures were at
straight at the missing strong keys. the observation boundary, which is where phase 5 was designed to look.
- 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 46 additionally need peerspeak
running; rows 8, 9 and 1b need EasyEffects, Firefox and Sunshine respectively.
3. Re-measure O5 with node binding in place.
+71 -10
View File
@@ -1,11 +1,14 @@
# Design v3: whole-desktop screen-share audio without self-echo # 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 **Status:** 🟢 **v3.7 — round 10: the §5.1 matrix PASSED in full and the architecture is
audit run of the *fixed* observer.** v3.4's architecture is still unchanged and converged. unchanged for the third consecutive measured round.** Round 8 revised the **observation
Round 8 revised the **observation boundary** (§6.7); round 9 revises what stickiness is boundary** (§6.7), round 9 revised what stickiness may remember (§6.8), and round 10 deletes
allowed to remember (new §6.8). Both were found by running code, not by reading it. the pipewire-pulse PID **derivation heuristic** (§6.1.2) after measuring that WirePlumber
**Date:** 2026-07-25 (v1: 07-19 · v2: 07-20 · Option C 07-20 · v3.1 r4 · v3.2 r5 · v3.3 r6 · repeats a `sec_pid` too — which had switched key 4's suppression off permanently. All three
v3.4 r7 · v3.5 r8 · v3.6 r9) 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." **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`), **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. 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: - 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 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`). 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/<pid>/comm` (or cmdline) and confirming it is `pipewire-pulse`. `/proc/<pid>/comm` (or cmdline) and confirming it is `pipewire-pulse`.
- "This PID owns implausibly many unrelated streams" is a **diagnostic**, never - "This PID owns implausibly many unrelated streams" is a **diagnostic**, never
correctness logic. 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/<pid>/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<u32>`, 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 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 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 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 - **D7 — no materially simpler design exists** that still meets Joe's ask. The available
simplification is to *narrow v1 scope*, not to change architecture. ✅ 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 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 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 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_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** | `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** | 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; 12 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/<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 |
### v1 scope — agreed ### v1 scope — agreed