feat(adr-118/p4.5): BfldPipeline facade + BfldConfig (146/146 GREEN)
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<BfldEvent>
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<BfldFrame>
for callers that need wire-format bytes rather than JSON events.
- BfldPipelineHandle wrapping the pipeline in Arc<Mutex<...>> + a
tokio task that pumps an MQTT loop (ADR-122 §2.2 emitter half).
Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
parent
ea98ceb335
commit
ac461f94fc
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
/// 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<SignatureHasher>,
|
||||
}
|
||||
|
||||
impl BfldConfig {
|
||||
/// Build a minimal config: node_id only, class defaulted to Anonymous.
|
||||
#[must_use]
|
||||
pub fn new(node_id: impl Into<String>) -> 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<String>) -> 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<IdentityEmbedding>,
|
||||
) -> Option<BfldEvent> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
Loading…
Reference in New Issue