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>
This commit is contained in:
2026-07-25 19:19:39 -04:00
co-authored by Claude Opus 5
parent 0af0124e17
commit 1b01847c66
7 changed files with 216 additions and 6 deletions
+16 -4
View File
@@ -22,9 +22,17 @@ use super::metrics::{BUCKET_LABELS, Metrics, QUEUE_THRESHOLD_US, Sample};
use super::*;
use crate::host::aec::AecConfig;
use crate::host::observer::{EventKind, Readiness};
use crate::host::taint::PEERSPEAK_OWNED_NODE_PREFIX;
use crate::host::taint::fixture::{self, Graph, NodeRef};
use crate::host::taint::snapshot::{GraphSnapshot, MediaRole};
/// The `node.name` a [`Graph::peerspeak_node`] fixture produces. Built from
/// the same constant the engine matches on, so these audit rows report the
/// name shape a live peerspeak node actually has (v3.5 §5.1, carrier 2).
fn owned_name(role: &str, pid: u32) -> String {
format!("{PEERSPEAK_OWNED_NODE_PREFIX}{role}_{pid}")
}
const AEC_MODULE: u64 = 7;
const TIMEOUT: Millis = 5_000;
@@ -284,13 +292,14 @@ fn a_shut_gate_preserves_the_engines_own_reasons() {
.body;
let (_, excluded) = partition(&body);
let playback = owned_name("peerspeak-playback", 200);
assert!(!body.fan_out_permitted);
assert_eq!(
excluded,
vec![
("music", "aec-validating"),
// Tagged, so it keeps the reason that actually applies to it.
("peerspeak-playback", "peerspeak-owned"),
(playback.as_str(), "peerspeak-owned"),
]
);
}
@@ -457,11 +466,12 @@ fn row_1_owner_bridge_forwarder_with_an_untainted_control() {
.body;
let (eligible, excluded) = partition(&body);
let call_name = owned_name("peerspeak-call", 200);
assert_eq!(eligible, vec!["clean-loopback-playback"]);
assert_eq!(
excluded,
vec![
("peerspeak-call", "peerspeak-owned"),
(call_name.as_str(), "peerspeak-owned"),
("tainted-loopback-playback", "tainted-owner-bridge"),
]
);
@@ -494,11 +504,12 @@ fn row_3_one_tainted_module_does_not_taint_the_other() {
.body;
let (eligible, excluded) = partition(&body);
let call_name = owned_name("peerspeak-call", 200);
assert_eq!(eligible, vec!["module-b-playback"]);
assert_eq!(
excluded,
vec![
("peerspeak-call", "peerspeak-owned"),
(call_name.as_str(), "peerspeak-owned"),
("module-a-playback", "tainted-owner-bridge"),
]
);
@@ -701,7 +712,8 @@ fn the_taint_view_covers_non_candidate_roles() {
tainted.contains(&("null-sink", "tainted-upstream")),
"the sink is not a candidate but its taint is what explains the row: {tainted:?}"
);
assert!(tainted.contains(&("peerspeak-call", "peerspeak-owned")));
let call_name = owned_name("peerspeak-call", 200);
assert!(tainted.contains(&(call_name.as_str(), "peerspeak-owned")));
}
/// A record must serialise to a single line. Newlines inside a JSON Lines