docs: design round 8 — the observation boundary (v3.5)
The phase-5 dry-run gate failed on its first live run: the engine built to v3.4 could not see its own primary taint root (echo, AEC off) while excluding every stream on the machine (silence). One cause — the PipeWire registry `global` event carries only a filtered subset of an object's properties, and eight the design depends on are never announced. Design doc (v3.4 → v3.5): - NEW §6.7 — the observation boundary. The global is an index, not a source of truth: bind every Node and Device, `info` props are the sole source, live prop tracking, one readiness obligation per unbound node, fail closed. Four user design calls recorded. - §5.1 — a second, registry-visible tag carrier (`node.name` prefix) alongside `peerspeak.owned`, so the primary root does not rest on one mechanism. - §6.4 — node/device props are not an optimisation to skip, they are unavailable from the global; the round-6 Link lesson was right and applied to exactly one object type. - §6.1.0, §6.1.4 — the two corrections the impl plan owed v3.5: a time-dependent "hazard is LIVE" claim, and an unreachable nominated test case (twice over). - §9.1 measured facts, §12 rig discipline (pw-dump binds; the registry does not), §14 readiness. Impl plan: - NEW phase 3r with a four-part exit gate, the first the direct inverse of the finding. Ports deliberately not bound in v1, with a revisit trigger. - Phase 1 pins the second carrier literal as a cross-repo contract. - Phase 5 marked GATE FAILED; matrix and O5 re-run after 3r and 1. - Risk register: the over-exclusion row fired and worked; new row for the observation boundary class. Architecture is unchanged and vindicated: fed correct properties, the engine decided correctly in every fixture. The §5.1 exact-partition requirement is what caught this — every exclusion was defensible and the eligible half was empty. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -82,6 +82,7 @@ if it differs, failing closed.
|
||||
| 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 |
|
||||
@@ -92,13 +93,19 @@ if it differs, failing closed.
|
||||
**Landing DAG** (development may be concurrent; *landing* order may not):
|
||||
|
||||
```
|
||||
0a ──────────────────► 2 ──► 3 ──► 4 ──► 5 ──► 6 ──► 7 ──► 8 ──► 9
|
||||
▲
|
||||
0b ────────────────────────────────────────────┤
|
||||
0c ──► 0d ─────────────────────────────────────┘
|
||||
1 ────────────────────────────────────────► 5
|
||||
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-25: the phase-5 gate FAILED on its first live run** and put 3r into the
|
||||
DAG. Phase 5's machinery is built and correct — it is the audit that found the defect — so
|
||||
"5 (re-run)" is a *re-run of the matrix*, not a rebuild. **Phase 6 does not start** until a
|
||||
passing results file exists. Phases 2 and 4 need no change; the defect is entirely in 3.
|
||||
**Phase 1 is now a hard prerequisite of the re-run for both carriers** (plan §3).
|
||||
|
||||
- **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
|
||||
@@ -253,6 +260,31 @@ 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:
|
||||
|
||||
@@ -299,6 +331,11 @@ fail-closed default asserted at the boundary.
|
||||
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.
|
||||
@@ -319,6 +356,59 @@ removals are ignored, the fallback is dead code, and readiness releases early:
|
||||
| **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 🔴 blocks the phase-5 re-run
|
||||
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.
|
||||
|
||||
**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`**;
|
||||
@@ -343,6 +433,18 @@ observable in Phase 5 before they gate anything real.
|
||||
|
||||
## 5. Phase 5 — dry-run audit mode 🚦 MAJOR GATE
|
||||
|
||||
> **🚦 STATUS 2026-07-25: BUILT, RUN, AND THE GATE FAILED.** Results:
|
||||
> `docs/screenshare-audio-exclusion-phase5-results.md`. The machinery is correct and needs no
|
||||
> rework — **it found the defect on its first live run**, which is the phase working exactly as
|
||||
> designed. What failed is the observer beneath it (v3.5 §6.7). **Phase 6 does not start.** The
|
||||
> matrix re-runs after phase 3r and phase 1's second carrier land; no row was completable
|
||||
> under the defect, so none of it carries over. O5's numbers do not carry over either.
|
||||
>
|
||||
> Read this before re-running: `PIXELPASS_AUDIO_AUDIT_FILE=… PIXELPASS_AUDIO_AUDIT_AEC=off
|
||||
> pixelpass --audit-audio`. **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.
|
||||
|
||||
**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
|
||||
@@ -608,7 +710,8 @@ mid-share load stays a **synthetic** test until a second `enable` site or hot re
|
||||
| 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 |
|
||||
| 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 |
|
||||
@@ -665,9 +768,10 @@ is right.
|
||||
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 (v3.5 / round 8)
|
||||
## 11. Corrections owed to the design doc — ✅ APPLIED in v3.5 (round 8, 2026-07-25)
|
||||
|
||||
Two measured findings that belong in v3.4 itself, both agreed by both reviewers:
|
||||
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
|
||||
@@ -680,6 +784,12 @@ Two measured findings that belong in v3.4 itself, both agreed by both reviewers:
|
||||
|
||||
## 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
|
||||
|
||||
Reference in New Issue
Block a user