Commit Graph
153 Commits
Author SHA1 Message Date
molluskandClaude Opus 5 104a95a6d7 repair: let the token beat the namespace guesswork, and ask liveness first
Round 5. Two blocking P2s, both in the execution shell rather than the token
design, plus an overstated claim of mine.

**The degradation signals were defeating the token in exactly the case it exists
for.** A host crashing inside a container leaves a token that matches this
machine, boot and pid namespace perfectly — but the probe saw a container marker
or a multi-entry `NSpid` and answered `Unknown` for everything, so token-qualified
repair did nothing precisely where it had just become safe. Those signals are
guesswork about whether a bare pid is meaningful, and for an attributed pid that
is no longer a guess. Liveness is now asked with the module's attribution in hand:
a tokened pid goes straight to `kill`, while an untagged one still has to get past
the signals, because there they are the only protection left.

**Liveness now runs before the fresh snapshot, not after it.** The natural order —
verify the module, check liveness, unload — leaves the dangerous window open:
after `kill` returns ESRCH this process can be descheduled while the planned module
vanishes, a new host inherits both the pid and the module index, and its
differently-nonced arguments occupy that index. Nothing re-read those arguments, so
the reused index would have been unloaded. Asking liveness first means the
post-liveness fingerprint check catches that replacement, leaving only the
irreducible snapshot-to-unload interval.

**The nonce claim was overstated and is now true.** A token was minted once per
`Routing` session and reused for every subsequent reload, making it a host-session
nonce rather than a per-load one. It is now minted inside `load_module`, mixing a
bumped counter with the clock, so two loads by the same pid really do render
different arguments — which, combined with the reordering above, is what lets a
fingerprint tell a module from its replacement at the same index.

⚠️ **The first version of the attribution fix had no gate, and the mutation said
so.** Swapping `of_attributed` back to `of` passed all 254 tests, because on an
ordinary desktop the probe is not degraded and the two paths agree, while the
planner tests use a fake liveness closure that never touches the probe at all. The
new test constructs a *deliberately degraded* probe, which is the only state where
the distinction is observable. Both mutants — routing a tokened pid through `of`,
and re-applying the degradation gate inside `of_attributed` — now fail it.

Codex's answers to the questions I raised, recorded because they close them:
omitting the pid from the token loses nothing, since the canonical argument already
binds the token to exactly one pid; namespace inode reuse is real but only after
the old namespace is destroyed, so its host is necessarily gone and no live owner is
endangered; and refusing foreign tokens even under `--repair-legacy-untagged` is the
right line, because the flag speaks to missing evidence rather than wrong evidence.
No finding against the two-hole template derivation.

255 tests, clippy clean, fmt clean. All four live field gates re-run green: tokened
cleaned, foreign refused with and without the flag, untagged refused then cleaned on
request, A/B orphan removal byte-identical elsewhere, reference gate still firing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 02:18:52 -04:00
molluskandClaude Opus 5 d9ef38c1c5 repair: a pid is not an owner — modules carry a machine/boot/namespace token
Closes the last blocking finding: a pid is only a number, and the same number is
a different process in a different pid namespace. Repair running inside a
container that can reach the host's Pulse socket saw a live host's modules, asked
about that pid in its own namespace, was told nothing existed, and unloaded a
running host's audio. No negative signal closes that — `NSpid == 1` does not prove
the initial namespace, since its leftmost value is relative to whichever procfs
was mounted.

So the module now carries the answer with it. Every module a host loads gets
`pixelpass.owner=<version>-<machine>-<boot>-<pid_ns>-<nonce>`, and repair only
asks about a pid when all three identities match its own. Anything else is
reported and left alone, and its pid is never even looked up — asking is the bug,
because the answer would be meaningless.

**Untagged modules are refused by default.** Everything loaded before tokens
existed is unattributable, so `--repair` now lists those and does nothing, with
`--repair-legacy-untagged` to opt into the old pid-only heuristic after seeing the
candidates. That is a deliberate loss of reach: the failure being optimised
against is a false-positive destructive repair, and leaving an old orphan behind
is recoverable where destroying live routing is not. A foreign token is refused
even with the flag, since the flag speaks to missing evidence, not wrong evidence.

The vehicle was verified on the live server before anything was built on it: all
three shapes accept a property-list argument (`sink_properties`,
`sink_input_properties`, `source_output_properties`), the recorded argument comes
back byte-identical — so exact-form matching still holds — and the property really
lands on the resulting sink, sink-input and source-output.

**Audit gate passed, with the variable isolated.** The token rides on real graph
objects that phases 2/3 observe, so the partition had to be re-measured. Running
the same fixture with and without tokens gives an identical partition: 2 eligible
(FFXIV, Chromium), 2 excluded with the same `tainted-owner-bridge` reason, and the
same six-entry taint set. Everything that differs from the empty-graph baseline is
the fixture's own doing — a local-monitor loopback genuinely bridges our owned sink
into the real default sink — and none of it is the property's. Attributing that to
the token without the untokened control would have been the mistake.

A side benefit: the per-load nonce narrows the ABA window I previously documented
as unclosable. Two loads by the same pid no longer render byte-identical
arguments, so a fingerprint taken from one no longer matches the other.

Six new tests, three mutation-verified gates: `can_judge` always true, `pid_ns`
dropped from the comparison, and untagged treated as judgeable regardless of
policy — each killed by its own test. ⚠️ The third "survived" on first run because
my mutation script's indentation did not match and the edit silently did nothing;
the re-run asserts the file actually changed. A mutation that was never applied
proves the same amount as no mutation at all.

Field-verified live, three fixtures for one dead pid in one run: tokened with this
machine's identity is cleaned, tokened with a foreign pid namespace is left alone
and reported (and the legacy flag does not override it), and untagged is refused
then cleaned only when asked. The two older field fixtures were tokenised too —
without that the A/B test would have failed and the reference-gate test would have
passed for the wrong reason, which is a vacuous gate in the harness rather than the
code.

253 tests, clippy clean, fmt clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 01:57:55 -04:00
molluskandClaude Opus 5 1cf6e915c8 repair: drop the settling ritual, record the binding's timeout leak
Both P3s from the round-4 review.

The eight non-blocking mainloop iterations in `Drop` were a ritual, not a barrier:
a fixed number of polls cannot guarantee any particular event became ready. They
were also unnecessary — PulseAudio's context unlink cancels outstanding operations
and tears down the context's socket machinery synchronously, so once
`drop(context)` returns the mainloop has no obligation left to service. Removed
rather than replaced with a time-bounded drain, since there is no asynchronous
obligation for such a drain to wait on. Re-verified live: both field gates and a
clean-graph run still exit 0, with no abort.

Also recorded, at the constant it depends on: on a request timeout the `Operation`
wrapper is dropped while still running, and libpulse-binding 2.30.1 only unrefs the
C operation, so the boxed callback and its captured `Rc`s leak until the context
cancels the operation at disconnect. Harmless here — `--repair` is a one-shot
process that exits immediately after — and it cannot become a use-after-free, since
the closure owns its clones and the context clears callbacks before the mainloop is
touched. It would NOT be acceptable in the long-lived host, so the note says so
where someone would otherwise reuse this module for host-side loading.

The review's verdict on the teardown itself: disconnect, destroy the context while
the mainloop lives, then the mainloop, is the correct order, and taking the context
explicitly makes it independent of field declaration order.

247 tests, clippy clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 01:41:16 -04:00
molluskandClaude Opus 5 45464b4af5 repair: read and unload Pulse modules through libpulse, not pactl text
Third review round found three more blocking P2s, and they shared one root cause:
`pactl`'s text output cannot carry the guarantees repair was claiming. All three
are closed by talking to the protocol instead. New dependency taken with the
user's explicit sign-off after vetting.

**Record boundaries were unprovable.** `pactl list short modules` prints a
module's argument raw into a tab/newline-delimited format with no escaping. A
*genuine* module whose argument contains a newline renders a first line that reads
byte-exactly like one of our canonical forms, with the remainder dropped as an
unparseable continuation — no forged index, so the duplicate-index check could not
see it. Repair would have classified and unloaded a module it never saw in full.
**Field-confirmed on the live server**, because this needed no adversary: loading a
loopback whose argument is canonical-then-newline-then-`remix=false` (a real
loopback option) produces exactly that listing. A tab in the same position is
worse: it hid a sink reference from the gate that protects a still-referenced sink.

**Index and argument could be mis-paired.** The listing carrying exact arguments
(`-f json`) carries no index at all on pactl 17; the one carrying the index cannot
carry the argument faithfully. Correlating them by position — which the previous
commit did — is unsound 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.

**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. A remote server can also be selected by client config with the
variable unset entirely.

New `repair/introspect.rs` owns one verified-local connection: `pa_module_info`
gives 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. It holds no policy beyond refusing the wrong server; every
decision stays in the pure planner.

⚠️ **The field test caught a real bug that no unit test could have.** The first
version did its work correctly and then aborted on the way out:

    Assertion '!e->dead' failed at ../pulseaudio/src/pulse/mainloop.c:207,
    function mainloop_io_free(). Aborting.

SIGABRT, core dumped, exit 134 — a fully successful repair reporting failure to its
caller. Cause: Rust drops fields in declaration order and the context's teardown
frees IO events living in the mainloop, which I had declared first. **This is
exactly the invariant phase 0b exists for, met again one layer down.** Fixed, and
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.

Liveness keeps its `NSpid`/container checks but the claim is corrected: `NSpid > 1`
means "definitely nested", while `NSpid == 1` is NOT proof of the initial namespace
— its leftmost value is relative to the procfs that was mounted, so a nested
namespace with its own `/proc` reports one entry legitimately. These are negative
signals that fail closed, not a proof of trustworthy pids. Closing that properly
needs modules to carry an owner token (machine/boot plus pid-namespace identity),
which changes what pixelpass writes into the graph and how far back `--repair` can
clean up: recorded as a design decision, not guessed at.

