//! Phase 5 โ€” dry-run audit mode ๐Ÿšฆ (impl plan ยง5). //! //! **This phase adds no capability. Its entire purpose is to be wrong loudly //! and safely.** It runs phases 2โ€“4 against the *live* graph on every graph //! event and reports what they conclude. It creates no links, loads no modules, //! and changes no routing โ€” the only thing it produces is a line of JSON. //! //! Why this is the gate the plan marks ๐Ÿšฆ: the defects that matter here are //! graph-*reasoning* defects. The 57 phase-2 fixture tests prove the engine //! matches my model of PipeWire; only a live run proves my model matches //! PipeWire. A wrong answer at this phase costs a log line. The same wrong //! answer in phase 6 costs an echo โ€” the sharer's own voice, copied back into //! the share, which is the failure this whole design exists to prevent. //! //! ## The one structural requirement (ยง5.1) //! //! Every emitted record carries the **complete candidate universe partitioned //! into exact eligible and excluded sets**, with a stable reason code on each //! excluded row โ€” never a spot check on named nodes. Checking only the nodes a //! row names constrains nothing about the rest, and it lets the degenerate //! "exclude everything" implementation pass: that build is silent, produces no //! echo, and satisfies any assertion phrased purely as *this must be excluded*. //! Asserting the eligible half of each row is what fails it. That requirement is //! also the plan's answer to open question O7 (over-exclusion needs no separate //! gate โ€” it is subsumed by this one). //! //! ## What is deliberately *not* here //! //! - **No link creation, and no code path that could reach one.** The auditor //! consumes a [`Projection`] and returns a record. It has no handle to //! anything mutable. //! - **No stdout.** Records go to stderr as JSON Lines //! ([`sink`]) because peerspeak parses pixelpass's stdout event stream //! (`screenshare/mod.rs:92`); a stray line there corrupts it. //! - **No `--aec` CLI flag.** That surface is phase 7's mode selector. The audit //! takes its AEC identity from `PIXELPASS_AUDIO_AUDIT_AEC` through the //! *same* [`parse_aec_arg`] the real flag will use, so the parser and the //! validator are both exercised without committing to a public interface //! before it is designed. //! //! ## Fan-out gating vs. taint (read before interpreting a record) //! //! Two independent things can exclude a candidate and the record keeps them //! distinguishable: //! //! - The **taint engine** (phase 2) excludes individual nodes with its own //! reason codes โ€” `peerspeak-owned`, `aec-identity`, `tainted-upstream`, โ€ฆ //! - The **AEC validator** (phase 4) can forbid fan-out *entirely*, regardless //! of taint, whenever the configured identity is unvalidated, failed or //! revoked. Silence over echo. //! //! When the gate is shut, a candidate the engine would have called eligible is //! reported excluded with an audit-level reason ([`GateReason`]); a candidate //! the engine excluded on its own keeps *its* reason, because that names the //! mechanism that actually applies to it. `fan_out_permitted` on the record //! carries the gate state, so the two cases are always tellable apart. //! //! **Consequence for the ยง5.1 matrix:** every row whose point is the //! eligible/excluded partition must run with `PIXELPASS_AUDIO_AUDIT_AEC=off` //! (state `NotConfigured`, gate open). Row 12 โ€” the AEC lifecycle row โ€” is the //! one that runs with a real `pulse-module:`, and the gate slamming shut is //! precisely what it asserts. #![allow(dead_code)] // Trigger paths are wired by `sink` + `run`; rows are read by tests. pub mod metrics; pub mod run; pub mod sink; #[cfg(test)] mod tests; use serde::Serialize; use crate::host::aec::{AecConfig, AecState, AecValidator}; use crate::host::observer::{EventKind, Millis, Projection, Readiness}; use crate::host::taint::snapshot::Serial; use crate::host::taint::{Decisions, Eligibility, ExclusionCtx, StickyState, evaluate}; /// How long the AEC validator may sit in `Validating` after the graph first /// reports ready before failing closed. Generous relative to the observer's own /// 2 s readiness budget: in the audit a `Failed` is a diagnostic, and timing out /// early would report an absent module that was merely slow to appear. pub const AEC_VALIDATION_TIMEOUT_MILLIS: Millis = 5_000; /// Everything the auditor needs beyond the live graph. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct AuditConfig { /// The AEC identity to validate, as parsed from /// `PIXELPASS_AUDIO_AUDIT_AEC`. Defaults to [`AecConfig::Off`] โ€” an audit /// run is not a share, so "there is no echo canceller in play" is the /// honest default, and it is what leaves the fan-out gate open for the /// partition rows. pub aec: AecConfig, pub aec_timeout: Millis, } impl Default for AuditConfig { fn default() -> Self { Self { aec: AecConfig::Off, aec_timeout: AEC_VALIDATION_TIMEOUT_MILLIS, } } } /// An audit-level exclusion: the AEC validator has shut the fan-out gate. These /// codes are disjoint from the taint engine's /// [`Reason::code`](crate::host::taint::Reason::code) values, so a reader never /// has to know which layer produced a code to interpret it. // The shared `Aec` prefix is the point: `GateReason::Validating` and // `AecState::Validating` would be one careless glob import away from being // confused, and these three are the *audit's* view of that machine, not the // machine itself. #[allow(clippy::enum_variant_names)] #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum GateReason { /// The configured AEC identity has not been seen yet. Not an error โ€” the /// module may still be loading โ€” but no fan-out happens meanwhile. AecValidating, /// The deadline passed with the identity never observed. AecFailed, /// The whole identity disappeared mid-run: every node bearing the index is /// gone (v3.4 ยง5.3). AecRevoked, } impl GateReason { pub fn code(self) -> &'static str { match self { Self::AecValidating => "aec-validating", Self::AecFailed => "aec-failed", Self::AecRevoked => "aec-revoked", } } /// The gate reason implied by a validator state, or `None` when fan-out is /// permitted. Mirrors [`AecValidator::fan_out_permitted`] โ€” kept as one /// `match` over the same enum so the two cannot drift: every state that /// permits fan-out maps to `None` and every state that forbids it maps to a /// code. pub fn from_state(state: AecState) -> Option { match state { AecState::NotConfigured | AecState::Validated => None, AecState::Validating => Some(Self::AecValidating), AecState::Failed => Some(Self::AecFailed), AecState::Revoked => Some(Self::AecRevoked), } } } /// Stable string for an [`AecState`], for the record's `aec_state` field. /// /// Defined here rather than on [`AecState`] to keep the merged phase-4 module /// untouched by a reporting concern. fn aec_state_code(state: AecState) -> &'static str { match state { AecState::NotConfigured => "not-configured", AecState::Validating => "validating", AecState::Validated => "validated", AecState::Failed => "failed", AecState::Revoked => "revoked", } } /// Stable string for the observer's readiness epoch. fn readiness_code(readiness: Readiness) -> &'static str { match readiness { Readiness::Waiting => "waiting", Readiness::Complete => "complete", Readiness::TimedOut => "timed-out", } } /// One candidate node's effective answer. `reason` is `None` exactly when /// `eligible` is true. #[derive(Clone, Debug, PartialEq, Eq, Serialize)] pub struct AuditRow { pub serial: u64, pub name: Option, pub eligible: bool, #[serde(skip_serializing_if = "Option::is_none")] pub reason: Option<&'static str>, /// The exclusion was carried over from a previous snapshot rather than /// derived from the current topology (phase-2 stickiness). pub sticky: bool, } /// A tainted node of *any* media role, not just fan-out candidates. Candidates /// already appear in [`AuditBody::candidates`]; this is the diagnostic view โ€” /// when a candidate's exclusion is a surprise, the taint that reached it is the /// next question, and it usually sits on a node that is not itself a candidate. #[derive(Clone, Debug, PartialEq, Eq, Serialize)] pub struct TaintRow { pub serial: u64, pub name: Option, pub reason: &'static str, pub sticky: bool, } /// The decision content of one recompute โ€” everything except which recompute it /// was. Split out from [`AuditRecord`] so "did anything actually change?" is a /// derived `==` rather than a hand-maintained field comparison that a later /// field addition could silently fall out of. #[derive(Clone, Debug, PartialEq, Eq, Serialize)] pub struct AuditBody { /// The observer's dynamic readiness. False โ‡’ every candidate is excluded /// `graph-not-ready`; no decision from a partial graph is a decision. pub graph_ready: bool, /// The sticky readiness epoch, which distinguishes the three ways /// `graph_ready` can be false (see [`Projection::readiness`]). pub epoch: &'static str, pub aec_state: &'static str, /// The index handed to the taint engine โ€” `Some` only while `Validated`. #[serde(skip_serializing_if = "Option::is_none")] pub aec_module_id: Option, /// Whether the AEC validator permits fan-out at all right now. pub fan_out_permitted: bool, /// The audit-level reason fan-out is forbidden, when it is. #[serde(skip_serializing_if = "Option::is_none")] pub gate_reason: Option<&'static str>, /// **The complete candidate universe**, ascending by serial โ€” every /// `Stream/Output/Audio` node in the snapshot, partitioned. ยง5.1's exact /// partition is `candidates`, not a subset of it. pub candidates: Vec, pub eligible_count: usize, pub excluded_count: usize, /// Taint across all node roles, ascending by serial. pub taint: Vec, } impl AuditBody { /// Serials of eligible candidates, ascending โ€” the half of the partition an /// exclude-everything build fails. pub fn eligible(&self) -> Vec { self.candidates .iter() .filter(|row| row.eligible) .map(|row| row.serial) .collect() } /// `(serial, reason code)` for excluded candidates, ascending. pub fn excluded(&self) -> Vec<(u64, &'static str)> { self.candidates .iter() .filter(|row| !row.eligible) .map(|row| (row.serial, row.reason.unwrap_or("?"))) .collect() } /// The eligible candidate with this name, if any. Convenience for the /// matrix rows, which name nodes rather than serials. pub fn row_named(&self, name: &str) -> Option<&AuditRow> { self.candidates .iter() .find(|row| row.name.as_deref() == Some(name)) } } /// One recompute, as emitted. #[derive(Clone, Debug, PartialEq, Eq, Serialize)] pub struct AuditRecord { /// Monotonic per-run counter over *every* recompute, emitted or suppressed, /// so a gap in the emitted sequence is visibly a suppression rather than a /// lost line. pub seq: u64, pub trigger: &'static str, /// Observer-clock milliseconds at which this recompute ran. pub at_ms: Millis, #[serde(flatten)] pub body: AuditBody, } /// What one [`Auditor::observe`] produced. #[derive(Clone, Debug, PartialEq, Eq)] pub struct AuditOutcome { pub record: AuditRecord, /// Whether the record should be written. See [`Auditor::observe`]. pub emit: bool, } /// The dry-run auditor: phases 2โ€“4 folded together over a live projection. /// /// Read-only by construction โ€” it borrows a [`Projection`] and owns only the /// state phases 2 and 4 thread explicitly ([`StickyState`], [`AecValidator`]). /// There is no field here through which a link could be created. #[derive(Clone, Debug)] pub struct Auditor { validator: AecValidator, sticky: StickyState, seq: u64, /// The body of the last record actually written, for change suppression. last_emitted: Option, } impl Auditor { pub fn new(config: AuditConfig) -> Self { Self { validator: AecValidator::new(config.aec, config.aec_timeout), sticky: StickyState::default(), seq: 0, last_emitted: None, } } pub fn aec_state(&self) -> AecState { self.validator.state() } pub fn sticky(&self) -> &StickyState { &self.sticky } /// Fold one projection into the audit. /// /// **Called once per applied registry event โ€” never on a coalesced batch.** /// That is not a performance preference, it is the phase-4 integration /// contract (`aec/mod.rs`, the `Validated` arm): revocation is detected by /// observing the *empty gap* between a module unload and the next reload, /// and module indices are reused verbatim (v3.4 ยง5.2 correction 3). Coalesce /// across that gap and a fresh module silently inherits a dead module's /// validated identity. [`sink`] is what upholds this, by running the /// recompute inline on the observer thread rather than polling /// [`RegistryObserverHandle::latest`](crate::host::observer::adapter::RegistryObserverHandle::latest), /// which coalesces by nature. /// /// `emit` is true for every graph-triggered recompute, and for a /// tick-triggered one only when the decision content changed. Ticks arrive /// at a constant 4 Hz purely to drive the AEC deadline; emitting an /// identical record four times a second would bury the graph events the /// audit exists to show. `seq` still advances on suppressed records, so /// nothing about the run is silently unaccounted for. pub fn observe( &mut self, projection: &Projection, kind: EventKind, now: Millis, ) -> AuditOutcome { self.seq += 1; // Phase 4 first: its verdict is an *input* to phase 2 via // `ExclusionCtx::aec_module_id`, so observing the graph in the other // order would evaluate taint against the previous recompute's identity. self.validator .observe(&projection.snapshot, projection.graph_ready, now); let aec_state = self.validator.state(); let gate_reason = GateReason::from_state(aec_state); let ctx = ExclusionCtx { aec_module_id: self.validator.validated_module_id(), pipewire_pulse_pid: projection.pipewire_pulse_pid, // The audit creates nothing, so it owns nothing. Another host's // capture sink is still caught โ€” by the `pixelpass_capture_*` name // prefix (v3.4 ยง6.2), which is what ยง5.1 row 7 exercises โ€” so an // empty set costs the matrix nothing. pixelpass_owned: Default::default(), graph_ready: projection.graph_ready, }; let (decisions, sticky) = evaluate(&projection.snapshot, &ctx, &self.sticky); self.sticky = sticky; let body = build_body( projection, &decisions, aec_state, self.validator.validated_module_id(), gate_reason, ); let emit = kind == EventKind::Graph || self.last_emitted.as_ref() != Some(&body); if emit { self.last_emitted = Some(body.clone()); } AuditOutcome { record: AuditRecord { seq: self.seq, trigger: kind.code(), at_ms: now, body, }, emit, } } } fn build_body( projection: &Projection, decisions: &Decisions, aec_state: AecState, aec_module_id: Option, gate_reason: Option, ) -> AuditBody { let candidates: Vec = decisions .candidates .values() .map(|decision| { // The engine's own reason wins when it has one: it names the // mechanism that actually excluded *this* node, which is what the // ยง5.1 rows assert. The gate reason applies only to candidates the // engine would have passed โ€” otherwise a shut gate would erase every // reason code in the record and the matrix would stop constraining // the engine at all. let (eligible, reason, sticky) = match decision.eligibility { Eligibility::NotEligible { reason, sticky } => (false, Some(reason.code()), sticky), Eligibility::Eligible => match gate_reason { Some(gate) => (false, Some(gate.code()), false), None => (true, None, false), }, }; AuditRow { serial: decision.serial.0, name: decision.name.clone(), eligible, reason, sticky, } }) .collect(); let eligible_count = candidates.iter().filter(|row| row.eligible).count(); let taint: Vec = decisions .taint .iter() .map(|(&serial, entry)| TaintRow { serial: serial.0, name: node_name(projection, serial), reason: entry.reason.code(), sticky: entry.sticky, }) .collect(); AuditBody { graph_ready: projection.graph_ready, epoch: readiness_code(projection.readiness), aec_state: aec_state_code(aec_state), aec_module_id, fan_out_permitted: gate_reason.is_none(), gate_reason: gate_reason.map(GateReason::code), excluded_count: candidates.len() - eligible_count, eligible_count, candidates, taint, } } fn node_name(projection: &Projection, serial: Serial) -> Option { projection .snapshot .node(serial) .and_then(|node| node.name.clone()) }