//! ADR-262 **P3** — the live RuField surface. //! //! This is the data-path wiring that turns RuView's governed sensing cycle into //! signed RuField [`FieldEvent`]s on two **additive** network endpoints: //! //! - `GET /api/field` — the most recent surfaced `FieldEvent`(s) as JSON; //! - `GET /ws/field` — a WebSocket that streams each cycle's `FieldEvent` //! (mirrors the `/ws/sensing` broadcast-subscribe pattern). //! //! It is purely additive: `/ws/sensing` and every existing endpoint are //! unchanged. The conversion itself lives entirely in the P1 //! [`wifi_densepose_rufield`] anti-corruption bridge (ADR-262 §5.4 — the single //! coupling point); this module only (a) holds the dedicated signer + a bounded //! ring buffer of recent events in server state, (b) builds a //! [`SensingSnapshot`] from the **same real data** the cycle already produced //! (`SensingUpdate` features/classification/signal_field joined with the //! governed-engine [`TrustedOutput`] trust state at `main.rs:~5886`/`:~5938`), //! and (c) applies the §10 network egress gate so above-policy classes never //! reach the wire. //! //! ## Honesty (ADR-262 §0 / §6) //! //! This wires **real** RuView sensing into RuField events on a live endpoint, //! but: (a) it is the **single-link CSI** sensing with its existing caveats — //! there is **no validated room-coordinate accuracy** (`field_localize` says so; //! positions are "strongest field peak", not triangulation); (b) the signing //! key is a **dedicated dev/sensing key** pending the ADR-262 §8 Q1 ownership //! decision (reusing the `cog-ha-matter` Ed25519 key is the **deferred P2** //! call — P3 deliberately uses a standalone key so it does not pre-empt that); //! (c) **no accuracy is claimed.** The win is narrowly: "RuView's live sensing //! now speaks RuField on `/ws/field`." use std::collections::VecDeque; use std::sync::Arc; use axum::{ extract::{ ws::{Message, WebSocket, WebSocketUpgrade}, State, }, response::{IntoResponse, Json}, }; use tokio::sync::{broadcast, RwLock}; // Re-export the bridge input types `main.rs` needs to build a snapshot, so the // server-side call site depends only on `rufield_surface` (the server seam). pub use wifi_densepose_rufield::{ network_egress_allowed, snapshot_to_field_event, FieldEvent, RuViewPrivacyClass, SensingClass, SensingFeatures, SensingSnapshot, Signer, SignalField, }; /// How many recent surfaced `FieldEvent`s the ring buffer retains. Small and /// bounded — this is a live tap, not a store (ADR-262 §4 P3 "small bounded ring /// buffer of recent events"). pub const FIELD_RING_CAPACITY: usize = 64; /// Broadcast channel depth for `/ws/field`. Matches the `/ws/sensing` `tx` /// channel size (256) so a slow field client drops messages rather than /// stalling the sensing loop. pub const FIELD_BROADCAST_CAPACITY: usize = 256; /// Environment variable carrying the 32-byte hex/raw signing seed for the /// dedicated RuField sensing signer. When unset, a deterministic dev default is /// used (with a logged warning). See [`FieldSurface::from_env`]. pub const SIGNING_SEED_ENV: &str = "WDP_RUFIELD_SIGNING_SEED"; /// Deterministic dev signing seed used when [`SIGNING_SEED_ENV`] is unset. This /// is a **dev/sensing key**, intentionally standalone (ADR-262 §8 Q1 — the /// `cog-ha-matter` key reuse is the deferred P2 decision, not pre-empted here). const DEV_SIGNING_SEED: &[u8; 32] = b"adr262-ruview-rufield-dev-seed!!"; /// The live RuField surface state held in `AppStateInner` (ADR-262 P3). /// /// Owns the **dedicated** ed25519 [`Signer`], a bounded ring buffer of the most /// recent network-surfaced events, and the `/ws/field` broadcast sender. pub struct FieldSurface { signer: Signer, /// Bounded ring of recent **network-surfaced** events (most recent last). recent: VecDeque, /// Broadcast topic for `/ws/field` (JSON-serialized `FieldEvent`s). tx: broadcast::Sender, /// True when the dev default seed is in use (drives a one-time warning and /// is surfaced in `/api/field` metadata so operators can see they are on a /// dev key). using_dev_key: bool, } impl FieldSurface { /// Build a surface with an explicit 32-byte seed (deterministic signer). #[must_use] pub fn from_seed(seed: &[u8; 32], using_dev_key: bool) -> Self { let (tx, _rx) = broadcast::channel(FIELD_BROADCAST_CAPACITY); Self { signer: Signer::from_seed(seed), recent: VecDeque::with_capacity(FIELD_RING_CAPACITY), tx, using_dev_key, } } /// Build a surface from the environment (ADR-262 §4 P3 / open-question 1). /// /// Reads [`SIGNING_SEED_ENV`] as either a 64-char hex string or a raw 32+ /// byte UTF-8 value (first 32 bytes used). When unset/invalid it falls back /// to the deterministic [`DEV_SIGNING_SEED`] and logs a `WARN` — the key is /// a standalone **dev/sensing** key, NOT the deferred-P2 `cog-ha-matter` /// key. #[must_use] pub fn from_env() -> Self { match std::env::var(SIGNING_SEED_ENV).ok().and_then(|v| parse_seed(&v)) { Some(seed) => { tracing::info!( "ADR-262 P3: RuField surface using signing seed from {SIGNING_SEED_ENV} \ (dedicated sensing key)" ); Self::from_seed(&seed, false) } None => { tracing::warn!( "ADR-262 P3: {SIGNING_SEED_ENV} unset/invalid — RuField surface using the \ DETERMINISTIC DEV signing key. This is a dev/sensing key pending the \ ADR-262 §8 Q1 (P2) key-ownership decision; set {SIGNING_SEED_ENV} (64-hex \ or 32-byte value) for a real deployment." ); Self::from_seed(DEV_SIGNING_SEED, true) } } } /// The public key of the dedicated signer (hex), so consumers can verify /// receipts without the private seed. #[must_use] pub fn signer_pubkey_hex(&self) -> String { self.signer.public_hex() } /// Whether the dev default key is in use. #[must_use] pub fn using_dev_key(&self) -> bool { self.using_dev_key } /// A `/ws/field` subscription. #[must_use] pub fn subscribe(&self) -> broadcast::Receiver { self.tx.subscribe() } /// The most recent surfaced events, oldest→newest. #[must_use] pub fn recent(&self) -> Vec { self.recent.iter().cloned().collect() } /// Convert one cycle's [`SensingSnapshot`] into a signed [`FieldEvent`], /// apply the §10 network egress gate, and — **iff** the event may leave the /// box — push it into the ring + broadcast it on `/ws/field`. /// /// Returns `Some(event)` when an event was surfaced, `None` when the cycle /// was held edge-local (above network policy — e.g. a `Derived → P4/P5` /// cycle) or carried no presence. Two structural guarantees live here, so /// they hold regardless of caller: /// /// - **no phantom events** — a no-presence cycle (`presence == false`) /// surfaces nothing (ADR-262 §4 P3 / §6); there is no person to describe. /// - **privacy-safety pin** — above-policy classes (P0, P3–P5) are never /// placed on the network surface; only egress-safe P1/P2 events leave. pub fn emit(&mut self, snap: &SensingSnapshot) -> Option { // No-presence ⇒ no phantom event (fabricating one would be dishonest). if !snap.classification.presence { return None; } let event = snapshot_to_field_event(snap, &self.signer); // §10 network egress gate (ADR-262 §4 P3): only P1/P2 leave the box by // default; P0 raw and P3/P4/P5 (above the default P2 ceiling, or // identity/biometric) are held edge-local. A `Derived` cycle is P4/P5 // ⇒ never surfaced as a low-privacy network event. if !network_egress_allowed(event.observation.privacy_class, snap.identity_bound) { tracing::trace!( privacy_class = ?event.observation.privacy_class, "ADR-262 P3: cycle held edge-local (above network policy), not surfaced on /api/field" ); return None; } if self.recent.len() == FIELD_RING_CAPACITY { self.recent.pop_front(); } self.recent.push_back(event.clone()); if let Ok(json) = serde_json::to_string(&event) { let _ = self.tx.send(json); } Some(event) } } /// Parse [`SIGNING_SEED_ENV`] as 64-char hex or a raw 32+ byte UTF-8 value. fn parse_seed(v: &str) -> Option<[u8; 32]> { let v = v.trim(); // 64 hex chars → 32 bytes. if v.len() == 64 && v.bytes().all(|b| b.is_ascii_hexdigit()) { let mut out = [0u8; 32]; for (i, chunk) in v.as_bytes().chunks(2).enumerate() { let hi = (chunk[0] as char).to_digit(16)?; let lo = (chunk[1] as char).to_digit(16)?; out[i] = ((hi << 4) | lo) as u8; } return Some(out); } // Otherwise: first 32 bytes of the raw value (must be at least 32 long so a // short/typo'd value fails closed to the dev key rather than a weak key). let bytes = v.as_bytes(); if bytes.len() >= 32 { let mut out = [0u8; 32]; out.copy_from_slice(&bytes[..32]); return Some(out); } None } /// Build a [`SensingSnapshot`] from the real per-cycle values (ADR-262 P3 §4.2). /// /// This is the join the ADR mandates: `SensingUpdate` features / classification /// / signal-field **plus** the governed engine's `effective_class` / `demoted` /// / `identity_bound` trust state. All inputs are the same real data the cycle /// already computed — nothing is fabricated. `signal_field` is passed through as /// the honest "strongest field peak" readout (no calibrated coordinates). #[allow(clippy::too_many_arguments)] #[must_use] pub fn build_snapshot( timestamp_ns: u64, node_id: String, features: SensingFeatures, classification: SensingClass, signal_field: Option, trust_class: RuViewPrivacyClass, demoted: bool, identity_bound: bool, ) -> SensingSnapshot { SensingSnapshot { timestamp_ns, features, classification, signal_field, trust_class, demoted, identity_bound, node_id, } } /// Map RuView's live governed-engine `bfld::PrivacyClass` (the `effective_class` /// on `TrustedOutput`) onto the bridge's [`RuViewPrivacyClass`] input. /// /// This is a **lossless, same-meaning** re-encoding of the four byte-level /// classes — both enums are `Raw/Derived/Anonymous/Restricted` in the same /// order. It exists only so `main.rs` can pass the engine's class into the /// bridge without the bridge depending on `wifi-densepose-bfld` (keeping it an /// anti-corruption layer, ADR-262 §5.4). The information-content privacy /// mapping (the §3.3 correctness item) happens *inside* the bridge. #[must_use] pub fn ruview_class_from_bfld(class: wifi_densepose_bfld::PrivacyClass) -> RuViewPrivacyClass { use wifi_densepose_bfld::PrivacyClass as B; match class { B::Raw => RuViewPrivacyClass::Raw, B::Derived => RuViewPrivacyClass::Derived, B::Anonymous => RuViewPrivacyClass::Anonymous, B::Restricted => RuViewPrivacyClass::Restricted, } } // ── Handlers ──────────────────────────────────────────────────────────────── /// Shared state for the field surface handlers. Generic over the lock guard so /// the module can be tested in isolation with a tiny state (ADR-262 P3 test /// gate) and wired into the full `AppStateInner` in `main.rs` via an adapter. pub type FieldState = Arc>; /// `GET /api/field` — the most recent network-surfaced `FieldEvent`s as JSON, /// plus surface metadata (the signer pubkey + whether a dev key is in use). /// /// When no event has been surfaced yet (empty room / above-policy cycles only) /// the `events` array is empty — an **explicit empty payload**, never a /// fabricated event (ADR-262 §4 P3 / §6 honesty). pub async fn api_field(State(state): State) -> Json { let s = state.read().await; Json(serde_json::json!({ "spec": "rufield", "endpoint": "/api/field", "signer_pubkey_hex": s.signer_pubkey_hex(), "dev_signing_key": s.using_dev_key(), "events": s.recent(), })) } /// `GET /ws/field` — upgrade to a WebSocket that streams each surfaced /// `FieldEvent` (JSON) as the sensing loop emits it. Mirrors `/ws/sensing`: /// subscribe to the broadcast topic and forward. pub async fn ws_field(ws: WebSocketUpgrade, State(state): State) -> impl IntoResponse { let rx = { let s = state.read().await; s.subscribe() }; ws.on_upgrade(move |socket| handle_ws_field_client(socket, rx)) } async fn handle_ws_field_client(mut socket: WebSocket, mut rx: broadcast::Receiver) { // Forward broadcast events; exit on client close or fatal lag. loop { match rx.recv().await { Ok(json) => { if socket.send(Message::Text(json)).await.is_err() { break; // client gone } } Err(broadcast::error::RecvError::Lagged(_)) => { // Slow client missed events — keep going from the latest. continue; } Err(broadcast::error::RecvError::Closed) => break, } } } /// Build the additive field-surface router. Mounted into the main HTTP router /// in `main.rs`; also used standalone by the integration tests (ADR-262 P3 /// gate, `tower::oneshot`). #[must_use] pub fn router(state: FieldState) -> axum::Router { use axum::routing::get; axum::Router::new() .route("/api/field", get(api_field)) .route("/ws/field", get(ws_field)) .with_state(state) } #[cfg(test)] mod tests { use super::*; use wifi_densepose_rufield::{is_fusable, PrivacyClass}; fn features() -> SensingFeatures { SensingFeatures { mean_rssi: -55.0, variance: 0.4, motion_band_power: 2.0, breathing_band_power: 0.3, dominant_freq_hz: 0.25, change_points: 1, spectral_power: 3.0, } } fn present_class() -> SensingClass { SensingClass { motion_level: "low".into(), presence: true, confidence: 0.82, } } #[test] fn parse_seed_hex_and_raw_and_short() { // 64 hex chars → 32 bytes. let hex = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"; let parsed = parse_seed(hex).expect("valid hex seed"); assert_eq!(parsed[0], 0x00); assert_eq!(parsed[31], 0xff); // Raw 32-byte value. assert!(parse_seed("0123456789abcdef0123456789abcdef").is_some()); // Too short → fail closed (None → dev key). assert!(parse_seed("short").is_none()); } #[test] fn anonymous_cycle_surfaces_fusable_event() { let mut surface = FieldSurface::from_seed(DEV_SIGNING_SEED, true); let snap = build_snapshot( 1_791_986_400_000_000_000, "esp32_room_01".into(), features(), present_class(), None, RuViewPrivacyClass::Anonymous, // → P2, network-allowed false, false, ); let ev = surface.emit(&snap).expect("anonymous P2 cycle is surfaced"); assert_eq!(ev.observation.privacy_class, PrivacyClass::P2); assert!(is_fusable(&ev), "live event must be ed25519-signed & fusable"); assert_eq!(surface.recent().len(), 1); } #[test] fn derived_cycle_never_surfaces_low_privacy() { // The privacy-safety pin: a Derived (identity) cycle maps to P4/P5 and // is held edge-local — it must NEVER appear on the network surface. let mut surface = FieldSurface::from_seed(DEV_SIGNING_SEED, true); for identity_bound in [false, true] { let snap = build_snapshot( 1_791_986_400_000_000_000, "esp32_room_01".into(), features(), present_class(), None, RuViewPrivacyClass::Derived, false, identity_bound, ); assert!( surface.emit(&snap).is_none(), "Derived cycle (identity_bound={identity_bound}) must be held edge-local" ); } assert!(surface.recent().is_empty(), "no Derived event may reach the surface"); } #[test] fn ring_buffer_is_bounded() { let mut surface = FieldSurface::from_seed(DEV_SIGNING_SEED, true); for i in 0..(FIELD_RING_CAPACITY + 10) { let snap = build_snapshot( 1_791_986_400_000_000_000 + i as u64, "esp32_room_01".into(), features(), present_class(), None, RuViewPrivacyClass::Anonymous, false, false, ); surface.emit(&snap); } assert_eq!(surface.recent().len(), FIELD_RING_CAPACITY); } }