libpulse-binding 2.30.1 vetted before use: MIT/Apache-2.0, 5.5M downloads, 3 new
crates total, build script does nothing but probe pkg-config, no network or
subprocess use anywhere in the sources, and all three historical RustSec advisories
(2018-0020, 2018-0021, 2019-0038) were fixed by 2.6.0. The reasoning is recorded in
Cargo.toml beside the dependency.

247 tests, clippy clean, fmt clean apart from the pre-existing taint/tests.rs:2683.
The text parser's tests are gone with the parser; the liveness probe keeps its own,
and the live field gates — A/B orphan removal, the reference/unrecognised fixture,
and the newline fixture — all pass with exit 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 01:23:23 -04:00
molluskandClaude Opus 5 01c582427b repair: one atomic listing, namespace-aware liveness, renderer as authority
Second review round. Two blocking P2s and two P3s, all applied.

**The two-listing correlation was unsound, so it is gone** (P2). Pairing the short
listing's indices with the JSON listing's exact arguments by position breaks with
repeated module names: if another client loads one module and unloads another
between the two calls, the counts and names still line up while the arguments have
shifted by one — and a *foreign* module inherits a canonical fingerprint. The name
check cannot see it and the retry never fires, because correlation "succeeded".

Observations now come from a single `pactl list short modules` invocation, so every
`(index, name, argument)` comes from one server response and cannot be
mis-assembled. The two costs of that format are handled rather than hoped away:

- A tab inside an argument is invisible here, so the row is marked
  `args_complete: false`. `classify` refuses such a row outright — a truncation
  could otherwise coincide with a canonical form — while the reference gate can
  still see that it names a sink.
- A crafted argument containing a newline can fabricate a row, but it only does
  damage if it claims a *real* module's index, which makes that index appear twice.
  A duplicated index now refuses the whole run.

libpulse introspection (`pa_module_info` carries index, name and argument in one
record) remains the exact route. It is a new dependency plus a mainloop in a
one-shot CLI path, so it is recorded as the upgrade rather than taken unilaterally.

**Liveness was still converting invisible-but-alive into dead** (P2). A
`/proc/self` preflight proves nothing: inside a pid namespace — a container, a
distrobox — `self` is visible while every process in the parent namespace is not,
and `hidepid` has the same shape. Repair there can reach the host's Pulse socket,
see a live host's modules, call its pid dead and unload a running host's audio.

So the probe now asks for positive confidence instead: `NSpid` in
`/proc/self/status` reports this process's pid in every namespace it appears in, so
more than one entry means our pid numbers are not the outer namespace's and every
verdict becomes `Unknown`. A kernel that does not report `NSpid`, a container
marker, and a non-local `PULSE_SERVER` all fail closed the same way. Liveness
itself is `kill(pid, 0)` via the existing `nix` dep, where `EPERM` proves
existence; pid 0 and pids past `i32::MAX` are never asked, since `kill(0, …)`
would signal our own process group.

**The renderer is the authority, not the derived template** (P3). `classify` now
re-renders the pid it extracted and demands byte equality, so the template is only
a pre-filter. `Shape` also owns the module *name* now, and `host/audio.rs` loads
through `Shape::{module_name, render_args}` — previously the "cannot drift" claim
covered only arguments while the names were still written out at both ends. The
sentinel assertion is unconditional (`assert!`), so a future shape that repeats the
pid cannot slip through a release build.

**A test I wrongly called unclosable** (P3). I argued no non-vacuous case could
prove the module name is part of the identity, because the name determines which
argument grammar can match. That was wrong: the grammars are not disjoint —
`module-echo-cancel sink_name=pixelpass_capture_9` is byte-identical to the
canonical null-sink argument, which the suite already constructs. Same index, same
arguments, different name, and `still_matches` must say no.

252 tests (+1 net; the correlation tests were replaced by parser and probe tests),
clippy clean, fmt clean apart from the pre-existing taint/tests.rs:2683. Both live
field gates re-run against the rewritten observation path: the A/B orphan test
still removes exactly the two orphans with the module table otherwise identical,
and the reference/unrecognised fixture still leaves both modules alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 22:07:58 -04:00
molluskandClaude Opus 5 9145b2a726 repair: exact-form matching, tri-state liveness, and a reference-gated sink
Codex's review of the repair planner returned changes-requested: no P1s, four
reachable P2s plus a P3, all in the observation and execution layers rather than
in the discovery fix itself. All applied.

