440 lines
17 KiB
Rust
440 lines
17 KiB
Rust
//! 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<FieldEvent>,
|
||
/// Broadcast topic for `/ws/field` (JSON-serialized `FieldEvent`s).
|
||
tx: broadcast::Sender<String>,
|
||
/// 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<String> {
|
||
self.tx.subscribe()
|
||
}
|
||
|
||
/// The most recent surfaced events, oldest→newest.
|
||
#[must_use]
|
||
pub fn recent(&self) -> Vec<FieldEvent> {
|
||
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<FieldEvent> {
|
||
// 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<SignalField>,
|
||
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<RwLock<FieldSurface>>;
|
||
|
||
/// `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<FieldState>) -> Json<serde_json::Value> {
|
||
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<FieldState>) -> 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<String>) {
|
||
// 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);
|
||
}
|
||
}
|