# Implementation plan: whole-desktop screen-share audio without self-echo

**Status:** 🟢 **v4 — three review rounds applied.** *Progress as of 2026-08-21:* phases 0a, 0b,
0c step 1, 1, 2, 3, 3r, 4 and 5 are merged, and the **phase-5 major gate PASSED on 2026-07-26**
(§1). The two pre-Phase-6 decisions are now **closed** in design v3.8 §6.9: the conservative
same-device hardware bridge is built and validated, and the 2 s readiness budget passed
baseline, inflated-graph and live-churn calibration. The completed **0c step 2** was
sliced S1–S5: S1 and S2 are
merged; **S3a is merged locally** in pixelpass (`15d1374`); and **S3b is built, validated, and
committed locally** (`5d3da8b`). **S4, S5, 0d, round 11's bridge changes, and Phase 6's first
pure channel-planning prerequisite are built, validated, and committed locally in pixelpass
(`781defc`).** The first bounded Phase 6 mutation slice is now built, validated and committed
locally in PixelPass (`98cde2c`): the hidden production path creates, retains, revokes and
crash-cleans exact-channel non-lingering fan-out links. The four versioned Phase 6 status events
are built, validated and committed locally in PixelPass (`5a65f50`). Capture-sink replacement
and successful exact-channel relinking are built, validated and committed locally in PixelPass
(`d09ee9b`). **The Row 1 mutation-edge identity gate and the Row 8d/8e fail-closed
construction gates are built, validated and committed locally in PixelPass (`6be07ef`).**
**The independent Row 6a/6b/6c refusal gates are built, validated and committed locally in
PixelPass (`956534f`).** **The revised Row 9 positive/negative late-arrival partition is built,
validated and committed locally in PixelPass (`7b11827`).** The deterministic Phase 6
link-manager matrix is complete. **The production-path three-arm AEC leak qualification is
built, validated and committed locally in PixelPass (`e027bc6`), completing Phase 6.** Phases
7–9 are not built — nothing released to date changes the feature's user-visible behaviour.
**Phase 7 is now the current front.**
**Date:** 2026-07-21 (v4); status line refreshed 2026-08-21
**Design of record:** [`screenshare-audio-exclusion-plan.md`](screenshare-audio-exclusion-plan.md) v3.8, round 11.
**Scope:** *ordering, gates and acceptance criteria only.*

**Reference convention.** `v3.4 §N` = the design doc. `plan §N` = this document. The two
numbering schemes collide (both have a §11 and a §12) and an unqualified reference in v2 sent
Phase 6's most important gate to a section that does not exist. Every cross-reference below is
qualified.

**Review history**
| Round | Findings | Outcome |
| --- | --- | --- |
| 1 | 7 P1 + 5 P2 | 12 accepted, 1 half-rejected → v2. `~/Documents/handoff-docs/Codex/peerspeak/review-2026-07-21-impl-plan-round1.md` |
| 2 | 3 P1 + 7 P2 + 1 P3 | all accepted → v3. `…/review-2026-07-21-impl-plan-round2.md` |
| 3 | verification pass: 4 of 7 edits landed, 3 partial; 2 P1 + 2 P2 + 1 P3 | all accepted → v4. Approved to start Phase 0a. `…/review-2026-07-21-impl-plan-round3.md` |

Adjudication in plan §10. Two of my own claims were refuted by Codex with source evidence and
two of its claims were refuted or narrowed by mine; both are recorded there rather than
quietly dropped.

---

## 0. What this plan is optimising for

The design is converged; the risk has moved from "is it right?" to "will it be built in an
order where each mistake is caught while it is still cheap." Three properties drive every
ordering decision:

1. **Nothing that can create an echo runs before the thing that decides eligibility has been
   validated against a real graph.** The taint engine is the hull. It is built, unit-tested,
   and floated empty (Phase 5, dry-run) before a single link is created.
2. **Every path to unsafe audio is closed structurally before the machinery that could take
   it is written.** There are **two** such paths, not one — the capture *source* and the
   capture sink's *inputs*. Both are closed in Phase 0d.
3. **Every phase ends in a state that is shippable or trivially revertible**, and every gate
   is one a broken implementation can *fail*. A gate that cannot fail is not a gate; where a
   gate asserts only that bad things are absent, it must also assert that good things are
   present, or "captures nothing at all" passes it.

The corollary, stated plainly because it is the most likely way this goes wrong: **the
temptation will be to write the link manager early**, because it is the visible feature.
Fan-out is roughly 400 lines and demos beautifully with a hand-picked node. It is also the
component that, shipped ahead of a validated engine, produces exactly the bug this feature
exists to prevent — in front of Joe.

## 0.1 Cross-repo reality

Two repos, no Cargo dependency; the contract is pixelpass's CLI plus its `--output json`
event stream (`peerspeak/src/screenshare/mod.rs:1-14`). The bulk of the work — graph engine,
link manager, AEC state machine — is **pixelpass**. peerspeak's share is tagging, argv,
capability gating, teardown ordering and the user-visible status surface.

**Hard ship-order constraint.** peerspeak spawns whatever `pixelpass` resolves on `PATH`. If
peerspeak passes `--aec=…` to a pixelpass that predates the flag, clap rejects it and **the
share hard-fails** — the documented A23 / audit-P2 skew failure that already governs
`--strict-audio` (`screenshare/mod.rs:249-262`).

> **pixelpass ships the capability first (Phase 7), including the old-peerspeak/new-pixelpass
> golden test. peerspeak only passes the new flags to a binary that advertised support
> (Phase 8), which owns the new-peerspeak/old-pixelpass golden test. Never "flag present or
> absent" as the protocol — v3.4 D5.**

⚠️ **Capability is resolved twice, against two independently-resolved binaries.**
`ListAudioApps` resolves pixelpass and probes it at `core/mod.rs:3375-3378`; `StartScreenShare`
resolves it *again* at `:3409-3419`. Between those moments `PATH` or the override can change.
Verified in source. Phase 8 must bind the capability result to the resolved path and re-probe
if it differs, failing closed.

---

## 1. Phase map and dependency DAG

| # | Phase | Repo | Mutates graph? | Exit gate |
| --- | --- | --- | --- | --- |
| 0a | `object.serial` u64 fix | pixelpass | no | boundary parse tests |
| 0b | Explicit teardown + drop ordering | peerspeak | no | **four independent mutations** (plan §2; revised from five, §10 r14) ✅ built |
| 0c | Graceful stop + connection-owned capture sink | both | sink ownership | two-host SIGKILL live gate **+ SIGINT-first gate** |
| 0d | **Typed capture plan + internal mode input** | pixelpass | no | mode matrix; neither unsafe source nor unsafe sink input constructible |
| 1 | peerspeak ownership tagging | peerspeak | no | tag on live nodes; literal pinned in plan §3 |
| 2 | Graph model + taint engine (pure) | pixelpass | **no PipeWire at all** | v3.4 §12 fixture matrix + degenerate-snapshot case |
| 3 | Registry observer + readiness epoch | pixelpass | read-only | six-part gate incl. **PID derivation** |
| **3r** | **Observer revision — bind every Node/Device (v3.5 §6.7)** | pixelpass | read-only | **four-part gate (plan §4 "Phase 3 revision")** |
| 4 | AEC identity validation state machine | pixelpass | read-only | fake-clock transition matrix |
| 5 | **Dry-run audit mode** | pixelpass | read-only | 🚦 **MAJOR GATE** — exact decision partitions (plan §5) |
| 6 | Link manager + status events, driven through the real host path | pixelpass | **yes — first mutation** | link-manager matrix (plan §6.1) + live dynamic matrix |
| 7 | **Public mode selector** + capability advertisement | pixelpass | no | old-peerspeak/new-pixelpass golden test |
| 8 | peerspeak integration (mode flag, argv, picker, UI, status) | peerspeak | no | new/new argv golden (mode **and** `--aec`); new-peerspeak/old-pixelpass golden; causal status delivery |
| 9 | Rig upgrade + field matrix | both | yes | 🚦 **SHIP GATE** — plan §7 |

**Landing DAG** (development may be concurrent; *landing* order may not):

```
0a ──────────────────► 2 ──► 3 ──► 4 ──► 5 ──► 3r ──► 5 (re-run) ──► 6 ──► 7 ──► 8 ──► 9
                                                                     ▲
0b ──────────────────────────────────────────────────────────────────┤
0c ──► 0d ───────────────────────────────────────────────────────────┘
1 (r8 carriers) ──────────────────────────────────────► 5 (re-run)
```

✅ **Status 2026-07-26: the phase-5 gate PASSED on run 2 — all 13 §5.1 rows completed.**
Record: [`screenshare-audio-exclusion-phase5-results.md`](screenshare-audio-exclusion-phase5-results.md)
(audit build pixelpass `main` @ `91c4ded`, release profile). Phase 1 was the hard prerequisite
of the re-run for both carriers (plan §3) and was satisfied — rows 4 and 5 passed at the real
tagging sites. **Phase 6 is no longer blocked by this gate.** What still blocks it is the rest
of the DAG: `0b → 6` is satisfied and merged; **0c and 0d are built, validated, and committed
locally with S4/S5 and round 11 in pixelpass `781defc`**. **Round 11 now closes the two former
design §6.8 blockers** with the v3.8 §6.9 hardware bridge and readiness calibration; Phase 6
is unblocked and its first pure planning prerequisite has landed.

⚠️ **Superseded, kept for the trail — status 2026-07-25 (evening): "3r is BUILT AND MERGED; the
re-run has not happened yet."** The phase-5 gate failed on its first live run and put 3r into
the DAG; 3r's own four-part gate then passed, including the live prop-recovery row on this host.
Phase 5's machinery was built and correct throughout — it is the audit that found the defect,
twice — so "5 (re-run)" was a *re-run of the matrix*, not a rebuild. Run 2 is that re-run.

⚠️ **A smoke run of the audit against the fixed observer immediately found a second defect
(design v3.6 §6.8): a fail-closed `unresolved-ancestry` mark was being promoted to permanent
sticky taint.** Fixed in the taint engine (evidence-only sticky pass, 3 new tests,
mutation-verified) and merged. Decisions were unaffected — all 57 phase-2 tests passed
untouched — so this is a change to what stickiness *remembers*, not to what it *decides*.
Note the pattern for the re-run: the matrix rows assert exact partitions, and a stale sticky
entry from enumeration would have contaminated every one of them.

- **0b strictly precedes 6.** v2/v3 drew 0b with no continuing edge. Phase 6 is the first phase
  that creates objects whose lifetime is tied to pixelpass being alive, so the teardown-ordering
  guarantee must exist before it: without it, the AEC can unload while a fanning-out pixelpass
  still holds link proxies and a stale module index (v3.4 §7.1).
- **0a strictly precedes 2**: the taint engine's lifetime-awareness (v3.4 §6.1.3) is keyed on
  `object.serial`; building the model against the current lossy `u32`
  (`pixelpass/src/host/audio.rs:534-540`; `RouterState::sink_serial` at `:594-598`) means a
  cross-cutting migration later.
- **0c strictly precedes 0d** (round-2): 0d's types must be built around the *final*
  connection-owned bare sink, not today's `Routing`. Building the type boundary against the
  legacy pactl sink means rebuilding it when 0c lands.
- **1 strictly precedes 5**: without tags the taint engine has no roots and the dry-run can
  only exercise the forwarder half of the problem.

---

## 2. Phase 0 — prerequisites

### 0a. `object.serial` u32 truncation — pixelpass
Parse as `u64` throughout; audit the other `parse::<u32>` at `:369` (determine whether it is a
serial or a genuinely-32-bit value before changing it). Tests: value > `u32::MAX`, and the
boundary.

### 0b. Explicit teardown + drop ordering — peerspeak
v3.4 §7.2, decision D4. All **three** of v3.4's fixes:

