# 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::<S>()` (`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<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:

- **`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<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 a `sensor` with `entity_category = "diagnostic"`, unique-id `<node>_bfld_privacy_mode`, state topic `ruview/<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 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 = <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 `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::<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 `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::<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_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.
