Runs phases 2-4 against the live PipeWire graph on every registry event and
reports the complete eligible/excluded candidate partition with stable reason
codes. Creates no links, loads no modules, changes no routing.
Impl plan §5. Two entry points behind the hidden PIXELPASS_AUDIO_AUDIT=1
trigger: inside a real `pixelpass host` run (the plan-literal reading, proves
the path phase 6 will mutate), and a hidden `--audit-audio` standalone mode
with no iroh endpoint or capture pipeline, which is what drives the §5.1
matrix.
The recompute runs inline on the observer thread via a new ProjectionSink
hook, once per applied event. Polling `latest()` was rejected: it coalesces,
and phase 4 detects a module unload by observing the empty gap before the next
module appears — with indices reused verbatim (v3.4 §5.2 correction 3), a
missed gap aliases a fresh module onto a dead identity. Running inline is what
makes phase 4's "one observe per graph event" contract true, and it puts the
cost where O5 can measure it.
Split as usual: the auditor and the metrics are pure and unit-tested; the
clock, the writer and the env parsing are the thin edge in `sink`/`run`.
- audit/mod.rs Auditor: AEC validator + taint engine + record building.
The AEC gate and the engine's own reasons stay
distinguishable — a shut gate must not erase the reason codes
the §5.1 rows assert.
- audit/metrics.rs O5: event rate, bucketed recompute distribution + exact
max, busy fraction, and a documented lower-bound queueing
proxy (libpipewire exposes no queue depth).
- audit/sink.rs JSON Lines to stderr, or PIXELPASS_AUDIO_AUDIT_FILE. Never
stdout — peerspeak parses that stream.
- audit/run.rs Env parsing; a malformed AEC value is fatal, matching phase
4's rule that it must not silently become "no AEC".
Observer gains `EventKind` (derived from RegEvent, so a consumer's view of
"was this a real graph change?" cannot disagree with the model's) and
`Projection::readiness`, which distinguishes the three ways graph_ready can be
false. taint::fixture is now pub(crate) so audit tests share one graph
vocabulary with the taint tests.
33 new tests, 178 green, clippy -D warnings and fmt clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
456 lines
18 KiB
Rust
456 lines
18 KiB
Rust
//! 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:<idx>`, 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<Self> {
|
||
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<String>,
|
||
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<String>,
|
||
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<u64>,
|
||
/// 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<AuditRow>,
|
||
pub eligible_count: usize,
|
||
pub excluded_count: usize,
|
||
/// Taint across all node roles, ascending by serial.
|
||
pub taint: Vec<TaintRow>,
|
||
}
|
||
|
||
impl AuditBody {
|
||
/// Serials of eligible candidates, ascending — the half of the partition an
|
||
/// exclude-everything build fails.
|
||
pub fn eligible(&self) -> Vec<u64> {
|
||
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<AuditBody>,
|
||
}
|
||
|
||
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<u64>,
|
||
gate_reason: Option<GateReason>,
|
||
) -> AuditBody {
|
||
let candidates: Vec<AuditRow> = 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<TaintRow> = 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<String> {
|
||
projection
|
||
.snapshot
|
||
.node(serial)
|
||
.and_then(|node| node.name.clone())
|
||
}
|