1. Replace the implicit-drop path at both channel-close sites. **Citation correction:** v3.4
   says `core/mod.rs:1516`; the actual `None => break` arms are at **`:1514`** and **`:1532`**.
   Take the session and `shutdown().await` it.
2. Move `echo_cancel` (currently `:682`) to the last declared field, after `screenshare_host`
   (`:685`), with a comment naming the invariant.
3. **Last-ditch drop wrapper**: `start_kill` + a bounded `try_wait` reap on the host before the
   AEC guard unloads. This is the *only* protection on the panic/unwind path, and unwind is
   reachable — the core has numerous `unwrap()` sites and no `panic=abort` profile.

> ✅ **0b IMPLEMENTED 2026-07-26** (peerspeak branch `phase-0b-teardown`). The ordering
> defect was live: `echo_cancel` was declared *ahead* of `screenshare_host`, so any unwind
> unloaded the AEC while the host was still fanning out. Fields moved into
> `src/core/teardown.rs` with `echo_cancel` declared last, and `ReapOnDrop` added because
> `kill_on_drop(true)` only *signals* — it hands the child to the runtime's orphan queue,
> which an unwinding runtime may never drain. Matrix revised to four mutations; see §10
> round 14, and round 15 for the two blocking review findings that followed.
>
> **Owed to phase 9:** an explicit lifecycle row — *drop the controller / close the command
> channel while sharing* — which is the live proof for the hoisted teardown call site.

⚠️ **Mutation testing: five mutations, each independently breaking a named test.**
⚠️ **SUPERSEDED by §10 round 14 — mutation 2 is vacuous and the matrix is now four.** v1 demanded
a mutation that targeted the wrong defense; v2 fixed that but bundled two defenses into one
combined mutant, which proves neither. Final form:

