From ac461f94fc59b0446309b44b8a2916d835786c12 Mon Sep 17 00:00:00 2001 From: ruv Date: Sun, 24 May 2026 16:28:42 -0400 Subject: [PATCH] feat(adr-118/p4.5): BfldPipeline facade + BfldConfig (146/146 GREEN) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Iter 19. Public lib.rs entry point per ADR-118 §2.1. Thin facade over BfldEmitter that adds a config-driven builder and a privacy_mode toggle for emergency demote-to-Restricted without rebuilding the gate/ring/hasher state. Added (gated on `feature = "std"`): - src/pipeline.rs: * BfldConfig { node_id, default_zone_id, privacy_class, signature_hasher } with new/with_zone/with_privacy_class/with_signature_hasher builder * BfldPipeline { baseline_class, privacy_mode, emitter } * BfldPipeline::new(config) — initializes the underlying emitter * process(inputs, embedding) -> Option Delegates to emitter.emit() then post-processes: if privacy_mode is engaged, demotes the resulting event to Restricted and calls apply_privacy_gating to strip identity fields * enable_privacy_mode() / disable_privacy_mode() / is_privacy_mode_enabled() * current_privacy_class() — returns Restricted when privacy_mode else baseline * current_gate_action() — delegate diagnostic - pub use BfldConfig, BfldPipeline from lib.rs Design note: the privacy_mode override is applied post-emission, NOT by rebuilding the emitter. This preserves gate state (current action, pending transitions), ring contents, and hasher salt across the toggle — critical for incident response where the operator needs to keep detecting anomalies while temporarily redacting the public surface. tests/pipeline_facade.rs (9 named tests, all green): config_defaults_to_anonymous_no_zone_no_hasher config_builder_methods_chain fresh_pipeline_is_not_in_privacy_mode pipeline_process_returns_anonymous_event_under_low_risk enable_privacy_mode_demotes_published_events_to_restricted (verifies BOTH identity_risk_score AND rf_signature_hash become None) disable_privacy_mode_restores_baseline_class (round-trip: enable → demoted → disable → restored to Anonymous) privacy_mode_overrides_derived_baseline_too (research-mode operator can still flip the emergency switch) pipeline_with_hasher_emits_derived_rf_signature_hash zone_is_threaded_from_config_to_event ACs progressed: - ADR-118 §2.1 — public entry point now matches the implementation plan §1.2 sketch: BfldPipeline::new(config) → process() → BfldEvent. Future iters add process_to_frame() and the tokio MQTT loop. - ADR-118 §1.5 enable_privacy_mode requirement — operator can engage Restricted-class redaction without restarting the pipeline or losing in-flight detection state. First runtime witness of this. Test config: - cargo test --no-default-features → 72 passed (pipeline cfg-out) - cargo test → 146 passed (137 + 9) Out of scope (next iter target): - process_to_frame(inputs, payload, embedding) -> Option for callers that need wire-format bytes rather than JSON events. - BfldPipelineHandle wrapping the pipeline in Arc> + a tokio task that pumps an MQTT loop (ADR-122 §2.2 emitter half). Co-Authored-By: claude-flow --- v2/crates/wifi-densepose-bfld/src/lib.rs | 4 + v2/crates/wifi-densepose-bfld/src/pipeline.rs | 156 ++++++++++++++++++ .../tests/pipeline_facade.rs | 127 ++++++++++++++ 3 files changed, 287 insertions(+) create mode 100644 v2/crates/wifi-densepose-bfld/src/pipeline.rs create mode 100644 v2/crates/wifi-densepose-bfld/tests/pipeline_facade.rs diff --git a/v2/crates/wifi-densepose-bfld/src/lib.rs b/v2/crates/wifi-densepose-bfld/src/lib.rs index b17e3ddc..56d8eaf3 100644 --- a/v2/crates/wifi-densepose-bfld/src/lib.rs +++ b/v2/crates/wifi-densepose-bfld/src/lib.rs @@ -27,6 +27,8 @@ pub mod identity_risk; #[cfg(feature = "std")] pub mod payload; #[cfg(feature = "std")] +pub mod pipeline; +#[cfg(feature = "std")] pub mod privacy_gate; pub mod signature_hasher; pub mod sink; @@ -47,6 +49,8 @@ pub use frame::BfldFrame; #[cfg(feature = "std")] pub use payload::BfldPayload; #[cfg(feature = "std")] +pub use pipeline::{BfldConfig, BfldPipeline}; +#[cfg(feature = "std")] pub use privacy_gate::PrivacyGate; pub use signature_hasher::{SignatureHasher, RF_SIGNATURE_LEN, SITE_SALT_LEN}; pub use sink::{check_class, LocalSink, MatterSink, NetworkSink, Sink}; diff --git a/v2/crates/wifi-densepose-bfld/src/pipeline.rs b/v2/crates/wifi-densepose-bfld/src/pipeline.rs new file mode 100644 index 00000000..aa7593c4 --- /dev/null +++ b/v2/crates/wifi-densepose-bfld/src/pipeline.rs @@ -0,0 +1,156 @@ +//! `BfldPipeline` — public entry point. ADR-118 §2.1. +//! +//! Thin facade over [`crate::BfldEmitter`] that adds: +//! +//! - A configuration struct ([`BfldConfig`]) for ergonomic construction. +//! - A `privacy_mode` toggle that flips the active class to +//! [`PrivacyClass::Restricted`] (and back to the configured baseline) +//! without rebuilding the underlying emitter state. +//! - A single named consumer call ([`Self::process`]) so callers don't have +//! to navigate the lower-level emitter API. +//! +//! Future iters add `process_to_frame()` (BfldFrame production) and a `tokio` +//! MQTT loop wrapper on top of this same facade. + +#![cfg(feature = "std")] + +use crate::emitter::{BfldEmitter, SensingInputs}; +use crate::identity_risk::GateAction; +use crate::signature_hasher::SignatureHasher; +use crate::{BfldEvent, IdentityEmbedding, PrivacyClass}; + +/// Construction parameters for [`BfldPipeline`]. Matches the ADR-118 default- +/// secure posture: `class = Anonymous`, no zone, no signature hasher. +#[derive(Debug, Clone)] +pub struct BfldConfig { + /// Node identifier published in every `BfldEvent.node_id`. + pub node_id: String, + /// Optional default zone; passed through to every event. + pub default_zone_id: Option, + /// Baseline privacy class. `privacy_mode = true` overrides to Restricted. + pub privacy_class: PrivacyClass, + /// Optional signature hasher; when present, the pipeline derives + /// `rf_signature_hash` via [`crate::IdentityFeatures`]. + pub signature_hasher: Option, +} + +impl BfldConfig { + /// Build a minimal config: node_id only, class defaulted to Anonymous. + #[must_use] + pub fn new(node_id: impl Into) -> Self { + Self { + node_id: node_id.into(), + default_zone_id: None, + privacy_class: PrivacyClass::Anonymous, + signature_hasher: None, + } + } + + /// Set the default zone. + #[must_use] + pub fn with_zone(mut self, zone_id: impl Into) -> Self { + self.default_zone_id = Some(zone_id.into()); + self + } + + /// Override the baseline privacy class. + #[must_use] + pub const fn with_privacy_class(mut self, class: PrivacyClass) -> Self { + self.privacy_class = class; + self + } + + /// Install a signature hasher. + #[must_use] + pub fn with_signature_hasher(mut self, hasher: SignatureHasher) -> Self { + self.signature_hasher = Some(hasher); + self + } +} + +/// Public BFLD entry point. Owns the configured emitter and the +/// `privacy_mode` toggle. +pub struct BfldPipeline { + /// Baseline class — the class to which `disable_privacy_mode()` returns. + baseline_class: PrivacyClass, + privacy_mode: bool, + emitter: BfldEmitter, +} + +impl BfldPipeline { + /// Build a pipeline from `config`. The underlying emitter is initialized + /// with the configured class; `privacy_mode` is initially `false`. + #[must_use] + pub fn new(config: BfldConfig) -> Self { + let mut emitter = BfldEmitter::new(config.node_id); + if let Some(zone) = config.default_zone_id { + emitter = emitter.with_zone(zone); + } + emitter = emitter.with_privacy_class(config.privacy_class); + if let Some(hasher) = config.signature_hasher { + emitter = emitter.with_signature_hasher(hasher); + } + Self { + baseline_class: config.privacy_class, + privacy_mode: false, + emitter, + } + } + + /// Process a single sensing frame. Delegates to the underlying emitter, + /// then post-processes the resulting event to honor `privacy_mode`. When + /// privacy mode is engaged the published event is demoted to Restricted + /// (identity-derived fields stripped) regardless of the configured baseline. + pub fn process( + &mut self, + inputs: SensingInputs, + embedding: Option, + ) -> Option { + let mut event = self.emitter.emit(inputs, embedding)?; + if self.privacy_mode { + event.privacy_class = PrivacyClass::Restricted; + event.apply_privacy_gating(); + } + Some(event) + } + + /// `true` if `enable_privacy_mode()` has been called more recently than + /// `disable_privacy_mode()`. + #[must_use] + pub const fn is_privacy_mode_enabled(&self) -> bool { + self.privacy_mode + } + + /// Read the currently active class. Returns Restricted if privacy mode is + /// engaged, otherwise the baseline. + #[must_use] + pub const fn current_privacy_class(&self) -> PrivacyClass { + if self.privacy_mode { + PrivacyClass::Restricted + } else { + self.baseline_class + } + } + + /// Read-only access to the current gate action — for diagnostics. + #[must_use] + pub const fn current_gate_action(&self) -> GateAction { + self.emitter.current_action() + } + + /// Engage privacy mode: future `process()` calls return events demoted + /// to Restricted (identity_risk_score + rf_signature_hash stripped) + /// regardless of the configured baseline. + /// + /// The override is applied post-emission so the underlying gate / ring / + /// hasher state remains unchanged and recoverable when privacy mode is + /// later disabled. + pub fn enable_privacy_mode(&mut self) { + self.privacy_mode = true; + } + + /// Disengage privacy mode: future events return to the configured baseline. + pub fn disable_privacy_mode(&mut self) { + self.privacy_mode = false; + } +} diff --git a/v2/crates/wifi-densepose-bfld/tests/pipeline_facade.rs b/v2/crates/wifi-densepose-bfld/tests/pipeline_facade.rs new file mode 100644 index 00000000..0a477765 --- /dev/null +++ b/v2/crates/wifi-densepose-bfld/tests/pipeline_facade.rs @@ -0,0 +1,127 @@ +//! Acceptance tests for the `BfldPipeline` facade. ADR-118 §2.1. + +#![cfg(feature = "std")] + +use wifi_densepose_bfld::{ + BfldConfig, BfldPipeline, IdentityEmbedding, PrivacyClass, SensingInputs, SignatureHasher, + EMBEDDING_DIM, SITE_SALT_LEN, +}; + +fn inputs() -> SensingInputs { + SensingInputs { + timestamp_ns: 1_700_000_000_000_000_000, + presence: true, + motion: 0.4, + person_count: 1, + sensing_confidence: 0.9, + sep: 0.2, + stab: 0.2, + consist: 0.2, + risk_conf: 0.2, + rf_signature_hash: None, + } +} + +fn embedding() -> IdentityEmbedding { + IdentityEmbedding::from_raw([0.05; EMBEDDING_DIM]) +} + +// --- BfldConfig builder -------------------------------------------------- + +#[test] +fn config_defaults_to_anonymous_no_zone_no_hasher() { + let c = BfldConfig::new("seed-01"); + assert_eq!(c.node_id, "seed-01"); + assert_eq!(c.privacy_class, PrivacyClass::Anonymous); + assert!(c.default_zone_id.is_none()); + assert!(c.signature_hasher.is_none()); +} + +#[test] +fn config_builder_methods_chain() { + let hasher = SignatureHasher::new([0u8; SITE_SALT_LEN]); + let c = BfldConfig::new("seed-01") + .with_zone("kitchen") + .with_privacy_class(PrivacyClass::Derived) + .with_signature_hasher(hasher); + assert_eq!(c.default_zone_id.as_deref(), Some("kitchen")); + assert_eq!(c.privacy_class, PrivacyClass::Derived); + assert!(c.signature_hasher.is_some()); +} + +// --- BfldPipeline core --------------------------------------------------- + +#[test] +fn fresh_pipeline_is_not_in_privacy_mode() { + let p = BfldPipeline::new(BfldConfig::new("seed-01")); + assert!(!p.is_privacy_mode_enabled()); + assert_eq!(p.current_privacy_class(), PrivacyClass::Anonymous); +} + +#[test] +fn pipeline_process_returns_anonymous_event_under_low_risk() { + let mut p = BfldPipeline::new(BfldConfig::new("seed-01")); + let evt = p.process(inputs(), Some(embedding())).expect("low risk"); + assert_eq!(evt.privacy_class, PrivacyClass::Anonymous); + assert!(evt.identity_risk_score.is_some()); +} + +// --- privacy_mode toggle ------------------------------------------------- + +#[test] +fn enable_privacy_mode_demotes_published_events_to_restricted() { + let mut p = BfldPipeline::new(BfldConfig::new("seed-01")); + p.enable_privacy_mode(); + assert!(p.is_privacy_mode_enabled()); + assert_eq!(p.current_privacy_class(), PrivacyClass::Restricted); + let evt = p.process(inputs(), Some(embedding())).expect("low risk"); + assert_eq!(evt.privacy_class, PrivacyClass::Restricted); + assert!(evt.identity_risk_score.is_none(), "score must be stripped"); + assert!(evt.rf_signature_hash.is_none(), "hash must be stripped"); +} + +#[test] +fn disable_privacy_mode_restores_baseline_class() { + let mut p = BfldPipeline::new(BfldConfig::new("seed-01")); + p.enable_privacy_mode(); + let demoted = p.process(inputs(), Some(embedding())).unwrap(); + assert_eq!(demoted.privacy_class, PrivacyClass::Restricted); + + p.disable_privacy_mode(); + assert!(!p.is_privacy_mode_enabled()); + assert_eq!(p.current_privacy_class(), PrivacyClass::Anonymous); + let restored = p.process(inputs(), Some(embedding())).unwrap(); + assert_eq!(restored.privacy_class, PrivacyClass::Anonymous); + assert!(restored.identity_risk_score.is_some()); +} + +#[test] +fn privacy_mode_overrides_derived_baseline_too() { + // Operator running at Derived (class 1, research mode) can still flip the + // emergency switch to Restricted without restarting the pipeline. + let mut p = BfldPipeline::new( + BfldConfig::new("seed-01").with_privacy_class(PrivacyClass::Derived), + ); + p.enable_privacy_mode(); + let evt = p.process(inputs(), Some(embedding())).unwrap(); + assert_eq!(evt.privacy_class, PrivacyClass::Restricted); + assert!(evt.identity_risk_score.is_none()); +} + +// --- hasher wiring through the facade ----------------------------------- + +#[test] +fn pipeline_with_hasher_emits_derived_rf_signature_hash() { + let hasher = SignatureHasher::new([7u8; SITE_SALT_LEN]); + let mut p = BfldPipeline::new(BfldConfig::new("seed-01").with_signature_hasher(hasher)); + let evt = p.process(inputs(), Some(embedding())).unwrap(); + let hash = evt.rf_signature_hash.expect("hasher path must produce a hash"); + assert_ne!(hash, [0u8; 32], "derived hash must be non-trivial"); +} + +#[test] +fn zone_is_threaded_from_config_to_event() { + let mut p = BfldPipeline::new(BfldConfig::new("seed-01").with_zone("kitchen")); + let evt = p.process(inputs(), Some(embedding())).unwrap(); + assert_eq!(evt.zone_id.as_deref(), Some("kitchen")); +}