**Only the canonical forms are ours** (P2, plan.rs). `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 mistaken for a top-level argument. The
whole recorded argument string must now equal what pixelpass itself would have
written.

The matcher's templates are **generated from the loader's own renderers**, not
written out beside them: hard-coding `latency_msec=20` in a matcher means a
loader change silently blinds repair to every module the new build loads, which
is the fail-closed-and-silent failure this project has been bitten by three
times. `host/audio.rs` now loads through those same renderers, so the two cannot
drift. Blindness is also reported rather than assumed impossible —
`unrecognised_pixelpass_modules` finds modules that name our sinks but match no
canonical form, and `--repair` says so loudly.

Measured on the live server before relying on it (pactl 17.0): arguments come
back byte-for-byte as passed, joined with single spaces, with `@DEFAULT_SINK@`
NOT resolved. Both facts are load-bearing for exact matching and both have a
test.

**Ordering is not a licence either** (P2, mod.rs). The plan put loopbacks before
the sink, but an unload can fail or be skipped and a loopback can appear after
planning, so the executor could still destroy a sink that something was attached
to. 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, not who owns it.

**Undecidable is not dead** (P2, mod.rs). `Path::exists()` maps permission
errors, a missing `/proc` and a foreign pid namespace all to `false`, which here
read as "dead, go ahead and unload". Liveness is now `Alive | Dead | Unknown`
via `try_exists()` behind a `/proc/self/stat` preflight, and `Unknown` is
treated exactly like alive and reported separately.

**The short listing cannot carry a fingerprint** (P2, mod.rs). Its arguments are
tab-delimited text that a module argument may itself contain, and a continuation
line beginning with a digit could fabricate a row. Observations now come from two
listings: the short one for the module index, and `pactl -f json list modules`
for the exact argument. Codex proposed JSON alone; on pactl 17 its records carry
`"index": null`, so it cannot be used on its own — verified, hence the
correlation. The pairing is positional and *checked* (same count, same name at
every position, else refuse), which is also what makes a fabricated row harmless
instead of exploitable: it has no JSON counterpart, so the sequences misalign.

Normalisation is gone (P3). Within one invocation every snapshot comes from the
same server, so re-rendering does not happen, and normalising only made
genuinely different arguments compare equal. The residual ABA window — planned
module vanishes, byte-identical one takes its index — cannot be closed through an
unload API whose only argument is an index; that is now said plainly in the
fingerprint's own doc comment rather than implied away.

Five vacuity gaps Codex found in the tests, closed: a raw-pactl-output-to-plan
test (the planner suite survived a parser that dropped every argument), the
liveness-once test now uses two pids with per-pid counters, non-canonical and
nested-quoted arguments have their own cases, and the reference gate has one.

Field-verified on the live graph, both new rules: the A/B orphan test still
removes exactly the two orphans with the module table otherwise byte-identical,
and a fixture of a dead pid's legacy sink plus a non-canonical loopback naming it
leaves both alone and reports why.

251 tests (+9), clippy clean, fmt clean apart from the pre-existing
taint/tests.rs:2683.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 21:31:30 -04:00
molluskandClaude Opus 5 919d5bdef2 repair: find the orphans a connection-owned sink leaves behind
Phase 0c's first landed piece. `--repair` does not get smaller when the capture
sink becomes connection-owned — it goes blind, which is the opposite of the
assumption I started from and the finding that reordered this phase.

Today repair learns a dead host's pid ONLY from `module-null-sink
sink_name=pixelpass_capture_<pid>`, and matches loopbacks only if their pid is
already in that set. After 0c the sink is a native node that removes itself with
its owning connection, so a hard-killed host leaves two Pulse loopbacks and *no
null-sink module to learn the pid from*. The set stays empty, nothing matches,
and the orphans are invisible forever. Candidate pids are now derived
independently from all three module shapes.

Split into a pure planner (`repair/plan.rs`) and an I/O shell, because every
interesting property here is a decision — which pid is dead, which module is
whose, in what order to unload — and none of them need PipeWire to exercise.

The rule that is new, and that the old code could not express: **a plan is not a
licence.** Pulse module indices are reused verbatim, so an id planned against one
module can name a different live module by the time the unload runs; a pid
recheck alone does not catch that. Every action now carries a full fingerprint
(id, module name, normalized args, derived pid, shape) which is re-verified
against a FRESH snapshot immediately before each unload, with liveness rechecked
last, closest to the destruction. Anything that does not match exactly is
skipped and said out loud — never unloaded on the strength of a stale plan.

Ordering is carried by `Shape`'s declaration order rather than by two separate
passes, so loopbacks unload before the sink they reference by construction.
Liveness is asked once per pid, not once per module: a flapping answer must not
be able to half-repair a host, which is the one outcome worse than doing nothing.

Field-tested against a real post-0c orphan, not just mocked: a connection-owned
sink created via `pw-cli create-node adapter` (module-null-sink count: 0, so the
old discovery provably could not see it), both loopback shapes loaded against it,
then SIGKILL of the owning connection. The sink vanished on its own, both
loopbacks survived, `--repair` removed exactly those two, and the module table
was otherwise byte-identical before and after — the collateral-damage half of the
two-host safety property.

Mutation-verified, five mutants, each killed by its own gate: null-sink-only
discovery (the 0c blindness itself), id-ordered unloads instead of shape-ordered,
a fingerprint that compares only the index (exactly one), no liveness filter, and
un-normalized args (exactly one).

Still owed: the live two-host gate (two real hosts, kill one, prove the other's
graph and modules are untouched) cannot run until the native sink exists, so it
is deferred to 0c's combined exit gate. This lands with test + single-host field
proof only, which Codex agreed is an acceptable phase dependency rather than an
objection to landing repair first.

242 tests (+11), clippy clean, fmt clean apart from the known pre-existing
`taint/tests.rs:2683` — pixelpass is never `cargo fmt`ed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 20:09:40 -04:00
molluskandClaude Opus 5 c78eb2dd39 host/taint: a self-claimed pid is not provenance (F11-1)
Boundedness now requires a strong key, or a key-4 value backed by a
**resolved Client** — an unambiguous Client yielding Some(pipewire.sec.pid),
read before pipewire-pulse suppression. A node whose Client cannot be
resolved at all is unbounded whatever `application.process.id` it puts on
itself, so it can no longer spare itself from `propagate_unresolved_owner`'s
sweep with a value it made up.

Closes the round-11 review's finding 1: the key-4 union could *reduce* taint,
because the same key list feeds boundedness and the sweep is armed by an
UNbounded tainted reader. The recorded three-step path (reader bounded by its
Client's real pid, output leg on an ambiguous Client claiming a bogus pid, no
shared key so no bridge either) is now a test.

Bridging is untouched: it still uses the full union, so boundedness is stored
on OwnerKeyIndex rather than re-derived from the key set, and `bounded_by` is
the single implementation of the predicate.

Five-case Client matrix as tests (absent · ambiguous · unique-but-pid-less ·
resolved-native · resolved-to-pipewire-pulse). The pid-less row is the one
that distinguishes the correct reading of "resolved" from "a unique Client
exists", which would have left the hole open. Mutation-verified: dropping the
provenance test fails four of the six rows and passes the two that must not
regress.

Measured cost on the live graph: zero. Before- and after-binaries audited the
same graph simultaneously (tagged producer + parec on the monitor as a real
tainted reader, so the sweep was armed) — 181 records each, the same 14
distinct decision states, none exclusive to either side, no unresolved-owner
on either, eligible half non-empty throughout. O5 unmoved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 04:44:58 -04:00
mollusk 91c4dedcb0 host/observer: derive pipewire-pulse's PID from comm, not from repetition
MEASURED DEFECT, found by the §5.1 row-1 matrix run: the daemon PID was
unresolvable on this host, permanently, which switched key 4's suppression off
and fused every Pulse-emulated node into a single owner. Row 1's clean control
forwarder was excluded, Firefox was excluded across an application.process.id
bridge, and the taint reached the sunshine sinks and both sound cards -- the
same machine-wide over-exclusion cascade as phase 5's F2, from a new cause.

Stage 1 returned the one sec_pid shared by 2+ Clients, reasoning that only the
Pulse shim repeats a value. WirePlumber repeats one too: it holds Clients
'WirePlumber' and 'WirePlumber [export]', both sec_pid 1747. Two values
repeated, the rule called that ambiguous and returned None, and
owner::keys_of's documented fail-closed asymmetry did the rest.

The rule was wrong in both directions, so the prefilter is gone rather than
patched: a second process holding two Clients defeats it (permanent, not a
corner case), and a session where pipewire-pulse holds exactly one Client never
repeats anything so the candidate is missed. comm was always the authoritative
check; repetition stood in front of it and was a guess about other processes'
Client counts.

Now: candidates() lists every distinct sec_pid, resolve() picks the unique one
whose /proc comm is exactly pipewire-pulse, and several matches still fail
closed (a single Option<u32> cannot suppress two daemons -- recorded, not
approximated). The adapter probes only PIDs entering the set, and
retain_probed_comms bounds the map to live PIDs so a PID that leaves and
returns is re-probed instead of answered from a stale comm.

Row 1 now passes its exact partition, key named: tainted forwarder leg excluded
on node.link-group, clean forwarder leg and Firefox eligible, taint confined to
the tainted half. 225 tests green (WirePlumber-pair and single-Client
regressions covered), clippy clean.
2026-07-26 02:32:07 -04:00
mollusk d462754894 host/audit: name the owner key the bridge resolved on
Impl plan section 5.1 row 1 asserts "reason = owner bridge, naming the key",
and the record could not express that: Reason::code collapses
TaintedOwnerBridge { key } to one string, so an exclusion that arrived across a
named owner key was indistinguishable from one that arrived by an incidental
link walk reaching the same verdict. Telling those apart is the entire point of
the row.

OwnerKey::code already documented itself as ending up in the phase 5 audit
output; it was simply never wired to it. Adds owner_key to AuditRow and
TaintRow, omitted when absent, and absent is meaningful: a bridge whose tainted
member shared no key directly reached the node transitively, so there is no
single key to name and naming one would be a false diagnosis.

Read-only and diagnostic-only. New test mutation-verified (stubbing the key to
None fails it); 221 tests green, clippy clean.
2026-07-26 02:21:07 -04:00
mollusk 90efa51c26 Merge phase 1: ownership carriers (taint roots from peerspeak's tags)
pixelpass-side half of phase 1: the taint engine recognises both registry-visible
ownership carriers peerspeak now emits (peerspeak.owned and the
peerspeak_owned_ node.name prefix), giving the primary taint root a path that
survives the filtered registry global event (design v3.5 section 6.7).

Reviewed by Codex rounds 10-12. F11-1 (the owner-key union can REDUCE taint via
owner_is_bounded, switching the unresolved-owner sweep off) remains OPEN and
still blocks phase 6; it is deliberately deferred to be decided with section 5.1
matrix data, and its sharpened rule is recorded in owner_is_bounded's doc.

220 tests green, clippy clean.
2026-07-26 02:14:34 -04:00
molluskandClaude Opus 5 b8b8b78b09 host/taint: pin what "resolved" must mean before F11-1 is implemented
Round 12 re-examined the deferral and agreed it holds while evaluate()
is audit-only, and that the recorded rule closes the path without
unbounding Pulse-emulated apps — but only under one reading of
"a node whose Client cannot be resolved at all".

The trap is worth writing down before anyone implements it: reading
"resolved" as "a unique Client object exists" passes for a unique Client
with sec_pid = None, which supplies no protected identity and leaves
exactly the self-claimed-PID hole the rule exists to close. It has to
mean an unambiguous Client yielding Some(pipewire.sec.pid), taken before
pipewire-pulse suppression.

That also means the §5.1 matrix needs five Client cases rather than two:
absent, ambiguous, unique-but-pid-less, resolved-native, and
resolved-to-pipewire-pulse. The pid-less row is the one that
distinguishes the two readings and the one a two-case matrix skips
without saying so.

Docs only. Still deferred, still to be decided with matrix data in hand.
220 tests green, clippy clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 02:01:07 -04:00
molluskandClaude Opus 5 289016d071 host/taint: record the open owner-union/boundedness interaction
Round 11 review, finding 1. Verified correct: round 10's claim that the key
union was "strictly additive" was too strong. The same key list feeds
owner_is_bounded, and the unresolved-owner sweep is triggered by an UNbounded
tainted reader -- so adding the Client's PID can move a reader from unbounded
to bounded and switch the sweep off, letting a same-process output leg with an
ambiguous Client and a bogus self-claimed PID stay eligible.

Cannot leak today (evaluate() is audit-only); becomes live in phase 6.

Not fixed in this round, and the doc says why: the blunt repair -- only
protected keys bound an owner -- makes every Pulse-emulated app unbounded,
which re-triggers the mass over-exclusion the design exists to avoid and would
empty the eligible half of the 5.1 matrix. The targeted rule (a node whose
Client cannot be resolved at all is not bounded by its own self-claimed PID)
is written down along with what it needs structurally, to be implemented with
matrix data in hand rather than argued from a whiteboard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 01:09:17 -04:00
molluskandClaude Opus 5 4b2b192601 host/taint: correct the constant's own doc, and record the ProcessId ambiguity
Verification round on the round-10 review fixes.

Finding 6 named the fixture, taint/tests.rs and snapshot.rs, but the same
stale claim was also on PEERSPEAK_OWNED_VALUE itself — the definition site
for the very literal the finding was about, still arguing that any truthy
value counts and that this is the fail-closed direction. Corrected with the
reason the argument fails.

Also records a known imprecision the union widened: OwnerKey::ProcessId now
covers both application.process.id and the Client's pipewire.sec.pid, so a
bridge reported under the former may have resolved on the latter.
Pre-existing since R10-3; not fixed here because these codes are a stable
contract for the audit output and the phase 6 status event, so splitting one
wants its own decision.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 00:00:38 -04:00
molluskandClaude Opus 5 993befdedd host/taint: carry both pids as owner keys; gate the wiring and the contract
Round 10 review, findings 1, 4 and 6.

Finding 1 (P1, phase 6) — key 4 was `node.or_else(client)`, so a node's
client-controlled application.process.id REPLACED its Client's protected
pipewire.sec.pid. One process using two Clients could therefore split its
identity: the tainted reader reports a bogus node pid, the output leg omits
the node pid and falls back to the Client's real one, the legs are bounded
by different values, and they neither bridge nor trip the unbounded sweep —
the output stays eligible while re-emitting the call. Now a union of both
values, deduplicated, with exception 1 applied to each independently so the
pipewire-pulse pid still cannot fuse unrelated Clients.

Mutation-verified: reverting to or_else fails ONLY the new split-Client test
(so the union changes nothing else), dropping exception 1 fails 32 rows, and
using the Client pid alone fails 16.

Not reachable today — evaluate() is reached only by the dry-run audit, which
creates no links. It becomes live when phase 6 consumes these decisions.

Finding 4 — R10-4's test called peerspeak_owned() directly, so reverting
node_observation_from_props to truthy() left it green; the only case it
shared with production, exact "1", passes under both. A new test builds a
real pw_properties dict and drives the production wiring, and the mutation
now fails exactly that test while the helper test still passes.

Finding 6 — the cross-repo fixture still documented carrier 1 as "any value
other than false/0", which R10-4 made exact-"1". A producer following it
could emit "true" and silently lose the carrier. Fixture updated in both
repos (byte-identical, verified), along with the stale prose in taint/tests
and snapshot.rs, and the contract is now also exercised through the
production adapter rather than only against the constants.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 23:57:09 -04:00
molluskandClaude Opus 5 abaf5d9c10 host/taint: a pid-less Client still makes its id ambiguous
Verification round on R10-3's own fix. The ambiguity guard detected a
duplicate client id by looking it up in the pid map — which is only
populated for Clients that carry a sec_pid at all. A pid-less first
claimant therefore left no trace, so the next Client claiming the same id
looked unique and its pid was used, resolving an ambiguous id: exactly
the guess the guard exists to refuse.

Reachable, not theoretical — pid-less Clients are ordinary here (the
session manager's is one). Reproduced: the bystander app went eligible
off a coin-toss owner attribution.

Claimed ids are now tracked separately from resolved pids.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 21:07:42 -04:00
molluskandClaude Opus 5 67f4ff931e host/observer: match the ownership carrier exactly, not leniently
The lenient `truthy` spelling was wrong for this one property. Under it,
`peerspeak.owned=""` and `peerspeak.owned="false "` both read as owned,
so any process could suppress a rival application's audio from the share
with a property it did not have to spell correctly.

The justification for leniency was that treating an unexpected value as
"owned" over-excludes and is therefore safe. That does not hold: leniency
here buys false-positive exclusion, not safety. Fail-closed on this
feature is about ancestry — an unresolvable graph is not eligible — not
about parsing. The producer emits exactly PEERSPEAK_OWNED_VALUE at all
three of its sites and is pinned to it by the shared cross-repo fixture,
and a garbled property still leaves carrier 2's node.name prefix, which
is a union with this one.

`truthy` stays as it is for port.exclusive, port.monitor and
node.passthrough: those are PipeWire's own, their spelling varies by
producer, and each causes exclusion when true, so leniency really is the
safe direction there. Both halves now have a row saying so.

Codex phase-1 review F6. Round 10, R10-4. Mutation-verified.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 20:59:17 -04:00
molluskandClaude Opus 5 295a575b15 host/taint: owner key 4 falls back to the Client's pipewire.sec.pid
Native PipeWire clients put no application.process.id on their nodes —
only client.id. keys_of read node properties alone, so those nodes had no
key 4, were therefore unbounded, and propagate_unresolved_owner excluded
them the moment any tainted reader existed anywhere on the machine.

Measured: an untagged mpv was eligible alone, and became unresolved-owner
the instant peerspeak played audio. Since peerspeak playing audio is the
only situation in which this feature runs, that amounted to "native
PipeWire apps are never shareable". The tainted reader that armed it was
sunshine, which is itself bounded — so this is the bounded-reader arm,
not the keyless-reader case §6.1.1 narrates.

The pid is one hop away, on the node's Client, already in the snapshot.

RISK, and the guard on it: every Pulse-emulated Client carries
pipewire-pulse's own PID as sec_pid — measured, 15 unrelated Clients
sharing 2528 on this host. An unguarded fallback would fuse all of them
into one owner. Exception 1 therefore applies to the fallback exactly as
it does to the node's own property, so the fallback strictly *adds*
correct bounding rather than trading it.

Ambiguous client ids yield no fallback pid: inventing an owner key is the
one direction that can reduce taint, so a coin toss is the wrong guess.

The client index is threaded through a new OwnerCtx rather than a sixth
positional Option<u32>, and evaluate() builds one and shares it, so the
components and the key index cannot disagree about who is bounded.

Round 10, R10-3. 6 new rows; 3 mutations verified — removing the
fallback, dropping the pulse-pid exception (11 rows die), and resolving
an ambiguous client id instead of dropping it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 20:57:47 -04:00
molluskandClaude Opus 5 bf5f2508b8 host/taint: honour the ownership carriers on producers only
Neither ownership carrier is a security boundary — both are strings any
unprivileged process can put on its own node — so an unrestricted taint
root is a denial of the whole feature. An unlinked Stream/Input/Audio
named `peerspeak_owned_rogue` is a tainted *reader* (receivers includes
nodes by role, no link required) and an unbounded one, so
propagate_unresolved_owner fails every candidate on the machine closed.

Measured before this change: BASELINE eligible=1 excluded=[] became
WITH IMPOSTOR eligible=0 excluded=[firefox -> unresolved-owner].

Restricting the root to Stream/Output/Audio costs nothing real —
peerspeak only ever tags playback streams — and the AEC's virtual
sink/source is untouched, since it roots on module id, not on this tag.

A tag that is ignored is not silent: misplaced_ownership_tags feeds a
new `ignored_ownership_tags` audit field (omitted when empty), because
the fix *removes* an exclusion, and the two causes of a dropped tag —
a peerspeak tagging bug, or an impersonation attempt — both want seeing.

Codex phase-1 review F2, reproduced live. Round 10, R10-1.
5 new rows, mutation-verified: dropping the role restriction kills both
engine rows, and stubbing the diagnostic kills the third.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 20:46:40 -04:00
molluskandClaude Opus 5 45ca5057f8 host/taint: refuse an ambiguous contract fixture instead of resolving it
Codex phase-1 review, finding 3 (P2), concrete half. This side searched
a list and took the first match for a key; peerspeak's side collected
into a map and took the last. A byte-identical fixture containing a
duplicated key would therefore leave both suites green while the two
repos had selected *different* contracts — the precise drift the shared
file exists to prevent.

Both sides now assert the key is not already defined. Verified by
appending a duplicate `prop_value` to both fixtures: both suites fail.

The rest of finding 3 — one CI gate that feeds peerspeak's real
tag_child output through this repo's actual adapter and classifier,
rather than two per-repo literal tests — is a larger piece of work and
is not attempted here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 20:25:22 -04:00
molluskandClaude Opus 5 8b41f64e12 host/observer: tag the live prop-recovery row with the real wire value
Verification-round follow-up to 1b01847. The phase-3r live row proves
carrier 1 survives the bind, which is the property F1 destroyed — but
it tagged its fixture sink with `peerspeak.owned=true`, not the `1`
the contract pins and peerspeak actually emits. It would have passed
even if the real literal did not.

Adds PEERSPEAK_OWNED_VALUE so the fixture can name the producer's
value, and asserts it against the shared contract file alongside the
other two literals. The sink's name still deliberately avoids the
`peerspeak_owned_` prefix, so carrier 2 cannot stand in for carrier 1
in that row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 20:06:06 -04:00
molluskandClaude Opus 5 1b01847c66 host/taint: match peerspeak's second ownership carrier
The consumer half of phase 1 (plan §5.1, impl plan §3). The engine's
tag root becomes a union: `peerspeak.owned` truthy OR `node.name`
starting with `peerspeak_owned_`. Round 8 added the second carrier
because a node property is invisible to the registry `global` event
and recoverable only by binding the node — which is exactly how the
phase-5 gate failed — while `node.name` is announced directly.

The union lives in `local_root_reason`, not in the adapter. Folding
both into the one `peerspeak_owned` bool at the observation boundary
would make each carrier untestable alone, which is the phase-3r
lesson: a gate asserting a value two sources can satisfy gates
neither. The existing `peerspeak_tagged_nodes_…` fixture now carries
both carriers, so it would keep passing if either were deleted; two
new tests pin them individually, and a third pins that the prefix
matches only at the start of a name.

Both literals are now named constants — they are a cross-repo wire
contract with peerspeak, not local naming — and asserted against
tests/fixtures/ownership-tag-contract.txt, committed byte-identical
in both repos. That test also runs the fixture's own worked example
name through the engine, so the shared file cannot document a value
this side does not actually exclude.

Five mutations verified: drop either carrier, loosen `starts_with` to
`contains`, or rename either constant, and exactly the intended test
fails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 19:19:39 -04:00
mollusk 0af0124e17 Merge round 9: uncertainty is not history
Found by the phase-5 audit minutes after phase 3r landed — a permanent sticky
taint on a hardware sink, from one link seen during enumeration. Sticky state
is now built from an evidence-only pass; decisions still fail closed.
2026-07-25 18:49:06 -04:00
mollusk e34289fdb5 Merge phase 3r: the registry global is an index, properties come from a bind
v3.5 §6.7, the fix for the phase-5 gate failure (F1/F2). Pure core + adapter,
each half reviewed by the other author; four-part exit gate passed, including
row 1 live on this host, plus an added live gate for the Device-side path.
2026-07-25 18:49:06 -04:00
mollusk 471b8221ff host/observer: address the Codex phase-3r review (2 fixes, both verified)
Codex's adversarial review of the pure core found no *certain* P1. Two
findings taken, both mutation-verified (the fix reverted, the intended test
dies, nothing else moves):

**F2, certain, P2 — `device_props` tested the wrong kind of ambiguity.** It
required exactly one live *Device* on the claimed id rather than exactly one
live *global*. With `[Device, Port]` on one id — a missed removal, the same
precondition as every other recycled-id hazard — it kept answering from the
older Device, so a node claiming that id held a stale `session_device = true`.
That flag strips the node's owner keys and its fail-closed backstop, so a
forwarder wearing it can put its output leg back on the eligible side. Now:
one slot total, and it must be the Device.

**F3, worth checking, P3 — `device.api` was corroborating by presence.**
`device.api=v4l2` under `factory.name=api.alsa.pcm.sink` satisfied the
positive classifier. No truthful configuration produces that pair, which is
the argument for reading it as an observation gone wrong rather than as
corroboration. The API must now equal the one the factory allowlist is
written for, an empty value is not a value, and the two sides disagreeing
fails closed. Tied to the allowlist being ALSA-only via a named constant.

Two findings NOT fixed here, both pre-existing and neither introduced by
round 8 — raised to the design doc instead:

- **P1, worth checking: hardware playback-to-capture paths** (Stereo Mix,
  Digital Loopback) on a card whose driver is an ordinary `snd_hda_intel`.
  Both its sink and source classify `session_device`, taint cannot cross the
  hardware hop, and a capture app reading that source can re-emit the call.
  This is `snd_aloop` again in a form the driver name cannot detect;
  distinguishing it needs ALSA control inspection, which is a design change
  and a new I/O surface, not a local fix.
- **P3: the 2 s readiness budget** can in principle never see an
  obligation-free instant under sustained startup churn, and `TimedOut` is
  sticky by design, so the process would be silent for its lifetime.
  Measured here: readiness at ~3 ms with 19 binds, so the margin is three
  orders of magnitude — but it wants a calibration argument, not a guess.

197 unit + 3 live green, clippy -D warnings and fmt clean.
2026-07-25 18:48:53 -04:00
mollusk 64f98990c8 host/taint: uncertainty is not history — it never enters sticky state
Found by the phase-5 audit on the live graph, immediately after phase 3r
landed: a hardware sink carried a permanent `unresolved-ancestry` taint. The
cause was one link observed while its output node was still unbound — a
correct fail-closed answer — which was then written into sticky state, where
retirement requires every member object to be absent. A live sound card never
is, so the mark survived readiness, 21 recomputes and deliberate churn.

Phase 3r makes this systematic rather than rare: every node is now withheld
until its bind resolves, so any link seen across that gap raises
`UnresolvedAncestry` on its input side. It fires at startup, every startup.

User decision (2026-07-25): uncertainty-based taint retires once the
uncertainty is gone; evidence-based taint keeps the absence rule.

Retiring by reason *code* would not be enough, because uncertainty launders
itself — an unresolved node propagates `TaintedUpstream`, which is
indistinguishable from real contamination once recorded. So the split is by
**provenance**: `evaluate` runs the fixpoint twice. Pass 1 fails closed
exactly as before and is what every decision is made from; pass 2 raises no
uncertainty root at all, and is the only thing sticky state is built from.
Nothing derived from an uncertainty can reach the sticky path.

Decisions are unchanged by construction — all 57 existing taint tests pass
untouched, including the fail-closed and sticky-survival rows.

3 new tests, mutation-verified (pointing `build_sticky` back at the
fail-closed taint kills exactly the two new uncertainty tests and nothing
else): unresolved ancestry clears once resolved; taint laundered downstream
of an uncertainty clears with it; real taint still survives its topology
disappearing.

Live: the audit's post-readiness records now report taint 0 where they
reported a permanent sticky entry before. Recompute cost roughly doubles as
expected (two fixpoints) — 80 µs worst case observed, against a 47 Hz event
rate.

`taint/tests.rs` keeps its one pre-existing hand-formatted line; everything
else in both files is rustfmt-clean.
2026-07-25 18:46:56 -04:00
mollusk 306b601490 host/observer: phase 3r adapter — bind every Node and Device
The I/O half of round 8 (Codex, gpt-5.6-sol xhigh; reviewed, formatted and
extended here). The adapter now reads `object.serial` and nothing else off a
Node or Device global, binds the object, and takes every property the engine
reasons about from its `info` props.

- `BoundProxy` generalises `BoundLink` to Node/Device/Link, each holding its
  listener *before* its proxy so the listener is dropped first — the original
  Link variant had that order inverted.
- Bind attachment now finds its slot by never-recycled serial rather than
  taking the queue's back, so nested callback activity during a bind cannot
  attach one generation's proxy to another's slot on a recycled id. A proxy
  that finds no slot is returned to the caller and dropped after the borrow
  ends. Removal still pops oldest-first, matching the model's `live_ids`.
- An `info` is parsed and emitted on the first callback carrying props and
  thereafter only when `change_mask` contains PROPS. I considered emitting
  unconditionally and leaning on the model's suppression rule, and rejected
  it: if a state-only `info` ever delivered a partial props dict, that would
  overwrite a complete observation with an incomplete one — a worse failure
  than the one it guards against, and the same class as F1.
- Ports stay unbound (v3.5 §6.7 / impl plan §4 item 6).

Gates: exit-gate row 1 (live prop recovery) passes on this host — the tagged
null sink projects `peerspeak.owned`, `pulse.module.id`, `node.passthrough`,
the loopback legs share a `node.link-group`, and a real ALSA node classifies
`session_device`.

Added a second live test for the Device half. Row 1's `session_device`
assertion is satisfied by a *union*: WirePlumber 0.5.15 copies `device.api`
and `alsa.driver_name` onto ALSA nodes here, so it passes through the node
fallback and would keep passing if the Device bind delivered nothing —
leaving §6.7 decision 4 ungated on the development machine. The new test
binds every Device and requires an ALSA card to announce both keys.
Mutation-verified: breaking the Device-side driver read fails the new test
while row 1 still passes, which is the gap as claimed.

195 unit + 3 live green, clippy -D warnings and fmt clean.
2026-07-25 18:32:27 -04:00
mollusk b3d71724ae host/observer: phase 3r pure core — node/device props come from a bind
v3.5 §6.7. The registry `global` event announces only a fixed 13-key subset
of a Node's properties, and eight the engine depends on are never among them
(phase-5 gate failure F1/F2). The core now treats the global as an index and
takes every property from the object's bound `info`.

- `RegEvent::NodeAdded { serial, id }` is identity only; `RegEvent::NodeInfo`
  carries the properties and is both the first resolution and every later
  PROPS change for the node's lifetime (decision 2). Same split for Device
  (`DeviceAdded` / `DeviceInfo`).
- A node with no `info` is withheld from the snapshot and is a readiness
  obligation; an unresolvable bind ends in sticky `TimedOut`, fail closed
  (decision 3). Devices are keyed by serial too, so a recycled device id with
  two live claimants is ambiguous ⇒ withheld rather than guessed.
- One live-node map replaces the admitted/withheld pair; classification is
  recomputed at projection time from current inputs, since both sides of it
  now change over an object's lifetime.
- `classify` takes the bound Device's props: presence is a union with the
  Device winning (this recovers a real card whose node was never given
  `alsa.driver_name` — the phase-3 review's owed fix), while the
  non-terminal-driver denylist is a union in the safe direction.
- `apply` returns `Outcome`, the only sound place to enforce the suppression
  rule: a property update is dropped only when model state provably did not
  change, i.e. the resulting projection is identical.

Adapter: stops reading properties off Node/Device globals and emits the new
index events. Binding every Node and Device — the I/O half — is the next
commit (Codex's), so until then every node is withheld and readiness times
out by design.

Tests: 55 observer (was 38) — the prop-update matrix, readiness with node
binds, and recycled-Node-id churn (phase 3r gate rows 2–4). 195 green,
clippy -D warnings and fmt clean.
2026-07-25 18:17:48 -04:00
molluskandClaude Opus 5 a1ac7ea8d5 Merge phase 5: dry-run audit mode (read-only)
The audit machinery is complete and verified live. The §5.1 gate itself
FAILED — see peerspeak docs/screenshare-audio-exclusion-phase5-results.md —
but both findings are defects in phase 3's observation boundary, not in this
code, and round 8 needs the audit tool on main to re-run the matrix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 15:43:39 -04:00
molluskandClaude Opus 5 bbf6744444 host/audit: phase 5 — dry-run audit mode (read-only)
Runs phases 2-4 against the live PipeWire graph on every registry event and
reports the complete eligible/excluded candidate partition with stable reason
codes. Creates no links, loads no modules, changes no routing.

Impl plan §5. Two entry points behind the hidden PIXELPASS_AUDIO_AUDIT=1
trigger: inside a real `pixelpass host` run (the plan-literal reading, proves
the path phase 6 will mutate), and a hidden `--audit-audio` standalone mode
with no iroh endpoint or capture pipeline, which is what drives the §5.1
matrix.

The recompute runs inline on the observer thread via a new ProjectionSink
hook, once per applied event. Polling `latest()` was rejected: it coalesces,
and phase 4 detects a module unload by observing the empty gap before the next
module appears — with indices reused verbatim (v3.4 §5.2 correction 3), a
missed gap aliases a fresh module onto a dead identity. Running inline is what
makes phase 4's "one observe per graph event" contract true, and it puts the
cost where O5 can measure it.

Split as usual: the auditor and the metrics are pure and unit-tested; the
clock, the writer and the env parsing are the thin edge in `sink`/`run`.

- audit/mod.rs   Auditor: AEC validator + taint engine + record building.
                 The AEC gate and the engine's own reasons stay
                 distinguishable — a shut gate must not erase the reason codes
                 the §5.1 rows assert.
- audit/metrics.rs  O5: event rate, bucketed recompute distribution + exact
                 max, busy fraction, and a documented lower-bound queueing
                 proxy (libpipewire exposes no queue depth).
- audit/sink.rs  JSON Lines to stderr, or PIXELPASS_AUDIO_AUDIT_FILE. Never
                 stdout — peerspeak parses that stream.
- audit/run.rs   Env parsing; a malformed AEC value is fatal, matching phase
                 4's rule that it must not silently become "no AEC".

Observer gains `EventKind` (derived from RegEvent, so a consumer's view of
"was this a real graph change?" cannot disagree with the model's) and
`Projection::readiness`, which distinguishes the three ways graph_ready can be
false. taint::fixture is now pub(crate) so audit tests share one graph
vocabulary with the taint tests.

33 new tests, 178 green, clippy -D warnings and fmt clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 15:43:10 -04:00
molluskandClaude Opus 4.8 e0fe47d5a9 Merge phase 4: AEC identity validation state machine (pure core)
Bounded read-only state machine (NotConfigured/Validating/Validated/
Failed/Revoked) that validates peerspeak's live echo-cancel module
identity and fills ExclusionCtx.aec_module_id — the last field the taint
engine needed. Design v3.4 §5.2/§5.3, impl plan §4.

Adversarial Codex review: no merge-blockers; five worth-checking items
addressed via documentation + closing two test holes (both fixes
mutation-verified). No core logic change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 15:07:10 -04:00
molluskandClaude Opus 4.8 4a844ac6fa host/aec: address Codex phase-4 review (no merge-blockers; docs + test holes)
Codex adversarial review found no merge-blocking defects. Five
worth-checking items, all triaged for reachability:

- F1 (spurious pre-ready revoke): unreachable — the AEC's four nodes are
  two Stream/* legs + a null-sink-like virtual sink/source, none claiming
  a device.id, so the phase-3 observer never withholds them; index_present
  goes false only on a genuine full unload. Documented why revoke is NOT
  gated on graph_ready, and why gating it would reopen the reused-index
  alias trap (F4) during a hot-reload-under-churn. Pinned with
  revokes_on_empty_even_while_not_ready (mutation-verified: `&& graph_ready`
  on the revoke guard dies here).
- F4 (test relies on observing the empty gap): documented the phase-5/6
  integration contract it rests on (one observe per graph event, no
  coalescing across a module lifetime boundary) and owed the robust fix
  (serial-continuity / observer-generation) to a later hardening round.
- F2 (late positive evidence beats the deadline): intentional and correct
  — a demonstrably-present identity is ground truth. Documented +
  late_positive_evidence_wins_over_expired_deadline (both arms: node-first
  validates, Tick-first fails closed and stays sticky).
- F3 (real P3 coverage hole): strengthened deadline_is_not_armed_until_
  graph_ready to prove the budget starts at first-ready, not construction
  (mutation-verified: a construction-relative deadline now dies).
- F5 (`+7` grammar mismatch): documented the producer contract — peerspeak
  emits bare decimal (pactl returns unsigned decimal), the narrow parser
  is deliberate. Unreachable on the measured stack.

No core logic change. 24 pure aec tests, cargo test --bins green (145 unit
+ 1 ignored live), clippy -D warnings + fmt clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 15:07:01 -04:00
molluskandClaude Opus 4.8 b74ed17823 host/aec: phase 4 — AEC identity validation state machine (pure core)
Bounded, read-only state machine that validates peerspeak's live
echo-cancel module identity before the taint engine trusts it, filling
the last ExclusionCtx field (aec_module_id). Design v3.4 §5.2/§5.3,
impl plan §4.

States (v3.4 §5.3 verbatim): NotConfigured / Validating / Validated /
Failed / Revoked. No fan-out while Validating; Failed and Revoked are
sticky terminals so a reused module index (indices ARE reused, §5.2
correction 3) cannot alias a Revoked epoch onto an unrelated reload.
Revocation is loss of the whole identity (every node bearing the index
gone), never one leg corking. The Failed deadline is armed only on the
first graph_ready, so a slow initial enumeration is "unknown" not
"absent" and never times out spuriously.

parse_aec_arg handles --aec=off|pulse-module:<idx> (D5): bare-u64
decimal accepted past u32::MAX, rejecting sign/whitespace/non-digit/
overflow/unknown-form.

22 pure tests (the exit-gate transition matrix), cargo test --bins
green (143 unit + 1 ignored live), clippy -D warnings + fmt clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 14:51:49 -04:00
8973e5dc19 Merge phase 3: registry observer (pure core + I/O adapter)
Split-seam mutual-review build: pure reducer/classifiers (Claude) + libpipewire
adapter (Codex), each reviewed by the other. Two review rounds closed 3 P1s
(dynamic graph_ready over invisible edges; snd_aloop absent-driver fail-closed;
FIFO lockstep). Exit gate incl. live topology-diff row passes on the host.

Additive/read-only — does not yet replace the audio.rs router (integration
phase). DAG: 0a -> 2 -> 3 done; next is Phase 4 (AEC validation state machine).

Co-Authored-By: Codex (gpt-5.6-sol) <codex@openai.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 14:01:32 -04:00
molluskandClaude Opus 4.8 f90bee63f7 host/observer: close the snd_aloop absent-driver leak (Codex re-review)
Codex's re-review of the phase-3 fixes confirmed finding 1/5/6 closed but
found the finding-2 fix incomplete: the denylist only rejected a *present*
snd_aloop driver, so an snd_aloop node whose alsa.driver_name was not copied
onto the node still classified session_device=true — the original leak. The
absence is reachable: PipeWire >=1.2.6 stopped overwriting node props with
card props, and WirePlumber only began copying alsa.* onto nodes in 0.5.13.

Fix: session_device now requires a PRESENT, non-denied ALSA driver; a missing
alsa.driver_name fails closed to NotSessionDevice (a real card without the
prop is over-excluded — safe; recovering it needs reading the driver from the
backing Device global, owed to a later round). Mutation-verified: reverting to
fail-open on absence is killed by classify_alsa_without_driver_name_fails_closed.

Also: corrected the finding-3 limitation doc to cite PipeWire's object.serial
identity contract rather than overclaiming the live gate proves it (Codex P3,
non-blocking).

121 unit + live gate row 6 green, clippy clean, observer files fmt-clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 13:40:47 -04:00
molluskandClaude Opus 4.8 557c1030a7 host/observer: address Codex phase-3 review (2 P1 + P3s)
Cross-review round: Codex adversarially reviewed my pure core, found two
merge-blocking P1s and several P3s. Triaged each for reachability; fixes below,
each mutation-verified (revert killed by its intended test).

P1 finding 1 — graph_ready was sticky-once-Complete, so a Link added
post-enumeration whose endpoints are still binding (an INVISIBLE edge, absent
from the snapshot) left graph_ready=true and a candidate could be reported
eligible over unseen tainted ancestry. graph_ready is now dynamic:
Complete AND no outstanding obligations. Readiness::Complete stays sticky as
the epoch marker. New regression test + flipped the old sticky-churn test.

P1 finding 2 — snd_aloop presents with an allowlisted ALSA factory and
device.api=alsa exactly like a real card but forwards audio through a kernel
hop the Link graph cannot see; it was classified session_device=true, dropping
its owner keys + backstop (leak). Added alsa.driver_name to DeviceClaim and a
NON_TERMINAL_ALSA_DRIVERS denylist under the factory allowlist; adapter now
populates it. Negative fixture added.

P3 finding 5 — the BlueZ allowlist entries (api.bluez5.pcm.*) were invented;
removed them (real names are api.bluez5.media.*). A BT sink now over-excludes
(safe) pending a measured fixture. P3 finding 6 — strengthened the timeout
test to assert TimedOut stays sticky through later DeviceAdded/sync/tick.

Findings 3 (dropped-link unrepresented) and 4 (missed-removal generation
ambiguity) documented as accepted low-reachability limitations (links carry
object.serial — confirmed by the live gate; registry does not drop removals).

Codex confirmed the pulse-PID matrix fails safe and the adapter add() FIFO is
lockstep. 120 unit + live gate row 6 green, clippy + fmt clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 13:28:31 -04:00
66a0dd54df host/observer: phase 3 I/O adapter (read-only registry observer)
Codex authored the adapter half of the phase-3 split; I reviewed it and
applied one robustness fix (below). Additive/read-only — does not yet replace
the existing audio.rs router (that migration is a later integration phase).

adapter.rs: a dedicated libpipewire main-loop thread translating registry
globals into RegEvents and publishing the latest Projection via
RegistryObserverHandle::latest(). core.sync(0)/done is matched one-shot →
ServerSynced; a 250 ms loop timer emits Tick for the fail-closed readiness
timeout; pulse-PID candidates are probed from /proc/<pid>/comm only when the
candidate changes; Links missing endpoint props are bound (LinkInfoRef, weak
back-ref to avoid the listener cycle) and resolved via LinkEndpointsResolved.
mod.rs: `pub mod adapter;`. audio.rs: parse_object_serial → pub(crate) so the
adapter reuses the strict 64-bit parser.

Review fix: record_global was called unconditionally per global (including
unknown object types and dropped globals), which could desync the bound-link
FIFO from the model's live_ids and leak a Link proxy on a recycled id. Now
folded into `add()` so a slot is recorded only when an Added event is applied
— the two id queues are provably lockstep.

Exit gate complete: 5 pure rows + the live topology-diff row (row 6) — the
#[ignore] adapter test PASSES on this host against the live daemon
(module-null-sink + module-loopback observed appearing and disappearing).
cargo test --bins 117 + 1 live green, clippy clean, no fmt sweep.

Co-Authored-By: Codex (gpt-5.6-sol) <codex@openai.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 13:06:36 -04:00
molluskandClaude Opus 4.8 8206864a43 host/observer: phase 3 pure core — RegistryModel reducer + classifiers
My half of the phase-3 split (impl plan §4). Pure, no PipeWire: the adapter
(Codex's half) translates live registry callbacks / binds / /proc reads /
core.sync into RegEvents and feeds this reducer.

- RegistryModel::apply folds RegEvents into serial-keyed maps with an
  insertion-ordered id index so global_remove accounts for the oldest
  generation first; recycled ids stay Ambiguous until accounted (v3.4 §6.1.3).
- Readiness epoch: graph_ready false until ServerSynced + no outstanding
  obligations (withheld nodes, pending link binds); bounded timeout fails
  closed. Gates sticky retirement only; sticky once terminal.
- session_device classifier: hardware-PCM factory allowlist, exact match,
  fail closed to false; a node on an unresolved Device is withheld, never
  admitted provisional.
- pulse-PID derivation split into pure candidate (repeated sec_pid) + validate
  (/proc comm), so the 6-case failure matrix is unit-testable; any failure =>
  None (key 4 unusable).

34 tests cover 5 of 6 exit-gate rows (the live topology-diff row is the
adapter's). cargo test --bins 117 green, fmt + clippy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 12:43:59 -04:00
molluskandClaude Opus 4.8 ab597c332d Merge phase 2: pure graph model + taint engine
Impl-plan §4/Phase 2, design v3.4 §6.1-§6.1.3. Pure engine
(evaluate(&GraphSnapshot,&ExclusionCtx,&StickyState)->(Decisions,StickyState)),
never linked against libpipewire; fed by the phase-3 observer to come.

Six adversarial review rounds with Codex (gpt-5.6-sol xhigh). Real echo
leaks found and closed in rounds 1-3 (owner-bridge, sticky-client
contamination, asymmetric forwarders, device mis-classification); my F1
narrowing refuted and conceded in round 4; contract strengthenings in 5-6.
Every fix mutation-verified (reverting it is killed by its intended test).
57 tests, each asserting an exact eligible/excluded partition.

KNOWN v1 LIMITATION (user-accepted 2026-07-22, owed to design doc round 8):
an app that buffers the call, fully tears down its PipeWire objects, and
replays after reconnecting can leak. In-threat-model but contrived; the
fix (process-generation sticky lifetime, revising v3.4 §6.1.3) is deferred
to phase 3's process-liveness work. Documented in src/host/taint/mod.rs.

Phase-3 obligations recorded in the taint module docs and Codex's round-6
report. Unblocks phase 3 (registry observer).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 04:42:52 -04:00
molluskandClaude Opus 4.8 279903e56e host/taint: correct the buffered-echo scoping (in-threat-model); pin ambiguous-client (Codex round 6)
Codex refuted my round-5 disposition and was right: the buffered-echo gap
is NOT limited to keyless/unbounded readers. A normal PID-bearing app —
recorder, DAW, GStreamer — can read the call, buffer it in application
memory, fully tear down its PipeWire Node *and* Client, then (still the
same live process) open a fresh Client + output and replay. `seed_sticky`
drops the PID fingerprint once every old serial is gone, so the replayed
leg is Eligible. That is in-threat-model, so my "outside the threat model"
claim was false.

- Rewrote the module-doc gap note honestly: in-threat-model, reachable by
  non-adversarial software, sitting on the design's §6.1.3 "full teardown
  ⇒ starts clean" boundary. Framed the two options — (A) accept as a
  documented v1 limitation, (B) process-generation lifetime (PID + /proc
  start-time, phase 3 supplies liveness, §6.1.3 revised). This is a
  designer's decision (it revises the security surface); NOT resolved in
  code. `a_fingerprint_does_not_outlive_its_owner` currently encodes
  Option A and flips under B.
- P2 (fixed): pinned the ambiguous-client-id branch. A mutation
  remembering only the first of two clients claiming one global id
  survived the suite; added a test scoped to the ambiguous owner (the
  global count was masked by the peerspeak owner's client). Verified the
  `.next()` mutation now fails it.

57 tests. Phase 2 is NOT converged — the buffered-echo design decision is
owed to the user before merge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 04:29:40 -04:00
molluskandClaude Opus 4.8 65fde92628 host/taint: pin the role-receiver mutation; doc fixes; document the unbounded-buffer limit (Codex round 5)
Round 5 convergence check. Codex confirmed F1(broad rule)/F2(doc)/
link-group fingerprint complete, and raised three more:

- P2 (fixed): a mutation deleting the *role-based* receiver insertion
  survived all 55 tests — every tested bridge source also had an inbound
  link. A pixelpass capture sink is a taint root before anything links
  into it, and its re-emitting sibling must bridge from it on role alone.
  Added `a_local_root_receiver_bridges_without_an_inbound_link`; mutation
  now killed.
- P3 (fixed): doc drift. The backstop's preamble still described the old
  "targets must be unbounded / apps never swept" rule; rewritten to the
  two-tier trigger/sweep. The `session_device` factory guidance now says
  explicit allowlist, not "and the like".
- P1 (dispositioned as a documented v1 limitation, not fixed): a buffered
  echo across a *full* teardown of an *unbounded* reader. Grounds, in the
  module docs: (1) it needs a stream exposing no PID/module-id/link-group,
  which is malformed/identity-hiding and outside v3.4 §2's non-adversarial
  threat model; (2) it contradicts the design's explicit "reappears after
  full teardown ⇒ new owner, starts clean" (§6.1.3), so closing it is a
  design change; (3) the only closed-form fix is a whole-share hammer
  (one keyless stream ⇒ desktop unshareable for the share). Reachable
  cases — a reader live now — are already covered by the backstop.
  Owed to the design doc as a round-8 note.

56 tests. Taking the P1 disposition to Codex for ratification, then to
the user as a design decision.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 18:06:45 -04:00
molluskandClaude Opus 4.8 2183084ec8 host/taint: concede the unbounded-reader rule; pin link-group fingerprints (Codex round 4)
Round 4 adjudicated my three round-3 pushbacks. Codex ruled: F2 bool seam
sufficient (YES), F3 accepted as a phase-3 contract not a phase-2 blocker
(YES) — but my F1 narrowing was unsound (NO), with a clean counterexample.

F1 (conceded): I had narrowed "unbounded tainted reader ⇒ exclude every
output" to spare outputs carrying a real, non-daemon PID, arguing an
unbounded reader must be daemon-owned. Codex refuted it:
`application.process.id` is optional and client-controlled, so one real
process can present NO pid on its reading leg (unbounded) and a real pid
on its output leg — the narrowing spares that output and leaks the call.
App properties cannot carry a soundness argument; only `pipewire.*` has
protected identity. Reverted to the broad rule: an unbounded tainted
reader excludes the whole candidate universe. Added the exact
counterexample as a test (`a_real_app_with_no_pid_on_its_reader_leg...`)
and kept a bounded-reader test to show the round-1 blast-radius guarantee
still holds for the bounded tier.

F2 (doc corrected): removed the "a mis-classified filter is still braced"
claim — Codex showed a filter with no shared strong key, wrongly marked
`session_device`, cannot trip the backstop from its reading leg and leaks
through a differently-keyed output. A false positive is now documented as
leak-capable; the only defence is the correct positive classifier.

F3 (link-group fingerprint, pinned): a mutation dropping LinkGroup
fingerprints survived all 53 tests, because the strong-key fingerprint
test used pulse.module.id. Added a link-group new-connection test.

Mutation-verified 2/2. 55 tests.

Phase-2 open item is now only F3-as-phase-3-contract, which Codex accepted
is not a phase-2 blocker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 17:54:08 -04:00
molluskandClaude Opus 4.8 f35bab0379 host/taint: close the inverse asymmetric leak; strengthen contracts (Codex round 3)
Round 3 was the second verification round. One real leak, one accepted
narrowing of Codex's own suggested fix, two contract strengthenings, and
a test-gap fix.

F1 (P1, real leak, fixed): the inverse of round-1 finding 4. A tainted
reader that is itself *unbounded* (client.id only, daemon PID suppressed)
whose re-emitting leg carried an *unmatched* strong key left that leg
"bounded" and Eligible. An unbounded reader cannot be positively related
to any output, so a strong key that does not match it back proves nothing.

  Two-tier backstop. A bounded tainted reader excludes only unbounded
  outputs (a differently-keyed output is provably a different owner). An
  unbounded tainted reader also excludes daemon-owned outputs — but NOT
  ordinary apps.

  ⚠️ Deliberately narrower than Codex's suggested "exclude every output".
  An unbounded reader is necessarily daemon-owned (a real app has its own
  PID, which is a usable key, so it would be bounded), so its sibling is
  another daemon leg, never an app. Sweeping in real apps would lose the
  round-1 "blast radius stays small" guarantee for no safety gain. When
  the daemon PID is unknown the app/leg distinction collapses and the rule
  degrades to Codex's exclude-all. Both directions are pinned by tests,
  and the over-aggressive variant fails the spares-real-apps test.

F2 (contract, strengthened): `session_device` is documented as a positive
high-confidence phase-3 classification, not `device.id`+`device.api`
(measured insufficient — a card filter can carry both; node.physical is
null on the real ALSA nodes so it is not a discriminator). Fail closed:
unknown ⇒ false. Documented why a mis-classified filter still does not
leak in practice — its legs share a link-group (strong-key bridge) and an
unbounded reading leg trips the two-tier backstop.

F5 (P2, test gap): a mutation keeping only PID fingerprints survived all
49 tests. Added a strong-key (pulse.module.id) new-connection fixture.

Mutation-verified 3/3 including the over-aggressive counter-mutation.

Still OWED to round 3, carried to round 4 for adjudication: finding 3
(a not-ready epoch can persist provisional owner *fusion* as sticky
over-exclusion). It is over-exclusion, never an echo leak, and closing it
needs a readiness/provenance model decision rather than a local patch —
see the round-4 handoff. 53 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 17:40:24 -04:00
molluskandClaude Opus 4.8 31084edcfa host/taint: close the partial fixes found in Codex round 2
The verification round earned its place: five of the six round-1 fixes
were partial, and two of the gaps were worse than the bugs they replaced.

1. ⚠️ The round-1 sticky fix smuggled the suppressed key back in.
   `client_serials_of` recorded the shared `WirePlumber [export]` client as
   a member of a tainted hardware sink's owner, so the *second* recompute
   expanded that client to every sound card on the box, tainted the
   microphone, and excluded every app holding one — the §6.1.1 catastrophe
   arriving one epoch late instead of never. `client.id` may now only be
   recorded, or expanded, for nodes where it is a usable owner key.
   The regression test evaluates an unchanged snapshot three times: a
   correct engine's answer must not drift when nothing has.
2. Sticky followed a surviving *connection*, not a surviving *owner*. A
   process can leave one client idle and open a second — GStreamer opens
   one per stream as a matter of course — and the new leg escaped.
   `StickyOwner` now carries owner **fingerprints** (strong keys and a
   usable PID, never `client.id`), applied only while some serial member
   is still live, so a recyclable key cannot resurrect a dead owner.
3. An **ambiguous** link input endpoint tainted every claimant but made
   none of them a receiver, so their sibling output legs stayed eligible.
   Taint without receiver status cannot start an owner bridge.
4. `device.id` is a raw observation, not the classification the coarse-key
   exception needs — PipeWire defines it only as "the Device this node
   belongs to", so a forwarding node carrying one would have lost both its
   owner keys and its ability to trip the backstop. Replaced by
   `session_device`, a phase-3 obligation (`device.id` AND `device.api`)
   documented to fail closed when it cannot classify.
5. Readiness now gates sticky **retirement only**. Round 1 stopped a
   not-ready epoch erasing history; it also stopped it recording any, so a
   reader could consume and buffer the call during that epoch, vanish
   before readiness, and leave its output eligible.
6. Added the unresolved-output-plus-unknown-role fixture: deleting one
   `receivers.insert` survived all 42 previous tests.

Mutation-verified: 7/7 reverts killed by their intended test. Two attempts
did not land first time and both were my error, not the engine's — the
client-key guard is applied at two sites so removing one is not a revert
(removing the pair is, and that is killed), and the fingerprint-lifetime
test put the recycled node in a snapshot *after* the entry had already
been retired, so the guard was never consulted. Rewritten to place it in
the same snapshot that first sees the owner gone.

Cost comment corrected again, to O(D·(V+E+Σ|sources|·|targets|)).

49 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 17:12:21 -04:00
molluskandClaude Opus 4.8 a46c4cd20c host/taint: close five leaks found in Codex round 1
All five were reachable, all five now have a regression test, and each
test was verified by injecting the mutation that reverts its fix.

1. Sticky taint ignored surviving Client members. An app can close every
   stream while keeping its PipeWire connection open and then open a new
   one — Firefox does this constantly — and the new leg came back
   Eligible while the owner's buffers still held the call. Sticky seeding
   now resolves live Client serials to their current nodes.
2. "Receives audio" was inferred from `media.class` alone, so a node with
   an absent or unexpected class sitting on a real inbound link could not
   start an owner bridge and its sibling re-emitted the call. A node is
   now a receiver if it appears as a resolved `link.input.node` OR has a
   receiving role.
3. The device-node coarse-key exception was keyed on `media.class` being
   `Audio/Sink|Source|Duplex`, which also stripped the only correlation a
   *native virtual sink* has (own client, no link-group, no module id).
   Now keyed on `device.id`, measured on the live graph as the exact
   discriminator: the 5 ALSA nodes carry device.id 43/45/46 and share
   `client.id` 42 (`WirePlumber [export]`); the 3 `support.null-audio-sink`
   nodes carry no device.id and hold their own clients.
4. The unbounded-owner backstop required the tainted *reader* to be
   unbounded. Properties can be asymmetric — a reader with a link-group
   whose re-emitting leg has none is bounded while its sibling is not
   findable — so that condition is dropped; targets stay restricted to
   unbounded output legs, which keeps the blast radius small.
5. A not-ready snapshot could retire sticky owners, erasing taint history
   on the strength of a graph already declared untrustworthy. `evaluate`
   now returns the prior state unchanged while `!graph_ready`.

Test-quality findings, also fixed:
- a single pass of each rule survived all 32 tests (every fixture needed
  at most one owner hop) → two-chained-forwarder test with a clean
  control, plus a 60-layer chain to catch an accidental blow-up
- first-write-wins `raise()` survived → a node reached by bridge on one
  pass and by a direct link on the next must report the stronger reason
- `drop_clients` left the fixture's client caches stale, so "a fresh
  client after teardown" was really a dangling id; the recycling row now
  reuses node id, client id AND `pulse.module.id` verbatim

Also corrected the cost claim: this is O((V+E)·D) for owner-bridge depth
D, not O(V+E) as v3.4 §6.4 states. Owner keys are now computed once per
snapshot instead of per candidate pair.

42 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 16:47:38 -04:00
molluskandClaude Opus 4.8 6ead1fe9f8 host/taint: pure graph model + taint engine (phase 2)
Implements design v3.4 §6.1–§6.1.3 behind a fixture test surface. No
PipeWire types in any signature; nothing here links against libpipewire.
Not wired into anything yet — phase 3's registry observer is what will
feed it, so the module is `#![allow(dead_code)]` for now.

    evaluate(&GraphSnapshot, &ExclusionCtx, &StickyState)
        -> (Decisions, StickyState)