| # | Mutation | Must break |
| --- | --- | --- |
| 1 | remove `shutdown().await` at `:1514` | close-arm-A teardown test |
| 2 | remove `shutdown().await` at `:1532` | close-arm-B teardown test |
| 3 | remove the explicit **wait** after host kill | explicit-ordering test (host kill+wait strictly precedes AEC unload) |
| 4 | reverse the field order | panic/unwind ordering test |
| 5 | remove the wrapper reap | panic/unwind ordering test (distinct assertion from #4) |

Both channel-close arms get their own test; a single "closes the command channel" test can
exercise one arm and leave the other unsafe.

### 0c. Graceful stop + connection-owned capture sink — both

> ✅ **MECHANISM PROBE PASSED on this host, 2026-07-26** (PipeWire 1.6.8). Run *before* any
> structural work, on the reviewer's insistence, because a single unverified assumption could
> have invalidated the entire approach: whether a hand-created adapter is visible to
> pipewire-pulse under the name pixelpass's capture path depends on. It is.
>
> ```
> pw-cli> create-node adapter factory.name=support.null-audio-sink \
>           node.name=pixelpass_probe_<pid> media.class=Audio/Sink \
>           audio.channels=2 audio.position=[FL,FR] node.virtual=true \
>           monitor.channel-volumes=true object.linger=false
> ```
>
> Five gates, all green:
> 1. `pactl list short sinks` shows the sink under the **exact** `node.name`.
> 2. `pactl list short sources` shows **`<node.name>.monitor`** — the derived monitor name is
>    a pipewire-pulse contract, not a property of Pulse-created sinks. This was the one that
>    could have sunk the approach.
> 3. `gst-launch-1.0 pulsesrc device=<node.name>.monitor num-buffers=40 ! fakesink` pulled its
>    buffers and exited clean, and a real recording stream attached — so the pixelpass capture
>    path works against it unchanged.
> 4. **No null-sink module was loaded** (`pactl list short modules | grep -c null-sink` stayed
>    at its baseline of 3). It is genuinely not a Pulse module.
> 5. **SIGKILL of the owning connection removed both Pulse-visible names**, with zero residue
>    anywhere in `pw-dump`. That is the entire point of 0c, demonstrated on the real graph.
>
> The default sink never moved, so this is also safe to run on a live desktop.
> **Every O1 stop condition listed for 0c is retired.** `object.linger=false` is load-bearing:
> the bundled pipewire-rs example sets `linger=1` for the opposite behaviour.
>
> ⚠️ **`--repair`'s job does not shrink — it BREAKS.** Discovery derives dead host PIDs
> **only** from `module-null-sink` entries (`pixelpass/src/repair.rs`), and only then matches
> loopbacks against that PID set. The native sink is scoped to **every mode that owns a
> capture sink**, not just `DesktopExcluding`, so legacy Pulse loopbacks will coexist with a
> connection-owned sink; when that host dies the sink vanishes automatically and its loopbacks
> become **undiscoverable orphans**. Candidate PIDs must be derived independently from all
> three module shapes (`null-sink sink_name=`, `loopback sink=`, `loopback source=…monitor`),
> with a liveness recheck immediately before each destructive unload. This makes the repair
> rework **load-bearing, not defensive**.
v3.4 §7.4. The **largest hidden cost in Phase 0**: moving the null sink off `pactl load-module`
(`pixelpass/src/host/audio.rs:69`, cleaned up only in `Routing::cleanup` at `:259-260`, which
SIGKILL skips) onto a connection-owned PipeWire object.

- peerspeak: SIGINT (**not** SIGTERM — pixelpass installs only `ctrl_c()`,
  `pixelpass/src/common/signal.rs:6`), bounded wait, SIGKILL fallback, at `core/mod.rs:699`
  and `:3480`.
- pixelpass: connection-owned sink; `--repair` (`src/repair.rs:15-63`) extended and proven safe
  with a second live host.

**Exit gate — two halves. The round-2 finding was that v2 gated only the first.**

*(i) Ownership,* a live two-host test — connection-ownership is a runtime property no unit test
can establish:

```bash
pactl list short sinks | rg 'pixelpass_capture_'
pw-dump | jq -r '.[] | select(.type=="PipeWire:Interface:Node") | .info.props as $p
  | select(($p["node.name"] // "") | startswith("pixelpass_capture_"))
  | [$p["object.serial"], $p["node.name"]] | @tsv'
kill -KILL <first-pixelpass-pid>
# re-run both: the killed host's object GONE, the second host's REMAINS
pixelpass --repair    # the live host must be untouched
```

*(ii) Graceful stop,* which the above does not touch at all — it exercises only external
SIGKILL, so Stop Share could remain `child.kill().await` (`core/mod.rs:3480`, still true today)
and every command above would pass:
- a fake-child signal-order test: SIGINT first, SIGKILL only after the bound expires;
- a live Stop Share run: SIGINT sent, child exits within the declared bound, **no fallback
  kill on the normal path**.

**O1 is closed: no demotion path.** v1 pre-authorised moving 0c after Phase 6 if it ballooned.
That is a waiver of settled decision v3.4 D6 hidden in a sequencing document, which is how a
converged design quietly decays. If 0c balloons, **stop and reopen D6 as design round 8.**

### 0d. Typed capture plan + internal mode input — pixelpass

Today `setup_audio` returns `(Option<Routing>, String)` (`pipeline.rs:123-142`) and the `String`
flows untyped into `build_args` (`:151-157`). There are **two** unsafe paths into the capture,
and v2 closed only the first:

**Path 1 — the source string.** `default_audio_monitor()` has exactly one call site,
`pipeline.rs:138`. That single line hands `pulsesrc` the real default monitor.

**Path 2 — the sink's inputs (round-2 P1, the defect v2 missed).** Even with a type-safe source,
`Routing::start` loads `module-loopback source=@DEFAULT_SINK@.monitor → pixelpass_capture_*`
whenever it runs outside strict-app mode (`audio.rs:80-92`), and it runs whenever
`PIXELPASS_AUDIO_VIA_NULL_SINK` is set (`pipeline.rs:124-125`). So `DesktopExcluding` could
correctly read *its own* sink's monitor while the legacy loopback has already filled that sink
with the whole-desktop mix — **full echo, with no source switch anywhere.** v3.4 §3 states this
loopback must never load in the new mode; nothing structurally enforced it.

Both are closed by construction:

- `LegacyDesktop` — the only variant that can produce `DefaultMonitor`.
- `PerApp { routing }` — legacy `Routing`, unchanged.
- `DesktopExcluding { capture_sink }` — owns a **bare** connection-owned sink type (0c) whose
  API **cannot construct the legacy loopback at all**. Not "does not call it": the constructor
  is not reachable from this variant.
- **Conflict policy, pinned here rather than discovered later:** the new mode combined with
  `--app` or `PIXELPASS_AUDIO_VIA_NULL_SINK` **rejects at CLI parse time**. It must never fall
  through to legacy `Routing`, and it must never silently ignore an input the user set.
- **Internal mode input lands here too** (round-2 P1): a non-advertised `HostOpts` field plus a
  hidden trigger, so the variant is reachable through the real host/spawn path before Phase 6
  needs to measure through it. `HostOpts` has no mode field today and `setup_audio` selects
  solely on `app` + the env override.

**Why 0d is a prerequisite rather than a Phase 6 deliverable** (my divergence from Codex's
round-1 suggestion; it agreed in round 2): this is a pure non-mutating refactor of one function,
and landing it in Phase 6 means the link manager is written against the untyped API and then
refactored underneath itself, while the two most dangerous paths in the codebase stay unguarded
through four phases of active work around them. Guardrails go up before the scaffolding.

**Exit gate:**
- mode matrix over every `(app, strict_audio, mode, env-override)` combination asserting the
  resulting capture plan, including every conflict combination rejecting;
- type-level: `DesktopExcluding` can name neither the default monitor nor the legacy loopback;
- **graph assertion**: with the new mode active, no default-monitor link or module feeds the
  capture sink;
- legacy behaviour byte-identical.

> ✅ **BUILT, VALIDATED, AND COMMITTED locally in pixelpass `781defc`, 2026-08-21.** The real hidden
> `--internal-desktop-excluding` host input resolves to a typed `CapturePlan`; only
> `LegacyDesktop` can construct `DefaultMonitor`, while `DesktopExcluding` owns a bare
> connection-owned sink whose type has no legacy loopback API. The complete 16-row mode matrix
> passes, both conflict inputs reject before graph mutation, and the exact legacy GStreamer
> audio tail is unchanged. The serialized live graph assertion passed with neither an incoming
> PipeWire link nor a Pulse module feeding the new sink; all four prior ownership/cleanup live
> regressions also passed and left no PixelPass audio residue. Broad result: pixelpass **310
> passed, 9 ignored**, fresh `--doctor` all green; peerspeak screen-share units **41 passed, 1
> ignored**, plus the real Stop Share/SIGINT compatibility gate passed. This remains an internal
> mode with no fan-out and no public selector; Phase 6 is still the first fan-out mutation.

A constructible-but-not-yet-public variant is acceptable for the interval between 0d and Phase
6 provided it is unit-tested and reachable by the hidden trigger.

---

## 3. Phase 1 — peerspeak ownership tagging (v3.4 §5.1)

Zero behaviour change; it is what makes Phase 5 observable.

- Native playback: prop on the stream dict, `src/audio/pipewire_impl.rs:374-388`.
- mpv/VLC spawn (`src/screenshare/mod.rs:768-775`), notification spawn (`src/notify.rs:265-272`):
  `PULSE_PROP` + `PIPEWIRE_PROPS` on the `Command`.

⚠️ **The literal is a cross-repo wire contract and is pinned HERE, before Phase 1 starts** — not
deferred with the v3.4 §11 product naming, which is a separate and genuinely user-facing question.

```
key:   peerspeak.owned
value: 1
```

⚠️ **Round 8 — a SECOND carrier is required, and its literal is pinned here too** (v3.5 §5.1).
`peerspeak.owned` is invisible to the registry `global` event and readable only via a node
bind (v3.5 §6.7); the prefix below is announced by the registry and needs no bind, so the
primary taint root no longer rests on a single observation mechanism.

```
key:    node.name
format: peerspeak_owned_<role>_<pid>        e.g. peerspeak_owned_mpv_31284
prefix: peerspeak_owned_                    ← the matched literal
```

- **Both carriers are set at every tagging site.** A node is owned if **either** matches —
  union, the fail-closed direction. The engine's tag root is `peerspeak.owned == 1` **OR**
  `node.name` starts with `peerspeak_owned_`.
- **`node.description` is NOT touched**, so mixers still show "mpv". Only `node.name`, which
  is the internal identifier, carries the prefix.
- The prefix mechanism is already proven here: `pixelpass_capture_*` is matched on
  `node.name` and was the only root that kept working under the F1 defect.
- Same three requirements as the property literal: one named constant per repo, the
  black-box cross-repo test driven from a shared fixture, and phase 5 as the real proof.
- ⚠️ Native call playback sets both on its own stream dict. The child spawns set the prefix
  through the same `PULSE_PROP` / `PIPEWIRE_PROPS` env that carries the property —
  `node.name` is settable there, and **the phase-1 exit gate must show it landing on a live
  mpv node**, not just in the env.

**A per-repo literal test is not a contract test.** Two tests, one per repo, each maintained
beside its own implementation, get updated in lockstep with a rename and prove nothing. Required:

1. The literal appears **once** per repo as a named constant, commented with a pointer to this
   section and to the other repo's constant.
2. A **black-box cross-repo test**: peerspeak constructs the child `Command`, the test reads the
   env it would set, and asserts it produces the exact property string pixelpass's engine
   matches on — driven from a single shared fixture string committed in both repos.
3. The real proof is the **Phase 5 dry-run**, which requires pixelpass to classify all three
   live peerspeak playback paths as `NotEligible` *for the tag reason*. Emission alone proves
   only that peerspeak talks, not that pixelpass listens.

**Exit gate:** `pw-dump` shows the tag on a native call playback node, an mpv node and a
notification node on this box. (Consumption is gated in Phase 5.)

**Non-goal:** the known grandchild-inheritance leak (v3.4 §5.1) stays accepted in v1.

---

## 4. Phases 2–4 — the engine (pixelpass)

### Phase 2 — graph model + taint engine, pure
All of v3.4 §6.1–§6.1.3, **with no PipeWire types in any signature**:

```
fn evaluate(snapshot: &GraphSnapshot, ctx: &ExclusionCtx, prior: &StickyState)
    -> (Decisions, StickyState)
```

- `GraphSnapshot` = plain owned Node/Port/Link/Client structs keyed on `u64` serial, with the
  recyclable id retained only as a lookup key, never as identity (v3.4 §6.1.3).
- `Decisions` carries `Eligibility::NotEligible { reason }` with **stable reason codes**, not
  prose. That code is the Phase 5 dry-run output, the Phase 6 JSON status event, and the
  eventual "why isn't this app shared" answer. Design it once, here.
- `StickyState` threaded explicitly, so stickiness is testable as a snapshot sequence.

**Every row of the v3.4 §12 taint-engine table is a required deliverable**, plus one addition:
an **empty/degenerate snapshot must yield "nothing eligible", not "everything eligible"** — the
fail-closed default asserted at the boundary.

**Exit gate:** fixture matrix green; the engine has never been linked against libpipewire.

### Phase 3 — registry observer + readiness epoch, read-only
v3.4 §6.3 and §6.4. Replaces (not extends) the existing router, which watches Node and Metadata
adds, forwards raw removals, and binds no graph (`src/host/audio.rs:523-585`).

> ⚠️ **Built and merged, then superseded in part by "Phase 3 revision (round 8)" below.** This
> section's node-property requirements assume the registry `global` event carries them. It does
> not (v3.5 §6.7). Everything here about removals, the readiness epoch, PID derivation and the
> Link path is unaffected and still holds.

- Node, Port, Link **and Client** globals; adds **and removes**.
- Link endpoint props from the global are the **optimisation**; the bind-`LinkInfoRef` fallback
  is the correctness path.
- Readiness = `core.sync()`/`done` **plus** no outstanding required observations, fail-closed
  timeout. Log which condition released the epoch.
- pipewire-pulse PID derivation (v3.4 §6.1.2): consistent `pipewire.sec.pid` across Pulse
  clients, validated against `/proc/<pid>/comm`.

**Exit gate — six parts.** A one-time `pw-dump` diff passes while Port observation is absent,
removals are ignored, the fallback is dead code, and readiness releases early:

| gate | proves |
| --- | --- |
| adapter tests: add **and remove** of all four object types | removal handling exists |
| forced-absent Link endpoint props | the bind fallback is live, not decorative |
| unresolved-observer timeout test | readiness fails closed |
| readiness does not release with an observation outstanding | the epoch means something |
| **PID derivation matrix** (round-2): consistent valid PID · inconsistent PIDs · missing client property · `/proc` entry missing · `comm` mismatch · PID reuse — **every failure makes owner-bridge key 4 unusable** | the pure engine can be correct on a wrong context; this is where the context is built |
| **live**: create and destroy a controlled node/link topology; diff Nodes, **Ports**, Links and Clients before/during/after | the adapter tracks a *changing* graph, not a static one |

### Phase 3 revision (round 8) — bind every Node and Device ✅ BUILT AND MERGED 2026-07-25
v3.5 §6.7. Phase 3 shipped reading node properties off the registry `global` event, where
**eight of them are never announced**. This is the fix. Scope is the observer only — phases 2
and 4 are unaffected, and the phase-5 audit machinery is already correct.

> **🟢 Done.** Pure core + adapter, split-seam with mutual review as in phase 3 (mine and
> Codex's respectively, each reviewing the other). All four gate rows below pass, the live
> row on this host. Codex's review of the core found no certain P1; two findings taken and
> mutation-verified (a `device_props` ambiguity test that checked for one live *Device*
> rather than one live *global*, and `device.api` corroborating by presence). Two findings
> left open as design items, both pre-existing — hardware playback-to-capture paths and the
> readiness-budget calibration. **Both are now closed by design v3.8 §6.9 / the pre-Phase-6
> round-11 gate below.**
>
> **Added beyond the spec: a second live gate for the Device-side path.** Row 1's
> `session_device` assertion is satisfied by a union, and WirePlumber 0.5.15 copies
> `device.api`/`alsa.driver_name` onto ALSA nodes on this host — so row 1 passes through the
> node fallback and would keep passing if the Device bind delivered nothing at all, leaving
> §6.7 decision 4 ungated on the development machine. Verified by mutation: breaking the
> Device-side driver read fails the new test while row 1 still passes.

**Requirements.**

1. **Bind every `Node` global**, unconditionally, no `media.class` filter. Retain the proxy
   and its `info` listener in that global's slot in the existing per-id FIFO
   (`LiveGlobal.bound_link` generalises to a bound-proxy slot).
   ⚠️ The phase-3 review's finding 3 — record the id and apply the add as **one** step, so
   the proxy FIFO stays lockstep with the model's `live_ids` — now applies on the **hottest**
   path in the observer. A recycled Node id must not pop another generation's proxy.
2. **The global is an index; `info` is the source of truth.** Read from the global only what
   must exist before the bind resolves: `object.serial` (identity), the object's id, and
   `device.id`/`node.id` linkage. **Every** taint-relevant property — including `node.name`
   and `media.class`, so there is exactly one source — comes from the bound `info` props.
3. **A node with no `info` yet is WITHHELD from the snapshot and is a readiness obligation**
   (`pending_nodes`, beside `withheld` and `pending_links`). `graph_ready` false while any is
   outstanding; the existing bounded deadline makes an unresolvable bind sticky-`TimedOut`,
   fail closed. No provisional-ownership admission, ever (v3.4 §6.1.3).
4. **Track props for the node's lifetime.** On a later `info` with `PROPS` in `change_mask`,
   re-read, re-classify, and apply a `NodePropsUpdated` event.
   ⚠️ **Suppression rule:** a prop update may be dropped **only** when the resulting
   `Projection` is identical to the current one. Anything looser breaks phase 4's
   no-coalescing contract; anything stricter (emitting on every `info`, including
   state-only changes) inflates the O5 event rate with non-events.
5. **Bind every `Device` global** and read `device.api` **and** `alsa.driver_name` from its
   `info` props — authoritative, and 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 rule would over-exclude real cards). `factory.name` exists only on the node.
   `classify` takes both sides; node values are the fallback, Device values win.
6. **Ports are NOT bound in v1 — an explicit accepted limitation.** `port.exclusive` is the
   only port property missing from the global, and it guards a *mutation* (don't fan out into
   an exclusive port), not echo: an exclusive port rejects the second link, so phase 6 sees a
   clean link-create failure it must handle correctly anyway. Binding ~21 more objects at
   rest to pre-empt an error that surfaces safely is not worth the obligation surface in v1.
   **Revisit trigger:** any phase-6 link-matrix row where an exclusive-port link failure is
   not cleanly recoverable. (`node.passthrough`, the *other* half of that §6.2 row, is a node
   property and **is** recovered by this revision.)

**Exit gate — four parts.** The first is the direct inverse of the F1 finding.

| gate | proves |
| --- | --- |
| **live prop recovery**: a `module-null-sink` tagged `peerspeak.owned=true` plus a `module-loopback` reading its monitor — assert the projection carries `peerspeak.owned`, `pulse.module.id`, `node.link-group` **and** `factory.name`/`device.api`/`alsa.driver_name` on a real ALSA node | the eight properties actually arrive — F1 cannot recur silently |
| **pure-model prop-update matrix**: props-changed → re-classified; identical props → suppressed; a `session_device`-relevant change flips classification. (A *live* prop mutation has no reliable CLI trigger — the pure test is the gate, a live sighting is opportunistic) | the lifetime-tracking path exists and its suppression rule is exact |
| **readiness with node binds**: no projection reports `graph_ready` while a node bind is outstanding; an `info` that never arrives ends in sticky `TimedOut` | withholding and fail-closed timeout still hold with the new obligation class |
| **recycled Node id under churn**: repeated add/remove of the same id; no proxy leak, no cross-generation misattribution | the FIFO lockstep rule survives being moved to the hot path |

**Then re-run the whole phase-5 §5.1 matrix and re-measure O5** with bind I/O included — the
existing numbers were taken on the degraded graph and inherit nothing.

### Phase 4 — AEC identity validation state machine, read-only
v3.4 §5.3 verbatim: `NotConfigured / Validating / Validated / Failed / Revoked`;
`--aec=off|pulse-module:<idx>` parsing (D5); bounded deadline; **no fan-out while `Validating`**;
revocation = loss of *all* nodes bearing the index, never one leg corking. Foreign
`echo-cancel-*` groups: warn and exclude (D3).

**Moved ahead of the dry-run gate (round-1 P1).** v1 put this after the dry-run while the
dry-run checklist required AEC validation and revocation semantics — a circular dependency that
made the major gate uncompletable as written.

**Exit gate — a fake-clock/event-sequence transition matrix**, because these are timing
semantics a live poke cannot cover: `Validating → Failed` on deadline expiry; `Validating →
Validated` on first matching node; partial-node disappearance ⇒ **stays `Validated`**; all
nodes gone ⇒ `Revoked`; `Revoked` stops fan-out and drops proxies; a retained stale index does
not alias onto a reloaded module (v3.4 §5.2 correction 3 — indices *are* reused). Parsing: JSON
number and string forms, `> u32::MAX`, absent, malformed.

Then wire the state machine's output into the dry-run so `Validating`/`Failed`/`Revoked` are
observable in Phase 5 before they gate anything real.

---

## 5. Phase 5 — dry-run audit mode 🚦 MAJOR GATE

> **🚦 STATUS 2026-07-26: GATE PASSED on run 2.** Results:
> `docs/screenshare-audio-exclusion-phase5-results.md`. All 13 rows completed, the eligible
> half of every row is non-empty, and O5 is re-measured on the fixed graph (worst recompute
> 67 µs; readiness 1–2 ms with 18 binds). Three rows carry recorded substitutions (8, 9, 13)
> and three findings are recorded as non-blocking.
>
> **Run 2 found and fixed a third defect of the F2 class, F13-1:** pipewire-pulse's PID was
> unresolvable on this host *permanently*, because stage 1 of the derivation required exactly
> one repeated `sec_pid` and **WirePlumber repeats one too** (two Clients, one PID). Key 4's
> suppression therefore never fired and every Pulse-emulated node fused into one owner. Fixed
> in pixelpass `91c4ded`: probe every distinct `sec_pid` and let `/proc/<pid>/comm` decide.
> **The eligible half of row 1 is the only thing that exposed it** — the verdict was
> fail-closed and silent.
>
> ⚠️ **Phase 6 is NOT unblocked by this file alone.** F11-1 was the other gate and is now
> **closed** (2026-07-26, pixelpass `c78eb2d`: key 4 bounds an owner only when the node's
> Client resolves; measured cost on the live graph, zero — see the results file). Phases
> 0b/0c/0d are satisfied and committed locally with S4/S5 in pixelpass `781defc`.
> **Round 11 (2026-08-21) closed the remaining hardware playback-to-capture and readiness
> gates; Phase 6 is now unblocked for development.**
>
> Two things to keep when re-running: **every partition row must run with `AEC=off`** (a
> configured-but-unvalidated AEC shuts the fan-out gate and empties the eligible half of every
> row, which reads as a failure that is really a harness error), and **start the audit BEFORE
> building the fixture**. Fixture-first makes the whole graph arrive as one enumeration burst,
> so every node is first tainted while `graph_ready` is false; that partial-graph taint enters
> sticky state and the keyless sticky reason then wins over the evidence-derived one, so a row
> cannot assert its own key. Read keys at *derivation* (first non-sticky appearance).

**Adds no capability. Its entire purpose is to be wrong loudly and safely.**

A hidden trigger (`PIXELPASS_AUDIO_AUDIT=1`) running Phases 2–4 against the live graph on every
graph event, emitting per `Stream/Output/Audio` node: serial, name, decision, stable reason
code, graph epoch. It creates **no links**. Output goes to **stderr or a defined JSON event** —
never unstructured prose into `--output json`, which peerspeak parses
(`screenshare/mod.rs:92`).

Why this is the gate: the C2/C3-class defects are graph-*reasoning* defects. A fixture proves
the code matches my model of PipeWire; only a live run proves my model matches PipeWire. A wrong
answer here costs a log line; the same wrong answer in Phase 6 costs an echo.

### 5.1 Every row asserts an exact partition, not a spot check

Round 2's sharpest structural point: checking only named targets constrains nothing about
everything else, so **each row must assert the complete candidate universe partitioned into
exact eligible and excluded sets, with reason codes on the excluded side.** That single
requirement is also the answer to O7 — it is the over-exclusion gate, because an
exclude-everything implementation fails the eligible half of every row.

| # | Scenario | Excluded (with reason code) | Eligible |
| --- | --- | --- | --- |
| 1 | `module-null-sink` + `module-loopback` forwarder (the v3.4 §6.1 measured shape) | output leg, reason = **owner bridge**, naming the key — *not* a Link walk | same forwarder shape with **no** tainted input |
| 1b | *opportunistic, non-gating:* Sunshine's null-sink topology while it is routing desktop audio | its forwarder leg, if a re-emitting leg exists | — |
| 2 | `gst-launch pulsesrc ! pulsesink` split clients, input **explicitly rooted on a tainted monitor** | output leg via key 4 | the same process reading an **untainted** source |
| 3 | **two** Pulse modules; **one** tainted input | the tainted module's output only | **the other module's output must be ELIGIBLE** — this is what makes wrong pipewire-pulse-PID fusion observable |
| 4 | peerspeak native call playback | that node, reason = tag | — |
| 5 | peerspeak-spawned **mpv** (watched share) | that node, reason = tag | mpv launched by hand |
| 6 | peerspeak **notification** sound | that node, reason = tag | — |
| 7 | a **second** pixelpass host's capture sink, **plus a controlled forwarder reading that sink's monitor** | the forwarder's **named output serial** (cycle prevention, v3.4 §6.2) | — |
| 8 | EasyEffects running | combined output leg | EasyEffects stopped ⇒ ordinary streams |
| 9 | Firefox: music only / mic on a **different Device** / mic on the **same Device receiving tainted playback** / capturing a tainted monitor | the third and fourth (`tainted-owner-bridge`) | the first two |
| 10 | sticky taint: tainted input leg removed, output leg lives | still excluded | after full owner teardown + restart |
| 11 | recycled serial/index/link-group after teardown | — | must **not** inherit taint |
| 12 | AEC loaded, then unloaded | four nodes; then `Revoked` | — |
| 13 | `Audio/Duplex` device | over-taints, recorded as **known accepted** (v3.4 §6.1 caveat) | — |

Rows 3 and 7 were vacuous in v2: row 3 had no tainted module, so incorrect fusion of all
pipewire-pulse modules changed no emitted decision; row 7 observed a capture sink without naming
a downstream candidate, so recognising `pixelpass_capture_*` as a mere sink name would pass
without any transitive propagation.

### 5.2 Also record, per O5
Graph-event rate, recompute duration **distribution and maximum**, and whether events queue
behind recompute/logging. v3.4 §6.4's "full recompute is fine for v1" then rests on measured
headroom and epoch lag rather than on a node count.

### 5.3 ⚠️ Do not build a gate on a transient topology
v1 leaned on v3.4 §6.1.0's "the hazard is LIVE on this machine right now." **Measured
2026-07-21 ~14:55 — no longer true**, six hours after it was written: `Default Sink` is
`alsa_output.pci-0000_10_00.6.analog-stereo` (IDLE), all three `sink-sunshine-*` null sinks
SUSPENDED. Sunshine is still running (pid 4104) and still reads a monitor — but the hardware
sink's, via active link `56 → 95`, not a null sink's. So Sunshine running is **not** sufficient
for the topology to be present; see plan §11.

The **controlled fixture (row 1) is authoritative** — deterministic and always available. But
the wild sample is not therefore unnecessary: a fixture I build tests my model against my own
assumptions, whereas Sunshine is an uncontrived third-party forwarder nobody designed for this
test. It stays as row 1b, **opportunistic and non-gating**, because it cannot be relied on to
be present.

**Any surprise here goes back to the design doc as a new measured round. Phase 6 did not start
until this results file existed; round 11's targeted addendum now pins the same-device rule.**

### 5.4 Pre-Phase-6 round-11 closure — ✅ PASSED 2026-08-21

Design v3.8 §6.9 and the addendum in
[`screenshare-audio-exclusion-phase5-results.md`](screenshare-audio-exclusion-phase5-results.md)
are the durable evidence. PixelPass retains snapshot-local `device.id` on positively classified
session-device nodes and adds a conservative `Sink → Source` taint edge only within that Device.
No ALSA control-name guess is part of the runtime policy.

Exit gates:

- 83 focused taint tests pass, including same-device exclusion, different-device eligibility,
  and the accepted same-device-microphone over-exclusion.
- 65 pure observer tests and all three serialized live PipeWire observer tests pass; a live
  passive device retains the `device.id` consumed by the engine.
- Full non-GUI suite: 313 passed, 0 failed, 9 ignored. The three live observer tests were then
  run explicitly and passed.
- Targeted live audit exact partition: tagged ALC897 playback excluded the same-ALC897
  capture/re-emitter as `tainted-owner-bridge`; the Arctis-source control remained eligible.
- Readiness: baseline 30 starts p95/max 10/11 ms; 48-module graph 30 starts 113/114 ms;
  20 starts during 250 create/remove cycles 6/8 ms, zero timeouts. Keep the 2 s budget.

All temporary modules were unloaded by their exact module ids, configured audio defaults were
unchanged, and root filesystem free space was 17 GiB after tests and Clippy.

---

## 6. Phases 6–8 — mutation, capability, integration

### Phase 6 — link manager + status events, driven through the real host path
v3.4 §4.2 + §6.2 + §6.3 items 3–4. Non-lingering links (rig gotcha: `object.linger=false` is
*ignored* by `pw-link --props` and `pw-cli create-link`; only `pw-link -m` yields one), proxies
retained for the life of the share, per-port link sets, "captured" only when **every** required
link is `ACTIVE`, same-epoch revalidation immediately before each creation, proxy drop on
ancestry becoming unsafe.

> **Status 2026-08-21 — first Phase 6 mutation slice built, validated and committed locally.** The
> hidden `DesktopExcluding` production path now launches a same-observer-callback link manager.
> It revalidates every recyclable id against its `object.serial` immediately before mutation,
> creates exact FL/FR (or MONO fan-out) links through `link-factory`, retains the proxies, marks a
> stream captured only when every required link is `ACTIVE`, and drops owned links before the
> capture sink on normal teardown. The links explicitly set `object.linger=false`.
>
> Twelve deterministic controller tests cover channel planning, recycled ids, idempotence,
> partial activation, late arrivals, newly unsafe ancestry, AEC gating/revocation and mutation
> failure. The full non-GUI suite passes with 326 passed, 0 failed and 10 ignored, and strict
> all-target Clippy is clean. The real hidden host path was also tested with a late stereo
> stream: exactly two native links became `ACTIVE`, no Pulse `module-loopback` fed the capture
> sink, and both links were revoked when the stream ended. A separate subprocess gate sent
> PixelPass `SIGKILL`; the connection-owned capture sink and both link object serials disappeared
> without Rust destructors. The three serialized live observer tests and `pixelpass --doctor`
> pass.
>
> **Status-event slice, committed locally as `5a65f50`.** PixelPass now emits
> exact version-1 `stream_unsupported`, `aec_failed`, `aec_revoked` and `foreign_aec_warning`
> records. The PipeWire callback only enqueues owned records; a Tokio-side forwarder performs
> JSON serialization and stdout I/O. Each cause has a direct emission test plus an exact JSON
> golden. Repeated graph ticks do not repeat a sticky failure, every leg of one foreign AEC
> collapses to one warning, an unindexed native AEC still counts as foreign, and `aec_revoked`
> is queued only after owned links are dropped. Full PixelPass: 330 passed, 0 failed, 10 ignored;
> strict all-target Clippy, both serialized Phase 6 live gates and `pixelpass --doctor` pass.
>
> **Capture-sink replacement slice, committed locally as `d09ee9b`.** The
> connection owner now recreates an unexpectedly removed non-lingering sink under the stable
> Pulse name but with a fresh exact serial. A narrow identity channel hands that serial to the
> fan-out observer, whose own main-loop command immediately reconciles its coherent snapshot:
> stale proxies are dropped before new links are created, and the stream returns to `Captured`
> only after every replacement channel is `ACTIVE`. The observer treats only PipeWire's
> asynchronous `-ENOENT` for a resource lost during ordinary graph churn as recoverable; every
> other Core error remains fatal.
>
> The pure recovery gate proves both stale links are dropped and two replacement links activate.
> A live owner-only gate destroys the sink and observes a fresh serial. The real hidden host gate
> destroys an actively linked sink, observes a new sink serial and two fresh `ACTIVE` links,
> proves both old link serials are absent, and leaves no residue. All three serialized Phase 6
> live gates pass together. Full PixelPass: 332 passed, 0 failed, 12 ignored; strict all-target
> Clippy, formatting, `git diff --check`, and `pixelpass --doctor` pass.
>
> This does **not** complete Phase 6. The remaining failure-matrix rows are still open. The public
> mode selector remains Phase 7 and PeerSpeak parsing/UI integration remains Phase 8. The last
> committed PixelPass checkpoint is `6be07ef`.
>
> **Row 1 plus Row 8d/8e slice — built, validated and committed locally as `6be07ef` on
> 2026-08-21.** The production
> link mutator now applies serial revalidation to retained proxies as well as new requests; a
> node/global-id recycle between planning and mutation yields zero `create_link` calls and drops
> any stale retained intent. `CapturePlan` has one injectable production construction seam, and
> separate optimized-release gates prove both capture-sink construction failure and the
> observer's sticky readiness timeout escape `DesktopExcluding` as errors without ever resolving
> the default monitor or constructing legacy `Routing`.
>
> The full suite passes with 335 passed, 0 failed and 12 ignored. All three new gates pass in an
> optimized release build; strict all-target Clippy, formatting and `git diff --check` pass. All
> three serialized Phase 6 live audio-plan gates still pass, `pixelpass --doctor` passes, and the
> final Pulse/PipeWire/process residue scan is empty. Nix remains unavailable, so validation used
> system Rust 1.97.1. The disposable 1.0 GiB release cache was removed afterward, leaving 12 GiB
> free. Next is the independent Row 6a/6b/6c refusal/reason slice; Phase 6 is not complete.

> **Row 6a/6b/6c slice — built, validated and committed locally as PixelPass `956534f` on
> 2026-08-21.**
> The bound Node observer now subscribes to the configured `SPA_PARAM_Format` only for
> `Stream/Output/Audio` nodes that advertise it as readable, and classifies the native libspa
> format as raw, encoded or IEC958. Missing/unparseable format evidence is a per-stream
> fail-closed `format-unknown`, not permission to link. `node.passthrough` stays a separate
> predicate, so it cannot be masked by the format classifier.
>
> Three independent controller fixtures prove Row 6a `port.exclusive`, Row 6b encoded and Row
> 6c IEC958 each make **zero** link-creation attempts and emit exactly their own
> `stream_unsupported` reason (`port-exclusive`, `encoded`, `iec958-passthrough`) once. A fourth
> gate independently exercises explicit `node.passthrough`; another proves the ordinary
> Node-info-before-Format ordering stays fail-closed without emitting a transient false warning,
> then captures once raw PCM arrives. Row 6a remains the accepted injected-graph gate: v1 still
> does not bind Ports, so a live exclusive port reaches the already-gated clean link-failure path.
>
> The first read-only live audit exposed and prevented two observer defects before completion:
> generic Pod deserialization rejected the real Format object, and enumerating Format on every
> driver Node produced expected ENOENT/EIO core errors. The corrected path uses libspa's native
> format parser and the Node's advertised readable-param list. A second live audit observed
> Strawberry, FFXIV and Chromium settle from `format-unknown` to eligible raw PCM within the
> initial callback burst, with no parse or core errors. Full PixelPass validation is 342 passed,
> 0 failed and 12 ignored; all three Row 6 gates pass in an optimized one-job release build;
> strict all-target Clippy, formatting, `git diff --check` and `pixelpass --doctor` pass. The
> 966 MiB disposable release cache was removed and disk space returned. Next is the revised Row
> 9 positive/negative partition; Phase 6 is not complete.

> **Revised Row 9 slice — built, validated and committed locally as PixelPass `7b11827` on
> 2026-08-21.**
> The former single music-only late-arrival fixture is now four independent controller gates
> carrying Phase 5's exact Firefox partition across the Phase 6 mutation boundary. Music-only
> and a Firefox output whose microphone is on a different Device each create exactly two stereo
> links and reach `Captured`. A Firefox output whose input reads the same Device receiving
> tainted playback, and one reading that sink's tainted monitor, each retain
> `tainted-owner-bridge`, make zero link-creation calls and hold no proxies. Every case is
> re-driven unchanged to prove idempotence.
>
> Full PixelPass validation is 345 passed, 0 failed and 12 ignored; all four focused Row 9 gates,
> strict all-target Clippy, formatting and `git diff --check` pass. The three serialized live
> Phase 6 mutation gates — late eligible fan-out/cleanup, sink replacement/relink and SIGKILL
> cleanup — pass, `pixelpass --doctor` passes, and the final Pulse/PipeWire/process residue scan
> is empty. Nix is unavailable, so this slice used system Rust 1.96.1. The deterministic
> link-manager matrix is now complete. Phase 6 remains open on its production-path three-arm AEC
> leak measurement re-run, whose naive positive control must exist only behind a test seam.

> **Production-path three-arm AEC leak qualification — built, validated and committed locally as
> PixelPass `e027bc6` on 2026-08-21. Phase 6 is complete.** A `#[cfg(test)]` policy can re-admit
> only the exact configured `aec-identity` candidate; the unsafe policy, constructor and field do
> not exist in a production build. A focused controller gate proves safe mode retains only the
> ordinary stereo pair while the deliberately naive mode adds exactly the AEC playback pair.
>
> The ignored serialized live gate drives the hidden `DesktopExcluding` selector through the real
> connection-owned sink, registry observer, taint controller, native link manager and
> `<sink>.monitor` Pulse source. It loads one exact-ID WebRTC echo-cancel module per arm, filters
> `media.class=Stream/Output/Audio` before matching `pulse.module.id`, retains and reports every
> child stderr stream, verifies the exact graph links, and records 48 kHz stereo s16le with
> `parec`. The guarded and naive arms inject a PeerSpeak-owned 1500 Hz stream into the real AEC
> sink; all arms retain an ordinary 440 Hz desktop stream.
>
> Two complete runs passed the thresholds declared in the test. Desktop 440 Hz stayed between
> -32.84 and -34.05 dBFS. The guarded arm's 1500 Hz result (-75.76 to -78.03 dBFS) never rose more
> than 3 dB above its run's control floor, while the naive positive control measured -33.80 to
> -33.92 dBFS, comparable to its desktop tone and at least 18 dB above both control and guarded.
> The supported conclusion is deliberately narrow: **no incremental 1500 Hz energy was detectable
> above the control floor at this analysis resolution**; this is not a claim that remote audio is
> absent. Phase 9 still owns the PN/MLS intelligibility rig and field variance.
>
> Final PixelPass validation: 346 passed, 0 failed and 13 ignored; strict all-target Clippy,
> formatting, `git diff --check`, `pixelpass --doctor`, and all four serialized Phase 6 live audio
> gates pass. The final Pulse/PipeWire/process/temp-file residue scan is empty. Nix is unavailable,
> so validation used system Rust 1.96.1. The next implementation front is Phase 7's public mode
> selector and versioned capability advertisement.

Failure ⇒ report the stream unsupported. **Never** fall back to the default monitor — and after
0d that fallback is unconstructible in this mode, by either path.

**Everything here is measured through the real selector → sink → link manager → `pulsesrc`
path**, using 0d's hidden trigger. A harness-only measurement would pass while the production
CLI still reaches only legacy branches.

**Status events land here** (round-1/2: no phase owned them). pixelpass's event enum
(`src/common/output.rs:36-66`) has nothing for exclusion status, and capture-spawn failure
(`host/mod.rs:309-312`) replies to the viewer while emitting no event at all. Required as
**versioned wire-shaped events**, not stderr lines. Without them the safe failure mode is
unexplained silence after the first viewer connects — and a sharer who cannot see why will
switch back to unsafe whole-desktop audio.

There are **four** production causes, and each needs an **exact JSON golden plus a cause →
emission test** — not a shared "an event is emitted" assertion, which passes while three of the
four remain unwired:

| cause | event | trigger under test |
| --- | --- | --- |
| per-stream link failure | `stream_unsupported` | link-matrix row 8c |
| AEC validation deadline | `aec_failed` | Phase 4 `Validating → Failed` |
| AEC identity lost mid-share | `aec_revoked` | Phase 4 `Validated → Revoked` |
| foreign `echo-cancel-*` present (D3) | `foreign_aec_warning` | a second AEC module loaded |

Phase 8 owns the other half of each: parse, traverse the **new mode's** notice channel, and
reach the intended UI state. The channel is currently created only for `audio_app`
(`core/mod.rs:3424`) and only the two `AppAudio` events are translated (`:3431-3434`).

#### 6.1 Link-manager matrix (local anchor — v3.4 §12 has only a one-line bullet)

v2 pointed its most important gate at a "v3.4 §12 bookkeeping matrix" that does not exist.
Here it is. Each row is a deterministic test with an injected graph, not a live observation:

| # | Case | Assertion |
| --- | --- | --- |
| 1 | graph mutated to tainted **between evaluation and `create_link`** | **zero unsafe `create_link` calls** — not "eventually cleaned up" |
| 2 | per-port enumeration | exact set of attempted links and their states |
| 3 | partial activation (FL `ACTIVE`, FR not) | **not** reported captured |
| 4 | duplicate enumeration of the same node | idempotent; no second link set |
| 5 | ancestry becomes unsafe after `ACTIVE` | owned proxies dropped |
| 6a | `port.exclusive` port | refused, reason code emitted |
| 6b | encoded stream | refused, reason code emitted |
| 6c | passthrough (IEC958) stream | refused, reason code emitted |
| 7 | capture sink replaced | relink **succeeds** — every required port back to `ACTIVE` and the node reported captured again; stale proxies dropped |
| 8a | AEC `Failed` (validation deadline) | plan stays `DesktopExcluding`; no capture |
| 8b | AEC `Revoked` mid-share | plan stays `DesktopExcluding`; fan-out stops |
| 8c | link creation error | plan stays `DesktopExcluding`; that stream reported unsupported |
| 8d | capture-sink creation failure | plan stays `DesktopExcluding`; mode fails, does not degrade |
| 8e | readiness-epoch timeout | plan stays `DesktopExcluding`; fail closed |
| 9 | an **eligible** late-arriving node | **positively captured** — the over-exclusion counterpart to row 1 |

Rows 6a–6c were one combined fixture in v3: a single working refusal predicate would have
masked two missing ones. Row 7 required only "relink attempted", which a permanently-failing
attempt satisfies while v3.4 §4.2 requires a live owner to actually restore links after sink
recreation. Rows 8a–8e replace an unenumerated "any failure path", under which testing one
handler passes while another silently swaps the plan to `LegacyDesktop`.

Row 1 is the one v2 could not falsify: "clean → tainted mid-share ⇒ links dropped" can pass by
observing eventual removal, while an unsafe link genuinely existed for a window. Row 8 is O8's
answer: 0d's enum prevents a `DesktopExcluding` value from *containing* `DefaultMonitor`, but
not a failure handler from replacing the whole plan with `LegacyDesktop`, so this needs a
release-mode integration test per failure transition. A `debug_assert!` is cheap and worth
adding, but it is not a gate.

Plus the live dynamic matrix (SIGKILL removes owned links; node appearing after share start;
sink recreation) and the three-arm leak measurement re-run through the production path with
v3.4 §12's rig discipline (`media.class` filter first, never drop stderr, verify the link is
in-graph, `parec -d <sink>.monitor`). The deliberately-naive predicate used as that
measurement's positive control lives in a **test-only injected implementation**, never a
shippable runtime override.

