feat(adr-118/p4.1): BfldEvent privacy-gated output + JSON (102/102 GREEN)
Iter 13. Lands ADR-121 §2.1 (output event) + ADR-122 §2.1 (field-gating
policy). BfldEvent collapses the GateAction-driven sensing pipeline
into the canonical wire-format publishable on MQTT.
Added:
- serde (workspace, derive feature, optional) + serde_json (workspace, optional) deps
- New crate feature `serde-json` (default-on; requires `std`)
- src/event.rs (gated on `feature = "std"`):
* BfldEvent struct with all sensing + identity-derived fields
* with_privacy_gating(...) constructor that applies field-gating policy:
class < Restricted (3): identity_risk_score + rf_signature_hash kept
class >= Restricted (3): both nulled to None
* apply_privacy_gating() — idempotent in-place masking
* to_json() -> Result<String, serde_json::Error> (gated on serde-json)
* Custom ser_privacy_class serializer emits lowercase names
("anonymous", "restricted", etc.) per the BFLD JSON spec
* skip_serializing_if = "Option::is_none" on identity-derived fields so
privacy-gated events are observationally indistinguishable from
events that never had the field set
- pub use BfldEvent from lib.rs
tests/event_privacy_gating.rs (9 named tests, all green):
anonymous_event_retains_identity_risk_and_hash
restricted_event_strips_identity_fields (class 3 → None)
apply_privacy_gating_is_idempotent
event_type_is_always_bfld_update (parameterized over 3 classes)
json::json_round_trip_emits_type_field_first_or_last_but_present
json::anonymous_json_includes_identity_fields
json::restricted_json_omits_identity_fields_entirely
(asserts the JSON string does NOT contain identity_risk_score or
rf_signature_hash, verifying skip_serializing_if works as intended)
json::privacy_class_serializes_to_lowercase_name
json::zone_id_none_is_omitted_from_json
ACs progressed:
- ADR-121 AC6 (identity_risk score absent at class 3) — structurally
enforced by with_privacy_gating + skip_serializing_if combination.
- ADR-122 AC1 — JSON shape matches the HA-DISCO publishable event
contract; identity fields can be reliably stripped by privacy_class.
- ADR-118 AC5 — privacy_mode = engaged maps to PrivacyClass::Restricted
with no identity fields in the published event.
Test config:
- cargo test --no-default-features → 64 passed (unchanged; event cfg-out)
- cargo test → 102 passed (93 + 9)
Out of scope (next iter target):
- Emitter struct that wires GateAction + privacy class + sensing inputs
into BfldEvent construction (ADR-118 §2.1 pipeline diagram).
- MQTT topic publisher (ADR-122 §2.2) — depends on a runtime (tokio).
Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
parent
ae6fd75095
commit
926c66f677
|
|
@ -9160,6 +9160,8 @@ version = "0.3.0"
|
|||
dependencies = [
|
||||
"crc",
|
||||
"proptest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"static_assertions",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
|
||||
/// 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<f32>,
|
||||
|
||||
/// 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<String>,
|
||||
privacy_class: PrivacyClass,
|
||||
identity_risk_score: Option<f32>,
|
||||
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<String, serde_json::Error> {
|
||||
serde_json::to_string(self)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "serde-json")]
|
||||
fn ser_privacy_class<S: serde::Serializer>(
|
||||
class: &PrivacyClass,
|
||||
s: S,
|
||||
) -> Result<S::Ok, S::Error> {
|
||||
let name = match class {
|
||||
PrivacyClass::Raw => "raw",
|
||||
PrivacyClass::Derived => "derived",
|
||||
PrivacyClass::Anonymous => "anonymous",
|
||||
PrivacyClass::Restricted => "restricted",
|
||||
};
|
||||
s.serialize_str(name)
|
||||
}
|
||||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue