# 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.