### Phase 7 — public mode selector + capability advertisement (pixelpass ships first)

⚠️ **Round-3 P1: nothing in v3 ever promoted the hidden trigger to a public flag.** 0d added an
internal mode input; Phase 7 advertised capability and naming; Phase 8 added `--aec`, the picker
and status. No phase required the actual **mode selector** to exist publicly or to be passed.
The result would be a capability-gated picker entry that, when chosen, still spawns legacy
whole-desktop capture — the feature appearing to ship while doing nothing. Reachable: peerspeak's
host argv has no mode parameter (`screenshare/mod.rs:152`) and pixelpass's `HostOpts` has no mode
field (`cli.rs:153`); v3.4 §11 requires a distinct mode selector.

So Phase 7 lands **both**:
1. the public mode flag (naming per v3.4 §11), replacing the 0d hidden trigger as the production
   entry point — the hidden trigger may remain for testing;
2. D2's **versioned machine-readable** capability response or bitset. Must **not** overload
   `app_audio_supported: bool` — per-app-strict and desktop-excluding are independent
   capabilities. `--help` substring probing survives only as the legacy fallback.

**Old-peerspeak + new-pixelpass golden test lands here, before pixelpass ships**: behaviour
byte-identical, absent `--aec` still accepted.

v3.4 §11 public naming is a **blocking user input at the start of this phase**. Internal typed
variant names (0d) do not block on it.

