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.
This commit is contained in:
2026-07-25 18:49:06 -04:00
2 changed files with 260 additions and 34 deletions
+129 -34
View File
@@ -381,36 +381,28 @@ pub fn evaluate(
let components = OwnerComponents::build(snapshot, ctx.pipewire_pulse_pid);
let keys = owner::OwnerKeyIndex::build(snapshot, ctx.pipewire_pulse_pid);
let mut taint: BTreeMap<Serial, Reason> = BTreeMap::new();
let mut sticky_serials: BTreeSet<Serial> = BTreeSet::new();
seed_local_roots(snapshot, ctx, &mut taint);
seed_sticky(
// Pass 1 — the fail-closed view. Every decision is made from this one, so
// "we could not see" counts as taint.
let (taint, sticky_serials) = compute_taint(
snapshot,
ctx,
&keys,
prior,
&components,
&mut taint,
&mut sticky_serials,
prior,
Uncertainty::FailsClosed,
);
// Monotone fixpoint: every step only adds taint, or lowers a node's
// reason priority, both of which are bounded. Link propagation and the
// owner bridge feed each other — a bridged output leg has downstream
// links, and a downstream monitor reader bridges to its own siblings —
// so neither can be run once.
let edges = downstream_edges(snapshot, &mut taint);
loop {
let mut changed = false;
changed |= propagate_links(&edges.edges, &mut taint);
changed |= propagate_owner_bridge(&keys, &components, &edges, &mut taint);
changed |= propagate_unresolved_owner(snapshot, &keys, &edges, &mut taint);
if !changed {
break;
}
}
let decisions = build_decisions(snapshot, ctx, &taint, &sticky_serials);
// Pass 2 — the evidence-only view, and the only thing sticky state is
// ever built from (see [`Uncertainty`]).
let (evidence, _) = compute_taint(
snapshot,
ctx,
&keys,
&components,
prior,
Uncertainty::Ignored,
);
// ⚠️ Readiness gates **retirement only**, never addition (Codex rounds
// 1 and 2, which caught the two halves of this in turn). An object
// missing from an untrustworthy snapshot has not been observed to
@@ -419,10 +411,105 @@ pub fn evaluate(
// *observed* during a not-ready epoch is real — a reader can consume
// and buffer the call and then vanish before readiness — so discarding
// additions was the same defect pointing the other way.
let next_sticky = build_sticky(snapshot, &keys, &components, &taint, prior, ctx.graph_ready);
let next_sticky = build_sticky(
snapshot,
&keys,
&components,
&evidence,
prior,
ctx.graph_ready,
);
(decisions, next_sticky)
}
/// Whether a pass treats "we could not see" as taint.
///
/// **Both passes exist because stickiness is a claim about history, and
/// uncertainty is not history.** A node tainted only because the graph was
/// mid-enumeration has had nothing observed about it; remembering that as
/// taint forever is over-exclusion with no evidence behind it, and phase 3r's
/// bind-everything observer makes the window it happens in systematically
/// wide (every node is withheld until its bind resolves, so any link observed
/// across that gap raises [`Reason::UnresolvedAncestry`] on its input side).
/// Measured on a live desktop: a hardware sink acquired a permanent sticky
/// taint at every startup, from one link seen while its output node was still
/// unbound.
///
/// Retiring by *reason code* is not enough, because uncertainty launders
/// itself: an unresolved node propagates [`Reason::TaintedUpstream`] to its
/// downstream, and that reason is indistinguishable from real contamination
/// once recorded. So the split is by **provenance** — the sticky pass never
/// raises an uncertainty root at all, and nothing derived from one can reach
/// it. Decisions are unaffected: they are made from the fail-closed pass,
/// which is unchanged.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Uncertainty {
/// Unresolved ancestry and an unbounded tainted reader are taint
/// (v3.4 §6.1, §6.1.1, §6.1.4).
FailsClosed,
/// Only positively observed contamination counts.
Ignored,
}
/// One taint fixpoint over the snapshot. The `uncertainty` mode decides
/// whether absence of evidence is treated as evidence of contamination.
fn compute_taint(
snapshot: &GraphSnapshot,
ctx: &ExclusionCtx,
keys: &owner::OwnerKeyIndex,
components: &OwnerComponents,
prior: &StickyState,
uncertainty: Uncertainty,
) -> (BTreeMap<Serial, Reason>, BTreeSet<Serial>) {
let fails_closed = uncertainty == Uncertainty::FailsClosed;
let mut taint: BTreeMap<Serial, Reason> = BTreeMap::new();
let mut sticky_serials: BTreeSet<Serial> = BTreeSet::new();
seed_local_roots(snapshot, ctx, &mut taint);
if fails_closed {
for serial in ambiguous_id_nodes(snapshot) {
raise(&mut taint, serial, Reason::UnresolvedAncestry);
}
}
seed_sticky(
snapshot,
keys,
prior,
components,
&mut taint,
&mut sticky_serials,
);
// Edges are built identically in both passes — receiver status is a
// topological fact and must not depend on the mode, or the owner bridge
// would see two different graphs.
let mut unresolved_input: BTreeSet<Serial> = BTreeSet::new();
let edges = downstream_edges(snapshot, &mut unresolved_input);
if fails_closed {
for serial in unresolved_input {
raise(&mut taint, serial, Reason::UnresolvedAncestry);
}
}
// Monotone fixpoint: every step only adds taint, or lowers a node's
// reason priority, both of which are bounded. Link propagation and the
// owner bridge feed each other — a bridged output leg has downstream
// links, and a downstream monitor reader bridges to its own siblings —
// so neither can be run once.
loop {
let mut changed = false;
changed |= propagate_links(&edges.edges, &mut taint);
changed |= propagate_owner_bridge(keys, components, &edges, &mut taint);
if fails_closed {
changed |= propagate_unresolved_owner(snapshot, keys, &edges, &mut taint);
}
if !changed {
break;
}
}
(taint, sticky_serials)
}
/// Roots that are visible on the node itself.
fn seed_local_roots(
snapshot: &GraphSnapshot,
@@ -433,14 +520,20 @@ fn seed_local_roots(
if let Some(reason) = local_root_reason(node, ctx) {
raise(taint, node.serial, reason);
}
// A node whose own global id is ambiguous cannot be the reliable
// endpoint of any link, so its ancestry is unresolvable.
if snapshot.node_by_id(node.id) == Some(IdLookup::Ambiguous) {
raise(taint, node.serial, Reason::UnresolvedAncestry);
}
}
}
/// Nodes whose own global id is ambiguous: they cannot be the reliable
/// endpoint of any link, so their ancestry is unresolvable. Uncertainty, not
/// evidence — see [`Uncertainty`].
fn ambiguous_id_nodes(snapshot: &GraphSnapshot) -> BTreeSet<Serial> {
snapshot
.nodes()
.filter(|node| snapshot.node_by_id(node.id) == Some(IdLookup::Ambiguous))
.map(|node| node.serial)
.collect()
}
fn local_root_reason(node: &NodeSnapshot, ctx: &ExclusionCtx) -> Option<Reason> {
if node.props.peerspeak_owned {
return Some(Reason::PeerspeakOwned);
@@ -563,7 +656,7 @@ fn nodes_of_client(
/// `output node → input nodes`, resolving snapshot-local ids. An endpoint
/// that does not resolve taints the *other* end as unresolved ancestry when
/// that other end is the input side — we cannot know what is feeding it.
fn downstream_edges(snapshot: &GraphSnapshot, taint: &mut BTreeMap<Serial, Reason>) -> Edges {
fn downstream_edges(snapshot: &GraphSnapshot, unresolved_input: &mut BTreeSet<Serial>) -> Edges {
let mut edges: BTreeMap<Serial, Vec<Serial>> = BTreeMap::new();
let mut receivers: BTreeSet<Serial> = BTreeSet::new();
for link in snapshot.links() {
@@ -575,8 +668,10 @@ fn downstream_edges(snapshot: &GraphSnapshot, taint: &mut BTreeMap<Serial, Reaso
receivers.insert(to);
}
(_, Some(IdLookup::Unique(to))) => {
// Something feeds this node and we cannot say what.
raise(taint, to, Reason::UnresolvedAncestry);
// Something feeds this node and we cannot say what. Reported
// rather than raised here, because whether "cannot say" is
// taint depends on which pass is running ([`Uncertainty`]).
unresolved_input.insert(to);
receivers.insert(to);
}
(_, Some(IdLookup::Ambiguous)) => {
+131
View File
@@ -820,6 +820,137 @@ fn an_ambiguous_recycled_global_id_fails_closed() {
);
}
// ──────────────────────────────────────────────────────────────────────
// Uncertainty is not history — it never enters sticky state
// (round 9, from a live phase-5 audit run; see `Uncertainty` in mod.rs)
// ──────────────────────────────────────────────────────────────────────
#[test]
fn unresolved_ancestry_does_not_survive_being_resolved() {
// Measured live on a desktop: a link is observed while its output node is
// still unbound, the input side fails closed — correctly — and then that
// fail-closed mark became *sticky*, so a hardware sink stayed excluded for
// the process lifetime even after the node resolved and turned out to be
// an ordinary game. Phase 3r's bind-everything observer widens that window
// to every node, so this must clear.
let mut graph = Graph::new();
let ghost = graph.dangling_id();
let client = graph.client_of_app(6000);
let victim = graph.node("victim-in", MediaRole::StreamInput, app(client, 6000));
let sibling = graph.node("victim-out", MediaRole::StreamOutput, app(client, 6000));
graph.link_ids(ghost, victim.id);
let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11114);
let c = ctx();
// While the ancestry is genuinely unresolved, the decision is unchanged:
// fail closed, both the victim and its sibling excluded.
let (first, sticky) = evaluate(&graph.build(), &c, &StickyState::default());
assert_partition(
&first,
&[("firefox", firefox)],
&[("victim-out", sibling, "tainted-owner-bridge")],
);
assert_tainted(&first, victim, "unresolved-ancestry");
// The node behind that id turns up — nothing tainted, it was simply not
// observed yet. The uncertainty is gone, so nothing may remain of it.
let late_client = graph.client_of_app(7100);
let resolved = graph.node_with_id(
"was-unbound",
MediaRole::StreamOutput,
ghost,
app(late_client, 7100),
);
let (second, _) = evaluate(&graph.build(), &c, &sticky);
assert_partition(
&second,
&[
("firefox", firefox),
("victim-out", sibling),
("was-unbound", resolved),
],
&[],
);
}
#[test]
fn uncertainty_laundered_into_downstream_taint_is_not_sticky_either() {
// Retiring by reason *code* would not be enough: an unresolved node
// propagates `tainted-upstream`, which is indistinguishable from real
// contamination once recorded. The split has to be by provenance, so a
// node two hops from the uncertainty must clear too.
let mut graph = Graph::new();
let ghost = graph.dangling_id();
let forwarder_client = graph.client_of_app(6100);
let forwarder_in = graph.node(
"fwd-in",
MediaRole::StreamInput,
app(forwarder_client, 6100),
);
let forwarder_out = graph.node(
"fwd-out",
MediaRole::StreamOutput,
app(forwarder_client, 6100),
);
let downstream_client = graph.client_of_app(6200);
let downstream = graph.node("downstream", MediaRole::Sink, app(downstream_client, 6200));
let downstream_leg = graph.node(
"downstream-out",
MediaRole::StreamOutput,
app(downstream_client, 6200),
);
graph.link_ids(ghost, forwarder_in.id);
graph.link(forwarder_out, downstream);
let c = ctx();
let (first, sticky) = evaluate(&graph.build(), &c, &StickyState::default());
assert_tainted(&first, forwarder_in, "unresolved-ancestry");
assert_tainted(&first, downstream, "tainted-upstream");
assert!(
first.candidates[&downstream_leg.serial].reason().is_some(),
"while the ancestry is unresolved the downstream owner is excluded too"
);
let late_client = graph.client_of_app(7200);
graph.node_with_id(
"was-unbound",
MediaRole::StreamOutput,
ghost,
app(late_client, 7200),
);
let (second, _) = evaluate(&graph.build(), &c, &sticky);
assert_eq!(
second.candidates[&downstream_leg.serial].reason(),
None,
"nothing derived from the uncertainty may outlive it"
);
assert_eq!(
second.candidates[&forwarder_out.serial].reason(),
None,
"including the unresolved node's own owner siblings"
);
}
#[test]
fn real_taint_is_still_sticky_when_its_topology_goes_away() {
// The other half of the same rule, stated positively: *evidence* is
// history and must survive. This is the guard on the change above — if
// provenance splitting ever leaks into the evidence path, peerspeak's own
// audio starts escaping.
let (graph, call, rec_in, rec_out, firefox) = sticky_scene();
let c = ctx();
let (_, sticky) = evaluate(&graph.build(), &c, &StickyState::default());
let (second, _) = evaluate(&graph.build_without(&[rec_in]), &c, &sticky);
assert_partition(
&second,
&[("firefox", firefox)],
&[
("call", call, "peerspeak-owned"),
("rec-out", rec_out, "tainted-owner-bridge"),
],
);
}
// ──────────────────────────────────────────────────────────────────────
// Stickiness and lifetime-awareness (v3.4 §6.1.3)
// ──────────────────────────────────────────────────────────────────────