- snapshot.rs: owned Node/Port/Link/Client model keyed on `Serial`
  (object.serial, 64-bit, identity) with `GlobalId` retained strictly as
  a snapshot-local lookup key. Two live objects claiming one id resolve
  as `Ambiguous`, which fails closed.
- owner.rs: the owner bridge — the key union (link-group, pulse.module.id,
  client.id, application.process.id) with equality-not-first-present
  semantics, transitive union-find components, and both suppression rules.
- mod.rs: monotone fixpoint over link edges, the conditional owner bridge
  (gated on the tainted member being one that *receives* audio) and the
  unbounded-owner backstop, then sticky merge. Stable `Reason` codes with
  an explicit priority so the reported reason never depends on traversal
  order.

Three judgement calls that go beyond what v3.4 spells out, all flagged
in the source:

1. Coarse keys (client.id, application.process.id) may not bridge
   device-role nodes. Every ALSA device is created by one WirePlumber
   process, so they share a client and a PID; peerspeak's playback taints
   the default sink on every recompute, and without this rule that taint
   reaches the microphone source and then every app holding a mic loses
   its playback — the §6.1.1 catastrophe by another route.
2. "Owner is bounded" is not "has a usable key": client.id alone does not
   bound an owner (the measured GStreamer split-client refutation), so
   the fail-closed backstop keys on strong keys or a usable PID.
