diff --git a/v2/crates/wifi-densepose-rufield/src/bridge.rs b/v2/crates/wifi-densepose-rufield/src/bridge.rs index 32b2b8be59..416f449487 100644 --- a/v2/crates/wifi-densepose-rufield/src/bridge.rs +++ b/v2/crates/wifi-densepose-rufield/src/bridge.rs @@ -93,8 +93,10 @@ fn event_id(snap: &SensingSnapshot) -> String { /// 3. Stamps the §3.3 egress privacy class (information-content mapping with /// the demotion floor) on both tensor and observation. /// 4. Builds a real `ProvenanceRef` (sha256 raw hash over the tensor/feature -/// bytes, `synthetic = false`) and **signs** it with the supplied ed25519 -/// [`Signer`] so `rufield_provenance::is_fusable` passes. +/// bytes, `synthetic` stamped from `snap.synthetic` — the caller's, not +/// this bridge's, judgment of where the cycle came from) and **signs** it +/// with the supplied ed25519 [`Signer`] so `rufield_provenance::is_fusable` +/// passes regardless of which way `synthetic` reads. /// /// Determinism: with no RNG anywhere and a deterministic ed25519 signer, the /// same `snap` + same signer seed yields a byte-identical event. @@ -171,14 +173,14 @@ pub fn snapshot_to_field_event(snap: &SensingSnapshot, signer: &Signer) -> Field firmware_hash: firmware_hash(), model_id: MODEL_ID.to_string(), calibration_id, - synthetic: false, // a real (non-synthetic) live/replay event + synthetic: snap.synthetic, signature_hex: None, signer_pubkey_hex: None, }; let sensor = SensorDescriptor { modality: "wifi_csi".to_string(), - vendor: "esp32".to_string(), + vendor: if snap.synthetic { "simulated" } else { "esp32" }.to_string(), device_id: snap.node_id.clone(), placement: "unknown".to_string(), // Optional sensor pose, added upstream. Left unset on purpose: a CSI diff --git a/v2/crates/wifi-densepose-rufield/src/lib.rs b/v2/crates/wifi-densepose-rufield/src/lib.rs index 1925342c0d..140c7995bc 100644 --- a/v2/crates/wifi-densepose-rufield/src/lib.rs +++ b/v2/crates/wifi-densepose-rufield/src/lib.rs @@ -59,6 +59,7 @@ //! demoted: false, //! identity_bound: false, //! node_id: "esp32_room_01".into(), +//! synthetic: false, //! }; //! //! let signer = Signer::from_seed(b"adr-262-bridge-seed-32-bytes-ok!"); diff --git a/v2/crates/wifi-densepose-rufield/src/snapshot.rs b/v2/crates/wifi-densepose-rufield/src/snapshot.rs index 12347bed7f..2336246b83 100644 --- a/v2/crates/wifi-densepose-rufield/src/snapshot.rs +++ b/v2/crates/wifi-densepose-rufield/src/snapshot.rs @@ -149,4 +149,11 @@ pub struct SensingSnapshot { pub identity_bound: bool, /// Stable node id (e.g. `"esp32_room_01"`). pub node_id: String, + /// Whether this cycle came from real hardware/replay or a synthetic demo + /// source (RuView's `--source simulated` / Docker demo mode). Stamped + /// verbatim onto the emitted event's `ProvenanceRef.synthetic` — this + /// bridge does not get to assume "real" by default; the caller who knows + /// where the cycle actually came from must say so. + #[serde(default)] + pub synthetic: bool, } diff --git a/v2/crates/wifi-densepose-rufield/tests/p1_gates.rs b/v2/crates/wifi-densepose-rufield/tests/p1_gates.rs index f467d8ed92..14a83d567b 100644 --- a/v2/crates/wifi-densepose-rufield/tests/p1_gates.rs +++ b/v2/crates/wifi-densepose-rufield/tests/p1_gates.rs @@ -47,6 +47,7 @@ fn sample_snapshot() -> SensingSnapshot { demoted: false, identity_bound: false, node_id: "esp32_room_01".into(), + synthetic: false, } } @@ -68,6 +69,30 @@ fn gate_is_fusable_verified_receipt() { assert!(is_fusable(&ev), "verified receipt ⇒ fusable (§11 invariant)"); } +/// Regression guard: `synthetic` must be a straight pass-through of the +/// caller's `SensingSnapshot.synthetic`, never hardcoded. Before this fix the +/// bridge always stamped `synthetic: false`, so a simulated/demo-source +/// snapshot produced an event indistinguishable from real hardware — a +/// dishonest claim this crate's own docs (§0 / §6) exist to prevent. +#[test] +fn gate_synthetic_flag_passes_through_honestly() { + let mut snap = sample_snapshot(); + + snap.synthetic = true; + let ev = snapshot_to_field_event(&snap, &signer()); + assert!( + ev.provenance.synthetic, + "a snapshot marked synthetic must produce a synthetic-marked event" + ); + + snap.synthetic = false; + let ev = snapshot_to_field_event(&snap, &signer()); + assert!( + !ev.provenance.synthetic, + "a snapshot marked non-synthetic must produce a non-synthetic-marked event" + ); +} + #[test] fn gate_fusion_ingest_accepts_and_infers() { let ev = snapshot_to_field_event(&sample_snapshot(), &signer()); diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index 0b35a5c6a5..6259fa20e3 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -4694,7 +4694,7 @@ fn derive_single_person_pose( /// / AETHER concern, ADR-262 §8 Q4). This is conservative for egress — it only /// ever *lowers* a Derived cycle from P5 to P4, both of which are already held /// edge-local, so it cannot leak. -fn emit_rufield_event(s: &AppStateInner, update: &SensingUpdate, node_id: u8) { +fn emit_rufield_event(s: &AppStateInner, update: &SensingUpdate, node_id: u8, synthetic: bool) { // No-presence ⇒ no phantom event. if !update.classification.presence { return; @@ -4713,9 +4713,10 @@ fn emit_rufield_event(s: &AppStateInner, update: &SensingUpdate, node_id: u8) { .unwrap_or(0) }; + let node_prefix = if synthetic { "simulated_node" } else { "esp32_node" }; let snap = rufield_surface::build_snapshot( timestamp_ns, - format!("esp32_node_{node_id}"), + format!("{node_prefix}_{node_id}"), rufield_surface::SensingFeatures { mean_rssi: update.features.mean_rssi, variance: update.features.variance, @@ -4737,6 +4738,7 @@ fn emit_rufield_event(s: &AppStateInner, update: &SensingUpdate, node_id: u8) { rufield_surface::ruview_class_from_bfld(effective_class), s.engine_bridge.demoted(), false, // identity_bound — see fn-doc (conservative, cannot leak). + synthetic, ); // `field_surface` is its own Arc>; `try_write` is non-blocking and @@ -7044,7 +7046,7 @@ async fn udp_receiver_task( // whose mapped privacy class clears the §10 network egress // gate are surfaced (P1/P2); a `Derived → P4/P5` cycle is // held edge-local. `presence == false` ⇒ no phantom event. - emit_rufield_event(&s, &update, node_id); + emit_rufield_event(&s, &update, node_id, false); observe_sensing_update(s.latest_update.as_ref(), &update); s.latest_update = Some(update); @@ -7148,6 +7150,10 @@ fn observe_sensing_update(prev: Option<&SensingUpdate>, update: &SensingUpdate) // ── Simulated data task ────────────────────────────────────────────────────── +/// Node id the simulated demo source publishes under (`NodeInfo.node_id` and +/// the mirrored `node_states` entry both use this — see `simulated_data_task`). +const SIMULATED_NODE_ID: u8 = 1; + async fn simulated_data_task(state: SharedState, tick_ms: u64) { let mut interval = tokio::time::interval(Duration::from_millis(tick_ms)); info!("Simulated data source active (tick={}ms)", tick_ms); @@ -7179,6 +7185,27 @@ async fn simulated_data_task(state: SharedState, tick_ms: u64) { s.frame_history.pop_front(); } + // Mirror this frame into `node_states` under a synthetic node id + // (matching the `NodeInfo.node_id` this task publishes below). Without + // this, `node_states` stays empty forever under `--source simulated`: + // it is otherwise populated only by the real ESP32 UDP ingestion path, + // so the governed trust engine (`engine_bridge.observe_cycle`, driven + // by `node_states`) never produces an `effective_class`, and + // `emit_rufield_event` — gated on exactly that — silently never fires. + // `/api/field` / `/ws/field` would report zero events forever under + // the project's own documented "no hardware, Docker demo" quick start. + { + let ns = s + .node_states + .entry(SIMULATED_NODE_ID) + .or_insert_with(NodeState::new); + ns.frame_history.push_back(frame.amplitudes.clone()); + if ns.frame_history.len() > FRAME_HISTORY_CAPACITY { + ns.frame_history.pop_front(); + } + ns.last_frame_time = Some(std::time::Instant::now()); + } + let sample_rate_hz = 1000.0 / tick_ms as f64; let (features, mut classification, breathing_rate_hz, sub_variances, raw_motion) = extract_features_from_frame(&frame, &s.frame_history, sample_rate_hz); @@ -7230,7 +7257,7 @@ async fn simulated_data_task(state: SharedState, tick_ms: u64) { source: "simulated".to_string(), tick, nodes: vec![NodeInfo { - node_id: 1, + node_id: SIMULATED_NODE_ID, rssi_dbm: features.mean_rssi, position: [2.0, 0.0, 1.5], amplitude: frame_amplitudes, @@ -7295,6 +7322,21 @@ async fn simulated_data_task(state: SharedState, tick_ms: u64) { if update.classification.presence { s.total_detections += 1; } + + // Governed trust cycle + signed RuField emission (ADR-135..146 / + // ADR-262 P3), mirroring the real ESP32 UDP path so the simulated + // demo source can honestly exercise the same governance + `/api/field` + // surface instead of always reporting zero events. + { + let sref: &mut AppStateInner = &mut s; + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0); + sref.engine_bridge.observe_cycle(&sref.node_states, now_ms); + } + emit_rufield_event(&s, &update, SIMULATED_NODE_ID, true); + if let Ok(json) = serde_json::to_string(&update) { let _ = s.tx.send(json); } diff --git a/v2/crates/wifi-densepose-sensing-server/src/rufield_surface.rs b/v2/crates/wifi-densepose-sensing-server/src/rufield_surface.rs index 26c614feb7..bff075672e 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/rufield_surface.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/rufield_surface.rs @@ -239,6 +239,7 @@ pub fn build_snapshot( trust_class: RuViewPrivacyClass, demoted: bool, identity_bound: bool, + synthetic: bool, ) -> SensingSnapshot { SensingSnapshot { timestamp_ns, @@ -249,6 +250,7 @@ pub fn build_snapshot( demoted, identity_bound, node_id, + synthetic, } } @@ -387,6 +389,7 @@ mod tests { RuViewPrivacyClass::Anonymous, // → P2, network-allowed false, false, + false, ); let ev = surface.emit(&snap).expect("anonymous P2 cycle is surfaced"); assert_eq!(ev.observation.privacy_class, PrivacyClass::P2); @@ -409,6 +412,7 @@ mod tests { RuViewPrivacyClass::Derived, false, identity_bound, + false, ); assert!( surface.emit(&snap).is_none(), @@ -431,6 +435,7 @@ mod tests { RuViewPrivacyClass::Anonymous, false, false, + false, ); surface.emit(&snap); } diff --git a/v2/crates/wifi-densepose-sensing-server/tests/rufield_surface_test.rs b/v2/crates/wifi-densepose-sensing-server/tests/rufield_surface_test.rs index e4f10ca24e..2dd3d015da 100644 --- a/v2/crates/wifi-densepose-sensing-server/tests/rufield_surface_test.rs +++ b/v2/crates/wifi-densepose-sensing-server/tests/rufield_surface_test.rs @@ -78,6 +78,7 @@ async fn inject(state: &FieldState, trust: RuViewPrivacyClass, presence: bool, i trust, false, // demoted identity_bound, + false, // synthetic ); state.write().await.emit(&snap); }