Author SHA1 Message Date
mollusk 471b8221ff host/observer: address the Codex phase-3r review (2 fixes, both verified)
Codex's adversarial review of the pure core found no *certain* P1. Two
findings taken, both mutation-verified (the fix reverted, the intended test
dies, nothing else moves):

**F2, certain, P2 — `device_props` tested the wrong kind of ambiguity.** It
required exactly one live *Device* on the claimed id rather than exactly one
live *global*. With `[Device, Port]` on one id — a missed removal, the same
precondition as every other recycled-id hazard — it kept answering from the
older Device, so a node claiming that id held a stale `session_device = true`.
That flag strips the node's owner keys and its fail-closed backstop, so a
forwarder wearing it can put its output leg back on the eligible side. Now:
one slot total, and it must be the Device.

**F3, worth checking, P3 — `device.api` was corroborating by presence.**
`device.api=v4l2` under `factory.name=api.alsa.pcm.sink` satisfied the
positive classifier. No truthful configuration produces that pair, which is
the argument for reading it as an observation gone wrong rather than as
corroboration. The API must now equal the one the factory allowlist is
written for, an empty value is not a value, and the two sides disagreeing
fails closed. Tied to the allowlist being ALSA-only via a named constant.

Two findings NOT fixed here, both pre-existing and neither introduced by
round 8 — raised to the design doc instead:

- **P1, worth checking: hardware playback-to-capture paths** (Stereo Mix,
  Digital Loopback) on a card whose driver is an ordinary `snd_hda_intel`.
  Both its sink and source classify `session_device`, taint cannot cross the
  hardware hop, and a capture app reading that source can re-emit the call.
  This is `snd_aloop` again in a form the driver name cannot detect;
  distinguishing it needs ALSA control inspection, which is a design change
  and a new I/O surface, not a local fix.
- **P3: the 2 s readiness budget** can in principle never see an
  obligation-free instant under sustained startup churn, and `TimedOut` is
  sticky by design, so the process would be silent for its lifetime.
  Measured here: readiness at ~3 ms with 19 binds, so the margin is three
  orders of magnitude — but it wants a calibration argument, not a guess.

