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
+34 -1
View File
@@ -123,6 +123,29 @@ pub const CAPTURE_SINK_PREFIX: &str = "pixelpass_capture_";
/// what `pulse.module.id` is for (v3.4 §5.2 correction 4).
pub const ECHO_CANCEL_GROUP_PREFIX: &str = "echo-cancel-";
/// Ownership carrier 1: the node property peerspeak sets on everything it
/// plays (v3.5 §5.1). Read at the observer boundary, which is the only place
/// that touches raw property names — see [`super::observer`].
///
/// ⚠️ **Cross-repo wire contract.** peerspeak emits this; it does not depend
/// on this crate, nor this crate on it. The values are pinned in
/// `tests/fixtures/ownership-tag-contract.txt`, committed byte-identical in
/// both repos, and asserted by [`tests::ownership_carriers_match_the_cross_repo_fixture`].
/// The producer's matching constants live in peerspeak
/// `src/audio/ownership.rs`. Changing either is a both-repos-same-session
/// change that invalidates the phase 5 matrix.
pub const PEERSPEAK_OWNED_PROP: &str = "peerspeak.owned";
/// Ownership carrier 2: a `node.name` prefix (v3.5 §5.1, round 8).
///
/// Matched as a **union** with [`PEERSPEAK_OWNED_PROP`] — either one makes a
/// node peerspeak-owned. Two carriers because a property is invisible to the
/// registry `global` event and recoverable only by binding the node (v3.5
/// §6.7), which is precisely how the phase-5 gate failed; this one is
/// announced directly. A union is also the fail-closed direction: a missed
/// tag leaks call audio into the share, a spurious one only over-excludes.
pub const PEERSPEAK_OWNED_NODE_PREFIX: &str = "peerspeak_owned_";
/// Why a node is tainted or excluded. Stable machine-readable codes: this
/// value is the phase 5 audit output, the phase 6 status event, and the
/// eventual answer to "why isn't this app being shared?".
@@ -535,7 +558,17 @@ fn ambiguous_id_nodes(snapshot: &GraphSnapshot) -> BTreeSet<Serial> {
}
fn local_root_reason(node: &NodeSnapshot, ctx: &ExclusionCtx) -> Option<Reason> {
if node.props.peerspeak_owned {
// The two ownership carriers, as a union (v3.5 §5.1). Kept here rather
// than folded together at the observer boundary so that the union is a
// pure, directly-testable rule: an adapter that collapsed both into the
// one `peerspeak_owned` bool would make each carrier untestable alone,
// which is exactly how phase 3r's row 1 nearly gated nothing.
if node.props.peerspeak_owned
|| node
.name
.as_deref()
.is_some_and(|name| name.starts_with(PEERSPEAK_OWNED_NODE_PREFIX))
{
return Some(Reason::PeerspeakOwned);
}
if let (Some(module), Some(aec)) = (node.props.pulse_module_id, ctx.aec_module_id)