### Phase 8 — peerspeak integration
- `EchoCancelGuard::module_index()` accessor (currently private; only `source_name()` /
  `sink_name()` exist).
- **Emit the public mode flag** when the new picker choice is selected. Gated by an **exact
  new/new argv golden** that requires *both* the mode flag and `--aec=…` to be present — the
  round-3 P1. An argv test that checks only `--aec` passes while the mode flag is never sent and
  pixelpass silently runs legacy capture.
- Always pass `--aec=off|pulse-module:<idx>` — absence is not a protocol state (D5).
- **Bind capability to the resolved binary path**; re-probe immediately before constructing
  new-mode argv if resolution differs from the probe's; fail closed. Closes the `:3375-3378`
  vs `:3409-3419` double-resolution gap.
- **New-peerspeak + old-pixelpass golden test** (round-2: the phase map promised "both
  directions" and only one was specified) — against an old-capability response and an old fake
  binary: the new picker entry stays absent and **no new flags are emitted**. Path rebinding
  alone does not test the failure policy.
- Parse and surface the Phase 6 status events, with a **causal** test: an event emitted by
  pixelpass must reach the UI. Matching enums defined independently in both repos would
  otherwise pass. The notice channel is currently created only when `audio_app` is set
  (`core/mod.rs:3424`) and only the two `AppAudio` events are translated (`:3431-3434`);
  everything else is logged and lost — so the new mode needs its own channel creation path.
- Capability-gated picker entry; wording per v3.4 §11.
- Regression: existing `--app` / `--strict-audio` argv byte-identical to today.

---

## 7. Phase 9 — rig upgrade and field tests 🚦 SHIP GATE

v3.4 §9.2's rig upgrade is **owed before any exclusion claim is published**: two orthogonal
PN/MLS probes, windowed per-channel normalised cross-correlation reporting max per-window
correlation, plus xrun telemetry. Until it exists the only defensible claim is the gross-leak
distinction, in v3.4 §9.2's exact wording.

**Every row gets a declared pass/fail threshold before the run, not after.** Baseline for all
rows: excluded probe ≤ the declared rig criterion; **eligible control audio present**; original
playback routes intact; zero surviving owned links or capture sinks after teardown; xrun and CPU
within recorded bounds.

The **full** v3.4 §12 matrix — v1 silently dropped rows 2 and 7:

1. Sharer in a call while sharing, AEC on **and** off.
2. **Sharer simultaneously viewing another share while sharing** (restored). Highest-value test
   of the child-tag path: mpv playing a watched share while hosting. Reachable —
   `StartScreenShare` stores a host at `core/mod.rs:3458`, `ViewShare` stores viewer children at
   `:3525`, no mutual exclusion.
3. Lifecycle, each separately: Stop, room leave, UI crash, pixelpass panic, SIGINT, SIGTERM,
   SIGKILL, last viewer, pipewire-pulse restart, PipeWire daemon restart, `--repair`.
4. EasyEffects running for the whole share.
5. Output-device switch mid-share via the real `Ctrl+Meta+F` / `Ctrl+Meta+S` scripts.
6. Two concurrent hosts; notification mid-share; app that starts playing after the share.
7. **Sample-rate / channel / passthrough behaviour on real sinks, and CPU cost** (restored).

⚠️ **Mid-share taint-root arrival — v3.4 §6.1.4 names an unreachable case, and so did my first
replacement.** v3.4 says "AEC-load-mid-share is the case to test": unreachable, because there is
exactly one `echo_cancel::enable` site at session join (`core/mod.rs:1850`), the guard moves
into `ActiveSession` at `:2729`, and `StartScreenShare` rejects `active_session == None` at
`:3397-3404` ("Join a call before sharing your screen") — so the AEC always predates the share.
My proposed replacement, "a peer joining creates their playback node," is **also wrong**:
peerspeak starts **one mixed playback stream** at session construction (the sole core
`start_playback`, `core/mod.rs:1900`), and `PeerJoined` (`:2396-2409`) only admits and connects
the sender. No per-peer node is ever created.

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 hot reload arms it.

---

## 8. Open questions — final status

| # | Question | Status |
| --- | --- | --- |
| O1 | Is 0c a true blocker? | **CLOSED — yes, no demotion path.** The *echo* argument for demoting it is sound and irrelevant: D6 settled it. Balloon ⇒ round 8. |
| O2 | Ship the dry-run mode? | **CLOSED — keep**, env-gated, stable reason codes + epoch + serial, stderr or defined JSON event so `--output json` stays clean. |
| O3 | Naming | **SPLIT.** The `peerspeak.owned` wire literal is pinned in plan §3 now (a contract, not product wording). Public mode/picker wording remains the **user's call**, blocking at the start of Phase 7 only. |
| O4 | Sticky state: engine or observer? | **CLOSED — pure engine.** Observer supplies lifetime-bearing membership/removal facts; the engine decides. |
| O5 | Is full recompute really fine? | **CLOSED — measure it in Phase 5**: duration distribution, maximum, and queueing, not a recompute count. |
| O6 | Can the default-monitor fallback be made structurally impossible? | **CLOSED — yes, but it took two closures, not one.** Source path *and* sink-input path, both in 0d. Placement 0d rather than Phase 6 was my divergence; Codex agreed in round 2 with the added constraint `0c → 0d`. |
| O7 | Does over-exclusion need its own gate? | **CLOSED — subsumed.** Codex correctly narrowed my premise: an exclude-everything build already fails the eligible controls in six Phase 5 rows *provided they are asserted*. Fix is the exact-partition requirement (plan §5.1) plus link-matrix row 9's positive capture assertion. |
| O8 | Runtime assertion for "no source switch on failure"? | **CLOSED — 0d's enum is insufficient.** It stops a `DesktopExcluding` value containing `DefaultMonitor`, not a failure handler swapping the whole plan for `LegacyDesktop`. Release-mode integration test per failure transition (link-matrix row 8); `debug_assert!` in addition, but it is not the gate. |

---

## 9. Risk register

| Risk | Where it bites | Mitigation |
| --- | --- | --- |
| Engine correct, **source** wrong | full echo, engine bypassed | 0d path 1 — typed capture plan |
| Engine correct, **sink inputs** poisoned | full echo, no source switch anywhere | 0d path 2 — bare sink type + conflict rejection + graph assertion |
| Taint engine subtly wrong about real PipeWire | Phase 6 leaks the call into the share | Phase 5 exact-partition gate with negative controls |
| Unsafe link exists briefly, then is cleaned up | a real leak that "eventual cleanup" tests score as a pass | link-matrix row 1: zero unsafe `create_link` calls |
| Link manager built before the engine is validated | same, discovered in front of a viewer | strict DAG; plan §0's stated temptation |
| Tag literal mismatch across repos | v3.4 §5.1 silently does nothing, *quietly* | literal pinned in plan §3; cross-repo black-box test; consumption gated in Phase 5 rows 4–6 |
| Cross-repo skew | share hard-fails on spawn | pixelpass-first; a golden test in **each** direction; capability bound to resolved path |
| Fail-closed with no explanation | user switches back to unsafe whole-desktop audio | causal status-delivery test, Phase 6 → Phase 8 |
| Over-exclusion ships as "working" | mode captures silence, all gates pass | exact partitions (plan §5.1) + link-matrix row 9 — **🟢 FIRED 2026-07-25 and worked**: the build *was* the exclude-everything degenerate case, and the empty eligible half is what exposed it |
| **A property the engine reads is silently absent at the observation boundary** | engine correct, context permanently `None`; fails in *both* directions at once (F1: no taint root ⇒ echo; F2: no owner key ⇒ exclude everything) | **v3.5 §6.7 — never read node/device props off a registry global.** Phase 3r's live prop-recovery gate asserts each one arrives. General form: `pw-dump` is a **bound** view; the registry is not, and the difference is silent |
| Wrong pipewire-pulse PID | mass over-exclusion from a correct engine on a wrong context | Phase 3 PID-derivation matrix |
| 0c balloons | prerequisites eat the schedule | reopen D6 as round 8 — no silent waiver |
| "It works on my box" | the only box is this box | two-machine field test is the ship gate |

## 10. Adjudication record

**Round 14 (2026-07-26) — 0b's five-mutation matrix is revised to four, and one pinned
mutation is retired as vacuous.** Reached independently by both reviewers, then agreed.

- **Mutation 2 cannot be killed by any test, because its site cannot execute.** The
  best-effort wake arm (`core/mod.rs`, the `besteffort_wake_rx` close arm) is unreachable
  **by construction, twice over**: (i) `run_core_loop` owns a clone of `besteffort_wake_tx`
  — created at `CoreController::new` and used for the `has_more` re-arm inside the loop —
  and a tokio `Receiver::recv()` yields `None` only once *every* sender is dropped; (ii)
  even without that clone, both `CoreController` and `CoreCommandSender` hold `reliable_tx`
  alongside the wake sender, and the `select!` is `biased` with the reliable arm first, so
  the reliable arm always wins the race to exit. Writing teardown there would be code that
  provably never runs, dressed as a tested path.
- **Mutation 1's site is reachable but not unit-testable.** It sits inside `run_core_loop`,
  which builds a real iroh endpoint and loads identity; no unit test can drive it.
- **Decision: (b) + (c).** Teardown is **hoisted to one unconditional site after the loop**,
  so every `break` is covered structurally, including any added later — strictly better than
  duplicating teardown across one live arm and one dead one. The **seam-level mutation gates
  are the real ordering proof**, and the call site's live proof is owed to **phase 9**, which
  gains an explicit row: *drop the controller / close the command channel while sharing*.
  "UI crash" is not precise enough to serve as that row.
- **Rejected: an integration test built to preserve the number five.** It would pay for a
  full iroh core plus test-only observability and prove only that a method was called — not
  the ordering invariant, which is the thing that actually breaks.
- The 0b gate is therefore **four mutations**, enumerated exactly (round 16 P3-5 — the earlier
  wording said "three plus the 0c pair", which reads as five and blurred what 0b owns):

  | # | mutation | killed by | status |
  |---|----------|-----------|--------|
  | 1 (old gate 3) | remove the wait after the host kill | `explicit_shutdown_reaps_the_host_before_the_aec_can_unload` | killed now |
  | 2 (old gate 4) | reverse `ScreenshareTeardown`'s field declaration order | `the_aec_unloads_after_the_children_on_the_drop_path` | killed now |
  | 3 (old gate 5) | remove the reap loop from `ReapOnDrop::drop` | `dropping_a_guard_kills_and_then_reaps_the_child` | killed now |
  | 4 (old 1) | remove the teardown at the hoisted post-loop call site | — | **deferred to the phase-9 row** *drop the controller / close the command channel while sharing* |

  4-vs-5 separation verified: reversing the field order leaves the reap test green, and
  removing the reap loop leaves the ordering test green. **0c's own pair (no SIGINT · no
  SIGKILL fallback) is counted under 0c, not here**, along with the round-15/16 additions
  (disarm the wrapper at entry · disarm it between the waits · treat a wait error as a reap ·
  report an unconfirmed stop as clean · zero the grace).

