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.
This commit is contained in:
@@ -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)
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user