3. Sticky entries record a reason per node rather than one per owner, so
   a forwarder's output leg keeps `tainted-owner-bridge` instead of
   inheriting its input leg's `tainted-upstream`.

32 fixture tests, each asserting an exact partition of the full candidate
universe rather than spot-checking named nodes: v3.4 §12's matrix, the
impl plan's degenerate-snapshot boundary, and the eligible half of every
scenario so an exclude-everything build fails.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 16:24:15 -04:00
molluskandClaude Opus 4.8 d54e2b99fc Merge phase 0a: object.serial u32→u64
Impl-plan §2/0a. Exit gate (boundary parse tests) met; reviewed by Codex
(gpt-5.6-sol xhigh) round 1 — APPROVE-WITH-NITS, one P3 fixed and its
mutant verified. Unblocks phase 2 (pure taint engine).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 16:00:49 -04:00
molluskandClaude Opus 4.8 87de5213fe audio: cover ordinary serial lengths in parse tests
Codex round 1 (P3): the valid cases were only 1, 10 and 20 digits long,
so `if (2..10).contains(&raw.len()) { return None }` survived all four
tests while rejecting every serial a freshly started daemon hands out.
Verified: that mutant passes the old suite and fails the new test.

Also corrects the doc comment — leading zeroes are accepted (harmless
and unambiguous), only whitespace padding is rejected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 16:00:34 -04:00
molluskandClaude Opus 4.8 9b6c8bb5c3 audio: parse object.serial as u64 (phase 0a)
`object.serial` is a 64-bit PipeWire counter, not a u32 object id.
Parsing it with `parse::<u32>()` returns None past u32::MAX, which
silently leaves `RouterState::sink_serial` unset — `try_flush` then
routes nothing and app-filter mode is dead with no diagnostic.

