Files
peerspeak/docs/screenshare-audio-exclusion-plan.md
T
molluskandClaude Opus 5 692ad677d2 docs: record F11-1 closed — boundedness needs a resolved Client
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>
2026-07-26 04:46:25 -04:00

1332 lines
82 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Design v3: whole-desktop screen-share audio without self-echo
**Status:** 🟢 **v3.7 — round 10: the §5.1 matrix PASSED in full and the architecture is
unchanged for the third consecutive measured round.** Round 8 revised the **observation
boundary** (§6.7), round 9 revised what stickiness may remember (§6.8), and round 10 deletes
the pipewire-pulse PID **derivation heuristic** (§6.1.2) after measuring that WirePlumber
repeats a `sec_pid` too — which had switched key 4's suppression off permanently. All three
were found by running code, not by reading it, and all three were at the *observation*
boundary rather than in the design.
**Date:** 2026-07-26 (v1: 07-19 · v2: 07-20 · Option C 07-20 · v3.1 r4 · v3.2 r5 · v3.3 r6 ·
v3.4 r7 · v3.5 r8 · v3.6 r9 · v3.7 r10)
**Origin:** Joe's suggestion — "whitelist all audio except audio coming from peerspeak."
**Scope:** a new capture mode in pixelpass (`src/host/pipeline.rs`, `src/host/audio.rs`),
playback tagging + AEC-identity export + teardown-ordering invariants in peerspeak.
**Why v3 is a rewrite, not a patch.** v1 and v2 both described a design that *moves*
desktop streams onto a capture sink. That design is dead. v2's §5.1 adopted **Option C
(copy via a second owned link)** after a measured feasibility spike, and the AEC
identity gate — the one thing both reviewers agreed nothing could go ahead of — has
since passed. Roughly two thirds of v2 described problems Option C does not have.
Carrying that text forward as "retained for the record" made the live plan unreadable.
v1/v2 remain in git history at `88ad5a0` and `10203e1`.
**Review history**
| Round | Artifact | Verdict |
| --- | --- | --- |
| v1 review | `~/Documents/handoff-docs/Codex/peerspeak/review-2026-07-19-audio-exclusion-design.md` | not sound as written; 4 blockers, all verified correct |
| v2 review | `…/review-2026-07-20-audio-exclusion-design-v2.md` | blocked; produced Option C |
| fan-out spike | `~/Documents/handoff-docs/Claude/peerspeak/fanout-spike-results-2026-07-20.md` + Codex rounds 3/4 | **Option C adopted**, ratified |
| AEC identity gate | `~/Documents/handoff-docs/Claude/peerspeak/aec-playback-leg-identity-2026-07-20.md` | **🟢 gate passed**, both models agree after 2 adversarial rounds |
| **phase 5 dry-run gate (r8)** | `docs/screenshare-audio-exclusion-phase5-results.md` | **🚦 GATE FAILED** — the observation boundary is wrong (§6.7); architecture unaffected |
---
## 1. The problem
A sharer using whole-desktop audio sends their entire output mix to viewers. That mix
necessarily includes peerspeak's own playback — remote peers' voices, remote screen-share
audio — so viewers hear themselves.
`--strict-audio` solved this for *per-app* mode by refusing to mirror the desktop at all.
Whole-desktop mode has no equivalent, so a sharer today chooses between echo and sharing
exactly one app's audio. Joe's ask is the missing third option: **share the desktop,
minus peerspeak.**
## 2. Framing
PipeWire has no capture-side filter. A monitor port carries an already-summed signal;
once peerspeak's output is in that mix it cannot be subtracted out. What PipeWire does
provide is **graph topology**: a node's output ports may link to more than one consumer.
So the feature is built by constructing a sink that only *eligible* streams feed, and
capturing that sink's monitor. Same observable behaviour as a filter, entirely different
mechanism.
This is **not** echo cancellation. `src/audio/echo_cancel.rs` addresses the microphone
path, where coupling is acoustic and needs adaptive cancellation. Here the signals never
need to be summed at all.
**Fidelity is not claimed.** The *membership* of the constructed mix is exact — peerspeak's
audio is never summed in. The *delivered audio* is not lossless: pixelpass downmixes to
48 kHz stereo and AAC-encodes at 128 kbps (`pixelpass/src/host/pipeline.rs:303-324`).
Adding a second link also makes the source node participate in a second format/buffer
negotiation, which can perturb it even though the original link survives (§9.3).
## 3. What exists today
`setup_audio` (`pixelpass/src/host/pipeline.rs:123-142`) activates `Routing` **only** when
`--app` is set or `PIXELPASS_AUDIO_VIA_NULL_SINK` is set. peerspeak's no-app argv is
`--host --output json` (`peerspeak/src/screenshare/mod.rs:135-165`), so normal
whole-desktop capture **bypasses `Routing` entirely** and hands the real default monitor
to `pulsesrc`. This feature is therefore a genuinely new mode, not an inverted predicate.
`Routing::start` (`pixelpass/src/host/audio.rs:65-212`) today provides:
| Piece | Condition |
| --- | --- |
| `module-null-sink pixelpass_capture_<pid>` | always, when `Routing` runs at all |
| `module-loopback @DEFAULT_SINK@.monitor → capture` | skipped only when `--app` **and** `--strict-audio` |
| `StreamRouter` (libpipewire thread) | only when `opts.app` is `Some` |
| local-monitor `capture.monitor → @DEFAULT_SINK@` | on `FirstRoutedStream`, unloaded on `LastRoutedStreamGone` |
Under Option C the null sink is reused, the monitor loopback is never loaded in this
mode, the local monitor is not needed at all, and `StreamRouter` is replaced by a
link manager rather than extended. Two existing defects in that file are inherited and
must be dealt with as prerequisites (§10).
## 4. Architecture — Option C: copy, don't move
pixelpass creates a *second*, pixelpass-owned link from each eligible playback stream's
output ports to the capture sink, and leaves the stream's existing route untouched.
```
app output ──────────────────────► existing hardware / filter path (UNTOUCHED)
└── pixelpass-owned link ────► pixelpass_capture_<pid> ──► gst pulsesrc ──► viewers
peerspeak-owned playback ────────► speakers only (never linked to capture)
AEC playback leg ────────────────► speakers only (never linked to capture)
```
### 4.1 Why this beats the move-based design
| Problem under move (Option A) | Under copy |
| --- | --- |
| local-monitor loopback needed to keep the sharer hearing audio | not needed — audio never leaves the speakers |
| added playback latency for the sharer | gone |
| output-device switch mid-share strands the desktop | gone — the app's own route is never touched |
| prior `target.object` capture/restore | gone — nothing is retargeted |
| pavucontrol conflicts | gone |
| two concurrent hosts fight over `target.object` | gone — links are independent per host |
| SIGKILL strands the whole desktop on an orphan sink | gone — see §4.2 |
**The decisive measurement (spike E2): destroying the capture sink mid-share left the
application playing to its speakers undisturbed.** Capture-side failure degrades to "not
captured," never to "the user's audio is broken." That is Option C's entire case, and it
is the reason a broader eligibility predicate is now acceptable (§6).
### 4.2 Ownership of links is the safety mechanism
In the Rust `pipewire` crate, **dropping a `Link` proxy destroys the object.** So:
- Links are created **non-lingering** and their proxies are **retained** for the life of
the share.
- Process death (including SIGKILL) destroys the connection, which destroys the links.
Measured: non-lingering links die on SIGKILL of the owner.
- A stream is counted as captured only once **every** required link reaches `ACTIVE`.
- On any link failure: leave the original route alone, report the stream as unsupported.
Never fall back to "capture the default monitor instead" — that fallback *is* the echo.
⚠️ **Rig gotchas that constrain how this is built and tested** (learned the hard way):
`pw-link --props object.linger=false` and `pw-cli create-link <props>` both **ignore**
linger and force it on; only `pw-link -m` yields a non-lingering link. `pw-cli
create-object` does not exist in 1.6.8 — the verb is `create-link <node> <port> <node>
<port>`. And links do **not** self-restore after a node or sink is recreated: a *live
owner* must re-link. That re-link loop is the pattern pixelpass has to implement, and
it is also what makes the daemon-restart case (§12) untested rather than free.
## 5. Ownership: identifying peerspeak's own audio
Every peerspeak-owned playback path is either a stream peerspeak itself creates, a
`Command::new(…)` spawn, or a pactl-loaded module. Three mechanisms cover all three.
### 5.1 Env-inherited tag, for spawned children — mechanism ✅ MEASURED, not yet built
⚠️ **This table lists IMPLEMENTATION SITES, not current behaviour.** None of these sites
tags anything today: `pipewire_impl.rs:374-388` sets no `peerspeak.owned` prop, and
neither the mpv/VLC spawn (`screenshare/mod.rs:768-775`) nor the notification spawn
(`notify.rs:265-272`) sets any env. The *mechanism* is measured working (below); the
*wiring* is work.
| Owner | Site | Mechanism to add |
| --- | --- | --- |
| native call playback | `src/audio/pipewire_impl.rs:374-388` | add the tag to the stream's property dict |
| mpv / VLC | `src/screenshare/mod.rs:768-775` | `.env("PULSE_PROP", …)` + `PIPEWIRE_PROPS` on the `Command` |
| `pw-play` / `paplay` / `aplay` | `src/notify.rs:265-272` | same |
Exact strings to be pinned at implementation time (§11), and each site needs a test:
native props asserted on the constructed dict, child sites asserted on the `Command` env.
Measured 2026-07-20 on this box (PipeWire 1.6.8): `peerspeak.owned=1` reached the graph
node for **paplay ✅, mpv ✅, VLC ✅**. `PULSE_PROP` and `PULSE_PROP_OVERRIDE` are both
present in `/usr/lib/pulseaudio/libpulsecommon-17.0.so`. (Codex asserted this mechanism
did not exist; it was disproved empirically. VLC has no native PipeWire aout on this box,
which is true and irrelevant — its Pulse path honours `PULSE_PROP`.)
**The tag is a correctness mechanism, not a security boundary.** Any same-user client can
set `peerspeak.owned=1` and opt itself out of capture, and a child can sanitize its own
environment. PipeWire is explicit that only `pipewire.*` properties are usable for
security decisions. Acceptable here: the threat model is "don't echo the user's own call
back at them," not "defend against a hostile local process." Stated, not assumed.
⚠️ **Known leak: a stable boolean is inherited by grandchildren.** Anything mpv or VLC
spawns is exempted too. Accepted for now; revisit if it bites.
⚠️ **Round 8 — the tag needs a SECOND, registry-visible carrier (user's call, 2026-07-25).**
`peerspeak.owned` is invisible to the registry `global` event (§6.7) and is readable only via
a node bind. That is safe — an unread tag means the node is withheld or the epoch fails
closed, i.e. silence, never echo — but it makes the primary taint root depend on the one
mechanism the round-8 finding proved fragile. So each tagging site sets **both**:
| carrier | visibility | role |
| --- | --- | --- |
| `peerspeak.owned=1` | bind only | primary root; keeps the measured `PULSE_PROP` env-inheritance mechanism for mpv/VLC/paplay |
| a pinned `node.name` prefix | **announced by the registry** | secondary root; fires with no bind at all |
The prefix mechanism is already proven in this codebase — `pixelpass_capture_*` is matched on
`node.name` and was the *only* root still functioning under the defect. Either carrier alone
marks a node owned (union, not intersection: fail-closed direction). `node.description` is
deliberately left untouched so volume mixers still read "mpv", not "peerspeak: mpv". The
literal is a cross-repo wire contract and is pinned in the implementation plan §3 alongside
the property literal.
### 5.2 The AEC playback leg — 🟢 gate passed, with five load-bearing corrections
`module-echo-cancel` is loaded via pactl (`peerspeak/src/audio/echo_cancel.rs:83-94`), so
its playback leg lives inside `pipewire-pulse` and **cannot inherit an env tag.** It is a
`Stream/Output/Audio` node linked straight to the speakers, so a broad selector *will*
pick it up unless explicitly excluded — and that is not a nicety:
> **Measured (3 arms, links verified in-graph before measuring, recorded via
> `parec -d <sink>.monitor` — the production path):** naive fan-out that includes the AEC
> leg copies remote-call audio into the share at **≈desktop level** (45.0 dB vs the
> 44.5 dB desktop tone). Exact exclusion produces **83.3 dB, matching the control floor
> to 0.1 dB.**
**What `module-echo-cancel` actually creates (RESULT 1):** four nodes, not two —
`Audio/Sink peerspeak_ec_sink.<pid>`, `Audio/Source peerspeak_ec_source.<pid>`,
`Stream/Output/Audio **echo-cancel-playback**` (passive, virtual), and
`Stream/Input/Audio echo-cancel-capture`. Codex's "Pulse-compat exposes only
`sink_properties`" concern is real about the *module args* and irrelevant to the *graph
props*.
**The identity (RESULT 2):** all four nodes carry `pulse.module.id` equal, byte for byte,
to the module index `pactl load-module` returned — the value peerspeak already stores in
`EchoCancelGuard::module_index` (`echo_cancel.rs:106-112`, a `String` validated as `u64`).
No PID guesswork, no name matching, no leg correlation.
```
exclude every node where pulse.module.id == <the index pactl returned to the live guard>
```
**⚠️ The five corrections. These are the design, not footnotes:**
1. **This is not an "identity contract."** It is an exact correlation *observed on
PipeWire 1.6.8*, not a documented API. No PipeWire source is installed on this box and
neither model had network, so whether the property is the *mechanism* pipewire-pulse
uses to reap module-created objects or a cosmetic mirror is **UNRESOLVED**.
**Runtime-validate and fail closed** (§5.3).
2. **"Has any `pulse.module.id`" is REJECTED as an exclusion rule.** Tunnel, RTP and
loopback modules may be the *only* carrier of audio the user legitimately wants
shared. **Exact equality with the owned live index — nothing weaker.**
3. **Module index and `node.link-group` are REUSED verbatim across unload/reload** (both
came back `536870919` / `echo-cancel-1974-13`), and **node IDs are recycled *and
reassigned across legs*** — id 136 was the playback leg on load 1 and the capture leg
on load 2. ⇒ **Never cache a node id. Never assume leg order. Never cache the module
index across an unload** — it is only trustworthy as "the index for the currently-live
guard," so pixelpass must be (re)told on every load.
4. **The `echo-cancel-` group prefix is hazard *detection*, not ownership.** Neither it
nor the fixed node name `echo-cancel-playback` identifies *peerspeak's* instance. A
foreign or second AEC is a product-policy question (§5.4). The better long-term answer
is the **native** PipeWire AEC module, which exposes `playback.props` and would let us
stamp our own random token directly on the playback leg.
5. **PID — the whole muddle, finally resolved** (third measurement, 2026-07-21, settles
four rounds of contradictory claims). Two *different properties on two different
object types* were being conflated:
| property | lives on | value for Pulse-emulated clients |
| --- | --- | --- |
| `application.process.id` | the **Node** | the app's **own** PID (Firefox `11114`, paplay's own) |
| `pipewire.sec.pid` | the **Client** | **pipewire-pulse's** PID — `2541` for *every* Pulse client on this box (Firefox, Steam, KDE Connect, libcanberra all identical) |
Module-created streams (the AEC) are the exception on the *node* side: their
`application.process.id` is pipewire-pulse's (`1974` at the time of that run), because
pipewire-pulse genuinely is the client. Earlier notes claiming `pipewire.sec.pid` was
"absent on these nodes" were right — it is absent from **nodes** because it is a
**Client** property. **PID-based AEC exclusion stays unusable**; PID is a fallback
hint, never identity. `client.id`*not* PID — is the usable per-app correlation key
(§6.1 edge type 3).
Parse defensively: `pulse.module.id` renders as a JSON **number** in `pw-dump` but SPA
props are strings. `536870919 = 0x20000007`; pipewire-pulse indices start at `0x20000000`
so they fit u32 — but this sits right next to the `object.serial` u32-truncation bug
(§10.1), so compare as strings or as `u64`, never as `u32`.
### 5.3 Fail closed — validation and revocation
Because the identity is an observed correlation rather than a contract, pixelpass must
**verify it at runtime and refuse to run the mode when it cannot.** A one-shot
"enumerate at start" check is *not* good enough — it races, in both directions:
- peerspeak's `enable()` returns once `wait_for_nodes` sees the virtual **source and
sink** by name (`echo_cancel.rs:132-158`, polling `pactl list sources/sinks short`).
It does **not** wait for the playback `Stream/Output/Audio` hazard leg, which is the
node we actually need to exclude.
- pixelpass's capture spawns **lazily, on first viewer** (`pixelpass/src/host/mod.rs:300-304`),
so enumeration happens at a moment peerspeak does not control, and a refusal surfaces
*after* the ticket has been handed out.
**So validation is a bounded state machine, not a check:**
```
NotConfigured ──(--aec=off)──────────────► fan-out proceeds, no AEC exclusion
Validating ──(--aec=pulse-module:<i>)─► enumerate after a registry sync barrier;
wait up to a bounded deadline for ≥1 node
with pulse.module.id == i
Validated ──► fan-out permitted, excluding that identity transitively (§6.1)
Failed ──► deadline expired, identity never observed ⇒ NO FAN-OUT
Revoked ──► the live module identity disappeared ⇒ STOP FAN-OUT NOW
```
- **No fan-out occurs in `Validating`.** Silence is the safe direction; echo is not.
- **`Failed` is fail-closed**: report a capability failure, do not silently share.
Fall back to a mode with no echo risk, or an explicit user override.
- **Revocation is loss of the live *module* identity** — *all* nodes bearing the index
gone — not the transient absence of one playback leg, which can cork or relink. Getting
this wrong turns a normal cork into a spurious share-wide audio stop.
- On `Revoked`, **drop the link proxies**; do not keep the numeric index and hope. The
index is reused (§5.2 correction 3), so a retained stale index can alias onto an
unrelated future module.
- **Never** infer exclusion from `node.name == "echo-cancel-playback"` alone — it is a
fixed, non-unique, trivially spoofable string. Usable only as belt-and-braces *after*
the exact match, or as foreign-instance hazard detection.
### 5.4 Foreign or second AEC instances — a product decision
If a node matching `node.link-group` prefix `echo-cancel-` exists that is **not** ours,
peerspeak is not the only echo canceller on the box. Options: (a) fail closed — refuse
the mode; (b) warn and exclude all `echo-cancel-*` groups, accepting that a legitimate
unrelated AEC's output silently won't be shared. **Recommendation: (b) with a visible
warning** — the failure it prevents (echo) is worse than the failure it causes (one
app's audio missing), and it matches §6's overall posture. **Open decision D3 (§13).**
## 6. Eligibility — a broad guarded selector
Under a *move* design, mis-selection rewired the user's desktop, which forced a narrow
allowlist. Under copy, mis-selection costs at most one stream's capture — so the narrow
allowlist is out. But "fan out everything" is still wrong: it recreates self-echo, it
recreates call echo, it can build cycles, and a second link participates in format/buffer
negotiation.
### 6.1 ⚠️ Eligibility is a GRAPH property, not a node property
**This is the single biggest correction in round 4, and it is reachable on the target
machine, not hypothetical.**
A node-local property check is unsound, because **exclusion does not propagate downstream.**
If any node re-emits audio it received, the re-emitting node is a fresh, *untagged*
`Stream/Output/Audio` carrying the mix — including peerspeak's playback and the AEC leg,
both of which were correctly excluded one hop earlier.
### 6.1.0 The hazard is REACHABLE on this machine — it was live when measured
⚠️ **Round-8 correction to this heading, and it is the point of the correction.** v3.4 said
"🔴 LIVE on this machine **right now**." That was true at 2026-07-21 ~09:00 and **false by
~14:55 the same day** — six hours later the default sink was
`alsa_output.pci-0000_10_00.6.analog-stereo` (IDLE), all three `sink-sunshine-*` null sinks
SUSPENDED, with Sunshine still running the whole time. So the topology appears when
**Sunshine actually routes desktop audio through its null-sink chain**, which is strictly
narrower than "Sunshine is running."
**Nothing may gate on this topology being present.** The reachability argument below is
unaffected and stands: a stock third-party application puts a virtual-sink forwarder in the
default audio path on an ordinary desktop, no EasyEffects required. But as a *test* input it
is opportunistic only — the controlled `module-null-sink` + `module-loopback` fixture is the
authoritative one because it is deterministic and always available.
Measured 2026-07-21 ~09:00. **The user's default sink was not hardware.** `pactl info`
reported:
```
Default Sink: sink-sunshine-stereo ← factory.name = support.null-audio-sink
```
All three real hardware sinks (`Arctis_1_Wireless`, `pci-…analog-stereo`,
`…hdmi-stereo-extra3`) are **SUSPENDED**; the only `RUNNING` sink is Sunshine's virtual
one. The live graph is:
```
Firefox:output_{FL,FR} ──► sink-sunshine-stereo:playback_{FL,FR} (a null sink)
sink-sunshine-stereo:monitor_{FL,FR} ──► sunshine:input_{FL,FR}
```
That is **exactly the forwarder shape** this section is about, active in the default audio
path, with no EasyEffects involved. Consequences:
1. **Reachability is settled.** Earlier rounds argued from "EasyEffects is installed";
the real machine is already running a virtual-sink forwarder topology full time.
peerspeak's own playback would land in `sink-sunshine-stereo` and its monitor.
2. **§6.5's "hardware-sink-only" shortcut wouldn't just leak — it would capture
NOTHING here**, because no application links to a hardware sink at all.
3. Sunshine happens to have **only** a `Stream/Input/Audio` leg (id 168) — it encodes and
sends over the network rather than re-emitting locally — so it is not itself a fan-out
leak source. The topology, not this particular app, is the point.
4. §10 item 2 (`@DEFAULT_SINK@` resolved once) is worse than described: the default sink
here is a *transient app-owned null sink* that appears and disappears with Sunshine.
**Separate item this raised for peerspeak itself — RESOLVED for this machine, kept as a
low-priority general defect** (not part of this feature): `echo_cancel::enable` appends
`sink_master=` / `source_master=` only when the configured device is `Some` and non-empty
(`echo_cancel.rs:89-94`); otherwise `module-echo-cancel` binds to whatever PipeWire calls
the default. On a box where an application owns the default sink — as Sunshine does here —
that means the AEC would bind to a null sink.
**Not live for this user:** `~/.config/peerspeak/config.json` pins
`output_device = alsa_output.usb-SteelSeries_…Arctis_1_Wireless-00.analog-stereo` and
`input_device = …mono-fallback`, so `sink_master` is always passed explicitly and the AEC
binds to the Arctis regardless of the default. The exposure exists only for a user on
"system default". Possible hardening (own task, not this feature): resolve and validate
the default sink before load, and refuse or warn when it is a null/virtual sink.
The canonical *installed* instance: **EasyEffects.** When it is running, applications play into
`ee_sink` and EasyEffects emits a single combined output stream to the hardware. That
output leg has no `peerspeak.owned` tag and no `pulse.module.id` matching our AEC, so
**both §5.1 and §5.2 pass it**, and fanning it out re-injects the entire call into the
share at full level — the exact bug this feature exists to prevent, arriving through the
front door. ⚠️ **`easyeffects` is installed on this machine** (`pacman -Qq easyeffects`),
and `libpipewire-module-filter-chain.so` is present. It happened not to be running during
the spike, which is *why* the spike did not catch this. Same shape: combine-sinks,
`module-loopback`, virtual-sink forwarders, tunnel/RTP sinks, DSP bridges, and any
monitor-sourced stream.
**So the predicate is transitive TAINT over the live SIGNAL graph** — and "signal graph"
is doing real work in that sentence, because it is **not** the graph of `Link` objects.
> A node is eligible only if **no** signal path reaches it from a peerspeak-owned node,
> the live AEC identity, or any pixelpass-owned object (ours *or* another host's
> `pixelpass_capture_*`). Unresolvable ancestry is **not** eligible.
⚠️ **A Link-only walk does NOT catch the leak.** Measured on the live graph 2026-07-21
by reproducing the forwarder topology with `module-null-sink` + `module-loopback`
(same shape as EasyEffects, no EasyEffects required):
```
fabletest_sink:monitor_FL ──Link 87──► input.loopback-2541-13 (Stream/Input/Audio, id 109)
⋮ ← NO LINK OBJECT EXISTS HERE
output.loopback-2541-13 ──Link 105─► alsa_output…analog-stereo (id 56)
(Stream/Output/Audio, id 86)
```
**There is no `Link` between the forwarder's input leg (109) and its output leg (86).**
Walking upstream from the leaking node 86 over Links alone finds *no inbound links at
all* — a dead end that reads as "clean," and the node gets fanned out. The two legs are
related only by shared properties:
| key | input leg (109) | output leg (86) |
| --- | --- | --- |
| `node.link-group` | `loopback-2541-13` | `loopback-2541-13` |
| `client.id` | `107` | `107` |
| `pulse.module.id` | `536870917` | `536870917` |
So the signal graph needs **three** edge types:
1. **Link edges**`link.output.node → link.input.node`. ✅ Measured: registry Link
objects carry all four endpoint props (`link.output.node`, `link.output.port`,
`link.input.node`, `link.input.port`).
2. **Sink-monitor edges** — ✅ **free at node granularity.** Measured: the monitor
connection *is* a real `Link` whose output node is the sink node itself
(`out-node=104 (fabletest_sink) → in-node=109`). So a node-level walk crosses
`app → sink → monitor-reader` with no synthetic edge. A **port**-granular walk *does*
need an explicit rule (inputs feed `port.monitor=true` outputs); a node-granular one
does not. **Use node granularity for taint; use ports only for link creation.**
⚠️ **Accepted cost — duplex over-taint.** Node granularity smears taint across
multi-role nodes: an `Audio/Duplex` node whose playback side is tainted will have its
capture side treated as tainted too. That is **fail-closed** (over-exclusion, not a
leak) and is accepted for v1, but it can contradict the "Firefox with a microphone
stays shareable" promise in §6.1.1 on a duplex device. Port-modelling `Audio/Duplex`
and unknown-role nodes is the fix if this bites in field testing.
3. **Owner-bridge edges** — the intra-process hop the graph cannot see. This one is
genuinely hard; see §6.1.2. A single key does **not** work.
### 6.1.2 ⚠️ The owner bridge — no single key works (measured refutation)
**I claimed `client.id` was the fallback bridge key for apps without a `link-group`.
I disproved my own claim; Codex independently reached the same verdict.**
Test: one `gst-launch-1.0 pulsesrc device=fable_src.monitor ! audioconvert ! pulsesink
device=fable_dst` process — one PID, an input leg and an output leg, the exact
OBS/recorder/forwarder shape, and the same framework pixelpass itself uses.
| leg | `client.id` | `node.link-group` | `application.process.id` |
| --- | --- | --- | --- |
| `Stream/Input/Audio` | **209** | null | 20172 |
| `Stream/Output/Audio` | **210** | null | 20172 |
**Two distinct client objects for one process**, both named `gst-launch-1.0`, both with
`pipewire.sec.pid=2541`. So `client.id` bridges *a client connection*, not *an owner*
an app is free to open one connection per stream, and GStreamer does exactly that. The
earlier "libcanberra appears as clients 74 and 77" observation was the same signal and I
under-weighted it.
**Replacement: a conservative union of owner keys, strongest first.**
⚠️ **Wording trap — "resolves" means "yields a MATCH between the two legs," NOT "is the
first property present on the node."** A naive first-present implementation reproduces the
exact bug: in the gst-launch case `client.id` **is present on both legs** (209 and 210), so
first-present stops at key 3, finds the values differ, and concludes "different owners —
not bridged." The leak survives. The rule is: *try each key in order; a key resolves only
if both legs carry it and the values are equal; otherwise fall through to the next key.*
This must be an explicit unit test (§12).
| # | key | scope | notes |
| --- | --- | --- | --- |
| 1 | `node.link-group` | per module/filter instance | precise; set by PipeWire modules (loopback, echo-cancel, filter-chain) |
| 2 | `pulse.module.id` | per pactl module | precise; module-created streams only |
| 3 | `client.id` | per **connection** | correct when an app uses one connection; insufficient alone |
| 4 | `application.process.id` (on the **Node**) | per process | ✅ the only key that bridged the gst-launch legs (`20172` on both) |
| — | else | — | **unresolved ⇒ fail closed** (exclude the output leg) |
⚠️ **Key 4 has a trap that must be coded explicitly.** For *module-created* streams,
`application.process.id` is **pipewire-pulse's own PID** (§5.2 correction 5). Bridging on
it would fuse *every* Pulse module's legs into one owner, so a single tainted module input
would exclude every module-created stream on the box — mass over-exclusion, and exactly
the failure correction 2 warned about (a tunnel/RTP module may be the sole carrier of
audio the user wants shared). **So: never bridge on key 4 when the value equals the
pipewire-pulse PID.** Those cases are already covered precisely by keys 1 and 2.
Note the irony worth recording: PID returns to the design, but in a different *role*.
It is unusable as **identity** ("is this peerspeak's audio?") and workable as
**correlation** ("are these two legs the same app?") — and in the correlation role a
wrong answer fails closed rather than leaking.
**How pixelpass learns the pipewire-pulse PID** (needed for the key-4 exception). It must
derive this itself; it cannot assume a value, and peerspeak can supply only a *hint*:
- Read `pipewire.sec.pid` from the **Client** objects of Pulse-emulated streams. Measured:
it is `2541` for Firefox, Steam, KDE Connect, sunshine and libcanberra alike, while each
node's own `application.process.id` differs (Firefox `11114`, sunshine `4119`).
- ~~Require a **single consistent** value across those clients~~ and validate it by reading
`/proc/<pid>/comm` (or cmdline) and confirming it is `pipewire-pulse`.
- "This PID owns implausibly many unrelated streams" is a **diagnostic**, never
correctness logic.
> #### 🔴 Round 10 (MEASURED, phase-5 run 2): "a repeated `sec_pid`" does not identify pulse
>
> The struck rule above was implemented as *the single `sec_pid` shared by two or more
> Clients*, on the reasoning that native clients each carry their own distinct PID so only the
> Pulse shim repeats a value. **Measured on this host: WirePlumber repeats one too** — it holds
> two Clients, `WirePlumber` and `WirePlumber [export]`, both `sec_pid` 1747. Two values
> repeated, "single consistent" was unsatisfiable, and the derivation returned `None`
> **permanently, on a stock desktop**.
>
> The consequence was not a missing optimisation. With the daemon PID unknown the key-4
> exception never fires, every Pulse-emulated node fuses into one owner, and the result is the
> machine-wide over-exclusion cascade of phase 5's F2 — reached again from a new cause, and
> caught again only by the §5.1 requirement to assert the **eligible** half of a row.
>
> The rule failed in both directions, so the repetition test is **deleted** rather than
> tightened:
>
> - **False ambiguity** — any second process holding two Clients defeats it, and WirePlumber
> always does.
> - **False absence** — a session in which pipewire-pulse holds exactly one Client (one Pulse
> app running) repeats nothing at all, so the candidate is never even considered.
>
> `comm` was always the authoritative check; repetition was a heuristic standing in front of it,
> and what it actually encoded was an assumption about *other* processes' Client counts.
> **The rule is now: every distinct `pipewire.sec.pid` is a candidate; the daemon is the unique
> one whose `/proc/<pid>/comm` is exactly `pipewire-pulse`.** Zero matches ⇒ `None` (nothing we
> can prove to suppress). **Several** matches ⇒ also `None`: two live pipewire-pulse daemons (a
> nested or sandboxed session) cannot both be suppressed by a single `Option<u32>`, and failing
> closed there lands on the over-exclusion side, consistent with the failure-mode paragraph
> below. Suppressing a *set* of daemon PIDs is the real answer if a multi-daemon host ever turns
> up; it is out of v1 and recorded rather than silently approximated.
>
> The lesson generalises past this key: **a property of the objects we are trying to identify is
> evidence; a property of everyone else's object count is a guess.** The `/proc` read was already
> there and already authoritative — the heuristic in front of it only added a way to be wrong.
Failure modes: if pixelpass fails to identify the real pipewire-pulse PID, the result is
broad **over-exclusion** (annoying, safe). If it wrongly suppresses a genuine app PID, the
result is over-exclusion **for that app** — safe *only* because unresolved ancestry is
fail-closed. If unresolved ancestry were ever implemented fail-open, both of these become
leaks. That is the invariant holding this whole section up.
### 6.1.1 ⚠️ Bridge taint must be conditional, or it over-excludes badly
The owner bridge must propagate taint **only when the input leg is itself tainted.**
The naive rule "this client has both an input and an output leg ⇒ exclude the output" is
catastrophic: it excludes any app using a microphone. Firefox in a video call has both
legs, and its playback is perfectly shareable.
The correct rule:
1. Taint roots: peerspeak-owned nodes, live AEC identity, pixelpass-owned objects.
2. Taint flows downstream along Link edges (crossing sinks and monitors for free, §6.1
edge type 2). A sink carrying tainted audio has a **tainted monitor**.
3. A `Stream/Input/Audio` reading a tainted monitor becomes tainted.
4. **Only then** does the owner bridge carry taint to that client's `Stream/Output/Audio`
legs.
Result, both cases correct:
| scenario | outcome |
| --- | --- |
| Firefox playing music (output leg only) | **eligible** — shared |
| Firefox in a Meet call (mic input leg, untainted source) | **eligible** — shared |
| Firefox screen-sharing *with desktop audio* (input leg on a tainted monitor) | **excluded** |
| EasyEffects / loopback / combine-sink forwarding a mix containing peerspeak | **excluded** |
Where an owner has a tainted input leg and an output leg with **no** resolvable bridge
(§6.1.2), fail closed and exclude the output leg. This only ever engages for owners
actually reading a tainted monitor, so the blast radius is small.
> **⚠️ Round 13 (F11-1) — "bounded" is not "has a key". A self-claimed pid is not
> provenance.** Which legs this backstop sweeps depends on whether the tainted reader and
> the candidate outputs are *bounded* — i.e. whether we could enumerate their sibling legs
> and be right. Key 4 is a union of the node's `application.process.id` (client-controlled,
> optional) and its Client's `pipewire.sec.pid` (protected), so a node could bound itself
> with a value it invented and escape the sweep while its real sibling was unfindable.
>
> **Rule:** a strong key (`node.link-group`, `pulse.module.id`) bounds an owner on its own;
> 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. The
> ordering is load-bearing in both directions: read after suppression and every
> Pulse-emulated app on the box goes unbounded (§6.1.1 catastrophe, new door); accept "a
> unique Client object exists" instead of a `sec_pid` and a pid-less Client leaves the hole
> open.
>
> **Bridging is unchanged** — it still uses the full union, because a self-claimed pid is
> perfectly good *evidence that two legs are related*, which is the taint-increasing
> direction. Only the permission to declare a differently-keyed output "provably someone
> else" now demands a `pipewire.*` answer to "who is this".
>
> Measured cost on this host: **zero** — before/after binaries audited the same live graph
> simultaneously, same 14 decision states, no `unresolved-owner` on either side, eligible
> half non-empty. Implemented in pixelpass `c78eb2d`; five-case Client matrix in the tests.
### 6.1.3 ⚠️ Taint must be STICKY — current topology is not enough
**Conceded to Codex in round 6; my "conditional bridge" was correct about topology and
wrong about time.** Taint computed from the *current* graph forgets **buffered audio**.
Failure: an app reads a tainted monitor, buffers or delays (a recorder with a 5 s ring
buffer, a DAW with latency compensation, anything doing lookahead), and then its input leg
untaints or disappears — the user stops sharing to it, the stream corks, the app closes
the capture. A purely topological recompute now sees a clean input, un-taints the owner,
and pixelpass **relinks the output leg while it is still emitting peerspeak audio from
the buffer.** No graph event marks the moment the buffer drains.
**Rule: taint is sticky per owner for the duration of the share.** Once an owner is
tainted it stays tainted until its client/output nodes disappear. Re-eligibility requires
teardown, not a topology change. A timed drain is strictly weaker and would need
measurement to justify; do not ship one in v1.
⚠️ **Stickiness must be lifetime-aware, never keyed on a raw recyclable id.** `client.id`,
node ids, module indices, `node.link-group` values and PIDs **all recycle on this stack**
measured directly for module indices and node ids (§5.2 correction 3), and the same
`link-group` string came back verbatim across an unload/reload. If sticky taint were stored
against a bare key, a later unrelated app inheriting that recycled id would inherit the
taint and be silently excluded forever.
Store stickiness against a **live owner component** — the concrete set of node/client
objects observed to form that owner — and clear it only once **all** member objects have
disappeared. A key that reappears after full teardown is a new owner and starts clean.
The cost is that an app which *once* read a tainted monitor stays unshared for the rest of
the share. That is the right trade — it is silence for one app, versus echo for everyone.
### 6.1.4 The transition window is real but bounded
Taint can also *arrive*: a peer joins and speaks, or the AEC loads, mid-share. Recompute
on every graph event and dropping links for newly tainted nodes is **necessary but not
zero-leak**, because a topology change races audio already in flight.
Order of magnitude on this box: a graph quantum is ~`512/48000``1024/48000`**≈10.621.3
ms**, plus main-loop scheduling. App-internal buffers are unbounded in principle. So the
worst case is a sub-quantum-to-tens-of-ms audible sliver at the transition.
⚠️ Mitigating structure: if peerspeak's playback **roots already exist** in the graph and
merely become non-silent (the common case — the AEC and call playback nodes are created at
join, long before anyone speaks), then topological taint has **already** excluded
everything downstream and there is no window at all. The window exists only when a taint
root is *newly created* mid-share.
⚠️ **Round-8 correction: v3.4 nominated an unreachable test case here** ("that makes
AEC-load-mid-share the case to test"), **and so did its first replacement.** The conclusion
above is unaffected; only the example was wrong.
- *AEC-load-mid-share is unreachable.* There is exactly one `echo_cancel::enable` site, at
session join (`core/mod.rs:1850`); the guard moves into `ActiveSession` (`:2729`); and
`StartScreenShare` rejects `active_session == None` (`:3397-3404`). The AEC always
predates the share.
- *"A peer joins and their playback node is created" is also unreachable.* peerspeak starts
**one mixed playback stream** at session construction (`core/mod.rs:1900`); `PeerJoined`
(`:2396-2409`) admits and connects the sender and creates no per-peer node.
**The reachable newly-created mid-share taint roots are: a notification sound played
mid-share** (`notify.rs:265-272`) **and starting to view another share mid-share**, which
spawns a tagged mpv/VLC (`screenshare/mod.rs:768-775`). Those are the transition-window
field tests. Owned-AEC mid-share load stays a **synthetic** test until a second `enable`
site or a hot reload arms it.
This means pixelpass must **track Node, Port and Link globals** — not just node
global-add, which is all the existing router does (`pixelpass/src/host/audio.rs:523-584`
subscribes to global-add/global-remove and never binds links). That is a materially
larger component than v3.0 implied.
Ancestry that cannot be resolved (unknown forwarder shape, unresolvable monitor source)
is **not** eligible. That is a deliberate reversal of v3.0's "unrecognized ⇒ fan out":
E2 makes mis-selection cheap *for the user's audio graph*, but it is not cheap for
**echo**, and echo is the whole feature.
### 6.2 The predicate
**Start from all `Stream/Output/Audio` nodes, then exclude:**
| Exclusion | Basis |
| --- | --- |
| `peerspeak.owned` present | §5.1 env tag |
| `pulse.module.id` == the live AEC index | §5.2, exact equality only |
| **tainted via the signal graph** (Link + monitor + conditional owner bridge) | §6.1 — the load-bearing one |
| pixelpass-owned objects, incl. **other hosts'** `pixelpass_capture_*` ancestry | cycle prevention |
| tainted input leg + output leg with no resolvable bridge | §6.1.1, fail closed |
| `port.exclusive` ports, encoded/passthrough streams | fan-out will refuse or corrupt |
| links we already own for that node | idempotence |
- `node.dont-move` **drops out entirely** — fan-out is not a metadata move.
- Links are created **per port**; channel-count and layout mismatches are per-port.
### 6.3 Dynamic graph handling — revalidate, don't fire-and-forget
A node can appear with incomplete ancestry, pass the predicate, get fanned out, and only
*then* receive an inbound link from a filter input or `pixelpass_capture_<pid>.monitor`.
An add-only listener never sees it. Required:
1. An **initial registry sync barrier** — register the listener, call `Core::sync()`, wait
for `done`, and only then consider the initial pass complete.
⚠️ **Know what this barrier is and is not.** PipeWire's `core.h:261` defines sync as a
*previous-work roundtrip*: because methods and events are ordered, `done` means
"everything emitted so far has been handled." It is **not** graph quiescence and does
not promise the graph has stopped changing. Readiness therefore = `done` **plus** "no
unresolved required Link/Port observations outstanding," with a fail-closed timeout.
2. Candidates stay **pending** until their ports *and* current inbound links are known.
3. **Revalidate immediately before creating each Link**, not just at selection time.
4. On any later link add/remove that makes ancestry unsafe, **drop the owned link
proxies** for the affected node — retention (§4.2) is what makes revocation possible.
### 6.4 Implementation shape and cost
`pipewire` crate **0.9.2** (`pixelpass/Cargo.toml:48`, `Cargo.lock:4121`).
- **Endpoint props are an OPTIMIZATION, not the correctness path** (settled in round 6).
Registry `Link` globals carry all four endpoint keys
(`/usr/include/pipewire-0.3/pipewire/keys.h:256`) and they were observed present on
every live Link — but the observation was via `pw-dump`, which binds objects, and
neither the headers nor the crate prove those keys are *always* present in the
**global-add** props. So: use them when present, and **rely on the bind fallback for
correctness.** A raw global-add log in the implementation spike settles current
behaviour, but the fallback must exist regardless.
- **Fallback:** bind that Link, install an info listener, read the endpoint ids off
`LinkInfoRef` (`pipewire-0.9.2/src/link.rs:153` exposes all four without params), then
**drop the observer proxy**. Retained proxies are needed only for *our own* fan-out
links, where dropping is the ownership/lifecycle mechanism (§4.2) — not for external
links we merely observe.
⚠️ **Round 8: measured, the Link endpoint props are always present** — this fallback is
correctness insurance that never fires on this host. The bullet above it is still right;
it was just under-applied. See **§6.7**.
- 🔴 **Node and Device props are NOT AVAILABLE from the global at all** — not an
optimisation, not usually-present, never. Binding each Node and Device is the **only**
path to `peerspeak.owned`, `pulse.module.id`, `node.link-group`,
`application.process.id`, `node.passthrough`, `factory.name`, `device.api` and
`alsa.driver_name`. **§6.7** is the whole story; it is the round-8 finding and it
supersedes any reading of this section that treats node props as observable off the
registry.
- **Readiness epoch.** Start fan-out only when: initial `core.sync`/`done` received **and**
every required Link/Port/Client/Node observer fallback has resolved or hit a fail-closed
timeout. Then **revalidate in the same graph epoch immediately before creating each
owned link** — a stale decision from a previous epoch is not a decision.
- **Full recompute per graph event is fine for v1.** Rebuild taint by BFS/DFS from the
hazard roots, O(V+E). A typical desktop graph is ~40 nodes / ~120 links; this is
sub-millisecond-class in Rust. No incremental dirty-set in v1; coalesce bursts only if
logs show churn.
### 6.5 Rejected: the "hardware-sink-only" shortcut
Considered and **rejected**: "fan out only nodes whose output links to a real hardware
sink, excluding anything feeding a virtual/null sink." It is cheaper and it is **unsound
for exactly the case that matters** — measured: `output.loopback-2541-13` links *directly*
to `alsa_output.pci-0000_10_00.6.analog-stereo`. The forwarder's output leg **is** a
hardware-linked node, so the shortcut passes it and leaks the mix. The original app stream
would meanwhile be excluded for feeding the virtual sink — i.e. the shortcut gets both
halves backwards.
It survives only as an optional degraded fallback mode whose stated behaviour is "no
EasyEffects/filter/loopback-routed audio is captured at all."
### 6.6 Foreign/hazard nodes
Treat an unvalidated `echo-cancel-*` output node as an excluded hazard rather than an
eligible stream (§5.4).
### 6.7 🔴 The observation boundary — registry props are a FILTERED SUBSET (round 8, MEASURED)
**This is the finding that failed the phase-5 gate**, and it is the one place v3.4 was
materially wrong. Full evidence: `docs/screenshare-audio-exclusion-phase5-results.md` (F1).
**The fact.** The registry `global` event does **not** carry an object's properties. It
carries a small fixed subset the daemon copies onto the global. Measured on PipeWire 1.6.8 /
WirePlumber 0.5.15, the complete union of keys announced for a `Node` global across every
node on the box is:
```
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
```
Eight properties this design depends on are **never** announced, and a props lookup for them
returns absent rather than failing — so an implementation reading them off the global is
silently, permanently wrong:
| property | what dies without it |
| --- | --- |
| `peerspeak.owned` | **the primary taint root** — §5.1, all of phase 1 |
| `pulse.module.id` | **AEC identity exclusion** — §5.2 and the §5.3 validator |
| `node.link-group` | owner key 1 — echo-cancel, EasyEffects, loopback siblings (§6.1.2) |
| `application.process.id` | owner key 4 — the measured GStreamer split-client case (§6.1.2) |
| `node.passthrough` | the passthrough exclusion (§6.2) |
| `device.api` | `session_device` classification |
| `factory.name` | `session_device` classification — the discriminator itself |
| `alsa.driver_name` | `session_device` classification — the `snd_aloop` denylist |
`port.exclusive` is likewise absent from `Port` globals, so that §6.2 exclusion never fires.
**Links and Clients are unaffected** — every endpoint key and `pipewire.sec.pid` is announced,
so §6.1.2's pulse-PID derivation works as designed.
The consequence measured live was not degradation but inversion: with `peerspeak.owned`
invisible the tagged sink is **not a taint root at all** (an echo, with AEC off), while the
one root that still fires — the `pixelpass_capture_*` name prefix, `node.name` being
announced — leaves every candidate with no *strong* owner key, which trips §6.1.1's
fail-closed backstop and excludes every stream on the machine. Both halves broken, in
opposite directions, from one cause.
**The rule (round 8).**
> **A node's properties come from a bind, never from the global.** The registry global is an
> *index* — it tells us an object exists, its id, its serial, its media class and its name —
> and nothing it says about ownership, grouping or hardware identity may be trusted or even
> read. Every `Node` global is bound and its `info` props are the sole source of node
> properties. Same for `Device`.
Verified recoverable by bind on the same objects that lack them from the registry:
`factory.name = api.alsa.pcm.sink`, `device.api = alsa`, `alsa.driver_name = snd_usb_audio`,
`peerspeak.owned = true`, `pulse.module.id`, `node.link-group = loopback-2528-13`,
`application.process.id`.
Four decisions fix the shape (user's calls, 2026-07-25):
1. **Bind every `Node` global, unconditionally** — no filtering by `media.class`. Deciding
which nodes matter *before* their props exist is the same class of mistake as reading the
props off the global, and a node skipped for looking irrelevant has no owner keys, which
is exactly the unbounded-reader condition that produced the machine-wide cascade. Cost is
~14 nodes at rest on this box.
2. **Track prop changes for the node's lifetime.** `info` fires again with `PROPS` set in
`change_mask`; re-read, re-classify, re-emit. A one-shot read (the Link-fallback pattern)
would miss a `node.link-group` or `pulse.module.id` set after node creation. Measured
cheap: one change event in 8 s at rest.
3. **One readiness obligation per unbound node**, and a node with no `info` yet is **withheld
from the snapshot entirely** — never admitted with provisional ownership (the §6.1.3 rule,
already applied to device-unresolved nodes). If a bind never resolves, readiness goes
sticky-`TimedOut` and there is no fan-out at all: identical to a never-resolving Link
bind, one rule for both. ⚠️ *Owed hardening:* per-node quarantine (that node ineligible
**and** taint-bearing, the rest of the graph still working) is strictly better and is
deferred because it is a new concept in the pure engine, not a fix to the observer.
4. **`session_device` reads from both.** `factory.name` exists only on the node, so the node
bind is required regardless. But `device.api` **and** `alsa.driver_name` are on the bound
`Device`'s `info` props (measured — they are absent from the Device *global*, which is
what the phase-5 results file checked). Reading the ALSA driver from the backing Device is
authoritative and closes the phase-3 review's owed fix: on PipeWire ≥ 1.2.6 with
WirePlumber < 0.5.13 the driver name is not copied to the node, and the fail-closed
"absent driver ⇒ not a session device" rule would over-exclude real cards.
**Why v3.4 missed it, which is the transferable lesson.** Round 6 settled the *same question*
for Links and got it right — "endpoint props are an OPTIMISATION, the bind is the correctness
path" (§6.4) — on the explicit reasoning that `pw-dump` binds, so observing a key via
`pw-dump` says nothing about whether the **global-add** carries it. Every property in the
table above was likewise confirmed via `pw-dump`. The doubt was correctly formed and then
applied to exactly one object type. The general form: **`pw-dump` is a bound view; the
registry is not, and the difference is silent.**
Two things this does *not* change: the architecture (Option C, taint as a graph property, the
owner-key union, sticky taint — all vindicated, and the fixtures that fed them correct props
produced correct answers), and the Link/Client observation path.
⚠️ **O5 must be re-measured.** The phase-5 numbers (max 15 µs recompute, `busy_fraction`
0.0004) were taken on the graph this defect produces and do not include per-node bind I/O.
### 6.8 🔴 Uncertainty is not history — what stickiness may remember (round 9, MEASURED)
**Found by the phase-5 audit on the live graph within minutes of §6.7's fix landing**, which
is the strongest available argument for the dry-run phase existing at all.
**The observation.** With phase 3r running, a real hardware sink
(`alsa_output.usb-…Arctis_1_Wireless…`) carried `unresolved-ancestry` **permanently** — the
mark survived the readiness epoch, 21 recomputes and deliberate module churn. Traced to a
single event during enumeration: a link was observed while its output node was still
unbound, so §6.1.4's fail-closed rule correctly raised `UnresolvedAncestry` on the input
side. That mark was then written into sticky state, and §6.1.3 retires a sticky entry only
when **every member object is absent** — which a live sound card never is.
**Why round 8 made it systematic rather than rare.** Under §6.7 every node is withheld until
its bind resolves, so *any* link observed across that gap raises unresolved ancestry. It
fires at startup, every startup, on whichever node happens to lose the race.
**The rule (round 9).**
> **Sticky taint is a claim about history, and uncertainty is not history.** A node tainted
> only because the graph could not be seen has had nothing observed about it. Decisions
> still fail closed on it — that is unchanged and non-negotiable — but nothing about it may
> be *remembered* once the uncertainty is gone.
**Retiring by reason code is not sufficient, and this is the load-bearing part.** Uncertainty
launders itself: an unresolved node propagates `TaintedUpstream` to everything downstream,
and that reason is indistinguishable from real contamination once recorded. The split must
be by **provenance**, so the engine runs its fixpoint twice per recompute:
| pass | uncertainty roots | consumed by |
| --- | --- | --- |
| fail-closed | raised (`UnresolvedAncestry`, `UnresolvedOwner`) | **every decision** — unchanged from v3.5 |
| evidence-only | never raised, so nothing derived from one exists | **sticky state, and only sticky state** |
Evidence-based taint — `peerspeak.owned`, the AEC identity, pixelpass-owned objects, a
foreign echo canceller, and anything propagated from them — keeps §6.1.3's absence rule
exactly as written. That is what stops an app buffering the call and laundering itself
through a teardown, and it is unaffected by this change.
Cost: two fixpoints per graph event. Measured 80 µs worst case against a 47 Hz event rate,
so the O5 headroom absorbs it without argument.
⚠️ **Owed, from the round-9 review (Codex, P1 "worth checking"): hardware
playback-to-capture paths.** A card offering "Stereo Mix" / "Digital Loopback" presents an
ordinary driver name (`snd_hda_intel`), so both its sink and its source classify
`session_device` — and audio written to the sink reappears on the source through a hop the
Link graph cannot see. This is the `snd_aloop` hazard (§6.1.1, phase-3 review finding 2) in
a form the driver denylist cannot detect. It is **not new in round 9** and not introduced by
either recent round; distinguishing it needs ALSA control inspection, a new I/O surface and
therefore a design decision. Until then a card with that path enabled can carry the call
from sink to source untainted, and a capture app reading it can re-emit: **echo**.
⚠️ **Also owed: a calibration argument for the readiness budget.** The observer times out
after 2 s and `TimedOut` is sticky by design, so a process that never sees one
obligation-free instant during initial enumeration is silent for its lifetime. Measured on
this host: readiness at ~3 ms with 19 binds. The margin is three orders of magnitude, which
is an argument, but it is one measurement on one idle desktop.
## 7. Lifecycle and teardown invariants
### 7.1 ⚠️ The invariant
> **The AEC module must not unload while pixelpass is alive and fanning out.**
If it does, the index pixelpass holds becomes stale, and — because indices are reused
(§5.2 correction 3) — it can alias onto an unrelated future module, silently un-excluding
the real hazard or excluding innocent audio.
### 7.2 ⚠️ VERIFIED DEFECT — implicit-drop order is inverted (not yet fixed)
`ActiveSession` (`peerspeak/src/core/mod.rs:668-691`) declares:
```rust
echo_cancel: Option<EchoCancelGuard>, // :682
screenshare_host: Option<tokio::process::Child>, // :685 (kill_on_drop)
```
Rust drops fields in **declaration order**. On the **implicit-drop** path — the command
channels close at `core/mod.rs:1512` (`reliable_rx.recv()` returns `None``break`), or
the core loop unwinds — `ActiveSession` is dropped **without** `shutdown()` running. So
`echo_cancel` unloads (a blocking `pactl unload`) *before* `screenshare_host`'s
`kill_on_drop` even fires: **the AEC unloads while pixelpass is still alive.** Exactly the
ordering the invariant forbids. Verified in source.
`shutdown()` (`:694-730`) gets it right — it kills `screenshare_host` at `:699` and drops
`echo_cancel` at `:730` — but only as an emergent property of statement order, which any
refactor can silently invert.
**⚠️ Reordering the fields is NOT sufficient** (round-4 correction). `screenshare_host`
is spawned with `kill_on_drop(true)` (`peerspeak/src/screenshare/mod.rs:402`). Tokio's
`Drop` for such a `Child` *sends* the kill and hands the process to a best-effort orphan
reaper — it does **not** wait, and gives no promptness guarantee, especially if the
runtime is itself shutting down. Explicit `child.kill().await` is SIGKILL **plus wait**;
implicit drop is SIGKILL and move on. So even with `echo_cancel` declared last, the
`pactl unload` can still run while pixelpass is briefly alive.
**Fix, in order of importance:**
1. **Eliminate the implicit path.** At both channel-close `break` sites
(`core/mod.rs:1516` and `:1532`), explicitly `take()` the session and
`shutdown().await` it rather than letting it drop. The ordered teardown should be the
*only* teardown.
2. **Move `echo_cancel` to the last declared field** anyway, with a comment naming the
invariant — defence in depth for paths 1 does not cover (panics, unwinds).
3. **Last-ditch ordering in the drop path**: a wrapper whose `Drop` does `start_kill` +
a bounded `try_wait` loop on the host before the AEC guard unloads.
**D4 regression guard** (concrete, replacing v3.0's vague "field-order assertion"): a
test using a **fake host** and **fake echo-cancel guard** that each record a timestamped
event into a shared slot on teardown, asserting `host kill+wait completed` strictly
precedes `echo unload` — plus a core-loop test that closes the command channel and proves
`shutdown()` actually ran. A field-order assertion alone does not test the behaviour that
matters.
### 7.3 Construction paths
There is exactly **one** `echo_cancel::enable` call site (`core/mod.rs:1850`, at session
join), the guard moves into `ActiveSession`, and there is exactly **one** explicit drop
(`:730`). **No mid-call AEC reload path exists.** This is what downgraded Codex's demanded
QUIESCE/SET_AEC/RESUME two-process epoch protocol to:
> **P1-impact latent hazard, currently unreachable in normal operation.** Required now:
> encode teardown ordering for explicit *and* implicit destruction, prevent or review
> additional AEC construction paths, fail closed if identity is missing or revoked.
> **An epoch protocol becomes required if and only if hot AEC reload is added.**
Anyone adding a second `enable` site, or any hot-reload, re-opens that requirement.
### 7.4 Graceful stop is still owed
**Stop Share is currently SIGKILL** (`peerspeak/src/core/mod.rs:699` + `:3480`, Tokio
`Child::kill()`). Under Option C this no longer strands the user's desktop audio — the
links die with the connection, which is the point. But it still leaks:
- The capture null sink is **pactl-loaded and pipewire-pulse-owned**
(`pixelpass/src/host/audio.rs:69`), so **Stop Share leaks one null-sink module every
time.** This is true today, independent of this feature.
Required:
1. A graceful control path in peerspeak: **SIGINT** (not SIGTERM — pixelpass installs
only `tokio::signal::ctrl_c()`, `pixelpass/src/common/signal.rs:6`), bounded wait,
SIGKILL fallback.
2. The capture sink should become **connection-owned** rather than pactl-owned, so it
shares the links' death-with-the-process property.
3. `--repair` (`pixelpass/src/repair.rs:15-63`) extended to this mode, and safe with a
second live host.
## 8. IPC: getting the index to pixelpass
pixelpass is a **separate process** and cannot learn the module index on its own.
peerspeak spawns it, so:
- New pixelpass flag: `--exclude-pulse-module <idx>` (naming, §11).
- peerspeak passes `EchoCancelGuard`'s index at spawn. The field is currently private
with only `source_name()` / `sink_name()` accessors — add `module_index()`.
- If the AEC is **off**, the flag is absent and pixelpass must not invent an exclusion.
- If the AEC is **on** but the flag is absent, that is a bug in peerspeak; pixelpass
cannot detect it. ⇒ peerspeak should pass an explicit `--aec=off|<idx>` rather than
"flag present or not," so pixelpass can distinguish "no AEC" from "someone forgot."
**Open decision D5 (§13).**
- Because no hot-reload path exists (§7.3), spawn-time argv is sufficient today. A
runtime channel is required only alongside hot reload.
## 9. What is proven, and what is not
### 9.1 Proven by measurement on this machine (PipeWire 1.6.8 / WirePlumber 0.5.15)
- Fan-out carries full-level audio (24.1 dB, matching the speaker monitor) for **paplay,
mpv, VLC**; the app keeps its speaker link.
- WirePlumber does **not** reap foreign links across default-sink switch, switch-back,
suspend, resume, or 100 s steady state.
- Non-lingering links die on **SIGKILL** of the owner.
- **E2:** destroying the capture sink mid-share left the app playing to speakers
undisturbed.
- `peerspeak.owned=1` lands on paplay/mpv/VLC nodes via `PULSE_PROP`.
- `pulse.module.id` on all four AEC nodes == the index pactl returned.
- Naive fan-out of the AEC leg leaks remote audio at ≈desktop level; exact exclusion sits
at the control floor.
**Round 8 (2026-07-25), from the phase-5 dry-run against the live graph:**
- The registry `global` event announces **13 keys** for a Node and **none** of the eight
ownership/identity properties this design reads (§6.7). `port.exclusive` is absent from
Port globals. Link endpoint keys and Client `pipewire.sec.pid` **are** announced, on every
object, always.
- **Binding recovers all of them** — `factory.name`, `device.api`, `alsa.driver_name`,
`peerspeak.owned`, `pulse.module.id`, `node.link-group`, `application.process.id` read off
the bound object's `info` props.
- `factory.id` is **not** a shortcut to `factory.name`: every ALSA node claims `factory.id`
19, whose Factory resolves to `"adapter"`, not `api.alsa.pcm.sink`.
- `alsa.driver_name` and `device.api` **are** on the **bound `Device`**'s `info` props
(absent from the Device *global*) — the authoritative source for §6.7 decision 4.
- Full recompute per graph event: **max 15 µs, mean 4 µs** over 334 recomputes at a 47 Hz
event rate under deliberate churn; `busy_fraction` 0.0004. Closes O5 for the engine —
⚠️ but measured on the degraded graph, with no per-node bind I/O in it.
### 9.2 ⚠️ Wording discipline — three overclaims already made, do not make a fourth
The correct statement of the exclusion result is:
> *"No incremental 1500 Hz energy was detectable above the control floor at the analysis
> resolution in this steady-state run."*
**Not** "absent," **not** "conclusive." What survives is the gross-leak distinction: the
naive arm copies the probe at ≈desktop level, exact exclusion does not. Likewise, equal
`mean_volume` on a sine proves **signal presence, not fidelity** (blind to xruns, drift,
dropouts, channel swap, quantum change), and "copy semantics" is a **topology** result —
it shows the tested link operation did not move the tested Pulse stream, and says nothing
about gain, latency, continuity, or native clients.
The rig is additionally blind to: startup/teardown/cork/relink transients (hidden by
whole-file averaging), broadband and out-of-band leakage, level-dependent and nonlinear
products, and nodes created after enumeration.
**Minimum rig upgrade before shipping:** two orthogonal PN/MLS probes + windowed
per-channel normalized cross-correlation, reporting max per-window correlation, plus xrun
telemetry.
### 9.3 Untested — carried forward, each one a real risk
AEC reload mid-share · two concurrent AEC instances · two concurrent capture sinks · the
**native** PipeWire AEC module · a native-PipeWire (non-Pulse) app · pixelpass's own
null-sink + loopback present simultaneously · the full GStreamer/AAC/network path ·
fidelity (needs broadband source + correlation + xrun telemetry) · PipeWire daemon restart
· quantum/latency perturbation from the second link · sample-rate mismatch, surround,
IEC958 passthrough · browser and game stream shapes.
## 10. Prerequisite fixes (split out, land before the feature)
Both reviews agree these are their own tasks, not part of this feature.
1. **`object.serial` u32 truncation** — 64-bit in PipeWire, parsed as `u32` at
`pixelpass/src/host/audio.rs:534-540`. Global IDs are reused; serial is the recommended
stable identifier. Must be fixed before the router grows.
2. **`@DEFAULT_SINK@` resolved once at module load** (`audio.rs:139-151`), no
default-metadata listener — the user's `Ctrl+Meta+F` / `Ctrl+Meta+S` output-switch
hotkeys strand the local monitor mid-share. **This already hurts per-app mode today.**
Option C does not need the local monitor, so this is decoupled from the feature — but
it is a live bug.
3. **`try_flush` records intent, not success** (`audio.rs:642-655`): it ignores
`Metadata::set_property`'s return and appends every pending ID to `routed_node_ids`.
Any status surfaced to the user inherits that dishonesty. Option C's link-state
`ACTIVE` check (§4.2) is the honest replacement for the new mode; the old path should
be fixed or deleted.
4. **Graceful stop + connection-owned capture sink** (§7.4).
5. **Drop-order fix + regression guard** (§7.2).
## 11. Naming — the user's call
Placeholders throughout. pixelpass mode: `--audio-mode=desktop-shared` /
`--audio-mode=desktop-excluding`. Exclusion flag: `--exclude-pulse-module <idx>` (or
`--aec <idx|off>`, §8). Do **not** surface `--exclude-pid` — it names an unreliable
mechanism and reads like process control.
peerspeak picker wording: something like "System audio except peerspeak," noting that
call and watched-share playback are excluded. Whether "All system audio" stays alongside
it (with a stated echo risk) or is replaced when the capability is present is a product
decision.
## 12. Testing
Pure, unit-testable seams with PipeWire at the edges, per house style:
- `fn eligibility(node_props: &Props, ctx: &ExclusionCtx) -> Eligibility` with
`NotEligible { reason }` so status can explain itself. Cases: `peerspeak.owned` present;
`pulse.module.id` == live index; `pulse.module.id` present but **different** (must be
ELIGIBLE — correction 2); `pulse.module.id` absent; a pixelpass-owned node; capture-sink
ancestry; `port.exclusive`; passthrough; non-`Stream/Output/Audio`; missing props
entirely; the capture sink itself; a foreign `echo-cancel-*` group.
- `pulse.module.id` parsing: JSON number **and** string forms, values > `u32::MAX`,
absent, malformed.
- AEC-identity validation state machine: `NotConfigured` / `Validated` / `Revoked`, and
the assertion that `Revoked` stops fan-out.
- Pure capability-probe parsing (§13 D2).
- Link bookkeeping: per-port link set, "captured" only at all-`ACTIVE`, idempotent
re-enumeration, proxy retention/drop.
**⚠️ Node-local eligibility tests are NOT sufficient.** The C2/C3-class defects all live in
the graph engine, so the taint engine needs its own pure test surface, fed synthetic
Node/Port/Link/Client fixtures:
| test | catches |
| --- | --- |
| GStreamer **split clients** (two `client.id`s, one `application.process.id`) are bridged | the C2 refutation |
| the key union **falls through** a present-but-unequal key (`client.id` differs ⇒ try key 4) | the §6.1.2 wording trap |
| the pipewire-pulse PID does **not** bridge unrelated modules | mass over-exclusion |
| a module forwarder with neither `link-group` nor `pulse.module.id`**unresolved ⇒ excluded** | fail-closed invariant |
| sticky taint **survives** the tainted input leg unlinking/disappearing while the output leg lives | the C3 buffered-audio defect |
| sticky taint **clears** once every owner member object is gone | over-exclusion forever |
| a **recycled** `client.id` / module index / node id / `link-group` does **not** inherit taint | §6.1.3 lifetime-awareness |
| the readiness epoch blocks stale or unresolved link decisions | §6.4 |
| taint crosses `app → sink → monitor-reader` at node granularity | §6.1 edge type 2 |
| an `Audio/Duplex` node over-taints (asserted as *known accepted* behaviour, so a future fix is a deliberate change) | §6.1 caveat |
Field tests — the only thing that can prove a viewer does not hear themselves:
1. Sharer in a call while sharing, **AEC on and AEC off**.
2. Sharer simultaneously *viewing* another share while sharing.
3. Lifecycle, each separately: Stop button, room leave, UI crash, pixelpass panic, SIGINT,
SIGTERM, SIGKILL, last-viewer disconnect, pipewire-pulse restart, PipeWire daemon
restart, `pixelpass --repair`.
4. Output-device switch mid-share via the real hotkey scripts.
5. Two concurrent hosts (second pixelpass CLI, second peerspeak instance).
6. A notification sound firing mid-share.
7. An app that **starts playing after** the share began (dynamic node).
8. Sample-rate / channel / passthrough behaviour on real sinks; CPU cost.
⚠️ **Test-rig discipline** (each of these already produced a wrong result once):
filter on `media.class` **first** when selecting nodes with `jq``media.name` also
matches the Client object; **never** filter stderr out of a measurement run; **verify the
link exists in the graph** before measuring; reproduce over the transport production
actually uses (`parec -d <sink>.monitor`, not `pw-record --target <sink-node-id>`, which
reads 91 dB silence); **(r8)** never conclude a property is *observable* because
`pw-dump` shows it — `pw-dump` **binds**, the registry does not, and the eight properties
of §6.7 look identical in `pw-dump` to properties the code can actually see. Compare
`pw-cli ls <Type>` (registry view) against `pw-dump` (bound view) before depending on any
key.
## 13. Decisions — resolved in round 4
Both reviewers agree on all seven. Recorded as decided; reopen only with new evidence.
- **D1 — fail closed, but only after a bounded validation wait** (§5.3). Never "share
anyway, warn" as the default. ✅
- **D2 — a versioned machine-readable capability response or bitset.** The
`pixelpass --help` substring probe (`src/screenshare/mod.rs:245-278`, invoked at
`src/core/mod.rs:3368`) stays only as a compatibility fallback, and this mode must
**not** overload `app_audio_supported: bool` — strict per-app and desktop-excluding are
independent capabilities. ✅
- **D3 — warn and exclude foreign `echo-cancel-*` nodes** for the first implementation;
never fan them out. ✅
- **D4 — explicit async shutdown + a fake-resource ordering test** (§7.2). A field-order
assertion alone is inadequate. ✅
- **D5 — `--aec=off|pulse-module:<idx>`, always passed.** Absence of the flag is not a
protocol state (§8). ✅
- **D6 — land §10 items 1, 4 and 5 first**, plus the new graph-ancestry predicate (§6.1).
Items 2 and 3 are real per-app debt but do not block Option C unless the new mode reuses
those code paths. ✅
- **D7 — no materially simpler design exists** that still meets Joe's ask. The available
simplification is to *narrow v1 scope*, not to change architecture. ✅
## 14. Readiness — 🟢 v3.7 (round 10): the §5.1 matrix PASSED; architecture unchanged.
**Round 10 (2026-07-26).** The phase-5 matrix ran in full and **passed all 13 rows with a
non-empty eligible half in every one** — results in
`screenshare-audio-exclusion-phase5-results.md`. It also found a third measured defect on
first contact, and once again the failure was fail-closed and *silent*, exposed only by the
requirement to assert what must remain **eligible**: §6.1.2's pulse-PID derivation returned
`None` permanently on this host, so key 4 fused every Pulse-emulated node into one owner.
| | verdict |
| --- | --- |
| Architecture — Option C, taint as a graph property, owner-key union, sticky taint, AEC identity state machine | **unchanged, three times vindicated** |
| §6.1.2 | **revised** — the derivation heuristic is deleted; `comm` alone decides |
| §5.1 matrix | **PASSED** — 13/13, incl. the full sticky lifecycle (row 10) and provable identifier recycling (row 11) |
| O5 | **closed on the real graph** — worst recompute 67 µs, churn mean 10 µs, busy fraction 0.0006 |
| Phase 5 | **machinery unchanged and correct** — three real defects caught on first contact with the live graph, none of them in the engine |
| Phase 6 | **still blocked** — by F11-1, phases 0b/0c/0d, and the "Stereo Mix" design call, *not* by this matrix. (F11-1 was **closed later the same day** with this matrix's data — §6.1.2's round-13 box; the rest stand.) |
Three rows passed with recorded substitutions (8 EasyEffects, 9 Firefox's own mic/monitor
paths, 13 a real `Audio/Duplex` device) and the third-party samples stay owed.
**Round 9 (2026-07-25).** Phase 3r shipped §6.7 and the audit was re-run
immediately; it found a *second* measured defect within minutes — a permanent sticky taint
on a hardware sink (§6.8). Both rounds share a shape worth naming: **the architecture was
right and the instrumentation was wrong**, and only running the code against a live daemon
found either.
| | verdict |
| --- | --- |
| Architecture — Option C, taint as a graph property, owner-key union, sticky taint, AEC identity state machine | **unchanged, twice vindicated** |
| §6.8 | **new** — sticky state is built from an evidence-only pass; decisions still fail closed |
| §6.1.3 | **narrowed** — the absence rule now governs evidence-based taint only |
| Phase 3r | **built and merged**, four-part gate passed incl. live |
| Phase 5 | **machinery unchanged and correct** — it has now caught two real defects on first contact with the live graph |
**Round 8 was not a review round.** It was opened by the phase-5 dry-run audit failing its
gate on the first live run: the engine built to v3.4 was measured **non-functional** — it
failed to recognise its own primary taint root (an echo) while excluding every stream on the
machine (silence). One cause, §6.7: node properties are not observable from the registry.
What that does and does not touch:
| | verdict |
| --- | --- |
| Architecture — Option C, taint as a graph property, owner-key union, sticky taint, AEC identity state machine | **unchanged and vindicated.** Fed correct properties, the engine decided correctly in every fixture; the defect is entirely at the observation boundary |
| §5.1 tagging | **revised** — a second, registry-visible carrier added |
| §6.4 implementation shape | **revised** — node/device props require a bind |
| §6.7 | **new** — the observation boundary, and the rule that the global is an index, not a source of truth |
| §6.1.0, §6.1.4 | **corrected** — a time-dependent claim and an unreachable test case |
| Phases 2 and 4 (pure engine, AEC validator) | **no change owed** |
| Phase 3 (observer) | **must be revised before phase 5 re-runs** |
The §5.1 exact-partition requirement is what caught this, exactly as argued: the build's
exclusions were all defensible, and an exclusion-only checklist would have passed it. It was
the *eligible* half of the partition being empty that exposed the failure.
**Rounds 17 record.** Both reviewers agreed v3.4 was ready to become the implementation
plan; every blocker raised was either fixed or refuted with evidence. That remains true —
round 8 found something no design review could have, because it required running the code
against a live daemon.
How the blockers closed:
| round | blocker | outcome |
| --- | --- | --- |
| 4 | eligibility is node-local | **fixed** — §6 rewritten as graph taint |
| 4 | one-shot AEC validation | **fixed** — §5.3 bounded state machine |
| 4 | field reorder insufficient | **fixed** — §7.2 explicit shutdown + ordering test |
| 5 | Link-only walk misses forwarders | **fixed** — §6.1 three edge types, measured |
| 5 | monitor edge needs synthetic modelling | **refuted** — free at node granularity (C1) |
| 6 | `client.id` is not an owner key | **refuted my own claim** — §6.1.2 owner-key union |
| 6 | current-only taint forgets buffers | **conceded** — §6.1.3 sticky taint |
| 7 | "resolves" = first-present would still leak | **fixed** — §6.1.2 wording trap + test |
| 7 | stickiness on recyclable ids | **fixed** — §6.1.3 lifetime-aware owner components |
| **8** | **node props are not on the registry global (measured, phase 5)** | **fixed** — §6.7 bind-every-node rule; §5.1 second carrier |
| **9** | **a fail-closed unresolved mark became permanent sticky taint (measured, phase 5 re-run)** | **fixed** — §6.8 evidence-only sticky pass |
| **9** | `device_props` tested for one live *Device* rather than one live *global* on the id (Codex, certain) | **fixed** in phase 3r — stale `session_device` on a contested id is an echo path |
| **9** | `device.api` corroborated by presence, so `v4l2` under an ALSA factory passed (Codex) | **fixed** in phase 3r — the API must equal the allowlist's own |
| **9** | hardware playback-to-capture ("Stereo Mix") defeats the `session_device` classifier (Codex, P1 worth checking) | **OPEN — design decision owed**, §6.8; pre-existing, needs ALSA control inspection |
| **9** | the 2 s readiness budget has no calibration argument (Codex) | **OPEN — measurement owed**, §6.8; 12 ms observed on this host with 18 binds (phase-5 run 2) |
| **10** | **the pulse-PID derivation required a *single* repeated `sec_pid`; WirePlumber repeats one too, so it returned `None` permanently and key 4's suppression never fired (measured, phase-5 run 2)** | **fixed** — §6.1.2 round-10 box: probe every distinct `sec_pid`, let `/proc/<pid>/comm` decide |
| **10** | the audit's `sticky` flag means "is in the remembered set", so it is true for nearly every tainted node and does not answer "excluded only because remembered" | **OPEN — reporting only**; the evidence-only pass §6.8 already computes what is needed |
| **10** | a bridge's named key is lost when a leg reappears under a new serial (sticky `reason_for` falls back to keyless, and `raise` will not replace a same-rank reason) | **OPEN — reporting only**; verdict unaffected |
### v1 scope — agreed
Option C fan-out · explicit `--aec=off|pulse-module:<idx>` · peerspeak playback and child
tagging · exact AEC module validation · graph taint with the owner-key union and the
pipewire-pulse PID exception · sticky taint · readiness epoch · fail-closed unresolved
ancestry · owned non-lingering links · §10 items 1, 4 and 5 landed first.
### Deliberately OUT of v1
Node-granular taint only (no port modelling, duplex over-taint accepted) · no timed drain ·
no hot-AEC-reload epoch protocol · no native PipeWire AEC support · no incremental
dirty-set (full recompute) · no "implausibly many streams" heuristic as correctness ·
pipewire-pulse/PipeWire daemon restart handled as **revoke and stop**, not seamless
recovery · **(r8)** no per-node quarantine — an unresolvable node bind fails the whole
readiness epoch closed rather than isolating that node (§6.7 decision 3) · **(r8)** no Port
binding, so `port.exclusive` is never observed and the §6.2 row it guards relies on the link
create failing cleanly · **(r8)** no serial-continuity signal for the AEC validator's
no-coalescing contract.
### Next step (round 9)
Phases 0a, 2, 3, 3r, 4 and 5 are built; §6.7 and §6.8 are implemented and merged. What
remains before phase 6 unblocks:
1. ~~**Revise phase 3** to §6.7~~**done**, phase 3r merged, four-part gate passed
including the live prop-recovery row and an added live gate for the Device-side path.
2. **Revise phase 1** to emit both carriers (§5.1), literals pinned in plan §3. Unblocked
and next.
3. **Re-run the whole phase-5 §5.1 matrix** — no row was completable under the round-8
defect, so nothing carries over — and **re-measure O5** with bind I/O *and* the round-9
second fixpoint in it. Phase 6 stays blocked until that results file passes.
4. Decide the two items §6.8 leaves open: hardware playback-to-capture paths (a real echo
path, needs a design call) and the readiness-budget calibration.
Still owed beyond that, unchanged: the §9.2 rig upgrade before any exclusion claim is
published, and **field-test §12** — nothing in this design has been tested over the real
GStreamer/AAC/network path or on two machines.