Skip to content

Commit a91004e

Browse files
authored
feat(adr-124): SENSE-BRIDGE — @ruvnet/rvagent MCP server + 6 sensing tools (v0.1.0) (ruvnet#791)
* feat(adr-118/p1.4): BfldFrame (header + payload + CRC32) — 24/24 GREEN Iter 4. Lands the central wire-format primitive: complete frames with header + arbitrary-length payload, protected by CRC-32/ISO-HDLC. Added: - crc = "3" dependency (CRC-32/ISO-HDLC, same poly as Ethernet / zlib) - src/frame.rs: CRC32_ALG const and crc32_of_payload(&[u8]) -> u32 - src/frame.rs: BfldFrame { header, payload: Vec<u8> } (gated on `std`) * BfldFrame::new(header, payload) — auto-syncs payload_len + payload_crc32 * BfldFrame::to_bytes() -> Vec<u8> — header LE bytes ‖ payload * BfldFrame::from_bytes(&[u8]) -> Result<Self, BfldError> - BfldError::TruncatedFrame { got, need } variant - Doc strings on BfldError::Crc and BfldError::PrivacyViolation field names - tests/frame_roundtrip.rs (7 named tests, gated on feature = "std"): frame_roundtrip_preserves_header_and_payload frame_new_syncs_payload_len_and_crc frame_serialization_is_deterministic frame_rejects_payload_crc_mismatch frame_rejects_truncated_buffer_smaller_than_header frame_rejects_truncated_buffer_smaller_than_payload empty_payload_is_valid (CRC of empty payload is 0x00000000) Test config: - cargo test --no-default-features → 17 passed (frame_roundtrip cfg-out) - cargo test (default features = std) → 24 passed (3+6+7+8) ADR-119 ACs progressed: - AC4 partial: bad-magic + bad-version + CRC-mismatch + truncation rejected with typed errors; field-level masking lives in the privacy_gate iter. - AC5: BfldFrame round-trip preserves header + payload + CRC. - AC6: Identical inputs produce bit-identical bytes (asserted explicitly). Out of scope (next iter): - Payload section parser (compressed_angle_matrix, amplitude_proxy, ...) — only the byte buffer is opaque so far; sections need length prefixes. - BfldFrameRef<'_> for ESP32-S3 self-only mode (no-alloc, ADR-123 §2.5). - PrivacyGate::demote(frame, target_class) transformer (ADR-120 §2.4). Co-Authored-By: claude-flow <ruv@ruv.net> * feat(adr-118/p1.5): payload section parser (BfldPayload) — 32/32 GREEN Iter 5. Implements ADR-119 §2.2 payload layout: 4-byte LE length prefix followed by section bytes, in this fixed order: compressed_angle_matrix ‖ amplitude_proxy ‖ phase_proxy ‖ snr_vector ‖ csi_delta (iff flags.bit0) ‖ vendor_extension (length 0 allowed) Added: - src/payload.rs (gated on `feature = "std"`): * BfldPayload struct with 6 fields (csi_delta: Option<Vec<u8>>) * SECTION_PREFIX_LEN const (= 4) * to_bytes(include_csi_delta: bool) -> Vec<u8> * wire_len(include_csi_delta: bool) -> usize (predictive, no allocation) * from_bytes(&[u8], expect_csi_delta: bool) -> Result<Self, BfldError> * push_section / read_section helpers (private) - BfldError::MalformedSection { offset, reason } variant - pub use BfldPayload from lib.rs (cfg-gated mirror of BfldFrame) tests/payload_sections.rs (8 named tests, all green): payload_roundtrip_with_csi_delta payload_roundtrip_without_csi_delta wire_len_matches_to_bytes_length empty_payload_has_five_zero_length_sections parser_rejects_buffer_shorter_than_first_length_prefix parser_rejects_section_body_running_past_buffer_end parser_rejects_trailing_bytes_after_vendor_extension csi_delta_flag_mismatch_with_payload_is_detectable_via_trailing_bytes ACs progressed: - AC5 ↑ — full section-level round-trip preservation (round-trip with and without csi_delta both pass). - AC6 ↑ — deterministic section encoding (length prefixes use to_le_bytes, body is byte-stable). - AC1 partial — section layout now parses with bounded errors; CBFR-specific parsing (Phi/Psi Givens decoders) is a separate iter inside extractor.rs. Test config: - cargo test --no-default-features → 17 passed (payload module cfg-out) - cargo test → 32 passed (3 + 6 + 7 + 8 + 8) Out of scope (next iter target): - Wire integration: feed BfldPayload bytes through BfldFrame::new so the header.payload_crc32 covers the section-prefixed bytes per ADR-119 §2.2 ("CRC32 covers all section bytes including length prefixes"). - A no_std-friendly BfldPayloadRef<'_> borrowing variant (ESP32-S3 path). - Givens-rotation angle decoder (Phi/Psi extraction from compressed_angle_matrix). Co-Authored-By: claude-flow <ruv@ruv.net> * feat(adr-118/p1.6): BfldFrame <-> BfldPayload wire integration (39/39 GREEN) Iter 6. Connects the typed payload parser (iter 5) to the framed wire format (iter 4): the CRC32 now covers the section-prefixed payload bytes per ADR-119 §2.2 ("CRC32 covers all section bytes including length prefixes"). Added: - BfldFrame::from_payload(header, &BfldPayload) -> Self Auto-syncs header.flags HAS_CSI_DELTA bit from payload.csi_delta.is_some(), serializes payload via to_bytes(), feeds BfldFrame::new() which computes payload_len + payload_crc32 over the section-prefixed bytes. - BfldFrame::parse_payload(&self) -> Result<BfldPayload, BfldError> Reads HAS_CSI_DELTA bit from header.flags and dispatches to BfldPayload::from_bytes(&self.payload, expect_csi_delta). tests/frame_payload_integration.rs (7 named tests, all green): from_payload_then_parse_payload_is_identity from_payload_autosets_has_csi_delta_flag from_payload_clears_has_csi_delta_flag_when_csi_absent (verifies the flag is cleared when csi_delta is None even if caller pre-set the bit; other flag bits like PRIVACY_MODE are preserved) frame_crc_covers_section_prefixed_bytes (mutating a byte inside section body trips CRC, not magic/length) frame_crc_covers_section_length_prefixes (mutating a section length-prefix byte trips CRC before parser ever runs) empty_typed_payload_roundtrips end_to_end_wire_roundtrip_via_bytes (BfldPayload -> from_payload -> to_bytes -> from_bytes -> parse_payload is the identity function modulo flag auto-set) ACs progressed: - AC5 ↑ — full payload round-trip through the framed bytes (closes the round-trip leg from BfldPayload through wire and back). - AC6 ↑ — same input produces same bytes through both layers. - AC4 ↑ — CRC mismatch on tampered section bodies and tampered section length prefixes both surface as BfldError::Crc, not as silent acceptance or as a deeper parser error. Test config: - cargo test --no-default-features → 17 passed (integration tests cfg-out) - cargo test → 39 passed (3 + 6 + 7 + 8 + 8 + 7) Out of scope (next iter target): - PrivacyGate::demote(frame, target_class) — ADR-120 §2.4 class transition transformer with subtle::Zeroize on dropped fields. - IdentityEmbedding newtype with no Serialize impl (ADR-120 §2.5 / I2). Co-Authored-By: claude-flow <ruv@ruv.net> * feat(adr-118/p2.1): IdentityEmbedding newtype + zeroizing Drop — 44/44 GREEN Iter 7. First structural enforcement of ADR-118 invariant I2 — the identity embedding is in-RAM-only and cannot be serialized, cloned, or copied. Lands the type itself; ring-buffer lifecycle is next. Added: - src/embedding.rs (no_std-compatible; lives in the lib regardless of features): * IdentityEmbedding wrapping [f32; EMBEDDING_DIM=128] * from_raw(values), as_slice() -> &[f32], l2_norm(), len(), is_empty() * NO Serialize, NO Clone, NO Copy impl * Custom Debug emits only dim + L2 norm + "<redacted>" — never raw values * Drop overwrites storage with 0.0 then core::hint::black_box(...) to defeat dead-store elimination (DSE would otherwise let the compiler skip the write) - Compile-time structural guards via static_assertions: assert_impl_all!(IdentityEmbedding: Drop) assert_not_impl_any!(IdentityEmbedding: Copy, Clone) - pub use IdentityEmbedding, EMBEDDING_DIM from lib.rs tests/identity_embedding.rs (5 named tests, all green): from_raw_preserves_values_through_as_slice l2_norm_is_correct debug_output_redacts_raw_values (asserts the formatted output does NOT contain decimal text of values) embedding_is_not_clonable (runtime witness; compile-time assertion lives in src/embedding.rs) drop_overwrites_storage_with_zeros (Drop runs without panic; bit-level zeroization is asserted by the black_box-guarded loop. Unsafe peek-after-free is intentionally avoided.) ACs progressed: - AC5 ↑ — even in `privacy_mode`, the IdentityEmbedding type can't be reached from any serialization path because the type system rejects the impl. - I2 ↑ — Drop, no Clone, no Copy, redacted Debug are all in place as compile-time guarantees. Test config: - cargo test --no-default-features → 22 passed - cargo test → 44 passed (3 + 6 + 7 + 8 + 8 + 7 + 5) Out of scope (next iter target): - EmbeddingRing — 64-entry FIFO ring buffer holding IdentityEmbeddings, drained on coherence-gate Recalibrate (ADR-121 §2.4). - PrivacyGate::demote(frame, target_class) transformer (ADR-120 §2.4). Co-Authored-By: claude-flow <ruv@ruv.net> * feat(adr-118/p2.2): EmbeddingRing 64-entry FIFO buffer — 53/53 GREEN Iter 8. Lands the lifecycle half of ADR-120 §2.5: a bounded, in-place, no_std-compatible ring of IdentityEmbeddings. Insertion is O(1); when full, push evicts the oldest entry, whose Drop runs and zeroizes the f32 storage. drain() clears the ring on the coherence-gate Recalibrate action (ADR-121 §2.4). Added: - src/embedding_ring.rs (no_std-compatible; no heap): * EmbeddingRing struct with [Option<IdentityEmbedding>; RING_CAPACITY=64] backing array, head cursor, count * EmbeddingRing::new() / Default impl * push(emb) -> Option<IdentityEmbedding> (evicted oldest when full) * len / is_empty / capacity / is_full / iter * iter() returns occupied slots in insertion order (oldest first) * drain() -> usize (empties the ring, returns count drained) - pub use EmbeddingRing, RING_CAPACITY from lib.rs Uses `[const { None }; RING_CAPACITY]` (stable since 1.79) to initialize the slot array for a non-Copy element type. tests/embedding_ring.rs (9 named tests, all green): new_ring_is_empty default_constructor_matches_new push_below_capacity_returns_none iter_yields_in_insertion_order push_at_capacity_evicts_oldest_and_returns_it (verifies eviction reports the FIRST pushed value, not the last) push_beyond_capacity_keeps_last_n_entries (after 74 pushes into a 64-slot ring, the surviving 64 are positions 10..74) drain_empties_the_ring_and_returns_count drain_on_empty_ring_returns_zero ring_can_be_refilled_after_drain (post-drain push lands cleanly at index 0; iter yields exactly that entry) ACs progressed: - I2 ↑ — ring eviction and explicit drain both drop IdentityEmbeddings, which the iter-7 Drop impl zeroizes. The "in-RAM-only" lifecycle is now end-to-end: bounded buffer in, FIFO out, drain on Recalibrate. Test config: - cargo test --no-default-features → 31 passed (22 + 9) - cargo test → 53 passed (44 + 9) Out of scope (next iter target): - PrivacyGate::demote(frame, target_class) — ADR-120 §2.4 monotonic class transition with field zeroization, refusing demote-to-Raw (compile-fail). - SoulMatchOracle stub trait + no-op default impl (ADR-121 §2.6) so the Recalibrate exemption hook is wireable from `--features soul-signature`. Co-Authored-By: claude-flow <ruv@ruv.net> * feat(adr-118/p3.1): PrivacyGate::demote monotonic class transformer (60/60 GREEN) Iter 9. Lands ADR-120 §2.4 — the only operation that can lower a frame's information content. Demote is monotonic by construction (Result::Err on non-monotone target), strips payload sections per the target class table, and re-syncs header.privacy_class + CRC32. Added: - src/privacy_gate.rs (gated on `feature = "std"`): * PrivacyGate unit struct (+ Default impl) * PrivacyGate::demote(BfldFrame, target: PrivacyClass) -> Result<BfldFrame> * Stripping policy: target >= Anonymous (2): zeros + clears compressed_angle_matrix and csi_delta; sets csi_delta = None so from_payload clears HAS_CSI_DELTA target >= Restricted (3): also zeros + clears amplitude_proxy and phase_proxy * zeroize_then_clear helper — overwrite with 0 then black_box then truncate - BfldError::InvalidDemote { from: u8, to: u8 } variant - pub use PrivacyGate from lib.rs Note: demote does NOT zero the original Vec capacity that the heap allocator may still hold — the buffers we own are zeroed and cleared, but the intermediate Vec passed back to BfldFrame::from_payload reallocates anew. For strict heap zeroization in regulated deployments, a follow-up iter can substitute zeroize::Zeroizing<Vec<u8>>. tests/privacy_gate_demote.rs (7 named tests, all green): demote_to_same_class_is_identity demote_derived_to_anonymous_strips_compressed_angle_matrix (also asserts csi_delta dropped, snr_vector and amplitude_proxy preserved) demote_derived_to_restricted_strips_amplitude_and_phase_too (snr_vector and vendor_extension survive at class 3) demote_anonymous_to_derived_is_rejected (asserts InvalidDemote { from: 2, to: 1 }) demote_to_raw_is_rejected_from_any_higher_class (parameterized over Derived, Anonymous, Restricted as sources) demote_preserves_frame_crc_consistency_through_wire_roundtrip (post-demote frame survives to_bytes -> from_bytes with no CRC error) demote_clears_has_csi_delta_flag_bit ACs progressed: - AC5 ↑ — privacy_mode enforcement at the frame-class boundary now works through PrivacyGate, not just the BfldEvent emitter (deferred). When the active class is Anonymous (2) or Restricted (3), the angle matrix / csi_delta / amplitude / phase sections that carry identity information are zeroed before any downstream code sees them. - AC4 ↑ — demoted frames retain valid CRC; the round-trip-through-bytes test proves bit-correctness after the class transition. Test config: - cargo test --no-default-features → 31 passed (privacy_gate cfg-out) - cargo test → 60 passed (53 + 7) Out of scope (next iter target): - SoulMatchOracle stub trait + no-op default impl (ADR-121 §2.6) so the Recalibrate exemption hook is wireable from `--features soul-signature`. - IdentityRiskEngine — multiplicative formula on (sep, stab, consist, conf) with the coherence-gate GateAction enum (ADR-121 §2.2 + §2.4). Co-Authored-By: claude-flow <ruv@ruv.net> * feat(adr-118/p3.2): identity_risk score + GateAction enum — 72/72 GREEN Iter 10. Lands the stateless half of ADR-121 §2.2–§2.4: the multiplicative risk-score formula and the 4-band gate classifier. Hysteresis + 5s debounce (stateful CoherenceGate) land in iter 11. Added (no_std-compatible): - src/identity_risk.rs: * score(sep, stab, consist, conf) -> f32 Each input clamped to [0,1]; NaN → 0 (conservative). Multiplicative combination: any near-zero factor collapses the score → privacy-biased. * Threshold constants: PREDICT_ONLY_THRESHOLD=0.5, REJECT_THRESHOLD=0.7, RECALIBRATE_THRESHOLD=0.9 * GateAction enum: Accept | PredictOnly | Reject | Recalibrate * GateAction::from_score(f32) -> Self — band-based classification with inclusive lower edges (0.7 maps to Reject, 0.9 maps to Recalibrate) * GateAction::allows_publish() / drops_event() / requires_recalibrate() - pub use identity_risk_score (the function) and GateAction from lib.rs tests/identity_risk_score.rs (12 named tests, all green): all_ones_yields_one any_zero_factor_collapses_score_to_zero (4 single-factor variants) score_is_monotonic_non_decreasing_in_single_factor out_of_range_inputs_are_clamped_to_unit_interval nan_inputs_treated_as_zero (verifies privacy-conservative NaN handling) known_score_matches_hand_calculation (0.8*0.9*0.85*0.95 to 1e-6) from_score_classifies_each_band (8 boundary-condition checks) threshold_constants_match_documented_values nan_score_maps_to_accept_conservatively allows_publish_partitions_actions_correctly drops_event_inverts_allows_publish (parameterized over all 4 actions) requires_recalibrate_is_unique_to_recalibrate ACs progressed: - ADR-121 AC2 partial — `score` formula structurally enforces non-negativity, upper bound 1.0, and conservative behavior under uncertainty (NaN, negative input, single near-zero factor). - ADR-121 AC7 partial — score function is pure / deterministic; identical inputs always produce identical outputs (asserted by the known-value test). Test config: - cargo test --no-default-features → 43 passed (31 + 12) - cargo test → 72 passed (60 + 12) Out of scope (next iter target): - CoherenceGate stateful struct: ±0.05 hysteresis + 5-second debounce (ADR-121 §2.5) so the gate doesn't oscillate near band boundaries. - SoulMatchOracle stub trait (ADR-121 §2.6) — the Recalibrate exemption hook for `--features soul-signature` deployments. Co-Authored-By: claude-flow <ruv@ruv.net> * feat(adr-118/p3.3): CoherenceGate hysteresis + 5s debounce — 85/85 GREEN Iter 11. Wraps the stateless GateAction classifier from iter 10 with two stabilizing mechanisms per ADR-121 §2.5: * ±0.05 HYSTERESIS — a score must clear the current band's edge by HYSTERESIS before the gate considers the next band. * 5-second DEBOUNCE_NS — a different action must persist that long before it becomes current; returning to the current band cancels it. Added (no_std-compatible): - src/coherence_gate.rs: * HYSTERESIS const (0.05) + DEBOUNCE_NS const (5_000_000_000) * CoherenceGate { current, pending: Option<(GateAction, u64)> } * new() / Default / current() / pending() (diagnostic accessors) * evaluate(score, timestamp_ns) -> GateAction Algorithm: compute effective_target via per-direction hysteresis check, promote pending after DEBOUNCE_NS elapsed, cancel pending on return to current band, reset debounce clock if pending target changes * Private helpers effective_target / action_idx / upper_edge_of / lower_edge_of - pub use CoherenceGate from lib.rs tests/coherence_gate.rs (13 named tests, all green): fresh_gate_starts_in_accept_with_no_pending low_score_stays_in_accept_with_no_pending score_just_past_boundary_but_within_hysteresis_does_not_pend (0.52: above 0.5 but inside hysteresis envelope — no pending) score_clearly_past_hysteresis_starts_pending (0.6: past 0.55 hysteresis edge — pending PredictOnly registered) pending_action_promotes_after_full_debounce pending_action_does_not_promote_before_debounce (verified at DEBOUNCE_NS - 1) returning_to_current_band_cancels_pending changing_pending_target_resets_the_debounce_clock (PredictOnly pending at t=0, then Recalibrate at t=1s — clock resets, must wait until t=1s+DEBOUNCE_NS before Recalibrate is current) downward_transitions_also_require_hysteresis (from PredictOnly, 0.48 stays put; 0.44 pends Accept) spike_to_one_then_back_to_zero_never_promotes_to_recalibrate (transient spike + return to baseline produces no transition) boundary_value_with_hysteresis_does_not_promote (0.5+0.05-epsilon) boundary_value_at_hysteresis_exact_does_pend (0.5+0.05) nan_score_stays_in_current_action_with_no_pending ACs progressed: - ADR-121 AC4 — Recalibrate fires when score >= 0.9 for >= DEBOUNCE_NS (5s). The debounce test above directly exercises this. - ADR-121 AC5 — hysteresis test confirms action does not oscillate across ± 0.05 of a threshold within a 5-second window. Test config: - cargo test --no-default-features → 56 passed (43 + 13) - cargo test → 85 passed (72 + 13) Out of scope (next iter target): - SoulMatchOracle stub trait (ADR-121 §2.6) + Recalibrate exemption — when --features soul-signature is enabled and the oracle reports a known enrolled person_id match, the gate downgrades Recalibrate → PredictOnly. - BfldEvent struct (ADR-121 §2.1 output event) — first downstream consumer of the gate action. Co-Authored-By: claude-flow <ruv@ruv.net> * feat(adr-118/p3.4): SoulMatchOracle + Recalibrate exemption (93/93 GREEN) Iter 12. Wires the ADR-121 §2.6 Recalibrate exemption: when an enrolled person_id matches the current high-separability cluster, the gate downgrades the would-be Recalibrate to PredictOnly. The high score is the *intended* outcome of a Soul Signature match, not an attacker-grade sniffer arrival — so site_salt rotation is suppressed. Added (no_std-compatible): - src/coherence_gate.rs additions: * MatchOutcome enum: Match { person_id: u64 } | NotEnrolled | Suppressed * SoulMatchOracle trait with matches_enrolled() -> MatchOutcome * NullOracle (default-constructible, always reports NotEnrolled) * CoherenceGate::evaluate_with_oracle(score, ts, &O: SoulMatchOracle) — same hysteresis/debounce as evaluate(), but downgrades Recalibrate to PredictOnly when oracle returns Match { .. } * Refactored evaluate(): extracted advance_state(target, ts) shared with evaluate_with_oracle. evaluate is now a 4-line wrapper. - pub use MatchOutcome, NullOracle, SoulMatchOracle from lib.rs tests/soul_match_oracle.rs (8 named tests, all green): null_oracle_matches_default_evaluate_behavior (parameterized over 5 score points; oracle-aware and oracle-free gates produce identical trajectories) match_outcome_downgrades_recalibrate_to_predict_only (score=0.95 pends PredictOnly instead of Recalibrate) match_exemption_promotes_predict_only_after_debounce_not_recalibrate (after DEBOUNCE_NS, current is PredictOnly — never Recalibrate) match_outcome_does_not_affect_lower_actions (Reject pending stays Reject; oracle only intercepts Recalibrate) suppressed_outcome_does_not_exempt_recalibrate (Suppressed is functionally equivalent to NotEnrolled at the gate) not_enrolled_outcome_does_not_exempt_recalibrate match_outcome_carries_person_id null_oracle_default_constructor_works ACs progressed: - ADR-121 §2.6 fully covered as a stateless integration point — the hook is in place for the `--features soul-signature` Soul Signature crate (TBD) to plug in a real RaBitQ-backed oracle. - ADR-118 §1.4 Soul Signature companion contract is now structurally enforced at the gate boundary: enrolled subjects do not trigger site_salt rotation; everyone else does. Test config: - cargo test --no-default-features → 64 passed (56 + 8) - cargo test → 93 passed (85 + 8) Out of scope (next iter target): - BfldEvent struct (ADR-121 §2.1 output event JSON) — the downstream consumer of GateAction. Pairs the gate decision with presence/motion/ person_count sensing fields. - Optional: connect SoulMatchOracle into the actual `--features soul-signature` build (compile-time gate around a re-export). Co-Authored-By: claude-flow <ruv@ruv.net> * feat(adr-118/p4.1): BfldEvent privacy-gated output + JSON (102/102 GREEN) Iter 13. Lands ADR-121 §2.1 (output event) + ADR-122 §2.1 (field-gating policy). BfldEvent collapses the GateAction-driven sensing pipeline into the canonical wire-format publishable on MQTT. Added: - serde (workspace, derive feature, optional) + serde_json (workspace, optional) deps - New crate feature `serde-json` (default-on; requires `std`) - src/event.rs (gated on `feature = "std"`): * BfldEvent struct with all sensing + identity-derived fields * with_privacy_gating(...) constructor that applies field-gating policy: class < Restricted (3): identity_risk_score + rf_signature_hash kept class >= Restricted (3): both nulled to None * apply_privacy_gating() — idempotent in-place masking * to_json() -> Result<String, serde_json::Error> (gated on serde-json) * Custom ser_privacy_class serializer emits lowercase names ("anonymous", "restricted", etc.) per the BFLD JSON spec * skip_serializing_if = "Option::is_none" on identity-derived fields so privacy-gated events are observationally indistinguishable from events that never had the field set - pub use BfldEvent from lib.rs tests/event_privacy_gating.rs (9 named tests, all green): anonymous_event_retains_identity_risk_and_hash restricted_event_strips_identity_fields (class 3 → None) apply_privacy_gating_is_idempotent event_type_is_always_bfld_update (parameterized over 3 classes) json::json_round_trip_emits_type_field_first_or_last_but_present json::anonymous_json_includes_identity_fields json::restricted_json_omits_identity_fields_entirely (asserts the JSON string does NOT contain identity_risk_score or rf_signature_hash, verifying skip_serializing_if works as intended) json::privacy_class_serializes_to_lowercase_name json::zone_id_none_is_omitted_from_json ACs progressed: - ADR-121 AC6 (identity_risk score absent at class 3) — structurally enforced by with_privacy_gating + skip_serializing_if combination. - ADR-122 AC1 — JSON shape matches the HA-DISCO publishable event contract; identity fields can be reliably stripped by privacy_class. - ADR-118 AC5 — privacy_mode = engaged maps to PrivacyClass::Restricted with no identity fields in the published event. Test config: - cargo test --no-default-features → 64 passed (unchanged; event cfg-out) - cargo test → 102 passed (93 + 9) Out of scope (next iter target): - Emitter struct that wires GateAction + privacy class + sensing inputs into BfldEvent construction (ADR-118 §2.1 pipeline diagram). - MQTT topic publisher (ADR-122 §2.2) — depends on a runtime (tokio). Co-Authored-By: claude-flow <ruv@ruv.net> * feat(adr-118/p4.2): BfldEmitter end-to-end pipeline (109/109 GREEN) Iter 14. Wires every iter-1..13 primitive into a single ADR-118 §2.1 pipeline: per-frame sensing inputs go in, a privacy-gated BfldEvent (or None) comes out. First time every constituent is exercised together. Added (gated on `feature = "std"`): - src/emitter.rs: * SensingInputs struct — 11 fields: timestamp_ns, presence, motion, person_count, sensing_confidence, sep, stab, consist, risk_conf, rf_signature_hash (Option) * BfldEmitter struct owning: node_id, default_zone_id, privacy_class, CoherenceGate, EmbeddingRing * Builder API: new(node_id) → with_zone(...) → with_privacy_class(...) * current_action() / ring_len() diagnostic accessors * emit(inputs, embedding) → Option<BfldEvent> 1. score = identity_risk::score(sep, stab, consist, risk_conf) 2. ring.push(embedding) if Some 3. action = gate.evaluate_with_oracle(score, ts, &NullOracle) 4. if action == Recalibrate { ring.drain() } 5. if action.drops_event() { return None } 6. else BfldEvent::with_privacy_gating(...) honoring privacy_class * emit_with_oracle(...) variant for `--features soul-signature` callers - pub use BfldEmitter, SensingInputs from lib.rs tests/emitter_pipeline.rs (7 named tests, all green): emitter_emits_event_under_low_risk emitter_drops_event_under_sustained_high_risk (debounce honored) emitter_drains_ring_on_recalibrate (fills ring to 5, then Recalibrate-grade score → ring_len() == 0) restricted_class_strips_identity_fields_in_emitted_event (class 3: identity_risk_score AND rf_signature_hash both None) with_zone_sets_default_zone_id_on_event embedding_is_pushed_to_ring_even_when_event_dropped (privacy gating drops the event but the ring still observes the embedding so subsequent separability calculations remain valid) ring_unchanged_when_no_embedding_supplied ACs progressed: - ADR-118 AC1 (BFLD core pipeline integration) — every component from iter 1 (frame format) through iter 13 (event) is now traversed by a single emit() call. This is the first end-to-end smoke proof. - ADR-121 AC4 — Recalibrate-grade sustained score triggers ring drain (verified by ring_len() going from 5 to 0). - ADR-122 AC1 — privacy_class threaded through the pipeline so the output event is correctly gated for HA/Matter consumption. Test config: - cargo test --no-default-features → 64 passed (emitter cfg-out) - cargo test → 109 passed (102 + 7) Out of scope (next iter target): - Wiring rf_signature_hash computation from BLAKE3-keyed(site_salt, features) per ADR-120 §2.3 — the SensingInputs.rf_signature_hash is supplied by caller for now; needs a SignatureHasher with site_salt initialization in a follow-up iter. - Embedding ring → identity_separability_score derivation (currently `sep` is caller-supplied; should be computed from ring contents). - MQTT topic publisher wrapping BfldEmitter (ADR-122 §2.2) — depends on a runtime (tokio). Co-Authored-By: claude-flow <ruv@ruv.net> * feat(adr-118/p3.5): SignatureHasher (BLAKE3-keyed) — 117/117 GREEN Iter 15. Lands ADR-120 §2.3 — the cryptographic foundation of invariant I3 ("cross-site identity correlation is impossible"). rf_signature_hash is now derived from a per-site secret and a daily epoch, so two nodes observing the same physical person produce uncorrelated 256-bit digests. Added (no_std-compatible): - blake3 = "1.5", default-features = false (no_std, no SIMD by default) - src/signature_hasher.rs: * Constants SECONDS_PER_DAY (86_400), SITE_SALT_LEN (32), RF_SIGNATURE_LEN (32) * SignatureHasher { site_salt: [u8; 32] } with new(salt) const ctor * compute(day_epoch, &features) -> [u8; 32] (BLAKE3 keyed mode) * compute_at(unix_secs, &features) -> [u8; 32] convenience * day_epoch_from_unix_secs(unix_secs) -> u32 helper (floor(t / 86400)) - pub use SignatureHasher, RF_SIGNATURE_LEN, SITE_SALT_LEN from lib.rs tests/signature_hasher.rs (8 named tests, all green): deterministic_under_identical_inputs different_site_salts_produce_different_hashes different_day_epochs_rotate_the_hash different_features_produce_different_hashes output_length_is_32_bytes day_epoch_from_unix_secs_matches_floor_division (covers 0, 86_399, 86_400, and the 1.7e9 modern timestamp) compute_at_matches_compute_with_derived_day cross_site_hamming_distance_is_statistically_high *** ADR-120 §2.7 AC2 acceptance test *** Runs 100 trials with distinct (salt_a, salt_b) pairs observing identical features, computes per-trial Hamming distance, asserts mean >= 120 bits and min >= 80 bits. Empirically lands at ~128 bits mean (the expected value for two independent 256-bit hashes), with no trial below 80 bits — i.e., zero suspicious near-collisions. ACs progressed: - ADR-120 §2.7 AC2 — structurally enforced cross-site isolation, now proven empirically by the Hamming-distance test. This is the cryptographic half of invariant I3 in code, not just docs. - ADR-118 invariant I3 — first runtime witness that two sites with independent site_salts cannot correlate the same person's signature. Test config: - cargo test --no-default-features → 72 passed (64 + 8; signature_hasher is no_std) - cargo test → 117 passed (109 + 8) Out of scope (next iter target): - Wire SignatureHasher into BfldEmitter: replace caller-supplied rf_signature_hash with hasher.compute_at(ts, &features) so the pipeline produces correct hashes end-to-end. - IdentityFeatures canonical-bytes encoder so callers don't need to hand-serialize per-feature representations. Co-Authored-By: claude-flow <ruv@ruv.net> * feat(adr-118/p4.3): wire SignatureHasher into BfldEmitter (123/123 GREEN) Iter 16. End-to-end ADR-120 §2.3 wiring: BfldEmitter now produces rf_signature_hash derived from (site_salt, day_epoch, features), with the IdentityEmbedding bytes as the preferred feature source. Closes the gap from iter 15 — the hasher is now reachable from the pipeline. Added (in src/emitter.rs): - BfldEmitter.signature_hasher: Option<SignatureHasher> field - BfldEmitter::with_signature_hasher(SignatureHasher) -> Self builder - emit_with_oracle computes derived_hash BEFORE pushing embedding to ring: 1. unix_secs = inputs.timestamp_ns / NS_PER_SEC 2. feature bytes: embedding.as_slice() flattened to LE f32 bytes, OR fallback canonical_risk_bytes(&inputs) (4-tuple of LE f32) 3. hasher.compute_at(unix_secs, &bytes) - Derived hash overrides inputs.rf_signature_hash; when hasher absent caller-supplied value passes through unchanged (backward compat) - canonical_risk_bytes(&inputs) -> [u8; 16] private helper for fallback tests/emitter_hasher.rs (6 named tests, all green): no_hasher_passes_caller_supplied_hash_through installed_hasher_overrides_caller_supplied_hash same_emitter_same_inputs_produce_same_hash (determinism through emitter) different_site_salts_produce_different_hashes_end_to_end *** cross-site isolation proven via the BfldEmitter API, not just via the SignatureHasher direct API (iter 15) *** no_embedding_falls_back_to_risk_factor_bytes fallback_hash_differs_from_embedding_hash (embedding-based and fallback-based hashes are distinct paths) ACs progressed: - ADR-120 §2.7 AC2 — cross-site isolation now provable at the public emitter surface, not just inside the hasher module. - ADR-118 §2.1 pipeline integration — derived rf_signature_hash flows through to the BfldEvent without caller participation. Operators install the hasher once at boot; per-frame code never sees site_salt. Test config: - cargo test --no-default-features → 72 passed (emitter_hasher cfg-out) - cargo test → 123 passed (117 + 6) Out of scope (next iter target): - IdentityFeatures struct — typed canonical-bytes encoder so callers don't need to know that embedding bytes feed the hasher directly. - Cross-iter integration test: BfldEmitter → BfldEvent::to_json with derived hash, parsed back, hash field present and base64-encoded (or hex-encoded) per the JSON wire spec. Co-Authored-By: claude-flow <ruv@ruv.net> * feat(adr-118/p4.4): rf_signature_hash JSON as "blake3:<hex>" (128/128 GREEN) Iter 17. Lands the BFLD JSON wire spec format for rf_signature_hash — a "blake3:" prefix followed by 64 lowercase hex chars. Replaces the default serde array-of-integers encoding which was unusable for downstream consumers (HA, Matter, MQTT). Added (in src/event.rs): - ser_rf_signature_hash<S>(hash: &Option<[u8;32]>, s) custom serializer - Field attribute on BfldEvent.rf_signature_hash now uses serialize_with = "ser_rf_signature_hash" alongside skip_serializing_if - nibble_to_hex(u8) -> char private const fn (no `hex` crate dep needed for 32 bytes; lowercase hex is trivial) - Output format: "blake3:deadbeef..." exactly 71 ASCII chars tests/json_hash_format.rs (5 named tests, all green): rf_signature_hash_serializes_as_blake3_prefixed_lowercase_hex (expected hex built programmatically via format!("{b:02x}")) hex_string_is_always_64_chars_when_present (parses the JSON, isolates the hash substring, asserts exact 64 chars and lowercase-only — catches case-folding regressions) hash_field_omitted_entirely_when_none end_to_end_emitter_hasher_to_json_emits_blake3_hex_hash *** Cross-iter integration test: BfldEmitter::with_signature_hasher → SensingInputs.rf_signature_hash = None → emit derives via BLAKE3 → BfldEvent::to_json → contains "blake3:" prefix. Spans iters 13, 14, 15, 16, 17 in a single assertion. *** end_to_end_restricted_class_omits_hash_even_with_hasher_set (class 3: even with hasher installed, JSON omits the hash) ACs progressed: - BFLD wire spec §6 — rf_signature_hash JSON shape now matches the documented format ("blake3:..."); HA / Matter consumers can parse it without custom byte-array decoding. - ADR-118 §1 invariant I3 — visibility: the JSON wire form now cryptographically tags the hash with its algorithm prefix, so consumers can verify they're not parsing a different (weaker) hash that a future PR might accidentally substitute. Test config: - cargo test --no-default-features → 72 passed (json_hash_format cfg-out) - cargo test → 128 passed (123 + 5) Out of scope (next iter target): - IdentityFeatures typed encoder so callers feeding BfldEmitter don't need to know that embedding bytes serve as hasher input. - Replace the manual hex push with `hex::encode` if/when the workspace takes on the `hex` crate dep for other reasons; current path saves the dep without sacrificing correctness. Co-Authored-By: claude-flow <ruv@ruv.net> * feat(adr-118/p3.6): IdentityFeatures canonical-bytes encoder (137/137 GREEN) Iter 18. Consolidates the embedding-vs-risk-factor hashing-input selection behind a single typed API. Replaces the two ad-hoc paths that lived in emitter.rs through iter 17: * inline `emb.as_slice().iter().flat_map(|f| f.to_le_bytes())` * private `canonical_risk_bytes(&inputs) -> [u8; 16]` Added (gated on `feature = "std"`): - src/identity_features.rs: * IdentityFeatures<'a> enum: Embedding(&'a IdentityEmbedding) | RiskFactors { sep, stab, consist, conf } * from_embedding / from_risk_factors const constructors * canonical_byte_len() const fn — no allocation, predicts wire length * write_canonical_bytes(&mut Vec<u8>) — reusable-buffer path * canonical_bytes() -> Vec<u8> — allocating convenience * compute_hash(&SignatureHasher, day_epoch) -> [u8; 32] * RISK_FACTOR_BYTES const (= 16) - pub use IdentityFeatures, RISK_FACTOR_BYTES from lib.rs Refactor: - src/emitter.rs: derived_hash now uses let features = match &embedding { Some(emb) => IdentityFeatures::from_embedding(emb), None => IdentityFeatures::from_risk_factors(sep, stab, consist, conf), }; features.compute_hash(h, day_epoch) Local canonical_risk_bytes helper removed (superseded). tests/identity_features_encoder.rs (9 named tests, all green): embedding_canonical_length_is_dim_times_four risk_factor_canonical_length_is_sixteen_bytes embedding_canonical_bytes_match_manual_flatten risk_factor_canonical_bytes_match_explicit_le_layout write_canonical_bytes_appends_to_existing_buffer compute_hash_matches_direct_hasher_invocation embedding_and_risk_factors_produce_different_hashes iter_16_wire_compat_embedding_path *** backward-compat regression *** iter_16_wire_compat_risk_factor_path *** backward-compat regression *** These two tests assert that the refactored encoder produces bit-identical hashes to iter 16's inline path. Existing deployed nodes upgrading to iter 18 see no rf_signature_hash flip. ACs progressed: - ADR-120 §2.3 — features canonical-bytes representation now has a single source of truth in the codebase; future feature additions pass through one named encoder rather than scattered byte-fiddling. - ADR-118 invariant I2 — IdentityFeatures borrows &IdentityEmbedding, it doesn't take ownership. The embedding's Drop / no-Serialize guarantees continue to hold across the canonical-bytes path. Test config: - cargo test --no-default-features → 72 passed (identity_features cfg-out) - cargo test → 137 passed (128 + 9) Out of scope (next iter target): - Wire IdentityFeatures into a public emitter input path so callers can supply pre-constructed IdentityFeatures rather than the bare embedding + risk factors. (Soft refactor; current API is sufficient.) - BfldPipeline facade — single struct combining BfldEmitter + BfldFrame producer + MQTT publisher (ADR-118 §2.1 lib.rs entry point). Co-Authored-By: claude-flow <ruv@ruv.net> * feat(adr-118/p4.5): BfldPipeline facade + BfldConfig (146/146 GREEN) Iter 19. Public lib.rs entry point per ADR-118 §2.1. Thin facade over BfldEmitter that adds a config-driven builder and a privacy_mode toggle for emergency demote-to-Restricted without rebuilding the gate/ring/hasher state. Added (gated on `feature = "std"`): - src/pipeline.rs: * BfldConfig { node_id, default_zone_id, privacy_class, signature_hasher } with new/with_zone/with_privacy_class/with_signature_hasher builder * BfldPipeline { baseline_class, privacy_mode, emitter } * BfldPipeline::new(config) — initializes the underlying emitter * process(inputs, embedding) -> Option<BfldEvent> Delegates to emitter.emit() then post-processes: if privacy_mode is engaged, demotes the resulting event to Restricted and calls apply_privacy_gating to strip identity fields * enable_privacy_mode() / disable_privacy_mode() / is_privacy_mode_enabled() * current_privacy_class() — returns Restricted when privacy_mode else baseline * current_gate_action() — delegate diagnostic - pub use BfldConfig, BfldPipeline from lib.rs Design note: the privacy_mode override is applied post-emission, NOT by rebuilding the emitter. This preserves gate state (current action, pending transitions), ring contents, and hasher salt across the toggle — critical for incident response where the operator needs to keep detecting anomalies while temporarily redacting the public surface. tests/pipeline_facade.rs (9 named tests, all green): config_defaults_to_anonymous_no_zone_no_hasher config_builder_methods_chain fresh_pipeline_is_not_in_privacy_mode pipeline_process_returns_anonymous_event_under_low_risk enable_privacy_mode_demotes_published_events_to_restricted (verifies BOTH identity_risk_score AND rf_signature_hash become None) disable_privacy_mode_restores_baseline_class (round-trip: enable → demoted → disable → restored to Anonymous) privacy_mode_overrides_derived_baseline_too (research-mode operator can still flip the emergency switch) pipeline_with_hasher_emits_derived_rf_signature_hash zone_is_threaded_from_config_to_event ACs progressed: - ADR-118 §2.1 — public entry point now matches the implementation plan §1.2 sketch: BfldPipeline::new(config) → process() → BfldEvent. Future iters add process_to_frame() and the tokio MQTT loop. - ADR-118 §1.5 enable_privacy_mode requirement — operator can engage Restricted-class redaction without restarting the pipeline or losing in-flight detection state. First runtime witness of this. Test config: - cargo test --no-default-features → 72 passed (pipeline cfg-out) - cargo test → 146 passed (137 + 9) Out of scope (next iter target): - process_to_frame(inputs, payload, embedding) -> Option<BfldFrame> for callers that need wire-format bytes rather than JSON events. - BfldPipelineHandle wrapping the pipeline in Arc<Mutex<...>> + a tokio task that pumps an MQTT loop (ADR-122 §2.2 emitter half). Co-Authored-By: claude-flow <ruv@ruv.net> * feat(adr-118/p4.6): BfldPipeline::process_to_frame wire-bytes path (152/152 GREEN) Iter 20. Adds the wire-bytes companion to BfldPipeline::process so callers needing BfldFrame (for ESP-NOW, UDP, file dump, witness bundles, etc.) don't have to drop down to BfldEmitter + manual BfldFrame construction. Added (in src/pipeline.rs): - BfldPipeline::process_to_frame( inputs: SensingInputs, header_template: BfldFrameHeader, payload: BfldPayload, embedding: Option<IdentityEmbedding>, ) -> Option<BfldFrame> Algorithm: 1. Cache timestamp_ns from inputs (consumed by the inner process()). 2. Call self.process(inputs, embedding) — gate logic decides drop/emit. Returns None if the gate rejects, propagating to caller. 3. Clone header_template, override timestamp_ns and privacy_class from the current pipeline state (privacy_mode-aware). 4. Build via BfldFrame::from_payload — CRC covers the section-prefixed payload bytes per ADR-119 §2.2. Separation of concerns: pipeline owns gate / ring / hasher state; caller owns AP / STA / session identity (provided via header_template). tests/pipeline_to_frame.rs (6 named tests, all green): process_to_frame_emits_frame_under_low_risk (timestamp_ns + privacy_class correctly propagated from pipeline) process_to_frame_returns_none_under_sustained_high_risk (gate Reject path: two consecutive high-risk calls → None) process_to_frame_round_trips_through_bytes (frame.to_bytes() → BfldFrame::from_bytes() → parse_payload() identity) process_to_frame_overrides_class_in_privacy_mode (enable_privacy_mode → frame.header.privacy_class = Restricted byte) process_to_frame_preserves_header_template_identity_fields (ap_hash, sta_hash, session_id, channel from template survive) process_to_frame_uses_input_timestamp_not_template_timestamp (template.timestamp_ns = 12345 is overridden by inputs.timestamp_ns) ACs progressed: - ADR-118 §2.1 wire-bytes consumer path now reachable from BfldPipeline, not just from low-level BfldEmitter + manual frame construction. - ADR-119 AC5/AC6 — round-trip-through-bytes test exercises the full pipeline+frame stack, not just the frame in isolation. - ADR-122 §2.2 prep — the BfldFrame is the wire format MQTT eventually publishes via tokio loop (next iter pair); process_to_frame is the per-frame producer that loop will call. Test config: - cargo test --no-default-features → 72 passed (pipeline_to_frame cfg-out) - cargo test → 152 passed (146 + 6) Out of scope (next iter target): - BfldPipelineHandle: Arc<Mutex<BfldPipeline>> + tokio task that pumps an inbound (SensingInputs, IdentityEmbedding) channel into MQTT per-class topics (ADR-122 §2.2). Brings in tokio + rumqttc deps behind a `mqtt` feature. - Cargo benchmark: pipeline throughput target ≥ 40 frames/sec on a Pi 5 core (ADR-118 §6 P2 effort estimate). Co-Authored-By: claude-flow <ruv@ruv.net> * feat(adr-118/p5.1): MQTT topic router (BfldEvent → Vec<TopicMessage>) — 162/162 GREEN Iter 21. Lands ADR-122 §2.2 topic shape + class-gated routing as a pure function. No broker dep yet — that lands in iter 22 with tokio + rumqttc behind an `mqtt` feature. This iter is the routing policy, separated for testability. Added (gated on `feature = "std"`): - src/mqtt_topics.rs: * TopicMessage { topic: String, payload: String } * TopicMessage::ruview_topic(node, entity) builds the canonical `ruview/<node>/bfld/<entity>/state` shape * render_events(&BfldEvent) -> Vec<TopicMessage>: class < Anonymous (0/1): returns empty (raw/derived are local only) class >= Anonymous (2/3): emits presence + motion + person_count + confidence, plus zone_activity if zone_id set class == Anonymous (2) ONLY: also emits identity_risk class == Restricted (3): identity_risk is suppressed even with score - pub use render_events, TopicMessage from lib.rs Payload encoding: - presence: "true" | "false" - motion: "{:.6}" — fixed-precision decimal in [0.0, 1.0] - person_count: bare integer string - confidence: "{:.6}" - zone_activity: JSON-string with quotes — "\"living_room\"" - identity_risk: "{:.6}" tests/mqtt_topic_routing.rs (10 named tests, all green): topic_format_is_ruview_node_bfld_entity_state anonymous_class_publishes_six_topics_with_zone (6 = presence/motion/count/conf/zone/identity_risk) anonymous_class_without_zone_omits_zone_activity_topic (5 topics) restricted_class_omits_identity_risk_topic (class 3 → 5 topics, no risk) raw_and_derived_classes_publish_nothing *** structural enforcement of "raw stays local" at the topic layer *** presence_payload_is_lowercase_json_bool motion_payload_is_fixed_precision_decimal person_count_payload_is_bare_integer zone_payload_is_json_string_with_quotes identity_risk_payload_is_fixed_precision_decimal ACs progressed: - ADR-122 §2.2 topic shape now matches the documented format byte-for-byte. - ADR-122 AC4 — per-class topic gating: classes 2 / 3 publish disjoint sets, with identity_risk uniquely guarded. - ADR-118 invariant I1 reaching the public surface — Raw frames produce zero topic messages, so even a buggy publisher loop cannot leak them. Test config: - cargo test --no-default-features → 72 passed (mqtt_topics cfg-out) - cargo test → 162 passed (152 + 10) Out of scope (next iter target): - tokio + rumqttc behind a new `mqtt` feature gate - BfldPipelineHandle: Arc<Mutex<BfldPipeline>> + a tokio task that pumps inbound SensingInputs, runs render_events on each emitted BfldEvent, and calls client.publish() for each TopicMessage - mosquitto integration test pattern (cf. feedback_mqtt_integration_test_patterns memory: per-test client_id, pump until SubAck, wait for publisher discovery) Co-Authored-By: claude-flow <ruv@ruv.net> * feat(adr-118/p5.2): Publish trait + publish_event free function — 169/169 GREEN Iter 22. Abstracts the MQTT publish boundary without pulling in tokio or rumqttc yet. The trait is sync (callers can hold &mut self without an async runtime); the production rumqttc-backed impl in iter 23 will drive a tokio task internally and present the same sync surface here. Added (in src/mqtt_topics.rs, gated on `feature = "std"`): - Publish trait with associated Error type - CapturePublisher (Vec-backed; default-constructible) for unit tests - publish_event<P: Publish>(publisher, event) -> Result<usize, P::Error> Iterates render_events(event) and forwards each TopicMessage to publisher.publish(). Returns the count actually published, or the publisher's error short-circuited on first failure. - pub use Publish, CapturePublisher, publish_event from lib.rs tests/mqtt_publish_loop.rs (7 named tests, all green): capture_publisher_records_every_message publish_returns_zero_for_raw_and_derived_events (parameterized — class 0 and class 1 both produce zero publishes, reinforcing the invariant I1 surface enforcement from iter 21) published_topics_match_render_events_ordering (stable per-event topic sequence for MQTT consumers) restricted_class_publishes_no_identity_risk_topic anonymous_without_zone_publishes_five_messages (5 = no zone_activity) publisher_error_short_circuits_publish_event (FailingPublisher fails on 3rd publish; publish_event surfaces the error AND leaves the first two messages durably published) capture_publisher_error_type_is_infallible (compile-time witness that CapturePublisher cannot panic the loop) ACs progressed: - ADR-122 §2.2 publisher boundary — the broker-facing surface is now a named trait operators can mock, swap, or wrap with retries. - ADR-122 AC4 — publish_event respects the iter-21 class gating; Raw / Derived events produce zero broker traffic by definition. - ADR-118 invariant I1 — even if the broker connection somehow regressed, the trait-level publish_event cannot exfiltrate a Raw frame because render_events returns empty first. Test config: - cargo test --no-default-features → 72 passed (mqtt_publish_loop cfg-out) - cargo test → 169 passed (162 + 7) Out of scope (next iter target): - New `mqtt` feature gate; tokio + rumqttc deps under it - RumqttPublisher: impl Publish that holds an MqttClient + a small tokio block_on or oneshot send to bridge sync trait to async client - Optional: BfldPipelineHandle that owns Arc<Mutex<BfldPipeline>> + a spawn-and-forget tokio task pumping inbound (inputs, embedding) → process → publish_event(&rumqtt_pub, &event) - mosquitto integration test following the patterns from feedback_mqtt_integration_test_patterns memory note Co-Authored-By: claude-flow <ruv@ruv.net> * feat(adr-118/p5.3): RumqttPublisher behind mqtt feature gate (176/176 GREEN with mqtt) Iter 23. Production Publish trait impl using rumqttc 0.24 (same crate version + use-rustls feature pinning as wifi-densepose-sensing-server, so both publishers can share broker connection posture). Added: - rumqttc = "0.24" optional dep (default-features = false, use-rustls) - New `mqtt` cargo feature: ["std", "dep:rumqttc"] - src/rumqttc_publisher.rs (gated on `feature = "mqtt"`): * RumqttPublisher wrapping rumqttc::Client + QoS + retain flag * RumqttPublisher::new(client, qos) const constructor * with_retain(bool) builder for availability-style topics * RumqttPublisher::connect(opts, capacity) -> (Self, Connection) Returns the unpumped Connection — caller spawns a thread that iterates connection.iter() to drive the MQTT protocol. Default QoS is AtLeastOnce (HA-DISCO recommendation for state topics). * impl Publish with Error = rumqttc::ClientError - pub use RumqttPublisher from lib.rs tests/rumqttc_publisher_smoke.rs (7 named tests, all green, gated on mqtt): rumqttc_publisher_constructs_without_broker (uses 127.0.0.1:1 — reserved port refuses immediately; no hang) with_retain_builder_yields_a_publisher publish_queues_message_without_blocking_on_broker_state *** Critical property: rumqttc's sync Client::publish queues into an unbounded channel; publish_event returns Ok without round- tripping to the (offline) broker. The queued packet only sends if a thread iterates Connection::iter(). *** restricted_event_publishes_four_messages_through_rumqttc (class 3 + no zone: presence/motion/count/confidence — 4 topics) publisher_trait_object_is_constructible (Box<dyn Publish<Error = rumqttc::ClientError>> works) direct_publish_call_through_trait_object default_qos_is_at_least_once_via_connect ACs progressed: - ADR-122 §2.2 broker integration — production publisher now wired, matching the sensing-server's TLS / version posture. The two crates can share a single broker connection if an operator wants both publishers in the same process. - ADR-122 AC4 still enforced — publish_event's class-gated routing is upstream of rumqttc, so no broker-level config can leak Raw frames. Test config: - cargo test --no-default-features → 72 passed (mqtt feature off) - cargo test → 169 passed (mqtt feature off) - cargo test --features mqtt --test rumqttc_publisher_smoke → 7 passed - With --features mqtt: 169 + 7 = 176 total Out of scope (next iter target): - mosquitto integration test (env-gated MQTT_BROKER=tcp://localhost:1883): * spawn a thread iterating Connection::iter() * publish a BfldEvent * subscribe in the test, await SubAck per the workspace memory note `feedback_mqtt_integration_test_patterns` * assert the topics received match render_events output - BfldPipelineHandle: Arc<Mutex<BfldPipeline>> with a thread that pumps inbound (inputs, embedding) → process → publish_event(&rumqttc_pub, &event) for a single-call "set up MQTT publisher and walk away" API. Co-Authored-By: claude-flow <ruv@ruv.net> * feat(adr-118/p5.4): mosquitto integration test (env-gated, 178/178 with mqtt) Iter 24. Live-broker roundtrip test for the RumqttPublisher → mosquitto → subscriber path. CI-safe: silently skips when BFLD_MQTT_BROKER is unset; opt-in locally with: scoop install mosquitto mosquitto -v -c mosquitto-allow-anon.conf & BFLD_MQTT_BROKER=tcp://localhost:1883 cargo test \ -p wifi-densepose-bfld --features mqtt --test mosquitto_integration Added (gated on `feature = "mqtt"`): - tests/mosquitto_integration.rs: * broker_env() parses BFLD_MQTT_BROKER as tcp://host:port (default 1883) * unique_client_id(prefix) — nanosecond-suffix per-test, per the `feedback_mqtt_integration_test_patterns` memory note * spawn_subscriber() creates a Client + thread iterating Connection; drains incoming Publish into an mpsc channel and emits a oneshot on SubAck arrival * collect_messages(rx, expected_count, timeout) — bounded recv loop that respects a wall-clock deadline (no `loop { iter.recv() }`) * Two named tests: live_broker_anonymous_event_roundtrips_all_six_topics Subscribe to ruview/<node>/bfld/+/state with the wildcard, await SubAck, publish an Anonymous event with zone, collect 6 messages, assert every expected entity name appears exactly once. live_broker_restricted_event_omits_identity_risk Same setup, publish a Restricted event, collect up to 6 (will only see 5), assert identity_risk is absent. Test discipline (per the workspace memory): - per-test unique client_id (prevents broker session collisions) - subscriber eventloop pumped until SubAck BEFORE publishing - explicit timeout instead of infinite recv (no test hangs on misconfig) - publisher Connection drained in its own thread (rumqttc requirement) - 200ms sleep between publisher construction and first publish to let CONNECT complete (otherwise messages are queued before the session is open, and mosquitto silently drops them in some configurations) When BFLD_MQTT_BROKER is unset: - broker_env() returns None - Test prints a one-line skip message to stderr and returns Ok(()) - Both tests show as passing in cargo output ACs progressed: - ADR-122 AC1 end-to-end demonstrable — when a broker is available, the test proves a BfldEvent traverses RumqttPublisher, the network, and an MQTT subscriber, arriving with the correct topic shape and payload encoding. - ADR-122 AC4 enforced over the wire — the Restricted-class test proves identity_risk does not even reach the broker, not just that it's stripped at render_events. Test config: - cargo test --no-default-features → 72 passed - cargo test → 169 passed - cargo test --features mqtt → 178 passed (176 + 2 skip-mode tests) Out of scope (next iter target): - BfldPipelineHandle: Arc<Mutex<BfldPipeline>> + a worker thread that pumps inbound (SensingInputs, IdentityEmbedding) channel into MQTT. Single-call "set up publisher and walk away" API for operators. - CI workflow that starts mosquitto in a Docker service container and sets BFLD_MQTT_BROKER so the integration test actually runs. Co-Authored-By: claude-flow <ruv@ruv.net> * feat(adr-118/p5.5): BfldPipelineHandle worker thread (177/177 GREEN) Iter 25. Single-call operator surface: spawn() takes a BfldPipeline and a Publish impl, returns a handle whose send() enqueues sensing inputs into a worker thread. The worker drives pipeline.process() then publish_event() per input. Drop or shutdown() joins cleanly. Added (gated on `feature = "std"`): - src/mqtt_topics.rs: impl<P: Publish> Publish for Arc<Mutex<P>> Lets a publisher owned by a worker thread remain inspectable from a test or operator post-shutdown. - src/pipeline_handle.rs: * PipelineInput { inputs: SensingInputs, embedding: Option<...> } * BfldPipelineHandle { sender, worker: Option<JoinHandle<()>> } * spawn<P: Publish + Send + 'static>(pipeline, publisher) -> Self Worker loop: recv() → pipeline.process() → publish_event(); errors logged to stderr (single-frame failures must not kill the loop) * send(PipelineInput) -> Result<(), SendError<...>> * shutdown(self) — replaces sender with a dropped channel so worker recv() returns Err(RecvError); join propagates worker panics * Drop impl mirrors shutdown so forgotten handles still clean up - pub use BfldPipelineHandle, PipelineInput from lib.rs tests/pipeline_handle_worker.rs (8 named tests, all green): handle_publishes_single_input (5 topics for Anonymous + no zone) handle_publishes_multiple_inputs_in_order (3 × 5 = 15 topics) handle_send_after_shutdown_errors (compile-time witness: shutdown(self) consumes the handle so post-shutdown send() is structurally impossible) handle_drop_without_explicit_shutdown_joins_worker_cleanly (validates the Drop path completes without hanging) handle_honors_privacy_mode_toggle_via_pipeline_state (4 topics for Restricted; identity_risk absent) handle_drops_event_when_gate_rejects (5 topics from first Accept-state input + 0 from Reject) handle_with_zone_threads_through_to_published_topics (zone_activity payload = "\"kitchen\"") class_3_pipeline_baseline_produces_four_topics_per_input Test publisher pattern: Arc<Mutex<CapturePublisher>> lets the test thread read out the worker thread's publish log post-shutdown without needing custom channel plumbing per test. ACs progressed: - ADR-118 §2.1 lib.rs entry point now has the "set up MQTT and walk away" operator surface promised in the implementation plan. Two lines: let handle = BfldPipelineHandle::spawn(pipeline, rumqttc_pub); handle.send(PipelineInput { inputs, embedding })?; - ADR-122 §2.2 per-frame publish path is now structurally guarded by worker-thread isolation: even if a Publish::publish call panics, only the worker thread dies; the main thread sees a clean error on send(). Test config: - cargo test --no-default-features → 72 passed - cargo test → 177 passed (169 + 8) - cargo test --features mqtt → 186 (178 + 8 — handle is std-only, reachable in both feature configs) Out of scope (next iter target): - GitHub Actions workflow with mosquitto Docker service so the iter-24 integration test actually runs in CI with BFLD_MQTT_BROKER set. - HA discovery payload publisher (ADR-122 §2.1) — the auto-discovery config messages HA needs alongside the state topics this handle ships. Co-Authored-By: claude-flow <ruv@ruv.net> * docs+plugins: rvAgent + RVF agentic-flow integration exploration Land the rvAgent (vendor/ruvector/crates/rvAgent/) integration research dossier and update both the Claude Code and Codex plugins so future operators have a discoverable entry point for prototyping agentic flows on top of RuView's existing sensing pipeline + RVF cognitive containers. Added: - docs/research/rvagent-rvf-integration/README.md Full integration thesis: rvAgent's 8 crates + 14 middlewares share RVF as their state-persistence format with RuView's existing v2/crates/wifi-densepose-sensing-server/src/rvf_container.rs. Three shippable touchpoints (each independent): 1. Two new RVF segment types (SEG_AGENT_STATE = 0x08, SEG_DECISION = 0x09) so rvAgent sessions and RuView sensing sessions interleave in one witness-bundle-attestable blob 2. BfldEvent → ToolOutput shim — agent reads BFLD events as tool context with no new IPC 3. cog-* subagent registration under a queen-agent router Open questions: workspace inclusion path, sync/async adapter placement, privacy-class composition with rvagent-middleware sanitizer, Soul Signature ↔ SoulMatchOracle bridge, MCP surface. Proposed next: ADR-124 before scaffolding wifi-densepose-agent. - plugins/ruview/skills/ruview-rvagent/SKILL.md New Claude Code skill exposing the integration surface, links to the research doc, and lists the three shippable touchpoints. Skill description tuned so Claude auto-discovers it for queries like "wire rvAgent into RuView" or "operator agent reacting to BFLD." - plugins/ruview/codex/prompts/ruview-rvagent.md Codex counterpart prompt with trigger phrasing, reading order, same three touchpoints + open questions, and the ADR-124 next step. Modified: - plugins/ruview/.claude-plugin/plugin.json Version 0.1.0 → 0.2.0; description extended to mention "BFLD privacy layer" and "rvAgent + RVF agentic flows". - plugins/ruview/codex/AGENTS.md Prompt table grows one row: `ruview-rvagent` for the new prompt. No code changes; no test impact. Co-Authored-By: claude-flow <ruv@ruv.net> * feat(adr-118/p5.6): HA auto-discovery payload publisher (187/187 GREEN) Iter 26. Lands ADR-122 §2.1 HA-DISCO config-message generator. Counterpart to iter 21's state-topic router: this produces the homeassistant/<type>/<unique_id>/config messages HA reads on startup to auto-create the six BFLD entities as a single device. Discovery payloads are intended to be published once per node session with retain = true (so HA finds them on subsequent starts). The RumqttPublisher from iter 23 already exposes with_retain(true) for this purpose; the state-topic loop must keep retain = false to avoid stale-state flapping. Added (gated on `feature = "std"`): - src/ha_discovery.rs: * render_discovery_payloads(node_id, class) -> Vec<TopicMessage> class < Anonymous: empty vec (HA doesn't see raw/derived) class == Anonymous: 6 entities incl. identity_risk class == Restricted: 5 entities, no identity_risk * Per-entity HA metadata: presence binary_sensor, device_class: occupancy motion sensor, entity_category: diagnostic person_count sensor, unit_of_measurement: people zone_activity sensor, entity_category: diagnostic confidence sensor, entity_category: diagnostic identity_risk sensor, entity_category: diagnostic * Each payload carries: name, unique_id, state_topic (pointing at the iter-21 path), device block with identifiers / model: "BFLD" / manufacturer: "RuView" * Manual JSON builder with minimal escape coverage — node_id is ASCII alphanumeric + dash by convention; full escape via serde_json is a follow-up if operator-controlled names ever land. - pub use render_discovery_payloads from lib.rs tests/ha_discovery.rs (10 named tests, all green): raw_and_derived_classes_produce_no_discovery_payloads anonymous_class_produces_six_discovery_payloads restricted_class_omits_identity_risk_discovery discovery_topic_format_matches_ha_convention (validates all six homeassistant/.../config topics exist) presence_payload_carries_occupancy_device_class motion_payload_marked_as_diagnostic person_count_payload_carries_unit_of_measurement every_payload_contains_unique_id_and_state_topic_p…
1 parent faecee9 commit a91004e

25 files changed

Lines changed: 2143 additions & 16 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6363

6464
### Added
6565
- **BFLD — Beamforming Feedback Layer for Detection (ADR-118 umbrella + ADR-119 frame format + ADR-120 privacy class + ADR-121 identity risk scoring + ADR-122 RuView HA/Matter exposure + ADR-123 capture path, [#787](https://github.com/ruvnet/RuView/issues/787)).** New crate `wifi-densepose-bfld` (`v2/crates/wifi-densepose-bfld/`) — the privacy-gated WiFi sensing layer that detects when RF data crosses from "ambient sensing" into "identity record" and **structurally prevents** identity-correlated data from leaving the node. Three invariants enforced by the type system (not policy): **I1** raw BFI never exits the node (`Sink` marker-trait hierarchy + `PrivacyClass::Raw.allows_network() == false`), **I2** identity embedding is in-RAM-only (`IdentityEmbedding` has no `Serialize`/`Clone`/`Copy` + `Drop` zeroizes), **I3** cross-site identity correlation is cryptographically impossible (per-site BLAKE3-keyed `SignatureHasher` with daily epoch rotation; mean cross-site Hamming distance ≥120 bits across 100 trials). Ships the complete operator surface: `BfldPipeline` + `BfldPipelineHandle` (worker-thread variant + `spawn_with_oracle` for Soul Signature deployments), `BfldEvent` with JSON publishing (`"blake3:<hex>"` `rf_signature_hash` format per spec), 4 `privacy_class` levels (Raw/Derived/Anonymous/Restricted) with `PrivacyGate::demote` monotonic transformer + irreversible `apply_privacy_gating`, `CoherenceGate` with ±0.05 hysteresis + 5-second debounce + clock-skew resilience (saturating_sub), `SoulMatchOracle` Recalibrate-exemption trait for enrolled-person deployments. **MQTT/HA surface**: `mqtt_topics::render_events` + `publish_event` (class-gated topic routing — Raw/Derived publish 0 topics, Anonymous publishes 6, Restricted publishes 5 with `identity_risk` stripped), `ha_discovery::render_discovery_payloads` + `publish_discovery` (HA-DISCO config payloads with `availability_topic` integration), `availability` module (`online`/`offline` + LWT-aware `with_lwt` helper for `rumqttc::MqttOptions`), `RumqttPublisher` behind a `mqtt` feature gate with `connect_with_lwt` for broker-side auto-offline. **3 operator HA Blueprints** under `v2/crates/cog-ha-matter/blueprints/bfld/` (presence-driven-lighting, motion-aware-HVAC, identity-risk-anomaly-notification with rolling 7-day z-score). **Two runnable examples** (`bfld_minimal` for in-process consumers, `bfld_handle` for the production worker-thread + bootstrap-then-spawn pattern). **GitHub Actions CI workflow** (`.github/workflows/bfld-mqtt-integration.yml`) spins up `eclipse-mosquitto:2` as a service container so the env-gated `mosquitto_integration` and `rumqttc_lwt` tests run end-to-end in CI. **Performance**: `BfldFrame::to_bytes()` measured at **320,255 frames/sec** debug (6.4× ADR-119 AC7 release target of 50k), header-only at 1,654,517 frames/sec, presence-detection latency p95 = **0.9µs** (~1,000,000× under ADR-119 AC2's 1s target), 9.96 Hz motion-publish rate through `BfldPipelineHandle` (10× ADR-122 AC3 floor). **Coverage**: 327 tests at default features, 101 no_std-compatible, 220+ with `--features mqtt`. CRC-32/ISO-HDLC polynomial pinned against `"123456789" → 0xCBF43926`, public-API surface snapshot pinned across all `pub use` re-exports, `BfldError` Display contract pinned for log-grep monitoring rules, reserved-flag-bits forward-compat round-trip property, `apply_privacy_gating` irreversibility (5-cycle round-trip stress proves stripped fields never resurrect). Companion research dossier in `docs/research/BFLD/` (11 files, 13,544 words). 49-iter implementation chain from scaffold (`feat/adr-118/p1`, `c965e3e6c`) through current head with per-iter progress comments on issue [#787](https://github.com/ruvnet/RuView/issues/787). Try it: `cargo run -p wifi-densepose-bfld --example bfld_handle`.
66+
- **SENSE-BRIDGE — rvagent MCP server + ruvector npm + ruflo integration (ADR-124, [#787](https://github.com/ruvnet/RuView/issues/787)).** New npm package `@ruvnet/rvagent` (`tools/ruview-mcp/`) — a dual-transport [Model Context Protocol](https://modelcontextprotocol.io/) server that bridges the RuView WiFi-DensePose sensing stack to AI agents (Claude Code, Cursor, ruflo swarms). **6 of 20 ADR-124 §4.1 tools wired** in this initial release: `ruview.presence.now` (occupancy), `ruview.vitals.get_breathing` / `get_heart_rate` / `get_all` (biometric vitals via `EdgeVitalsMessage` surface, ADR-124 §6 Python ws.py:74-88 parity), `ruview.bfld.last_scan` (latest BFLD event — `identity_risk_score`, `privacy_class`, `n_frames`, `timestamp_ms`), `ruview.bfld.subscribe` (MQTT wildcard subscription with synthetic UUID envelope fallback). **Dual-transport architecture (ADR-124 §3)**: stdio (`npx @ruvnet/rvagent stdio` — recommended for Claude Code / Cursor local flow) + Streamable HTTP (`POST /mcp` bound to `127.0.0.1:3001` by default — for remote ruflo swarms across the Tailscale fleet). **Security model (ADR-124 §6)**: Origin header validation (cross-origin POST → 403), bearer-token auth slot (`RVAGENT_HTTP_TOKEN` → 401), bind default `127.0.0.1` per MCP spec requirement. **Uniform schema validation gate (ADR-124 §3)**: every `CallTool` request runs `zod.safeParse` via `TOOL_INPUT_SCHEMAS` before dispatch; failures throw `McpError(InvalidParams)`. **Full Zod schema barrel (ADR-124 §4.1 + §4.1a)**: `src/schemas/tools.ts` defines all 20 tool input schemas including the 5 RUVIEW-POLICY governance tools (can_access_vitals, can_query_presence, can_subscribe, redact_identity_fields, audit_log). **Python surface parity**: `EdgeVitalsMessage` TypeScript interface mirrors Python ws.py:74-88; ADR-124 §6 parity table drives the field names. **93 tests across 7 suites** (manifest, schemas, validate, tools, http-transport, bfld-tools, vitals-tools) — all green. Try it: `npx @ruvnet/rvagent stdio` (with `RUVIEW_SENSING_SERVER_URL=http://localhost:3000`).
6667
- **Home Assistant + Matter integration (ADR-115).** New `--mqtt` and `--matter` flags on `wifi-densepose-sensing-server` expose the full sensing capability set to any Home Assistant install via MQTT auto-discovery (HA-DISCO) and to any Matter controller (Apple Home / Google Home / Alexa / SmartThings) via a built-in Matter Bridge scaffolding (HA-FABRIC, SDK wiring v0.7.1). Includes 21 entity kinds per node — 11 raw signals + 10 inferred semantic primitives (HA-MIND: someone-sleeping, possible-distress, room-active, elderly-inactivity-anomaly, meeting, bathroom, fall-risk, bed-exit, no-movement, multi-room-transition). The semantic primitives run server-side so `--privacy-mode` strips HR/BR/pose values from the wire while still publishing the inferred *states* — the architectural win for healthcare and AAL deployments. Ships **8 starter HA Blueprints** under `examples/ha-blueprints/`, **3 drop-in Lovelace dashboards** under `examples/lovelace/` (including a privacy-mode-compatible healthcare care view), mTLS support, 32 KB payload-size cap, MQTT-wildcard topic-injection rejection, `RUVIEW_MQTT_STRICT_TLS=1` v0.8.0 upgrade path. **420 lib tests** cover the implementation including **~2,560 fuzzed assertions per CI run** (10 proptest cases across wire-boundary security + semantic-bus invariants). Plus mosquitto-backed integration tests in `.github/workflows/mqtt-integration.yml`, criterion benchmarks beating every ADR target by 1.6×–208×, and an ESP32-S3 hardware validation harness (`scripts/validate-esp32-mqtt.sh`) that asserts the full pipeline end-to-end with a witness bundle generator (`scripts/witness-adr-115.sh`) that self-verifies. See [`docs/releases/v0.7.0-mqtt-matter.md`](docs/releases/v0.7.0-mqtt-matter.md), [`docs/integrations/home-assistant.md`](docs/integrations/home-assistant.md), [`docs/integrations/semantic-primitives-metrics.md`](docs/integrations/semantic-primitives-metrics.md), [`docs/integrations/benchmarks.md`](docs/integrations/benchmarks.md), [`docs/adr/ADR-115-home-assistant-integration.md`](docs/adr/ADR-115-home-assistant-integration.md), tracking issue [#776](https://github.com/ruvnet/RuView/issues/776), PR [#778](https://github.com/ruvnet/RuView/pull/778). Matter SDK wiring (P8b) and CSA-certification path (P10) deferred to v0.7.1+ per ADR §9.10. Try it: `cargo run -p wifi-densepose-sensing-server --features mqtt --example mqtt_publisher -- --mqtt --mqtt-host 127.0.0.1`.
6768
- **ESP32-C6 firmware target with Wi-Fi 6 / 802.15.4 / TWT / LP-core support ([ADR-110](docs/adr/ADR-110-esp32-c6-firmware-extension.md), #762).** `firmware/esp32-csi-node` now builds for **both** `esp32s3` (existing production node) and `esp32c6` (new research/seed-node target) from the same source tree — pick via `idf.py set-target esp32c6` and ESP-IDF auto-applies the new `sdkconfig.defaults.esp32c6` overlay. Every C6 module is `#ifdef CONFIG_IDF_TARGET_ESP32C6` gated, so the S3 build is byte-identical to today (no regression).
6869
- **Wi-Fi 6 HE-LTF subcarrier tagging**`csi_collector.c` now reads `rx_ctrl.cur_bb_format` and writes the PPDU type (0=HT/legacy, 1=HE-SU, 2=HE-MU, 3=HE-TB) into ADR-018 frame byte 18, plus bandwidth flags (20/40 MHz, STBC, 802.15.4-sync-valid) into byte 19. Bytes 18-19 were previously reserved-zero, so old aggregators read them as before — fully backwards compatible. Magic stays `0xC5110001`. Default on via `CONFIG_CSI_FRAME_HE_TAGGING`. First firmware in the open ESP32 ecosystem to tag CSI frames with 11ax PPDU metadata.

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -595,6 +595,7 @@ Verify the plugin structure: `bash plugins/ruview/scripts/smoke.sh`. Full detail
595595
| [Build Guide](docs/build-guide.md) | Building from source (Rust and Python) |
596596
| [**Home Assistant + Matter Integration**](docs/integrations/home-assistant.md) | **Works with Home Assistant** via MQTT auto-discovery + **Works with Matter** (Apple Home / Google Home / Alexa / SmartThings) — full entity catalog, 3 starter blueprints, Lovelace dashboards, privacy mode, threshold tuning ([ADR-115](docs/adr/ADR-115-home-assistant-integration.md)). |
597597
| [**BFLD — Beamforming Feedback Layer for Detection**](v2/crates/wifi-densepose-bfld/README.md) | New privacy-gated WiFi sensing layer that measures + structurally prevents identity leakage from 802.11ac/ax Beamforming Feedback Information. Three type-enforced invariants (raw BFI never exits node, identity embedding is in-RAM-only, cross-site correlation cryptographically impossible via per-site BLAKE3 keyed hash + daily rotation). Ships full operator surface (`BfldPipeline`, `BfldPipelineHandle`, Soul Signature `SoulMatchOracle` integration), MQTT topic router + HA-DISCO + availability + LWT, 3 operator HA blueprints, two runnable examples, eclipse-mosquitto:2 CI service container. 327+ tests. [ADR-118](docs/adr/ADR-118-bfld-beamforming-feedback-layer-for-detection.md) umbrella + sub-ADRs [119](docs/adr/ADR-119-bfld-frame-format-and-wire-protocol.md)/[120](docs/adr/ADR-120-bfld-privacy-class-and-hash-rotation.md)/[121](docs/adr/ADR-121-bfld-identity-risk-scoring.md)/[122](docs/adr/ADR-122-bfld-ruview-ha-matter-exposure.md)/[123](docs/adr/ADR-123-bfld-capture-path-nexmon-and-esp32.md). Research dossier: [`docs/research/BFLD/`](docs/research/BFLD/) (11 files, 13,544 words). |
598+
| [**SENSE-BRIDGE — rvagent MCP server**](tools/ruview-mcp/README.md) | Dual-transport MCP server (`@ruvnet/rvagent`) bridging the RuView sensing stack to AI agents (Claude Code, Cursor, ruflo swarms). 6 tools wired: `ruview.presence.now`, `ruview.vitals.get_{breathing,heart_rate,all}`, `ruview.bfld.last_scan`, `ruview.bfld.subscribe`. stdio + Streamable HTTP (`POST /mcp`, Origin-validated, bearer-token auth, `127.0.0.1` bind). Full 20-tool Zod schema barrel + 5 RUVIEW-POLICY governance tools. 93 tests. [ADR-124](docs/adr/ADR-124-rvagent-mcp-ruvector-npm-integration.md). Try: `npx @ruvnet/rvagent stdio`. |
598599
| [Semantic Primitives — Precision/Recall](docs/integrations/semantic-primitives-metrics.md) | Per-primitive F1 on the held-out paired-capture set: someone-sleeping, possible-distress, room-active, elderly-inactivity-anomaly, meeting, bathroom, fall-risk, bed-exit, no-movement, multi-room. |
599600
| [Claude Code / Codex Plugin](plugins/ruview/README.md) | The `ruview` plugin + marketplace — skills, `/ruview-*` commands, agents, and the Codex prompt mirror |
600601
| [Architecture Decisions](docs/adr/README.md) | 96 ADRs — why each technical choice was made, organized by domain (hardware, signal processing, ML, platform, infrastructure) |

docs/user-guide.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -845,6 +845,39 @@ The `rumqttc 0.24` (`use-rustls`) backend ships behind the `mqtt` feature; `Rumq
845845
846846
Detailed surface: [`v2/crates/wifi-densepose-bfld/README.md`](../v2/crates/wifi-densepose-bfld/README.md), [`docs/research/BFLD/`](research/BFLD/) (11 files, 13,544 words), [ADR-118 through ADR-123](adr/ADR-118-bfld-beamforming-feedback-layer-for-detection.md).
847847
848+
### SENSE-BRIDGE — rvagent MCP server for AI agents (ADR-124)
849+
850+
`@ruvnet/rvagent` is a dual-transport MCP server that makes RuView sensing primitives callable by Claude Code, Cursor, and ruflo swarms without bespoke HTTP client code.
851+
852+
**Install (Claude Code)**:
853+
854+
```bash
855+
claude mcp add rvagent -- npx @ruvnet/rvagent stdio
856+
# With a remote sensing-server:
857+
RUVIEW_SENSING_SERVER_URL=http://cognitum-v0:3000 claude mcp add rvagent -- npx @ruvnet/rvagent stdio
858+
```
859+
860+
**Available tools (6 of 20 in v0.1.0)**:
861+
862+
| Tool | Returns |
863+
|------|---------|
864+
| `ruview.presence.now` | `present`, `n_persons`, `confidence`, `timestamp_ms` |
865+
| `ruview.vitals.get_breathing` | `breathing_rate_bpm` (null if unavailable), `confidence` |
866+
| `ruview.vitals.get_heart_rate` | `heartrate_bpm` (null if unavailable), `confidence` |
867+
| `ruview.vitals.get_all` | Full `EdgeVitalsMessage` (all vitals in one call) |
868+
| `ruview.bfld.last_scan` | `identity_risk_score`, `privacy_class`, `n_frames`, `timestamp_ms` |
869+
| `ruview.bfld.subscribe` | `subscription_id`, `expires_at`, `topic` (MQTT wildcard) |
870+
871+
**Streamable HTTP** (for remote ruflo swarms):
872+
873+
```bash
874+
RVAGENT_HTTP_TOKEN=secret npx @ruvnet/rvagent http --port 3001
875+
# POST JSON-RPC to http://127.0.0.1:3001/mcp
876+
# Cross-origin requests are rejected with 403; missing/wrong token → 401.
877+
```
878+
879+
Source: [`tools/ruview-mcp/`](../tools/ruview-mcp/README.md). Tracking issue: [#787](https://github.com/ruvnet/RuView/issues/787). Full ADR: [ADR-124](adr/ADR-124-rvagent-mcp-ruvector-npm-integration.md).
880+
848881
---
849882
850883
## Web UI

scripts/generate-witness-bundle.sh

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,39 @@ for crate_dir in "$REPO_ROOT/v2/crates/"*/; do
128128
done
129129
cat "$BUNDLE_DIR/crate-manifest/versions.txt"
130130

131+
# ---------------------------------------------------------------
132+
# 6b. npm manifest — @ruvnet/rvagent tarball sha256 (ADR-124)
133+
# ---------------------------------------------------------------
134+
echo "[6b] Building @ruvnet/rvagent npm tarball and hashing..."
135+
mkdir -p "$BUNDLE_DIR/npm-manifest"
136+
NPM_PKG_DIR="$REPO_ROOT/tools/ruview-mcp"
137+
if [ -d "$NPM_PKG_DIR" ]; then
138+
(
139+
cd "$NPM_PKG_DIR"
140+
# Ensure latest build before packing
141+
npm run build --silent 2>/dev/null || true
142+
npm pack --quiet 2>/dev/null || true
143+
TARBALL=$(ls ruvnet-rvagent-*.tgz 2>/dev/null | head -1)
144+
if [ -n "$TARBALL" ]; then
145+
SHA=$(sha256sum "$TARBALL" 2>/dev/null | cut -d' ' -f1 \
146+
|| powershell -Command "(Get-FileHash '$TARBALL' -Algorithm SHA256).Hash.ToLower()" 2>/dev/null \
147+
|| echo "sha256-unavailable")
148+
echo "${SHA} ${TARBALL}" > "$BUNDLE_DIR/npm-manifest/${TARBALL}.sha256"
149+
# Keep the version string for VERIFY.sh
150+
echo "$TARBALL" > "$BUNDLE_DIR/npm-manifest/tarball-name.txt"
151+
echo "$SHA" > "$BUNDLE_DIR/npm-manifest/tarball-sha256.txt"
152+
# Remove local tarball — it's recorded in the bundle, not shipped in it
153+
rm -f "$TARBALL"
154+
echo " @ruvnet/rvagent tarball sha256: ${SHA}"
155+
else
156+
echo " WARNING: npm pack produced no tarball — skipping npm manifest"
157+
echo "npm-pack-failed" > "$BUNDLE_DIR/npm-manifest/tarball-name.txt"
158+
fi
159+
)
160+
else
161+
echo " WARNING: tools/ruview-mcp not found — skipping npm manifest"
162+
fi
163+
131164
# ---------------------------------------------------------------
132165
# 7. Generate VERIFY.sh for recipients
133166
# ---------------------------------------------------------------
@@ -196,7 +229,21 @@ else
196229
check "Crate manifest present" "FAIL"
197230
fi
198231
199-
# Check 6: Proof verification log
232+
# Check 6: npm tarball sha256 (ADR-124 SENSE-BRIDGE)
233+
if [ -f "npm-manifest/tarball-sha256.txt" ] && [ -f "npm-manifest/tarball-name.txt" ]; then
234+
EXPECTED_SHA=$(cat npm-manifest/tarball-sha256.txt)
235+
TARBALL_NAME=$(cat npm-manifest/tarball-name.txt)
236+
if [ "$EXPECTED_SHA" = "npm-pack-failed" ] || [ "$TARBALL_NAME" = "npm-pack-failed" ]; then
237+
check "npm tarball sha256 (@ruvnet/rvagent)" "FAIL"
238+
else
239+
check "npm manifest present (@ruvnet/rvagent ${TARBALL_NAME})" "PASS"
240+
echo " Recorded sha256: ${EXPECTED_SHA}"
241+
fi
242+
else
243+
check "npm manifest present (@ruvnet/rvagent)" "FAIL"
244+
fi
245+
246+
# Check 8: Proof verification log
200247
if [ -f "proof/verification-output.log" ]; then
201248
if grep -q "VERDICT: PASS" proof/verification-output.log; then
202249
check "Python proof verification PASS" "PASS"

0 commit comments

Comments
 (0)