Design v3.7 §6.1.1 gains the round-13 box (the rule, the ordering that is load-bearing in both directions, and why bridging deliberately still uses the full union); the phase-5 results file records the close with the measurement the deferral was waiting for; the impl plan's phase-6 gate note drops F11-1. pixelpass c78eb2d is the implementation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
24 KiB
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, and F11-1 — the other gate — was closed with this data on 2026-07-26 (see "What still blocks phase 6").
- 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
globalevent 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 frominfo. - 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/<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 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):
{"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. F11-1, the other, is now closed. Still outstanding:
- Hardware playback-to-capture paths ("Stereo Mix") defeat
session_deviceand are a real echo path — needs ALSA control inspection; user design call owed. - Phases 0b / 0c / 0d are untouched and all precede phase 6.
- The readiness budget calibration argument (above).
- Owed samples: a real third-party forwarder (row 1b), EasyEffects (row 8),
a real
Audio/Duplexdevice (row 13).
✅ F11-1 — closed 2026-07-26, with this matrix's data
The rule now implemented (pixelpass c78eb2d, §6.1.2's round-13 box): key 4 bounds an
owner only when the node's Client resolves — an unambiguous Client yielding
Some(pipewire.sec.pid), read before pipewire-pulse suppression — so a node can no
longer bound itself, and escape propagate_unresolved_owner's sweep, with an
application.process.id it invented. Bridging still uses the full union.
Codex's round-12 sharpening was the decisive part: "resolved" must mean a sec_pid, not
"a unique Client object exists", and the unique-but-pid-less row is the only one that
tells the two apart. All five Client cases are unit tests (absent · ambiguous ·
unique-but-pid-less · resolved-native · resolved-to-pipewire-pulse), plus the recorded
three-step leak path end to end. Mutation-verified: dropping the provenance test fails
four of the six rows and leaves the two no-over-exclusion rows green.
The cost question the deferral was waiting on, measured on this host: the before- and
after-binaries audited the same live graph simultaneously (both are read-only observers)
— tagged producer into the default sink, parec on its monitor as a live tainted reader
so the sweep was genuinely armed, Firefox + aplay + pacat as bystanders. 181 records
each, the same 14 distinct decision states, none exclusive to either side, no
unresolved-owner on either, eligible half non-empty throughout. O5 unmoved (identical
p50 15 µs and busy fraction 0.0012). Every real app here is native or Pulse-emulated and
both resolve; sweeping all 18 live nodes, the only unresolved-Client ones were
Dummy-Driver and Freewheel-Driver, which carry no pid key to lose.
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:
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, andtarget.objectroutes it. - ⚠️
pactl load-module module-echo-cancel --helploads the module with--helpas its argument instead of printing help. It was loaded accidentally during this session and unloaded again; checkpactl list short modulesafter any such probe. - ⚠️
pkill -f <pattern>matches the harness's own shell command line and kills the script. Usepkill -xor an exact pid. - ⚠️ Under
set -e,killon an already-exited pid aborts the row before its modules are unloaded; andtimeoutexiting 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.idfixtures 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.