Commit Graph
17 Commits
Author SHA1 Message Date
mollusk 956534f63c fix(host): close unsupported fanout gates 2026-08-21 19:45:18 -04:00
mollusk 781defcd84 feat(host): build desktop audio exclusion foundation 2026-08-21 15:39:07 -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
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 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 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 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
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 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