**Round 15 (2026-07-26) — review of the 0b/0c-peerspeak implementation returned two blocking
findings, both accepted.** Recorded because both are the same shape: a defence that existed
but was disarmed exactly when it was needed.

- **The drop fallback was disarmed across its own wait.** `shutdown` took the child out of
  the wrapper before the first `.await`; a cancellation or unwind during the wait left the
  raw child to drop with `kill_on_drop` (which signals without reaping) while `Drop` found
  `None`. The child now stays owned until the reap is **confirmed**.
- **A failed wait was reported as a reap, and the hard-kill wait was unbounded.** The
  `io::Result` was discarded, so a wait error returned "reaped"; and a process in
  uninterruptible sleep after SIGKILL could wedge the core loop forever. Both waits are now
  bounded and the conflict case has a written policy: availability wins, the child stays
  owned so the bounded `Drop` retry stays armed, and the residual risk is logged.
- **A gate of mine was vacuous and the review's fourth test-double point caught it.** The
  elapsed-time assertion compared against `STOP_GRACE` itself, so zeroing the constant left
  it trivially true. `the_grace_is_a_real_interval` now pins the constant to a band.

**Round 16 (2026-07-26) — the re-review of the 0b/0c-peerspeak fixes returned *approve with
follow-ups*: no blocking findings, five P3s, all five applied before the merge.** The two that
carry design content:

