# 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.rs` defines `PrivacyClass` as `#[repr(u8)]` with four variants `Raw = 0`, `Derived = 1`, `Anonymous = 2`, `Restricted = 3`, plus `allows_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.rs` implements `PrivacyGate::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.rs` enforces I1 at compile time via `Sink::MIN_CLASS` and the runtime `check_class::()` (`sink.rs:47-55`), with the three concrete `LocalKind`/`NetworkKind`/`MatterKind` tags. 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, and `identity_risk` only 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.rs` defines `GateAction { 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.rs` hard-codes `privacy_class: PrivacyClass::Anonymous` as the constructed default (`emitter.rs:82`) and the Soul Signature gate is controlled only by whether a `SoulMatchOracle` is 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 existing `PrivacyClass`, 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 stripping `privacy_gate.rs` already performs. They are what ADR-140's `privacy_action` field 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. ```rust // 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 ```rust // 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 `Reject`s. ### 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. ```rust // 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, } /// 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, } 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 { 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(&self) -> bool { crate::sink::check_class::(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 { 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: - **`PrivateHome` vs `EnterpriseAnonymous` both → `Anonymous`.** The difference is salt isolation, not class. Enterprise enables `multiseed_salt()` so that two seeds observing the same person in adjacent units produce uncorrelated `rf_signature_hash` values, preserving I3 (`signature_hasher.rs:8-18`) across a shared tenant boundary. Single-home does not need this. Both publish `identity_risk` at class 2 per the existing `ha_discovery.rs:116` rule — Enterprise additionally enforces `AggregateOnly` semantically, suppressing any zone-level or per-cluster surface beyond the five aggregate entities. - **`CareWithConsent` → `Derived` with Soul on.** This is the only mode that resolves to class `Derived`, matching `lib.rs:88-90`'s comment "Required for Soul Signature deployments." It enables `soul-signature` (the Cargo feature, `Cargo.toml:24-27`) and installs a real `SoulMatchOracle` so the gate's `Recalibrate` exemption (`coherence_gate.rs:71-84`) fires for enrolled subjects. Class `Derived` is *not* on the public MQTT tree (`mqtt_topics.rs:111` requires `>= Anonymous`), so consented identity data stays on LAN/research surfaces — `DropRaw` and `ReduceResolution` still 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. ```rust // 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, /// Per-event strip records covered by this link (a window, see below). pub fields_stripped_per_event: Vec, /// 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, fields_stripped_per_event: Vec, 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 a `sensor` with `entity_category = "diagnostic"`, unique-id `_bfld_privacy_mode`, state topic `ruview//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 class `Raw`/`Derived` on 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 "no `promote`" posture of `privacy_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. ```rust // 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 = ` 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 `PrivacyMode` and a `PrivacyModeRegistry`, not four scattered wiring decisions. An installer selects `CareWithConsent`; the registry derives class, Soul gate, salt domain, and ACL deterministically. - **Cryptographic continuity.** `PrivacyAttestationProof` makes "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_action` and `privacy_limited_by` now have a concrete, queryable source (`is_action_enforced`, `strongest_action`), closing the trace requirement for semantic state. - **No wire/byte changes.** `PrivacyClass` byte values, `BfldFrameHeader`, `sink.rs` ACL, and the MQTT topic tree are untouched. Modes are purely additive. ### 3.2 Negative - **Two same-class modes.** `PrivateHome` and `EnterpriseAnonymous` both resolve to `Anonymous`; 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 at `PrivacyClass` will 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.rs` gains state.** The emitter now owns a registry and a chain tail, growing its memory footprint and making `emit()` 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-`promote` posture). 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::()` 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 `Reject`s); 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::()`, `::()`, `::()` equal `check_class::(mode.privacy_class()).is_ok()` — i.e., the registry ACL never disagrees with the compile-time sink ACL. In particular `RawResearch.allows_sink::() == 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_stripped` equals the set of fields the gate actually zeroed (no attestation lies) — T5 green. - **AC4**: `EnterpriseAnonymous` preserves 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_enforced` and `strongest_action` are callable by ADR-139/ADR-140 with no `&mut` access 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`/`MatterKind` - `v2/crates/wifi-densepose-bfld/src/privacy_gate.rs` — `PrivacyGate::demote` zeroizing strip (`:31-75`) - `v2/crates/wifi-densepose-bfld/src/identity_risk.rs` — `GateAction` (`:57-69`), risk-score bands - `v2/crates/wifi-densepose-bfld/src/emitter.rs` — `BfldEmitter` default class `Anonymous` (`:82`), gate consult (`:171`) - `v2/crates/wifi-densepose-bfld/src/event.rs` — `BfldEvent` field exposure table, `apply_privacy_gating` (`:112-117`) - `v2/crates/wifi-densepose-bfld/src/coherence_gate.rs` — `SoulMatchOracle`, `evaluate_with_oracle` Recalibrate exemption (`:71-84`) - `v2/crates/wifi-densepose-bfld/src/signature_hasher.rs` — BLAKE3 keyed `rf_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-signature` feature (`:24-27`) ### Related ADR Documents - `docs/adr/ADR-010-witness-chains-audit-trail-integrity.md` — hash-chain primitive - `docs/adr/ADR-118-bfld-beamforming-feedback-layer-for-detection.md` - `docs/adr/ADR-120-bfld-privacy-class-and-hash-rotation.md` - `docs/adr/ADR-121-bfld-identity-risk-scoring.md` - `docs/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.