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>
473 lines
24 KiB
Markdown
473 lines
24 KiB
Markdown
# 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 `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/<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):
|
||
|
||
```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. F11-1, the other, is now
|
||
closed. Still outstanding:
|
||
|
||
1. **Hardware playback-to-capture paths ("Stereo Mix")** defeat `session_device`
|
||
and are a real echo path — needs ALSA control inspection; user design call owed.
|
||
2. **Phases 0b / 0c / 0d** are untouched and all precede phase 6.
|
||
3. **The readiness budget calibration argument** (above).
|
||
4. **Owed samples:** a real third-party forwarder (row 1b), EasyEffects (row 8),
|
||
a real `Audio/Duplex` device (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:
|
||
|
||
```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": "<sink>" }' 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 <pattern>` 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.
|