diff --git a/docs/screenshare-audio-exclusion-impl-plan.md b/docs/screenshare-audio-exclusion-impl-plan.md index 603a57a..a9d4789 100644 --- a/docs/screenshare-audio-exclusion-impl-plan.md +++ b/docs/screenshare-audio-exclusion-impl-plan.md @@ -100,11 +100,21 @@ if it differs, failing closed. 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). +⚠️ **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 now passes, including the live prop-recovery row on this host. Phase 5's machinery is +built and correct — it is the audit that found the defect, twice — so "5 (re-run)" is a +*re-run of the matrix*, not a rebuild. **Phase 6 still does not start** until a passing +results file exists. **Phase 1 is a hard prerequisite of the re-run for both carriers** +(plan §3). + +⚠️ **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 @@ -356,11 +366,26 @@ 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 +### 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 recorded in design §6.8. +> +> **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 diff --git a/docs/screenshare-audio-exclusion-plan.md b/docs/screenshare-audio-exclusion-plan.md index 459e438..211a849 100644 --- a/docs/screenshare-audio-exclusion-plan.md +++ b/docs/screenshare-audio-exclusion-plan.md @@ -1,10 +1,11 @@ # Design v3: whole-desktop screen-share audio without self-echo -**Status:** 🟠 **v3.5 — round 8, reopened by a MEASURED implementation finding.** v3.4's -architecture is unchanged and still converged; what changed is the **observation boundary** -(new §6.7), which v3.4 got wrong in a way that made the built engine non-functional. +**Status:** 🟠 **v3.6 — round 9, opened by a second MEASURED finding, this time from a live +audit run of the *fixed* observer.** v3.4's architecture is still unchanged and converged. +Round 8 revised the **observation boundary** (§6.7); round 9 revises what stickiness is +allowed to remember (new §6.8). Both were found by running code, not by reading it. **Date:** 2026-07-25 (v1: 07-19 · v2: 07-20 · Option C 07-20 · v3.1 r4 · v3.2 r5 · v3.3 r6 · -v3.4 r7 · v3.5 r8) +v3.4 r7 · v3.5 r8 · v3.6 r9) **Origin:** Joe's suggestion — "whitelist all audio except audio coming from peerspeak." **Scope:** a new capture mode in pixelpass (`src/host/pipeline.rs`, `src/host/audio.rs`), playback tagging + AEC-identity export + teardown-ordering invariants in peerspeak. @@ -801,6 +802,64 @@ produced correct answers), and the Link/Client observation path. ⚠️ **O5 must be re-measured.** The phase-5 numbers (max 15 µs recompute, `busy_fraction` 0.0004) were taken on the graph this defect produces and do not include per-node bind I/O. +### 6.8 🔴 Uncertainty is not history — what stickiness may remember (round 9, MEASURED) + +**Found by the phase-5 audit on the live graph within minutes of §6.7's fix landing**, which +is the strongest available argument for the dry-run phase existing at all. + +**The observation.** With phase 3r running, a real hardware sink +(`alsa_output.usb-…Arctis_1_Wireless…`) carried `unresolved-ancestry` **permanently** — the +mark survived the readiness epoch, 21 recomputes and deliberate module churn. Traced to a +single event during enumeration: a link was observed while its output node was still +unbound, so §6.1.4's fail-closed rule correctly raised `UnresolvedAncestry` on the input +side. That mark was then written into sticky state, and §6.1.3 retires a sticky entry only +when **every member object is absent** — which a live sound card never is. + +**Why round 8 made it systematic rather than rare.** Under §6.7 every node is withheld until +its bind resolves, so *any* link observed across that gap raises unresolved ancestry. It +fires at startup, every startup, on whichever node happens to lose the race. + +**The rule (round 9).** + +> **Sticky taint is a claim about history, and uncertainty is not history.** A node tainted +> only because the graph could not be seen has had nothing observed about it. Decisions +> still fail closed on it — that is unchanged and non-negotiable — but nothing about it may +> be *remembered* once the uncertainty is gone. + +**Retiring by reason code is not sufficient, and this is the load-bearing part.** Uncertainty +launders itself: an unresolved node propagates `TaintedUpstream` to everything downstream, +and that reason is indistinguishable from real contamination once recorded. The split must +be by **provenance**, so the engine runs its fixpoint twice per recompute: + +| pass | uncertainty roots | consumed by | +| --- | --- | --- | +| fail-closed | raised (`UnresolvedAncestry`, `UnresolvedOwner`) | **every decision** — unchanged from v3.5 | +| evidence-only | never raised, so nothing derived from one exists | **sticky state, and only sticky state** | + +Evidence-based taint — `peerspeak.owned`, the AEC identity, pixelpass-owned objects, a +foreign echo canceller, and anything propagated from them — keeps §6.1.3's absence rule +exactly as written. That is what stops an app buffering the call and laundering itself +through a teardown, and it is unaffected by this change. + +Cost: two fixpoints per graph event. Measured 80 µs worst case against a 47 Hz event rate, +so the O5 headroom absorbs it without argument. + +⚠️ **Owed, from the round-9 review (Codex, P1 "worth checking"): hardware +playback-to-capture paths.** A card offering "Stereo Mix" / "Digital Loopback" presents an +ordinary driver name (`snd_hda_intel`), so both its sink and its source classify +`session_device` — and audio written to the sink reappears on the source through a hop the +Link graph cannot see. This is the `snd_aloop` hazard (§6.1.1, phase-3 review finding 2) in +a form the driver denylist cannot detect. It is **not new in round 9** and not introduced by +either recent round; distinguishing it needs ALSA control inspection, a new I/O surface and +therefore a design decision. Until then a card with that path enabled can carry the call +from sink to source untainted, and a capture app reading it can re-emit: **echo**. + +⚠️ **Also owed: a calibration argument for the readiness budget.** The observer times out +after 2 s and `TimedOut` is sticky by design, so a process that never sees one +obligation-free instant during initial enumeration is silent for its lifetime. Measured on +this host: readiness at ~3 ms with 19 binds. The margin is three orders of magnitude, which +is an argument, but it is one measurement on one idle desktop. + ## 7. Lifecycle and teardown invariants ### 7.1 ⚠️ The invariant @@ -1084,7 +1143,23 @@ Both reviewers agree on all seven. Recorded as decided; reopen only with new evi - **D7 — no materially simpler design exists** that still meets Joe's ask. The available simplification is to *narrow v1 scope*, not to change architecture. ✅ -## 14. Readiness — 🟠 v3.5 (round 8): architecture converged, observation boundary REVISED. +## 14. Readiness — 🟠 v3.6 (round 9): architecture converged; observation boundary and sticky provenance REVISED. + +**Round 9 (2026-07-25, same day).** Phase 3r shipped §6.7 and the audit was re-run +immediately; it found a *second* measured defect within minutes — a permanent sticky taint +on a hardware sink (§6.8). Both rounds share a shape worth naming: **the architecture was +right and the instrumentation was wrong**, and only running the code against a live daemon +found either. + +| | verdict | +| --- | --- | +| Architecture — Option C, taint as a graph property, owner-key union, sticky taint, AEC identity state machine | **unchanged, twice vindicated** | +| §6.8 | **new** — sticky state is built from an evidence-only pass; decisions still fail closed | +| §6.1.3 | **narrowed** — the absence rule now governs evidence-based taint only | +| Phase 3r | **built and merged**, four-part gate passed incl. live | +| Phase 5 | **machinery unchanged and correct** — it has now caught two real defects on first contact with the live graph | + + **Round 8 was not a review round.** It was opened by the phase-5 dry-run audit failing its gate on the first live run: the engine built to v3.4 was measured **non-functional** — it @@ -1126,6 +1201,11 @@ How the blockers closed: | 7 | "resolves" = first-present would still leak | **fixed** — §6.1.2 wording trap + test | | 7 | stickiness on recyclable ids | **fixed** — §6.1.3 lifetime-aware owner components | | **8** | **node props are not on the registry global (measured, phase 5)** | **fixed** — §6.7 bind-every-node rule; §5.1 second carrier | +| **9** | **a fail-closed unresolved mark became permanent sticky taint (measured, phase 5 re-run)** | **fixed** — §6.8 evidence-only sticky pass | +| **9** | `device_props` tested for one live *Device* rather than one live *global* on the id (Codex, certain) | **fixed** in phase 3r — stale `session_device` on a contested id is an echo path | +| **9** | `device.api` corroborated by presence, so `v4l2` under an ALSA factory passed (Codex) | **fixed** in phase 3r — the API must equal the allowlist's own | +| **9** | hardware playback-to-capture ("Stereo Mix") defeats the `session_device` classifier (Codex, P1 worth checking) | **OPEN — design decision owed**, §6.8; pre-existing, needs ALSA control inspection | +| **9** | the 2 s readiness budget has no calibration argument (Codex) | **OPEN — measurement owed**, §6.8; ~3 ms observed on this host | ### v1 scope — agreed @@ -1146,17 +1226,20 @@ binding, so `port.exclusive` is never observed and the §6.2 row it guards relie create failing cleanly · **(r8)** no serial-continuity signal for the AEC validator's no-coalescing contract. -### Next step (round 8) +### Next step (round 9) -The sequenced implementation plan exists (`screenshare-audio-exclusion-impl-plan.md`) and -phases 0a, 2, 3, 4 and 5 are built. Round 8's work is: +Phases 0a, 2, 3, 3r, 4 and 5 are built; §6.7 and §6.8 are implemented and merged. What +remains before phase 6 unblocks: -1. **Revise phase 3** to §6.7: bind every Node and Device, live prop tracking, one readiness - obligation per unbound node. Plan §4 "Phase 3 revision (round 8)". -2. **Revise phase 1** to emit both carriers (§5.1), literals pinned in plan §3. -3. **Re-run the whole phase-5 §5.1 matrix** — no row was completable under the defect — and - **re-measure O5** with bind I/O in it. Phase 6 stays blocked until that results file - passes. +1. ~~**Revise phase 3** to §6.7~~ — **done**, phase 3r merged, four-part gate passed + including the live prop-recovery row and an added live gate for the Device-side path. +2. **Revise phase 1** to emit both carriers (§5.1), literals pinned in plan §3. Unblocked + and next. +3. **Re-run the whole phase-5 §5.1 matrix** — no row was completable under the round-8 + defect, so nothing carries over — and **re-measure O5** with bind I/O *and* the round-9 + second fixpoint in it. Phase 6 stays blocked until that results file passes. +4. Decide the two items §6.8 leaves open: hardware playback-to-capture paths (a real echo + path, needs a design call) and the readiness-budget calibration. Still owed beyond that, unchanged: the §9.2 rig upgrade before any exclusion claim is published, and **field-test §12** — nothing in this design has been tested over the real