43 KiB
ADR-141: BFLD Privacy Control Plane: Named Modes, Actions, and Runtime Attestation
| Field | Value |
|---|---|
| Status | Proposed |
| Date | 2026-05-28 |
| Deciders | ruv |
| Codebase target | wifi-densepose-bfld (new module mode.rs + attestation.rs; extends lib.rs PrivacyClass, sink.rs, privacy_gate.rs, identity_risk.rs, emitter.rs, ha_discovery.rs) |
| Relates to | ADR-010 (Witness Chains), ADR-118 (BFLD), ADR-120 (Privacy Class + Hash Rotation), ADR-121 (Identity-Risk Scoring), ADR-122 (RuView HA/Matter Exposure), ADR-136 (Streaming Engine), ADR-139 (WorldGraph), ADR-140 (Semantic State Record), ADR-143 (RF SLAM v2) |
1. Context
1.1 The Gap
The BFLD crate (v2/crates/wifi-densepose-bfld/src/) already implements a complete, structurally enforced privacy posture, but it does so entirely in terms of a 4-value numeric class — there is no first-class concept of a deployment mode and no concept of a discrete privacy action. Reading the real code:
lib.rsdefinesPrivacyClassas#[repr(u8)]with four variantsRaw = 0,Derived = 1,Anonymous = 2,Restricted = 3, plusallows_network()/allows_matter()/as_u8()(lib.rs:82-117). This is the entire vocabulary the system has for "what is this deployment allowed to emit." Nothing names why a node is at class 2 vs class 3, nor records which privacy transformations were actually applied.privacy_gate.rsimplementsPrivacyGate::demote()— a monotonic, zeroizing transformer that strips payload sections (compressed_angle_matrix,csi_delta,amplitude_proxy,phase_proxy) on each class transition (privacy_gate.rs:31-75). The stripping is real and irreversible, but it is silent: nothing records which sections were zeroed for which frame. There is no audit trail and no way for a downstream verifier to prove what was stripped.sink.rsenforces I1 at compile time viaSink::MIN_CLASSand the runtimecheck_class::<S>()(sink.rs:47-55), with the three concreteLocalKind/NetworkKind/MatterKindtags. The MQTT topic router (mqtt_topics.rs:109-157) and HA discovery (ha_discovery.rs:61-129) hard-code the rule "publish only at class >= Anonymous, andidentity_riskonly at exactly Anonymous." This is an implicit ACL scattered across two files; it is not declared in one place and is not bound to a named mode.identity_risk.rsdefinesGateAction { Accept, PredictOnly, Reject, Recalibrate }(identity_risk.rs:57-69) — but these are risk-gating actions on a per-event basis, not privacy actions. There is no enum that names the privacy transformation a mode enforces (e.g., "suppress identity", "drop raw", "aggregate only").emitter.rshard-codesprivacy_class: PrivacyClass::Anonymousas the constructed default (emitter.rs:82) and the Soul Signature gate is controlled only by whether aSoulMatchOracleis supplied (emitter.rs:138,coherence_gate.rs:71). Whether Soul Signature is enabled for a deployment is not a declared policy — it is an implicit consequence of construction-site wiring.
The consequence: a deployment's privacy stance is encoded in four separate places — the constructed PrivacyClass, the presence/absence of a SoulMatchOracle, the class-gated MQTT/HA fan-out, and the signature_hasher install — with no single declared object that says "this node runs in CareWithConsent mode, which means class Derived, Soul Signature enabled, identity_risk published, raw never networked." There is no runtime artifact a regulator, a Home Assistant dashboard, or the WorldGraph (ADR-139) can read to learn the effective policy, and no cryptographic proof that the policy was actually enforced frame-by-frame.
ADR-140 (Semantic State Record) requires that every semantic state trace to a privacy_action. ADR-139 (WorldGraph) needs a privacy_limited_by annotation to compute which edges/zones are degraded by privacy. Neither has anything to bind to today: BFLD exposes a numeric class but no action and no attestation. This ADR closes that gap.
1.2 What "Mode", "Action", and "Attestation" Mean Here
- A PrivacyMode is a named, operator-facing deployment posture (e.g.,
CareWithConsent). It is the human-meaningful unit a regulator or installer reasons about. It is not a new enforcement primitive — it is a declarative selection that maps to the existingPrivacyClass, plus a Soul Signature gate decision, plus an MQTT/Matter ACL. - A PrivacyAction is the discrete, machine-checkable privacy transformation that a mode enforces (e.g.,
SuppressIdentity,DropRaw). Actions are the bridge between the human mode and the byte-level strippingprivacy_gate.rsalready performs. They are what ADR-140'sprivacy_actionfield carries. - A PrivacyAttestationProof is a hash-chained record (per ADR-010) of which mode was active, which actions were enforced, and which fields were stripped per event. It is the cryptographic continuity proof that the declared mode was honored, surfaced read-only to HA/Matter diagnostics.
What this ADR is not: it does not change the four PrivacyClass byte values, does not weaken any structural invariant (I1/I2/I3 from lib.rs:8-11), and does not replace PrivacyGate::demote() — it records what demote() did.
1.3 Pipeline Position
SensingInputs
→ BfldEmitter::emit() (identity_risk + CoherenceGate)
↑ consults
PrivacyModeRegistry::active_mode() ← NEW
↓ resolves to (PrivacyClass, Soul gate, ACL)
→ PrivacyGate::demote(frame, target_class) (existing; now records stripped fields)
↓ emits per-frame
PrivacyActionRecord { actions, fields_stripped } ← NEW
↓ folded into
PrivacyAttestationProof { mode, actions, fields_stripped_per_event, prev_hash } ← NEW (hash-chained, ADR-010)
↓ surfaced
mqtt_topics.rs / ha_discovery.rs (active mode + proof hash diagnostic entity)
↓ consumed by
ADR-139 privacy_limited_by / ADR-140 privacy_action
The registry is consulted once per class transition (not once per byte). The attestation chain is appended per emitted event window, not per frame, to bound chain growth (see §2.5).
2. Decision
2.1 PrivacyMode: Five Named Variants Layered Over PrivacyClass
Introduce PrivacyMode in a new module mode.rs. It is a semantic abstraction over the existing 4-class PrivacyClass; it adds zero new enforcement bytes on the wire.
// v2/crates/wifi-densepose-bfld/src/mode.rs
use crate::PrivacyClass;
/// Operator-facing deployment posture. Maps deterministically to a
/// `PrivacyClass`, a Soul Signature gate decision, and an MQTT/Matter ACL via
/// the `PrivacyModeRegistry`. Adds no new wire bytes — `PrivacyClass` remains
/// the only byte carried in `BfldFrameHeader`.
#[repr(u8)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum PrivacyMode {
/// Local research: raw BFI retained, never networked. Maps to `Raw`.
RawResearch = 0,
/// Single-home production: anonymous sensing, Soul Signature OFF.
/// Maps to `Anonymous`, no per-day rf_signature_hash.
PrivateHome = 1,
/// Multi-tenant / enterprise: anonymous + per-seed salt rotation so no
/// two seeds can correlate. Maps to `Anonymous`, multiseed salt domain.
EnterpriseAnonymous = 2,
/// Care deployment with explicit consent: identity-derived fields enabled
/// behind consent. Maps to `Derived`, Soul Signature ON.
CareWithConsent = 3,
/// Regulated / no-identity: strictest posture. Maps to `Restricted`.
StrictNoIdentity = 4,
}
impl PrivacyMode {
/// The `PrivacyClass` this mode resolves to. This is the *only* coupling
/// to the existing enforcement layer.
#[must_use]
pub const fn privacy_class(self) -> PrivacyClass {
match self {
Self::RawResearch => PrivacyClass::Raw,
Self::PrivateHome | Self::EnterpriseAnonymous => PrivacyClass::Anonymous,
Self::CareWithConsent => PrivacyClass::Derived,
Self::StrictNoIdentity => PrivacyClass::Restricted,
}
}
/// Whether Soul Signature (`SignatureHasher` install + non-`Null` oracle)
/// is enabled in this mode. See `emitter.rs:138` / `coherence_gate.rs:71`.
#[must_use]
pub const fn soul_signature_enabled(self) -> bool {
matches!(self, Self::CareWithConsent)
}
/// Whether per-seed (multiseed) salt isolation is required so two seeds
/// in the same site produce uncorrelated `rf_signature_hash` (invariant I3,
/// `signature_hasher.rs:8-18`). Enterprise turns this on; single-home does not.
#[must_use]
pub const fn multiseed_salt(self) -> bool {
matches!(self, Self::EnterpriseAnonymous)
}
/// Stable string token used in TOML config, MQTT diagnostics, and the
/// attestation proof. Lowercase snake form of the variant.
#[must_use]
pub const fn token(self) -> &'static str {
match self {
Self::RawResearch => "raw_research",
Self::PrivateHome => "private_home",
Self::EnterpriseAnonymous => "enterprise_anonymous",
Self::CareWithConsent => "care_with_consent",
Self::StrictNoIdentity => "strict_no_identity",
}
}
}
The decision to keep PrivacyMode separate from PrivacyClass (rather than collapsing the two into a 5-variant class) is deliberate: PrivacyClass is a wire/sink-enforcement primitive with byte semantics relied on by frame.rs, sink.rs::check_class, and the on-NVS/MQTT representation. Two of the five modes (PrivateHome, EnterpriseAnonymous) resolve to the same class (Anonymous) but differ in salt domain — they are not separable at the class layer. Modes are a strictly higher-level concept and must not perturb the existing byte contract.
2.2 PrivacyAction: The Enforced-Transformation Vocabulary
// v2/crates/wifi-densepose-bfld/src/mode.rs (continued)
/// A discrete privacy transformation a mode enforces. These are the
/// machine-checkable bridge between a human `PrivacyMode` and the byte-level
/// stripping already performed by `PrivacyGate::demote()` (`privacy_gate.rs`).
///
/// ADR-140's semantic-state `privacy_action` field carries the *strongest*
/// action enforced for the event that produced the state.
#[repr(u8)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum PrivacyAction {
/// No transformation: the frame is published as-is at its class.
Allow = 0,
/// Strip identity-derived fields (`identity_risk_score`, `rf_signature_hash`)
/// — the `Restricted` strip in `event.rs:112-117`.
SuppressIdentity = 1,
/// Down-sample the angle/CSI surface (the `compressed_angle_matrix` /
/// `csi_delta` zeroing in `privacy_gate.rs:48-55`).
ReduceResolution = 2,
/// Refuse to network a `Raw` frame (structural invariant I1, `sink.rs:35`).
DropRaw = 3,
/// Emit only aggregate sensing (presence/motion/count/confidence); no
/// per-subject or per-cluster surface leaves the node.
AggregateOnly = 4,
}
PrivacyAction is Ord so a per-event set can be reduced to its strongest action for ADR-140's single-valued privacy_action field (the maximum). The actions are intentionally orthogonal to GateAction (identity_risk.rs:57): GateAction answers "is this event too risky to publish?"; PrivacyAction answers "what privacy transformation does the active mode require on every event?" They compose — a mode may enforce SuppressIdentity while the per-event gate independently Rejects.
2.3 PrivacyModeRegistry: Single Source of Truth + Append-Only Audit Log
The registry is the one declared object that the gap (§1.1) is missing. It owns the active mode, the mode→actions mapping, the ACL, and an append-only audit log that the witness verifier can replay.
// v2/crates/wifi-densepose-bfld/src/mode.rs (continued)
use crate::sink::Sink;
/// Declares the active mode and the policy it implies. Consulted by the
/// emitter/gate on every class transition. Holds an append-only, witness-
/// checkable audit log of every mode resolution and action enforcement.
#[derive(Debug)]
pub struct PrivacyModeRegistry {
active: PrivacyMode,
/// Append-only; never mutated in place. Each entry is hashed into the
/// attestation chain (§2.5).
audit_log: Vec<ModeAuditEntry>,
}
/// One append-only audit record. ADR-010 §"Hash chain" linkage is applied at
/// the `PrivacyAttestationProof` layer, not here — this is the raw event.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModeAuditEntry {
/// Monotonic capture-clock ns (matches `BfldEvent::timestamp_ns`).
pub timestamp_ns: u64,
/// Mode active at the moment of this transition/resolution.
pub mode: PrivacyMode,
/// Class the mode resolved to.
pub resolved_class: PrivacyClass,
/// The set of actions enforced, sorted ascending (Ord), deduplicated.
pub actions_enforced: Vec<PrivacyAction>,
}
impl PrivacyModeRegistry {
/// Build a registry pinned to `mode`. The production-safe default is
/// `PrivateHome` (resolves to `Anonymous`, matching `emitter.rs:82`).
#[must_use]
pub fn new(mode: PrivacyMode) -> Self {
Self { active: mode, audit_log: Vec::new() }
}
/// The currently active mode.
#[must_use]
pub const fn active_mode(&self) -> PrivacyMode {
self.active
}
/// The set of actions this mode enforces, sorted ascending. Pure function
/// of `active` — the canonical mode→actions mapping (§2.4 table).
#[must_use]
pub fn enforced_actions(&self) -> Vec<PrivacyAction> {
actions_for(self.active)
}
/// Whether a specific action is enforced under the active mode. This is the
/// predicate ADR-139/ADR-140 query to decide `privacy_limited_by` and
/// `privacy_action`.
#[must_use]
pub fn is_action_enforced(&self, action: PrivacyAction) -> bool {
actions_for(self.active).contains(&action)
}
/// Whether the active mode's class may cross sink `S`. Re-uses the
/// existing compile-time ACL (`sink.rs::check_class`). This is the
/// declared-in-one-place MQTT/Matter ACL the gap (§1.1) lacked.
#[must_use]
pub fn allows_sink<S: Sink>(&self) -> bool {
crate::sink::check_class::<S>(self.active.privacy_class()).is_ok()
}
/// Record a class transition / resolution into the append-only log and
/// return the entry that was appended (so the caller can fold it into the
/// attestation chain). Called by the emitter on every transition.
pub fn record_transition(&mut self, timestamp_ns: u64) -> &ModeAuditEntry {
let entry = ModeAuditEntry {
timestamp_ns,
mode: self.active,
resolved_class: self.active.privacy_class(),
actions_enforced: actions_for(self.active),
};
self.audit_log.push(entry);
self.audit_log.last().expect("just pushed")
}
/// Read-only view of the audit log for the witness verifier.
#[must_use]
pub fn audit_log(&self) -> &[ModeAuditEntry] {
&self.audit_log
}
}
/// Canonical mode→actions mapping (§2.4). Pure, total, `const`-friendly.
#[must_use]
pub fn actions_for(mode: PrivacyMode) -> Vec<PrivacyAction> {
use PrivacyAction::{Allow, AggregateOnly, DropRaw, ReduceResolution, SuppressIdentity};
let v = match mode {
PrivacyMode::RawResearch => vec![Allow], // local-only; I1 still blocks network in sink.rs
PrivacyMode::PrivateHome => vec![SuppressIdentity, DropRaw],
PrivacyMode::EnterpriseAnonymous => vec![SuppressIdentity, DropRaw, AggregateOnly],
PrivacyMode::CareWithConsent => vec![DropRaw, ReduceResolution],
PrivacyMode::StrictNoIdentity => {
vec![SuppressIdentity, ReduceResolution, DropRaw, AggregateOnly]
}
};
v // already authored in ascending Ord order
}
The audit log is Vec-backed and append-only by API surface (no pop, no index-mut). The registry requires &mut self only for record_transition; active_mode, enforced_actions, is_action_enforced, and allows_sink are &self reads safe to call from the publish path.
2.4 Mode → (Class, Soul Gate, MQTT ACL) Mapping
This is the explicit, single-place declaration the gap (§1.1) was missing. Each row is enforced by PrivacyMode::privacy_class(), PrivacyMode::soul_signature_enabled(), and the existing class-gated routers.
| Mode | PrivacyClass |
Soul Signature | Salt domain | MQTT/HA exposure (existing routers) | Enforced actions |
|---|---|---|---|---|---|
RawResearch |
Raw (0) |
off | per-node | none — class 0 never networked (mqtt_topics.rs:111, I1 sink.rs:35) |
Allow |
PrivateHome |
Anonymous (2) |
off | per-node | presence/motion/count/conf/identity_risk (ha_discovery.rs:116) |
SuppressIdentity, DropRaw |
EnterpriseAnonymous |
Anonymous (2) |
off | multiseed (signature_hasher.rs per-seed site_salt) |
same as PrivateHome | SuppressIdentity, DropRaw, AggregateOnly |
CareWithConsent |
Derived (1) |
on (SoulMatchOracle + SignatureHasher) |
per-node | LAN/research only — class 1 not on public tree (mqtt_topics.rs:111) |
DropRaw, ReduceResolution |
StrictNoIdentity |
Restricted (3) |
off | per-node | presence/motion/count/conf only; identity_risk not published (mqtt_topics.rs:147, event.rs:113) |
SuppressIdentity, ReduceResolution, DropRaw, AggregateOnly |
Two mappings warrant explanation:
PrivateHomevsEnterpriseAnonymousboth →Anonymous. The difference is salt isolation, not class. Enterprise enablesmultiseed_salt()so that two seeds observing the same person in adjacent units produce uncorrelatedrf_signature_hashvalues, preserving I3 (signature_hasher.rs:8-18) across a shared tenant boundary. Single-home does not need this. Both publishidentity_riskat class 2 per the existingha_discovery.rs:116rule — Enterprise additionally enforcesAggregateOnlysemantically, suppressing any zone-level or per-cluster surface beyond the five aggregate entities.CareWithConsent→Derivedwith Soul on. This is the only mode that resolves to classDerived, matchinglib.rs:88-90's comment "Required for Soul Signature deployments." It enablessoul-signature(the Cargo feature,Cargo.toml:24-27) and installs a realSoulMatchOracleso the gate'sRecalibrateexemption (coherence_gate.rs:71-84) fires for enrolled subjects. ClassDerivedis not on the public MQTT tree (mqtt_topics.rs:111requires>= Anonymous), so consented identity data stays on LAN/research surfaces —DropRawandReduceResolutionstill apply.
2.5 PrivacyAttestationProof: Hash-Chained Per ADR-010
The attestation proof gives cryptographic continuity that the declared mode was honored. It reuses the ADR-010 witness-chain primitive directly: each proof entry includes the SHAKE-256/BLAKE3 hash of the previous entry (ADR-010 §"Hash chain", previous_hash/entry_hash linkage), so any insertion, deletion, or reordering breaks verification.
// v2/crates/wifi-densepose-bfld/src/attestation.rs
#![cfg(feature = "std")]
use crate::mode::{PrivacyAction, PrivacyMode};
use blake3::Hasher; // already a dependency (Cargo.toml:33)
/// Per-event privacy enforcement record — the unit folded into the chain.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PrivacyActionRecord {
/// Capture-clock ns of the event this record attests.
pub timestamp_ns: u64,
/// Strongest action enforced for this event (ADR-140 `privacy_action`).
pub strongest_action: PrivacyAction,
/// Names of payload/event fields stripped for this event, e.g.
/// "compressed_angle_matrix", "rf_signature_hash". Sorted lexicographically
/// so the canonical-bytes hash is deterministic.
pub fields_stripped: Vec<&'static str>,
}
/// One link in the attestation hash chain. ADR-010-compatible.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PrivacyAttestationProof {
/// Active mode at the time this link was sealed.
pub mode: PrivacyMode,
/// All actions enforced under `mode`, ascending (from the registry).
pub actions_enforced: Vec<PrivacyAction>,
/// Per-event strip records covered by this link (a window, see below).
pub fields_stripped_per_event: Vec<PrivacyActionRecord>,
/// BLAKE3 hash of the *previous* link's `entry_hash`; all-zero for genesis.
pub prev_hash: [u8; 32],
/// BLAKE3 over (mode token || actions || records || prev_hash). Computed by
/// `seal()`; this is the value the next link references as `prev_hash`.
pub entry_hash: [u8; 32],
}
impl PrivacyAttestationProof {
/// Seal a new link given the previous link's `entry_hash` (or `[0u8; 32]`
/// for the genesis link). The hash binds mode, actions, and per-event
/// strips, so altering any field after sealing breaks the chain.
#[must_use]
pub fn seal(
mode: PrivacyMode,
actions_enforced: Vec<PrivacyAction>,
fields_stripped_per_event: Vec<PrivacyActionRecord>,
prev_hash: [u8; 32],
) -> Self {
let mut h = Hasher::new();
h.update(mode.token().as_bytes());
for a in &actions_enforced {
h.update(&[*a as u8]);
}
for rec in &fields_stripped_per_event {
h.update(&rec.timestamp_ns.to_le_bytes());
h.update(&[rec.strongest_action as u8]);
for f in &rec.fields_stripped {
h.update(f.as_bytes());
h.update(&[0u8]); // length-free field separator
}
}
h.update(&prev_hash);
let entry_hash = *h.finalize().as_bytes();
Self { mode, actions_enforced, fields_stripped_per_event, prev_hash, entry_hash }
}
/// Verify chain linkage against the previous link's `entry_hash` AND that
/// `entry_hash` recomputes from the sealed fields (tamper evidence).
#[must_use]
pub fn verify_link(&self, expected_prev: [u8; 32]) -> bool {
if self.prev_hash != expected_prev {
return false;
}
let recomputed = Self::seal(
self.mode,
self.actions_enforced.clone(),
self.fields_stripped_per_event.clone(),
self.prev_hash,
);
recomputed.entry_hash == self.entry_hash
}
/// Short proof hash for diagnostics: `"blake3:<16 hex>"` (first 8 bytes of
/// `entry_hash`). Surfaced on the HA diagnostic entity (§2.6).
#[must_use]
pub fn short_hash(&self) -> String {
let mut s = String::with_capacity(7 + 16);
s.push_str("blake3:");
for b in &self.entry_hash[..8] {
s.push_str(&format!("{b:02x}"));
}
s
}
}
Chain granularity — per window, not per frame. The proof links one event window (e.g., one emit cycle of the BfldEmitter, emitter.rs:138), not one CSI frame. A per-frame chain at 20 Hz would grow at 1,728,000 links/day; per-window keeps the chain bounded to the published-event rate while still attesting every strip (each window's fields_stripped_per_event enumerates the per-event strips inside it). BLAKE3 is reused (it is already a dependency, Cargo.toml:33) rather than introducing the SHAKE-256 used in ADR-010's MAT path — ADR-010 §"Hash chain" specifies a hash-linked chain but not a fixed algorithm; BFLD already keys its rf_signature_hash with BLAKE3 (signature_hasher.rs:20), so reusing it avoids a second crypto dependency in the no-std-capable crate.
2.6 Integration Into MQTT Discovery + a Read-Only HA Diagnostic Entity
The active mode and proof hash are surfaced as a read-only diagnostic so an operator, regulator, or the cognitum-v0 dashboard can see the live privacy posture without touching the sensing entities. This extends ha_discovery.rs and mqtt_topics.rs, both of which already class-gate every entity.
- A new discovery payload is rendered by
render_discovery_payloads()(ha_discovery.rs:61) for asensorwithentity_category = "diagnostic", unique-id<node>_bfld_privacy_mode, state topicruview/<node>/bfld/privacy_mode/state. Its state is a compact JSON object{"mode":"care_with_consent","class":"derived","proof":"blake3:<16hex>","actions":["drop_raw","reduce_resolution"]}. - The entity is published at every class
>= Anonymous(same gate as the existing five diagnostic sensors) and additionally at classRaw/Derivedon the LAN-only research surface — because a research/care deployment most needs to display its own attestation. The class gate for the public tree (mqtt_topics.rs:111) is unchanged; the diagnostic mode entity is added to the local diagnostic surface regardless of class so the proof is always inspectable on-node. - It is strictly read-only: the entity has no
command_topic. Mode changes are an operator/config action (TOML + restart, §2.7), never an MQTT write — consistent with the "nopromote" posture ofprivacy_gate.rs.
The proof hash on this entity is the short_hash() of the most recently sealed PrivacyAttestationProof. A verifier with the full chain (exported via a future attestation export CLI) can confirm continuity from genesis to the displayed hash.
2.7 Registry Wiring Into the Emitter
BfldEmitter (emitter.rs:65-88) gains an owned PrivacyModeRegistry and seals one attestation link per emit window. The change is additive — the existing emit()/emit_with_oracle() signatures are unchanged; the registry is configured via a new builder.
// emitter.rs additions (sketch)
pub struct BfldEmitter {
// ...existing fields (node_id, default_zone_id, privacy_class, gate, ring, signature_hasher)
registry: PrivacyModeRegistry, // NEW — single source of truth
last_proof_hash: [u8; 32], // NEW — chain tail; [0;32] genesis
}
impl BfldEmitter {
/// Configure the emitter from a named mode. Sets `privacy_class` from
/// `mode.privacy_class()`, installs/clears the signature hasher and Soul
/// oracle per `mode.soul_signature_enabled()`, and pins the registry.
#[must_use]
pub fn with_mode(mut self, mode: PrivacyMode) -> Self {
self.privacy_class = mode.privacy_class();
self.registry = PrivacyModeRegistry::new(mode);
self
}
/// Active mode + freshly sealed proof for the most recent emit window.
/// Read by the HA diagnostic entity (§2.6).
#[must_use]
pub fn attestation(&self) -> Option<&PrivacyAttestationProof> { /* tail of sealed chain */ }
}
On each emit(), after the gate decision (emitter.rs:171), the emitter: (1) calls registry.record_transition(ts); (2) builds a PrivacyActionRecord enumerating the fields the privacy gating actually stripped (e.g., at class Restricted the identity_risk_score + rf_signature_hash strip in event.rs:112-117 yields fields_stripped = ["identity_risk_score","rf_signature_hash"]); (3) calls PrivacyAttestationProof::seal(mode, actions, records, self.last_proof_hash) and updates last_proof_hash. The configured baseline mode (default PrivateHome) preserves the current Anonymous default (emitter.rs:82), so an un-migrated caller sees identical behavior plus a populated attestation chain.
2.8 Downstream Consumers (ADR-139, ADR-140)
| Consumer | What it reads | Binding |
|---|---|---|
| ADR-140 Semantic State Record | PrivacyActionRecord::strongest_action |
Populates the record's mandatory privacy_action field; the proof entry_hash populates the record's privacy-provenance reference |
| ADR-139 WorldGraph | PrivacyModeRegistry::is_action_enforced(AggregateOnly) / ReduceResolution |
A zone/edge whose evidence was degraded by ReduceResolution or AggregateOnly is tagged privacy_limited_by = <mode token> so the digital twin can mark the region as privacy-degraded rather than sensor-blind |
| ADR-136 Streaming Engine | attestation() short hash |
Stage-boundary frame contract may carry the active mode token for downstream stages without re-deriving it |
ha_discovery.rs / mqtt_topics.rs |
active mode + short_hash() |
Read-only diagnostic entity (§2.6) |
This honors the project rule that every semantic state traces to signal evidence + model version + calibration version + privacy decision: ADR-141 supplies the privacy decision half — the PrivacyActionRecord (what was enforced) plus the chain entry_hash (proof it was enforced) — which ADR-140 records alongside the signal/model/calibration provenance from ADR-134/ADR-135.
3. Consequences
3.1 Positive
- Single declared policy object. A deployment's privacy stance is now one named
PrivacyModeand aPrivacyModeRegistry, not four scattered wiring decisions. An installer selectsCareWithConsent; the registry derives class, Soul gate, salt domain, and ACL deterministically. - Cryptographic continuity.
PrivacyAttestationProofmakes "we ran in StrictNoIdentity and stripped identity on every event" a verifiable claim, not a code-review assertion. The chain reuses the ADR-010 primitive, so the existing witness verifier extends naturally. - Regulator/operator visibility. The read-only HA diagnostic entity exposes the live mode and proof hash without widening the sensing surface — useful for care-home compliance audits.
- Clean ADR-139/ADR-140 bindings.
privacy_actionandprivacy_limited_bynow have a concrete, queryable source (is_action_enforced,strongest_action), closing the trace requirement for semantic state. - No wire/byte changes.
PrivacyClassbyte values,BfldFrameHeader,sink.rsACL, and the MQTT topic tree are untouched. Modes are purely additive.
3.2 Negative
- Two same-class modes.
PrivateHomeandEnterpriseAnonymousboth resolve toAnonymous; the difference (salt domain,AggregateOnly) lives above the class layer and is only meaningful if downstream consumers honor the action set. A consumer that looks only atPrivacyClasswill not distinguish them. - Chain growth. Even per-window, a busy node accumulates attestation links. An export/prune policy (genesis re-anchoring after verified export) is needed and is deferred to a follow-up iter.
emitter.rsgains state. The emitter now owns a registry and a chain tail, growing its memory footprint and makingemit()no longer a pure transform of inputs→event. The seal cost (one BLAKE3 over a small buffer) is sub-microsecond but non-zero.- Mode change requires restart. By design there is no MQTT command topic to change mode at runtime (mirrors
privacy_gate.rs's no-promoteposture). Operators change mode via TOML config + restart, which is a heavier operation than a dashboard toggle.
3.3 Risks
| Risk | Probability | Impact | Mitigation |
|---|---|---|---|
Mode→action mapping drifts from what privacy_gate.rs actually strips, so the proof attests fields that were not really removed |
Medium | Attestation lies — worse than no attestation | The PrivacyActionRecord.fields_stripped is populated from the actual gate output (event.rs/privacy_gate.rs return values), not from the mode table; a unit test asserts the recorded strips equal the bytes the gate zeroed |
EnterpriseAnonymous multiseed salt not actually isolated (two seeds share a salt) → I3 broken under same class |
Low | Cross-unit identity correlation | multiseed_salt() gates a per-seed site_salt derivation; an acceptance test asserts cross-seed Hamming distance ~128 bits (reusing ADR-120 §2.7 AC2 from tests/signature_hasher.rs) |
| Chain genesis confusion: a node that restarts mid-deployment starts a fresh genesis, breaking continuity from the prior chain | Medium | Verifier sees a discontinuity it cannot distinguish from tampering | Genesis links record prev_hash = [0;32] and a boot epoch; the verifier treats a genesis link with a logged restart event as a legitimate re-anchor, not a break |
Operator selects RawResearch and assumes raw never networks, but a misconfigured custom Sink accepts class 0 |
Low | I1 violation | RawResearch's DropRaw action is redundant with the compile-time sink.rs ACL (MIN_CLASS); the registry's allows_sink::<NetworkKind>() returns false for Raw, giving a runtime second line of defense |
4. Alternatives Considered
4.1 Extend PrivacyClass to Five+ Variants Instead of Adding PrivacyMode
Collapsing modes into the class enum would avoid a second type. Rejected because PrivacyClass is a wire and sink-enforcement primitive: its byte values are serialized in BfldFrameHeader, switched on in sink.rs::check_class, the MQTT router, and the NVS/Matter representation. Two modes (PrivateHome, EnterpriseAnonymous) share the same class but differ only in salt domain — they are not separable at the byte layer, so they cannot be class variants without inventing byte semantics that the existing frame.rs/sink.rs code would have to learn. Modes are strictly higher-level and must not perturb the byte contract.
4.2 Per-Frame Attestation Chain
A chain link per CSI frame would attest every single frame. Rejected on growth grounds: 20 Hz × 86,400 s = 1.7 M links/day/node, unbounded. The per-window granularity (§2.5) attests every strip (each window enumerates its per-event records) at the published-event rate, which is orders of magnitude lower while losing no strip evidence.
4.3 Reuse GateAction Instead of a New PrivacyAction Enum
GateAction { Accept, PredictOnly, Reject, Recalibrate } already exists (identity_risk.rs:57). Rejected because it answers a different question — per-event risk gating — and overloading it would conflate "this event is risky" with "this mode strips identity on every event." They compose (a mode can SuppressIdentity while the gate independently Rejects); merging them would lose that orthogonality and break ADR-140's need for a stable privacy_action value independent of per-event risk.
4.4 Runtime Mode Changes via MQTT Command Topic
A command_topic would let a dashboard flip modes live. Rejected for the same reason privacy_gate.rs has no promote: a remote, unauthenticated-by-default MQTT write that weakens privacy (e.g., StrictNoIdentity → RawResearch) is a privilege-escalation surface. Mode is a config-time + restart decision; the diagnostic entity is read-only.
4.5 SHAKE-256 (Match ADR-010 Exactly) vs BLAKE3 Reuse
ADR-010's MAT path uses SHAKE-256. Adopting it here would mean a second crypto dependency in a crate that is #![cfg_attr(not(feature = "std"), no_std)] (lib.rs:14). Rejected: ADR-010 §"Hash chain" specifies a hash-linked chain, not a fixed algorithm, and BFLD already depends on BLAKE3 for rf_signature_hash (signature_hasher.rs:20, Cargo.toml:33). Reusing BLAKE3 keeps the no-std footprint minimal while satisfying the linkage/tamper-evidence contract.
5. Testing and Acceptance Criteria
5.1 Test Plan
T1 — Mode→class/Soul/salt mapping (unit). For each of the five PrivacyMode variants, assert privacy_class(), soul_signature_enabled(), and multiseed_salt() exactly match the §2.4 table. Assert token() round-trips through a from_token() parser.
T2 — Canonical action set (unit). For each mode, assert actions_for(mode) equals the §2.4 "Enforced actions" column, is sorted ascending (Ord), and is deduplicated. Assert is_action_enforced agrees with set membership for all 25 (mode, action) pairs.
T3 — ACL agreement with sink.rs (unit). For each mode, assert registry.allows_sink::<LocalKind>(), ::<NetworkKind>(), ::<MatterKind>() equal check_class::<S>(mode.privacy_class()).is_ok() — i.e., the registry ACL never disagrees with the compile-time sink ACL. In particular RawResearch.allows_sink::<NetworkKind>() == false (I1).
T4 — Attestation chain linkage (unit). Seal a genesis link (prev_hash = [0;32]), then three more, threading each entry_hash into the next prev_hash. Assert verify_link() passes for all four against the correct predecessors. Mutate one link's mode and assert verify_link() fails (tamper evidence). Insert/delete/reorder a link and assert verification breaks.
T5 — Recorded strips equal actual gate output (unit). Run BfldEmitter::with_mode(StrictNoIdentity), emit an event that would carry identity_risk_score + rf_signature_hash, and assert: (a) the emitted BfldEvent has both fields None (existing event.rs:113 behavior), AND (b) the sealed PrivacyActionRecord.fields_stripped equals ["identity_risk_score","rf_signature_hash"] (sorted) — proving the proof attests what was really stripped, not what the table claims.
T6 — Multiseed salt isolation (unit, reuses ADR-120 AC2). Two emitters in EnterpriseAnonymous with distinct per-seed salts observing identical identity features produce rf_signature_hash values with Hamming distance in [112, 144] bits (≈128 expected). Same test in PrivateHome with a shared node salt is not required to isolate (documents the difference).
T7 — Default-mode backward compatibility (unit). A BfldEmitter::new(node_id) with no with_mode() call behaves identically to today (class Anonymous, emitter.rs:82) and its registry reports active_mode() == PrivateHome.
T8 — HA diagnostic entity render (unit). render_discovery_payloads() emits the privacy_mode diagnostic sensor with entity_category = "diagnostic", no command_topic, and a state JSON containing the mode token, class, short_hash(), and action tokens. Assert the public sensing tree (presence/motion/etc.) is byte-identical to the pre-change output (no regression to mqtt_topics.rs:109).
T9 — Determinism proof (CI, extends ADR-028). Seal a fixed 4-link chain from a hard-coded mode sequence and assert the final entry_hash matches a recorded SHA-256-of-bytes constant in archive/v1/data/proof/expected_features.sha256 under key bfld_attestation_chain_v1. Makes the attestation hash deterministic end-to-end.
5.2 Acceptance Criteria
- AC1: All five modes resolve to the exact (class, Soul, salt, ACL, actions) tuple in §2.4 — T1, T2, T3 green.
- AC2: The attestation chain is tamper-evident: any single-field mutation, insertion, deletion, or reorder fails
verify_link()— T4 green. - AC3: For every emitted event,
PrivacyActionRecord.fields_strippedequals the set of fields the gate actually zeroed (no attestation lies) — T5 green. - AC4:
EnterpriseAnonymouspreserves I3 across seeds (cross-seed Hamming ≈ 128 bits) — T6 green. - AC5: An un-migrated
BfldEmitter::new()is observationally identical to today, plus a populated attestation chain — T7 green; the public MQTT tree is byte-identical — T8 green. - AC6:
is_action_enforcedandstrongest_actionare callable by ADR-139/ADR-140 with no&mutaccess to the registry (read path is&self).
5.3 Witness / Proof
Per ADR-028/ADR-010, three rows are added to the witness log:
| Row | Capability | Evidence |
|---|---|---|
| W-39 | Mode→action mapping is total and matches §2.4 | cargo test -p wifi-densepose-bfld mode::tests::mapping_table |
| W-40 | Attestation chain tamper-evidence | cargo test -p wifi-densepose-bfld attestation::tests::tamper_breaks_chain |
| W-41 | Recorded strips equal actual gate output | cargo test -p wifi-densepose-bfld attestation::tests::strips_match_gate |
source-hashes.txt in the witness bundle gains SHA-256(mode.rs) and SHA-256(attestation.rs).
6. Related ADRs
| ADR | Relationship |
|---|---|
| ADR-010 (Witness Chains) | Reuses: PrivacyAttestationProof adopts the hash-linked chain primitive (previous_hash/entry_hash); BFLD uses BLAKE3 rather than SHAKE-256 per §4.5 |
| ADR-118 (BFLD) | Extended: modes/actions/attestation layer over the existing pipeline; invariants I1/I2/I3 (lib.rs:8-11) unchanged |
| ADR-120 (Privacy Class + Hash Rotation) | Extended: PrivacyMode maps to PrivacyClass; EnterpriseAnonymous formalizes multiseed site_salt isolation (signature_hasher.rs) |
| ADR-121 (Identity-Risk Scoring) | Composes with: PrivacyAction is orthogonal to GateAction (identity_risk.rs:57); Soul gate exemption (coherence_gate.rs:71) is enabled by CareWithConsent |
| ADR-122 (HA/Matter Exposure) | Extended: read-only privacy_mode diagnostic entity added to ha_discovery.rs/mqtt_topics.rs; public tree unchanged |
| ADR-136 (Streaming Engine) | Consumer: active mode token may ride stage-boundary frame contracts |
| ADR-139 (WorldGraph) | Consumer: is_action_enforced(ReduceResolution/AggregateOnly) drives privacy_limited_by zone/edge tagging |
| ADR-140 (Semantic State Record) | Consumer: strongest_action populates privacy_action; chain entry_hash is the privacy-provenance reference |
| ADR-143 (RF SLAM v2) | Constrains: reflector/anchor surfaces are subject to ReduceResolution/AggregateOnly under the active mode |
7. References
Production Code
v2/crates/wifi-densepose-bfld/src/lib.rs—PrivacyClass(:82-117),BfldError, structural invariants I1/I2/I3 (:8-11)v2/crates/wifi-densepose-bfld/src/sink.rs—Sink::MIN_CLASS,check_class(:47-55),LocalKind/NetworkKind/MatterKindv2/crates/wifi-densepose-bfld/src/privacy_gate.rs—PrivacyGate::demotezeroizing strip (:31-75)v2/crates/wifi-densepose-bfld/src/identity_risk.rs—GateAction(:57-69), risk-score bandsv2/crates/wifi-densepose-bfld/src/emitter.rs—BfldEmitterdefault classAnonymous(:82), gate consult (:171)v2/crates/wifi-densepose-bfld/src/event.rs—BfldEventfield exposure table,apply_privacy_gating(:112-117)v2/crates/wifi-densepose-bfld/src/coherence_gate.rs—SoulMatchOracle,evaluate_with_oracleRecalibrate exemption (:71-84)v2/crates/wifi-densepose-bfld/src/signature_hasher.rs— BLAKE3 keyedrf_signature_hash, I3 site isolation (:8-18)v2/crates/wifi-densepose-bfld/src/ha_discovery.rs— class-gated discovery render (:61-129)v2/crates/wifi-densepose-bfld/src/mqtt_topics.rs— class-gated topic router (:109-157)v2/crates/wifi-densepose-bfld/Cargo.toml— BLAKE3 dependency (:33),soul-signaturefeature (:24-27)
Related ADR Documents
docs/adr/ADR-010-witness-chains-audit-trail-integrity.md— hash-chain primitivedocs/adr/ADR-118-bfld-beamforming-feedback-layer-for-detection.mddocs/adr/ADR-120-bfld-privacy-class-and-hash-rotation.mddocs/adr/ADR-121-bfld-identity-risk-scoring.mddocs/adr/ADR-122-bfld-ruview-ha-matter-exposure.md
Implementation Status & Integration (2026-05-29)
Part of the ADR-136 streaming-engine series -- skeleton/scaffolding, trust-first, mostly not yet on the live 20 Hz path. See ADR-136 (Implementation Status) for the series framing.
Built -- tested building block (commit 7d88eb84c, issue #845): PrivacyMode / PrivacyAction / PrivacyModeRegistry plus the BLAKE3 hash-chained PrivacyAttestationProof (verify_chain() detects tamper). no_std-safe (registry is std-gated for the ESP32 path). 6 tests.
Integration glue -- not yet on the live path: wiring the registry into PrivacyGate class transitions, the MQTT discovery payload, and a read-only Home Assistant diagnostic entity exposing the active mode + proof hash.
Trust contribution: the policy spine -- privacy posture is a tamper-evident, auditable chain rather than a checkbox; an operator's mode choice actively governs whether identity data may even exist.