- **An unconfirmed stop was reported to the user as a clean one.** `stop_host` returned a bare
  "was sharing" bool, so the one case where availability-first gives up (SIGKILL queued, reap
  never confirmed) still emitted `ScreenShareStopped` with no warning — the UI would say
  sharing ended while pixelpass might still be fanning out. `ReapOnDrop::shutdown` now returns
  `StopOutcome`, `stop_host` returns `Option<StopOutcome>`, and an `Unconfirmed` user-initiated
  stop raises a UI error naming the stray process. Session/viewer teardown discards the outcome
  on purpose: no user is waiting on an answer there and the risk is already logged.
- **Cancellation coverage only reached the graceful wait.** The mid-wait test could not kill a
  mutant that disarmed the wrapper *between* the two waits. Verified: the naive form of that
  mutant does not compile (the child is borrowed from `self`), but the restructured form —
  `self.child.take()` once cooperation has failed — compiles, and the pre-existing test passes
  it. `cancelling_shutdown_after_the_kill_leaves_the_fallback_armed` kills it.

**Deferred item — aggregate teardown latency (round 16 P3-4).** Bounds are per child, not per
teardown. Sequential drain gives `2 × STOP_GRACE` per unconfirmed child inline (≈4 s), plus
`REAP_BUDGET` (250 ms) per child on the `Drop` path: three wedged children ≈6 s of command-loop
stall, ≈12.75 s worst case including drop retries. Accepted as-is for 0b — one host plus one or
two viewers is the real shape, and concurrency here would mean detaching children from the
session that owns the AEC's lifetime. **Trigger to revisit: a fourth tracked child becomes
routine, or a measured teardown exceeds 5 s.** The fix, when triggered, is to drain viewers
concurrently while still owned by `shutdown_children` — not to detach them.

**Round 17 (2026-07-26 night) — two reviews: the repair planner (changes-requested, all applied)
and the 0c actor design (four blocking issues, all accepted).** 0c is now sliced, because the
fault-handling surface — not the design — is what grew.

*The repair planner: no P1s, four reachable P2s and a P3, all applied in `9145b2a`.*

