wifi-densepose/docs/adr/ADR-141-bfld-privacy-contro...

43 KiB
Raw Permalink Blame History

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.

// 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:

  • 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.
  • CareWithConsentDerived 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.

// 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.

// 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 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., StrictNoIdentityRawResearch) 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).


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.rsPrivacyClass (:82-117), BfldError, structural invariants I1/I2/I3 (:8-11)
  • v2/crates/wifi-densepose-bfld/src/sink.rsSink::MIN_CLASS, check_class (:47-55), LocalKind/NetworkKind/MatterKind
  • v2/crates/wifi-densepose-bfld/src/privacy_gate.rsPrivacyGate::demote zeroizing strip (:31-75)
  • v2/crates/wifi-densepose-bfld/src/identity_risk.rsGateAction (:57-69), risk-score bands
  • v2/crates/wifi-densepose-bfld/src/emitter.rsBfldEmitter default class Anonymous (:82), gate consult (:171)
  • v2/crates/wifi-densepose-bfld/src/event.rsBfldEvent field exposure table, apply_privacy_gating (:112-117)
  • v2/crates/wifi-densepose-bfld/src/coherence_gate.rsSoulMatchOracle, 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)
  • 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.