Files
ruvnet--RuView/v2/crates/wifi-densepose-bfld/src/event.rs
T
ruv 29f23cb97e feat(adr-118/p4.4): rf_signature_hash JSON as "blake3:<hex>" (128/128 GREEN)
Iter 17. Lands the BFLD JSON wire spec format for rf_signature_hash —
a "blake3:" prefix followed by 64 lowercase hex chars. Replaces the
default serde array-of-integers encoding which was unusable for
downstream consumers (HA, Matter, MQTT).

Added (in src/event.rs):
- ser_rf_signature_hash<S>(hash: &Option<[u8;32]>, s) custom serializer
- Field attribute on BfldEvent.rf_signature_hash now uses
  serialize_with = "ser_rf_signature_hash" alongside skip_serializing_if
- nibble_to_hex(u8) -> char private const fn (no `hex` crate dep needed
  for 32 bytes; lowercase hex is trivial)
- Output format: "blake3:deadbeef..." exactly 71 ASCII chars

tests/json_hash_format.rs (5 named tests, all green):
  rf_signature_hash_serializes_as_blake3_prefixed_lowercase_hex
    (expected hex built programmatically via format!("{b:02x}"))
  hex_string_is_always_64_chars_when_present
    (parses the JSON, isolates the hash substring, asserts exact 64
     chars and lowercase-only — catches case-folding regressions)
  hash_field_omitted_entirely_when_none
  end_to_end_emitter_hasher_to_json_emits_blake3_hex_hash
    *** Cross-iter integration test: BfldEmitter::with_signature_hasher
        → SensingInputs.rf_signature_hash = None → emit derives via
        BLAKE3 → BfldEvent::to_json → contains "blake3:" prefix.
        Spans iters 13, 14, 15, 16, 17 in a single assertion. ***
  end_to_end_restricted_class_omits_hash_even_with_hasher_set
    (class 3: even with hasher installed, JSON omits the hash)

ACs progressed:
- BFLD wire spec §6 — rf_signature_hash JSON shape now matches the
  documented format ("blake3:..."); HA / Matter consumers can parse
  it without custom byte-array decoding.
- ADR-118 §1 invariant I3 — visibility: the JSON wire form now
  cryptographically tags the hash with its algorithm prefix, so
  consumers can verify they're not parsing a different (weaker)
  hash that a future PR might accidentally substitute.

Test config:
- cargo test --no-default-features → 72 passed (json_hash_format cfg-out)
- cargo test                       → 128 passed (123 + 5)

Out of scope (next iter target):
- IdentityFeatures typed encoder so callers feeding BfldEmitter don't
  need to know that embedding bytes serve as hasher input.
- Replace the manual hex push with `hex::encode` if/when the workspace
  takes on the `hex` crate dep for other reasons; current path saves
  the dep without sacrificing correctness.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-24 16:08:29 -04:00

171 lines
6.4 KiB
Rust

//! `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.
/// Serializes as the JSON string `"blake3:<64-hex>"` per the BFLD wire spec.
#[cfg_attr(
feature = "serde-json",
serde(skip_serializing_if = "Option::is_none", serialize_with = "ser_rf_signature_hash")
)]
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)
}
/// Encode an `Option<[u8; 32]>` as the JSON string `"blake3:<64 lowercase hex chars>"`.
/// Used for `rf_signature_hash` so consumers don't have to decode a 32-element JSON
/// array of integers. Called only when the value is `Some(_)` because
/// `skip_serializing_if = "Option::is_none"` short-circuits the `None` case.
#[cfg(feature = "serde-json")]
fn ser_rf_signature_hash<S: serde::Serializer>(
hash: &Option<[u8; 32]>,
s: S,
) -> Result<S::Ok, S::Error> {
// The unwrap is safe: skip_serializing_if guarantees we only run with Some.
let bytes = hash.as_ref().expect("ser_rf_signature_hash called with None");
let mut out = String::with_capacity(7 + 64); // "blake3:" + 32*2 hex chars
out.push_str("blake3:");
for b in bytes {
// Manual lowercase-hex push — avoids pulling in the `hex` crate for 32 bytes.
out.push(nibble_to_hex(b >> 4));
out.push(nibble_to_hex(b & 0x0F));
}
s.serialize_str(&out)
}
#[cfg(feature = "serde-json")]
const fn nibble_to_hex(n: u8) -> char {
match n {
0..=9 => (b'0' + n) as char,
10..=15 => (b'a' + (n - 10)) as char,
_ => '?', // unreachable: input is masked with 0x0F
}
}