docs: phase 5 dry-run audit results — GATE FAILED, two findings

Impl plan §5's required results file. Phase 6 does not start.

F1 (fatal): the PipeWire registry `global` event delivers only a filtered
subset of node properties, and eight of the properties the phase-3 adapter
reads are not among them — peerspeak.owned, pulse.module.id, node.link-group,
application.process.id, node.passthrough, device.api, factory.name,
alsa.driver_name (plus port.exclusive on Ports). They are silently absent, so
the primary taint root never fires, the AEC identity can never validate, and
session_device is universally false. Measured on PipeWire 1.6.8 /
WirePlumber 0.5.15, with the full announced key set for all five object types
recorded. Links and Clients are unaffected; pulse-PID derivation works.

F2: with F1 in force no node has a strong owner key, so any tainted capture
stream is an unbounded tainted reader and phase 2's fail-closed backstop
excludes every Stream/Output/Audio on the machine. Fail-closed, so silence
rather than echo — but entirely non-functional, and non-functional in a way an
exclusion-only checklist would have scored as passing. The eligible half of
the §5.1 partition is what caught it, exactly as the plan argued it would.

The fix direction is measured and recorded: binding each Node and reading its
info props recovers every missing property, which is the pattern phase 3
already built for Links. factory.id is not a shortcut — it resolves to
"adapter", not api.alsa.pcm.sink.

O5 is closed with ~4 orders of magnitude of headroom: 308 graph events in
6.5s under churn, every recompute under 50us (max 15us), busy fraction 0.0004.
Caveat recorded — measured on the degraded graph, and the F1 fix adds
per-node bind I/O this run did not measure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 15:43:23 -04:00
co-authored by Claude Opus 5
parent 283d938b79
commit 297f4397a7
@@ -0,0 +1,282 @@
# 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 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
**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 |
| 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
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 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.