# 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. - **Run date:** 2026-07-25 - **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. --- ## F1 πŸ”΄ FATAL β€” the registry `global` event delivers only a filtered subset of node properties **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`. ### Measured The complete set of keys the registry announces for a `Node` global on this host (union over every node, via `pw-cli ls Node`): ``` 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 ``` Against what the adapter tries to read: | property | announced? | what dies 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)** | | **`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) | Ports and Links are also affected, one materially: | 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 | **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. ### 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 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: ``` 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 ``` 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. 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: - 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.