# Phase 5 β€” dry-run audit gate: results **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-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 `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 --- ## What changed since run 1 Run 1 failed on two defects, both fixed before this run: - **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. 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 node.description node.name node.nick object.path object.serial priority.driver priority.session ``` | property | announced? | what died without it | | --- | --- | --- | | `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 | | **`application.process.id`** | ❌ | the process owner key | | **`node.passthrough`** | ❌ | the passthrough local exclusion | | **`device.api`**, **`factory.name`**, **`alsa.driver_name`** | ❌ | `session_device` classification | Ports lost `port.exclusive`; Links and Clients were fine β€” notably `pipewire.sec.pid` **is** announced, so pulse-PID derivation was reachable. 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. 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. ### F2 🟠 Machine-wide over-exclusion cascade, downstream of F1 With F1 in force, `pixelpass_capture_*` (matched on `node.name`, which *is* 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. 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. ### What run 1's machinery got right None of this needed revisiting: - Running the recompute **inline on the observer thread**, once per applied 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.