- **Only the canonical forms are ours.** `classify` recognised any loopback with one
  pixelpass-looking endpoint, so a third party's `module-loopback source=some_mic
  sink=pixelpass_capture_4242` was ours to unload once that pid died; and a `sink=` token nested
  inside a quoted `sink_input_properties` value could be read as a top-level argument. The whole
  recorded argument string must now equal what pixelpass itself writes.
- **The matcher's templates are generated from the loader's own renderers.** Hard-coding
  `latency_msec=20` beside a matcher means a loader change silently blinds repair to every module
  the new build loads — the fail-closed-and-silent class this project has now been bitten by
  three times (F2, F13-1, the sticky-uncertainty inversion). `host/audio.rs` loads through the
  same renderers, so drift is a compile-time question. Blindness is also *reported*:
  `unrecognised_pixelpass_modules` names anything matching `pixelpass_capture_*` that no
  canonical form recognises, so a newer pixelpass's shapes cannot make an older `--repair`
  quietly clean up nothing.
- **Ordering is not a licence either.** Planning loopbacks before the sink is necessary and
  insufficient: an unload can fail or be skipped, and a loopback can appear after planning. The
  sink unload is now gated on `sink_still_referenced` against the fresh snapshot — any other
  module naming that sink blocks it, ours or not, because the question is what would break rather
  than who owns it.
- **Undecidable is not dead.** `Path::exists()` maps a permission error, a missing `/proc` and a
  foreign pid namespace all to `false`, which read here as "dead, unload it". Liveness is now
  `Alive | Dead | Unknown` via `try_exists()` behind a `/proc/self/stat` preflight, `Unknown`
  behaves exactly like `Alive`, and it is reported separately so holding back is visible.
- **⚠️ One prescribed fix was not implementable as written, and measuring first is what caught
  it.** The reviewer's fix for fingerprint fidelity was "use `pactl -f json list modules` and
  deserialize the complete `argument`". **On pactl 17.0 those records carry no module index at
  all** (`"index": null`), and `unload-module` accepts only an index — JSON alone cannot drive
  repair. Replacement: **two listings, correlated positionally and checked** (ids and names from
  the short listing, exact arguments from JSON; equal counts and equal names at every position or
  the run refuses, with retries for a concurrent load). Verified on this host: both listings
  return the same 17 modules in an identical name sequence, from 41 physical lines. The check
  also turns the reviewer's fabricated-row attack from exploitable into harmless — a crafted
  short-listing line has no JSON counterpart, so the sequences misalign and repair stops instead
  of unloading an index inferred from text. *This is the reachability rule applied to a
  prescription rather than a finding: the chain was valid, the API it assumed did not exist.*
- **Measured before relying on it (pactl 17.0, live server):** recorded arguments come back
  byte-for-byte as passed, joined with single spaces, in order, and **`@DEFAULT_SINK@` is not
  resolved** to the concrete device. Both facts are load-bearing for exact matching — had either
  been false, the P2 fix would itself have been a silent blinding — so both carry a test.
- Normalisation deleted (P3): within one invocation every snapshot comes from one server, so
  re-rendering does not happen and normalising only made different arguments compare equal. The
  residual ABA window (planned module vanishes, a byte-identical one takes its index) cannot be
  closed through an index-only unload API, and is now stated as a limitation in `Fingerprint`'s
  own doc comment instead of implied away.
- Five vacuity gaps closed: a raw-pactl-text-to-plan test (the whole planner suite survived a
  parser that dropped every argument), per-pid liveness counters over two pids, non-canonical and
  nested-quote cases, and a reference-gate test. **One gap deliberately left open and declared:**
  a comparator using only `id + args` cannot be killed by a non-vacuous test, because the module
  *name* determines which argument grammar can match at all — that field is enforced structurally
  by `classify`, and a test appearing to cover it would be the self-satisfying kind.
- **Field-verified twice on the live graph:** the A/B orphan test still removes exactly the two
  orphans with the module table otherwise byte-identical, and a new fixture — a dead pid's legacy
  sink plus a *non-canonical* loopback naming it — unloads nothing, reports the unrecognised
  module, and reports the sink as still referenced.

*The 0c actor design: four blocking issues, all accepted; the epoch requirement conceded.*

- **A bounded join must not move the OS handle into `spawn_blocking`.** My ladder would have
  taken the thread handle out of the guard to poll it; if the close future is then cancelled or
  unwinds, `Drop` finds no handle and can neither poison nor fail-stop, while the blocking task
  stays wedged forever and can pin runtime shutdown. This is the **same defect shape as round
  15's** — a defence disarmed exactly when needed. The handle stays owned across every await;
  `is_finished()` is polled and `join()` called only once it reports finished. Same rule for the
  event task's handle (`await` through `&mut JoinHandle`).
- **`Commit::UnloadNow(id)` cannot forget the id.** An immediate unload can time out or be
  cancelled, and a ledger that never recorded the module cannot retry or reconcile it. Slots
  become a state machine — `Vacant | Loading { token, expected } | Loaded { fp } | Unloading
  { fp }` — with **affine** permits carrying a unique token, so two permitted loads for one slot
  cannot both commit.
- **`kill_on_drop` does not roll back a server-side mutation.** A bounded `pactl load-module`
  killed after the server created the module but before its id was read leaves a module with no
  id anywhere. So an ambiguous load requires **bounded reconciliation by fingerprint** — reusing
  repair's classification idea inside the live session, never its dead-pid policy — before any
  further capture may start. Related: cancellation must never be `select!`ed against
  `Command::output()`, or a completed load's id is dropped on the floor.
- **`_exit` is right, but the pre-exit sequence must not be able to block.** Event emission,
  stdio flushing and tracing all take locks a wedged thread may hold, so the watchdog able to
  `_exit` past a stalled diagnostic has to be **armed before** the wedge is detected, not created
  in response to it. And `_exit` skips `CaptureHandle::Drop`, so `gst-launch-1.0` and any
  in-flight `pactl` need parent-death/process-group containment or they outlive the host that
  reported its own death — with gst still holding screen-capture resources.
- **Epoch conceded, and my vacuity instinct was right.** `object.serial` is unique and never
  reused while global ids are, so "the object at this id still has the serial I recorded" is
  complete proof of identity; there is no same-core interleaving that serial equality misses.
  Epoch is carried for diagnostics and explicitly **not** a gate. It would only become
  load-bearing across a daemon incarnation or an actor reconnect, and the design makes core
  failure terminal with no reconnect — if that changes, the right answer is a core-incarnation
  nonce, not a "something churned" counter that invalidates observations on unrelated traffic.
- **"Unjoinability, not slowness" is not literally implementable** and the wording is corrected:
  no bounded observation distinguishes "returns one millisecond later" from "never returns", so
  the death condition is *failure to terminate within the post-cancellation policy deadline*.
  Two budgets, not one — a running MainLoop quitting is a different question from an
  initialisation call returning after cancellation, and the second is normally longer.
- **`GraphCmd::Route(Vec<u32>)` is deleted rather than fixed.** Matching and routing stay inside
  the actor's registry callback, where removals are already ordered against routes in-thread, so
  the privacy race is not introduced at all. For phase 6 the rule is structural: the only
  addressable type is an `ObservedNode { global_id, serial, epoch }` constructible solely from
  the actor's own observation, kept private and non-`Copy`, revalidated on serial immediately
  before any mutation. A bare id is not addressable.
- **An unacked `ClearRoutes` is not a wedge** (agreed), with one qualification taken: a stream
  setting `node.dont-reconnect`/`node.dont-fallback` may be left silent rather than moved back to
  the default, so the outcome is surfaced as `ClearRoutesUnconfirmed` rather than treated as
  benign. Separately, blindly clearing `target.object` can erase a target the user set manually —
  the prior value must be recorded and restored only while it is still pixelpass-owned.
- **One terminal fault needs a coordinator, not an emitter.** If the actor emits `CoreError`
  immediately and the subsequent teardown then fails to join, peerspeak never learns the process
  is fail-stopping. Actor faults are internal *candidates*; the tokio-side coordinator emits
  exactly one final fault, and `Wedged` overrides any earlier candidate. Because a callback panic
  can cross `extern "C"` and abort before any event is produced, **peerspeak must treat
  unexpected stdout EOF as a synthetic terminal fault** rather than trusting that a JSON line
  arrives.

*Measured for the actor argument (3 of 3 trials, live graph):* pipewire-pulse accepts **two sinks
with an identical `node.name`** — no rename, no suffix, no refusal, both visible as `<name>` and
`<name>.monitor` — and `pulsesrc device=<name>.monitor` attached to the **older** one every time.
So a surviving wedged owner does not merely risk a collision: it **silently steals the next
session's capture** while the loopbacks feed the new sink. That retires "detach and carry on" as
an option, and it is the evidence behind rejecting session-unique sink names (which would trade a
fail-stop ownership fault for silent accumulation, and re-open the discovery grammar 0c step 1
just closed and field-proved).

**0c step 2 is therefore sliced, and the slices land and are reviewed independently.** Nothing
here reopens D6 — the connection-owned-sink design is unchanged; what grew is the process-
lifecycle and fault surface, and a material part of it is pre-existing debt 0c forced into the
light (the `abort()` orphan race, the unbounded join, peerspeak advertising a dead share):

| slice | scope | why it can land alone |
|-------|-------|-----------------------|
| S1 | repair planner (`919d5bd` + `9145b2a`) | done; awaiting re-review, then merge |
| S2 | peerspeak host-fault path: always-on notice channel, EOF synthesis, session-scoped fault, clear `is_sharing` + presence ticket, `ScreenShareStopped` then error | fixes a defect **today** — a dead share stays advertised — and is independent of the actor |
| S3 | pixelpass ledger transactions + ambiguous-load reconciliation + child containment + pre-armed watchdog + poison state machine + supervisor health arm | fixes the `abort()` orphan race **today**; no libpipewire work |
| S4 | **built, validated, and committed locally in pixelpass `781defc` (2026-08-20):** the `AudioGraphOwner` actor itself, the readiness handshake, and both measured budgets | the only slice that needs new PipeWire mechanism |
| S5 | **built, validated, and committed locally in pixelpass `781defc` (2026-08-21):** the two live exit gates: two-host ownership/repair and Stop Share SIGINT | needs S4 on the graph |

**S5 live evidence (2026-08-21).** Pixelpass's ignored
`live_two_host_sigkill_and_repair_preserve_the_survivor` gate starts two independent routing
owners, observes a distinct native sink and ownership-tagged loopback for each, SIGKILLs the
first, and proves only its sink disappears. `--repair` then removes exactly the dead host's
loopback while explicitly leaving the second live host alone. The survivor exits through a real
SIGINT with its active graph teardown measured at 40 ms, inside peerspeak's 2 s grace, and leaves
no sink or module residue. Peerspeak's separate
`stop_share_ends_the_real_host_via_sigint_within_the_grace` end-to-end gate also passed against
the freshly built pixelpass binary, proving Stop Share drives that signal path before fallback.
The pre-gate cold review also found and fixed an S4 unwind regression: the actor's emergency
`Stop` path had quit without restoring still-owned `target.object` values. `Stop` now performs
the same ownership-checked restoration and waits for a PipeWire core round-trip before closing
the connection, so constructor cancellation or unwind cannot knowingly strand an app on the
disappearing sink.

**Round 18 (2026-07-26 night) — two more repair review rounds. `--repair` now reads and unloads
through libpulse, and one of the review's own prescriptions had to be replaced after measuring.**

*Round 17c — the re-review of my round-17a fixes found two more blocking P2s. Two of the four
fixes I had applied were themselves defective; this is the third time the "audit your own fixes"
rule has paid.*

- **My two-listing correlation was unsound.** Pairing short-listing indices with JSON arguments by
  position breaks whenever module names repeat: another client loading one module and unloading
  another *between the two calls* leaves counts and names aligned while every argument has shifted
  by one, so a foreign module inherits a canonical fingerprint. The name check cannot see it and
  the retry never fires, because correlation "succeeded".
- **My liveness fix still converted invisible-but-alive into dead.** A `/proc/self` preflight
  proves nothing: inside a pid namespace — a container, a distrobox — `self` stays visible while
  every process in the parent namespace is invisible, and `hidepid` has the same shape.

*Round 18 (round 4) — the fix for both, and a third defect neither of us had reached.*

- **Record boundaries in `pactl list short modules` are unprovable, and this needs no adversary.**
  A genuine module whose argument contains a newline renders a first line that is byte-exactly one
  of our canonical forms, with the rest dropped as an unparseable continuation — no forged index,
  so no duplicate-index check can see it. **Field-confirmed on the live server** with
  `…latency_msec=20\nremix=false`, `remix` being a real loopback option. A tab in the same position
  is worse: it hides a sink reference from the gate that protects a still-referenced sink.
- **Locality was a guess.** `PULSE_SERVER` is a fallback *list*, so `unix:/missing tcp:remote:4713`
  passes any "starts with unix:" test and then connects to another machine, where local pids mean
  nothing and a live remote host's modules look dead.
- **Resolution: `src/repair/introspect.rs`, one verified-local connection.** `pa_module_info`
  carries index, name and exact argument in a single record; `pa_context_is_local()` answers
  locality about the connection actually established; and unloading goes back through that same
  connection, so listing and destruction cannot disagree about which server they mean. Bounded
  throughout (3 s connect, 3 s per request, non-blocking iteration plus a 2 ms sleep). The layer
  holds no policy but "refuse the wrong server" — every decision stays in the pure planner.
- **The dependency was the user's call, taken with sign-off after vetting.** libpulse-binding
  2.30.1: MIT/Apache-2.0, 5.5M downloads, 3 new crates total, a build script that only probes
  pkg-config, no network or subprocess use in any source, and all three historical RustSec
  advisories (2018-0020/0021, 2019-0038) fixed by 2.6.0. Reasoning recorded beside the dep.
- ⚠️ **REUSABLE — a prescription can fail reachability, not just a finding.** The reviewer's
  fidelity fix was "use `pactl -f json list modules`". On pactl 17 those records carry **no module
  index at all** (`"index": null`) while `unload-module` accepts only an index, so it can never
  stand alone. Measuring first is what caught it.
- ⚠️ **REUSABLE — the field test found a bug no unit test could reach, and it was 0b's bug again.**
  The first introspection version did its work correctly and then aborted on the way out:
  `Assertion '!e->dead' failed at mainloop.c:207, function mainloop_io_free()` — SIGABRT, core
  dumped, **exit 134, so a fully successful repair reported failure to its caller**. Rust drops
  fields in declaration order and the context's teardown frees IO events living in the mainloop,
  which I had declared first. Fixed, then hardened past the fix: `Drop` explicitly takes and
  destroys the context before the mainloop, so the ordering no longer depends on where the fields
  are written. **Field-order drop hazards are not a peerspeak-specific lesson; they recur wherever
  one object's teardown reaches into another's.**
- **Still open, deliberately, and recorded rather than guessed:** closing the namespace hole needs
  modules to carry an **owner token** (machine/boot identity plus pid-namespace identity), with
  token-less modules treated as `Unknown`. That changes what pixelpass writes into the graph *and
  how far back `--repair` can clean up* — orphans from any older build would become uncleanable,
  which is a regression in the tool's entire purpose. `NSpid > 1` remains a sound negative signal;
  `NSpid == 1` is explicitly **not** proof, since its leftmost value is relative to the procfs that
  was mounted.
- **Deferred, now cheap to reconsider:** `host/audio.rs` still loads modules via `pactl` and parses
  the index off stdout, which is part of why S3's ambiguous-load problem exists. With libpulse in
  the tree, `pa_context_load_module` returns the index through an observable operation.

**Round 1 — 13 items, 12 accepted.** Phase reorder (AEC machine before dry-run); typed capture
plan (accepted, moved *earlier* than proposed); Phase 3 five-part gate; tag-consumption gating;
Phase 6 matrix mandatory; 0b unwind backstop restored **and my mutation test corrected — it
targeted the wrong mutation**; Phase 9 rows restored with pre-declared thresholds; DAG stated;
O1 demotion language removed; 0c two-host gate; skew tests moved to Phase 7; status events
assigned. **Half-rejected:** "the Sunshine topology is unverified and unnecessary" — *unverified*
was right and it has since flipped; *unnecessary* rejected, retained as non-gating row 1b.

**Round 2 — 11 items, all accepted.** The three that mattered:

- **P1, the sink-input path.** My 0d closed the source and left the capture sink's inputs open;
  `PIXELPASS_AUDIO_VIA_NULL_SINK` + `Routing::start` would have filled the owned sink with the
  whole-desktop mix and produced full echo with no source switch. This is the "at least one
  comparable error" I asked round 2 to find, and it was in the fix for round 1's headline P1.
- **P2, my v3.4 §6.1.4 replacement was also unreachable.** I claimed a peer joining creates their
  playback node; verified false — one mixed playback stream at session construction
  (`core/mod.rs:1900`), `PeerJoined` only admits the sender. Replaced with notification sound and
  watched-share start, both reachable.
- **P1, no invocable production selector**, so Phase 6's "production path" measurement would have
  run through a harness. Internal mode input moved into 0d.

**Round 3 — verification pass, 5 items, all accepted.** It confirmed 4 of the 7 round-2 edits
landed and 3 were partial, which is the reason to run a verification round at all rather than
declaring the fixes done. The one that mattered:

- **P1, the public mode selector was never assigned to any phase.** 0d added a hidden trigger,
  Phase 7 added capability + naming, Phase 8 added `--aec` — and nothing required the mode flag
  itself to exist publicly or be passed. A capability-gated picker entry would have appeared and,
  when chosen, spawned legacy whole-desktop capture: the feature shipping while doing nothing,
  with the echo intact. Fixed in Phase 7 (flag) and Phase 8 (emission + new/new argv golden).
- Three link-matrix rows I had just written were insufficiently falsifiable — a combined
  exclusive/encoded/passthrough fixture (one working predicate masks two missing), "relink
  attempted" (a permanently-failing attempt passes), and an unenumerated "any failure path".
  Split into 6a–6c, a success assertion, and 8a–8e.
- Status delivery was gated by one generic causal test that passes while three of the four
  events stay unwired. Now four exact JSON goldens with named triggers.
- `0b` was drawn in the DAG with no outgoing edge; it now explicitly precedes Phase 6.

**Codex's positions I narrowed:** it agreed the Sunshine sample is worth keeping as non-gating,
and corrected my wording — the topology appears when Sunshine *routes desktop audio through its
null-sink topology*, not merely whenever Sunshine is running, since I measured it running
without that topology. It also correctly narrowed O7's premise (an exclude-everything build does
already fail six rows' eligible controls, *if* asserted) while agreeing the exact-partition fix
is right.

**Verified by me before accepting:** the single `default_audio_monitor` call site
(`pipeline.rs:138`); the double binary resolution (`core/mod.rs:3375-3378` vs `:3409-3419`); the
no-session guard on `StartScreenShare` (`:3397-3404`); the sole core `start_playback` (`:1900`)
and `PeerJoined`'s scope (`:2396-2409`); and the current default-sink/Sunshine graph state.

## 11. Corrections owed to the design doc — ✅ APPLIED in v3.5 (round 8, 2026-07-25)

Both are now in the design doc (§6.1.0 and §6.1.4 respectively), alongside round 8's own
finding (§6.7, the observation boundary). Kept here as the record of what was owed and why:

1. **v3.4 §6.1.0's "🔴 the hazard is LIVE on this machine right now" is time-dependent and has
   already flipped.** Measured 2026-07-21 ~14:55 (details in plan §5.3). The reachability
   argument is unaffected — the topology appears **when Sunshine routes desktop audio through
   its null-sink topology**, which is narrower than "whenever Sunshine is running," since it was
   measured running without it. Nothing should gate on its presence.
2. **v3.4 §6.1.4's nominated test case is unreachable, and so was my first replacement.**
   Details in plan §7. The conclusion (the transition window exists only for newly-created
   roots) stands; the example must become the notification sound or watched-share start.

## 12. Not in this plan

**Round 8 additions:** **port binding** (so `port.exclusive` is never observed — plan §4 "Phase
3 revision" item 6, with its revisit trigger), **per-node quarantine** (an unresolvable node
bind fails the whole readiness epoch closed instead of isolating that one node — v3.5 §6.7
decision 3), and the **serial-continuity signal** for the AEC validator's no-coalescing
contract (phase 4's owed F4 hardening).

Everything v3.4 §14 lists as out of v1 — port-granular taint, timed drain, hot-AEC-reload epoch
protocol, native PipeWire AEC, incremental dirty-set, seamless daemon-restart recovery — plus
v3.4 §10 items 2 and 3 (per-app debt; D6 says they do not block Option C), the v3.4 §5.1
grandchild leak, and the general "AEC binds to the default sink when no device is pinned" defect
(v3.4 §6.1.0, resolved for this user, own task).