- factor the parse into a pure `parse_object_serial(&str) -> Option<u64>`
  (strict decimal; rejects signs, padding, overflow) with unit tests at
  the u32 boundary, past it, and at u64::MAX
- widen `RouterState::sink_serial` to `Option<u64>`
- log a warning when the sink's serial is unusable instead of returning
  silently
- audit the other `parse::<u32>` in this file: `load_module` returns a
  PulseAudio module index (uint32_t), genuinely 32-bit — annotated, not
  changed

Prerequisite for the taint engine's lifetime-awareness, which is keyed
on object.serial (screenshare-audio-exclusion-impl-plan.md §1, §2/0a).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 15:47:30 -04:00
molluskandClaude Opus 4.8 40604c716c debug: add PIXELPASS_TS_DUMP tap for A/V drift analysis
When PIXELPASS_TS_DUMP=<path> is set, tee the muxed MPEG-TS to a file in
addition to the normal fd=1 serve path, so the host-side stream can be
ffprobe'd for capture-side audio/video PTS drift. Each tee branch gets its
own queue so the disk sink cannot backpressure the live serve branch.

No effect when the variable is unset, mirroring PIXELPASS_GST_DEBUG.

Used to establish that the host produces an A/V-clean realtime stream
(+/-18 ms over 170 s), ruling out the capture side in the screen-share
drift investigation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 14:49:42 -04:00