diff --git a/v2/Cargo.lock b/v2/Cargo.lock index a51d4a63..5bec7ef4 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -9160,6 +9160,8 @@ version = "0.3.0" dependencies = [ "crc", "proptest", + "serde", + "serde_json", "static_assertions", "thiserror 2.0.18", ] diff --git a/v2/crates/wifi-densepose-bfld/Cargo.toml b/v2/crates/wifi-densepose-bfld/Cargo.toml index e187a6ff..725aefbc 100644 --- a/v2/crates/wifi-densepose-bfld/Cargo.toml +++ b/v2/crates/wifi-densepose-bfld/Cargo.toml @@ -11,8 +11,11 @@ keywords.workspace = true categories.workspace = true [features] -default = ["std"] +default = ["std", "serde-json"] std = [] +# JSON serialization for BfldEvent (ADR-121 §2.1, ADR-122 §2.1). Pulls in +# serde + serde_json; tied to `std` because serde_json is std-only. +serde-json = ["std", "dep:serde", "dep:serde_json"] # Soul Signature integration (ADR-118 §1.4, ADR-120 §2.7, ADR-121 §2.6) — # enables privacy_class = 1 (derived) mode and the SoulMatchOracle gate # exemption. Disabled by default per the structural class-2 default. @@ -22,6 +25,8 @@ soul-signature = [] thiserror.workspace = true static_assertions = "1.1" crc = "3" +serde = { workspace = true, features = ["derive"], optional = true } +serde_json = { workspace = true, optional = true } [dev-dependencies] proptest.workspace = true diff --git a/v2/crates/wifi-densepose-bfld/src/event.rs b/v2/crates/wifi-densepose-bfld/src/event.rs new file mode 100644 index 00000000..a3de0fc8 --- /dev/null +++ b/v2/crates/wifi-densepose-bfld/src/event.rs @@ -0,0 +1,136 @@ +//! `BfldEvent` — privacy-gated output event. ADR-121 §2.1, ADR-122 §2.1. +//! +//! Field exposure per privacy_class (ADR-122 §2.1): +//! +//! | Field | Raw(0) | Derived(1) | Anonymous(2) | Restricted(3) | +//! |------------------------|--------|------------|--------------|---------------| +//! | presence | y | y | y | y | +//! | motion | y | y | y | y | +//! | person_count | y | y | y | y | +//! | confidence | y | y | y | y | +//! | zone_id | y | y | y | y | +//! | identity_risk_score | y | y | **y** | **n** | +//! | rf_signature_hash | y | y | **y** | **n** | +//! +//! Construction defers to [`BfldEvent::with_privacy_gating`] which applies +//! the policy by stripping disallowed fields to `None` based on the supplied +//! `privacy_class`. Direct field access remains possible (for unit tests), +//! but the JSON serializer always honors the gating because the dropped +//! fields are `None` and the `Serialize` derive uses `skip_serializing_if`. + +#![cfg(feature = "std")] + +use crate::PrivacyClass; + +#[cfg(feature = "serde-json")] +use serde::Serialize; + +/// Privacy-gated output event published by the BFLD pipeline. +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde-json", derive(Serialize))] +pub struct BfldEvent { + /// Always `"bfld_update"`. Tags the event type for downstream routers. + #[cfg_attr(feature = "serde-json", serde(rename = "type"))] + pub event_type: &'static str, + + /// Originating BFLD node identifier. + pub node_id: String, + + /// Monotonic capture-clock timestamp in nanoseconds. + pub timestamp_ns: u64, + + /// Whether an occupant is present in the sensing zone. + pub presence: bool, + + /// Normalized motion magnitude in `[0.0, 1.0]`. + pub motion: f32, + + /// Estimated number of occupants. + pub person_count: u8, + + /// Sensing confidence in `[0.0, 1.0]`. + pub confidence: f32, + + /// Optional zone identifier; absent if the deployment is single-zone. + #[cfg_attr(feature = "serde-json", serde(skip_serializing_if = "Option::is_none"))] + pub zone_id: Option, + + /// Privacy classification byte for this event. + #[cfg_attr(feature = "serde-json", serde(serialize_with = "ser_privacy_class"))] + pub privacy_class: PrivacyClass, + + /// Identity-risk score, `[0.0, 1.0]`. Class 2 only; `None` at class 3. + #[cfg_attr(feature = "serde-json", serde(skip_serializing_if = "Option::is_none"))] + pub identity_risk_score: Option, + + /// 256-bit BLAKE3 keyed hash of the current cluster. Class 2 only; `None` at class 3. + #[cfg_attr(feature = "serde-json", serde(skip_serializing_if = "Option::is_none"))] + pub rf_signature_hash: Option<[u8; 32]>, +} + +impl BfldEvent { + /// Build an event from sensing fields, applying the privacy_class policy + /// to mask identity-derived fields. `identity_risk_score` and + /// `rf_signature_hash` are nulled out at class `Restricted`. + #[must_use] + pub fn with_privacy_gating( + node_id: String, + timestamp_ns: u64, + presence: bool, + motion: f32, + person_count: u8, + confidence: f32, + zone_id: Option, + privacy_class: PrivacyClass, + identity_risk_score: Option, + rf_signature_hash: Option<[u8; 32]>, + ) -> Self { + let mut e = Self { + event_type: "bfld_update", + node_id, + timestamp_ns, + presence, + motion, + person_count, + confidence, + zone_id, + privacy_class, + identity_risk_score, + rf_signature_hash, + }; + e.apply_privacy_gating(); + e + } + + /// Idempotently mask fields disallowed at the current `privacy_class`. + /// Called by [`Self::with_privacy_gating`]; exposed for callers that + /// mutate the event in place before publication. + pub fn apply_privacy_gating(&mut self) { + if self.privacy_class.as_u8() >= PrivacyClass::Restricted.as_u8() { + self.identity_risk_score = None; + self.rf_signature_hash = None; + } + } + + /// Serialize to canonical JSON. Fields masked by privacy gating are omitted + /// entirely (not emitted as `null`), so a privacy-gated event is + /// observationally indistinguishable from one that never had the field set. + #[cfg(feature = "serde-json")] + pub fn to_json(&self) -> Result { + serde_json::to_string(self) + } +} + +#[cfg(feature = "serde-json")] +fn ser_privacy_class( + class: &PrivacyClass, + s: S, +) -> Result { + let name = match class { + PrivacyClass::Raw => "raw", + PrivacyClass::Derived => "derived", + PrivacyClass::Anonymous => "anonymous", + PrivacyClass::Restricted => "restricted", + }; + s.serialize_str(name) +} diff --git a/v2/crates/wifi-densepose-bfld/src/lib.rs b/v2/crates/wifi-densepose-bfld/src/lib.rs index 933aca6f..24c9a482 100644 --- a/v2/crates/wifi-densepose-bfld/src/lib.rs +++ b/v2/crates/wifi-densepose-bfld/src/lib.rs @@ -16,6 +16,8 @@ pub mod coherence_gate; pub mod embedding; pub mod embedding_ring; +#[cfg(feature = "std")] +pub mod event; pub mod frame; pub mod identity_risk; #[cfg(feature = "std")] @@ -25,6 +27,8 @@ pub mod privacy_gate; pub mod sink; pub use coherence_gate::{CoherenceGate, MatchOutcome, NullOracle, SoulMatchOracle}; +#[cfg(feature = "std")] +pub use event::BfldEvent; pub use embedding::{IdentityEmbedding, EMBEDDING_DIM}; pub use embedding_ring::{EmbeddingRing, RING_CAPACITY}; pub use identity_risk::{score as identity_risk_score, GateAction}; diff --git a/v2/crates/wifi-densepose-bfld/tests/event_privacy_gating.rs b/v2/crates/wifi-densepose-bfld/tests/event_privacy_gating.rs new file mode 100644 index 00000000..6460fbb7 --- /dev/null +++ b/v2/crates/wifi-densepose-bfld/tests/event_privacy_gating.rs @@ -0,0 +1,116 @@ +//! Acceptance tests for ADR-121 §2.1 / ADR-122 §2.1 — `BfldEvent` privacy gating. + +#![cfg(feature = "std")] + +use wifi_densepose_bfld::{BfldEvent, PrivacyClass}; + +fn sample_at(class: PrivacyClass) -> BfldEvent { + BfldEvent::with_privacy_gating( + "seed-01".to_string(), + 1_700_000_000_000_000_000, + true, + 0.72, + 1, + 0.91, + Some("living_room".to_string()), + class, + Some(0.84), + Some([0xAB; 32]), + ) +} + +#[test] +fn anonymous_event_retains_identity_risk_and_hash() { + let e = sample_at(PrivacyClass::Anonymous); + assert!(e.identity_risk_score.is_some()); + assert!(e.rf_signature_hash.is_some()); +} + +#[test] +fn restricted_event_strips_identity_fields() { + let e = sample_at(PrivacyClass::Restricted); + assert!(e.identity_risk_score.is_none(), "risk score must be None at class 3"); + assert!(e.rf_signature_hash.is_none(), "rf hash must be None at class 3"); + // Sensing fields still present. + assert!(e.presence); + assert_eq!(e.person_count, 1); + assert_eq!(e.zone_id.as_deref(), Some("living_room")); +} + +#[test] +fn apply_privacy_gating_is_idempotent() { + let mut e = sample_at(PrivacyClass::Restricted); + e.apply_privacy_gating(); + e.apply_privacy_gating(); + assert!(e.identity_risk_score.is_none()); +} + +#[test] +fn event_type_is_always_bfld_update() { + for c in [ + PrivacyClass::Anonymous, + PrivacyClass::Restricted, + PrivacyClass::Derived, + ] { + assert_eq!(sample_at(c).event_type, "bfld_update"); + } +} + +#[cfg(feature = "serde-json")] +mod json { + use super::sample_at; + use wifi_densepose_bfld::PrivacyClass; + + #[test] + fn json_round_trip_emits_type_field_first_or_last_but_present() { + let json = sample_at(PrivacyClass::Anonymous).to_json().unwrap(); + assert!(json.contains(r#""type":"bfld_update""#), "JSON: {json}"); + assert!(json.contains(r#""node_id":"seed-01""#)); + assert!(json.contains(r#""presence":true"#)); + assert!(json.contains(r#""privacy_class":"anonymous""#)); + } + + #[test] + fn anonymous_json_includes_identity_fields() { + let json = sample_at(PrivacyClass::Anonymous).to_json().unwrap(); + assert!(json.contains("identity_risk_score")); + assert!(json.contains("rf_signature_hash")); + } + + #[test] + fn restricted_json_omits_identity_fields_entirely() { + let json = sample_at(PrivacyClass::Restricted).to_json().unwrap(); + assert!( + !json.contains("identity_risk_score"), + "JSON must omit identity_risk_score at class 3, got: {json}", + ); + assert!( + !json.contains("rf_signature_hash"), + "JSON must omit rf_signature_hash at class 3, got: {json}", + ); + // Sensing fields still emitted. + assert!(json.contains("presence")); + assert!(json.contains("motion")); + assert!(json.contains(r#""privacy_class":"restricted""#)); + } + + #[test] + fn privacy_class_serializes_to_lowercase_name() { + for (class, name) in [ + (PrivacyClass::Anonymous, "anonymous"), + (PrivacyClass::Restricted, "restricted"), + ] { + let json = sample_at(class).to_json().unwrap(); + let needle = format!(r#""privacy_class":"{name}""#); + assert!(json.contains(&needle), "missing {needle} in: {json}"); + } + } + + #[test] + fn zone_id_none_is_omitted_from_json() { + let mut e = sample_at(PrivacyClass::Anonymous); + e.zone_id = None; + let json = e.to_json().unwrap(); + assert!(!json.contains("zone_id"), "None zone_id must be omitted: {json}"); + } +}