197 unit + 3 live green, clippy -D warnings and fmt clean.
2026-07-25 18:48:53 -04:00
5 changed files with 164 additions and 275 deletions
+19 -2
View File
@@ -64,6 +64,11 @@ const HARDWARE_PCM_FACTORIES: &[&str] = &[
"api.alsa.pcm.source", "api.alsa.pcm.source",
]; ];
/// The `device.api` every entry in [`HARDWARE_PCM_FACTORIES`] belongs to.
/// A single value rather than a list, because the allowlist is ALSA-only;
/// this constant is the thing to change when that stops being true.
const HARDWARE_PCM_API: &str = "alsa";
/// ALSA drivers that expose a hardware-PCM `factory.name` but are **not** /// ALSA drivers that expose a hardware-PCM `factory.name` but are **not**
/// passive terminals — audio written in reappears on their capture side /// passive terminals — audio written in reappears on their capture side
/// through a path the PipeWire Link graph cannot see, so classifying them /// through a path the PipeWire Link graph cannot see, so classifying them
@@ -199,8 +204,20 @@ pub fn classify(claim: &DeviceClaim, device: Option<&DeviceProps>) -> Classifica
.flatten() .flatten()
.any(|d| NON_TERMINAL_ALSA_DRIVERS.contains(&d)); .any(|d| NON_TERMINAL_ALSA_DRIVERS.contains(&d));
let driver_ok = driver.is_some() && !driver_denied; let driver_ok = driver.is_some() && !driver_denied;
let api_present = device.device_api.is_some() || claim.device_api.is_some(); // The API must positively be the one the factory allowlist is written
let is_hardware_pcm = api_present && on_factory_allowlist && driver_ok; // for, not merely present (Codex phase-3r review, finding 3). "Present"
// admitted `device.api=v4l2` alongside `factory.name=api.alsa.pcm.sink`
// — a contradiction no truthful configuration produces, which is exactly
// why it should be read as an observation gone wrong rather than as
// corroboration. Disagreement between the two sides fails closed for the
// same reason. ⚠️ Tied to [`HARDWARE_PCM_FACTORIES`] being ALSA-only:
// adding a BlueZ factory means allowing `bluez5` here too.
let api_ok = match (device.device_api.as_deref(), claim.device_api.as_deref()) {
(Some(from_device), Some(from_node)) if from_device != from_node => false,
(Some(api), _) | (None, Some(api)) => api == HARDWARE_PCM_API,
(None, None) => false,
};
let is_hardware_pcm = api_ok && on_factory_allowlist && driver_ok;
if is_hardware_pcm { if is_hardware_pcm {
Classification::SessionDevice Classification::SessionDevice
} else { } else {
+23 -13
View File
@@ -596,21 +596,31 @@ impl RegistryModel {
} }
/// The bound properties of the Device a node claims by global id, or /// The bound properties of the Device a node claims by global id, or
/// `None` when that claim is unresolved — which covers all three /// `None` when that claim is unresolved — which covers every fail-closed
/// fail-closed cases at once: no such Device observed, its bind still /// case at once: no such Device observed, its bind still outstanding, or
/// outstanding, or **two live Devices sharing the recycled id**, where /// **the id claimed by more than one live global**, where there is no way
/// there is no way to tell whose properties these are (v3.4 §6.1.3). /// to tell whose properties these are (v3.4 §6.1.3).
///
/// ⚠️ The ambiguity test is "**exactly one** live global holds this id",
/// not "exactly one live *Device*" (Codex phase-3r review, finding 2).
/// The weaker test looks equivalent and is not: with `[Device, Port]` on
/// one id — a missed removal, the same precondition as every other
/// recycled-id hazard — it keeps answering with the older Device's
/// properties, so a node claiming that id holds a stale
/// `session_device = true`. That flag *removes* the node's owner keys and
/// its fail-closed backstop, so a forwarder wearing it can put its output
/// leg back on the eligible side: echo, from a lookup that was merely
/// looking at the wrong object type.
fn device_props(&self, id: GlobalId) -> Option<&DeviceProps> { fn device_props(&self, id: GlobalId) -> Option<&DeviceProps> {
let mut found: Option<Serial> = None; let slots = self.live_ids.get(&id)?;
for slot in self.live_ids.get(&id)? { if slots.len() != 1 {
if let Slot::Device(serial) = slot { return None; // Ambiguous ⇒ unresolved ⇒ withheld.
if found.is_some() {
return None; // Ambiguous ⇒ unresolved ⇒ withheld.
}
found = Some(*serial);
}
} }
self.devices.get(&found?)?.props.as_ref() let Slot::Device(serial) = slots.front()? else {
// The id is live, but it is not a Device any more.
return None;
};
self.devices.get(serial)?.props.as_ref()
} }
/// Classify one node's device claim against the currently resolved /// Classify one node's device claim against the currently resolved
+88
View File
@@ -346,6 +346,53 @@ fn classify_device_api_from_either_side_corroborates() {
); );
} }
#[test]
fn classify_contradictory_api_fails_closed() {
// Codex phase-3r review, finding 3. A `device.api` that is merely
// *present* is not corroboration: `v4l2` under an ALSA PCM factory is a
// contradiction, and the safe reading of a contradiction is "an
// observation went wrong", not "close enough".
let claim = DeviceClaim {
device_id: Some(gid(7)),
device_api: None,
factory_name: Some("api.alsa.pcm.sink".to_string()),
alsa_driver_name: Some("snd_hda_intel".to_string()),
};
assert_eq!(
classify(
&claim,
Some(&device_with(Some("v4l2"), Some("snd_hda_intel")))
),
Classification::NotSessionDevice,
"a non-ALSA api under an ALSA factory must not corroborate"
);
// The two sides disagreeing fails closed for the same reason.
let disagreeing = DeviceClaim {
device_id: Some(gid(7)),
device_api: Some("bluez5".to_string()),
factory_name: Some("api.alsa.pcm.sink".to_string()),
alsa_driver_name: Some("snd_hda_intel".to_string()),
};
assert_eq!(
classify(&disagreeing, Some(&alsa_device())),
Classification::NotSessionDevice,
"node and Device naming different APIs must fail closed"
);
// An empty value is not a value.
let empty = DeviceClaim {
device_id: Some(gid(7)),
device_api: Some(String::new()),
factory_name: Some("api.alsa.pcm.sink".to_string()),
alsa_driver_name: Some("snd_hda_intel".to_string()),
};
assert_eq!(
classify(&empty, Some(&device_with(None, Some("snd_hda_intel")))),
Classification::NotSessionDevice
);
}
#[test] #[test]
fn classify_allowlist_is_exact_not_substring() { fn classify_allowlist_is_exact_not_substring() {
// A factory that merely *contains* an allowlisted name must not pass. // A factory that merely *contains* an allowlisted name must not pass.
@@ -1300,6 +1347,47 @@ fn model_removed_device_withholds_its_nodes_again() {
assert!(!m.graph_ready(), "and it is an obligation again"); assert!(!m.graph_ready(), "and it is an obligation again");
} }
#[test]
fn model_device_id_shared_with_another_object_type_withholds() {
// Codex phase-3r review, finding 2 (certain). The ambiguity test has to
// be "exactly one live global holds this id", not "exactly one live
// *Device*": a Port recycling the id is the same missed-removal
// condition, and answering from the older Device leaves the claiming
// node wearing a stale `session_device = true` — which strips its owner
// keys and backstop, the difference between over-exclusion and echo.
let mut m = model();
add_device(&mut m, 4200, 42, alsa_device());
add_device_node(
&mut m,
100,
50,
MediaRole::Sink,
hw_claim(42, "alsa", "api.alsa.pcm.sink"),
);
assert!(
m.project()
.snapshot
.node(ser(100))
.unwrap()
.props
.session_device
);
// A Port appears on the recycled id 42.
m.apply(port(4300, 42, 50, PortDirection::In));
assert_eq!(
m.project().snapshot.nodes().count(),
0,
"a contested device id resolves nothing"
);
assert!(!m.graph_ready(), "and it is an outstanding obligation");
// Accounting for the Device's removal leaves the Port holding the id
// alone — still not a Device, so the node stays withheld.
m.apply(RegEvent::Removed { id: gid(42) });
assert_eq!(m.project().snapshot.nodes().count(), 0);
}
#[test] #[test]
fn model_ambiguous_device_id_withholds_its_nodes() { fn model_ambiguous_device_id_withholds_its_nodes() {
// Two live Devices on one recycled id: there is no way to know whose // Two live Devices on one recycled id: there is no way to know whose
+34 -129
View File
@@ -381,28 +381,36 @@ pub fn evaluate(
let components = OwnerComponents::build(snapshot, ctx.pipewire_pulse_pid); let components = OwnerComponents::build(snapshot, ctx.pipewire_pulse_pid);
let keys = owner::OwnerKeyIndex::build(snapshot, ctx.pipewire_pulse_pid); let keys = owner::OwnerKeyIndex::build(snapshot, ctx.pipewire_pulse_pid);
// Pass 1 — the fail-closed view. Every decision is made from this one, so let mut taint: BTreeMap<Serial, Reason> = BTreeMap::new();
// "we could not see" counts as taint. let mut sticky_serials: BTreeSet<Serial> = BTreeSet::new();
let (taint, sticky_serials) = compute_taint(
snapshot,
ctx,
&keys,
&components,
prior,
Uncertainty::FailsClosed,
);
let decisions = build_decisions(snapshot, ctx, &taint, &sticky_serials);
// Pass 2 — the evidence-only view, and the only thing sticky state is seed_local_roots(snapshot, ctx, &mut taint);
// ever built from (see [`Uncertainty`]). seed_sticky(
let (evidence, _) = compute_taint(
snapshot, snapshot,
ctx,
&keys, &keys,
&components,
prior, prior,
Uncertainty::Ignored, &components,
&mut taint,
&mut sticky_serials,
); );
// 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);
// ⚠️ Readiness gates **retirement only**, never addition (Codex rounds // ⚠️ Readiness gates **retirement only**, never addition (Codex rounds
// 1 and 2, which caught the two halves of this in turn). An object // 1 and 2, which caught the two halves of this in turn). An object
// missing from an untrustworthy snapshot has not been observed to // missing from an untrustworthy snapshot has not been observed to
@@ -411,105 +419,10 @@ pub fn evaluate(
// *observed* during a not-ready epoch is real — a reader can consume // *observed* during a not-ready epoch is real — a reader can consume
// and buffer the call and then vanish before readiness — so discarding // and buffer the call and then vanish before readiness — so discarding
// additions was the same defect pointing the other way. // additions was the same defect pointing the other way.
let next_sticky = build_sticky( let next_sticky = build_sticky(snapshot, &keys, &components, &taint, prior, ctx.graph_ready);
snapshot,
&keys,
&components,
&evidence,
prior,
ctx.graph_ready,
);
(decisions, next_sticky) (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. /// Roots that are visible on the node itself.
fn seed_local_roots( fn seed_local_roots(
snapshot: &GraphSnapshot, snapshot: &GraphSnapshot,
@@ -520,20 +433,14 @@ fn seed_local_roots(
if let Some(reason) = local_root_reason(node, ctx) { if let Some(reason) = local_root_reason(node, ctx) {
raise(taint, node.serial, reason); 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> { fn local_root_reason(node: &NodeSnapshot, ctx: &ExclusionCtx) -> Option<Reason> {
if node.props.peerspeak_owned { if node.props.peerspeak_owned {
return Some(Reason::PeerspeakOwned); return Some(Reason::PeerspeakOwned);
@@ -656,7 +563,7 @@ fn nodes_of_client(
/// `output node → input nodes`, resolving snapshot-local ids. An endpoint /// `output node → input nodes`, resolving snapshot-local ids. An endpoint
/// that does not resolve taints the *other* end as unresolved ancestry when /// 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. /// that other end is the input side — we cannot know what is feeding it.
fn downstream_edges(snapshot: &GraphSnapshot, unresolved_input: &mut BTreeSet<Serial>) -> Edges { fn downstream_edges(snapshot: &GraphSnapshot, taint: &mut BTreeMap<Serial, Reason>) -> Edges {
let mut edges: BTreeMap<Serial, Vec<Serial>> = BTreeMap::new(); let mut edges: BTreeMap<Serial, Vec<Serial>> = BTreeMap::new();
let mut receivers: BTreeSet<Serial> = BTreeSet::new(); let mut receivers: BTreeSet<Serial> = BTreeSet::new();
for link in snapshot.links() { for link in snapshot.links() {
@@ -668,10 +575,8 @@ fn downstream_edges(snapshot: &GraphSnapshot, unresolved_input: &mut BTreeSet<Se
receivers.insert(to); receivers.insert(to);
} }
(_, Some(IdLookup::Unique(to))) => { (_, Some(IdLookup::Unique(to))) => {
// Something feeds this node and we cannot say what. Reported // Something feeds this node and we cannot say what.
// rather than raised here, because whether "cannot say" is raise(taint, to, Reason::UnresolvedAncestry);
// taint depends on which pass is running ([`Uncertainty`]).
unresolved_input.insert(to);
receivers.insert(to); receivers.insert(to);
} }
(_, Some(IdLookup::Ambiguous)) => { (_, Some(IdLookup::Ambiguous)) => {
-131
View File
@@ -820,137 +820,6 @@ 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) // Stickiness and lifetime-awareness (v3.4 §6.1.3)
// ────────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────────