From d2089c342a7e1627a89cd014f0ce33633ffd4b35 Mon Sep 17 00:00:00 2001 From: rUv Date: Sun, 14 Jun 2026 15:32:24 -0400 Subject: [PATCH] fix(engine security): close witness domain-separation collision in governed-trust cycle + prove privacy monotonicity (#1074) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(engine): length-prefix witness fields to close domain-separation collision The BLAKE3 trust witness concatenated model_version, calibration_version, and privacy_decision boundary-to-boundary, with the variable-length evidence list lacking an explicit count. A string straddling a field boundary (e.g. a per-room adapter id absorbing the leading bytes of the calibration epoch, or a model_version absorbing a trailing evidence ref) collided with a different trust decision — silently un-distinguishing two distinct privacy-relevant inputs and defeating the ADR-137 tamper/drift audit guarantee. model_version is operator-influenceable via the adapter id (ADR-150 §3.4), so the ambiguity was reachable. Fix: domain-tag the hash and length-prefix every field (8-byte LE length), plus an explicit evidence count. Pinned by two fails-on-old tests: witness_distinguishes_model_calibration_boundary and witness_distinguishes_evidence_model_boundary. Co-Authored-By: claude-flow * test(engine): pin privacy monotonicity, fail-closed boundaries; de-magic constants Review hardening for the governed-trust cycle (no behavior change): - forced_contradiction_never_relaxes_class: property test over all 5 privacy modes proving a forced contradiction only ever raises the emitted class byte (more restrictive) and a clean cycle emits exactly the base class — the ADR-141/120 information-only-removed invariant. - empty_cycle_fails_closed: a zero-frame cycle errors (fusion NoFrames), emits no SemanticState, and does not advance the cycle counter. - single_node_cycle_is_well_formed: characterizes the n=1 boundary (no mesh, no directional, base class, witness still emitted) — documents single-node sensing as a valid non-demoting mode, not a bypass. - De-magicked the engine-construction literals (coherence accept gate, ADR-143 SLAM discovery + static-anchor thresholds) into named documented consts, value-identical, pinned by engine_constants_match_prior_values. Co-Authored-By: claude-flow * docs(engine-review): record witness domain-separation fix + monotonicity clean bill CHANGELOG [Unreleased] Security entry and review notes appended to ADR-137 (witness domain-separation fix) and ADR-141 (privacy monotonicity confirmed clean over all 5 modes, fail-closed boundaries pinned). Co-Authored-By: claude-flow --- CHANGELOG.md | 1 + ...-fusion-engine-quality-scoring-evidence.md | 31 +++ ...privacy-control-plane-modes-attestation.md | 30 +++ v2/crates/wifi-densepose-engine/src/lib.rs | 249 +++++++++++++++++- 4 files changed, 303 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dcab62ba..88aa103d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **ADR-260: RuField MFS — the open specification for camera-free multimodal field sensing.** A common event / tensor / calibration / privacy / provenance model that sits *above* WiFi CSI/CIR/BFLD, UWB, BLE Channel Sounding, mmWave radar, ultrasound, subsonic, infrared, and future quantum sensors (each modality emits a normalized `FieldEvent` → `FieldTensor` → `FusionGraph` → `PrivacyClass` → `ProvenanceReceipt`). Published as a **standalone repo** [`ruvnet/rufield`](https://github.com/ruvnet/rufield) and vendored here as the `vendor/rufield` submodule (the `vendor/rvcsi` pattern — not a `v2/` workspace member). The v0.1 reference stack is a self-contained 6-crate Rust workspace (`rufield-core`, `-provenance` [sha256 + ed25519], `-privacy` [P0–P5 guard], `-adapters` [deterministic `SyntheticSim` across wifi_csi/mmwave_radar/infrared_thermal], `-fusion` [graph + TOML weighted-Bayes rules → 7 room-state inferences], `-bench` [deterministic runner + the §31 acceptance test]). **60 tests / 0 failed, clippy-clean.** §27 acceptance criteria 1–8 and 10 PASS; the live dashboard (9) is deferred. **All benchmark metrics are SYNTHETIC** (scored against the simulator's own ground truth — presence/breathing/bed_exit/room_transition F1 = 1.000, nocturnal_scratch 0.923 reported honestly, p95 latency ~0.01 ms, provenance coverage 100%, 0 privacy violations) — they prove the pipeline recovers known truth, **not** field accuracy; real hardware adapters (ESP32 CSI, mmWave, thermal IR) are a documented roadmap item, none validated in v0.1. The Python deterministic proof is unchanged (rufield is off the signal-processing proof path). ### Security +- **`wifi-densepose-engine` governed-trust review — witness domain-separation gap FIXED + privacy monotonicity confirmed clean (ADR-137 / ADR-141 / ADR-032).** Beyond-SOTA correctness+security review of the security-critical composition root (the cycle enforcing RuView's privacy guarantees), not covered by the ADR-154–159 sweep. **One real witness-integrity bug fixed.** `witness_of` concatenated `model_version`, `calibration_version`, and `privacy_decision` boundary-to-boundary and left the variable-length evidence list without a count, so a string straddling a field boundary collided with a *different* trust decision — e.g. a per-room adapter id (ADR-150 §3.4, operator-influenceable) absorbing the leading bytes of the calibration epoch (`model="…cal:00a"`,`cal="b"`) yields the same witness as `model="…"`,`cal="cal:00ab"`. Two distinct privacy-relevant input tuples → one witness defeats the ADR-137 §2.7 "any privacy-relevant delta → different witness" tamper/drift audit. **Fix:** domain-tag the BLAKE3 hash (`ruview.engine.witness.v1`), write an explicit evidence count, and **length-prefix every field** (8-byte LE length ‖ bytes) — unambiguous framing regardless of contents. Witness-layout change by design (prior witness bytes invalidated); downstream consumers (`engine_bridge`, rufield) assert only witness *relationships* (`assert_ne`/`assert_eq` across runs), never absolute bytes, so nothing breaks. Pinned by two fails-on-old tests: `witness_distinguishes_model_calibration_boundary`, `witness_distinguishes_evidence_model_boundary`. **Dimensions confirmed clean (with evidence, no invented issues):** (1) **privacy monotonicity** — `effective_class` is recomputed each cycle from the active mode's floor with at most a single-step `demote_one` (clamped at `Restricted`), no cross-cycle state, proven over **all 5 modes** by `forced_contradiction_never_relaxes_class` (forced contradiction only ever raises the class byte; clean cycle == base); (2) **fail-closed** — empty cycle errors with no degenerate output (`empty_cycle_fails_closed`), single-node boundary characterized (`single_node_cycle_is_well_formed`), NaN coupling → `max(0.0)`→absent edge→at-risk (more restrictive); (3) **witness determinism** — no HashMap iteration / float formatting feeds the hash; (4) **mesh_guard** (ADR-032) — partition-risk → demotion path verified, thresholds already named documented fields. De-magicked the engine-construction literals (coherence accept gate, ADR-143 SLAM discovery + static-anchor thresholds) into named documented consts, value-identical, pinned by `engine_constants_match_prior_values`. `wifi-densepose-engine --no-default-features`: **27→33 tests**, 0 failed (+2 witness, +1 monotonicity property, +2 fail-closed boundary, +1 de-magic pin). Python deterministic proof unchanged (`f8e76f21…46f7a`, bit-exact — the engine is off the signal proof path). Review notes appended to ADR-137 (witness) and ADR-141 (monotonicity). - **ADR-157 Milestone-1 B4 - constant-time HMAC sync-beacon tag compare (`wifi-densepose-hardware`).** `AuthenticatedBeacon::verify` compared the 8-byte HMAC-SHA256 tag with `self.hmac_tag == expected`, which short-circuits on the first differing byte and leaks, through verification latency, how many leading bytes an attacker's forged tag matched - a byte-by-byte tag-recovery oracle (~256*N trials instead of 256^N). Replaced with a hand-rolled branch-free `constant_time_tag_eq` (XOR-accumulate every byte difference into a single `u8`, no early exit, `#[inline(never)]` + `core::hint::black_box` to stop the optimizer reintroducing a short-circuit or a non-constant-time `memcmp`). **No new dependency** - ADR-157 had deferred this only to avoid adding the `subtle` crate; a fixed 8-byte compare needs none. Grade MEASURED (constant-time *construction*; micro-timing on a noisy host is a smoke check only, gated `#[ignore]`). Pinned by `tag_compare_is_constant_time_shape` (equal/first-differ/last-differ/all-differ/length-mismatch + an end-to-end `verify()` last-byte tamper), proven to fail on a last-byte-skipping constant-time bug. ADR-157 §8 B4 -> RESOLVED. - **ADR-080 open HIGH findings closed on the Rust `wifi-densepose-sensing-server` boundary (ADR-164 G11).** The QE sweep's three HIGH findings — XFF-spoofing bypass, leaked stack traces, JWT-in-URL (CWE-598) — were logged against the Python v1 API and never re-verified against the shipped Rust sensing-server; the HOMECORE/M7 sweep (ADR-161) covered `homecore-server`, not this crate. - **#2 leaked internal errors (the one live exposure) — FIXED.** Six handlers in `main.rs` serialized the internal error `Display` straight into the JSON response body: `edge_registry_endpoint` returned a panicked `spawn_blocking` `JoinError` (`"task … panicked"`) in a `500`, plus the raw upstream error in a `503`; `delete_model`/`delete_recording`/`start_recording` returned `std::io::Error` strings (OS detail / path); `calibration_start`/`calibration_stop` returned the `FieldModel` error chain. New `error_response` module logs the full detail **server-side only** (with a correlation id) and returns a generic body (`{"error":"internal_error","correlation_id":…}`) — no `panicked`, no file paths, no Debug chain. 5 module tests (a leak-substring guard proven to fail on the reverted old body) + the existing handler suite. diff --git a/docs/adr/ADR-137-fusion-engine-quality-scoring-evidence.md b/docs/adr/ADR-137-fusion-engine-quality-scoring-evidence.md index fd3c8657..af0028cd 100644 --- a/docs/adr/ADR-137-fusion-engine-quality-scoring-evidence.md +++ b/docs/adr/ADR-137-fusion-engine-quality-scoring-evidence.md @@ -495,3 +495,34 @@ Rejected. `ViewpointFusionEvent` (viewpoint/fusion.rs lines 183–219) is an int **Integration glue -- not yet on the live path:** emission of `CalibrationIdMismatch` / `DriftProfileConflict` / `PhaseAlignmentFailed` once `calibration_id` propagation and the phase-align convergence signal are threaded onto frames; the BFLD witness record emitted on privacy demotion. **Trust contribution:** sensor *agreement made explicit* -- fusion records the evidence it relied on, and any disagreement automatically tightens the downstream privacy class. + +--- + +## Witness Integrity Review (2026-06-14) — domain-separation fix + +A beyond-SOTA security review of `wifi-densepose-engine` (the composition root +that builds the §2.7 trust witness in `witness_of`) found a real **witness +domain-separation gap**, now fixed. + +**Finding (witness-gap, HIGH).** `witness_of` concatenated `model_version`, +`calibration_version`, and `privacy_decision` boundary-to-boundary, and the +variable-length `evidence` list carried no explicit count. A string straddling a +field boundary therefore collided with a *different* trust decision — +e.g. a per-room adapter id (ADR-150 §3.4, operator-influenceable) that absorbs +the leading bytes of the calibration epoch (`model="…cal:00a"`, `cal="b"`) +produces the **same** witness as `model="…"`, `cal="cal:00ab"`. Two distinct +privacy-relevant input tuples → one witness defeats the "any privacy-relevant +delta → different witness" guarantee this ADR's §2.7 witness exists to provide. + +**Fix.** The witness now (a) prepends a domain tag `ruview.engine.witness.v1`, +(b) writes an explicit 8-byte evidence count, and (c) **length-prefixes every +field** (8-byte LE length ‖ bytes), so field framing is unambiguous regardless +of contents. This is a witness-layout change (all prior witness bytes are +invalidated by design); downstream consumers only assert witness *relationships* +(`assert_ne`/`assert_eq` across runs), not absolute bytes, so nothing breaks. + +Pinned by `witness_distinguishes_model_calibration_boundary` and +`witness_distinguishes_evidence_model_boundary` (both fail on the old +concatenation). Witness **determinism** was reviewed and confirmed clean: no +HashMap iteration and no float formatting feed the hash (floats appear only in +the `SemanticState` statement, which is outside the witness). diff --git a/docs/adr/ADR-141-bfld-privacy-control-plane-modes-attestation.md b/docs/adr/ADR-141-bfld-privacy-control-plane-modes-attestation.md index 9d3d1e57..870bb662 100644 --- a/docs/adr/ADR-141-bfld-privacy-control-plane-modes-attestation.md +++ b/docs/adr/ADR-141-bfld-privacy-control-plane-modes-attestation.md @@ -599,3 +599,33 @@ Per ADR-028/ADR-010, three rows are added to the witness log: **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. + +--- + +## Privacy Monotonicity Review (2026-06-14) — confirmed clean + +A beyond-SOTA security review of the governed-trust cycle +(`wifi-densepose-engine::StreamingEngine::process_cycle_calibrated`) examined +the privacy-demotion path this ADR governs. **The monotonicity invariant holds: +demotion only ever makes the emitted class more restrictive, never less.** + +Verification (no behaviour change, the result is a clean bill with evidence): + +- Each cycle computes `effective_class` fresh from the active mode's + `target_class()` (the floor) and applies at most a **single-step** demotion + (`demote_one`, clamped at `Restricted`). There is no cross-cycle state that + could let a permissive class overwrite a restrictive one. +- A forced contradiction (calibration mismatch / array-geometry insufficiency / + mesh partition risk, ADR-032) raises the class byte; a clean cycle emits + exactly the base class. +- Pinned by `forced_contradiction_never_relaxes_class`, a property test over + **all five** `PrivacyMode`s asserting `effective_class.as_u8() >= + base_class.as_u8()` (strictly greater unless already clamped at `Restricted`) + under a forced contradiction, and `== base` on a clean cycle. + +Fail-closed boundaries were also pinned: an empty cycle errors (no degenerate +over-permissive output, `empty_cycle_fails_closed`) and the single-node boundary +is characterized as a valid non-demoting mode (`single_node_cycle_is_well_formed`). + +The related witness domain-separation fix from the same review is recorded in +ADR-137 (the witness folds `effective_class`, so the demotion is auditable). diff --git a/v2/crates/wifi-densepose-engine/src/lib.rs b/v2/crates/wifi-densepose-engine/src/lib.rs index bc2ac1c6..d80b0031 100644 --- a/v2/crates/wifi-densepose-engine/src/lib.rs +++ b/v2/crates/wifi-densepose-engine/src/lib.rs @@ -205,7 +205,7 @@ impl StreamingEngine { pub fn new(mode: PrivacyMode, model_version: u16, registration: GeoRegistration) -> Self { Self { fuser: MultistaticFuser::with_config(MultistaticConfig::default()), - coherence_accept: 0.85, + coherence_accept: Self::DEFAULT_COHERENCE_ACCEPT, privacy: PrivacyModeRegistry::new(mode), world: WorldGraph::new(registration), model_version, @@ -213,7 +213,11 @@ impl StreamingEngine { array: ArrayCoordinator::new(ArrayCoordinatorConfig::default()), node_geom: BTreeMap::new(), evolution: None, - slam: RfSlam::with_discovery(0.5, 5, 0.6), + slam: RfSlam::with_discovery( + Self::SLAM_ASSOC_RADIUS_M, + Self::SLAM_MIN_SIGHTINGS, + Self::SLAM_MIN_COHERENCE, + ), person_tracks: BTreeMap::new(), semantic_retention: Self::DEFAULT_SEMANTIC_RETENTION, adapter: None, @@ -257,6 +261,31 @@ impl StreamingEngine { /// durable history belongs to the recorder). pub const DEFAULT_SEMANTIC_RETENTION: usize = 7_200; + /// Cross-node coherence at or above which fusion records a positive + /// `CoherenceGateThreshold` evidence ref (ADR-137). Below it the cycle still + /// emits, but without that corroborating evidence — so this gate shapes the + /// trust record, not the privacy class. (== prior inline 0.85.) + pub const DEFAULT_COHERENCE_ACCEPT: f32 = 0.85; + + /// ADR-143 reflector-discovery parameters used to build the persistent + /// `RfSlam`: association radius (m) within which two sightings are the same + /// reflector, the minimum number of sightings before a reflector is + /// considered stable, and the minimum per-sighting coherence to admit it. + /// (== prior inline `with_discovery(0.5, 5, 0.6)`.) + pub const SLAM_ASSOC_RADIUS_M: f64 = 0.5; + /// Minimum sightings before a discovered reflector is treated as stable. + pub const SLAM_MIN_SIGHTINGS: u64 = 5; + /// Minimum per-sighting coherence to admit a reflector sighting. + pub const SLAM_MIN_COHERENCE: f32 = 0.6; + + /// ADR-143 static-anchor classification thresholds passed to + /// `RfSlam::static_anchors`: the wall/ceiling stationarity ceiling and the + /// mobile-reflector floor (anchors more mobile than this are dropped, not + /// persisted). (== prior inline `static_anchors(0.05, 1.0)`.) + pub const ANCHOR_WALL_CEILING: f64 = 0.05; + /// Mobility floor above which a reflector is treated as mobile (skipped). + pub const ANCHOR_MOBILE_FLOOR: f64 = 1.0; + /// Override the `SemanticState` retention cap (minimum 1). pub fn set_semantic_retention(&mut self, max_states: usize) { self.semantic_retention = max_states.max(1); @@ -331,7 +360,9 @@ impl StreamingEngine { self.slam.observe(obs); } let mut written = Vec::new(); - for (pos, class) in self.slam.static_anchors(0.05, 1.0) { + for (pos, class) in + self.slam.static_anchors(Self::ANCHOR_WALL_CEILING, Self::ANCHOR_MOBILE_FLOOR) + { let kind = match class { wifi_densepose_signal::ruvsense::ReflectorClass::Wall => AnchorKind::Reflector, wifi_densepose_signal::ruvsense::ReflectorClass::Furniture => AnchorKind::Furniture, @@ -595,19 +626,46 @@ impl StreamingEngine { } } +/// Domain-separation tag for the witness hash. Bumping this string +/// intentionally invalidates every previously-recorded witness (a schema break). +const WITNESS_DOMAIN: &[u8] = b"ruview.engine.witness.v1"; + +/// Length-prefix a variable-length field into the witness hash so adjacent +/// fields can never be confused for one another. The 8-byte little-endian +/// length makes the field framing unambiguous regardless of the bytes inside +/// it (a field can contain the separator, the domain tag, anything). +fn witness_field(h: &mut blake3::Hasher, bytes: &[u8]) { + h.update(&(bytes.len() as u64).to_le_bytes()); + h.update(bytes); +} + /// Deterministic BLAKE3 witness over a trust decision: the provenance tuple /// (evidence ‖ model ‖ calibration ‖ privacy decision) plus the effective /// privacy-class byte. Stable across runs for identical decisions — the /// "signed operational belief" fingerprint (ADR-137 §2.7 / ADR-028). +/// +/// # Witness integrity (review finding: domain separation) +/// Every privacy-relevant field is **length-prefixed** before hashing, and the +/// (variable-length) evidence list is preceded by an explicit count. Without +/// this framing the fields were concatenated boundary-to-boundary, so a string +/// straddling a field boundary (e.g. an adapter id absorbing the leading bytes +/// of the calibration epoch, or a model_version absorbing a trailing evidence +/// ref) collided with a *different* trust decision — silently un-distinguishing +/// two distinct privacy-relevant inputs and defeating the tamper/drift audit. +/// `model_version` is operator-influenceable (per-room adapter id, ADR-150 +/// §3.4), so the ambiguity was reachable, not merely theoretical. fn witness_of(p: &SemanticProvenance, class: PrivacyClass) -> [u8; 32] { let mut h = blake3::Hasher::new(); + h.update(WITNESS_DOMAIN); + // Explicit evidence count, then each ref length-prefixed: the number of + // evidence refs is itself privacy-relevant and must be unambiguous. + h.update(&(p.evidence.len() as u64).to_le_bytes()); for e in &p.evidence { - h.update(e.as_bytes()); - h.update(b"\x1f"); + witness_field(&mut h, e.as_bytes()); } - h.update(p.model_version.as_bytes()); - h.update(p.calibration_version.as_bytes()); - h.update(p.privacy_decision.as_bytes()); + witness_field(&mut h, p.model_version.as_bytes()); + witness_field(&mut h, p.calibration_version.as_bytes()); + witness_field(&mut h, p.privacy_decision.as_bytes()); h.update(&[class.as_u8()]); *h.finalize().as_bytes() } @@ -1113,4 +1171,179 @@ mod tests { // StrictNoIdentity base = Restricted, even with no contradiction. assert_eq!(out.effective_class, PrivacyClass::Restricted); } + + /// De-magic pin (review finding): the named engine constants must keep + /// their prior inline values exactly, so the de-magic is a pure rename with + /// no behavior change. + #[test] + fn engine_constants_match_prior_values() { + assert_eq!(StreamingEngine::DEFAULT_COHERENCE_ACCEPT, 0.85); + assert_eq!(StreamingEngine::SLAM_ASSOC_RADIUS_M, 0.5); + assert_eq!(StreamingEngine::SLAM_MIN_SIGHTINGS, 5); + assert_eq!(StreamingEngine::SLAM_MIN_COHERENCE, 0.6); + assert_eq!(StreamingEngine::ANCHOR_WALL_CEILING, 0.05); + assert_eq!(StreamingEngine::ANCHOR_MOBILE_FLOOR, 1.0); + } + + /// Privacy monotonicity (the crux): across EVERY base mode, a forced + /// contradiction may only ever make the emitted class *more* restrictive + /// (higher byte) and never less. Demotion is single-step and clamps at + /// Restricted; a clean cycle emits exactly the base class. This is the + /// information-only-removed invariant of ADR-141/120 stated as a property + /// over the whole mode set. + #[test] + fn forced_contradiction_never_relaxes_class() { + let cal_mismatch = [Some(CalibrationId(1)), Some(CalibrationId(2))]; // disagree → contradiction + let cal_match = [Some(CalibrationId(5)), Some(CalibrationId(5))]; + let frames = [node_frame(0, 1000, 56), node_frame(1, 1001, 56)]; + for mode in [ + PrivacyMode::RawResearch, + PrivacyMode::PrivateHome, + PrivacyMode::EnterpriseAnonymous, + PrivacyMode::CareWithConsent, + PrivacyMode::StrictNoIdentity, + ] { + let base_class = mode.target_class(); + + // Clean cycle: emits exactly the base class (no relaxation upward). + let mut clean = StreamingEngine::new(mode, 1, GeoRegistration::default()); + let room_c = clean.add_room("r", "R"); + let oc = clean + .process_cycle_calibrated(&frames, &cal_match, room_c, 1) + .unwrap(); + assert_eq!(oc.effective_class, base_class, "clean cycle == base class"); + assert!(!oc.demoted); + + // Forced contradiction: class byte only ever increases (more + // restrictive), never decreases below the base. + let mut dirty = StreamingEngine::new(mode, 1, GeoRegistration::default()); + let room_d = dirty.add_room("r", "R"); + let od = dirty + .process_cycle_calibrated(&frames, &cal_mismatch, room_d, 1) + .unwrap(); + assert!(od.demoted, "calibration mismatch must demote in {mode:?}"); + assert!( + od.effective_class.as_u8() >= base_class.as_u8(), + "demotion must never relax: {mode:?} base={:?} got={:?}", + base_class, + od.effective_class + ); + // And it must be strictly more restrictive unless already clamped + // at the most-restrictive class. + if base_class != PrivacyClass::Restricted { + assert!( + od.effective_class.as_u8() > base_class.as_u8(), + "unclamped demotion must increase restriction in {mode:?}" + ); + } else { + assert_eq!(od.effective_class, PrivacyClass::Restricted); + } + } + } + + /// Fail-closed boundary: an empty cycle (zero frames) must NOT emit a + /// trusted output at all — fusion rejects it and the engine surfaces a + /// hard error. There is no degenerate output that could carry a stale or + /// over-permissive class. + #[test] + fn empty_cycle_fails_closed() { + let (mut e, room) = engine(); + let err = e.process_cycle(&[], CalibrationId(1), room, 1); + assert!(matches!(err, Err(EngineError::Fusion(_))), "empty cycle must error, got {err:?}"); + // No SemanticState was appended (room + sensor only). + assert_eq!(e.world().node_count(), 2); + assert_eq!(e.cycle_count(), 0, "a failed cycle must not advance the counter"); + } + + /// Single-node boundary characterization: a one-node cycle fuses (no + /// multistatic cross-check is possible), reports no mesh (n<2), and emits a + /// well-formed witness at the base class. Documents that single-node sensing + /// is a valid, non-demoting mode — not a silent bypass. + #[test] + fn single_node_cycle_is_well_formed() { + let (mut e, room) = engine(); + let out = e + .process_cycle(&[node_frame(0, 1000, 56)], CalibrationId(1), room, 1) + .unwrap(); + assert!(out.mesh.is_none(), "one node has no mesh cut"); + assert!(out.directional.is_none(), "no geometry registered"); + assert_eq!(out.effective_class, PrivacyClass::Anonymous); // PrivateHome base + assert_ne!(out.witness, [0u8; 32], "witness still emitted"); + } + + /// Witness domain-separation (review finding): the witness must change + /// whenever ANY privacy-relevant field changes. The model_version, + /// calibration_version, and privacy_decision fields are concatenated into + /// the hash; without an unambiguous delimiter between them, a string that + /// straddles the model/calibration boundary collides with a different + /// (model, calibration) tuple. + /// + /// `model_version` is operator-influenceable through the per-room adapter id + /// (ADR-150 §3.4), and `calibration_version` is `cal:` — so the two + /// provenances below are *both reachable* and represent genuinely different + /// trust decisions (different model identity, different calibration epoch), + /// yet the field-boundary ambiguity makes them hash-collide. A colliding + /// witness silently un-distinguishes two distinct privacy-relevant inputs, + /// defeating the tamper/drift audit guarantee. + #[test] + fn witness_distinguishes_model_calibration_boundary() { + let class = PrivacyClass::Anonymous; + // A: model "rfenc-v1+adapter:X", calibration epoch "cal:00ab". + let a = SemanticProvenance { + evidence: vec!["ev".into()], + model_version: "rfenc-v1+adapter:X".into(), + calibration_version: "cal:00ab".into(), + privacy_decision: "PrivateHome/Anonymous".into(), + }; + // B: adapter id absorbs the leading "cal:00a" of A's calibration; B's + // own calibration is the remaining "b". A.model‖A.cal == B.model‖B.cal, + // so the unseparated concatenation hashes identically — yet these are + // distinct (model identity, calibration epoch) tuples. + let b = SemanticProvenance { + evidence: vec!["ev".into()], + model_version: "rfenc-v1+adapter:Xcal:00a".into(), + calibration_version: "b".into(), + privacy_decision: "PrivateHome/Anonymous".into(), + }; + assert_ne!(a.model_version, b.model_version); + assert_ne!(a.calibration_version, b.calibration_version); + // Sanity: the two collide under naive concatenation. + assert_eq!( + format!("{}{}", a.model_version, a.calibration_version), + format!("{}{}", b.model_version, b.calibration_version), + ); + assert_ne!( + witness_of(&a, class), + witness_of(&b, class), + "distinct (model, calibration) tuples must not share a witness" + ); + } + + /// Witness domain-separation across the evidence/model boundary: a witness + /// must distinguish an extra evidence ref from a model_version that absorbs + /// the same bytes. The evidence loop terminates each ref with one separator; + /// the model field must itself be unambiguously delimited from the (variable + /// number of) evidence refs that precede it. + #[test] + fn witness_distinguishes_evidence_model_boundary() { + let class = PrivacyClass::Anonymous; + let a = SemanticProvenance { + evidence: vec!["e1".into(), "e2".into()], + model_version: "m".into(), + calibration_version: "cal:1".into(), + privacy_decision: "PrivateHome/Anonymous".into(), + }; + let b = SemanticProvenance { + evidence: vec!["e1".into()], + // absorbs "e2" + its 0x1f separator into the model field. + model_version: "e2\u{1f}m".into(), + calibration_version: "cal:1".into(), + privacy_decision: "PrivateHome/Anonymous".into(), + }; + assert_ne!( + witness_of(&a, class), + witness_of(&b, class), + "an extra evidence ref must not collide with a model_version that absorbs it" + ); + } }