mirror of
https://github.com/ruvnet/RuView
synced 2026-06-30 13:43:18 +00:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2dfc805f1e | |||
| 51b3433471 | |||
| b71d243b42 | |||
| bd91cd1e8a | |||
| 3f6c6eb108 | |||
| 61087b1588 | |||
| 5de8718882 | |||
| 78916d8455 | |||
| f9d99c50d9 | |||
| f21daf9aa8 | |||
| 2d29359809 | |||
| 4ac0a4d52b | |||
| cbd24cd1ed | |||
| fd0568caa1 | |||
| 4ec5b166e6 | |||
| 5d90d4fef2 | |||
| fd8b9c30e7 |
@@ -126,7 +126,10 @@
|
||||
"Bash(node .claude/*)",
|
||||
"mcp__claude-flow__:*"
|
||||
],
|
||||
"deny": []
|
||||
"deny": [
|
||||
"Read(./.env)",
|
||||
"Read(./.env.*)"
|
||||
]
|
||||
},
|
||||
"attribution": {
|
||||
"commit": "Co-Authored-By: claude-flow <ruv@ruv.net>",
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
name: BFLD MQTT Integration
|
||||
|
||||
# Runs the env-gated mosquitto integration tests from iters 24 + 29 of the
|
||||
# BFLD rollout (ADR-118 / ADR-122 §2.2). Spins up an eclipse-mosquitto:2
|
||||
# service container, exports BFLD_MQTT_BROKER, runs `cargo test --features
|
||||
# mqtt`. Local developers can reproduce with:
|
||||
#
|
||||
# scoop install mosquitto # Windows
|
||||
# # or: docker run -p 1883:1883 eclipse-mosquitto:2
|
||||
# BFLD_MQTT_BROKER=tcp://localhost:1883 \
|
||||
# cargo test -p wifi-densepose-bfld --features mqtt
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- 'feat/adr-118-*'
|
||||
- 'feat/bfld-*'
|
||||
paths:
|
||||
- 'v2/crates/wifi-densepose-bfld/**'
|
||||
- '.github/workflows/bfld-mqtt-integration.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'v2/crates/wifi-densepose-bfld/**'
|
||||
- '.github/workflows/bfld-mqtt-integration.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
mqtt-live-broker:
|
||||
name: cargo test --features mqtt (live mosquitto)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
services:
|
||||
mosquitto:
|
||||
image: eclipse-mosquitto:2
|
||||
ports:
|
||||
- 1883:1883
|
||||
# Allow anonymous connections — local-only CI broker, no exposure
|
||||
# to the public internet, never touches production credentials.
|
||||
options: >-
|
||||
--health-cmd "mosquitto_pub -h localhost -t healthcheck -m ping || exit 1"
|
||||
--health-interval 5s
|
||||
--health-timeout 3s
|
||||
--health-retries 10
|
||||
|
||||
env:
|
||||
BFLD_MQTT_BROKER: tcp://localhost:1883
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
RUSTFLAGS: -D warnings
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy
|
||||
|
||||
- name: Cache cargo registry + target
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
v2/target
|
||||
key: bfld-mqtt-${{ runner.os }}-${{ hashFiles('v2/Cargo.lock') }}
|
||||
|
||||
- name: Wait for mosquitto to be ready
|
||||
run: |
|
||||
for i in {1..20}; do
|
||||
if nc -z localhost 1883; then
|
||||
echo "mosquitto reachable on port 1883 (attempt $i)"
|
||||
exit 0
|
||||
fi
|
||||
echo "waiting for mosquitto ($i/20)..."
|
||||
sleep 1
|
||||
done
|
||||
echo "mosquitto never became reachable" >&2
|
||||
exit 1
|
||||
|
||||
- name: cargo test --no-default-features (baseline regression)
|
||||
working-directory: v2
|
||||
run: cargo test -p wifi-densepose-bfld --no-default-features
|
||||
|
||||
- name: cargo test (default features)
|
||||
working-directory: v2
|
||||
run: cargo test -p wifi-densepose-bfld
|
||||
|
||||
- name: cargo test --features mqtt (incl. live mosquitto roundtrip)
|
||||
working-directory: v2
|
||||
run: cargo test -p wifi-densepose-bfld --features mqtt
|
||||
|
||||
- name: cargo clippy --features mqtt (lint gate)
|
||||
working-directory: v2
|
||||
run: cargo clippy -p wifi-densepose-bfld --features mqtt --all-targets -- -D warnings
|
||||
continue-on-error: true
|
||||
@@ -26,8 +26,6 @@ on:
|
||||
- 'v2/crates/wifi-densepose-signal/**'
|
||||
- 'v2/crates/wifi-densepose-vitals/**'
|
||||
- 'v2/crates/wifi-densepose-wifiscan/**'
|
||||
- 'v2/crates/wifi-densepose-bfld/**'
|
||||
- 'v2/crates/cog-ha-matter/**'
|
||||
- 'v2/Cargo.toml'
|
||||
- 'v2/Cargo.lock'
|
||||
- 'ui/**'
|
||||
@@ -61,16 +59,11 @@ jobs:
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
# Bypassing docker/login-action@v3: the action kept emitting
|
||||
# "malformed HTTP Authorization header" against a known-good
|
||||
# dckr_pat_* token (verified by direct curl against the Hub API).
|
||||
# `docker login --password-stdin` is the documented credential
|
||||
# path and avoids whatever encoding step the action injects.
|
||||
env:
|
||||
DH_USER: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
DH_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
run: |
|
||||
printf '%s' "$DH_TOKEN" | docker login docker.io -u "$DH_USER" --password-stdin
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: docker.io
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Log in to ghcr.io
|
||||
uses: docker/login-action@v3
|
||||
|
||||
@@ -62,8 +62,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
they can be reintroduced with a real implementation.
|
||||
|
||||
### Added
|
||||
- **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`.
|
||||
- **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`).
|
||||
- **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`.
|
||||
- **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).
|
||||
- **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.
|
||||
|
||||
@@ -594,8 +594,6 @@ Verify the plugin structure: `bash plugins/ruview/scripts/smoke.sh`. Full detail
|
||||
| [User Guide](docs/user-guide.md) | Step-by-step guide: installation, first run, API usage, hardware setup, training |
|
||||
| [Build Guide](docs/build-guide.md) | Building from source (Rust and Python) |
|
||||
| [**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)). |
|
||||
| [**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). |
|
||||
| [**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`. |
|
||||
| [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. |
|
||||
| [Claude Code / Codex Plugin](plugins/ruview/README.md) | The `ruview` plugin + marketplace — skills, `/ruview-*` commands, agents, and the Codex prompt mirror |
|
||||
| [Architecture Decisions](docs/adr/README.md) | 96 ADRs — why each technical choice was made, organized by domain (hardware, signal processing, ML, platform, infrastructure) |
|
||||
|
||||
+5
-14
@@ -3,7 +3,7 @@
|
||||
# Multi-stage build for minimal final image
|
||||
|
||||
# Stage 1: Build
|
||||
FROM rust:1.89-bookworm AS builder
|
||||
FROM rust:1.85-bookworm AS builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
@@ -14,14 +14,9 @@ COPY v2/crates/ ./crates/
|
||||
# Copy vendored RuVector crates
|
||||
COPY vendor/ruvector/ /build/vendor/ruvector/
|
||||
|
||||
# Build release binaries:
|
||||
# - sensing-server with `mqtt` feature so the HA-DISCO MQTT publisher
|
||||
# (ADR-115) is wired in (auto-discovery topics flow to Home Assistant)
|
||||
# - cog-ha-matter, the ADR-116 Cognitum cog that wraps HA-DISCO +
|
||||
# HA-MIND + mDNS + embedded broker for Home Assistant / Matter
|
||||
RUN cargo build --release -p wifi-densepose-sensing-server --features mqtt 2>&1 \
|
||||
&& cargo build --release -p cog-ha-matter 2>&1 \
|
||||
&& strip target/release/sensing-server target/release/cog-ha-matter
|
||||
# Build release binary
|
||||
RUN cargo build --release -p wifi-densepose-sensing-server 2>&1 \
|
||||
&& strip target/release/sensing-server
|
||||
|
||||
# Stage 2: Runtime
|
||||
FROM debian:bookworm-slim
|
||||
@@ -32,9 +27,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy binaries
|
||||
# Copy binary
|
||||
COPY --from=builder /build/target/release/sensing-server /app/sensing-server
|
||||
COPY --from=builder /build/target/release/cog-ha-matter /app/cog-ha-matter
|
||||
|
||||
# Copy UI assets
|
||||
COPY ui/ /app/ui/
|
||||
@@ -51,7 +45,6 @@ RUN set -e; \
|
||||
test -d "$d" || { echo "FATAL: missing UI directory $d"; exit 1; }; \
|
||||
done; \
|
||||
test -x /app/sensing-server || { echo "FATAL: /app/sensing-server is not executable"; exit 1; }; \
|
||||
test -x /app/cog-ha-matter || { echo "FATAL: /app/cog-ha-matter is not executable"; exit 1; }; \
|
||||
echo "image assets OK"
|
||||
|
||||
# Optional bearer-token auth on /api/v1/*: leave unset for LAN-mode (default),
|
||||
@@ -65,8 +58,6 @@ EXPOSE 3000
|
||||
EXPOSE 3001
|
||||
# ESP32 UDP
|
||||
EXPOSE 5005/udp
|
||||
# MQTT broker (cog-ha-matter embedded broker — Home Assistant + Matter)
|
||||
EXPOSE 1883
|
||||
|
||||
ENV RUST_LOG=info
|
||||
|
||||
|
||||
@@ -15,21 +15,6 @@
|
||||
# MODELS_DIR — directory to scan for .rvf model files (default: data/models)
|
||||
set -e
|
||||
|
||||
# Route to cog-ha-matter (ADR-116) when invoked as:
|
||||
# docker run <image> cog-ha-matter [--flags]
|
||||
# or via the short alias `ha-matter`. Strips the keyword and execs the
|
||||
# Home Assistant + Matter cog binary, defaulting --sensing-url to the
|
||||
# co-located sensing-server endpoint so docker-compose deployments work
|
||||
# out of the box.
|
||||
case "${1:-}" in
|
||||
cog-ha-matter|ha-matter)
|
||||
shift
|
||||
exec /app/cog-ha-matter \
|
||||
--sensing-url "${SENSING_URL:-http://127.0.0.1:3000}" \
|
||||
"$@"
|
||||
;;
|
||||
esac
|
||||
|
||||
# If the first argument looks like a flag (starts with -), prepend the
|
||||
# server binary so users can just pass flags:
|
||||
# docker run <image> --source esp32 --tick-ms 500
|
||||
|
||||
@@ -1,196 +0,0 @@
|
||||
# ADR-118: BFLD — Beamforming Feedback Layer for Detection
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Proposed |
|
||||
| **Date** | 2026-05-24 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codename** | **BFLD** — Beamforming Feedback Layer for Detection |
|
||||
| **Relates to** | [ADR-024](ADR-024-contrastive-csi-embedding-model.md) (AETHER), [ADR-027](ADR-027-cross-environment-domain-generalization.md) (MERIDIAN), [ADR-028](ADR-028-esp32-capability-audit.md) (witness), [ADR-029](ADR-029-ruvsense-multistatic-sensing-mode.md) (multistatic), [ADR-030](ADR-030-ruvsense-persistent-field-model.md) (field model), [ADR-031](ADR-031-ruview-sensing-first-rf-mode.md) (sensing-first), [ADR-032](ADR-032-multistatic-mesh-security-hardening.md) (mesh security), [ADR-095](ADR-095-rvcsi-edge-rf-sensing-platform.md) (rvCSI), [ADR-115](ADR-115-home-assistant-integration.md) (HA), [ADR-116](ADR-116-cog-ha-matter-seed.md) (Matter), [ADR-117](ADR-117-pip-wifi-densepose-modernization.md) (pip) |
|
||||
| **Sub-ADRs** | [ADR-119](ADR-119-bfld-frame-format-and-wire-protocol.md) (frame), [ADR-120](ADR-120-bfld-privacy-class-and-hash-rotation.md) (privacy), [ADR-121](ADR-121-bfld-identity-risk-scoring.md) (risk), [ADR-122](ADR-122-bfld-ruview-ha-matter-exposure.md) (RuView), [ADR-123](ADR-123-bfld-capture-path-nexmon-and-esp32.md) (capture) |
|
||||
| **Research bundle** | [`docs/research/BFLD/`](../research/BFLD/) (11 files, 13,544 words) |
|
||||
| **Companion research** | [`docs/research/soul/`](../research/soul/) — Soul Signature multi-modal biometric. BFLD is the policy-enforcement and compliance layer for Soul Signature; the two share the AETHER encoder (ADR-024), the witness chain (ADR-110/028), the RVF container, and `cross_room.rs` (ADR-030). |
|
||||
| **Tracking issue** | TBD |
|
||||
|
||||
---
|
||||
|
||||
## 1. Context
|
||||
|
||||
### 1.1 The plaintext BFI problem
|
||||
|
||||
IEEE 802.11ac and 802.11ax beamforming feedback (BFI) is exchanged between client stations (STA) and access points (AP) in **unencrypted management-plane frames**. The STA compresses the channel response into a Givens-rotation angle matrix (Φ/ψ) and transmits it as a VHT/HE Compressed Beamforming Report (CBFR). Any device in WiFi monitor mode within range can passively sniff these frames without joining the network.
|
||||
|
||||
Two independent 2024–2025 research results establish the severity of this exposure:
|
||||
|
||||
1. **BFId** (KIT, ACM CCS 2025) — re-identifies 197 individuals from BFI alone with >90% accuracy from 5 s of capture. https://publikationen.bibliothek.kit.edu/1000185756
|
||||
2. **LeakyBeam** (NDSS 2025) — detects occupancy through walls at 20 m with 82.7% TPR / 96.7% TNR using only plaintext BFI. https://www.ndss-symposium.org/wp-content/uploads/2025-5-paper.pdf
|
||||
|
||||
Capture tooling is freely available: **Wi-BFI** (pip-installable), **PicoScenes**, **Nexmon BFI patches** for BCM43455c0 (Raspberry Pi 5 / 4 / 3B+).
|
||||
|
||||
### 1.2 Gap in the existing RuView pipeline
|
||||
|
||||
The wifi-densepose / RuView pipeline processes CSI via the rvCSI runtime (ADR-095/096) and emits presence, pose, vitals, and zone-activity events. **No layer in the existing pipeline measures whether the data it is processing is capable of identifying individuals.** All CSI is treated as equivalent from a privacy standpoint regardless of operating regime.
|
||||
|
||||
This gap becomes a compliance and liability issue at deployment scale. An operator placing RuView in a care home, hotel, shared office, or rental property has no instrument to verify that the system is operating anonymously.
|
||||
|
||||
### 1.3 BFI as a sensing signal
|
||||
|
||||
BFI is not only a threat vector — its compressed angle matrices carry multipath geometry useful for presence and motion detection, particularly in single-AP deployments where MIMO CSI is unavailable. BFLD treats BFI as an **optional input alongside CSI**, not a replacement.
|
||||
|
||||
### 1.4 Relationship to the Soul Signature research
|
||||
|
||||
The Soul Signature research (`docs/research/soul/`) defines a 7-channel multi-modal biometric for **consent-based** passive re-identification of enrolled individuals. Where Soul Signature *intentionally produces* identity (with a 60-second enrollment protocol), BFLD *measures and gates* identity leakage from the same sensing substrate. The two systems are complementary by design:
|
||||
|
||||
| Concern | Soul Signature | BFLD |
|
||||
|---------|----------------|------|
|
||||
| Intent | Create a biometric for enrolled persons | Measure and gate identity leakage |
|
||||
| Consent model | Explicit enrollment, GDPR/HIPAA modes | Default-deny, all unenrolled persons |
|
||||
| Operating class | Must run at `privacy_class = 1` (derived) | Defaults to class 2 (anonymous) |
|
||||
| Shared assets | AETHER encoder (ADR-024), WitnessChain (ADR-110/028), RVF container, `cross_room.rs` (ADR-030) | Same |
|
||||
| ID space | Long-lived opaque `person_id` per enrolled subject | Rotating `rf_signature_hash` per day per unenrolled person |
|
||||
|
||||
BFLD becomes Soul Signature's enforcement layer: the `identity_risk_score` gates whether a zone is leaky enough to enroll, the witness bundle is the regulator-facing audit artifact, and the structural privacy invariants (I1/I2/I3) ensure unenrolled bystanders stay anonymous even in zones where Soul Signature is actively matching enrolled persons. See ADR-120 §2.7 and ADR-121 §2.7 for the integration points.
|
||||
|
||||
### 1.5 What this ADR is *not*
|
||||
|
||||
- Not a removal of the CSI pipeline. ADR-095/096 rvCSI stays authoritative for CSI.
|
||||
- Not a port of any external sniffer into the repo. The Nexmon capture path lives in a separate adapter (see ADR-123).
|
||||
- Not a Matter SDK ship — Matter exposure is filtered through the ADR-116 `cog-ha-matter` boundary.
|
||||
|
||||
---
|
||||
|
||||
## 2. Decision
|
||||
|
||||
Create a new Rust crate **`wifi-densepose-bfld`** in `v2/crates/` that:
|
||||
|
||||
1. **Ingests** BFI angle matrices (Φ/ψ) from CBFR frames, optionally fused with CSI.
|
||||
2. **Computes** nine named features and an `identity_risk_score` (separability × temporal_stability × cross_perspective_consistency × sample_confidence).
|
||||
3. **Gates** all output through a `privacy_class` byte that **structurally prevents** identity-correlated data from being published at classes 2 (anonymous) and 3 (restricted).
|
||||
4. **Emits** `BfldEvent` JSON over MQTT under `ruview/<node_id>/bfld/*` with per-class topic routing.
|
||||
5. **Enforces three invariants structurally, not by policy**:
|
||||
- **I1**: Raw BFI never exits the node.
|
||||
- **I2**: Identity embedding is in-RAM-only (no disk, no network).
|
||||
- **I3**: Cross-site identity correlation is cryptographically impossible via per-site keyed BLAKE3 hash rotation with a daily epoch.
|
||||
|
||||
The umbrella implementation is decomposed into five sub-ADRs:
|
||||
|
||||
| Sub-ADR | Scope |
|
||||
|---------|-------|
|
||||
| **ADR-119** | `BfldFrame` wire format, magic `0xBF1D_0001`, deterministic serialization, CRC32 |
|
||||
| **ADR-120** | `privacy_class` semantics, BLAKE3 hash rotation, default-deny field classification |
|
||||
| **ADR-121** | Identity risk scoring formula, coherence gate, leakage estimator |
|
||||
| **ADR-122** | RuView surface: HA entities, Matter cluster boundary, MQTT topic ACL |
|
||||
| **ADR-123** | Capture path: Pi 5 / Nexmon adapter + ESP32-S3 BFI feasibility |
|
||||
|
||||
### 2.1 Crate module layout
|
||||
|
||||
```
|
||||
v2/crates/wifi-densepose-bfld/
|
||||
├── Cargo.toml
|
||||
└── src/
|
||||
├── lib.rs
|
||||
├── frame.rs # BfldFrame (ADR-119)
|
||||
├── extractor.rs # CBFR parser → BfiCapture
|
||||
├── features.rs # 9 features
|
||||
├── identity_risk.rs # risk score (ADR-121)
|
||||
├── privacy_gate.rs # privacy_class enforcement (ADR-120)
|
||||
├── hash_rotation.rs # BLAKE3 per-site rotation (ADR-120)
|
||||
├── emitter.rs # BfldEvent → MQTT
|
||||
├── mqtt.rs # topic routing (ADR-122)
|
||||
└── ffi.rs # PyO3 bindings (ADR-117 pattern)
|
||||
```
|
||||
|
||||
### 2.2 Reuse map
|
||||
|
||||
| BFLD module | Depends on |
|
||||
|---|---|
|
||||
| `features.rs` | `wifi-densepose-signal/src/ruvsense/coherence.rs`, `multistatic.rs` |
|
||||
| `identity_risk.rs` | `wifi-densepose-ruvector/src/viewpoint/attention.rs`, `coherence.rs` |
|
||||
| `privacy_gate.rs` | (new) — no upstream dependency |
|
||||
| `hash_rotation.rs` | `blake3 = "1.5"` (keyed mode) |
|
||||
| `extractor.rs` | `vendor/rvcsi/crates/rvcsi-adapter-nexmon` (ADR-095/096) |
|
||||
|
||||
---
|
||||
|
||||
## 3. Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- First explicit, auditable RF-layer privacy primitive in the wifi-densepose ecosystem.
|
||||
- `identity_risk_score` doubles as an anomaly signal (sudden spike → new AP firmware / nearby attacker-grade sniffer / unusual propagation).
|
||||
- BFI fusion augments presence/motion in single-AP deployments.
|
||||
- Deterministic frame hashes extend the ADR-028 witness-bundle pattern to the new surface.
|
||||
- Cross-site isolation is **structural, not policy-dependent** — a stronger guarantee than ACLs.
|
||||
|
||||
### Negative
|
||||
|
||||
- ESP32-S3 cannot directly capture CBFR via the Espressif WiFi API. Full BFLD pipeline requires a Pi 5 / Nexmon host sniffer (cognitum-v0 available; see ADR-123).
|
||||
- `identity_risk_score` calibration requires the KIT BFId dataset (non-commercial research agreement).
|
||||
- Estimated effort: ~10.5 engineer-weeks across the six ADRs.
|
||||
|
||||
### Neutral
|
||||
|
||||
- BFLD does not prevent passive BFI capture by an external attacker (LeakyBeam-class). It only ensures the **node's own output** is non-identifying. Operators must understand this distinction.
|
||||
- Daily hash rotation prevents multi-day analytics correlating individual signatures across the day boundary. Acceptable for privacy goals; may surprise analytics use-cases.
|
||||
|
||||
---
|
||||
|
||||
## 4. Alternatives Considered
|
||||
|
||||
### Alt 1: Skip BFI entirely (CSI-only)
|
||||
|
||||
Rejected because: (a) leaves the identity-leakage gap open for the CSI pipeline; (b) as BFI tooling becomes ubiquitous (Wi-BFI, PicoScenes), the absence of a privacy layer becomes more conspicuous for operators.
|
||||
|
||||
### Alt 2: Publish `identity_risk_score` publicly by default
|
||||
|
||||
Rejected: the risk score itself is privacy-sensitive (reveals presence via timing correlation). Default is opt-in.
|
||||
|
||||
### Alt 3: Cloud ML on raw BFI
|
||||
|
||||
Rejected: violates I1. Cloud training creates an off-node store of angle matrices reconstructible into identity profiles.
|
||||
|
||||
### Alt 4: Differential privacy noise on BFI at ingress
|
||||
|
||||
Deferred to a follow-up ADR. DP sensitivity analysis and its interaction with `identity_risk_score` calibration are not yet complete. Current design achieves privacy through structural impossibility, not noise injection.
|
||||
|
||||
---
|
||||
|
||||
## 5. Acceptance Criteria
|
||||
|
||||
- [ ] **AC1**: Extractor parses BFI from 802.11ac and 802.11ax captures, 20/40/80/160 MHz, 2×2 through 4×4 MIMO.
|
||||
- [ ] **AC2**: Presence detection latency ≤ 1 s p95 from first non-empty BFI frame.
|
||||
- [ ] **AC3**: Motion score published at ≥ 1 Hz on `ruview/<node_id>/bfld/motion/state`.
|
||||
- [ ] **AC4**: Raw BFI bytes never present in any serialized `BfldFrame` payload at any `privacy_class` value.
|
||||
- [ ] **AC5**: With `privacy_mode` enabled, all identity-derived fields are absent from outbound events.
|
||||
- [ ] **AC6**: Identical `BfiCapture` inputs produce bit-identical `BfldFrame` serialization (deterministic hash).
|
||||
- [ ] **AC7**: Pipeline produces valid `BfldEvent` outputs without `csi_matrix` (BFI-only mode).
|
||||
|
||||
Per-sub-ADR acceptance criteria are defined in ADR-119 through ADR-123.
|
||||
|
||||
---
|
||||
|
||||
## 6. Phased Rollout
|
||||
|
||||
| Phase | ADR | Scope | Effort |
|
||||
|-------|-----|-------|--------|
|
||||
| **P1** | 119 | Frame format + extractor stub | 1.5 wk |
|
||||
| **P2** | 121 | Features + identity_risk_score | 2.0 wk |
|
||||
| **P3** | 120 | Privacy gate + hash rotation | 1.5 wk |
|
||||
| **P4** | 122 (a) | MQTT emitter + HA discovery | 1.5 wk |
|
||||
| **P5** | 122 (b) | Matter cluster boundary in `cog-ha-matter` | 1.5 wk |
|
||||
| **P6** | 123 | Pi 5 / Nexmon capture adapter | 2.5 wk |
|
||||
| **Total** | | | **10.5 wk** |
|
||||
|
||||
---
|
||||
|
||||
## 7. Related ADRs
|
||||
|
||||
See header table. Cross-references in body cite the structural reuse of:
|
||||
- ADR-024 (AETHER embedding for identity_risk computation)
|
||||
- ADR-027 (MERIDIAN's no-cross-site assumption is now structurally enforced by I3)
|
||||
- ADR-028 (witness-bundle extends to BFLD surface)
|
||||
- ADR-029/030 (`multistatic.rs`, `cross_room.rs` reused)
|
||||
- ADR-095/096 (rvCSI Nexmon adapter for BFI capture)
|
||||
- ADR-115 (HA surface extension)
|
||||
- ADR-116 (`cog-ha-matter` boundary filter)
|
||||
- ADR-117 (PyO3 bindings pattern)
|
||||
@@ -1,163 +0,0 @@
|
||||
# ADR-119: BFLD Frame Format and Wire Protocol
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Proposed |
|
||||
| **Date** | 2026-05-24 |
|
||||
| **Deciders** | ruv |
|
||||
| **Parent** | [ADR-118](ADR-118-bfld-beamforming-feedback-layer-for-detection.md) |
|
||||
| **Relates to** | [ADR-028](ADR-028-esp32-capability-audit.md) (witness/deterministic proof), [ADR-095](ADR-095-rvcsi-edge-rf-sensing-platform.md) (rvCSI `CsiFrame` schema) |
|
||||
| **Tracking issue** | TBD |
|
||||
|
||||
---
|
||||
|
||||
## 1. Context
|
||||
|
||||
The BFLD pipeline (ADR-118) emits an over-the-wire `BfldFrame` consumed by the RuView aggregator, HA bridge, and witness bundle. The frame must be:
|
||||
|
||||
1. **Deterministic** — identical input ⇒ bit-identical output, so witness hashes survive verification (ADR-028 pattern).
|
||||
2. **Self-describing** — magic + version so future BFLD revisions don't silently corrupt aggregator state.
|
||||
3. **Privacy-classified at the byte level** — the receiver must know the data class before it even parses the payload, so it can drop frames it isn't authorized to handle.
|
||||
4. **Compact** — BFLD nodes may emit at up to 10 Hz; the frame must be small enough for unsharded MQTT and ESP-NOW transport.
|
||||
5. **Endianness-stable** — captures from x86_64 (ruvultra), aarch64 (cognitum-v0, Pi 5 cluster), and Xtensa (ESP32-S3) must produce identical bytes.
|
||||
|
||||
The existing rvCSI `CsiFrame` (ADR-095) is the closest precedent. BFLD reuses the same little-endian convention and the same "validate-before-FFI" posture.
|
||||
|
||||
---
|
||||
|
||||
## 2. Decision
|
||||
|
||||
### 2.1 `BfldFrame` header (40 bytes, little-endian, packed)
|
||||
|
||||
```rust
|
||||
#[repr(C, packed)]
|
||||
pub struct BfldFrameHeader {
|
||||
pub magic: u32, // 0xBF1D_0001
|
||||
pub version: u16, // 1
|
||||
pub flags: u16, // bit0=has_csi_delta, bit1=privacy_mode, bit2-15 reserved
|
||||
pub timestamp_ns: u64, // monotonic capture clock
|
||||
|
||||
pub ap_hash: [u8; 16], // BLAKE3-keyed(site_salt, ap_mac)[0..16]
|
||||
pub sta_hash: [u8; 16], // BLAKE3-keyed(site_salt ‖ day_epoch, sta_mac)[0..16]
|
||||
pub session_id: [u8; 16], // ephemeral, rotated on capture-session boundary
|
||||
|
||||
pub channel: u16, // 802.11 channel number
|
||||
pub bandwidth_mhz: u16, // 20 | 40 | 80 | 160
|
||||
pub rssi_dbm: i16,
|
||||
pub noise_floor_dbm: i16,
|
||||
|
||||
pub n_subcarriers: u16,
|
||||
pub n_tx: u8,
|
||||
pub n_rx: u8,
|
||||
pub quantization: u8, // 0=f32, 1=i16, 2=i8, 3=packed (4-bit nibbles)
|
||||
pub privacy_class: u8, // 0=raw, 1=derived, 2=anonymous, 3=restricted (default 2)
|
||||
|
||||
pub payload_len: u32,
|
||||
pub payload_crc32: u32, // CRC-32/ISO-HDLC over payload bytes only
|
||||
}
|
||||
```
|
||||
|
||||
Total header size: **86 bytes packed** (validated by `static_assertions::const_assert_eq!` in `wifi-densepose-bfld/src/frame.rs`). Earlier drafts stated 40 bytes — that was a counting error caught during P1 scaffold; see AC1 below.
|
||||
|
||||
### 2.2 Payload structure
|
||||
|
||||
Payload is a length-prefixed sequence of typed sections in this exact order:
|
||||
|
||||
```
|
||||
payload = compressed_angle_matrix
|
||||
‖ amplitude_proxy
|
||||
‖ phase_proxy
|
||||
‖ snr_vector
|
||||
‖ optional_csi_delta (present iff flags.bit0 set)
|
||||
‖ optional_vendor_extension (length 0 allowed)
|
||||
```
|
||||
|
||||
Each section is `[u32 len_le][bytes...]`. The CRC32 covers all section bytes including length prefixes, but **not** the header.
|
||||
|
||||
### 2.3 Privacy-class gating at serialization
|
||||
|
||||
The serializer enforces these rules **before** writing any payload bytes:
|
||||
|
||||
| `privacy_class` | `compressed_angle_matrix` | Identity-derived fields | Notes |
|
||||
|-----------------|---------------------------|-------------------------|-------|
|
||||
| 0 (`raw`) | full | full | **Local-only**, never serialized to a network sink |
|
||||
| 1 (`derived`) | downsampled to 8-bit, top-k subcarriers | full | Operator-acknowledged research mode |
|
||||
| 2 (`anonymous`, **default**) | absent (zero-length section) | absent | Production default |
|
||||
| 3 (`restricted`) | absent | absent + diagnostic-only | Equivalent to class 2 + suppresses `identity_risk_score` on the bus |
|
||||
|
||||
The serializer returns `Err(BfldError::PrivacyViolation)` if the caller attempts to publish a class-0 frame through a network sink. This is enforced by a sink-type marker trait (`LocalSink` vs `NetworkSink`).
|
||||
|
||||
### 2.4 Deterministic serialization
|
||||
|
||||
Three guarantees:
|
||||
|
||||
1. **Field order is fixed** by `#[repr(C, packed)]`.
|
||||
2. **Float quantization is canonical** — `quantization` byte values 1/2/3 use specified round-half-to-even with documented saturation; f32 (value 0) is forbidden over the wire (local-only).
|
||||
3. **CRC32 is computed last**, after all section bytes are placed.
|
||||
|
||||
The witness test in `tests/determinism.rs` captures a 200-frame BFI fixture, serializes it 1,000 times across two threads, and verifies the BLAKE3 of the resulting byte stream is bit-identical.
|
||||
|
||||
### 2.5 Magic value rationale
|
||||
|
||||
`0xBF1D_0001` is chosen so that `bf1d` reads as "BFLD" in hex-dump output, easing wireshark / xxd debugging. The final `0001` is the major version; minor revisions bump `version` field.
|
||||
|
||||
---
|
||||
|
||||
## 3. Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- 40-byte header + compact payload fits comfortably in a 1500-byte MTU even at 4×4 MIMO with 256 subcarriers.
|
||||
- Serialization is `#[no_std]` compatible — same code can run on ESP32-S3 (when ESP-NOW transport is added under ADR-123 P2).
|
||||
- Witness-bundle integration is direct: the existing `archive/v1/data/proof/verify.py` pattern extends to a `bfld_verify.py` that consumes the same SHA-256 expected-hash file format.
|
||||
|
||||
### Negative
|
||||
|
||||
- `#[repr(C, packed)]` on the header means consumers must use `read_unaligned` — small ergonomic cost, mitigated by a `#[derive(BfldFrameAccess)]` proc-macro.
|
||||
- Reserved flag bits 2-15 lock in future-extension order; any new bit assignment is a version bump.
|
||||
|
||||
### Neutral
|
||||
|
||||
- The vendor-extension section allows downstream RuView cogs (e.g., `cog-pose-estimation`) to attach metadata without a header change, at the cost of CRC scope creep. Vendor sections are explicitly outside the witness hash.
|
||||
|
||||
---
|
||||
|
||||
## 4. Alternatives Considered
|
||||
|
||||
### Alt 1: Protobuf / FlatBuffers
|
||||
|
||||
Rejected: schema evolution overhead, witness-hash instability across protoc versions, ~3× wire bloat for the small fixed-shape fields.
|
||||
|
||||
### Alt 2: CBOR
|
||||
|
||||
Rejected: deterministic CBOR (RFC 8949 §4.2) is achievable but the parser surface is large and tag handling is a footgun for the `no_std` ESP32 path.
|
||||
|
||||
### Alt 3: Variable-width magic / no magic
|
||||
|
||||
Rejected: receivers must distinguish BFLD frames from rvCSI `CsiFrame` and other RuView payloads on shared transports.
|
||||
|
||||
### Alt 4: Move CRC32 to header
|
||||
|
||||
Rejected: CRC must be computed after the payload, so its value would otherwise force a header rewrite; placing it last avoids a buffer-pass-back.
|
||||
|
||||
---
|
||||
|
||||
## 5. Acceptance Criteria
|
||||
|
||||
- [ ] **AC1**: `BfldFrameHeader` size is exactly **86 bytes** (packed) on x86_64, aarch64, and xtensa-esp32s3. The size was initially documented as 40 bytes during ADR drafting — that was a counting error; the implementation in `wifi-densepose-bfld/src/frame.rs` enforces the correct value via `const_assert_eq!`.
|
||||
- [ ] **AC2**: 1,000 serializations of a fixed `BfiCapture` fixture produce a bit-identical BLAKE3 hash.
|
||||
- [ ] **AC3**: `privacy_class = 0` frame returned through `NetworkSink::publish()` returns `Err(BfldError::PrivacyViolation)`.
|
||||
- [ ] **AC4**: Payload CRC32 mismatch causes `BfldFrame::parse()` to return `Err(BfldError::Crc)` without exposing partial payload state.
|
||||
- [ ] **AC5**: Round-trip serialize/parse preserves all header fields exactly.
|
||||
- [ ] **AC6**: A frame with `flags.bit0 = 0` (no CSI delta) and an unexpected CSI-delta section is rejected.
|
||||
- [ ] **AC7**: Bench: serialization throughput ≥ 50k frames/sec on a 2025-era M1/M2 / Pi 5 core.
|
||||
|
||||
---
|
||||
|
||||
## 6. References
|
||||
|
||||
- ADR-118 §2 (umbrella decision)
|
||||
- ADR-095 `CsiFrame` (`vendor/rvcsi/crates/rvcsi-core/src/frame.rs`)
|
||||
- CRC-32/ISO-HDLC: `crc = "3"` crate
|
||||
- BLAKE3 keyed mode: `blake3 = "1.5"`
|
||||
- IEEE 802.11-2020 §19.3.12 (Compressed Beamforming Report)
|
||||
@@ -1,192 +0,0 @@
|
||||
# ADR-120: BFLD Privacy Class and Hash Rotation
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Proposed |
|
||||
| **Date** | 2026-05-24 |
|
||||
| **Deciders** | ruv |
|
||||
| **Parent** | [ADR-118](ADR-118-bfld-beamforming-feedback-layer-for-detection.md) |
|
||||
| **Relates to** | [ADR-027](ADR-027-cross-environment-domain-generalization.md) (MERIDIAN no-cross-site), [ADR-032](ADR-032-multistatic-mesh-security-hardening.md) (mesh security), [ADR-106](ADR-106-dp-sgd-and-primitive-isolation.md) (primitive isolation), [ADR-115](ADR-115-home-assistant-integration.md) (privacy mode) |
|
||||
| **Companion research** | [`docs/research/soul/`](../research/soul/) — Soul Signature operates at `privacy_class = 1` (derived). §2.7 defines the dual-ID-space contract. |
|
||||
| **Tracking issue** | TBD |
|
||||
|
||||
---
|
||||
|
||||
## 1. Context
|
||||
|
||||
ADR-118 declares three structural invariants for BFLD:
|
||||
|
||||
- **I1**: Raw BFI never exits the node.
|
||||
- **I2**: Identity embedding is in-RAM-only.
|
||||
- **I3**: Cross-site identity correlation is cryptographically impossible.
|
||||
|
||||
I1/I2 are enforced by sink typing and module visibility (ADR-119 §2.3). I3 requires a hash-rotation scheme that makes the same physical person produce **different** `rf_signature_hash` values across sites and across day boundaries, without any out-of-band coordination between sites.
|
||||
|
||||
The existing `HA-PRIVACY` mode in ADR-115 already toggles between "full" and "anonymous" surfaces, but at a per-event granularity — not at a per-byte-field granularity. BFLD requires the latter because the `BfldFrame` payload mixes sensing data (publishable) and identity-derived data (non-publishable) in the same struct.
|
||||
|
||||
The BFId paper (KIT, ACM CCS 2025) demonstrates that even a few minutes of BFI capture across the same site is sufficient to build a persistent biometric. The mitigation must be **structural**, not policy-dependent.
|
||||
|
||||
---
|
||||
|
||||
## 2. Decision
|
||||
|
||||
### 2.1 The four privacy classes
|
||||
|
||||
A single `privacy_class: u8` byte in the `BfldFrame` header (ADR-119 §2.1) selects one of four classes. The crate enforces field availability statically through marker types.
|
||||
|
||||
| Class | Name | Use case | Available fields |
|
||||
|-------|------|----------|------------------|
|
||||
| **0** | `raw` | Local-only research, never networked | All fields, full-precision BFI matrix, identity embedding |
|
||||
| **1** | `derived` | Operator-acknowledged research over LAN | Downsampled angle matrix, full features, identity_risk_score, identity_embedding |
|
||||
| **2** | `anonymous` (**default**) | Production deployment | Aggregate sensing only: presence, motion, person_count, zone_id, confidence |
|
||||
| **3** | `restricted` | Care-home / regulated deployment | Class 2 minus `identity_risk_score` and `rf_signature_hash` |
|
||||
|
||||
Default for new RuView nodes is class **2**. Operators must explicitly opt-down to class 1 via the existing `--research-mode` flag (ADR-115 §7); class 0 is reserved for `cargo test` and is unreachable from `wifi-densepose-sensing-server`.
|
||||
|
||||
### 2.2 Enforcement via marker types
|
||||
|
||||
```rust
|
||||
pub trait Sink {}
|
||||
|
||||
pub trait LocalSink: Sink {} // Allowed: classes 0,1,2,3
|
||||
pub trait NetworkSink: Sink {} // Allowed: classes 1,2,3 (NOT class 0)
|
||||
pub trait MatterSink: NetworkSink {} // Allowed: class 2,3 + cluster-filter (ADR-122)
|
||||
|
||||
impl Emitter {
|
||||
pub fn publish<S: NetworkSink>(&self, sink: &S, frame: BfldFrame)
|
||||
-> Result<(), BfldError>
|
||||
{
|
||||
if frame.header.privacy_class == 0 {
|
||||
return Err(BfldError::PrivacyViolation {
|
||||
reason: "class 0 to NetworkSink",
|
||||
});
|
||||
}
|
||||
// ... serialize and write
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The compiler refuses to call `publish` on a sink that doesn't impl `NetworkSink` with a class-0 frame because the runtime check is paired with a sink-marker check. Cross-sink frame routing requires an explicit class transition (see §2.4).
|
||||
|
||||
### 2.3 BLAKE3 keyed hash rotation for `rf_signature_hash`
|
||||
|
||||
The signature hash is computed as:
|
||||
|
||||
```rust
|
||||
pub fn rf_signature_hash(
|
||||
site_salt: &[u8; 32], // generated on first boot, persisted in TPM/KMS
|
||||
day_epoch: u32, // floor(unix_time_utc / 86400)
|
||||
features: &IdentityFeatures,
|
||||
) -> Hash {
|
||||
let mut hasher = blake3::Hasher::new_keyed(site_salt);
|
||||
hasher.update(&day_epoch.to_le_bytes());
|
||||
hasher.update(&features.canonical_bytes());
|
||||
hasher.finalize()
|
||||
}
|
||||
```
|
||||
|
||||
**Structural cross-site isolation**: because `site_salt` is a 256-bit random secret unique to each node and never transmitted, two sites observing the same physical person produce uncorrelated hashes. There is no key the operator (or an attacker who compromises one node) can use to bridge sites. This is stronger than a policy-based "do not share" rule because the bridge **cannot be computed**.
|
||||
|
||||
**Daily rotation**: `day_epoch` flipping at UTC midnight forces the hash of the same person to change once per day. Multi-day correlation requires re-acquiring the biometric, which the rotation actively breaks.
|
||||
|
||||
### 2.4 Class-transition transformer
|
||||
|
||||
The only way a high-class frame becomes a lower-class frame is through `PrivacyGate::demote(frame, target_class)`. This function:
|
||||
|
||||
1. Asserts the target class is strictly higher number than (or equal to) the input class.
|
||||
2. Zeroes the disallowed fields with `subtle::Zeroize`.
|
||||
3. Re-computes `payload_crc32`.
|
||||
4. Returns the new frame.
|
||||
|
||||
There is no `promote` operation — a class-2 frame cannot be turned back into a class-1 frame, because the dropped fields were not retained anywhere reachable from the gate.
|
||||
|
||||
### 2.5 `identity_embedding` lifecycle
|
||||
|
||||
The embedding (output of the AETHER encoder, ADR-024) is held in a `subtle::Zeroizing<[f32; 128]>` ring buffer of 64 entries (≈30 KB). Entries are:
|
||||
|
||||
1. Written by the encoder on each capture window.
|
||||
2. Consumed by `identity_risk_score` computation (ADR-121).
|
||||
3. **Never** written to disk, MQTT, or any other I/O sink — there is no `Serialize` impl on the type.
|
||||
4. Overwritten by the ring (FIFO).
|
||||
|
||||
A compile-time `#[forbid(serde::Serialize)]` lint on `IdentityEmbedding` ensures a future PR cannot accidentally add a `Serialize` derive.
|
||||
|
||||
### 2.6 Default-deny field classification
|
||||
|
||||
Every new field added to `BfldFrame` or `BfldEvent` must be tagged with `#[must_classify]` (a custom attribute macro). The macro fails compilation if the field is not listed in the per-class allow-list table. This forces future contributors to make an explicit privacy decision on every new field.
|
||||
|
||||
### 2.7 Dual-ID-space contract for Soul Signature deployments
|
||||
|
||||
Soul Signature (`docs/research/soul/`) is a consent-based biometric system that *intentionally* produces long-lived per-person identity. It cannot operate at the default class 2 — the identity_embedding it needs is structurally absent there. The contract:
|
||||
|
||||
| Deployment mode | `privacy_class` | ID space for unenrolled bystanders | ID space for enrolled persons |
|
||||
|---|---|---|---|
|
||||
| Default BFLD-only | 2 (anonymous) | Daily-rotated `rf_signature_hash` | n/a — no enrollment |
|
||||
| Soul Signature opt-in | **1 (derived)** | Daily-rotated `rf_signature_hash` (unchanged) | Long-lived opaque `person_id` from Soul Signature graph |
|
||||
| Restricted / care-home | 3 (restricted) | Suppressed | n/a — Soul Signature **disabled** at class 3 |
|
||||
|
||||
Two ID spaces coexist with **no collision**: the rotating hash is the privacy-preserving identifier for everyone *not* on the consent roster; the stable `person_id` is reserved for enrolled subjects under their own GDPR/HIPAA mode. Soul Signature's `match_against_enrolled()` function consumes only the in-RAM `identity_embedding` (I2 still holds) and emits a `person_id` plus a calibrated similarity score; it never writes the embedding to disk or the wire. The class-1 requirement is enforced statically: the Soul Signature match API takes a `&IdentityEmbedding` parameter, which is only constructible when the BFLD crate is compiled with `--features soul-signature` against a class-1 frame.
|
||||
|
||||
---
|
||||
|
||||
## 3. Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- Cross-site identity correlation is **computationally impossible**, not merely "prohibited by policy". This is the strongest form of privacy guarantee available without a TEE.
|
||||
- Default-deny via `#[must_classify]` prevents the common pattern of "a new field shipped, then six months later we noticed it was identity-leaky".
|
||||
- `identity_embedding` cannot be serialized by accident — the type system carries the constraint.
|
||||
- The class transition transformer makes the data lifecycle explicit and auditable.
|
||||
|
||||
### Negative
|
||||
|
||||
- `site_salt` storage requires either a TPM (ADR-095/096 rvCSI platform feature gap) or a secrets file with strict mode. Loss of `site_salt` makes historical witness comparisons impossible — by design, but a documentation hazard.
|
||||
- `#[must_classify]` is a custom proc-macro; another moving part in the build.
|
||||
- Operators wanting multi-day analytics must work in aggregates only, not on per-individual signatures.
|
||||
|
||||
### Neutral
|
||||
|
||||
- Class 0 is `cargo test`-only. Some CI runners may need an explicit feature flag to compile class-0 paths.
|
||||
|
||||
---
|
||||
|
||||
## 4. Alternatives Considered
|
||||
|
||||
### Alt 1: Single boolean `privacy_mode` flag (status quo from ADR-115)
|
||||
|
||||
Rejected: insufficient granularity. The frame mixes publishable sensing with non-publishable identity, so the gate must operate at field-level, not event-level.
|
||||
|
||||
### Alt 2: SHA-256 instead of BLAKE3
|
||||
|
||||
Rejected: BLAKE3 keyed-hash mode is ~5× faster on the ESP32-S3 / Cortex-M cores and the security margin is equivalent for this use case. SHA-256 has no keyed-hash mode (HMAC-SHA256 is the alternative; works but is slower).
|
||||
|
||||
### Alt 3: Hash rotation on the hour, not the day
|
||||
|
||||
Rejected: hourly rotation breaks legitimate "person was here in the morning, came back in the afternoon" use-cases that operators may want. Day boundary is the compromise.
|
||||
|
||||
### Alt 4: Per-event nonces instead of daily epoch
|
||||
|
||||
Rejected: per-event nonces would force the consumer to track which events came from the same person within a session, which leaks identity information by structure. The day epoch preserves a coarse temporal grouping without leaking finer-grained identity.
|
||||
|
||||
---
|
||||
|
||||
## 5. Acceptance Criteria
|
||||
|
||||
- [ ] **AC1**: Calling `Emitter::publish` with a `privacy_class = 0` frame on a `NetworkSink` returns `BfldError::PrivacyViolation`.
|
||||
- [ ] **AC2**: Two BFLD nodes with different `site_salt` values observing the same simulated person produce `rf_signature_hash` values whose Hamming distance is ≥ 120 bits over 100 trials (statistical isolation test).
|
||||
- [ ] **AC3**: A frame with `privacy_class = 3` has both `identity_risk_score` and `rf_signature_hash` absent from the serialized payload.
|
||||
- [ ] **AC4**: `PrivacyGate::demote(class_1_frame, target=0)` fails to compile (compile-fail test).
|
||||
- [ ] **AC5**: A PR adding a new field to `BfldEvent` without `#[must_classify]` fails the build.
|
||||
- [ ] **AC6**: `IdentityEmbedding` has no `Serialize` impl reachable from any public function.
|
||||
- [ ] **AC7**: Dropping an `IdentityEmbedding` value zeroizes its memory (verified by a debugger-readable test under `cargo test --features zeroize-validation`).
|
||||
|
||||
---
|
||||
|
||||
## 6. References
|
||||
|
||||
- ADR-118 (umbrella)
|
||||
- ADR-119 (frame format; `privacy_class` byte location)
|
||||
- KIT BFId (ACM CCS 2025): https://publikationen.bibliothek.kit.edu/1000185756
|
||||
- NDSS LeakyBeam (2025): https://www.ndss-symposium.org/wp-content/uploads/2025-5-paper.pdf
|
||||
- BLAKE3 keyed-hash: https://github.com/BLAKE3-team/BLAKE3
|
||||
- `subtle::Zeroize` for memory hygiene
|
||||
@@ -1,182 +0,0 @@
|
||||
# ADR-121: BFLD Identity Risk Scoring and Coherence Gate
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Proposed |
|
||||
| **Date** | 2026-05-24 |
|
||||
| **Deciders** | ruv |
|
||||
| **Parent** | [ADR-118](ADR-118-bfld-beamforming-feedback-layer-for-detection.md) |
|
||||
| **Relates to** | [ADR-024](ADR-024-contrastive-csi-embedding-model.md) (AETHER), [ADR-027](ADR-027-cross-environment-domain-generalization.md) (MERIDIAN), [ADR-029](ADR-029-ruvsense-multistatic-sensing-mode.md) (multistatic fusion), [ADR-086](ADR-086-edge-novelty-gate.md) (novelty gate precedent), [ADR-120](ADR-120-bfld-privacy-class-and-hash-rotation.md) (privacy class) |
|
||||
| **Companion research** | [`docs/research/soul/`](../research/soul/) — risk score doubles as Soul Signature enrollment-quality signal; §2.7 defines the Recalibrate exemption. |
|
||||
| **Tracking issue** | TBD |
|
||||
|
||||
---
|
||||
|
||||
## 1. Context
|
||||
|
||||
BFLD's distinguishing primitive is the `identity_risk_score` — a scalar that says **"is this capture window currently capable of identifying a specific person?"**. The score has two consumers:
|
||||
|
||||
1. **The operator** — exposed as an HA diagnostic sensor (ADR-122). A spike from the long-term baseline indicates the RF environment has shifted toward a higher-leakage regime (new AP firmware, denser MIMO, attacker-grade sniffer in range).
|
||||
2. **The privacy gate** (ADR-120) — when the score crosses a configurable threshold, the gate downgrades the active `privacy_class` automatically (e.g., 2 → 3) until the score recovers.
|
||||
|
||||
The score must be:
|
||||
- **Bounded** in `[0, 1]` for HA gauge entities.
|
||||
- **Calibrated** against actual re-ID success rate, ideally on the KIT BFId dataset.
|
||||
- **Computable on-device** at ≥ 1 Hz on a Pi 5 core or an aarch64 cognitum-v0.
|
||||
- **Stable** — small environmental changes should not produce wild swings; the score is for slow-moving regime detection, not per-frame chatter.
|
||||
|
||||
ADR-086 (edge novelty gate) establishes a precedent for an on-device gate primitive. BFLD's risk scoring borrows the gate-pattern but with identity leakage as the trigger condition.
|
||||
|
||||
---
|
||||
|
||||
## 2. Decision
|
||||
|
||||
### 2.1 Nine features (from BFLD spec §5)
|
||||
|
||||
The features are computed over a sliding window of `W = 32` BFI frames (≈3 s at 10 Hz):
|
||||
|
||||
| Feature | Definition | Source |
|
||||
|---------|------------|--------|
|
||||
| `mean_angle_delta` | mean( ‖ Φ_t − Φ_{t-1} ‖ over subcarriers ) | extractor |
|
||||
| `subcarrier_variance` | var( ‖ Φ ‖ over subcarrier axis ) | extractor |
|
||||
| `temporal_entropy` | Shannon entropy of angle-bin histogram over W | extractor |
|
||||
| `doppler_proxy` | FFT peak magnitude of mean-angle time series | features.rs |
|
||||
| `path_stability` | 1 − ‖ Φ_t − median(Φ_{t-W..t}) ‖ / scale | features.rs |
|
||||
| `cross_antenna_correlation` | mean Pearson correlation across n_tx × n_rx pairs | features.rs |
|
||||
| `burst_motion_score` | high-pass-filtered angular velocity, soft-thresholded | features.rs |
|
||||
| `stationarity_score` | 1 − rolling KL divergence over W/2 vs W | features.rs |
|
||||
| `identity_separability_score` | top-1 cosine to nearest AETHER cluster centroid | identity_risk.rs |
|
||||
|
||||
The first eight are sensing features (also used by the presence/motion pipeline). Only the ninth depends on the AETHER embedding and therefore on `identity_class >= 1`.
|
||||
|
||||
### 2.2 Identity risk formula
|
||||
|
||||
```rust
|
||||
pub fn identity_risk_score(
|
||||
sep: f32, // identity_separability_score, [0, 1]
|
||||
stab: f32, // temporal_stability, [0, 1] = ema(path_stability, alpha=0.1)
|
||||
consist: f32,// cross_perspective_consistency, [0, 1] = multistatic.rs
|
||||
conf: f32, // sample_confidence, [0, 1] = f(SNR, n_subcarriers, n_rx)
|
||||
) -> f32 {
|
||||
// Clamp inputs, then multiplicative combination — any factor near 0 dominates.
|
||||
let s = sep.clamp(0.0, 1.0);
|
||||
let t = stab.clamp(0.0, 1.0);
|
||||
let p = consist.clamp(0.0, 1.0);
|
||||
let c = conf.clamp(0.0, 1.0);
|
||||
(s * t * p * c).clamp(0.0, 1.0)
|
||||
}
|
||||
```
|
||||
|
||||
Multiplicative combination is chosen so that **any** weak factor (e.g., very low SNR ⇒ low `conf`) collapses the score toward 0. This matches the privacy intent: when the system is uncertain, the score should be low and the operator should not be alarmed.
|
||||
|
||||
### 2.3 Calibration target
|
||||
|
||||
The score is calibrated against re-ID success rate on a held-out test split of the KIT BFId dataset. A piecewise-linear isotonic regression maps raw scores into a calibrated `[0, 1]` band where `score ≥ 0.8` corresponds to `>80%` re-ID accuracy on a 5-second window in the calibration dataset.
|
||||
|
||||
Calibration parameters live in `v2/crates/wifi-densepose-bfld/data/risk_calibration.toml` and are versioned independently of the code. A regression update is a content-only PR.
|
||||
|
||||
### 2.4 Coherence gate
|
||||
|
||||
The coherence gate (per ADR-029 `coherence_gate.rs` pattern) consumes the risk score and emits one of four actions:
|
||||
|
||||
```rust
|
||||
pub enum GateAction {
|
||||
Accept, // score < 0.5, publish normally
|
||||
PredictOnly, // 0.5 <= score < 0.7, publish but flag confidence
|
||||
Reject, // 0.7 <= score < 0.9, drop the event
|
||||
Recalibrate, // score >= 0.9, drop AND rotate site_salt
|
||||
}
|
||||
```
|
||||
|
||||
The `Recalibrate` action triggers a forced site-salt rotation — an aggressive response to a sustained high-risk regime. It costs the operator continuity of long-term aggregate analytics but is the right answer to an attacker-grade sniffer arriving in range.
|
||||
|
||||
### 2.5 Hysteresis
|
||||
|
||||
To prevent oscillation around the gate thresholds, the gate uses ±0.05 hysteresis and a 5-second debounce. A score must cross the boundary by the hysteresis margin and persist for the debounce window before the gate action changes.
|
||||
|
||||
### 2.6 Soul Signature interaction — Recalibrate exemption and enrollment-quality gate
|
||||
|
||||
Soul Signature (`docs/research/soul/`) intentionally exists in a high-separability regime — the whole point of its 60-second enrollment protocol is to push `identity_separability_score` toward 1.0. The default coherence gate (§2.4) would therefore fire `Recalibrate` constantly inside Soul Signature zones, rotating `site_salt` every few seconds and breaking enrollment.
|
||||
|
||||
Two integrations resolve this:
|
||||
|
||||
1. **Recalibrate exemption.** When the gate is about to fire `Recalibrate`, it consults a `SoulMatchOracle` (provided by the Soul Signature crate when compiled with `--features soul-signature`). If the oracle reports that the current high-separability cluster matches an enrolled `person_id` above the Soul Signature acceptance threshold, the gate downgrades to `PredictOnly` instead. The high score is the *intended* outcome of a successful match, not an attack indicator. Without the `soul-signature` feature, the oracle is a no-op stub returning `MatchOutcome::NotEnrolled`, so the gate behaves exactly per §2.4.
|
||||
|
||||
2. **Enrollment-quality gate.** Soul Signature's enrollment protocol (`scanning-process.md` §3) requires that the sensing zone meet a minimum identity-leakage regime — too low, and the resulting signature is unreliable. The BFLD `identity_risk_score` is exactly the right signal. Soul Signature gates enrollment on `score >= ENROLL_MIN` (default `0.65`) sustained over the 60-second window. If the score drops below threshold mid-enrollment, the protocol aborts and the operator is prompted to re-attempt in better RF conditions.
|
||||
|
||||
The exemption is asymmetric: it suppresses `Recalibrate` only for known-enrolled matches. Unknown high-separability clusters (a real attacker-grade sniffer, or an unenrolled person whose identity is unexpectedly leaky) still trigger `Recalibrate` as designed.
|
||||
|
||||
### 2.7 Compute budget
|
||||
|
||||
| Stage | Target latency | Implementation |
|
||||
|-------|----------------|----------------|
|
||||
| Feature extraction (8 features) | < 3 ms per window | ndarray + nalgebra; vectorized over subcarriers |
|
||||
| Separability (cosine to centroids) | < 5 ms per window | RuVector RaBitQ index (ADR-085) over ≤ 1k centroids |
|
||||
| Risk score | < 0.1 ms | scalar multiplicative |
|
||||
| Gate decision + hysteresis | < 0.1 ms | scalar |
|
||||
|
||||
Total p95 ≤ 10 ms per window on a Pi 5 core (8 ms target). Headroom on cognitum-v0 (Pi 5 + Hailo) is ample; ESP32-S3 hosts only the extraction stage (features computed; risk score is host-side per ADR-123). The `SoulMatchOracle` lookup (§2.6) adds < 1 ms when the `soul-signature` feature is enabled (RaBitQ index over enrolled centroids).
|
||||
|
||||
---
|
||||
|
||||
## 3. Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- The risk score becomes a first-class diagnostic surface for operators and a structural input to the privacy gate — both consumers from a single computation.
|
||||
- Multiplicative combination is conservative under uncertainty; the system is biased toward "report low risk when unsure", which is the right default.
|
||||
- Calibration is a content-only update — no recompile needed when the calibration file changes.
|
||||
- The recalibration gate action gives the system a self-healing response to a sniffer arrival without operator intervention.
|
||||
|
||||
### Negative
|
||||
|
||||
- Calibration requires the KIT BFId dataset; without it the score is uncalibrated and serves only as an internal trigger, not a publishable signal.
|
||||
- Multiplicative scoring can be dominated by `sample_confidence`, which is sensitive to channel conditions. A persistent low-SNR environment will keep the published score near 0 even when the underlying separability is high — an under-reporting failure mode that the documentation must call out.
|
||||
- The recalibrate action breaks historical hash continuity by design; an operator who wants long-term aggregates needs to know they will see a discontinuity on recalibrate events.
|
||||
|
||||
### Neutral
|
||||
|
||||
- The nine features overlap with the existing CSI pipeline. BFLD computes them on BFI; the CSI pipeline computes them on CSI. Both can be fused via `cross_perspective_consistency`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Alternatives Considered
|
||||
|
||||
### Alt 1: Additive scoring (`(s + t + p + c) / 4`)
|
||||
|
||||
Rejected: a sample with high separability but very low confidence would still produce a moderate score, which over-reports risk in degraded RF conditions.
|
||||
|
||||
### Alt 2: Maximum scoring (`max(s, t, p, c)`)
|
||||
|
||||
Rejected: over-reports risk because any single high factor pins the output, even if the others contradict it.
|
||||
|
||||
### Alt 3: Learned scoring (a small MLP)
|
||||
|
||||
Rejected for this ADR: introduces an opaque model whose output cannot be audited from first principles. The multiplicative formula is simple, conservative, and directly explainable to operators. A learned model is a future option once enough calibration data is in hand.
|
||||
|
||||
### Alt 4: Per-feature thresholds instead of a continuous score
|
||||
|
||||
Rejected: continuous score is needed for the HA gauge entity and for downstream calibration. Per-feature thresholds would force operators to interpret nine separate binaries.
|
||||
|
||||
---
|
||||
|
||||
## 5. Acceptance Criteria
|
||||
|
||||
- [ ] **AC1**: All nine features are computed in `< 8 ms` p95 per window on a Pi 5 core.
|
||||
- [ ] **AC2**: `identity_risk_score` is monotonic non-decreasing in any single input when the other three are held constant.
|
||||
- [ ] **AC3**: Calibration regression on the KIT BFId test split: `score ≥ 0.8` corresponds to ≥ 80% re-ID accuracy ± 5%.
|
||||
- [ ] **AC4**: The coherence gate emits `Recalibrate` if score is ≥ 0.9 for ≥ 5 seconds.
|
||||
- [ ] **AC5**: Hysteresis prevents action oscillation across ± 0.05 of a threshold within a 5-second window.
|
||||
- [ ] **AC6**: At `privacy_class = 3`, the risk score is computed but not published to MQTT (kept local for the gate only).
|
||||
- [ ] **AC7**: A reproducible 1,000-frame synthetic fixture produces a deterministic score sequence (bit-identical across runs).
|
||||
|
||||
---
|
||||
|
||||
## 6. References
|
||||
|
||||
- ADR-118 (umbrella)
|
||||
- ADR-024 (AETHER encoder for separability)
|
||||
- ADR-029 (`coherence_gate.rs` precedent)
|
||||
- ADR-086 (edge novelty gate pattern)
|
||||
- ADR-120 §2.4 (class transition consumed by gate)
|
||||
- KIT BFId dataset: https://publikationen.bibliothek.kit.edu/1000185756
|
||||
@@ -1,210 +0,0 @@
|
||||
# ADR-122: BFLD RuView Surface — Home Assistant, Matter, MQTT Exposure
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Proposed |
|
||||
| **Date** | 2026-05-24 |
|
||||
| **Deciders** | ruv |
|
||||
| **Parent** | [ADR-118](ADR-118-bfld-beamforming-feedback-layer-for-detection.md) |
|
||||
| **Relates to** | [ADR-031](ADR-031-ruview-sensing-first-rf-mode.md) (sensing-first), [ADR-100](ADR-100-cog-packaging-specification.md) (cog packaging), [ADR-115](ADR-115-home-assistant-integration.md) (HA-DISCO + HA-MIND), [ADR-116](ADR-116-cog-ha-matter-seed.md) (Matter cog), [ADR-120](ADR-120-bfld-privacy-class-and-hash-rotation.md) (privacy class) |
|
||||
| **Companion research** | [`docs/research/soul/`](../research/soul/) — Soul Signature deployments expose enrolled-match diagnostics only over HA, never Matter. See §2.7. |
|
||||
| **Tracking issue** | TBD |
|
||||
|
||||
---
|
||||
|
||||
## 1. Context
|
||||
|
||||
ADR-115 shipped the RuView Home Assistant surface (21 entities, MQTT auto-discovery, mTLS, privacy mode) on the `wifi-densepose-sensing-server` Rust binary. ADR-116 is packaging this as the `cog-ha-matter` Cognitum Seed cog. BFLD must integrate into this surface without expanding the privacy-sensitive footprint already in production.
|
||||
|
||||
The integration must:
|
||||
|
||||
1. **Extend HA-DISCO** to advertise BFLD entities via the existing MQTT-discovery scheme.
|
||||
2. **Reject identity fields at the Matter boundary** — Matter exposes occupancy/motion/people-count only, never `identity_risk_score` or `rf_signature_hash`.
|
||||
3. **Route MQTT topics by privacy class** — class-2/3 events on the public topic tree, class-1 events on a gated `research/` subtree, class-0 events nowhere.
|
||||
4. **Federate cleanly into cognitum-v0** — BFLD events from multiple nodes flow through `cognitum-rvf-agent` (port 9004 per CLAUDE.local.md) for cross-node analytics, but identity-derived fields are stripped at the **publishing-node boundary**, not at the federation hub.
|
||||
|
||||
---
|
||||
|
||||
## 2. Decision
|
||||
|
||||
### 2.1 HA entity surface (six new entities per node)
|
||||
|
||||
The cog republishes the existing 21 ADR-115 entities and adds:
|
||||
|
||||
| Entity ID | Type | Source field | Class gate | Diagnostic |
|
||||
|-----------|------|--------------|------------|------------|
|
||||
| `binary_sensor.<node>_bfld_presence` | occupancy | `BfldEvent.presence` | ≥ 2 | no |
|
||||
| `sensor.<node>_bfld_motion` | gauge `[0,1]` | `BfldEvent.motion` | ≥ 2 | no |
|
||||
| `sensor.<node>_bfld_person_count` | int | `BfldEvent.person_count` | ≥ 2 | no |
|
||||
| `sensor.<node>_bfld_zone_activity` | enum | `BfldEvent.zone_activity` | ≥ 2 | no |
|
||||
| `sensor.<node>_bfld_identity_risk` | gauge `[0,1]` | `BfldEvent.identity_risk_score` | == 2 only | **yes** |
|
||||
| `sensor.<node>_bfld_confidence` | gauge `[0,1]` | `BfldEvent.confidence` | ≥ 2 | yes |
|
||||
|
||||
The `identity_risk` entity is exposed only under privacy class 2 and is flagged `entity_category: diagnostic` so HA dashboards do not promote it to a main-card sensor by default. Under class 3 it is computed but not published (per ADR-121 §2.4).
|
||||
|
||||
MQTT discovery payload follows the ADR-115 schema, plus a `bfld_version` attribute matching the `BfldFrameHeader::version` field.
|
||||
|
||||
### 2.2 MQTT topic tree
|
||||
|
||||
```
|
||||
ruview/<node_id>/bfld/presence/state # class >= 2
|
||||
ruview/<node_id>/bfld/motion/state # class >= 2
|
||||
ruview/<node_id>/bfld/person_count/state # class >= 2
|
||||
ruview/<node_id>/bfld/zone_activity/state # class >= 2
|
||||
ruview/<node_id>/bfld/confidence/state # class >= 2
|
||||
ruview/<node_id>/bfld/identity_risk/state # class == 2 only
|
||||
ruview/<node_id>/bfld/raw # class 1, OFF by default
|
||||
ruview/<node_id>/bfld/availability # online/offline marker
|
||||
```
|
||||
|
||||
`raw` (class-1 derived BFI) is **not present** in the discovery payload at all — operators must explicitly subscribe and acknowledge the research-mode caveat. The publishing crate emits `MQTT_RAW_DISABLED` to availability when `privacy_class < 1`.
|
||||
|
||||
### 2.3 Mosquitto ACL example
|
||||
|
||||
```
|
||||
# Default-deny everything not explicitly granted
|
||||
pattern read ruview/+/bfld/+/state
|
||||
pattern read ruview/+/bfld/availability
|
||||
|
||||
# Public roles cannot read identity_risk or raw
|
||||
user public
|
||||
deny read ruview/+/bfld/identity_risk/state
|
||||
deny read ruview/+/bfld/raw
|
||||
|
||||
# Operator role can read identity_risk for diagnostics
|
||||
user operator
|
||||
allow read ruview/+/bfld/identity_risk/state
|
||||
|
||||
# Research role can read raw (requires class-1 operation)
|
||||
user research
|
||||
allow read ruview/+/bfld/raw
|
||||
```
|
||||
|
||||
The cog ships a default ACL template under `cog-ha-matter/etc/mosquitto.acl.d/bfld.conf` for operators who use the embedded broker (ADR-116 §2.2).
|
||||
|
||||
### 2.4 Matter cluster boundary
|
||||
|
||||
`cog-ha-matter` exposes BFLD via **three Matter clusters** only:
|
||||
|
||||
| Matter cluster | Source entity | Notes |
|
||||
|---|---|---|
|
||||
| Occupancy Sensing (0x0406) | `binary_sensor.<node>_bfld_presence` | reports binary occupancy + uncertainty (mapped from `confidence`) |
|
||||
| Boolean State (0x0045) | `sensor.<node>_bfld_motion >= 0.3` | thresholded; raw motion not exposed |
|
||||
| Occupancy Sensing extension | `sensor.<node>_bfld_person_count` | uses occupancy-sensor count where Matter spec supports |
|
||||
|
||||
**Explicitly NOT exposed via Matter**:
|
||||
|
||||
- `identity_risk_score`
|
||||
- `rf_signature_hash`
|
||||
- `identity_embedding`
|
||||
- `raw` BFI
|
||||
- `zone_activity` (zone IDs are site-specific and Matter is a cross-site surface)
|
||||
- `confidence` (HA-only diagnostic)
|
||||
|
||||
The Matter filter is implemented in `cog-ha-matter/src/matter/bfld_filter.rs` as a `MatterSink` trait impl that rejects classes 0 and 1 at compile time (via ADR-120 §2.2 marker types).
|
||||
|
||||
### 2.5 Federation with cognitum-v0
|
||||
|
||||
`cognitum-rvf-agent` (port 9004) receives BFLD events from multiple nodes. The events arriving at the federation hub are **already class-2/3** — identity-derived fields were stripped at each publishing node. The hub does not see and cannot reconstruct raw BFI or identity embeddings.
|
||||
|
||||
The federation contract:
|
||||
|
||||
| At publishing node | At cognitum-rvf-agent |
|
||||
|---|---|
|
||||
| Strip class-0/1 fields per ADR-120 | Receive class-2/3 events only |
|
||||
| Rotate `rf_signature_hash` per ADR-120 §2.3 | Aggregate counts; **do not** correlate hashes across sites |
|
||||
| Sign event with node Ed25519 key | Verify signature; reject unsigned events |
|
||||
|
||||
A `federation-witness` script (extending ADR-028) runs nightly on the hub and proves that no class-0/1 fields appeared in any received event over the previous 24 h.
|
||||
|
||||
### 2.6 HA blueprints (shipped with the cog)
|
||||
|
||||
Three operator-ready blueprints under `cog-ha-matter/blueprints/`:
|
||||
|
||||
1. **Presence-driven lighting** — `binary_sensor.*_bfld_presence` ⇒ `light.turn_on/off` with configurable hold time.
|
||||
2. **Motion-aware HVAC** — `sensor.*_bfld_motion > 0.3` ⇒ raise HVAC setpoint by ΔT.
|
||||
3. **Identity-risk anomaly notification** — `sensor.*_bfld_identity_risk` exceeds rolling z-score threshold ⇒ HA `notify.*` to the operator with the originating node and the 7-day baseline.
|
||||
|
||||
### 2.7 Soul Signature deployment posture
|
||||
|
||||
When the cog is compiled with `--features soul-signature`, two additional HA entities are exposed **at class 1 only**, and **never** over Matter:
|
||||
|
||||
| Entity ID | Type | Source | Class gate | Matter |
|
||||
|-----------|------|--------|------------|--------|
|
||||
| `sensor.<node>_soul_match_id` | string (opaque `person_id`) | Soul Signature match oracle | == 1 only | **rejected** |
|
||||
| `sensor.<node>_soul_match_score` | gauge `[0,1]` | Match similarity | == 1 only | **rejected** |
|
||||
| `sensor.<node>_soul_enrollment_quality` | gauge `[0,1]` | Mirror of `identity_risk_score` during enrollment | == 1 only | **rejected** |
|
||||
|
||||
These entities are part of the consent-based diagnostic surface for operators running Soul Signature deployments (care homes with explicit GDPR Art. 9 basis, employment with consent, etc.). The Matter cluster boundary in §2.4 already rejects them by type — the `MatterSink` impl only accepts class-2/3 frames, so `soul_match_id` is structurally unreachable through Matter.
|
||||
|
||||
Class-3 deployments **disable Soul Signature** entirely: the `match_against_enrolled()` call returns `MatchOutcome::Suppressed` and no soul entities are published. This makes class 3 the correct setting for any deployment where consent is uncertain or where regulators require Soul Signature to be unavailable.
|
||||
|
||||
A fourth blueprint ships only when `--features soul-signature` is enabled:
|
||||
|
||||
4. **Enrolled-person arrival notification** — `sensor.*_soul_match_id` transitions to a non-null value ⇒ HA `notify.*` to the enrolled person's configured contact (typically themselves or a designated caregiver). Default off; operator must opt in per enrolled person.
|
||||
|
||||
---
|
||||
|
||||
## 3. Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- Six new HA entities give operators a complete BFLD diagnostic dashboard without leaking identity.
|
||||
- Matter exposure is structurally narrow — the cluster-filter implementation cannot accidentally expose identity fields because the type system rejects them.
|
||||
- The default ACL template gives operators a working privacy posture out of the box.
|
||||
- The federation contract makes it explicit that the hub cannot reconstruct identity even from the union of all node events.
|
||||
|
||||
### Negative
|
||||
|
||||
- The `identity_risk` HA entity exists only under class 2. Operators who run class 3 deployments cannot see the score even in their own dashboard. This is correct but may surprise care-home installers; documentation must be clear.
|
||||
- Three Matter clusters is conservative — some HA users may want the count exposed as a percentage or rate, which Matter does not support natively.
|
||||
- HA-blueprint coverage is intentionally small; operators wanting custom automations must work through the YAML surface.
|
||||
|
||||
### Neutral
|
||||
|
||||
- The federation witness script runs nightly. A short-duration leak between witnesses is possible but bounded — any successful exfiltration of class-1 fields would still need to be reconstructed into identity, which the daily hash rotation breaks.
|
||||
|
||||
---
|
||||
|
||||
## 4. Alternatives Considered
|
||||
|
||||
### Alt 1: Expose `identity_risk` over Matter (Generic Sensor cluster)
|
||||
|
||||
Rejected: Matter is a cross-vendor surface; exposing identity-risk there leaks the score to every Matter controller in the home, including third-party hubs the operator may not control. Keep it HA-internal.
|
||||
|
||||
### Alt 2: One unified MQTT topic `ruview/<node>/bfld` with JSON payload
|
||||
|
||||
Rejected: per-entity topics are the HA-DISCO convention (ADR-115) and let ACLs be field-specific. A unified topic forces an all-or-nothing read policy.
|
||||
|
||||
### Alt 3: Federate raw BFI to cognitum-v0 for cross-node analytics
|
||||
|
||||
Rejected: violates ADR-120 I1 (raw never leaves the node). Aggregates are sufficient for cross-node analytics; raw centralization is a hard no.
|
||||
|
||||
### Alt 4: Default `entity_category: diagnostic = false` for `identity_risk`
|
||||
|
||||
Rejected: promoting `identity_risk` to a main-card sensor would surprise operators with an identity-adjacent gauge on their main dashboard. Diagnostic category is the right default.
|
||||
|
||||
---
|
||||
|
||||
## 5. Acceptance Criteria
|
||||
|
||||
- [ ] **AC1**: HA auto-discovery publishes six new entities per node on first connect; HA recognizes all six.
|
||||
- [ ] **AC2**: Under privacy class 3, `sensor.<node>_bfld_identity_risk` is absent from the MQTT discovery payload.
|
||||
- [ ] **AC3**: `MatterSink::publish` rejects any frame at compile time when the source has `privacy_class < 2`.
|
||||
- [ ] **AC4**: The default mosquitto ACL denies `read ruview/+/bfld/identity_risk/state` to the `public` user role.
|
||||
- [ ] **AC5**: Three HA blueprints install cleanly into a fresh HA install and trigger their configured actions against a mock BFLD event stream.
|
||||
- [ ] **AC6**: The federation-witness script detects an injected class-1 field in a synthetic event and exits non-zero.
|
||||
- [ ] **AC7**: Matter occupancy-sensing cluster reports presence within 1 s of an HA `binary_sensor.*_bfld_presence` state change.
|
||||
|
||||
---
|
||||
|
||||
## 6. References
|
||||
|
||||
- ADR-115 (HA-DISCO entity scheme)
|
||||
- ADR-116 (`cog-ha-matter` cog packaging)
|
||||
- ADR-120 (privacy class enforcement)
|
||||
- ADR-121 (identity risk source)
|
||||
- ADR-100 (cog packaging spec)
|
||||
- Mosquitto ACL reference: https://mosquitto.org/man/mosquitto-conf-5.html
|
||||
- Matter spec — Occupancy Sensing cluster (0x0406)
|
||||
- Cognitum V0 appliance dashboard: `http://cognitum-v0:9000/`
|
||||
@@ -1,186 +0,0 @@
|
||||
# ADR-123: BFLD Capture Path — Pi 5 / Nexmon Adapter and ESP32-S3 Feasibility
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Proposed |
|
||||
| **Date** | 2026-05-24 |
|
||||
| **Deciders** | ruv |
|
||||
| **Parent** | [ADR-118](ADR-118-bfld-beamforming-feedback-layer-for-detection.md) |
|
||||
| **Relates to** | [ADR-022](ADR-022-multi-bssid-wifi-scanning.md) (multi-BSSID scan), [ADR-028](ADR-028-esp32-capability-audit.md) (capability audit), [ADR-095](ADR-095-rvcsi-edge-rf-sensing-platform.md) (rvCSI), [ADR-096](ADR-096-rvcsi-ffi-crate-layout.md) (rvCSI FFI), [ADR-110](ADR-110-esp32-c6-firmware-extension.md) (C6 firmware), [ADR-119](ADR-119-bfld-frame-format-and-wire-protocol.md) (BfldFrame) |
|
||||
| **Tracking issue** | TBD |
|
||||
|
||||
---
|
||||
|
||||
## 1. Context
|
||||
|
||||
ADR-118 declares that BFLD captures BFI from commodity WiFi 5/6 traffic. The question this sub-ADR answers is: **on which hardware, with which adapter, and against which firmware limitations**.
|
||||
|
||||
### 1.1 ESP32-S3 BFI capability gap
|
||||
|
||||
The ESP32 capability audit (ADR-028) and the ESP32-S3 / C6 firmware (`firmware/esp32-csi-node/`, ADR-110) confirm that the Espressif WiFi API exposes **CSI** capture (`esp_wifi_set_csi_*`) but does not expose **raw 802.11 management-frame capture** in monitor mode for non-self-addressed CBFR reports. The S3 sees the CBFR frames its own AP-link generates (when it acts as a beamformer), but it cannot promiscuously sniff CBFR frames between other STA/AP pairs in the neighborhood.
|
||||
|
||||
The C6 (ESP32-C6 with RISC-V + Wi-Fi 6) has a more flexible RF subsystem but the same software-API constraint at the time of writing.
|
||||
|
||||
### 1.2 Pi 5 / Nexmon as the production capture host
|
||||
|
||||
The rvCSI platform (ADR-095/096) already vendors a Nexmon-based adapter (`rvcsi-adapter-nexmon`) that captures CSI from BCM43455c0 chips (Pi 5 / Pi 4 / Pi 3B+). Nexmon patches the firmware to surface CSI to userspace and **also surface CBFR frames** — the BFI extension is the same code path with a different filter.
|
||||
|
||||
cognitum-v0 (Pi 5 in the fleet, per CLAUDE.local.md) is already running Nexmon + the rvCSI runtime. It is the natural BFLD capture host.
|
||||
|
||||
### 1.3 What we need from each hardware tier
|
||||
|
||||
| Tier | Role | BFI capture | CSI capture | Notes |
|
||||
|------|------|-------------|-------------|-------|
|
||||
| ESP32-S3 / C6 | Sensing leaf | **no** | yes | Continues providing CSI to the existing pipeline |
|
||||
| Pi 5 / Nexmon | BFLD host | **yes** | yes (via Nexmon) | Primary BFLD capture |
|
||||
| ruvultra (RTX 5080 + AX210) | Training / dev | yes (via AX210 monitor mode) | yes | Dev capture; not production |
|
||||
| cognitum-v0 (Pi 5) | Appliance | **yes** (production) | yes | Production BFLD host |
|
||||
|
||||
---
|
||||
|
||||
## 2. Decision
|
||||
|
||||
### 2.1 Production capture path: Pi 5 / Nexmon
|
||||
|
||||
The BFLD production capture path is implemented as a new module in the vendored rvCSI submodule:
|
||||
|
||||
```
|
||||
vendor/rvcsi/crates/rvcsi-adapter-nexmon/
|
||||
└── src/
|
||||
├── lib.rs
|
||||
├── csi.rs # existing CSI capture
|
||||
└── bfi.rs # NEW — CBFR capture, exports BfiCapture
|
||||
```
|
||||
|
||||
The new `bfi.rs` parses CBFR frames (VHT or HE) from the Nexmon-patched firmware's userspace stream, extracts Φ/ψ angle matrices, and emits a `BfiCapture` struct that feeds the BFLD crate's extractor (ADR-118 §2.1, ADR-119).
|
||||
|
||||
The patch lives in the rvcsi submodule (`github.com/ruvnet/rvcsi`) and is shipped as `rvcsi-adapter-nexmon ^0.3.5` to crates.io. The wifi-densepose workspace consumes the published crate (or the submodule path during development).
|
||||
|
||||
### 2.2 BFLD crate adapter trait
|
||||
|
||||
`wifi-densepose-bfld` defines a `BfiCaptureAdapter` trait:
|
||||
|
||||
```rust
|
||||
pub trait BfiCaptureAdapter: Send + 'static {
|
||||
type Error: std::error::Error + Send + Sync + 'static;
|
||||
fn capture(&mut self) -> Result<Option<BfiCapture>, Self::Error>;
|
||||
fn capabilities(&self) -> AdapterCapabilities;
|
||||
}
|
||||
|
||||
pub struct AdapterCapabilities {
|
||||
pub supports_he: bool, // 802.11ax (Wi-Fi 6)
|
||||
pub supports_160mhz: bool,
|
||||
pub max_n_rx: u8,
|
||||
pub host_kind: HostKind, // Pi5Nexmon | Ax210Linux | EspS3Local | Mock
|
||||
}
|
||||
```
|
||||
|
||||
Three impls ship initially:
|
||||
|
||||
- `NexmonBfiAdapter` — Pi 5 / Nexmon (production)
|
||||
- `Ax210BfiAdapter` — Linux + AX210 in monitor mode (dev / training, ruvultra)
|
||||
- `MockBfiAdapter` — replay fixture for tests and CI
|
||||
|
||||
A future fourth impl (`EspS3LocalAdapter`) is reserved for the day Espressif exposes promiscuous CBFR — it captures only the S3's own AP-link BFI for local self-reporting.
|
||||
|
||||
### 2.3 Capture-side privacy boundary
|
||||
|
||||
Per ADR-120 I1, raw BFI never leaves the capturing host. The adapter must therefore live on **the same physical box** as the BFLD crate's extractor and privacy gate. The architecture pattern:
|
||||
|
||||
```
|
||||
[ Pi 5 / cognitum-v0 ]
|
||||
├── nexmon firmware (kernel)
|
||||
├── rvcsi-adapter-nexmon (userspace, captures BFI)
|
||||
├── wifi-densepose-bfld (extracts, scores, gates)
|
||||
│ └── privacy_gate → class-2/3 frames only
|
||||
└── wifi-densepose-sensing-server (publishes MQTT + Matter)
|
||||
```
|
||||
|
||||
A network-mode adapter that streams raw BFI from a remote capture host is **explicitly forbidden**. The adapter trait does not include any "remote URL" parameter.
|
||||
|
||||
### 2.4 Channel / bandwidth coverage
|
||||
|
||||
The Nexmon adapter is configured by the existing `rvcsi-adapter-nexmon` channel-hopping schedule (ADR-095 §3.2). For BFLD it adds:
|
||||
|
||||
- Filter for VHT CBFR (action frame, category 21, action 0) and HE CBFR (category 30, action 0).
|
||||
- Per-channel BFI session-tracking — the same beamformer/beamformee pair across a channel hop is reconciled by AP MAC + STA MAC.
|
||||
|
||||
### 2.5 ESP32-S3 local self-reporting (deferred)
|
||||
|
||||
For deployments without a Pi 5 / cognitum-v0 nearby, a degraded BFLD mode runs on the ESP32-S3 itself:
|
||||
|
||||
- Captures only its own AP-link CBFR (self-addressed).
|
||||
- Computes features over the limited window.
|
||||
- Reports a coarsened `presence` + `motion` only — no `identity_risk_score` (insufficient sample diversity).
|
||||
- Emits `BfldFrame` at `privacy_class = 2` with a `flags.bit3 = self_only` marker.
|
||||
|
||||
This path is implemented in firmware as part of P2 / P3 of the ADR-118 rollout, after the Pi 5 path is stable. Effort is small (firmware path reuses the existing CSI capture loop) but the value is also low until ESP32 firmware exposes promiscuous CBFR — which is a Espressif-IDF roadmap item, not under project control.
|
||||
|
||||
### 2.6 Dev path: ruvultra / AX210
|
||||
|
||||
For local dev iteration on the Windows / ruvultra box, the AX210 adapter provides a workable capture path on Linux (ruvultra is Ubuntu 6.17 per CLAUDE.local.md). The AX210 supports 802.11ax + monitor mode with the `iwlwifi` driver patches that have landed upstream. This path is for training-data collection and dev testing, not production.
|
||||
|
||||
---
|
||||
|
||||
## 3. Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- BFLD ships as a production-ready surface on cognitum-v0 day one — no new hardware procurement.
|
||||
- The adapter-trait design lets new capture paths (AX211, MediaTek Filogic, etc.) slot in without changes to the BFLD crate.
|
||||
- The capture-side privacy boundary is structural: there is no remote-capture code path, so a future PR cannot accidentally introduce one.
|
||||
- ruvultra's AX210 path unblocks training and dev iteration on Linux without depending on the Pi 5 fleet.
|
||||
|
||||
### Negative
|
||||
|
||||
- BFLD's full pipeline depends on cognitum-v0 (or another Pi 5 / Nexmon host) being present in the deployment. Operators without a Pi 5 get only the degraded ESP32-S3 self-reporting path (limited utility).
|
||||
- Nexmon is a third-party kernel module; tracking upstream patches is ongoing maintenance.
|
||||
- The CBFR frame format differs between VHT (802.11ac) and HE (802.11ax); the parser must support both, and any 802.11be (Wi-Fi 7) deployment will require an additional parser path.
|
||||
|
||||
### Neutral
|
||||
|
||||
- ruvultra dev path uses AX210; the AX210 is not the production NIC, so dev/prod parity is via the fixture replay + the Nexmon adapter on cognitum-v0.
|
||||
|
||||
---
|
||||
|
||||
## 4. Alternatives Considered
|
||||
|
||||
### Alt 1: Centralized capture host streams raw BFI to RuView nodes
|
||||
|
||||
Rejected: violates ADR-120 I1 (raw never leaves the capture host). The capture host **is** the BFLD node; there is no separation.
|
||||
|
||||
### Alt 2: Wait for Espressif promiscuous CBFR support
|
||||
|
||||
Rejected: indefinite timeline outside project control. The Pi 5 / Nexmon path is shippable today.
|
||||
|
||||
### Alt 3: Custom Pi 5 firmware fork instead of Nexmon
|
||||
|
||||
Rejected: forking BCM firmware is a huge maintenance burden and Nexmon already does what we need.
|
||||
|
||||
### Alt 4: Only ship the ESP32-S3 self-reporting path
|
||||
|
||||
Rejected: insufficient sample diversity for `identity_risk_score`. The whole point of BFLD is to measure identity leakage; a self-only path cannot do that meaningfully.
|
||||
|
||||
---
|
||||
|
||||
## 5. Acceptance Criteria
|
||||
|
||||
- [ ] **AC1**: `NexmonBfiAdapter` captures ≥ 100 valid CBFR frames per minute from a 2-AP-3-STA test bench on a Pi 5 (cognitum-v0).
|
||||
- [ ] **AC2**: VHT (802.11ac) and HE (802.11ax) CBFR frames are both parsed; mixed-PHY captures produce correctly-typed `BfiCapture` outputs.
|
||||
- [ ] **AC3**: 20/40/80/160 MHz channel widths are all supported (one fixture each in `tests/`).
|
||||
- [ ] **AC4**: `BfiCaptureAdapter` trait has no method accepting a remote URL or socket address.
|
||||
- [ ] **AC5**: ESP32-S3 self-only adapter compiles `#[no_std]` and produces a `BfldFrame` with `flags.bit3 = self_only` set, no `identity_risk_score` field.
|
||||
- [ ] **AC6**: AX210 adapter on ruvultra captures CBFR for at least one fixture-generating dev session.
|
||||
- [ ] **AC7**: Capture loop sustains 10 Hz BFI frame rate on cognitum-v0 without dropping frames over a 10-minute soak test.
|
||||
|
||||
---
|
||||
|
||||
## 6. References
|
||||
|
||||
- ADR-095 / ADR-096 (rvCSI Nexmon adapter)
|
||||
- ADR-028 (ESP32 capability audit)
|
||||
- ADR-110 (ESP32-C6 firmware)
|
||||
- Nexmon BCM43455c0 patches: https://github.com/seemoo-lab/nexmon
|
||||
- Wi-BFI: https://arxiv.org/abs/2309.04408
|
||||
- IEEE 802.11-2020 §19.3.12 (VHT CBFR), §27.3.11 (HE CBFR)
|
||||
- cognitum-v0 fleet entry: `CLAUDE.local.md` (Tailscale fleet table)
|
||||
@@ -1,466 +0,0 @@
|
||||
# ADR-124: rvagent — MCP (stdio + Streamable HTTP) + ruvector npm/TypeScript library for RuView with ruflo integration
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Proposed |
|
||||
| **Date** | 2026-05-24 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codename** | **SENSE-BRIDGE** — a typed bridge between the RuView sensing stack and the MCP agent ecosystem |
|
||||
| **Relates to** | [ADR-055](ADR-055-integrated-sensing-server.md) (sensing-server), [ADR-095](ADR-095-rvcsi-edge-rf-sensing-platform.md) (rvCSI), [ADR-097](ADR-097-adopt-rvcsi-as-ruview-csi-runtime.md) (rvCSI adoption), [ADR-115](ADR-115-home-assistant-integration.md) (HA-DISCO), [ADR-116](ADR-116-cog-ha-matter-seed.md) (Seed cog), [ADR-117](ADR-117-pip-wifi-densepose-modernization.md) (PIP-PHOENIX), [ADR-118](ADR-118-bfld-beamforming-feedback-layer-for-detection.md) (BFLD) |
|
||||
| **Tracking issue** | TBD |
|
||||
|
||||
---
|
||||
|
||||
## 1. Context
|
||||
|
||||
### 1.1 The access-layer gap
|
||||
|
||||
The RuView / wifi-densepose Rust stack exposes sensing data through three surfaces: a Tokio/Axum HTTP REST API and WebSocket at `wifi-densepose-sensing-server` (ADR-055); an MQTT namespace under `ruview/<node_id>/*` (ADR-115); and an rvCSI edge runtime (ADR-095/096). None of these surfaces speaks Model Context Protocol (MCP).
|
||||
|
||||
MCP is the dominant inter-process contract through which AI assistants (Claude, GPT, Codex) invoke external capabilities in 2026. Without an MCP bridge, RuView's sensing primitives are invisible to AI-driven automation workflows. An agent cannot ask "who is in the room?" or "subscribe me to fall alerts" without bespoke HTTP integration code in every consuming agent.
|
||||
|
||||
Two concrete user stories that SENSE-BRIDGE resolves:
|
||||
|
||||
1. A developer has a Claude Code session and wants to call `vitals.get_heart_rate` from a prompt — today this requires them to write an HTTP fetch, parse JSON, and handle WebSocket reconnect logic; with SENSE-BRIDGE they install `@ruvnet/rvagent` and the tool is available immediately via `claude mcp add rvagent`.
|
||||
2. A ruflo-orchestrated multi-agent swarm needs real-world presence data to gate a workflow: SENSE-BRIDGE gives the swarm an MCP tool call with the same `mcp__claude-flow__*` signature pattern already used for all other ruflo tools (CLAUDE.md §Ruflo Automation Primitives).
|
||||
|
||||
### 1.2 What rvagent is today
|
||||
|
||||
Research of the ruvnet npm registry profile and the ruflo GitHub repository (issue #1689) establishes that **rvagent is not yet a published standalone npm package** as of 2026-05-24. The name "rvagent" appears in the ruflo project exclusively as a WASM artifact (`rvagent_wasm_bg.wasm`, 588 KB) bundled with the RuFlo Web UI (PR #1687). That artifact exports 13 WASM functions including `callMcp`, `executeTool`, `listTools`, `listGalleryTemplates`, `searchGalleryTemplates`, and `loadGalleryTemplate`. It is an in-browser MCP client runner, not a RuView-specific MCP server.
|
||||
|
||||
There is no `rvagent` package on the npm registry as of this writing. The npm name is therefore available (Q1 in §8). The package name to register is `@ruvnet/rvagent` (scoped form, reduces name-squatting risk) or `rvagent` (unscoped form, simpler `npx` invocation). This ADR proposes `@ruvnet/rvagent`.
|
||||
|
||||
The WASM `callMcp` / `executeTool` surface of the existing ruflo rvagent is the functional model for what the new npm package should expose in TypeScript — but the new package is a **server**, not a client, and its tools are RuView-domain-specific rather than general ruflo-gallery tools.
|
||||
|
||||
### 1.3 MCP transport landscape as of 2026-05-24
|
||||
|
||||
The MCP specification shipped version `2025-03-26` (Streamable HTTP) and `2025-06-18` (current stable) replacing the legacy `2024-11-05` HTTP+SSE transport. Key facts relevant to this ADR:
|
||||
|
||||
- **stdio** remains the recommended local transport. Clients launch the MCP server as a subprocess; the server reads JSON-RPC from stdin and writes to stdout. This is the path `claude mcp add <name> -- npx @ruvnet/rvagent stdio` uses (CLAUDE.md §Quick Setup mirrors this pattern for the claude-flow MCP server).
|
||||
- **Streamable HTTP** (colloquially "SSE" in earlier documentation) replaces the deprecated pure-SSE transport. A single HTTP endpoint at e.g. `POST /mcp` accepts JSON-RPC requests and may respond with `Content-Type: text/event-stream` for streaming, or `application/json` for single-turn responses. The server must validate `Origin` headers and bind to `127.0.0.1` by default (MCP spec security requirement).
|
||||
- The `@modelcontextprotocol/sdk` npm package (latest stable at time of writing) ships `Server`, `StdioServerTransport`, and `StreamableHTTPServerTransport`. A single `Server` instance can be connected to both transports simultaneously by calling `server.connect(transport)` for each.
|
||||
- The legacy `SSEServerTransport` from protocol version `2024-11-05` is deprecated but still ship-able for backwards compatibility with older Claude desktop clients. SENSE-BRIDGE will support it behind an `--legacy-sse` flag for a single release cycle, then remove it.
|
||||
|
||||
### 1.4 ruvector npm surface
|
||||
|
||||
The `ruvector` npm package (version 0.2.x, latest 0.2.25 as of ~2026-05-01) is a napi-rs WASM/Node.js binding of the RuVector Rust crate. It provides:
|
||||
|
||||
- HNSW in-memory vector index (sub-0.5 ms query latency, 50 K+ QPS single-threaded)
|
||||
- 50+ attention mechanisms from the RuVector Rust crate
|
||||
- FlashAttention-3 SIMD path
|
||||
- Graph Neural Network support via `@ruvector/gnn`
|
||||
- Full TypeScript types; ships both ESM and CJS
|
||||
|
||||
The `ruvector` package is already a dependency in the existing Rust workspace's napi-rs node bindings (`ruvector-node` crate, version 0.1.29 on crates.io). The npm package and the Rust crate are developed in the same repository (`github.com/ruvnet/ruvector`). SENSE-BRIDGE can depend on `ruvector` directly without needing to add new Rust FFI — the vector ops needed (HNSW index of pose keypoints, embedding storage for AETHER person re-ID) are already exposed in the npm package's public surface.
|
||||
|
||||
### 1.5 ruflo integration context
|
||||
|
||||
The project's `CLAUDE.md` documents the 3-tier model routing (ADR-026) and the `mcp__claude-flow__*` tool namespace. ruflo exposes 314 native MCP tools. SENSE-BRIDGE adds a new domain namespace `mcp__rvagent__*` that represents RuView sensing capabilities, parallel to but separate from the ruflo tools. The boundary is:
|
||||
- **ruflo**: agent orchestration, memory, swarm coordination, hooks, task management
|
||||
- **rvagent / SENSE-BRIDGE**: RuView-specific sensing — presence, vitals, pose, BFLD, semantic primitives
|
||||
|
||||
ruflo can call rvagent tools via the standard MCP tool-call mechanism; rvagent does not depend on ruflo at runtime (but may optionally use ruflo memory namespaces for persistence).
|
||||
|
||||
---
|
||||
|
||||
## 2. Decision
|
||||
|
||||
Ship `@ruvnet/rvagent` as a standalone npm TypeScript library that:
|
||||
|
||||
1. Exposes a **dual-transport MCP server** (stdio + Streamable HTTP) wrapping RuView sensing primitives.
|
||||
2. Uses `ruvector` (npm) as the vector storage layer for pose embeddings and AETHER-class semantic search, with no reimplementation of vector ops in TypeScript.
|
||||
3. Mirrors the Python `wifi_densepose.client.*` surface (ADR-117 P4 — `python/wifi_densepose/client/ws.py`, `mqtt.py`, `primitives.py`) in TypeScript for parity across runtimes.
|
||||
4. Integrates as a ruflo plugin via the `ruflo-plugin` manifest convention, exposing tools in the `mcp__rvagent__*` namespace callable by ruflo agents.
|
||||
5. Ships strict TypeScript source, ESM + CJS dual output, Node.js 20+ minimum, type definitions in the tarball, zero bundler required.
|
||||
|
||||
---
|
||||
|
||||
## 3. Transport comparison
|
||||
|
||||
| Dimension | stdio | Streamable HTTP |
|
||||
|---|---|---|
|
||||
| **Launch mechanism** | Client forks `npx @ruvnet/rvagent stdio` as subprocess | Client POSTs to `http://host:port/mcp` |
|
||||
| **Primary use case** | Claude Code, Cursor, IDE plugins — local developer flow | Remote agents, ruflo swarms on separate hosts, browser-based dashboards |
|
||||
| **Connection state** | One client per server process; process dies with client | Multiple clients per server process; stateless or session-keyed |
|
||||
| **Streaming** | Newline-delimited JSON on stdout | `text/event-stream` response body |
|
||||
| **Auth** | None needed (process-level isolation) | Bearer token or mTLS required (per MCP spec security rules) |
|
||||
| **RuView sensing-server connectivity** | Server process holds a single WebSocket + MQTT connection to sensing-server; results forwarded to client via JSON-RPC | Server process holds a connection pool; session affinity via `Mcp-Session-Id` header |
|
||||
| **Tailscale fleet** | Works on local node only | Works across Tailscale fleet (cognitum-v0, cognitum-seed-1, ruvultra) with DNS name |
|
||||
| **Origin validation** | Not applicable | Required; server MUST reject cross-origin requests unless CORS policy explicitly permits |
|
||||
| **Resumability** | Not applicable (process is co-located) | Optional `Last-Event-ID` header for stream resumption after reconnect |
|
||||
| **Logging** | stderr — captured by Claude Code, displayed in conversation | Structured JSON to stdout, shipped to ruflo observability (ADR-observability) |
|
||||
| **Process lifecycle** | Ephemeral — exits when Claude Code session ends | Long-lived — suitable for always-on sensing daemon |
|
||||
| **When to choose** | Single developer, local ESP32 (COM9), quick scripting | Fleet deployment, multi-agent ruflo swarms, web dashboards |
|
||||
|
||||
Both transports are served by the same `Server` instance from `@modelcontextprotocol/sdk`. The only difference is the `Transport` class passed to `server.connect()`.
|
||||
|
||||
---
|
||||
|
||||
## 4. MCP tool catalog
|
||||
|
||||
All tools are in the `ruview` namespace. Input schemas below are TypeScript interface stubs; output types mirror the Python dataclasses from `python/wifi_densepose/client/ws.py` and `primitives.py`.
|
||||
|
||||
### 4.1 Tool catalog table
|
||||
|
||||
| Tool name | Input interface | Return shape | RuView surface wrapped |
|
||||
|---|---|---|---|
|
||||
| `ruview.presence.now` | `{ node_id?: string }` | `{ node_id: string; present: boolean; n_persons: number; confidence: number; timestamp_ms: number }` | `EdgeVitalsMessage.presence` / `EdgeVitalsMessage.n_persons` (ws.py:74-88) |
|
||||
| `ruview.vitals.get_breathing` | `{ node_id?: string; window_s?: number }` | `{ node_id: string; breathing_rate_bpm: number \| null; confidence: number; timestamp_ms: number }` | `EdgeVitalsMessage.breathing_rate_bpm` (ws.py:82) |
|
||||
| `ruview.vitals.get_heart_rate` | `{ node_id?: string; window_s?: number }` | `{ node_id: string; heartrate_bpm: number \| null; confidence: number; timestamp_ms: number }` | `EdgeVitalsMessage.heartrate_bpm` (ws.py:83) |
|
||||
| `ruview.vitals.get_all` | `{ node_id?: string }` | `EdgeVitalsResult` (all fields of `EdgeVitalsMessage` except `raw`) | Full `EdgeVitalsMessage` (ws.py:74-88) |
|
||||
| `ruview.pose.latest` | `{ node_id?: string }` | `{ node_id: string; persons: PosePersonResult[]; confidence: number; timestamp_ms: number }` | `PoseDataMessage` (ws.py:91-98) |
|
||||
| `ruview.pose.subscribe` | `{ node_id?: string; duration_s: number; callback_url?: string }` | `{ subscription_id: string; started_at: number; expires_at: number }` | WS stream — streams `PoseDataMessage` events for `duration_s` seconds |
|
||||
| `ruview.primitives.get` | `{ node_id?: string; primitive: SemanticPrimitiveKind }` | `SemanticPrimitiveResult` | `SemanticPrimitive` + `SemanticPrimitiveEvent` (primitives.py:36-75) |
|
||||
| `ruview.primitives.list_active` | `{ node_id?: string }` | `{ primitives: SemanticPrimitiveResult[] }` | All 10 ADR-115 semantic primitives (primitives.py:36-45) |
|
||||
| `ruview.primitives.subscribe` | `{ node_id?: string; primitive?: SemanticPrimitiveKind; duration_s: number }` | `{ subscription_id: string; expires_at: number }` | MQTT topic `homeassistant/+/wifi_densepose_<node>/+/state` (mqtt.py:8-9) |
|
||||
| `ruview.bfld.last_scan` | `{ node_id?: string }` | `{ node_id: string; identity_risk_score: number; privacy_class: number; n_frames: number; timestamp_ms: number }` | MQTT `ruview/<node_id>/bfld/scan_result` (ADR-118/ADR-121) |
|
||||
| `ruview.bfld.subscribe` | `{ node_id?: string; duration_s: number }` | `{ subscription_id: string; expires_at: number }` | MQTT `ruview/<node_id>/bfld/*` |
|
||||
| `ruview.node.list` | `{ }` | `{ nodes: NodeInfo[] }` | MQTT discovery + REST `/api/nodes` |
|
||||
| `ruview.node.status` | `{ node_id: string }` | `NodeStatusResult` | REST `/api/status` or MQTT will-message |
|
||||
| `ruview.vector.search_pose` | `{ query_embedding: number[]; k?: number; node_id?: string }` | `{ matches: VectorMatch[] }` | `ruvector` HNSW index of stored pose keypoints (ADR-016) |
|
||||
| `ruview.vector.store_pose` | `{ pose: PosePersonResult; node_id: string }` | `{ vector_id: string }` | `ruvector` HNSW upsert |
|
||||
|
||||
### 4.1a Policy / governance tools (RUVIEW-POLICY)
|
||||
|
||||
**Added 2026-05-24 per maintainer review.** Once tools can answer "who is in the room?", the library is no longer middleware — it is environmental intelligence infrastructure, and that changes the trust model. Every sensing tool above MUST route through this policy layer before returning data. The layer is enforced server-side in the MCP server, not client-side, so a malicious or misconfigured agent cannot bypass it.
|
||||
|
||||
| Tool name | Input interface | Return shape | Purpose |
|
||||
|---|---|---|---|
|
||||
| `ruview.policy.can_access_vitals` | `{ agent_id: string; node_id: string; vital: "breathing" \| "heart_rate" \| "all" }` | `{ allowed: boolean; reason: string; expires_at?: number }` | Gate every `ruview.vitals.*` call. Default-deny when no policy is registered for the (agent_id, node_id) pair. |
|
||||
| `ruview.policy.can_query_presence` | `{ agent_id: string; scope: "node" \| "fleet"; node_id?: string; zone?: string }` | `{ allowed: boolean; reason: string; redactions?: string[] }` | Fleet-scope presence queries (e.g. "is anyone home?") require explicit scope grant; node-scope is the safer default. |
|
||||
| `ruview.policy.can_subscribe` | `{ agent_id: string; topic: string; duration_s: number }` | `{ allowed: boolean; max_duration_s: number; reason: string }` | Subscriptions can be denied entirely or capped to a shorter duration than requested (e.g. agent asks for 1 h, policy returns 5 min). |
|
||||
| `ruview.policy.redact_identity_fields` | `{ payload: Record<string, unknown>; agent_id: string }` | `{ payload: Record<string, unknown>; redacted_fields: string[] }` | Server-side redaction pass applied to every tool return value. Strips `sta_mac`, raw BFLD matrices, and any keypoint set marked `privacy_class >= 2` per ADR-120. Called automatically by the MCP server; agents never see the un-redacted payload. |
|
||||
| `ruview.policy.audit_log` | `{ agent_id?: string; since_ts?: number }` | `{ events: PolicyAuditEvent[] }` | Returns the policy-decision audit trail for a maintainer-tier agent. Other agents are denied even if they hold valid tool grants — auditability of the auditor is itself a policy decision. |
|
||||
|
||||
Policy storage is a local JSON file (`~/.config/rvagent/policy.json` on Unix, `%APPDATA%\rvagent\policy.json` on Windows) backed by a CLI editor (`npx @ruvnet/rvagent policy grant ...`). Schema mirrors the ADR-010 claims-based authorization model where it exists in the Rust workspace, but the npm library keeps a self-contained store so SENSE-BRIDGE can ship without the full claims infrastructure on day one.
|
||||
|
||||
**Default policy when no file exists**: deny `ruview.vitals.*` and `ruview.policy.audit_log`; allow `ruview.presence.now` and `ruview.node.list` (coarse, non-biometric); allow `ruview.primitives.list_active` with `redact_identity_fields` applied. This is the "explore safely" default so a new install can sanity-check the agent is wired up without leaking biometric data.
|
||||
|
||||
### 4.2 MCP resource catalog
|
||||
|
||||
Resources provide read-only data that can be embedded in the LLM context window.
|
||||
|
||||
| Resource URI | Description | MIME type |
|
||||
|---|---|---|
|
||||
| `ruview://nodes` | JSON list of all discovered nodes (IP, firmware version, capabilities) | `application/json` |
|
||||
| `ruview://nodes/{node_id}/config` | Node configuration (channel, MAC filter, privacy class) | `application/json` |
|
||||
| `ruview://nodes/{node_id}/vitals/latest` | Latest `EdgeVitalsMessage` for the node | `application/json` |
|
||||
| `ruview://nodes/{node_id}/pose/latest` | Latest `PoseDataMessage` | `application/json` |
|
||||
| `ruview://nodes/{node_id}/bfld/latest` | Latest BFLD scan result | `application/json` |
|
||||
| `ruview://primitives/schema` | JSON schema for the 10 semantic primitives (ADR-115) | `application/json` |
|
||||
| `ruview://fleet/topology` | Tailscale-fleet topology (host, TS IP, role) — sourced from local CLAUDE.local.md fleet table | `text/markdown` |
|
||||
|
||||
### 4.3 MCP prompt templates
|
||||
|
||||
| Prompt name | Description | Arguments |
|
||||
|---|---|---|
|
||||
| `ruview.diagnose_node` | Walk the user through node connectivity check, firmware version, and live vitals stream | `{ node_id: string }` |
|
||||
| `ruview.presence_report` | Summarize presence + persons over a time window in natural language | `{ node_id: string; window_s: number }` |
|
||||
| `ruview.vitals_alert_rule` | Generate an HA automation YAML fragment for a vitals threshold alert | `{ primitive: SemanticPrimitiveKind; threshold: number }` |
|
||||
| `ruview.bfld_privacy_audit` | Produce a compliance-ready privacy audit paragraph from the last BFLD scan | `{ node_id: string }` |
|
||||
|
||||
---
|
||||
|
||||
## 5. Dependency graph
|
||||
|
||||
```
|
||||
@ruvnet/rvagent (npm / TypeScript)
|
||||
├── @modelcontextprotocol/sdk ^1.x — MCP Server, StdioServerTransport,
|
||||
│ StreamableHTTPServerTransport, McpError
|
||||
├── ruvector ^0.2 — HNSW vector index, embedding storage
|
||||
│ (napi-rs native bindings; NO reimplementation)
|
||||
├── zod ^3.x — Input schema validation for all tool inputs
|
||||
├── ws ^8.x — WebSocket client to sensing-server /ws/sensing
|
||||
│ └── @types/ws
|
||||
├── mqtt ^5.x — MQTT client for ruview/<node_id>/* topics
|
||||
│ (replaces paho-mqtt; mqtt.js is the npm standard)
|
||||
├── node-fetch / undici — — HTTP client for REST endpoints on sensing-server
|
||||
└── tsup (dev) — ESM + CJS dual build
|
||||
|
||||
Runtime back-ends (NOT bundled — must be reachable at runtime):
|
||||
├── wifi-densepose-sensing-server (Rust binary)
|
||||
│ ├── REST API :3000 /api/*
|
||||
│ ├── WebSocket :8765 /ws/sensing
|
||||
│ └── MQTT via local broker or ruview/<node_id>/*
|
||||
├── MQTT broker (mosquitto or broker at cognitum-v0:1883)
|
||||
└── ruvector HNSW index (in-process via napi-rs; no separate service)
|
||||
```
|
||||
|
||||
Key integration boundary: **ruvector is purely in-process**. The HNSW index lives in the `@ruvnet/rvagent` Node.js process memory, populated from pose keypoints received over the sensing-server WebSocket. There is no separate vector service. This matches the architecture of `wifi-densepose-ruvector` (Rust crate in the workspace) which is also in-process.
|
||||
|
||||
---
|
||||
|
||||
## 6. Python client surface parity table
|
||||
|
||||
The Python client in `python/wifi_densepose/client/` (ADR-117 P4) is the canonical reference for the TS surface. TypeScript should mirror it so users see the same domain model across runtimes.
|
||||
|
||||
| Python class / enum | File | TypeScript equivalent in @ruvnet/rvagent |
|
||||
|---|---|---|
|
||||
| `SensingMessage` | `ws.py:54-60` | `interface SensingMessage` |
|
||||
| `ConnectionEstablishedMessage` | `ws.py:63-70` | `interface ConnectionEstablishedMessage extends SensingMessage` |
|
||||
| `EdgeVitalsMessage` | `ws.py:74-88` | `interface EdgeVitalsMessage extends SensingMessage` |
|
||||
| `PoseDataMessage` | `ws.py:91-98` | `interface PoseDataMessage extends SensingMessage` |
|
||||
| `SensingClient` (asyncio) | `ws.py:160` | `class SensingClient` (EventEmitter-based, async iterator) |
|
||||
| `SemanticPrimitive` (enum) | `primitives.py:36-45` | `enum SemanticPrimitive` |
|
||||
| `SemanticPrimitiveEvent` | `primitives.py:60-75` | `interface SemanticPrimitiveEvent` |
|
||||
| `SemanticPrimitiveListener` | `primitives.py:84-155` | `class SemanticPrimitiveListener` |
|
||||
| `RuViewMqttClient` | `mqtt.py:56` | `class RuViewMqttClient` (wraps mqtt.js `MqttClient`) |
|
||||
| `_topic_matches` | `mqtt.py:237-257` | `function topicMatches(pattern, topic)` |
|
||||
|
||||
---
|
||||
|
||||
## 7. Implementation plan
|
||||
|
||||
```
|
||||
P1 ──► P2 ──► P3 ──► P4 ──► P5
|
||||
npm MCP MCP ruvector npm
|
||||
scaffold stdio SSE integration publish + ruflo bridge
|
||||
```
|
||||
|
||||
### P1 — Scaffold (1 week)
|
||||
|
||||
**Goal**: an installable npm package skeleton that compiles and passes CI.
|
||||
|
||||
- [ ] Create `npm/rvagent/` directory in the repo (mirrors `python/wifi_densepose/`). Do not add to `v2/` Rust workspace.
|
||||
- [ ] `package.json`: name `@ruvnet/rvagent`, version `0.1.0-alpha.1`, `type: "module"`, exports map with `./package.json`, `.` (ESM + CJS), `./stdio`, `./http`.
|
||||
- [ ] `tsconfig.json`: `strict: true`, `target: ES2022`, `module: NodeNext`, `moduleResolution: NodeNext`.
|
||||
- [ ] `tsup.config.ts`: dual `esm + cjs` build, `dts: true`.
|
||||
- [ ] Add `@modelcontextprotocol/sdk`, `ruvector`, `zod`, `ws`, `mqtt`, `tsup` as deps / devDeps.
|
||||
- [ ] CI job: `npm ci && npm run build` on `ubuntu-latest` with Node 20, 22.
|
||||
- [ ] Stub `src/index.ts` that exports package version string. Import succeeds.
|
||||
|
||||
### P2 — MCP stdio server (2 weeks)
|
||||
|
||||
**Goal**: `npx @ruvnet/rvagent stdio` connects to a running sensing-server over WebSocket + MQTT and exposes the tool catalog from §4.1 over stdio transport.
|
||||
|
||||
- [ ] `src/server.ts` — create `McpServer` instance, register all tools from §4.1 with Zod input schemas. Tools that require a live sensing-server connection return a structured error `{ error: "SENSING_SERVER_UNAVAILABLE" }` rather than throwing, so the LLM gets useful context.
|
||||
- [ ] `src/transports/stdio.ts` — `StdioServerTransport` entrypoint. Reads `RUVIEW_HOST` and `RUVIEW_PORT` env vars (default `localhost:8765` WS, `localhost:3000` REST, `localhost:1883` MQTT).
|
||||
- [ ] `src/sensing/ws-client.ts` — TypeScript port of `python/wifi_densepose/client/ws.py`. Async generator yielding `SensingMessage` variants. Reconnect with exponential back-off (the Python client explicitly does not reconnect — the TS one should, because the stdio process is long-lived).
|
||||
- [ ] `src/sensing/mqtt-client.ts` — TypeScript port of `python/wifi_densepose/client/mqtt.py` using `mqtt.js ^5`. Per-pattern callbacks, `topicMatches` wildcard helper.
|
||||
- [ ] `src/sensing/primitives.ts` — `SemanticPrimitive` enum + `SemanticPrimitiveListener`. Mirror of `primitives.py`.
|
||||
- [ ] Tool implementations for the 5 highest-priority tools: `ruview.presence.now`, `ruview.vitals.get_all`, `ruview.pose.latest`, `ruview.primitives.get`, `ruview.node.list`.
|
||||
- [ ] Resource implementations: `ruview://nodes`, `ruview://nodes/{node_id}/vitals/latest`.
|
||||
- [ ] Integration test: spin up `sensing-server --mock-frames` in Docker; assert `npx @ruvnet/rvagent stdio` receives a `ruview.vitals.get_all` tool call response with non-null `breathing_rate_bpm`.
|
||||
- [ ] `claude mcp add rvagent -- npx @ruvnet/rvagent stdio` smoke-test (manual).
|
||||
|
||||
### P3 — MCP Streamable HTTP server (2 weeks)
|
||||
|
||||
**Goal**: `npx @ruvnet/rvagent serve --port 3100` starts an HTTP server that serves the full MCP tool catalog over Streamable HTTP (and optionally legacy SSE for backwards compat).
|
||||
|
||||
- [ ] `src/transports/http.ts` — `StreamableHTTPServerTransport` backed by an Express 5 or Hono app (Hono preferred for lightweight edge deployability).
|
||||
- [ ] Session management: issue `Mcp-Session-Id` UUIDs on `POST /mcp` initialize; reject subsequent requests without session header with HTTP 400.
|
||||
- [ ] Origin validation: configurable `RUVIEW_ALLOWED_ORIGINS` env var; default reject all cross-origin requests (MCP spec security requirement §Streamable HTTP §Security Warning).
|
||||
- [ ] Auth: optional `RUVIEW_BEARER_TOKEN` env var. If set, require `Authorization: Bearer <token>` on all requests. This mirrors `v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs`.
|
||||
- [ ] Legacy SSE compatibility: `--legacy-sse` flag mounts the deprecated `SSEServerTransport` on `/sse` + `/message` for Claude Desktop clients on protocol version `2024-11-05`. Document this as a single-release compat shim.
|
||||
- [ ] Remaining tools from §4.1: `ruview.vitals.get_breathing`, `ruview.vitals.get_heart_rate`, `ruview.pose.subscribe`, `ruview.primitives.list_active`, `ruview.primitives.subscribe`, `ruview.bfld.last_scan`, `ruview.bfld.subscribe`, `ruview.node.status`.
|
||||
- [ ] Prompt template registrations from §4.3.
|
||||
- [ ] Integration test: `curl -X POST http://localhost:3100/mcp` with a `tools/list` request; assert the response lists all 15 tools.
|
||||
- [ ] Docker Compose entry for local fleet testing: `rvagent` HTTP container talking to `sensing-server` and `mosquitto` containers.
|
||||
|
||||
### P4 — ruvector integration (1 week)
|
||||
|
||||
**Goal**: `ruview.vector.search_pose` and `ruview.vector.store_pose` tools work end-to-end with a live HNSW index.
|
||||
|
||||
- [ ] `src/vector/index.ts` — wrapper around `ruvector` napi-rs bindings. Initialise an HNSW index at server startup; expose `store(id, embedding)` and `search(embedding, k)`.
|
||||
- [ ] Pose-to-embedding pipeline: when a `PoseDataMessage` arrives from the WS client, extract the 17-keypoint array, normalise to `[-1, 1]` per keypoint coordinate, flatten to a 34-dimensional float vector, store in HNSW with `node_id:person_index:timestamp_ms` as the ID.
|
||||
- [ ] `src/vector/aether.ts` — AETHER-style cross-viewpoint search (ADR-024): given a pose embedding query, search HNSW index across all stored poses and return the top-k matches with their source node IDs. This enables cross-node person re-identification via the MCP tool without any network call between nodes.
|
||||
- [ ] Verify that the `ruvector` napi-rs binary loads correctly on Node 20 linux/x86_64, macos/arm64, and windows/amd64. Document any platform-specific caveats.
|
||||
- [ ] Index persistence: optional `RUVIEW_VECTOR_DB_PATH` env var. If set, persist the HNSW index to disk using `ruvector`'s serialise API. If unset, in-memory only (default for stdio transport).
|
||||
- [ ] Integration test: feed 100 synthetic pose frames with known clustering, assert `ruview.vector.search_pose` retrieves nearest neighbours with recall >0.9.
|
||||
|
||||
### P5 — npm publish + ruflo bridge (1 week)
|
||||
|
||||
**Goal**: `npm install @ruvnet/rvagent` works for consumers; ruflo agents can call `mcp__rvagent__*` tools through the standard claude-flow MCP registration.
|
||||
|
||||
- [ ] Populate `package.json` with `publishConfig: { access: "public" }`, `engines: { node: ">=20" }`, `files` whitelist (`dist/`, `src/`, `README.md`).
|
||||
- [ ] Publish `@ruvnet/rvagent@0.1.0-alpha.1` to npm under the `@ruvnet` scope.
|
||||
- [ ] ruflo plugin manifest: create `.claude/plugins/rvagent/plugin.json` following the ruflo `plugin/` convention in the ruflo repo. The manifest registers the HTTP transport URL (configurable) and maps `mcp__rvagent__*` tool calls to the rvagent MCP server.
|
||||
- [ ] `ruview` skill in `.claude/agents/` (CLAUDE.md §Available Agents): an agent description that documents the rvagent tool namespace for ruflo orchestration.
|
||||
- [ ] `claude mcp add rvagent -- npx @ruvnet/rvagent stdio` tested against claude-flow MCP server on the local dev machine (ruvzen host on CLAUDE.local.md fleet).
|
||||
- [ ] Document the fleet deployment pattern: run `npx @ruvnet/rvagent serve` on cognitum-v0 (Tailscale IP 100.77.59.83, port 50060 range to avoid conflict with existing services; see CLAUDE.local.md services table). Register the URL as a remote MCP server in `.claude/settings.json`.
|
||||
- [ ] Publish announcement: link from project README (`docs/` link, not root README per CLAUDE.md rules).
|
||||
|
||||
---
|
||||
|
||||
## 8. Open questions
|
||||
|
||||
**Q1. npm package name availability**
|
||||
`rvagent` (unscoped) does not appear in the npm registry as of 2026-05-24 based on search results. `@ruvnet/rvagent` is definitely available (the `@ruvnet` scope is owned by ruvnet per the npm profile page). Should the package be published unscoped (`rvagent`) for simpler `npx rvagent stdio` invocation, or scoped (`@ruvnet/rvagent`) for namespace clarity? The decision should be made before P5 because the npm name is permanent.
|
||||
|
||||
**Q2. ruvector binary compatibility on Windows**
|
||||
The `ruvector` npm package is a napi-rs native addon. The project's primary development machine (ruvzen) is Windows 11. It is not confirmed whether `ruvector@0.2.25` ships a prebuilt Windows binary in its npm tarball or requires a Rust toolchain to compile. If no Windows binary is shipped, developers on ruvzen would need the Rust toolchain installed to use `@ruvnet/rvagent`. This must be confirmed before P5 by running `npm install ruvector` on ruvzen.
|
||||
|
||||
**Q3. ruvector TypeScript API stability**
|
||||
ruvector `0.2.x` is not a 1.0 release. The HNSW insert and search API surface may change between minor versions. SENSE-BRIDGE P4 should pin `ruvector@~0.2.25` and document the version constraint explicitly. The question is whether ruvector publishes a changelog with breaking-change notices.
|
||||
|
||||
**Q4. MCP tool call latency budget — RESOLVED**
|
||||
Raw sensing frequency ≠ agent interaction frequency. If a tool call ever waits on the next CSI frame, agent orchestration latency becomes physically coupled to RF acquisition jitter, which is unacceptable at scale. The library MUST take option (a) — return from a continuous local cache:
|
||||
|
||||
1. **Continuous local cache**: on startup the rvagent MCP server opens one WebSocket + one MQTT subscription per configured sensing-server endpoint and ingests every frame into an in-memory `Map<node_id, EdgeVitalsMessage>` (plus parallel maps for `PoseDataMessage` and BFLD). Cache hits return in <1 ms regardless of CSI frame rate.
|
||||
2. **Event-driven invalidation**: the cache entry's `received_at` timestamp is bumped on every received frame. The cache itself is never purged on a timer — only overwritten when fresh data lands, so a node that went quiet still serves its last-known value.
|
||||
3. **Bounded freshness windows**: each tool accepts an optional `max_age_ms` argument (default 1000). If the cached `received_at` is older than `max_age_ms`, the tool returns `{ value: null, reason: "stale", last_seen_ms: N, threshold_ms: max_age_ms }` rather than blocking. The agent decides whether to accept the staleness, raise to the user, or escalate to a `ruview.node.status` health check.
|
||||
|
||||
This pattern is required because P3's Streamable HTTP transport may serve dozens of concurrent agent sessions — see Q8. A shared cache + per-session freshness contract scales; per-session WS connections do not.
|
||||
|
||||
P2 must implement this cache; P3 must verify that fanning the same cache to N concurrent HTTP sessions still maintains <1 ms median tool-call latency under load.
|
||||
|
||||
**Q5. Subscription tool lifetime management**
|
||||
Tools `ruview.pose.subscribe`, `ruview.primitives.subscribe`, and `ruview.bfld.subscribe` return a `subscription_id` and stream events. In the stdio transport there is one client, so this is straightforward. In the HTTP transport with multiple sessions, subscription state must be tracked per `Mcp-Session-Id`. When a session expires (HTTP 404) or is deleted via HTTP DELETE, the subscription must be cleaned up. The lifecycle mechanism is not fully designed — this is a known gap that P3 must close.
|
||||
|
||||
**Q6. AETHER embedding dimension**
|
||||
The ADR proposes a 34-dimensional pose embedding (17 keypoints × 2 coordinates). The actual AETHER embedding model (ADR-024) uses a learned contrastive encoder, not raw keypoints. If the AETHER ONNX model is available in the Rust workspace at P4 time, the embedding should use it. If not, the raw-keypoint approach is a reasonable placeholder. The question is whether `wifi-densepose-nn` exposes the AETHER encoder in a form that can be called from Node.js without bundling libtorch in the npm package.
|
||||
|
||||
**Q7. ruflo plugin manifest format**
|
||||
The ruflo plugin convention (`plugin/` directory in the ruflo repo) is not fully documented in a public spec as of this writing. The manifest format was inferred from the `ruflo-plugins.gif` directory listing and referenced in issue #952. Before P5, the actual plugin manifest schema must be confirmed from the ruflo repo so SENSE-BRIDGE does not ship an incompatible manifest.
|
||||
|
||||
**Q8. MQTT vs direct WebSocket for Streamable HTTP transport**
|
||||
In the stdio transport, rvagent holds a single WebSocket + single MQTT connection to the sensing-server. In the Streamable HTTP transport (potentially serving dozens of agent sessions), maintaining one connection per session is not scalable. The recommended pattern is a single shared connection per (sensing-server endpoint), multiplexed to all sessions. The implementation complexity of this fan-out is non-trivial and is not fully specified here.
|
||||
|
||||
**Q9. Legacy SSE deprecation timeline**
|
||||
The MCP `2024-11-05` SSE transport is deprecated in the current spec but Claude Desktop versions prior to the spec `2025-03-26` update still use it. SENSE-BRIDGE proposes `--legacy-sse` for one release cycle. The question is which specific Claude Desktop version drops legacy SSE support, and whether any of the active fleet nodes (cognitum-v0, cognitum-seed-1) run a Claude Desktop version old enough to need it.
|
||||
|
||||
**Q10. Node.js vs Bun runtime**
|
||||
The ruflo monorepo uses `bun` as the primary runtime (per `bunfig.toml` in `v3/`). Should `@ruvnet/rvagent` also support Bun? Bun's napi-rs compatibility for native addons like `ruvector` is improving but not guaranteed for 0.2.x. The P1 CI should test on Node 20 first; Bun support can be declared as a stretch goal for P5.
|
||||
|
||||
---
|
||||
|
||||
## 9. Alternatives considered
|
||||
|
||||
### Alt-A — Python-only client (extend ADR-117 with MCP bindings)
|
||||
|
||||
Add `wifi_densepose.mcp` as a P6 module in the PIP-PHOENIX wheel (ADR-117). The Python MCP SDK (`mcp[cli]`) supports both stdio and HTTP transports and the PyO3 bindings give direct access to the sensing types.
|
||||
|
||||
**Rejected because**: Python is not the dominant runtime for MCP server hosting in 2026 — the ecosystem tooling (Claude Desktop, Claude Code `mcp add`, ruflo) is TypeScript-first. A Python MCP server requires the full pip install including PyO3 bindings, which is a heavier install than `npx @ruvnet/rvagent stdio`. The ruflo plugin format is TypeScript. ADR-117 is already sizeable; adding MCP to it conflates two distinct concerns (Python developer library vs. AI agent interface). Python MCP remains a viable future addition (Q10 for a future ADR) but is not the right first-ship target.
|
||||
|
||||
### Alt-B — Pure WebSocket/REST client without MCP framing
|
||||
|
||||
Ship a TypeScript client library `@ruvnet/ruview-client` that wraps the sensing-server WebSocket and REST API without the MCP layer. Consumers who want MCP integration would wrap it themselves.
|
||||
|
||||
**Rejected because**: it solves the connectivity problem but not the agent integration problem. Without MCP framing, Claude Code and ruflo agents cannot discover or call RuView capabilities through the standard `mcp__*` namespace — they would need custom prompt injection or bespoke tool definitions per agent. The whole value proposition of this ADR is that a single `claude mcp add rvagent` command makes all RuView primitives discoverable to any MCP-capable AI assistant. Splitting the library forces every consumer to re-add the MCP layer.
|
||||
|
||||
### Alt-C — Embed MCP server inside the existing wifi-densepose-sensing-server Rust binary
|
||||
|
||||
Add an MCP endpoint to the existing Axum server in `v2/crates/wifi-densepose-sensing-server/` (`v2/crates/wifi-densepose-sensing-server/src/main.rs`). This would use the `rmcp` Rust crate (Model Context Protocol SDK for Rust) and expose MCP over an additional port.
|
||||
|
||||
**Rejected because**: (a) it couples the release cycle of the npm-hosted MCP interface to the firmware/Rust release cycle, which are on separate cadences — a new MCP tool that merely adds a JSON field should not require a firmware rebuild; (b) the ruflo plugin ecosystem is TypeScript and expects npm packages, not Rust binaries; (c) the ruvector vector layer is a napi-rs Node.js native module and cannot be called directly from a Rust process without going through the napi-rs server-side API, adding unnecessary complexity; (d) the sensing-server binary is already 15-30 MB stripped — adding the MCP endpoint and its JSON-RPC machinery would further bloat it. This alternative is worth revisiting if the Rust `rmcp` crate matures and the vector layer migrates fully to native Rust, but it is not appropriate for the first implementation.
|
||||
|
||||
### Alt-D — Wrapping the existing ruflo WASM rvagent in a RuView shim
|
||||
|
||||
The ruflo WASM rvagent (`rvagent_wasm_bg.wasm`) already exports `callMcp` / `executeTool` / `listTools`. One could define a RuView shim that registers custom tools into the ruflo WASM rvagent gallery.
|
||||
|
||||
**Rejected because**: the ruflo WASM rvagent is an in-browser MCP *client* runner for the ruflo gallery, not a general-purpose MCP server that can expose sensing data. Its 13 exported functions are focused on template management and ruflo-gallery operations. Patching sensing tools into a browser WASM module is the wrong architecture for a server-side sensing bridge. The naming overlap is a reason to publish the new package promptly and clearly document the distinction.
|
||||
|
||||
---
|
||||
|
||||
## 10. Compatibility
|
||||
|
||||
### 10.1 Backwards compatibility with ADR-117 (PIP-PHOENIX) Python client
|
||||
|
||||
SENSE-BRIDGE does not replace the Python client. Both can coexist:
|
||||
- Python integrators use `from wifi_densepose.client import SensingClient` (ADR-117).
|
||||
- TypeScript / MCP integrators use `import { SensingClient } from "@ruvnet/rvagent"`.
|
||||
- MCP-capable AI assistants use `claude mcp add rvagent -- npx @ruvnet/rvagent stdio`.
|
||||
|
||||
All three talk to the same sensing-server backend; there is no shared state between the Python and TypeScript clients beyond what the sensing-server itself maintains.
|
||||
|
||||
### 10.2 Sensing-server API contract
|
||||
|
||||
SENSE-BRIDGE depends on the sensing-server WebSocket protocol documented in `v2/crates/wifi-densepose-sensing-server/src/main.rs` (referenced in `python/wifi_densepose/client/ws.py:6-13`). The three message types (`connection_established`, `pose_data`, `edge_vitals`) are stable across v0.7.x releases. If the sensing-server adds new message types, SENSE-BRIDGE follows the same pattern as the Python client: unknown `type` values yield a plain `SensingMessage` rather than an error, ensuring forward compatibility.
|
||||
|
||||
### 10.3 MCP protocol version
|
||||
|
||||
SENSE-BRIDGE targets MCP protocol version `2025-06-18` (current stable). It will include backwards compatibility with `2025-03-26` (Streamable HTTP without session management) and optionally `2024-11-05` (legacy SSE via `--legacy-sse` flag). Protocol version `2025-06-18` requires the `MCP-Protocol-Version` header on HTTP requests; SENSE-BRIDGE validates this per spec.
|
||||
|
||||
### 10.4 Node.js version
|
||||
|
||||
Minimum Node.js 20 LTS. Node 22 is supported and recommended for production (active LTS as of 2026). The `ruvector` napi-rs bindings must be confirmed compatible with both (Q2). Node 18 is EOL and explicitly not supported.
|
||||
|
||||
### 10.5 MQTT broker compatibility
|
||||
|
||||
SENSE-BRIDGE uses `mqtt.js ^5` which implements MQTT 3.1.1 and MQTT 5.0. The `mosquitto` local broker (CLAUDE.local.md §Local mosquitto) and cognitum-v0's MQTT stack (CLAUDE.local.md fleet table) are both compatible. TLS mode is optional via `RUVIEW_MQTT_TLS=1` env var.
|
||||
|
||||
---
|
||||
|
||||
## 11. Consequences
|
||||
|
||||
### 11.1 Positive consequences
|
||||
|
||||
- Any MCP-capable AI assistant can query RuView presence, vitals, pose, and BFLD data with zero custom integration code after `claude mcp add rvagent`.
|
||||
- ruflo multi-agent swarms gain first-class access to real-world sensing data, enabling swarms to gate decisions on physical events (fall detected → page caregiver workflow).
|
||||
- The TypeScript surface provides a second reference implementation of the sensing-server client protocol alongside the Python client (ADR-117), validating the protocol design against two independent consumers.
|
||||
- The ruvector HNSW integration enables cross-node person re-identification entirely within the rvagent process — no additional network calls between sensing nodes.
|
||||
|
||||
### 11.2 Negative consequences / risks
|
||||
|
||||
| Risk | Likelihood | Severity | Mitigation |
|
||||
|---|---|---|---|
|
||||
| **ruvector napi-rs not building on Windows** | Medium | Medium | Confirm in P1 CI; if binaries not prebuilt, document requirement of Rust toolchain on Windows |
|
||||
| **MCP protocol churn** — spec updated twice in 2025; another update in 2026 possible | Medium | Low | Pin `@modelcontextprotocol/sdk` to a minor range; wrap SDK calls behind an internal `transport.ts` abstraction so changes are isolated |
|
||||
| **Subscription lifecycle bugs** — zombie subscriptions if session cleanup is missed | High | Medium | Implement per-session resource registry with TTL; all subscriptions auto-expire after `duration_s` even if session is not explicitly deleted |
|
||||
| **sensing-server WS disconnect** — stdio process dies if not reconnecting | Low | High | Implement exponential back-off reconnect in `ws-client.ts`; emit `{ error: "RECONNECTING" }` tool responses during gap |
|
||||
| **npm name collision** — `rvagent` taken by another publisher before P5 | Low | Medium | Publish `@ruvnet/rvagent` scoped; use that name throughout |
|
||||
| **ruflo plugin manifest incompatibility** — format not publicly specced | Medium | Medium | Confirm format in P5 preparation; use the minimal required fields only |
|
||||
| **Sensing-tool surface becomes a surveillance API** — "who is in the room" is a privacy-charged primitive | High | High | RUVIEW-POLICY layer (§4.1a) gates every sensing call; default-deny for biometric tools; redaction applied server-side so agents cannot opt out |
|
||||
|
||||
### 11.3 Strategic implication: ambient-sensing normalization layer
|
||||
|
||||
The MCP tool catalog in §4 is RuView-WiFi-CSI-specific today. The shape of the catalog — `presence.now`, `vitals.get_*`, `pose.latest`, `primitives.*`, `bfld.*` — is **modality-agnostic at the semantic layer**: the same tools could be backed by any sensing modality that produces the same questions.
|
||||
|
||||
If the project later adds BLE, mmWave (e.g. the ESP32-C6 + Seeed MR60BHA2 already on COM4 per CLAUDE.md), LiDAR, thermal, camera, radar, or UWB inputs, the rvagent MCP surface stays the same. Only the source-multiplexer behind `cache.ts` changes — it now ingests from multiple modalities and resolves conflicts (e.g. WiFi CSI says "presence: true" but mmWave says "presence: false" → fusion policy decides; this is the kind of decision the RUVIEW-POLICY layer can also gate).
|
||||
|
||||
This positions the npm package not as "a WiFi client" but as the **semantic-environment API**: agents ask "is anyone here?" without caring which radio answered. The competitive landscape (Aqara FP2, ESPHome LD2410) exposes raw telemetry; SENSE-BRIDGE exposes environmental cognition.
|
||||
|
||||
The follow-on ADR (call it ADR-13x — RUVIEW-FUSION) would formalize the per-modality adapter contract. It is intentionally out of scope for ADR-124 — this ADR ships the WiFi-CSI path only — but the tool catalog and policy layer are designed to absorb additional modalities without API churn.
|
||||
|
||||
---
|
||||
|
||||
## 12. Acceptance criteria
|
||||
|
||||
The following must all pass before ADR-124 is considered Accepted:
|
||||
|
||||
- [ ] `npm install @ruvnet/rvagent` succeeds on Node 20/22, linux/x86_64, macos/arm64, windows/amd64 with no Rust toolchain required (ruvector prebuilts must ship).
|
||||
- [ ] `npx @ruvnet/rvagent stdio` starts and responds to a `tools/list` JSON-RPC request with the 15 tools from §4.1.
|
||||
- [ ] `npx @ruvnet/rvagent serve --port 3100` starts; `curl -X POST http://localhost:3100/mcp -H "Content-Type: application/json" -d '{"jsonrpc":"2.0","method":"tools/list","id":1}'` returns the tool list.
|
||||
- [ ] `ruview.vitals.get_all` with a running `sensing-server --mock-frames` returns `breathing_rate_bpm` and `heartrate_bpm` values within 5 seconds.
|
||||
- [ ] `ruview.vector.store_pose` followed by `ruview.vector.search_pose` with the same embedding returns the stored pose as the top-1 match.
|
||||
- [ ] `claude mcp add rvagent -- npx @ruvnet/rvagent stdio` followed by `/mcp` in a Claude Code session shows the rvagent tools listed.
|
||||
- [ ] All MCP tool input schemas are validated via Zod; an invalid input returns an MCP `INVALID_PARAMS` error, not an unhandled exception.
|
||||
- [ ] TypeScript strict-mode compilation (`tsc --noEmit`) passes with zero errors.
|
||||
- [ ] `npm run build` produces both ESM (`dist/esm/`) and CJS (`dist/cjs/`) outputs with `.d.ts` type declarations.
|
||||
- [ ] The published npm tarball size is `≤ 10 MB` including the ruvector napi-rs binary for the current platform.
|
||||
|
||||
---
|
||||
|
||||
## 13. References
|
||||
|
||||
### This repo
|
||||
|
||||
- `python/wifi_densepose/client/ws.py` — WebSocket client (ADR-117 P4): connection protocol, message types `connection_established`, `pose_data`, `edge_vitals`
|
||||
- `python/wifi_densepose/client/mqtt.py` — MQTT client (ADR-117 P4): topic namespaces, wildcard matching
|
||||
- `python/wifi_densepose/client/primitives.py` — Semantic primitive enum and listener (ADR-117 P4): 10 ADR-115 primitives
|
||||
- `v2/crates/wifi-densepose-sensing-server/src/main.rs` — Axum server: REST API, WebSocket endpoint `/ws/sensing`
|
||||
- `v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs` — Bearer token auth pattern for HTTP server
|
||||
- `v2/crates/wifi-densepose-sensing-server/src/semantic/` — 10 semantic primitive modules
|
||||
- `v2/crates/wifi-densepose-sensing-server/src/mqtt/` — MQTT publisher, discovery, topic routing
|
||||
- `docs/adr/ADR-055-integrated-sensing-server.md` — Sensing-server architectural context
|
||||
- `docs/adr/ADR-095-rvcsi-edge-rf-sensing-platform.md` — rvCSI edge runtime
|
||||
- `docs/adr/ADR-115-home-assistant-integration.md` — MQTT topic structure, 10 semantic primitives, 21 HA entities
|
||||
- `docs/adr/ADR-117-pip-wifi-densepose-modernization.md` — PIP-PHOENIX: Python client and PyO3 bindings (the Python-runtime parallel to this ADR)
|
||||
- `docs/adr/ADR-118-bfld-beamforming-feedback-layer-for-detection.md` — BFLD crate: `BfldEvent` MQTT topics
|
||||
- `docs/adr/ADR-024-contrastive-csi-embedding-model.md` — AETHER person re-ID embeddings
|
||||
- `docs/adr/ADR-016-ruvector-integration.md` — RuVector integration in the Rust workspace
|
||||
- `CLAUDE.md` — Project config: 3-tier model routing (ADR-026), ruflo MCP tools, `mcp__claude-flow__*` namespace
|
||||
- `CLAUDE.local.md` — Fleet table: Tailscale hosts, cognitum-v0 services table, local mosquitto pattern
|
||||
|
||||
### External
|
||||
|
||||
- [Model Context Protocol specification 2025-06-18](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports) — Transports: stdio and Streamable HTTP
|
||||
- [MCP TypeScript SDK — github.com/modelcontextprotocol/typescript-sdk](https://github.com/modelcontextprotocol/typescript-sdk) — `Server`, `StdioServerTransport`, `StreamableHTTPServerTransport`
|
||||
- [@modelcontextprotocol/sdk on npm](https://www.npmjs.com/package/@modelcontextprotocol/sdk)
|
||||
- [ruvector on npm](https://www.npmjs.com/package/ruvector) — v0.2.25, napi-rs HNSW vector DB
|
||||
- [ruvnet npm profile](https://www.npmjs.com/~ruvnet) — confirms `@ruvnet` scope ownership
|
||||
- [RuVector GitHub](https://github.com/ruvnet/ruvector) — Rust source + napi-rs node bindings
|
||||
- [ruflo (claude-flow) GitHub](https://github.com/ruvnet/ruflo) — ruflo plugin manifest convention, `v3/` structure
|
||||
- [ruflo issue #1689](https://github.com/ruvnet/ruflo/issues/1689) — documents existing rvagent WASM exports (`callMcp`, `executeTool`, `listTools`) and distinguishes them from this ADR's server-side rvagent
|
||||
- [Why MCP Deprecated SSE — fka.dev](https://blog.fka.dev/blog/2025-06-06-why-mcp-deprecated-sse-and-go-with-streamable-http/) — rationale for Streamable HTTP over legacy SSE
|
||||
- [MCP TypeScript SDK dual-transport patterns — dev.to](https://dev.to/zoricic/understanding-mcp-server-transports-stdio-sse-and-http-streamable-5b1p)
|
||||
@@ -1,285 +0,0 @@
|
||||
# ADR-125: RuView ↔ Apple Home native HAP bridge — direct HomeKit accessory advertisement from the Seed
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Proposed |
|
||||
| **Date** | 2026-05-25 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codename** | **APPLE-FABRIC** — RuView speaks HomeKit directly so Apple HomePod / Apple TV act as the discovery + automation surface with zero Home-Assistant middle layer |
|
||||
| **Relates to** | [ADR-115](ADR-115-home-assistant-integration.md) (HA-DISCO MQTT publisher), [ADR-116](ADR-116-cog-ha-matter-seed.md) (cog-ha-matter §P7 left HAP/Matter as a feature-flag stub), [ADR-118](ADR-118-bfld-beamforming-feedback-layer-for-detection.md) (BFLD presence + identity-risk events), [ADR-122](ADR-122-bfld-ruview-ha-matter-exposure.md) (BFLD HA/Matter exposure) |
|
||||
| **Tracking issue** | TBD |
|
||||
|
||||
---
|
||||
|
||||
## 1. Context
|
||||
|
||||
### 1.1 The misunderstanding worth correcting once
|
||||
|
||||
A naive integration tries to **push** data to a HomePod — open a socket, send a JSON-RPC, call an MQTT topic on `homepod.local`. Apple intentionally does not expose that surface. The HomePod is not an endpoint; it is the **Home Hub + Matter Controller + HomeKit Controller + Siri endpoint** for the Apple Home ecosystem on the LAN. It **discovers** accessories that advertise themselves on the local network via Bonjour/mDNS using the HomeKit Accessory Protocol (HAP) or Matter.
|
||||
|
||||
The correct direction of flow is therefore:
|
||||
|
||||
```text
|
||||
RuView / Seed
|
||||
↓ (advertise HAP / Matter accessory on LAN)
|
||||
HomeKit / Matter accessory
|
||||
↓ (mDNS discovery)
|
||||
HomePod
|
||||
↓ (forwards to Apple Home automation graph)
|
||||
Apple Home ecosystem (iPhone, Watch, Mac, Siri, automations)
|
||||
```
|
||||
|
||||
### 1.2 What we ship today and where it stops
|
||||
|
||||
ADR-115 ships an **MQTT auto-discovery publisher** that talks to Home Assistant. ADR-116's `cog-ha-matter` Cognitum cog wraps that publisher into a Seed-installable artifact with mDNS, an embedded rumqttd broker, RuVector-backed thresholds, and an Ed25519 witness chain. ADR-122 explicitly extends the same publisher with the BFLD presence / identity-risk / Soul-Match topics so a Home Assistant install sees them as auto-discovered entities. The current path to HomePod therefore runs:
|
||||
|
||||
```text
|
||||
RuView sensing-server ──► cog-ha-matter (MQTT HA-DISCO + HA-MIND)
|
||||
↓
|
||||
Home Assistant broker
|
||||
↓
|
||||
Home Assistant HomeKit Bridge add-on
|
||||
↓
|
||||
HomePod
|
||||
```
|
||||
|
||||
This works and the auto-discovery is real, but it introduces a hard dependency: an operator must run Home Assistant, install its HomeKit Bridge integration, and pair the bridge in the Apple Home app. The Seed alone does not appear in Apple Home.
|
||||
|
||||
ADR-116 §P7 anticipated this — the `cog-ha-matter` `Cargo.toml` already carries a `matter = []` feature stub with the comment "matter-rs is added in P7; intentionally absent in P1 to keep the dep surface small until the SDK choice is validated." This ADR closes that box.
|
||||
|
||||
### 1.3 Why now
|
||||
|
||||
Three forces line up in 2026-05:
|
||||
|
||||
1. **The BFLD privacy gate (ADR-118 / 120 / 121) is shipped.** Class-2 and class-3 frames are the only ones eligible to cross the Matter boundary (ADR-122 §2.4). Without that gate we could not safely expose RuView signals to a consumer ecosystem. With it, every Anonymous / Restricted event is safe to advertise as a HomeKit sensor.
|
||||
2. **`@ruvnet/rvagent` (ADR-124) is on npm.** The MCP surface that lets agents query RuView is live. A first-class Apple-Home presence widens RuView's reach from "agents that speak MCP" to "anyone with an iPhone and a HomePod" — the consumer wedge.
|
||||
3. **The Cognitum Seed Docker image now bundles `cog-ha-matter`** (this branch's `Dockerfile.rust` change, see #794) — the runtime where a HAP advertiser would live is finally a single-image deployment.
|
||||
|
||||
### 1.4 Strategic framing
|
||||
|
||||
The combination is asymmetric:
|
||||
|
||||
| Layer | RuView contributes | Apple Home contributes |
|
||||
|-------|---------------------|------------------------|
|
||||
| Sensing | Passive RF presence, breathing, heart rate, fall risk, BFLD identity-risk, through-wall occupancy, longitudinal wellness | (none — Apple has no native RF sensing surface) |
|
||||
| Adoption | (limited — researcher-grade hardware today) | iPhone, Watch, Mac, HomePod, Apple TV installed base; consumer trust; voice; on-device intelligence |
|
||||
| UX | (utility CLI + a Web UI) | Home app, Siri, automation engine, notifications, accessibility |
|
||||
| Trust | Ed25519 witness chain, privacy class gate, local-first | Apple HomeKit local pairing, end-to-end encrypted, no cloud requirement |
|
||||
|
||||
RuView supplies the **invisible cognition layer** Apple cannot provide on its own; Apple supplies the **distribution and UX** that an open sensing stack cannot bootstrap. Direct HAP integration removes the only structural barrier between those two layers — Home Assistant as a mandatory intermediary.
|
||||
|
||||
---
|
||||
|
||||
## 2. Decision
|
||||
|
||||
Ship a **native HomeKit / Matter accessory** in the Seed runtime so a freshly-imaged Cognitum Seed appears in the Apple Home app under `Add Accessory → More Options` with **zero Home-Assistant dependency**.
|
||||
|
||||
Concretely:
|
||||
|
||||
1. Add a `hap-accessory` workspace component that advertises a set of HomeKit characteristics over mDNS using HAP-1.1 (HomeKit Accessory Protocol).
|
||||
2. The component subscribes to `wifi-densepose-sensing-server`'s WebSocket / BFLD `MqttEvent` stream and maps each privacy-class-2/3 event onto a HomeKit characteristic update.
|
||||
3. The same Docker image that ships `sensing-server` and `cog-ha-matter` ships the new advertiser as a third entrypoint:
|
||||
|
||||
```bash
|
||||
docker run --network host ruvnet/wifi-densepose:latest hap-accessory --privacy-mode
|
||||
```
|
||||
|
||||
`--network host` (or a macvlan bridge) is required because HAP pairing depends on the accessory and the controller seeing each other's mDNS broadcasts on the same L2 segment — same constraint Home Assistant's HomeKit Bridge has.
|
||||
|
||||
### 2.1 Two implementation tracks (decided here together; ship 2.1.a first)
|
||||
|
||||
#### 2.1.a — **HAP-python sidecar** (fastest to ship, lands first)
|
||||
|
||||
Add a tiny Python entrypoint `bridges/hap-python/ruview_hap.py` using the well-maintained [`HAP-python`](https://github.com/ikalchev/HAP-python) library. The Dockerfile gets a thin Python runtime stage; the entrypoint script polls `sensing-server` over HTTP and pushes characteristic updates into the HAP loop.
|
||||
|
||||
```python
|
||||
# bridges/hap-python/ruview_hap.py (≈80 LOC)
|
||||
from pyhap.accessory import Accessory
|
||||
from pyhap.accessory_driver import AccessoryDriver
|
||||
from pyhap.const import CATEGORY_SENSOR
|
||||
import urllib.request, json, threading, time
|
||||
|
||||
SENSING_URL = "http://127.0.0.1:3000/api/v1"
|
||||
|
||||
class RuViewSensor(Accessory):
|
||||
category = CATEGORY_SENSOR
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
s_motion = self.add_preload_service('MotionSensor')
|
||||
self.c_motion = s_motion.configure_char('MotionDetected')
|
||||
s_occ = self.add_preload_service('OccupancySensor')
|
||||
self.c_occ = s_occ.configure_char('OccupancyDetected')
|
||||
s_temp = self.add_preload_service('TemperatureSensor')
|
||||
self.c_temp = s_temp.configure_char('CurrentTemperature')
|
||||
threading.Thread(target=self._poll, daemon=True).start()
|
||||
|
||||
def _poll(self):
|
||||
while True:
|
||||
try:
|
||||
v = json.loads(urllib.request.urlopen(f"{SENSING_URL}/vitals").read())
|
||||
self.c_motion.set_value(bool(v.get("motion_present")))
|
||||
self.c_occ.set_value(int(bool(v.get("occupancy"))))
|
||||
if "ambient_temp_c" in v:
|
||||
self.c_temp.set_value(v["ambient_temp_c"])
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(1.0)
|
||||
|
||||
driver = AccessoryDriver(port=51826)
|
||||
driver.add_accessory(accessory=RuViewSensor(driver, 'RuView Sense'))
|
||||
driver.start()
|
||||
```
|
||||
|
||||
Pairing flow on the operator's iPhone:
|
||||
|
||||
1. Open Apple Home → `Add Accessory` → `More Options`
|
||||
2. Tap `RuView Sense` (appears via mDNS automatically)
|
||||
3. Enter the setup code shown in `docker logs` (or pinned in env)
|
||||
4. Done — Siri can say "Hey Siri, is anyone in the living room?"
|
||||
|
||||
Replace the `motion_present` / `occupancy` mappings progressively as RuView capabilities mature: BFLD class-2 `presence` event → `OccupancyDetected`; BFLD class-3 `identity_risk_score > threshold` → `SecuritySystemCurrentState`; `breathing_present` → `OccupancyDetected` (sleep room); `fall_risk` → a programmable switch that fires an Apple Home automation.
|
||||
|
||||
Acceptance criteria for 2.1.a:
|
||||
|
||||
- A1: `docker run ... hap-accessory --privacy-mode` advertises an `_hap._tcp` service that the HomePod sees within 30s (`dns-sd -B _hap._tcp local.` on a peer Mac shows `RuView Sense`).
|
||||
- A2: Pairing from Apple Home succeeds and the entity appears in the Home app under the configured room.
|
||||
- A3: `MotionDetected` flips within 2 s of an actual RF presence detection from a calibrated ESP32 source (`CSI_SOURCE=esp32`).
|
||||
- A4: Restarting the container preserves the pairing (HAP state persisted under `/var/lib/ruview-hap/`).
|
||||
- A5: Privacy: the entrypoint refuses to launch without `--privacy-mode` when `RUVIEW_BFLD_PRIVACY_CLASS` is unset, matching the structural invariant I1 (Raw BFI never exits the node — ADR-118 §2.2).
|
||||
|
||||
#### 2.1.b — **Rust-native HAP** (single binary, closes ADR-116 P7)
|
||||
|
||||
Wire one of the maintained Rust HAP crates into `cog-ha-matter` so the Python sidecar can be removed. Candidate crates:
|
||||
|
||||
- [`hap`](https://crates.io/crates/hap) (Sebastian Schmidt) — last published 0.1.0-pre.16, MIT, active in 2024, supports HAP-1.1, has examples for `MotionSensor`, `LightBulb`, `OccupancySensor`. **First choice.**
|
||||
- [`accessory-server`](https://crates.io/crates/accessory-server) — narrower scope, fewer services
|
||||
- A future `matter-rs` crate from project-chip — once stable (CHIP SDK Rust bindings are still emerging in 2026-05)
|
||||
|
||||
The `matter = []` feature stub in `cog-ha-matter/Cargo.toml` (added in ADR-116 P1) becomes:
|
||||
|
||||
```toml
|
||||
[features]
|
||||
default = []
|
||||
mqtt = ["dep:rumqttc"]
|
||||
matter = ["dep:hap"] # ADR-125 §2.1.b
|
||||
```
|
||||
|
||||
with a runtime subcommand `cog-ha-matter --mode hap` that mirrors the Python advertiser's accessory set. Single binary, no Python interpreter in the image, matches the all-Rust ethos of the Cognitum Seed (ADR-116 §1.4).
|
||||
|
||||
### 2.1.c — **Topology: one HAP bridge, N child accessories** (decided)
|
||||
|
||||
The advertiser publishes a **single HAP bridge** (`RuView Sense`) that owns N child accessories — one per logical sensor surface (presence-bedroom, presence-office, vitals-bedroom, semantic-events, …). Operators pair the bridge once; child accessories appear automatically and can be re-assigned to rooms in the Apple Home app.
|
||||
|
||||
The alternative — N independent accessories each advertised separately — was rejected. It forces operators to pair RuView once per room (`RuView Bedroom`, `RuView Office`, `RuView Wellness`, `RuView Presence`, …), which becomes messy after the second or third room, and diverges from how every reference HomeKit accessory in the Home app behaves (a Hue bridge with bulbs, an Eve Energy bridge, etc.). Single pairing also makes container restart / re-image trivial — one persisted pairing key, not N.
|
||||
|
||||
### 2.1.d — **Identity-risk mapping: semantic events, not probabilistic surveillance** (decided)
|
||||
|
||||
`identity_risk_score` is a continuous 0..1 confidence from the BFLD identity-features pipeline (ADR-121 §2.6). It must NOT cross the HomeKit boundary as a raw value, and must NOT be wired to `SecuritySystemCurrentState`. Apple-Home users read security-system state as **"intruder detected"** — exposing a probability there turns RuView into surveillance UX with all the false-positive blame that entails.
|
||||
|
||||
Instead, the bridge exposes **thresholded semantic events** that read like ambient awareness, not threat detection:
|
||||
|
||||
| Semantic event | HomeKit primitive | Trigger (illustrative) |
|
||||
|----------------|--------------------|-------------------------|
|
||||
| `Unknown Presence` | `MotionSensor` (programmable; stateful) | BFLD class-2 presence + no matching SoulMatch oracle hit (ADR-121 §2.6) for > 30 s |
|
||||
| `Unexpected Occupancy` | `OccupancySensor` (programmable) | Occupancy in a room outside its operator-defined "expected schedule" window |
|
||||
| `Unrecognized Activity Pattern` | Programmable `Switch` (stateful, momentary) | BFLD longitudinal drift gate (ADR-118 §2.3 / ADR-122 §2.7) fires Reject or Recalibrate |
|
||||
|
||||
What stays internal:
|
||||
|
||||
- Raw `identity_risk_score` (numeric 0..1) — never published
|
||||
- Soul-Signature match probability — never published
|
||||
- `rf_signature_hash` — never published (already enforced by ADR-118 §2.5 / ADR-122 §2.4 — this is the structural invariant restated at the HAP boundary)
|
||||
|
||||
The naming is the contract. "Unknown Presence" is *who's-here-and-it's-fine-but-worth-noting*; an end user will write an automation ("turn on the porch light when Unknown Presence is detected after 9pm") without ever thinking it accuses anyone of being an intruder. That semantic framing is the difference between RuView becoming the calm-tech ambient substrate Apple Home needs vs. another paranoid surveillance widget.
|
||||
|
||||
This is the part of the ADR that determines whether RuView's HomeKit story ages well or generates the wrong kind of headlines.
|
||||
|
||||
### 2.2 What we DO NOT do in 2.1.a or 2.1.b
|
||||
|
||||
- **No Matter (CHIP) controller code.** Matter is the long-term play but its SDK in Rust is not yet stable and the certificate provisioning is heavy. HAP-1.1 over Bonjour gives 95% of the UX for 10% of the complexity, today.
|
||||
- **No direct connection to the HomePod.** As the framing in §1.1 makes explicit, RuView never opens a socket to the HomePod. It advertises; the HomePod discovers.
|
||||
- **No iCloud account binding.** HAP pairing is local-network-only by design — RuView gets adoption without ever touching Apple ID, which is a privacy story we keep cleanly.
|
||||
- **No Class-0 (`Raw`) BFI exposure.** Structural invariant I1 (ADR-118 §2.2) holds. Only privacy-class-2 (Anonymous) and class-3 (Restricted) frames may be mapped onto HomeKit characteristics. The advertiser refuses to start in any other mode.
|
||||
|
||||
### 2.3 Sequencing
|
||||
|
||||
1. **P1** (this ADR-125 + 1 PR) — HAP-python sidecar (§2.1.a) lands as a separate entrypoint in the same Docker image. AC A1–A5 are gates.
|
||||
2. **P2** (follow-up PR after operator feedback from 5+ Apple Home pairings) — Rust-native HAP (§2.1.b). Replaces P1; P1's `bridges/hap-python/` becomes an archived reference implementation.
|
||||
3. **P3** (when matter-rs stabilizes) — Matter Controller path (still RuView-as-accessory, but using the Matter clusters rather than HAP-1.1 services). The Cognitum Cog gains a Matter QR code; pairing flow widens to "any Matter-capable controller, not just Apple."
|
||||
|
||||
---
|
||||
|
||||
## 3. Consequences
|
||||
|
||||
### 3.1 Wins
|
||||
|
||||
- **Direct discoverability on Apple Home.** A Seed in the kitchen appears as `RuView Sense` in the Home app within seconds of `docker run`. No HA, no MQTT broker, no Home-Assistant HomeKit Bridge add-on.
|
||||
- **Siri natively answers RuView questions.** "Hey Siri, is anyone in the kitchen?" — the question reaches the HomeKit characteristic without any custom skill or HA template sensor.
|
||||
- **Apple-Home automations gain ambient triggers** RuView already produces (presence, breathing, fall, identity-risk) for free — they become first-class automation triggers in the Home app's UI.
|
||||
- **Strategically corrects RuView's distribution problem.** The Apple Home installed base is the largest consumer surface for HomeKit-grade accessories. RuView's sensing IP becomes addressable to that base without an SDK port.
|
||||
- **Closes ADR-116 §P7** — the long-flagged matter / HAP gap is now scheduled, not deferred indefinitely.
|
||||
|
||||
### 3.2 Costs
|
||||
|
||||
- **Python runtime in the Docker image (only for 2.1.a, until 2.1.b lands).** Adds ~30 MB to the runtime layer. Mitigation: P2 removes it; P1 isolates the Python dep in a side-stage so the sensing-server / cog-ha-matter layers stay clean.
|
||||
- **Network-mode constraint.** HAP pairing needs the controller and accessory on the same L2 segment (mDNS broadcasts). Operators who run RuView in a container behind a NAT/bridge need `--network host` or a macvlan — same constraint HA's HomeKit Bridge has, but worth documenting.
|
||||
- **Pairing state persistence.** HAP-python stores pairing data in a local file; that state must survive container restarts. Volume-mount `/var/lib/ruview-hap/` to a persistent location.
|
||||
|
||||
### 3.3 Risks
|
||||
|
||||
- **HAP-python maintenance.** The library is community-maintained; if it goes stale, P2 (Rust-native) absorbs the risk. 2.1.a is explicitly a stepping stone, not a long-term commitment.
|
||||
- **Apple's evolving requirements.** HomeKit Accessory Certification is required to put a HAP logo on hardware, not to ship a software accessory that pairs locally. RuView's container deployment is squarely in the "uncertified developer accessory" lane, which Apple explicitly permits for local pairing. Worth restating in the operator README.
|
||||
- **Privacy-class enforcement at the bridge boundary.** A bug that lets a class-0 BFI frame's data influence a HAP characteristic update would violate I1. Mitigation: the bridge consumes only the BFLD `MqttEvent` stream (which is already gated by `PrivacyGate` per ADR-120), never raw BFI; tests assert this in the same style as ADR-122 §4.3.
|
||||
|
||||
### 3.4 Reversibility
|
||||
|
||||
The advertiser is a separate entrypoint — pulling it out is `docker run` without the `hap-accessory` first-arg, identical to today's behavior. Zero impact on `sensing-server` and `cog-ha-matter` operations.
|
||||
|
||||
---
|
||||
|
||||
## 4. Acceptance test (P1 / §2.1.a)
|
||||
|
||||
```bash
|
||||
# 1. Start a sensing server (simulated source so the test runs anywhere)
|
||||
docker run -d --name rs -p 3000:3000 -e CSI_SOURCE=simulated \
|
||||
ruvnet/wifi-densepose:latest
|
||||
|
||||
# 2. Launch the HAP advertiser sidecar in privacy mode
|
||||
docker run -d --name hap --network host \
|
||||
-v /var/lib/ruview-hap:/var/lib/ruview-hap \
|
||||
-e RUVIEW_BFLD_PRIVACY_CLASS=2 \
|
||||
ruvnet/wifi-densepose:latest hap-accessory --privacy-mode
|
||||
|
||||
# 3. From a Mac on the same LAN: should see RuView Sense as HAP
|
||||
dns-sd -B _hap._tcp local. # expect: "RuView Sense" within 30 s
|
||||
|
||||
# 4. From iPhone Home app: Add Accessory → More Options → RuView Sense
|
||||
# Enter setup code from `docker logs hap`
|
||||
# Expect: pairing completes, entity appears in selected Room
|
||||
|
||||
# 5. Cycle the container; re-open Home app: entity is still paired
|
||||
docker restart hap
|
||||
# Expect: no re-pairing prompt; characteristic updates resume
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Open questions
|
||||
|
||||
Two questions from the original draft were resolved during review (§2.1.c and §2.1.d). Genuinely-open questions that follow-up PRs will close:
|
||||
|
||||
- **Setup-code derivation.** Derived deterministically from the Seed's Ed25519 witness key (so reinstalls re-use the same code, operator never re-enters), or random per launch (slightly better security, worse UX on container restarts)? Leaning deterministic + witness-key-derived; verify against Apple's HomeKit Accessory Protocol §5.6.5 (setup-code uniqueness) before committing.
|
||||
- **ESP32 / Cognitum-Seed-class hardware as a direct HAP advertiser** (not via the host appliance). The current decision parks the bridge on the host runtime; a future ADR can evaluate whether an ESP32-S3 with 8MB flash has enough headroom to run HAP-1.1 directly, which would remove the host appliance from the path entirely for single-room deployments.
|
||||
|
||||
---
|
||||
|
||||
## 6. References
|
||||
|
||||
- ADR-115 — Home-Assistant integration (HA-DISCO MQTT publisher)
|
||||
- ADR-116 — `cog-ha-matter` Seed cog (this is where the `matter` feature stub lives)
|
||||
- ADR-118 — BFLD beamforming-feedback layer (privacy gate + class invariants)
|
||||
- ADR-122 — BFLD RuView HA/Matter exposure (current MQTT-based bridge that this ADR's HAP-native path complements)
|
||||
- HomeKit Accessory Protocol Specification (Non-Commercial Version), Apple — https://developer.apple.com/apple-home/
|
||||
- HAP-python — https://github.com/ikalchev/HAP-python
|
||||
- `hap` (Rust) — https://crates.io/crates/hap
|
||||
@@ -1,293 +0,0 @@
|
||||
# BFLD SOTA Survey — Beamforming Feedback: State of the Art
|
||||
|
||||
## 1. BFI vs CSI: Physical-Layer Differences and Leakage Profiles
|
||||
|
||||
### 1.1 Channel State Information (CSI)
|
||||
|
||||
CSI is the raw complex channel frequency response (CFR) measured at the receiver across
|
||||
all subcarriers and antenna pairs. Extracting CSI requires either (a) firmware
|
||||
modifications on the receiving NIC (Atheros CSI Tool, Nexmon CSI patch for BCM43455c0
|
||||
on Raspberry Pi 4/5) or (b) a specialized radio (software-defined radio with 802.11
|
||||
decoders). The resulting matrix is typically Ntx × Nrx × Nsubcarrier complex floats —
|
||||
dense, high-dimensional, and not transmitted over the air in standard operation.
|
||||
|
||||
This project's existing rvCSI runtime (`vendor/rvcsi/`) captures CSI via the Nexmon
|
||||
firmware patch on Raspberry Pi hardware (ADR-095/096). The ESP32-S3 on COM9 cannot
|
||||
produce CSI in the format needed for the full pipeline — it lacks the antenna count
|
||||
and the firmware support for per-subcarrier phase extraction at the fidelity rvcsi
|
||||
expects.
|
||||
|
||||
### 1.2 Beamforming Feedback Information (BFI)
|
||||
|
||||
BFI is fundamentally different: it is the compressed representation of the channel that
|
||||
a STA (station/client) sends back to an AP (access point) so the AP can steer its beam
|
||||
toward the client. The standard (IEEE 802.11ac/ax, section 9.4.1.52) defines the
|
||||
compressed beamforming format as:
|
||||
|
||||
1. The AP transmits a Null Data Packet (NDP) sounding frame.
|
||||
2. The STA measures the channel from the NDP, computes the singular-value decomposition
|
||||
V = U Sigma V^H, then compresses the right singular vectors using a series of Givens
|
||||
rotations.
|
||||
3. The Givens rotation produces a set of angles: Phi (φ) angles in [0, 2π) and Psi (ψ)
|
||||
angles in [0, π/2). In 802.11ac these are quantized to 7 and 5 bits respectively; in
|
||||
802.11ax the default is 4 bits for φ and 2 bits for ψ.
|
||||
4. The STA transmits a VHT/HE Compressed Beamforming frame (CBFR) containing those
|
||||
quantized angles, one set per active subcarrier (or per compressed subcarrier group),
|
||||
plus an SNR field per stream.
|
||||
|
||||
The CBFR is a **management-plane 802.11 frame, not an 802.3 data frame**. It is
|
||||
transmitted before association encryption is negotiated; in WPA2/WPA3 deployments, the
|
||||
beamforming sounding and feedback exchange happens in the clear because WPA2/WPA3
|
||||
encrypt data frames only. Even 802.11ax (Wi-Fi 6/6E) with Protected Management Frames
|
||||
(PMF) enabled does NOT encrypt action frames in the beamforming exchange by default on
|
||||
commodity APs as of 2025 (NDSS 2025 finding, "Lend Me Your Beam",
|
||||
https://www.ndss-symposium.org/ndss-paper/lend-me-your-beam-privacy-implications-of-plaintext-beamforming-feedback-in-wifi/).
|
||||
|
||||
**Key asymmetry**: extracting CSI requires physical access to a device and firmware
|
||||
modification; extracting BFI requires only a WiFi adapter in monitor mode and a parser
|
||||
for the CBFR frame format. Wi-BFI (Haque, Meneghello, Restuccia; ACM WiNTECH 2023,
|
||||
https://arxiv.org/abs/2309.04408) is an open-source pip-installable tool that does
|
||||
exactly this.
|
||||
|
||||
### 1.3 Why BFI Is Uniquely Dangerous
|
||||
|
||||
CSI is a research instrument — accessing it requires deliberate effort. BFI is a
|
||||
production protocol artifact that any 802.11ac/ax STA broadcasts periodically as a
|
||||
matter of course. The attack-surface implications:
|
||||
|
||||
- **No firmware modification needed** on the target device or AP.
|
||||
- **Passive capture** is sufficient. Frames are broadcast in all directions, not
|
||||
beamformed, so a nearby attacker receives them at essentially the same SNR as the AP.
|
||||
- **Structured leakage**: the Phi/Psi angle matrices encode a compressed but
|
||||
non-trivially-invertible representation of the spatial channel, which includes
|
||||
multipath geometry that is body-shaped — the human body is a dielectric obstacle whose
|
||||
shape and movement modulate the channel.
|
||||
- **Regularity**: sounding happens at the AP's request, typically at 5–40 Hz in modern
|
||||
802.11ax deployments. A 60-second capture at 10 Hz produces 600 CBFR frames —
|
||||
sufficient for the BFId classifier to achieve >90% re-identification accuracy (ACM CCS
|
||||
2025, https://dl.acm.org/doi/10.1145/3719027.3765062).
|
||||
|
||||
---
|
||||
|
||||
## 2. Compressed Angle Matrices: The Identity Surface
|
||||
|
||||
### 2.1 Givens Rotation Reconstruction
|
||||
|
||||
The Phi/Psi angles encode a unitary matrix via the Givens rotation decomposition:
|
||||
|
||||
V = G(N, N-1, φ_{N,N-1}, ψ_{N,N-1}) · G(N, N-2, ...) · ... · G(2,1, φ_{2,1}, ψ_{2,1}) · D
|
||||
|
||||
where D is a diagonal phase matrix. For a 2×2 MIMO system this is two angles; for a
|
||||
4×4 system this is 12 angles. Each "column" in the BFI payload corresponds to one
|
||||
subcarrier group (or every 4th subcarrier in 802.11ax, every 2nd in 802.11ac).
|
||||
|
||||
The resulting per-subcarrier angle sequence is a time-varying signature of the spatial
|
||||
channel. Because the human body modulates the multipath channel, this sequence encodes
|
||||
body-specific geometry. The BFId paper (https://dl.acm.org/doi/10.1145/3719027.3765062)
|
||||
demonstrates that a supervised classifier trained on these sequences achieves identity
|
||||
recognition on a 197-person dataset.
|
||||
|
||||
### 2.2 The AI/ML Compression Feedback Loop
|
||||
|
||||
IEEE 802.11 standardization is actively exploring AI/ML-based compression for
|
||||
beamforming feedback (IEEE 802.11bn / Wi-Fi 8 study group, "Toward AIML Enabled WiFi
|
||||
Beamforming CSI Feedback Compression", https://arxiv.org/html/2503.00412v1). This work
|
||||
proposes neural codebooks that reduce feedback overhead. An important side effect: the
|
||||
learned latent space of a neural BFI compressor may be *more* identity-discriminative
|
||||
than the raw angles, because neural compression tends to preserve class-discriminative
|
||||
variance. BFLD must be designed to handle compressed BFI encodings, not just the raw
|
||||
Phi/Psi format.
|
||||
|
||||
---
|
||||
|
||||
## 3. Tooling Landscape
|
||||
|
||||
### 3.1 Wi-BFI
|
||||
|
||||
- **Source**: https://arxiv.org/abs/2309.04408 / https://github.com/kfoysalhaque/MU-MIMO-Beamforming-Feedback-Extraction-IEEE802.11ac
|
||||
- **Capabilities**: real-time and offline extraction of BFAs from 802.11ac and 802.11ax;
|
||||
20/40/80/160 MHz; SU-MIMO and MU-MIMO; pip-installable.
|
||||
- **Relevance to BFLD**: the BFLD extractor module (`extractor.rs`) must produce
|
||||
semantically equivalent output to Wi-BFI — i.e., per-subcarrier Phi/Psi angle arrays
|
||||
plus per-stream SNR — so that research results from the Wi-BFI ecosystem can be
|
||||
replicated on BFLD captures.
|
||||
|
||||
### 3.2 PicoScenes
|
||||
|
||||
- **Source**: https://www.semanticscholar.org/paper/Eliminating-the-Barriers-Demystifying-Wi-Fi-Baseband-Jiang-Zhou/...
|
||||
- **Capabilities**: cross-NIC CSI and CBFR measurement platform; supports Intel AX200,
|
||||
AX210, Atheros AR9300, QCA6174; runs on Linux with custom kernel modules.
|
||||
- **Relevance to BFLD**: PicoScenes can simultaneously capture CSI and BFI from the
|
||||
same frame sequence, enabling the CSI+BFI fusion path described in the BFLD spec
|
||||
(`csi_matrix` optional input). The rvcsi adapter layer (`vendor/rvcsi/`) already
|
||||
handles the Nexmon PCap format; a PicoScenes adapter is a future extension.
|
||||
|
||||
### 3.3 Nexmon CSI (BCM43455c0)
|
||||
|
||||
- **Source**: https://github.com/seemoo-lab/nexmon_csi
|
||||
- **Hardware**: Raspberry Pi 4/5 with BCM43455c0 chip — the same hardware used in
|
||||
`cognitum-v0` (Pi 5 appliance in this fleet, see CLAUDE.local.md).
|
||||
- **Capabilities**: per-subcarrier complex CSI in monitor mode; 4×4 MIMO on Pi 5 with
|
||||
BCM43456.
|
||||
- **Relevance to BFLD**: the rvcsi nexmon adapter already routes PCap frames from this
|
||||
hardware into the wifi-densepose pipeline. BFI extraction on the same hardware requires
|
||||
an additional sniffer for CBFR frames alongside the CSI sniffer.
|
||||
|
||||
### 3.4 Atheros CSI Tool / iwlwifi CSI
|
||||
|
||||
- Legacy tools for Intel and Atheros NICs; require kernel module injection. Not relevant
|
||||
to the current hardware fleet (ESP32-S3 + Raspberry Pi 5), but documented here for
|
||||
completeness and for future Intel AX210-based deployments.
|
||||
|
||||
---
|
||||
|
||||
## 4. Identity Inference Attacks
|
||||
|
||||
### 4.1 BFId (ACM CCS 2025)
|
||||
|
||||
**Reference**: Todt, Morsbach, Strufe; KIT. ACM CCS 2025.
|
||||
https://dl.acm.org/doi/10.1145/3719027.3765062
|
||||
https://publikationen.bibliothek.kit.edu/1000185756
|
||||
Dataset: https://ps.tm.kit.edu/english/bfid-dataset/index.php
|
||||
|
||||
BFId is the first published identity-inference attack that uses BFI exclusively (no
|
||||
CSI). The methodology:
|
||||
|
||||
1. **Dataset**: 197 individuals, multiple sessions, multiple AP angles. Each subject
|
||||
walked a defined path while their STA continuously triggered BFI exchanges. CSI
|
||||
was also recorded simultaneously for comparison.
|
||||
2. **Feature extraction**: temporal sequences of Phi/Psi angle matrices, windowed at
|
||||
varying lengths. Basic statistical features (mean, variance, cross-subcarrier
|
||||
correlation) fed a shallow classifier.
|
||||
3. **Results**: re-identification accuracy >90% with as little as 5 seconds of BFI.
|
||||
Performance was robust to different walking styles and viewing angles — consistent
|
||||
with the hypothesis that anthropometric body shape (torso width, stride, limb
|
||||
geometry) rather than gait phase is the primary discriminator.
|
||||
4. **Comparison to CSI**: BFI-only accuracy was comparable to CSI-only accuracy for
|
||||
identity tasks, despite BFI being a compressed representation. This confirms that
|
||||
the Givens angle compression preserves identity-discriminative variance.
|
||||
|
||||
### 4.2 LeakyBeam (NDSS 2025)
|
||||
|
||||
**Reference**: Xiao, Chen, He, Han, Han; Zhejiang U., NTU, KAIST. NDSS 2025.
|
||||
https://www.ndss-symposium.org/ndss-paper/lend-me-your-beam-privacy-implications-of-plaintext-beamforming-feedback-in-wifi/
|
||||
|
||||
LeakyBeam targets occupancy detection (is a person present?) rather than identity.
|
||||
Key findings:
|
||||
|
||||
- BFI is detectable through walls at 20 m range with commodity hardware.
|
||||
- True positive rate 82.7%, true negative rate 96.7% in real-world evaluation.
|
||||
- The attack works because BFI encodes motion-induced channel perturbations even through
|
||||
obstacles — the Phi/Psi angle variance changes measurably when a body enters the room.
|
||||
- The defense (obfuscating BFI before transmission) requires minimal hardware changes.
|
||||
|
||||
**Implication for BFLD**: if a passive attacker with no relationship to the AP can
|
||||
detect occupancy, then the BFLD node is implicitly broadcasting presence information
|
||||
unless active obfuscation is deployed at the STA firmware level. BFLD cannot prevent
|
||||
this passive attack — it can only ensure the *node's own output* does not additionally
|
||||
leak identity.
|
||||
|
||||
### 4.3 Prior RF-Based Gait and Biometric Inference
|
||||
|
||||
Before BFI-specific attacks, the threat landscape was already established through
|
||||
CSI-based attacks:
|
||||
|
||||
- **Gait from CSI**: WiGait (2017), Wi-Gait (ScienceDirect 2023,
|
||||
https://www.sciencedirect.com/science/article/abs/pii/S1389128623001962),
|
||||
Gait+Respiration ID (IEEE Xplore 2021,
|
||||
https://ieeexplore.ieee.org/document/9488277) all demonstrate >90% gait-based
|
||||
re-identification from standard WiFi.
|
||||
- **Breathing biometrics**: Respiration rate and depth are person-specific at a
|
||||
population level. IEEE 802.11 CSI captures breathing as amplitude oscillations at
|
||||
0.1–0.5 Hz.
|
||||
- **Anthropometric inference**: Hand size, torso width, and limb geometry modulate the
|
||||
channel; classifiers trained on activity data have been shown to leak anthropometrics
|
||||
as a side effect.
|
||||
|
||||
The BFId finding that BFI achieves comparable accuracy to CSI for identity is consistent
|
||||
with this prior body of work — it simply demonstrates the attack is achievable with a
|
||||
lower barrier to entry.
|
||||
|
||||
---
|
||||
|
||||
## 5. Privacy-Preserving Sensing: Current State of the Art
|
||||
|
||||
### 5.1 Differential Privacy on RF Embeddings
|
||||
|
||||
"Differentially Private Feature Release for Wireless Sensing: Adaptive Privacy Budget
|
||||
Allocation on CSI Spectrograms" (https://arxiv.org/pdf/2512.20323) applies Laplace/
|
||||
Gaussian mechanisms to CSI spectrograms, calibrating epsilon per subcarrier based on
|
||||
empirical sensitivity. Results show meaningful reduction in identity-inference accuracy
|
||||
while preserving activity-recognition utility at epsilon = 1.0–4.0.
|
||||
|
||||
BFLD's `identity_risk_score` could be used as an adaptive epsilon selector: high-risk
|
||||
frames receive a tighter privacy budget (more noise), low-risk frames pass unmodified.
|
||||
This is a forward-looking integration not in the current spec.
|
||||
|
||||
### 5.2 Federated / Local-Only Inference
|
||||
|
||||
The consensus across 2024–2025 literature on wireless federated learning
|
||||
(https://arxiv.org/pdf/2603.19040, https://arxiv.org/pdf/2109.09142) is that
|
||||
local differential privacy (LDP) with gradient perturbation is achievable on resource-
|
||||
constrained edge devices. For BFLD's use case the critical property is simpler: the
|
||||
identity embedding never needs to leave the node. There is no federated learning step
|
||||
for identity. The risk score is a local computation whose output is published; the
|
||||
embedding that produced it is not.
|
||||
|
||||
### 5.3 ZK Attestation for Sensing
|
||||
|
||||
ZK-SenseLM (https://arxiv.org/pdf/2510.25677) proposes zero-knowledge proofs that a
|
||||
sensing model's output derives from legitimate data. This is architecturally close to
|
||||
ADR-028's witness-bundle approach. Future BFLD work could use ZK proofs to attest that
|
||||
the identity_risk_score was computed from the claimed input without revealing the input.
|
||||
|
||||
### 5.4 "Protecting Human Activity Signatures in Compressed IEEE 802.11 CSI Feedback"
|
||||
|
||||
(https://arxiv.org/pdf/2512.18529) — This 2024 paper directly addresses activity-
|
||||
signature leakage in CBFR frames and proposes perturbation of Phi/Psi angles at the STA
|
||||
before transmission. The defense is the dual of BFLD's approach: BFLD detects leakage
|
||||
at the receiver; this paper proposes suppression at the transmitter. Both approaches
|
||||
are complementary.
|
||||
|
||||
---
|
||||
|
||||
## 6. Relationship to Existing Project ADRs
|
||||
|
||||
**ADR-027 (MERIDIAN cross-environment generalization)**: BFLD's cross-room hash
|
||||
rotation directly instantiates the "no cross-site correlation" invariant that MERIDIAN
|
||||
assumes for privacy-safe multi-room deployment.
|
||||
|
||||
**ADR-028 (ESP32 capability audit + witness verification)**: The deterministic-proof
|
||||
pattern (`verify.py` + SHA-256 expected hash) is the template for BFLD's own acceptance
|
||||
test. BFLD must produce a deterministic frame hash given the same input — acceptance
|
||||
criterion 6 in the spec.
|
||||
|
||||
**ADR-024 (AETHER contrastive CSI embedding)**: BFLD reuses the AETHER embedding
|
||||
infrastructure for its identity_risk measurement. The risk score is a function of how
|
||||
separable the current embedding is from the population of known embeddings.
|
||||
|
||||
**ADR-029/030 (RuvSense multistatic + field model)**: BFLD's `cross_perspective_
|
||||
consistency` component of the risk formula requires correlation across multiple sensor
|
||||
viewpoints — the multistatic infrastructure from ADR-029 provides this.
|
||||
|
||||
**ADR-032 (multistatic mesh security hardening)**: The BFLD threat model is a
|
||||
superset of the security model in ADR-032. ADR-032 covers mesh compromise; BFLD adds
|
||||
the passive sniffing threat at the management-plane layer.
|
||||
|
||||
---
|
||||
|
||||
## 7. Open Technical Questions
|
||||
|
||||
1. **BFI capture on ESP32-S3**: The ESP32-S3's `esp_wifi_csi_set_config` API provides
|
||||
CSI via the vendor-specific Espressif HT20 format. It does not expose VHT/HE CBFR
|
||||
frames. BFI capture on this hardware likely requires host-side sniffing (Pi 5 +
|
||||
Nexmon in monitor mode, already available on cognitum-v0).
|
||||
|
||||
2. **Quantization resolution degradation**: At 4 bits for φ and 2 bits for ψ (802.11ax
|
||||
defaults), the angle resolution is coarser than in 802.11ac (7/5 bits). The BFId
|
||||
paper used 802.11ac hardware. BFLD must validate that the identity_risk_score
|
||||
calibration remains valid at lower quantization.
|
||||
|
||||
3. **WiFi 7 (802.11be) changes**: 802.11be introduces multi-link operation (MLO) and
|
||||
may change the sounding/feedback cadence. BFLD's frame format (magic 0xBF1D_0001,
|
||||
version byte) is designed to accommodate future protocol versions.
|
||||
@@ -1,141 +0,0 @@
|
||||
# BFLD Soul — Architectural Intent and Ethical Stance
|
||||
|
||||
## 1. The Central Metaphor: Immune System, Not Surveillance Lens
|
||||
|
||||
An immune system does not catalog every pathogen it encounters. It classifies threats
|
||||
by type, responds proportionally, and keeps its detailed records local to the organism.
|
||||
When the immune system flags a cell as dangerous, it does not broadcast the cell's
|
||||
identity to the outside world — it takes local action.
|
||||
|
||||
BFLD is built around this same principle. Its job is to detect when RF data is crossing
|
||||
from the realm of "ambient sensing" into the realm of "identity record" — and to respond
|
||||
locally: raise the risk score, restrict what leaves the node, rotate identifiers. It does
|
||||
not produce identity; it guards against the accidental production of identity.
|
||||
|
||||
This distinction matters because the same physical signal that drives BFLD's presence
|
||||
detection is also the signal that academic attackers (BFId, LeakyBeam) exploit for
|
||||
re-identification. BFLD cannot suppress the underlying physics. What it can do is make
|
||||
the node's *output* non-identifying, even when the node's *input* is capable of
|
||||
supporting identification.
|
||||
|
||||
---
|
||||
|
||||
## 2. Distinguishing Identity from the Rest of WiFi Sensing
|
||||
|
||||
WiFi sensing produces a spectrum of information:
|
||||
|
||||
| Output | Privacy class | Reversibility |
|
||||
|--------|--------------|---------------|
|
||||
| Presence (yes/no) | 2 — anonymous | Not reversible to identity |
|
||||
| Motion magnitude (0..1) | 1 — derived | Not reversible to identity |
|
||||
| Person count (integer) | 1 — derived | Not reversible to identity |
|
||||
| Zone activity | 1 — derived | Not reversible to identity |
|
||||
| Identity risk score | 1 — derived | Risk score, not identity |
|
||||
| RF signature hash | 1 — derived | Hash rotates daily; not reversible |
|
||||
| Identity embedding | 0 — raw | Directly reversible to biometric |
|
||||
| Raw BFI matrix | 0 — raw | Directly reversible to biometric |
|
||||
|
||||
BFLD's design follows this table structurally: the outputs in privacy class 0 never
|
||||
leave the node. The outputs in class 1 leave the node only after explicit operator opt-in
|
||||
for the sensitive ones (identity_risk_score). The outputs in class 2 flow freely.
|
||||
|
||||
This table is not a policy list — it is wired into the frame format. The `privacy_class`
|
||||
byte in every `BfldFrame` is checked at the emitter boundary before any byte leaves the
|
||||
node. Code that wants to send class-0 data must positively bypass a compile-time safety
|
||||
check, not merely forget to set a flag.
|
||||
|
||||
---
|
||||
|
||||
## 3. Three Non-Negotiable Invariants
|
||||
|
||||
These are not configurable options. They are structural properties of BFLD that
|
||||
hold regardless of operator configuration:
|
||||
|
||||
### Invariant 1: Raw BFI Never Leaves the Node
|
||||
|
||||
The BFI matrix, once ingested by the BFLD extractor, is consumed locally and never
|
||||
serialized to any outbound channel. This is enforced in two ways:
|
||||
|
||||
1. The `BfldFrame` struct's `bfi_matrix` field is not part of the serializable payload
|
||||
— it exists only as a private field in `extractor.rs` and is dropped after
|
||||
feature extraction completes.
|
||||
2. The MQTT emitter (`mqtt.rs`) has no code path that serializes a BFI matrix.
|
||||
The `ruview/<node_id>/bfld/raw/state` topic is disabled by default and, when
|
||||
enabled, publishes only a metadata summary (subcarrier count, timestamp, SNR range),
|
||||
not the angle matrices.
|
||||
|
||||
### Invariant 2: Identity Embedding Is Local-Only
|
||||
|
||||
The embedding computed by the RuVector pipeline (used to calculate `identity_risk_score`)
|
||||
lives in an in-RAM ring buffer with a configurable retention window (default: 10 minutes).
|
||||
It is never written to disk. It is never serialized to any MQTT topic. It is never
|
||||
included in any `BfldFrame` payload even at `privacy_class = 0` — raw means raw angles,
|
||||
not the derived embedding.
|
||||
|
||||
The mathematical property that enables this: `identity_risk_score` can be computed as a
|
||||
scalar from the embedding (separability × temporal_stability × cross_perspective_
|
||||
consistency × sample_confidence) without revealing the embedding itself. The score is a
|
||||
projection onto a scalar; the full vector is not required by any downstream consumer.
|
||||
|
||||
### Invariant 3: Cross-Site Identity Matching Is Structurally Impossible
|
||||
|
||||
The `rf_signature_hash` is computed as:
|
||||
|
||||
blake3(site_salt ‖ day_epoch ‖ ephemeral_features)
|
||||
|
||||
where `site_salt` is a secret generated at first boot, stored in NVS, and never
|
||||
transmitted. Two BFLD nodes at two different sites will produce hashes in disjoint
|
||||
hash spaces by construction. Even an adversary who obtains the hash stream from
|
||||
both nodes cannot determine whether the same person visited both sites, because the
|
||||
site_salt is unknown and different.
|
||||
|
||||
The daily rotation (`day_epoch` = floor(timestamp_ns / 86400e9)) means that even within
|
||||
a single site, the hash of the same person changes each day. Hashes older than 24 hours
|
||||
have zero correlation with hashes produced today.
|
||||
|
||||
This is structural impossibility, not policy. The invariant holds even if the operator
|
||||
misconfigures the system, because it derives from the cryptographic property of blake3
|
||||
with a secret key, not from access-control rules.
|
||||
|
||||
---
|
||||
|
||||
## 4. Relationship to RuView's Ambient Intelligence Positioning
|
||||
|
||||
The project memory records RuView's positioning as "ambient intelligence platform, not
|
||||
sensor; packaging (HA, Docker, mDNS, blueprints) is the bottleneck." This framing is
|
||||
load-bearing for BFLD's design.
|
||||
|
||||
A "sensor" in the Home Assistant model is a device that reports measurements. A "sensor"
|
||||
is allowed to identify who is present — facial recognition cameras are sensors. BFLD
|
||||
explicitly rejects this model: the node is an ambient intelligence node that knows
|
||||
something about the environment (motion, occupancy, activity level) but structurally
|
||||
cannot know *who* is in the environment.
|
||||
|
||||
This positioning enables deployment in spaces where identity-tracking would be
|
||||
unacceptable: shared workspaces, guest accommodations, hotel rooms, care facilities.
|
||||
The argument to an operator at a care facility is not "trust us, we won't log who your
|
||||
patients are." It is: "the system is architecturally incapable of logging who your
|
||||
patients are, because the identifier rotates daily with a site-specific secret we don't
|
||||
hold."
|
||||
|
||||
---
|
||||
|
||||
## 5. Why This Layer Must Exist Before WiFi 7 Ships
|
||||
|
||||
802.11be (Wi-Fi 7) is entering mass market deployment in 2025–2026. It introduces
|
||||
multi-link operation (MLO), which dramatically increases the frequency of beamforming
|
||||
sounding exchanges. Where 802.11ax sonding might occur at 10–40 Hz, MLO sounding on
|
||||
multiple links simultaneously could produce 3–5× more CBFR frames per second.
|
||||
|
||||
More frames means more training data for identity classifiers. The BFId result at 5
|
||||
seconds of 802.11ac data will almost certainly improve with 5 seconds of 802.11be MLO
|
||||
data. The attack surface is not static.
|
||||
|
||||
BFLD's frame format (magic 0xBF1D_0001, version byte for extension) is designed to
|
||||
remain valid across protocol generations. The feature extraction modules are pluggable:
|
||||
a WiFi 7 BFI extractor can be added without changing the privacy gate, the hash rotation,
|
||||
or the MQTT emitter. The invariants remain invariant.
|
||||
|
||||
The window to establish safe defaults is now, before the installed base is hundreds of
|
||||
millions of unprotected nodes. BFLD is the layer that carries those safe defaults into
|
||||
every deployment from day one.
|
||||
@@ -1,278 +0,0 @@
|
||||
# BFLD Security Threat Model
|
||||
|
||||
## 1. Adversary Classes
|
||||
|
||||
### A1 — Passive Sniffer (Curious Neighbor)
|
||||
|
||||
**Capability**: WiFi adapter in monitor mode; consumer laptop running Wi-BFI or
|
||||
tcpdump with CBFR filter. No special access, no relationship to the target network.
|
||||
|
||||
**Goal**: Determine occupancy or identity of persons in an adjacent apartment/office.
|
||||
|
||||
**Effort**: Low. Wi-BFI is pip-installable. Monitor mode is available on commodity
|
||||
Linux laptops. No prior knowledge of the target network required — CBFR frames are
|
||||
broadcast in all directions.
|
||||
|
||||
**Relevance to BFLD**: A1 is the LeakyBeam threat (NDSS 2025). BFLD cannot prevent
|
||||
A1 from capturing BFI from the air. BFLD's job is to ensure its own output does not
|
||||
make A1's work easier by publishing identity-correlated data on reachable channels.
|
||||
|
||||
### A2 — Targeted Stalker
|
||||
|
||||
**Capability**: A1 capabilities plus knowledge of the target's device MAC address
|
||||
(obtainable from BSSID probe requests) and time correlation with known schedules.
|
||||
|
||||
**Goal**: Track a specific individual's presence across time or across locations.
|
||||
|
||||
**Effort**: Medium. Requires sustained monitoring (hours to days) and a correlation
|
||||
step.
|
||||
|
||||
**Relevance to BFLD**: If rf_signature_hash were stable over time, A2 could correlate
|
||||
hash sequences across sessions to confirm a specific person's schedule. The daily hash
|
||||
rotation (Invariant 3) severs this correlation.
|
||||
|
||||
### A3 — ISP / Operator
|
||||
|
||||
**Capability**: Access to MQTT broker, HA instance, or cloud integration receiving
|
||||
BFLD events.
|
||||
|
||||
**Goal**: Build behavioral profiles of occupants across many homes/installations.
|
||||
|
||||
**Effort**: Low if raw or identity-correlated fields are published to the broker.
|
||||
|
||||
**Relevance to BFLD**: BFLD restricts what reaches the broker. An operator cannot
|
||||
accidentally publish identity-correlated data because the privacy gate blocks it at
|
||||
the node boundary.
|
||||
|
||||
### A4 — Nation-State / Law Enforcement
|
||||
|
||||
**Capability**: Compelled access to cloud storage, MQTT broker logs, or HA history.
|
||||
Physical access to the BFLD node with forensic tools.
|
||||
|
||||
**Goal**: Retrospectively identify who was present at a location and when.
|
||||
|
||||
**Effort**: Depends on what data was logged. If BFLD's invariants hold, the broker
|
||||
holds only: presence events (boolean), motion scores (float), person counts (integer),
|
||||
and rotated hashes. None of these are individually re-identifiable.
|
||||
|
||||
**Relevant mitigation**: The daily hash rotation means that even log retention is
|
||||
privacy-preserving: a hash from Monday and a hash from Tuesday, even from the same
|
||||
person at the same node, are in disjoint hash spaces.
|
||||
|
||||
### A5 — Compromised AP Firmware
|
||||
|
||||
**Capability**: Malicious AP firmware that modifies the sounding schedule to extract
|
||||
more identity-discriminative BFI, or that responds to specially crafted packets with
|
||||
high-resolution channel feedback.
|
||||
|
||||
**Goal**: Improve passive capture quality from the node's BFI stream.
|
||||
|
||||
**Relevance to BFLD**: BFLD ingests BFI as captured from the air. If the AP is
|
||||
compromised to produce unusually high-resolution BFI, BFLD's identity_risk_score
|
||||
will correctly detect the elevated separability and flag the frames at higher risk.
|
||||
The system is self-normalizing to the quality of what is captured.
|
||||
|
||||
### A6 — Supply-Chain Compromise of RuView Node
|
||||
|
||||
**Capability**: Modified BFLD binary with the privacy gate removed or with an
|
||||
exfiltration path added.
|
||||
|
||||
**Goal**: Long-term silent collection of identity embeddings or raw BFI.
|
||||
|
||||
**Mitigation**: ADR-028's witness-bundle pattern — deterministic SHA-256 of the
|
||||
pipeline output. A compromised binary would produce different output for the same
|
||||
input, failing the verify.py check. The BFLD acceptance criterion 6 (deterministic
|
||||
frame hashes) is the direct countermeasure.
|
||||
|
||||
---
|
||||
|
||||
## 2. Attack Trees
|
||||
|
||||
### AT-1: Passive BFI Capture → Identity Inference
|
||||
|
||||
```
|
||||
Attacker Goal: Re-identify a specific person via BFI
|
||||
|
|
||||
+-- Step 1: Place WiFi adapter in monitor mode (A1)
|
||||
| |
|
||||
| +-- CBFR frames arrive unencrypted (established by NDSS 2025 / BFId)
|
||||
|
|
||||
+-- Step 2: Parse Phi/Psi angles using Wi-BFI or equivalent
|
||||
| |
|
||||
| +-- No modification of target device required (Wi-BFI passive)
|
||||
|
|
||||
+-- Step 3: Collect 5-60 seconds of frames
|
||||
| |
|
||||
| +-- BFId: 5s sufficient at 10 Hz sounding rate for >90% accuracy
|
||||
|
|
||||
+-- Step 4: Run identity classifier (BFId architecture or similar)
|
||||
| |
|
||||
| +-- Requires enrollment (prior reference capture)
|
||||
| | |
|
||||
| | +-- OR: exploit BFLD's rf_signature_hash as a correlation anchor
|
||||
| | (mitigated by daily rotation — AT-2 below)
|
||||
|
|
||||
+-- Outcome: Identity label with >90% confidence
|
||||
```
|
||||
|
||||
BFLD mitigation: BFLD does not prevent AT-1 at the air interface. It ensures that
|
||||
BFLD's own output does not provide the "correlation anchor" in step 4.
|
||||
|
||||
### AT-2: Cross-Site Correlation via rf_signature_hash Leak
|
||||
|
||||
```
|
||||
Attacker Goal: Confirm person X visited site A and site B on the same day
|
||||
|
|
||||
+-- Prerequisite: Attacker has read access to MQTT broker at both sites
|
||||
|
|
||||
+-- Step 1: Collect rf_signature_hash sequences from site A and site B
|
||||
|
|
||||
+-- Step 2: Look for matching hashes within the same day_epoch
|
||||
| |
|
||||
| +-- BLOCKED: site_salt is site-specific and secret.
|
||||
| blake3(salt_A ‖ day ‖ features) != blake3(salt_B ‖ day ‖ features)
|
||||
| even if features are identical.
|
||||
| Two sites with the same person produce hashes in disjoint spaces.
|
||||
|
|
||||
+-- Outcome: No match possible. Attack fails structurally.
|
||||
```
|
||||
|
||||
### AT-3: Timing Side-Channel on identity_risk_score
|
||||
|
||||
```
|
||||
Attacker Goal: Infer when a known person is present by monitoring risk score changes
|
||||
|
|
||||
+-- Prerequisite: Read access to MQTT topic ruview/<node_id>/bfld/identity_risk/state
|
||||
|
|
||||
+-- Step 1: Baseline: collect identity_risk_score during known-empty periods
|
||||
|
|
||||
+-- Step 2: Monitor for anomalous spikes correlated with known schedules
|
||||
| |
|
||||
| +-- Partial mitigation: risk score is not published by default.
|
||||
| | Operator must explicitly enable it.
|
||||
| |
|
||||
| +-- Residual risk: even with publication enabled, the score measures risk of
|
||||
| identification, not identity itself. A high risk score means "this frame
|
||||
| is identity-discriminative" not "person X is present."
|
||||
|
|
||||
+-- Mitigation: MQTT ACL restricts identity_risk to local broker by default.
|
||||
+-- Mitigation: privacy_class=3 (restricted) zeros the risk score on output.
|
||||
```
|
||||
|
||||
### AT-4: MQTT Topic Enumeration
|
||||
|
||||
```
|
||||
Attacker Goal: Discover what BFLD data is published and harvest it
|
||||
|
|
||||
+-- Step 1: Connect to broker without TLS (if TLS not configured)
|
||||
|
|
||||
+-- Step 2: Subscribe to ruview/# wildcard
|
||||
|
|
||||
+-- Mitigation: Default mosquitto ACL denies wildcard subscription to anonymous clients.
|
||||
+-- Mitigation: TLS + client certificates recommended for all BFLD deployments.
|
||||
+-- Mitigation: ruview/<node_id>/bfld/raw/state is disabled by default.
|
||||
```
|
||||
|
||||
### AT-5: Matter Cluster Abuse
|
||||
|
||||
```
|
||||
Attacker Goal: Extract identity-correlated data via the Matter protocol integration
|
||||
|
|
||||
+-- Step 1: Join the Matter fabric as a legitimate controller
|
||||
|
|
||||
+-- Step 2: Read clusters exposed by the BFLD Matter endpoint
|
||||
| |
|
||||
| +-- Available: OccupancySensing (presence), MotionSensor (motion),
|
||||
| PeopleCount (person_count)
|
||||
| |
|
||||
| +-- NOT AVAILABLE: identity_risk_score, rf_signature_hash, raw_bfi,
|
||||
| identity_embedding — these are rejected at the Matter boundary.
|
||||
|
|
||||
+-- Outcome: Attacker gets presence/motion/count — same as any occupancy sensor.
|
||||
No identity-correlated data is accessible via Matter.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Trust Boundary Diagram
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────────┐
|
||||
│ BFLD NODE (local) │
|
||||
│ │
|
||||
│ WiFi air interface │
|
||||
│ │ CBFR frames (unencrypted, passively sniffable by any A1) │
|
||||
│ ▼ │
|
||||
│ ┌──────────────┐ raw BFI ┌──────────────┐ │
|
||||
│ │ BFI │──────────────│ Feature │ │
|
||||
│ │ Extractor │ (local RAM) │ Extractor │ │
|
||||
│ └──────────────┘ └──────┬───────┘ │
|
||||
│ │ features (not BFI) │
|
||||
│ ▼ │
|
||||
│ ┌──────────────┐ embedding │
|
||||
│ │ Identity │──────────────┐ │
|
||||
│ │ Risk Engine │ (local RAM │ │
|
||||
│ └──────┬───────┘ ring buf) │ │
|
||||
│ │ risk_score │ │
|
||||
│ ▼ │ │
|
||||
│ ┌───────────────────────────────────────────────────────┐ │ │
|
||||
│ │ Privacy Gate │ │ │
|
||||
│ │ privacy_class check | hash rotation | field masking │ │ │
|
||||
│ └───────┬──────────────────────────────────────────────┘ │ │
|
||||
│ │ filtered BfldFrame [embedding │ │
|
||||
│ │ (no raw BFI, no embedding) NEVER exits │ │
|
||||
│ ▼ this box] │ │
|
||||
│ ┌──────────────┐ │ │
|
||||
│ │ MQTT │ presence/motion/person_count/risk(opt) │ │
|
||||
│ │ Emitter │────────────────────────────────────────► │ │
|
||||
│ └──────────────┘ [TLS recommended] │ │
|
||||
│ │ │
|
||||
└──────────────────────────────────────────────────────────────┘─────────┘
|
||||
│
|
||||
│ MQTT (TLS)
|
||||
▼
|
||||
┌─────────────────────┐ ┌──────────────────────────────────────┐
|
||||
│ Local Broker │ │ cognitum-v0 federation endpoint │
|
||||
│ (mosquitto) │──────► │ (identity fields STRIPPED at node │
|
||||
└────────┬────────────┘ │ boundary before federation) │
|
||||
│ └──────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐ ┌──────────────────────────────────────┐
|
||||
│ Home Assistant │──────► │ Matter Fabric │
|
||||
│ (presence/motion/ │ │ (OccupancySensing / MotionSensor / │
|
||||
│ person_count only)│ │ PeopleCount ONLY) │
|
||||
└─────────────────────┘ └──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Threat Profile per privacy_class Value
|
||||
|
||||
| privacy_class | Value | Data exposed outbound | Residual threats |
|
||||
|--------------|-------|----------------------|-----------------|
|
||||
| raw | 0 | Derived angles + amplitude proxy + phase proxy + SNR. Never BFI matrix. | Angle sequences are identity-discriminative; use only in controlled research environments. Never default. |
|
||||
| derived | 1 | All BFLD output fields including identity_risk_score and rf_signature_hash. | Risk score timing side-channel (AT-3). Hash must remain rotated. |
|
||||
| anonymous | 2 | presence, motion, person_count, zone_activity, confidence. No identity-correlated fields. | Temporal occupancy patterns may leak schedule information. Not identity. |
|
||||
| restricted | 3 | presence only (binary). All other fields zeroed or suppressed. | Minimal. On/off presence is equivalent to a passive IR sensor. |
|
||||
|
||||
---
|
||||
|
||||
## 5. Witness / Attestation Strategy
|
||||
|
||||
Following ADR-028's pattern, BFLD should produce a deterministic proof bundle:
|
||||
|
||||
1. **Reference input**: a fixed seed synthetic BFI matrix (512 bytes, PRNG seed=117)
|
||||
stored alongside the test suite.
|
||||
2. **Expected output hash**: SHA-256 of the serialized `BfldFrame` produced from that
|
||||
input, committed to the repository.
|
||||
3. **CI check**: `verify_bfld.py` — same structure as `archive/v1/data/proof/verify.py`
|
||||
— runs in CI and locally. A compromised binary (A6 threat) would change the output
|
||||
hash and immediately fail this check.
|
||||
4. **Witness log**: extend `docs/WITNESS-LOG-028.md` with a BFLD section covering the
|
||||
privacy gate and hash rotation.
|
||||
|
||||
This attestation does not prevent a runtime compromise, but it raises the cost
|
||||
significantly: a supply-chain attacker must either (a) match the expected output hash
|
||||
while also exfiltrating data (computationally infeasible for a hash adversary), or
|
||||
(b) accept that the tampered binary will be detected on the next verify run.
|
||||
@@ -1,279 +0,0 @@
|
||||
# BFLD Privacy Gating — Mechanisms in Depth
|
||||
|
||||
## 1. The privacy_class Byte: Concrete Data Exposure Tables
|
||||
|
||||
The `privacy_class` byte is the single authoritative classifier for what a BFLD node
|
||||
is permitted to emit. It is set by the privacy gate module (`privacy_gate.rs`) on every
|
||||
outbound `BfldFrame` based on the computed `identity_risk_score` and operator configuration.
|
||||
|
||||
### Class 0 — raw
|
||||
|
||||
Intended exclusively for local research captures and red-team validation. Not a
|
||||
deployable configuration.
|
||||
|
||||
| Field | Published | Notes |
|
||||
|-------|-----------|-------|
|
||||
| presence | Yes | Boolean |
|
||||
| motion | Yes | 0..1 float |
|
||||
| person_count | Yes | u8 |
|
||||
| identity_risk_score | Yes | f32 |
|
||||
| rf_signature_hash | Yes | Rotated blake3, 32 bytes hex |
|
||||
| zone_activity | Yes | |
|
||||
| confidence | Yes | |
|
||||
| compressed_angle_matrix | Yes | Phi/Psi per subcarrier — the sensitive surface |
|
||||
| amplitude_proxy | Yes | |
|
||||
| phase_proxy | Yes | |
|
||||
| snr_vector | Yes | |
|
||||
| bfi_matrix (raw) | NEVER | Dropped before serialization; not in wire format |
|
||||
| identity_embedding | NEVER | Local RAM only; not in wire format |
|
||||
|
||||
### Class 1 — derived
|
||||
|
||||
Default for operator-opted-in diagnostics. Includes identity_risk_score and hash but
|
||||
no angle matrices.
|
||||
|
||||
| Field | Published | Notes |
|
||||
|-------|-----------|-------|
|
||||
| presence | Yes | |
|
||||
| motion | Yes | |
|
||||
| person_count | Yes | |
|
||||
| identity_risk_score | Yes | Diagnostic; not in HA default entities |
|
||||
| rf_signature_hash | Yes | Rotated hash only |
|
||||
| zone_activity | Yes | |
|
||||
| confidence | Yes | |
|
||||
| compressed_angle_matrix | No | Zeroed |
|
||||
| amplitude_proxy | No | |
|
||||
| phase_proxy | No | |
|
||||
| snr_vector | Yes | Per-stream aggregate only |
|
||||
| bfi_matrix (raw) | NEVER | |
|
||||
| identity_embedding | NEVER | |
|
||||
|
||||
### Class 2 — anonymous
|
||||
|
||||
Default for all standard deployments. No identity-correlated fields.
|
||||
|
||||
| Field | Published | Notes |
|
||||
|-------|-----------|-------|
|
||||
| presence | Yes | |
|
||||
| motion | Yes | |
|
||||
| person_count | Yes | |
|
||||
| identity_risk_score | No | Suppressed |
|
||||
| rf_signature_hash | No | Suppressed |
|
||||
| zone_activity | Yes | |
|
||||
| confidence | Yes | |
|
||||
| All angle/amplitude/phase fields | No | Zeroed |
|
||||
| bfi_matrix (raw) | NEVER | |
|
||||
| identity_embedding | NEVER | |
|
||||
|
||||
### Class 3 — restricted
|
||||
|
||||
Maximum privacy. Suitable for care facilities, medical deployments, guest spaces.
|
||||
|
||||
| Field | Published | Notes |
|
||||
|-------|-----------|-------|
|
||||
| presence | Yes | |
|
||||
| motion | No | Suppressed |
|
||||
| person_count | No | Suppressed |
|
||||
| All other fields | No | |
|
||||
| bfi_matrix (raw) | NEVER | |
|
||||
| identity_embedding | NEVER | |
|
||||
|
||||
---
|
||||
|
||||
## 2. rf_signature_hash Rotation Algorithm
|
||||
|
||||
### Construction
|
||||
|
||||
```
|
||||
site_salt := blake3_keyed_hash(secret="bfld-site-seed", data=node_mac_address)
|
||||
# Generated once at first boot, stored in NVS, never transmitted
|
||||
# 32 bytes
|
||||
|
||||
day_epoch := floor(timestamp_ns / 86_400_000_000_000)
|
||||
# One new epoch per UTC day
|
||||
|
||||
ephemeral := mean_angle_delta ‖ subcarrier_variance ‖ burst_motion_score
|
||||
# A small fixed-length summary of the current window's features
|
||||
# Not identity-specific — any of several persons could produce
|
||||
# similar values
|
||||
|
||||
rf_signature_hash := BLAKE3(
|
||||
key = site_salt, // 32 bytes; site-specific secret key
|
||||
input = day_epoch_bytes(8) ‖ ephemeral_features(24)
|
||||
)
|
||||
```
|
||||
|
||||
### Why cross-site re-identification is structurally impossible
|
||||
|
||||
Two BFLD nodes at sites A and B produce:
|
||||
|
||||
```
|
||||
hash_A = BLAKE3(key=salt_A, input=day ‖ features)
|
||||
hash_B = BLAKE3(key=salt_B, input=day ‖ features)
|
||||
```
|
||||
|
||||
BLAKE3 is a PRF (pseudorandom function family) keyed on site_salt. Given identical
|
||||
`day ‖ features` inputs, hash_A and hash_B are pseudorandom and independent because
|
||||
salt_A != salt_B. An adversary who observes hash_A and hash_B cannot determine whether
|
||||
they correspond to the same person without knowing both salts.
|
||||
|
||||
This is not a security proof; it is a consequence of BLAKE3's PRF security assumption,
|
||||
which holds as long as the site_salt remains secret.
|
||||
|
||||
### Why within-site, within-day tracking is safe
|
||||
|
||||
Within a single day at a single site, two frames from the same person will produce
|
||||
similar ephemeral features, leading to similar (though not identical — ephemeral features
|
||||
have some frame-to-frame variation) hash values. This is intentional: it allows
|
||||
clustering of same-person events within a session without enabling identity recovery.
|
||||
|
||||
The hash is NOT the identity. It is a pseudonym within the scope of (site, day). A
|
||||
person who visits the same site on two different days gets different pseudonyms on each
|
||||
day.
|
||||
|
||||
### Daily rotation schedule
|
||||
|
||||
```
|
||||
epoch_0 = 0 # day 0 (unix epoch: 1970-01-01)
|
||||
epoch_k = k * 86_400_000_000_000 # day k in nanoseconds
|
||||
rotation_time = epoch_{k+1} # midnight UTC
|
||||
```
|
||||
|
||||
At rotation time, all existing rf_signature_hash values become cryptographically
|
||||
disconnected from future values. Logs from before rotation cannot be correlated with
|
||||
logs after rotation even by the node operator.
|
||||
|
||||
---
|
||||
|
||||
## 3. Identity Embedding Lifecycle
|
||||
|
||||
```
|
||||
BFI frame arrives
|
||||
|
|
||||
v
|
||||
Feature extraction (identity_risk.rs)
|
||||
|
|
||||
v
|
||||
RuVector embedding computed: Vec<f32, 128>
|
||||
|
|
||||
+-------> identity_risk_score (scalar projection)
|
||||
| Published (class 1) or suppressed (class 2/3)
|
||||
|
|
||||
v
|
||||
In-RAM ring buffer (EmbeddingRingBuf)
|
||||
- capacity: 600 frames (default 10 minutes at 1 Hz)
|
||||
- implemented as VecDeque<Embedding> in heap memory
|
||||
- NEVER written to disk (no serde, no file I/O in the type)
|
||||
- NEVER serialized to any MQTT or HTTP path
|
||||
- Cleared on node restart (RAM is volatile)
|
||||
|
|
||||
v [after retention window]
|
||||
Dropped from ring buffer
|
||||
```
|
||||
|
||||
The ring buffer serves two purposes: (1) temporal_stability calculation requires
|
||||
comparing the current embedding to recent embeddings; (2) the coherence gate
|
||||
(`coherence_gate.rs`, from `v2/crates/wifi-densepose-signal/src/ruvsense/`) uses
|
||||
recent frames to determine whether a new frame is a continuation of an existing
|
||||
trajectory or a new event.
|
||||
|
||||
Both purposes require only that the embeddings exist in RAM during the computation.
|
||||
Neither purpose requires persistence.
|
||||
|
||||
---
|
||||
|
||||
## 4. Privacy-Mode Wire-Format Diff
|
||||
|
||||
The following shows what changes in the serialized `BfldFrame` payload when the node
|
||||
transitions from class 1 (derived) to class 2 (anonymous), which is the transition
|
||||
that happens when `privacy_mode` is enabled by the operator.
|
||||
|
||||
```
|
||||
BfldFrame {
|
||||
magic: 0xBF1D_0001, // unchanged
|
||||
version: 1, // unchanged
|
||||
ap_id: blake3(node_mac ‖ "ap"), // unchanged (already hashed at ingress)
|
||||
sta_id: ephemeral_u64, // unchanged (already ephemeral)
|
||||
session_id: u64, // unchanged
|
||||
quantization: 0x02, // unchanged (i8 in class 1)
|
||||
privacy_class: 0x01 -> 0x02, // CHANGED
|
||||
|
||||
// Payload (compressed):
|
||||
compressed_angle_matrix: [...], // class 1: present; class 2: zeroed + omitted
|
||||
amplitude_proxy: [...], // class 1: present; class 2: omitted
|
||||
phase_proxy: [...], // class 1: present; class 2: omitted
|
||||
snr_vector: [...], // class 1: present; class 2: present (aggregate)
|
||||
|
||||
// Event (JSON within payload or outer envelope):
|
||||
presence: true, // unchanged
|
||||
motion: 0.42, // unchanged
|
||||
person_count: 1, // unchanged
|
||||
identity_risk_score: 0.71, // class 1: present; class 2: OMITTED
|
||||
rf_signature_hash: "a3f2...", // class 1: present; class 2: OMITTED
|
||||
zone_activity: "living_room", // unchanged
|
||||
confidence: 0.88, // unchanged
|
||||
payload_crc32: <recomputed> // recomputed after changes
|
||||
}
|
||||
```
|
||||
|
||||
The wire-format diff is verified by the acceptance test suite: the same input must
|
||||
produce a deterministic output for each privacy_class value.
|
||||
|
||||
---
|
||||
|
||||
## 5. Default-Deny Posture for Future Fields
|
||||
|
||||
Every new field added to `BfldFrame` or the BFLD event JSON in the future MUST be
|
||||
classified before it ships. The process:
|
||||
|
||||
1. New field is added to `BfldFrame` struct.
|
||||
2. A `#[privacy_class(minimum = N)]` attribute annotation (or equivalent runtime
|
||||
check in `privacy_gate.rs`) declares the minimum privacy class at which this
|
||||
field is suppressed.
|
||||
3. Unit test asserts that serializing at class < N includes the field and at class ≥ N
|
||||
omits it.
|
||||
4. The PR that adds the field cannot pass CI without the classification annotation.
|
||||
|
||||
This is enforced by a custom `#[must_classify]` lint in the crate — any public field
|
||||
on `BfldFrame` without a classification attribute produces a compile warning that
|
||||
becomes a CI error.
|
||||
|
||||
---
|
||||
|
||||
## 6. Auditability: Verifying That Raw BFI Never Left the Network
|
||||
|
||||
An operator who wants to verify that no raw BFI or identity data has been transmitted
|
||||
from their BFLD node can use the following procedure:
|
||||
|
||||
### 6.1 Network-level audit (tcpdump)
|
||||
|
||||
```bash
|
||||
# On the node or a port-mirrored switch:
|
||||
tcpdump -i eth0 -w bfld_audit.pcap port 1883 or port 8883
|
||||
|
||||
# After capture, search for the BFI frame magic bytes in the PCAP:
|
||||
# Magic 0xBF1D_0001 in big-endian is bytes BF 1D 00 01
|
||||
# If these bytes appear in the MQTT payload, raw BFI may be present.
|
||||
# They should NOT appear — BFLD strips the angle matrix at privacy_class >= 2.
|
||||
strings bfld_audit.pcap | grep -v "presence\|motion\|person_count" | wc -l
|
||||
# Expected: only presence/motion/person_count keys in the MQTT payloads.
|
||||
```
|
||||
|
||||
### 6.2 Node self-check command
|
||||
|
||||
```bash
|
||||
# RuView CLI (planned for P3):
|
||||
wifi-densepose bfld audit --duration 60s
|
||||
# Output: "60 frames processed. 0 frames with raw_bfi in payload.
|
||||
# 0 frames with identity_embedding in payload.
|
||||
# privacy_class distribution: {2: 57, 3: 3}"
|
||||
```
|
||||
|
||||
### 6.3 CI deterministic hash check
|
||||
|
||||
```bash
|
||||
python python/wifi_densepose/verify_bfld.py
|
||||
# Must print: VERDICT: PASS
|
||||
# If a modified binary is exfiltrating raw BFI as part of the payload,
|
||||
# the output hash will differ from the committed expected hash.
|
||||
```
|
||||
@@ -1,239 +0,0 @@
|
||||
# BFLD Automation & Ecosystem Integration
|
||||
|
||||
## 1. Home Assistant Integration
|
||||
|
||||
### 1.1 Entities Exposed by BFLD
|
||||
|
||||
BFLD extends the sensing-server's existing HA entity set (ADR-115, 21 entities) with
|
||||
the following new entities:
|
||||
|
||||
| Entity | Type | HA Platform | privacy_class | Default |
|
||||
|--------|------|-------------|--------------|---------|
|
||||
| `binary_sensor.bfld_presence` | Boolean | binary_sensor | 2 — anonymous | ON |
|
||||
| `sensor.bfld_motion` | Float 0..1 | sensor | 2 — anonymous | ON |
|
||||
| `sensor.bfld_person_count` | Integer | sensor | 1 — derived | ON |
|
||||
| `sensor.bfld_confidence` | Float 0..1 | sensor | 2 — anonymous | ON |
|
||||
| `sensor.bfld_identity_risk` | Float 0..1 | sensor (diagnostic) | 1 — derived | OFF |
|
||||
| `sensor.bfld_zone_activity` | String | sensor | 2 — anonymous | ON |
|
||||
|
||||
`bfld_identity_risk` is classified as a diagnostic entity in the HA model — it is
|
||||
hidden by default in the UI and not included in recorder history unless explicitly
|
||||
enabled. This matches the operator opt-in posture for class-1 fields.
|
||||
|
||||
### 1.2 MQTT Discovery Payload (example for presence sensor)
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "BFLD Presence",
|
||||
"unique_id": "bfld_presence_<node_id_hash>",
|
||||
"state_topic": "ruview/<node_id>/bfld/presence/state",
|
||||
"device_class": "occupancy",
|
||||
"payload_on": "true",
|
||||
"payload_off": "false",
|
||||
"device": {
|
||||
"identifiers": ["ruview_<node_id_hash>"],
|
||||
"name": "RuView BFLD Node",
|
||||
"model": "wifi-densepose-bfld",
|
||||
"manufacturer": "RuView"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Topic: `homeassistant/binary_sensor/bfld_<node_id_hash>/presence/config`
|
||||
|
||||
### 1.3 HA Blueprints
|
||||
|
||||
**Blueprint 1: Presence-driven lighting**
|
||||
|
||||
Trigger: `binary_sensor.bfld_presence` changes to `on`.
|
||||
Condition: Time is between sunset and sunrise.
|
||||
Action: Turn on `light.living_room` at 40% brightness.
|
||||
Exit: `binary_sensor.bfld_presence` off for 5 minutes → turn off light.
|
||||
|
||||
This blueprint uses only class-2 (anonymous) data. No identity information is required.
|
||||
|
||||
**Blueprint 2: Motion-aware HVAC**
|
||||
|
||||
Trigger: `sensor.bfld_motion` rises above 0.3 (active movement threshold).
|
||||
Action: Set `climate.living_room` to comfort mode.
|
||||
Trigger: `sensor.bfld_motion` stays below 0.1 for 20 minutes (room settled).
|
||||
Action: Set `climate.living_room` to eco mode.
|
||||
|
||||
**Blueprint 3: Identity-risk anomaly notification**
|
||||
|
||||
Trigger: `sensor.bfld_identity_risk` rises above 0.8 (high-risk threshold).
|
||||
Condition: privacy mode is NOT enabled.
|
||||
Action: Notify user via HA mobile app: "BFLD: High identity-leakage risk detected.
|
||||
Consider enabling privacy mode."
|
||||
|
||||
This blueprint is the only one that touches a class-1 field. The notification is
|
||||
a privacy-protective action — it alerts the operator that the sensing environment
|
||||
has changed (e.g., new router firmware, new AP nearby, changed room geometry) in
|
||||
a way that makes the RF channel more identity-discriminative.
|
||||
|
||||
---
|
||||
|
||||
## 2. Matter Exposure
|
||||
|
||||
Matter clusters expose the absolute minimum set of BFLD outputs. The constraint is
|
||||
intentional: Matter fabrics can include cloud bridges, and identity-correlated data
|
||||
must never reach cloud endpoints.
|
||||
|
||||
### 2.1 Permitted Matter Clusters
|
||||
|
||||
| Matter Cluster | Cluster ID | BFLD Source | Notes |
|
||||
|----------------|-----------|-------------|-------|
|
||||
| Occupancy Sensing | 0x0406 | `presence` | `OccupancySensing` attribute `Occupancy` bit 0 |
|
||||
| Motion Detection | 0x040E (proposed) | `motion` | Published as motion event cluster |
|
||||
| People Count | — (vendor extension) | `person_count` | No standard cluster yet; use vendor attribute |
|
||||
|
||||
### 2.2 Rejected Matter Fields
|
||||
|
||||
The following BFLD fields MUST NOT be exposed via Matter regardless of operator
|
||||
configuration:
|
||||
|
||||
- `identity_risk_score`
|
||||
- `rf_signature_hash`
|
||||
- `raw_bfi`
|
||||
- `identity_embedding`
|
||||
- `compressed_angle_matrix`
|
||||
- Any future field classified at privacy_class < 2
|
||||
|
||||
This rejection is enforced in the `cog-ha-matter` crate (`v2/crates/cog-ha-matter/`),
|
||||
which filters `BfldFrame` events before populating Matter attribute reports.
|
||||
|
||||
### 2.3 Matter Endpoint Configuration
|
||||
|
||||
```
|
||||
Endpoint 1: BFLD Occupancy
|
||||
- Cluster: Occupancy Sensing (0x0406)
|
||||
- Attribute 0x0000 Occupancy: 0x01 (bitmask, bit 0 = presence)
|
||||
- Attribute 0x0001 OccupancySensorType: 0x03 (Other = WiFi RF)
|
||||
- Cluster: Basic Information (0x0028)
|
||||
- NodeLabel: "BFLD-<node_id_short>"
|
||||
- ProductName: "wifi-densepose-bfld"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. MQTT Topic Structure and ACL Recommendations
|
||||
|
||||
### 3.1 Topic Tree
|
||||
|
||||
```
|
||||
ruview/<node_id>/bfld/
|
||||
presence/state # "true" | "false" — class 2
|
||||
motion/state # "0.42" — class 2
|
||||
person_count/state # "1" — class 1
|
||||
identity_risk/state # "0.71" — class 1, disabled by default
|
||||
raw/state # disabled by default, class 0 metadata only
|
||||
zone_activity/state # "living_room" — class 2
|
||||
confidence/state # "0.88" — class 2
|
||||
events/bfld_update # Full JSON event payload — class 2 fields only by default
|
||||
```
|
||||
|
||||
### 3.2 Mosquitto ACL Recommendations
|
||||
|
||||
```
|
||||
# /etc/mosquitto/acl.conf (example)
|
||||
|
||||
# BFLD node publishes to its own subtree
|
||||
user bfld_node_<node_id>
|
||||
topic write ruview/<node_id>/bfld/#
|
||||
|
||||
# Home Assistant reads presence, motion, count, zone, confidence
|
||||
user homeassistant
|
||||
topic read ruview/+/bfld/presence/state
|
||||
topic read ruview/+/bfld/motion/state
|
||||
topic read ruview/+/bfld/person_count/state
|
||||
topic read ruview/+/bfld/zone_activity/state
|
||||
topic read ruview/+/bfld/confidence/state
|
||||
topic read ruview/+/bfld/events/bfld_update
|
||||
|
||||
# HA diagnostic access (operator opt-in required to add this rule):
|
||||
# topic read ruview/+/bfld/identity_risk/state
|
||||
|
||||
# DENY all wildcard subscriptions for anonymous clients:
|
||||
# (mosquitto default: anonymous clients get no access)
|
||||
|
||||
# DENY raw topic for all non-admin users:
|
||||
# raw/state is never written by default; no read ACL needed
|
||||
```
|
||||
|
||||
### 3.3 TLS Configuration
|
||||
|
||||
BFLD should use TLS for all MQTT connections. The BFLD node connects as a TLS client;
|
||||
the broker must present a certificate matching the expected CA. The sensing-server
|
||||
already supports mTLS (ADR-115). BFLD inherits this configuration.
|
||||
|
||||
---
|
||||
|
||||
## 4. Node-RED and OpenHAB Compatibility
|
||||
|
||||
BFLD publishes standard MQTT payloads with consistent topic structure. No Node-RED
|
||||
or OpenHAB plugin is required; standard MQTT input/output nodes work directly.
|
||||
|
||||
**Node-RED example flow**:
|
||||
|
||||
```json
|
||||
[
|
||||
{"id": "bfld-in", "type": "mqtt in",
|
||||
"topic": "ruview/+/bfld/presence/state", "qos": "1"},
|
||||
{"id": "filter", "type": "switch",
|
||||
"property": "payload", "rules": [{"t": "eq", "v": "true"}]},
|
||||
{"id": "notify", "type": "http request",
|
||||
"url": "http://ha/api/events/bfld_presence_on"}
|
||||
]
|
||||
```
|
||||
|
||||
**OpenHAB MQTT binding** (items file):
|
||||
|
||||
```
|
||||
Switch BfldPresence "BFLD Presence" {mqtt="<[broker:ruview/node1/bfld/presence/state:state:default]"}
|
||||
Number BfldMotion "BFLD Motion" {mqtt="<[broker:ruview/node1/bfld/motion/state:state:default]"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. cognitum-v0 Federation
|
||||
|
||||
The cognitum-v0 appliance (Pi 5, running ruview-mcp-brain on port 9876,
|
||||
cognitum-rvf-agent on port 9004, ruvector-hailo-worker on port 50051 — see
|
||||
CLAUDE.local.md) is the fleet coordinator for multi-room correlation.
|
||||
|
||||
BFLD events from individual nodes flow to cognitum-v0 via the federation path.
|
||||
The critical constraint: **identity fields are stripped at the node boundary before
|
||||
federation**. The stripping happens in the local BFLD emitter (`mqtt.rs`), not in
|
||||
cognitum-v0. By the time a BFLD event reaches the broker that cognitum-v0 subscribes to,
|
||||
it contains only class-2 (anonymous) or class-3 (restricted) fields.
|
||||
|
||||
### 5.1 Federation Topics
|
||||
|
||||
```
|
||||
# Node-local (not federated):
|
||||
ruview/<node_id>/bfld/identity_risk/state
|
||||
ruview/<node_id>/bfld/raw/state
|
||||
|
||||
# Federated (forwarded to cognitum-v0 broker):
|
||||
ruview/<node_id>/bfld/presence/state
|
||||
ruview/<node_id>/bfld/motion/state
|
||||
ruview/<node_id>/bfld/person_count/state
|
||||
ruview/<node_id>/bfld/events/bfld_update
|
||||
```
|
||||
|
||||
### 5.2 cognitum-rvf-agent Role
|
||||
|
||||
The `cognitum-rvf-agent` (port 9004) handles cross-node RVF (RuView Frame) container
|
||||
events. For BFLD, it receives federated presence/motion/count events and can correlate
|
||||
them for multi-room occupancy (e.g., "person moved from living room node to kitchen
|
||||
node"). It does not receive or need identity information to perform this correlation —
|
||||
it uses temporal and spatial proximity, not identity.
|
||||
|
||||
### 5.3 Hailo Inference (Future)
|
||||
|
||||
The `ruvector-hailo-worker` (port 50051) on cognitum-v0 runs vector similarity on the
|
||||
Hailo-8 AI accelerator. A future extension could offload BFLD's identity_risk_score
|
||||
computation to the Hailo worker, keeping the identity embedding local to cognitum-v0
|
||||
while giving individual nodes the benefit of a larger enrollment pool for risk
|
||||
calibration. This is explicitly out of scope for the current BFLD spec — it is noted
|
||||
here as an integration-compatible extension point.
|
||||
@@ -1,253 +0,0 @@
|
||||
# BFLD Implementation Plan
|
||||
|
||||
## 1. New Crate: wifi-densepose-bfld
|
||||
|
||||
Location: `v2/crates/wifi-densepose-bfld/`
|
||||
|
||||
This crate slots between `wifi-densepose-signal` (BFI normalization, temporal windowing)
|
||||
and `wifi-densepose-sensing-server` (MQTT/HA integration). It does not depend on the
|
||||
training pipeline (`wifi-densepose-train`) or the neural-network inference crate
|
||||
(`wifi-densepose-nn`) in the default build — feature flags activate those paths.
|
||||
|
||||
### 1.1 Module Layout
|
||||
|
||||
```
|
||||
v2/crates/wifi-densepose-bfld/
|
||||
Cargo.toml
|
||||
src/
|
||||
lib.rs # Public API: BfldPipeline, BfldFrame, BfldEvent
|
||||
frame.rs # BfldFrame struct, serialization, CRC32, magic bytes
|
||||
extractor.rs # BFI packet capture interface, Phi/Psi parsing,
|
||||
# 802.11ac/ax CBFR format decoder
|
||||
features.rs # Feature computation: mean_angle_delta,
|
||||
# subcarrier_variance, temporal_entropy,
|
||||
# doppler_proxy, path_stability,
|
||||
# cross_antenna_correlation, burst_motion_score,
|
||||
# stationarity_score, identity_separability_score
|
||||
identity_risk.rs # identity_risk_score formula, EmbeddingRingBuf,
|
||||
# in-RAM-only lifecycle enforcement
|
||||
privacy_gate.rs # privacy_class assignment, field masking,
|
||||
# #[must_classify] lint check
|
||||
emitter.rs # BfldEvent construction, JSON serialization
|
||||
mqtt.rs # MQTT topic publishing, ACL, per-class topic routing
|
||||
tests/
|
||||
frame_roundtrip.rs # BfldFrame serialization + CRC32 determinism
|
||||
privacy_gate.rs # Per-class field suppression assertions
|
||||
hash_rotation.rs # Cross-site isolation + daily rotation proofs
|
||||
identity_risk.rs # Risk score bounded [0,1], local-only embedding
|
||||
acceptance.rs # All 7 acceptance criteria as named tests
|
||||
benches/
|
||||
pipeline_throughput.rs # Frame processing at 40 Hz
|
||||
```
|
||||
|
||||
### 1.2 Public API Sketch
|
||||
|
||||
```rust
|
||||
// lib.rs — primary entry points
|
||||
|
||||
pub struct BfldPipeline {
|
||||
config: BfldConfig,
|
||||
extractor: BfiExtractor,
|
||||
feature_engine: FeatureEngine,
|
||||
identity_risk: IdentityRiskEngine,
|
||||
privacy_gate: PrivacyGate,
|
||||
emitter: BfldEmitter,
|
||||
}
|
||||
|
||||
impl BfldPipeline {
|
||||
pub fn new(config: BfldConfig) -> Result<Self, BfldError>;
|
||||
pub fn process_frame(&mut self, raw: RawBfiCapture) -> Option<BfldEvent>;
|
||||
pub fn current_privacy_class(&self) -> PrivacyClass;
|
||||
pub fn enable_privacy_mode(&mut self); // forces class 3
|
||||
}
|
||||
|
||||
pub struct BfldEvent {
|
||||
pub timestamp_ns: u64,
|
||||
pub presence: bool,
|
||||
pub motion: f32, // 0.0..1.0
|
||||
pub person_count: u8,
|
||||
pub identity_risk_score: Option<f32>, // None if privacy_class >= 2
|
||||
pub rf_signature_hash: Option<[u8; 32]>, // None if privacy_class >= 2
|
||||
pub zone_id: Option<ZoneId>,
|
||||
pub confidence: f32,
|
||||
pub privacy_class: PrivacyClass,
|
||||
}
|
||||
|
||||
#[repr(u8)]
|
||||
pub enum PrivacyClass {
|
||||
Raw = 0,
|
||||
Derived = 1,
|
||||
Anonymous = 2,
|
||||
Restricted = 3,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Reuse Map: Existing Crates and Modules
|
||||
|
||||
### 2.1 RuvSense Modules (wifi-densepose-signal)
|
||||
|
||||
Path: `v2/crates/wifi-densepose-signal/src/ruvsense/`
|
||||
|
||||
| Module | Used by BFLD | Purpose |
|
||||
|--------|-------------|---------|
|
||||
| `coherence_gate.rs` | `identity_risk.rs` | Accept/reject frame based on coherence score; gates embeddings fed into risk calculation |
|
||||
| `multistatic.rs` | `features.rs` | Attention-weighted fusion for cross_perspective_consistency component of risk score |
|
||||
| `cross_room.rs` | `privacy_gate.rs` | Environment fingerprinting — confirms that the site_salt corresponds to the current room geometry |
|
||||
| `longitudinal.rs` | `identity_risk.rs` | Welford stats for temporal_stability component |
|
||||
| `adversarial.rs` | `extractor.rs` | Physically-impossible signal detection — flags frames that may be from a compromised AP (A5 threat) |
|
||||
|
||||
Not used by BFLD: `pose_tracker.rs`, `intention.rs`, `gesture.rs`, `tomography.rs`,
|
||||
`field_model.rs` — these operate above the identity-risk layer.
|
||||
|
||||
### 2.2 RuVector v2.0.4 Crates
|
||||
|
||||
| Crate | BFLD Usage | Rationale |
|
||||
|-------|-----------|-----------|
|
||||
| `ruvector-attention` | `identity_risk.rs` | Spatial attention over subcarrier dimension for embedding computation |
|
||||
| `ruvector-mincut` | `features.rs` | Person separation score as input to person_count feature |
|
||||
| `ruvector-temporal-tensor` | `extractor.rs` | Temporal windowing + compression of BFI angle sequences |
|
||||
|
||||
Not used: `ruvector-attn-mincut`, `ruvector-solver` — spectrogram and sparse
|
||||
interpolation are not needed in the BFI pipeline.
|
||||
|
||||
### 2.3 Cross-Viewpoint Fusion (wifi-densepose-ruvector)
|
||||
|
||||
Path: `v2/crates/wifi-densepose-ruvector/src/viewpoint/`
|
||||
|
||||
| Module | BFLD Usage |
|
||||
|--------|-----------|
|
||||
| `coherence.rs` | Cross-viewpoint phase coherence for cross_perspective_consistency risk component |
|
||||
| `geometry.rs` | Fisher Information / Cramer-Rao bounds for confidence estimation |
|
||||
| `attention.rs` | GeometricBias-weighted attention for multi-AP BFI fusion |
|
||||
| `fusion.rs` | MultistaticArray aggregate root — BFLD subscribes to domain events here |
|
||||
|
||||
---
|
||||
|
||||
## 3. ESP32 Firmware Additions
|
||||
|
||||
### 3.1 ESP32-S3 BFI Capability Assessment
|
||||
|
||||
The ESP32-S3's WiFi driver (`csi_collector.c` in `firmware/esp32-csi-node/main/`)
|
||||
uses `esp_wifi_csi_set_config()` and the `wifi_csi_cb_t` callback. This produces
|
||||
Espressif HT20 CSI in a vendor-specific format — amplitude + phase per subcarrier,
|
||||
not the VHT/HE Compressed Beamforming frames (CBFR) that contain Phi/Psi angles.
|
||||
|
||||
The ESP32-S3 does NOT have a public API to generate or capture CBFR frames. Espressif's
|
||||
802.11 implementation does receive and process CBFR frames internally (for beamforming
|
||||
its own transmissions), but these are not exposed via the CSI callback.
|
||||
|
||||
**Consequence**: BFI capture for BFLD requires host-side sniffing, not ESP32 firmware
|
||||
modification.
|
||||
|
||||
### 3.2 Host-Side BFI Capture Path
|
||||
|
||||
Recommended capture hardware: Raspberry Pi 5 with BCM43456 chip running Nexmon CSI
|
||||
patch. This is already present in the fleet as `cognitum-v0` (Pi 5, Tailscale IP
|
||||
100.77.59.83 per CLAUDE.local.md).
|
||||
|
||||
Capture path:
|
||||
1. Nexmon monitor mode captures all 802.11 frames on the target channel.
|
||||
2. A filter pass extracts CBFR frames (frame type = Action, subtype = VHT/HE CBFR).
|
||||
3. The rvcsi adapter (`vendor/rvcsi/`) already handles Nexmon PCap format; add a
|
||||
BFI extractor alongside the existing CSI extractor.
|
||||
4. Frames are forwarded to the BFLD pipeline via the existing UDP stream path
|
||||
(`stream_sender.c` / sensing-server).
|
||||
|
||||
### 3.3 Firmware Changes Required (Minimal)
|
||||
|
||||
The only firmware change needed in `firmware/esp32-csi-node/main/` is to the
|
||||
`stream_sender.c` protocol: add a packet type byte to the stream header to distinguish
|
||||
CSI frames from BFI frames. The BFI frames originate on the Pi-side host, not the
|
||||
ESP32; the ESP32 stream is unchanged.
|
||||
|
||||
```c
|
||||
// stream_sender.h — add packet type
|
||||
#define STREAM_PKT_TYPE_CSI 0x01
|
||||
#define STREAM_PKT_TYPE_BFI 0x02 // new: BFI frames from host capture
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Test Plan: 7 Acceptance Criteria Mapped to Rust Tests
|
||||
|
||||
| AC | Criterion | Test in `acceptance.rs` |
|
||||
|----|-----------|------------------------|
|
||||
| AC1 | Commodity WiFi 5/6 capture (80/160 MHz, 2×2 MIMO minimum) | `ac1_commodity_wifi_capture`: assert BfiExtractor parses 80 MHz VHT CBFR sample fixture |
|
||||
| AC2 | Presence detection latency ≤ 1s from first non-empty BFI frame | `ac2_presence_latency`: replay 10-frame window, assert first `BfldEvent` with `presence=true` within 1,000 ms wall time |
|
||||
| AC3 | Motion score published at ≥ 1 Hz on `motion/state` topic | `ac3_motion_hz`: mock MQTT sink, run at 5 Hz input, assert ≥ 1 motion event per second |
|
||||
| AC4 | Raw BFI bytes never appear in serialized output | `ac4_raw_bfi_absent`: fuzz 1,000 random BfiCaptures, assert no bfi_matrix bytes in serialized BfldFrame for any privacy_class |
|
||||
| AC5 | Privacy-mode suppresses all identity-derived fields | `ac5_privacy_mode`: enable privacy_mode, assert BfldEvent fields identity_risk_score and rf_signature_hash are None |
|
||||
| AC6 | Deterministic frame hash for identical inputs | `ac6_deterministic_hash`: run same BfiCapture 100 times, assert all output hashes identical |
|
||||
| AC7 | CSI-optional fusion: pipeline runs without csi_matrix | `ac7_csi_optional`: run BfldPipeline with None csi_matrix, assert no panic and presence event produced |
|
||||
|
||||
Additionally, `tests/hash_rotation.rs` must include:
|
||||
- `cross_site_isolation`: two BfldPipelines with different site_salts, identical inputs → hashes must differ
|
||||
- `daily_rotation`: same salt, frames 1 second before/after midnight → hashes must differ
|
||||
|
||||
---
|
||||
|
||||
## 5. Phased Rollout
|
||||
|
||||
### P1 — Frame Format + Extractor Stub (2 weeks)
|
||||
|
||||
Deliverables:
|
||||
- `frame.rs`: `BfldFrame` struct, serialization, CRC32, magic, version
|
||||
- `extractor.rs`: CBFR parser for 802.11ac VHT + 802.11ax HE formats
|
||||
- AC1, AC6 tests passing
|
||||
- `Cargo.toml` with workspace integration
|
||||
|
||||
Effort: 1 engineer, 2 weeks.
|
||||
|
||||
### P2 — Feature Extraction + Identity Risk (3 weeks)
|
||||
|
||||
Deliverables:
|
||||
- `features.rs`: all 9 named features (mean_angle_delta through identity_separability_score)
|
||||
- `identity_risk.rs`: risk formula, EmbeddingRingBuf, coherence gate integration
|
||||
- AC4, AC7 tests passing (raw-absent, CSI-optional)
|
||||
- Integration with `ruvector-attention` and `ruvector-temporal-tensor`
|
||||
|
||||
Effort: 1 engineer, 3 weeks.
|
||||
|
||||
### P3 — Privacy Gate + MQTT (2 weeks)
|
||||
|
||||
Deliverables:
|
||||
- `privacy_gate.rs`: privacy_class assignment, field masking, `#[must_classify]` lint
|
||||
- `mqtt.rs`: per-class topic routing, discovery payloads, ACL documentation
|
||||
- AC2, AC3, AC5 tests passing (latency, Hz, privacy-mode)
|
||||
- Hash rotation: `hash_rotation.rs` tests passing
|
||||
- Deterministic proof bundle: `verify_bfld.py` equivalent
|
||||
|
||||
Effort: 1 engineer, 2 weeks.
|
||||
|
||||
### P4 — Home Assistant Integration (1 week)
|
||||
|
||||
Deliverables:
|
||||
- MQTT discovery payloads for all 6 entities
|
||||
- 3 HA blueprints
|
||||
- `sensor.bfld_identity_risk` marked diagnostic + hidden by default
|
||||
- Update `wifi-densepose-sensing-server` to include BFLD event routing
|
||||
|
||||
Effort: 0.5 engineer, 1 week.
|
||||
|
||||
### P5 — Matter Exposure (1 week)
|
||||
|
||||
Deliverables:
|
||||
- `cog-ha-matter` crate updated to filter BfldFrame → Matter attribute reports
|
||||
- OccupancySensing cluster populated from `presence`
|
||||
- Rejection list for identity fields enforced at Matter boundary
|
||||
|
||||
Effort: 0.5 engineer, 1 week.
|
||||
|
||||
### P6 — cognitum Federation (1 week)
|
||||
|
||||
Deliverables:
|
||||
- Topic routing in `mqtt.rs` for federated vs local topics
|
||||
- Documentation for cognitum-rvf-agent BFLD event subscription
|
||||
- End-to-end test: Pi 5 (cognitum-v0) receives federated events, identity fields absent
|
||||
|
||||
Effort: 0.5 engineer, 1 week.
|
||||
|
||||
**Total estimate**: ~10.5 engineer-weeks across 6 phases, approximately 3 calendar months
|
||||
with one engineer.
|
||||
@@ -1,196 +0,0 @@
|
||||
# BFLD Benchmarks and Evaluation Strategy
|
||||
|
||||
## 1. Datasets
|
||||
|
||||
### 1.1 BFId Dataset (Primary)
|
||||
|
||||
**Reference**: Todt, Morsbach, Strufe; KIT. ACM CCS 2025.
|
||||
https://dl.acm.org/doi/10.1145/3719027.3765062
|
||||
https://ps.tm.kit.edu/english/bfid-dataset/index.php
|
||||
|
||||
197 individuals. BFI and CSI recorded simultaneously. Multiple sessions, multiple AP
|
||||
angles. Available to researchers for non-commercial use on request from KIT.
|
||||
|
||||
**Use in BFLD evaluation**: The BFId dataset provides the ground-truth identity labels
|
||||
needed to calibrate `identity_risk_score`. Specifically: given BFId's known re-ID
|
||||
accuracy as a function of time window, BFLD's identity_risk_score should correlate
|
||||
with BFId's success rate. High-risk frames (score > 0.7) should correspond to windows
|
||||
where BFId achieves > 80% accuracy; low-risk frames (score < 0.2) should correspond
|
||||
to windows where BFId accuracy approaches chance.
|
||||
|
||||
### 1.2 Wi-Pose and MM-Fi (Context)
|
||||
|
||||
**MM-Fi**: Multi-modal WiFi sensing dataset used by this project (ADR-015). Contains
|
||||
synchronized WiFi CSI, mmWave, and camera pose data. Does not contain BFI separately,
|
||||
but can be used to validate BFLD's CSI-optional path (AC7).
|
||||
|
||||
**Wi-Pose**: Academic benchmark for WiFi pose estimation. CSI only; used for
|
||||
person_count and motion accuracy baselines.
|
||||
|
||||
### 1.3 Proposed In-House Multi-Site Capture Protocol
|
||||
|
||||
**Purpose**: Validate cross-site isolation (Invariant 3) and daily rotation.
|
||||
|
||||
**Setup**:
|
||||
- Site A: ruvultra (RTX 5080 workstation, Tailscale 100.104.125.72) with USB WiFi
|
||||
adapter in monitor mode.
|
||||
- Site B: cognitum-v0 (Pi 5, Tailscale 100.77.59.83) with Nexmon monitor mode.
|
||||
- Subject pool: 5–10 volunteers.
|
||||
- Protocol: Each subject walks a fixed path at each site on 3 consecutive days.
|
||||
BFI captured simultaneously at both sites using Wi-BFI.
|
||||
|
||||
**Analysis**:
|
||||
1. Can the BFId classifier re-identify subjects within a site? (Baseline — should
|
||||
confirm BFId's published results.)
|
||||
2. Can any classifier re-identify subjects across sites using BFLD's
|
||||
rf_signature_hash? (Should fail — cross-site isolation test.)
|
||||
3. Can any classifier re-identify across days using BFLD's rf_signature_hash? (Should
|
||||
fail — daily rotation test.)
|
||||
|
||||
---
|
||||
|
||||
## 2. Metrics
|
||||
|
||||
### 2.1 Presence Detection
|
||||
|
||||
| Metric | Definition | Target |
|
||||
|--------|-----------|--------|
|
||||
| Latency p50 | Time from first non-empty BFI frame to first `presence=true` event | < 500 ms |
|
||||
| Latency p95 | | < 1000 ms (AC2) |
|
||||
| False positive rate | Presence=true when room is confirmed empty | < 5% |
|
||||
| False negative rate | Presence=false when person confirmed present | < 2% |
|
||||
|
||||
Measurement method: camera ground-truth (ruvultra webcam via MediaPipe Pose, same
|
||||
as ADR-079 collection protocol) for empty/occupied labels.
|
||||
|
||||
### 2.2 Motion Score
|
||||
|
||||
| Metric | Definition | Target |
|
||||
|--------|-----------|--------|
|
||||
| MAE vs ground truth | Mean absolute error of motion score vs camera-derived motion magnitude | < 0.1 |
|
||||
| Hz at sustained operation | Events published per second on `motion/state` | >= 1 Hz (AC3) |
|
||||
| Latency p95 | Time from motion onset (camera) to motion event | < 750 ms |
|
||||
|
||||
### 2.3 Person Count
|
||||
|
||||
| Metric | Definition | Target |
|
||||
|--------|-----------|--------|
|
||||
| Count accuracy | Fraction of windows where BFLD person_count == camera count | > 85% for 1–3 persons |
|
||||
| Count MAE | | < 0.5 for counts 1–4 |
|
||||
|
||||
Person count is harder than presence. The target is achievable with MinCut separation
|
||||
(`ruvector-mincut`) but requires multi-AP coverage for 4+ persons.
|
||||
|
||||
### 2.4 Identity Risk Calibration
|
||||
|
||||
This is BFLD's novel evaluation dimension — no prior system has explicitly quantified
|
||||
this.
|
||||
|
||||
**Calibration definition**: Let `r(t)` = BFLD's identity_risk_score at time t.
|
||||
Let `acc(t)` = BFId classifier's re-identification accuracy when trained on frames
|
||||
around time t. The identity_risk_score is *calibrated* if:
|
||||
|
||||
E[acc(t) | r(t) = v] is monotonically increasing in v
|
||||
|
||||
In other words: higher risk scores should correspond to frames where identity inference
|
||||
is genuinely easier.
|
||||
|
||||
**Evaluation protocol**:
|
||||
1. Run BFId classifier in sliding 5-second windows on the BFId dataset.
|
||||
2. Record per-window BFId accuracy (using leave-one-out cross-validation).
|
||||
3. Run BFLD's identity_risk_score computation on the same windows.
|
||||
4. Compute Spearman correlation between risk scores and BFId accuracy.
|
||||
5. Target: Spearman rho > 0.5 (positive monotonic correlation).
|
||||
|
||||
### 2.5 Privacy-Mode False Positive Rate
|
||||
|
||||
When `privacy_mode` is enabled (privacy_class = 3), all identity-correlated fields
|
||||
should be suppressed. The false positive rate is the fraction of outbound events
|
||||
that inadvertently include an identity-correlated field despite privacy_mode being
|
||||
active.
|
||||
|
||||
**Target**: 0% (this is a hard correctness requirement, not a statistical target).
|
||||
Verified by the AC5 fuzz test in `acceptance.rs`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Red-Team Protocol
|
||||
|
||||
### 3.1 Hash Re-identification Attack
|
||||
|
||||
**Question**: Can an attacker re-identify a person across rotated hashes?
|
||||
|
||||
**Setup**:
|
||||
- Run BFLD pipeline for person X across 3 days.
|
||||
- Collect `rf_signature_hash` values for each day: H_1, H_2, H_3.
|
||||
- Adversary has access to H_1, H_2, H_3 and knows they are from the same site.
|
||||
- Adversary attempts to confirm H_1, H_2, H_3 are from the same person.
|
||||
|
||||
**Success condition**: adversary achieves confirmation rate > chance (1/N for N subjects).
|
||||
|
||||
**Expected result**: FAIL (by construction of the hash rotation with site_salt).
|
||||
Since day_epoch changes daily and site_salt is fixed but unknown to the adversary,
|
||||
the hash function is a keyed PRF. The adversary has three random-looking 32-byte
|
||||
values with no structural relationship. Success rate should be indistinguishable from
|
||||
random guessing.
|
||||
|
||||
**Quantitative target**: success rate <= 1/N + 0.05 (within 5% of chance).
|
||||
|
||||
### 3.2 Cross-Site Re-identification Attack
|
||||
|
||||
**Question**: Can an attacker confirm person X visited both site A and site B?
|
||||
|
||||
**Setup**: Same as Section 1.3 in-house protocol. Adversary has BFLD event streams
|
||||
from both sites.
|
||||
|
||||
**Method**: Attempt to match rf_signature_hash values from site A and site B on the
|
||||
same day. Alternatively, train a classifier on BFI features (using the raw angle
|
||||
sequences from the captured data) and attempt cross-site re-ID.
|
||||
|
||||
**Expected result**: Hash-based matching fails by construction. Classifier-based
|
||||
re-ID may succeed if the adversary has raw angle data (which BFLD does not publish)
|
||||
but not using BFLD's published output.
|
||||
|
||||
**Success condition**: hash-based cross-site match rate <= 1/N + 0.05.
|
||||
|
||||
### 3.3 Timing Side-Channel Attack
|
||||
|
||||
**Question**: Can an attacker infer a person's schedule by monitoring
|
||||
identity_risk_score over time?
|
||||
|
||||
**Method**: Record identity_risk_score time series. Correlate with known schedule
|
||||
(person X leaves at 8am, returns at 6pm). Compute mutual information between
|
||||
schedule and risk score time series.
|
||||
|
||||
**Expected result**: Some correlation exists (risk score rises when person enters),
|
||||
but the attacker learns "someone is present" — equivalent to the presence sensor —
|
||||
not identity. This is acceptable: presence information is already published at
|
||||
class 2.
|
||||
|
||||
---
|
||||
|
||||
## 4. Comparison Baselines
|
||||
|
||||
| Baseline | Description | Presence F1 | Motion MAE | Identity leak |
|
||||
|----------|-------------|------------|-----------|--------------|
|
||||
| Raw CSI pipeline | Existing wifi-densepose pipeline (no BFLD) | ~0.95 (est.) | ~0.08 (est.) | Unquantified — no risk gating |
|
||||
| BFI-only (no BFLD) | Wi-BFI + threshold presence | ~0.82 (from LeakyBeam) | N/A | Angle matrices published |
|
||||
| BFI+CSI fusion (no BFLD) | Combined pipeline, ungated | ~0.97 (est.) | ~0.06 (est.) | Unquantified |
|
||||
| **BFLD (BFI+CSI, class 2)** | Full BFLD with anonymous privacy class | target 0.93 | target 0.10 | 0% (class 2 gate) |
|
||||
| BFLD (BFI-only, class 2) | BFLD without CSI input (AC7) | target 0.85 | target 0.12 | 0% (class 2 gate) |
|
||||
|
||||
The BFLD privacy-class guarantee reduces the raw sensing accuracy by a small margin
|
||||
versus an ungated BFI+CSI pipeline (target F1 0.93 vs estimated 0.97). This is the
|
||||
explicit trade-off: identity safety for a modest utility cost.
|
||||
|
||||
---
|
||||
|
||||
## 5. Continuous Evaluation in CI
|
||||
|
||||
Three tests run on every PR that touches the BFLD crate:
|
||||
|
||||
1. **Deterministic hash test** (AC6): same input → same output across platforms.
|
||||
2. **Privacy-mode field suppression fuzz** (AC5): 1,000 random inputs → no identity
|
||||
fields in class-2 output.
|
||||
3. **Latency smoke test** (AC2): 100-frame replay → first presence event < 200 ms
|
||||
(tighter than the 1s AC target, to keep CI fast).
|
||||
@@ -1,214 +0,0 @@
|
||||
# ADR-118: BFLD — Beamforming Feedback Layer for Detection
|
||||
|
||||
> This file is a draft. When approved, copy to:
|
||||
> `docs/adr/ADR-118-bfld-beamforming-feedback-layer-for-detection.md`
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Proposed |
|
||||
| **Date** | 2026-05-24 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codename** | **BFLD** — Beamforming Feedback Layer for Detection |
|
||||
| **Relates to** | [ADR-024](ADR-024-contrastive-csi-embedding-model.md) (AETHER contrastive embedding), [ADR-027](ADR-027-cross-environment-domain-generalization.md) (MERIDIAN cross-environment), [ADR-028](ADR-028-esp32-capability-audit.md) (capability audit / witness), [ADR-029](ADR-029-ruvsense-multistatic-sensing-mode.md) (RuvSense multistatic), [ADR-030](ADR-030-ruvsense-persistent-field-model.md) (persistent field model), [ADR-031](ADR-031-ruview-sensing-first-rf-mode.md) (sensing-first RF mode), [ADR-032](ADR-032-multistatic-mesh-security-hardening.md) (mesh security hardening), [ADR-095](ADR-095-rvcsi-edge-rf-sensing-platform.md) (rvCSI platform), [ADR-115](ADR-115-home-assistant-integration.md) (HA integration), [ADR-116](ADR-116-cog-ha-matter-seed.md) (Matter seed packaging), [ADR-117](ADR-117-pip-wifi-densepose-modernization.md) (pip modernization) |
|
||||
| **Tracking issue** | TBD |
|
||||
|
||||
---
|
||||
|
||||
## 1. Context
|
||||
|
||||
### 1.1 The Plaintext BFI Problem
|
||||
|
||||
IEEE 802.11ac and 802.11ax beamforming feedback information (BFI) is exchanged between
|
||||
client stations (STA) and access points (AP) in unencrypted management-plane frames.
|
||||
The STA compresses the channel response into a matrix of Givens rotation angles (Phi/Psi)
|
||||
and transmits them in a VHT/HE Compressed Beamforming Report (CBFR) frame. These frames
|
||||
are passively sniffable by any device in WiFi monitor mode without any access to the
|
||||
target network.
|
||||
|
||||
Two independent 2024–2025 research papers establish the severity of this exposure:
|
||||
|
||||
1. **BFId** (Todt, Morsbach, Strufe; KIT; ACM CCS 2025,
|
||||
https://dl.acm.org/doi/10.1145/3719027.3765062): demonstrates re-identification of
|
||||
197 individuals using BFI alone, with >90% accuracy from 5 seconds of capture.
|
||||
2. **LeakyBeam** (Xiao et al.; Zhejiang U., NTU, KAIST; NDSS 2025,
|
||||
https://www.ndss-symposium.org/ndss-paper/lend-me-your-beam-privacy-implications-of-plaintext-beamforming-feedback-in-wifi/):
|
||||
demonstrates occupancy detection through walls at 20 m range using BFI, with 82.7%
|
||||
TPR and 96.7% TNR.
|
||||
|
||||
Tooling for passive BFI capture is freely available. Wi-BFI
|
||||
(https://arxiv.org/abs/2309.04408) is pip-installable and supports 802.11ac/ax,
|
||||
SU/MU-MIMO, 20/40/80/160 MHz channels.
|
||||
|
||||
### 1.2 Gap in Existing Pipeline
|
||||
|
||||
The wifi-densepose sensing pipeline processes CSI via the rvCSI runtime (ADR-095/096)
|
||||
and produces presence, pose, vitals, and zone-activity events. No layer explicitly
|
||||
measures whether the data being processed is capable of identifying specific individuals.
|
||||
The pipeline treats all CSI as equivalent from a privacy standpoint, regardless of
|
||||
whether it is operating in a high-separability (identity-leaky) or low-separability
|
||||
(anonymous) regime.
|
||||
|
||||
This gap becomes a compliance and liability issue as WiFi sensing deployments scale.
|
||||
An operator deploying this system in a care facility, hotel, or shared office has no
|
||||
instrument to verify that the system is operating anonymously.
|
||||
|
||||
### 1.3 The BFI Opportunity
|
||||
|
||||
BFI is not only a threat vector — it is a complementary sensing signal. Because BFI
|
||||
encodes the channel response as a structured compressed matrix, it carries multipath
|
||||
geometry that can augment CSI-based presence and motion detection, particularly in
|
||||
scenarios where only one AP is available (fewer antenna pairs than a full MIMO CSI
|
||||
capture). The BFLD design treats BFI as an optional input alongside CSI, not as a
|
||||
replacement.
|
||||
|
||||
---
|
||||
|
||||
## 2. Decision
|
||||
|
||||
We will create a new crate `wifi-densepose-bfld` (to live in `v2/crates/`) that:
|
||||
|
||||
1. **Ingests** raw BFI (Phi/Psi angle matrices from CBFR frames) as input and optionally
|
||||
fuses CSI when available.
|
||||
2. **Computes** nine named features and derives an `identity_risk_score` using a
|
||||
separability × temporal_stability × cross_perspective_consistency × sample_confidence
|
||||
formula.
|
||||
3. **Gates** all output through a `privacy_class` mechanism that structurally prevents
|
||||
identity-correlated data from being published at privacy classes 2 and 3.
|
||||
4. **Emits** `BfldEvent` structs on MQTT topics under `ruview/<node_id>/bfld/` with
|
||||
per-class topic routing.
|
||||
5. **Enforces** three invariants structurally (not by policy):
|
||||
- Raw BFI never exits the node.
|
||||
- Identity embedding is in-RAM-only.
|
||||
- Cross-site identity correlation is made cryptographically impossible via per-site
|
||||
keyed BLAKE3 hash rotation with a daily epoch.
|
||||
|
||||
The `BfldFrame` wire format carries magic `0xBF1D_0001`, a version byte, hashed AP/STA
|
||||
identifiers, a quantization byte, a privacy_class byte, compressed feature payload, and
|
||||
a CRC32.
|
||||
|
||||
Matter exposure is limited to: OccupancySensing (presence), MotionSensor (motion),
|
||||
PeopleCount (person_count). Identity fields are rejected at the Matter boundary in the
|
||||
`cog-ha-matter` crate.
|
||||
|
||||
---
|
||||
|
||||
## 3. Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- Operators gain an explicit, auditable measure of privacy compliance at the RF layer —
|
||||
the first such primitive in the wifi-densepose ecosystem.
|
||||
- The identity_risk_score doubles as an anomaly signal: unexpected spikes indicate
|
||||
environmental changes (new AP firmware, nearby attacker-grade sniffer, unusual
|
||||
propagation geometry) that warrant investigation.
|
||||
- BFI fusion augments presence and motion accuracy in single-AP deployments, partially
|
||||
compensating for lower CSI antenna counts.
|
||||
- The crate's deterministic frame hashes enable the ADR-028 witness-bundle pattern to
|
||||
extend to the new sensing surface, preserving the existing audit trail model.
|
||||
- Cross-site identity isolation is structural, not policy-dependent. This is a stronger
|
||||
guarantee than access-control rules.
|
||||
|
||||
### Negative
|
||||
|
||||
- BFI capture on ESP32-S3 hardware is not directly possible via the Espressif WiFi API.
|
||||
The full BFLD pipeline requires a Pi 5 / Nexmon host-side sniffer (cognitum-v0 is
|
||||
available for this purpose, but it adds a fleet dependency for the BFI path).
|
||||
- The identity_risk_score calibration (correlation with actual re-ID success rate)
|
||||
requires the BFId dataset, which requires non-commercial research agreement with KIT.
|
||||
- ~10.5 engineer-weeks of implementation effort.
|
||||
|
||||
### Neutral
|
||||
|
||||
- BFLD does not prevent passive BFI capture by an external attacker (A1 / LeakyBeam
|
||||
threat). It only ensures the node's own output is non-identifying. Operators should
|
||||
be informed of this distinction.
|
||||
- The daily hash rotation means that occupant-counting analytics that span multiple
|
||||
days cannot correlate individual signatures across the day boundary. This is a privacy
|
||||
benefit that some analytics use-cases may find inconvenient.
|
||||
|
||||
---
|
||||
|
||||
## 4. Alternatives Considered
|
||||
|
||||
### Alt 1: Skip BFI entirely, CSI-only pipeline
|
||||
|
||||
The rvCSI pipeline (ADR-095/096) already handles CSI without BFI. This alternative
|
||||
requires no new crate and no change to the ESP32 firmware.
|
||||
|
||||
**Rejected because**: (a) it leaves the identity-leakage detection gap open for the
|
||||
existing CSI pipeline, and (b) as BFI capture tooling becomes more widespread (Wi-BFI,
|
||||
PicoScenes), the absence of a privacy layer becomes more conspicuous for operators.
|
||||
|
||||
### Alt 2: Publish identity_risk_score publicly (default-on)
|
||||
|
||||
Treat the risk score as a diagnostic metric that operators and the public can observe.
|
||||
|
||||
**Rejected because**: the risk score is itself a privacy-sensitive signal (it reveals
|
||||
when a specific person is present via timing correlation). The default should be
|
||||
opt-in, with the operator explicitly acknowledging the trade-off.
|
||||
|
||||
### Alt 3: Use raw BFI in cloud ML training
|
||||
|
||||
Send raw BFI angle matrices to a cloud training service to improve model quality.
|
||||
|
||||
**Rejected because**: this violates Invariant 1. Cloud training on raw BFI would
|
||||
create an off-node store of angle matrices that could be reconstructed into identity
|
||||
profiles. The on-device-only constraint is not negotiable.
|
||||
|
||||
### Alt 4: Differential privacy noise injection on BFI before any processing
|
||||
|
||||
Add calibrated Laplace/Gaussian noise to the angle matrices at ingress to provide
|
||||
epsilon-differential privacy on all downstream computations.
|
||||
|
||||
**Rejected for this ADR** (noted as future extension): DP noise calibration requires
|
||||
sensitivity analysis that is not yet complete, and the interaction between DP noise
|
||||
and the identity_risk_score formula requires separate validation. The current design
|
||||
achieves privacy through structural impossibility (local-only, hash rotation) rather
|
||||
than noise injection.
|
||||
|
||||
---
|
||||
|
||||
## 5. Acceptance Criteria
|
||||
|
||||
- [ ] **AC1**: The extractor parses BFI from commodity WiFi 5 (802.11ac) and WiFi 6
|
||||
(802.11ax) captures, supporting 20/40/80/160 MHz channel bandwidth and 2×2 through
|
||||
4×4 MIMO configurations.
|
||||
- [ ] **AC2**: Presence detection latency is ≤ 1s p95 from the first non-empty BFI
|
||||
frame in a new occupancy event.
|
||||
- [ ] **AC3**: Motion score is published at ≥ 1 Hz on the `ruview/<node_id>/bfld/motion/state`
|
||||
MQTT topic during sustained occupancy.
|
||||
- [ ] **AC4**: Raw BFI bytes (Phi/Psi angle matrices) are never present in any
|
||||
serialized `BfldFrame` payload at any `privacy_class` value.
|
||||
- [ ] **AC5**: When `privacy_mode` is enabled, all identity-derived fields
|
||||
(`identity_risk_score`, `rf_signature_hash`, `identity_embedding`) are absent from
|
||||
all outbound events.
|
||||
- [ ] **AC6**: Given identical `BfiCapture` inputs, the `BfldFrame` serialization
|
||||
produces bit-identical output (deterministic hash) across runs and across platforms.
|
||||
- [ ] **AC7**: The pipeline produces valid `BfldEvent` outputs when `csi_matrix` is
|
||||
absent (BFI-only mode), without panic or degraded presence/motion reporting beyond
|
||||
the documented accuracy bounds.
|
||||
|
||||
---
|
||||
|
||||
## 6. Related ADRs
|
||||
|
||||
- **ADR-024**: AETHER contrastive CSI embedding — BFLD reuses the AETHER embedding
|
||||
infrastructure for identity_risk computation.
|
||||
- **ADR-027**: MERIDIAN cross-environment — BFLD's cross-site isolation instantiates
|
||||
the "no cross-site correlation" assumption that MERIDIAN requires.
|
||||
- **ADR-028**: Witness verification — BFLD extends the deterministic proof pattern.
|
||||
- **ADR-029**: RuvSense multistatic — BFLD uses `multistatic.rs` for
|
||||
cross_perspective_consistency.
|
||||
- **ADR-030**: Persistent field model — BFLD uses `cross_room.rs` for
|
||||
environment fingerprinting in the hash rotation.
|
||||
- **ADR-031**: Sensing-first RF mode — BFLD is a new sensing primitive alongside
|
||||
the CSI-based sensing.
|
||||
- **ADR-032**: Mesh security hardening — BFLD's threat model is a superset.
|
||||
- **ADR-095/096**: rvCSI platform — BFLD shares the BFI capture path with rvCSI's
|
||||
Nexmon adapter.
|
||||
- **ADR-115**: HA integration — BFLD extends the 21-entity HA surface with 6 new
|
||||
entities.
|
||||
- **ADR-116**: Matter seed packaging — BFLD's Matter boundary filter is implemented
|
||||
in `cog-ha-matter`.
|
||||
- **ADR-117**: pip modernization — BFLD's Python bindings (PyO3) will follow the
|
||||
pattern established in ADR-117.
|
||||
@@ -1,111 +0,0 @@
|
||||
# GitHub Issue Draft
|
||||
|
||||
**Title**: feat: BFLD — Beamforming Feedback Layer for Detection (privacy-gated WiFi sensing)
|
||||
|
||||
**Labels**: `enhancement`, `privacy`, `security`, `area/signal`, `area/firmware`
|
||||
|
||||
**Milestone**: (TBD — suggest: v0.8.0)
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Add a new crate `wifi-densepose-bfld` that turns raw 802.11 Beamforming Feedback
|
||||
Information (BFI) into bounded, privacy-gated sensing outputs. BFLD detects when RF
|
||||
data crosses from "ambient sensing" into "identity record" and structurally prevents
|
||||
identity-correlated data from leaving the node.
|
||||
|
||||
This is the safety layer that was missing from the CSI pipeline. As passive BFI sniffing
|
||||
tools (Wi-BFI, PicoScenes) become widely available and academic attacks (BFId at ACM CCS
|
||||
2025, LeakyBeam at NDSS 2025) demonstrate >90% re-identification from commodity WiFi,
|
||||
the wifi-densepose ecosystem needs an explicit privacy layer before scaling deployment.
|
||||
|
||||
## Motivation
|
||||
|
||||
1. **BFI is plaintext and passively sniffable.** IEEE 802.11ac/ax CBFR frames are
|
||||
transmitted before WPA2/WPA3 encryption is applied. Any nearby device in monitor mode
|
||||
can capture them (NDSS 2025: https://www.ndss-symposium.org/ndss-paper/lend-me-your-beam-privacy-implications-of-plaintext-beamforming-feedback-in-wifi/).
|
||||
|
||||
2. **BFI enables re-identification.** The KIT BFId paper (ACM CCS 2025:
|
||||
https://dl.acm.org/doi/10.1145/3719027.3765062) demonstrates >90% identity
|
||||
recognition from 5 seconds of BFI, from a dataset of 197 individuals, using only
|
||||
the Phi/Psi Givens rotation angles.
|
||||
|
||||
3. **The existing pipeline has no identity-leakage measurement.** The rvCSI pipeline
|
||||
produces presence/motion/pose events without any indication of whether those outputs
|
||||
were derived from identity-discriminative data. An operator deploying in a care
|
||||
facility or shared office has no way to verify the system is behaving anonymously.
|
||||
|
||||
4. **WiFi 7 will make this worse.** 802.11be (Wi-Fi 7) multi-link operation increases
|
||||
sounding frequency 3–5×. The attack surface is not static.
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
New crate at `v2/crates/wifi-densepose-bfld/` with the following pipeline:
|
||||
|
||||
```
|
||||
BFI capture (CBFR frames, Pi 5 / Nexmon monitor mode)
|
||||
→ BFI extractor (Phi/Psi parser, 802.11ac/ax)
|
||||
→ Normalization + temporal windowing
|
||||
→ Feature extraction (9 named features)
|
||||
→ Identity risk engine (in-RAM embeddings, coherence gate)
|
||||
→ Privacy gate (privacy_class byte, field masking)
|
||||
→ MQTT emitter (per-class topic routing)
|
||||
```
|
||||
|
||||
Three structural invariants (not configurable, not policy):
|
||||
1. Raw BFI never leaves the node.
|
||||
2. Identity embedding is in-RAM-only (VecDeque, never persisted).
|
||||
3. Cross-site identity matching is cryptographically impossible via per-site BLAKE3
|
||||
keyed hash with daily rotation.
|
||||
|
||||
Output events published on `ruview/<node_id>/bfld/{presence,motion,person_count,...}/state`.
|
||||
|
||||
Matter and HA expose only: presence, motion, person_count. Identity fields are rejected
|
||||
at both boundaries.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] **AC1**: Parser handles 802.11ac VHT and 802.11ax HE CBFR frames at 20/40/80/160 MHz,
|
||||
2×2 through 4×4 MIMO.
|
||||
- [ ] **AC2**: Presence detection latency ≤ 1s p95 from first non-empty BFI frame in
|
||||
a new occupancy event.
|
||||
- [ ] **AC3**: Motion score published at ≥ 1 Hz on `ruview/<node_id>/bfld/motion/state`
|
||||
during sustained occupancy.
|
||||
- [ ] **AC4**: Raw BFI bytes (Phi/Psi angle matrices) are never present in any
|
||||
serialized output at any `privacy_class` value.
|
||||
- [ ] **AC5**: Privacy mode suppresses all identity-derived fields (`identity_risk_score`,
|
||||
`rf_signature_hash`, `identity_embedding`) from all outbound events.
|
||||
- [ ] **AC6**: Identical `BfiCapture` input → bit-identical `BfldFrame` output
|
||||
(deterministic, cross-platform).
|
||||
- [ ] **AC7**: Pipeline produces valid `BfldEvent` with `csi_matrix = None` (BFI-only
|
||||
mode), without panic or significant accuracy degradation.
|
||||
|
||||
## References
|
||||
|
||||
- BFId paper: https://dl.acm.org/doi/10.1145/3719027.3765062
|
||||
- KIT BFId dataset: https://ps.tm.kit.edu/english/bfid-dataset/index.php
|
||||
- LeakyBeam (NDSS 2025): https://www.ndss-symposium.org/ndss-paper/lend-me-your-beam-privacy-implications-of-plaintext-beamforming-feedback-in-wifi/
|
||||
- Wi-BFI tool: https://arxiv.org/abs/2309.04408
|
||||
- Protecting activity signatures in CSI feedback: https://arxiv.org/pdf/2512.18529
|
||||
- Research bundle: `docs/research/BFLD/` (this repo)
|
||||
- Draft ADR: `docs/research/BFLD/08-adr-draft.md` → ADR-118
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Preventing passive BFI capture by external attackers (hardware-level problem, not
|
||||
software).
|
||||
- Differential privacy noise injection (noted as future extension in ADR-118).
|
||||
- Federated identity learning (local-only is sufficient for the current use case).
|
||||
- BFI capture directly from ESP32-S3 firmware (Espressif API does not expose CBFR;
|
||||
host-side Pi 5 / Nexmon capture is the implementation path).
|
||||
- WiFi 7 / 802.11be multi-link BFI (frame format versioning accommodates it; not
|
||||
in scope for v1 implementation).
|
||||
|
||||
## Related Issues / PRs
|
||||
|
||||
- ADR-028 witness bundle (ref: this repo's `docs/WITNESS-LOG-028.md`)
|
||||
- ADR-115 HA integration (21 entities — BFLD adds 6 more)
|
||||
- ADR-116 Matter seed packaging (`cog-ha-matter` crate needs Matter boundary update)
|
||||
- ADR-117 pip modernization (PyO3 pattern reused for BFLD Python bindings)
|
||||
- rvCSI platform (ADR-095/096) — Nexmon adapter shared with BFLD BFI capture path
|
||||
@@ -1,136 +0,0 @@
|
||||
# BFLD: The Privacy Layer Your WiFi Sensing Stack Has Been Missing
|
||||
|
||||
Your WiFi router is broadcasting your identity in plaintext. Here is the layer that
|
||||
catches it.
|
||||
|
||||
---
|
||||
|
||||
## The Problem
|
||||
|
||||
Every time your phone or laptop connects to a WiFi 5 or WiFi 6 router, it periodically
|
||||
transmits a Beamforming Feedback Report (CBFR frame). This frame contains the compressed
|
||||
channel matrix the router needs to aim its antennas at your device. The compression uses
|
||||
Givens rotations — a pair of angles (Phi and Psi) per active subcarrier — that encode
|
||||
the spatial geometry of the wireless channel around your body.
|
||||
|
||||
Here is the catch: these frames are transmitted before WPA2/WPA3 encryption is applied.
|
||||
They are plaintext management frames, passively readable by any WiFi adapter in monitor
|
||||
mode within roughly 20 meters.
|
||||
|
||||
Two papers published in 2024–2025 confirm the threat is real:
|
||||
|
||||
- **BFId** (KIT, ACM CCS 2025): re-identifies 197 people from beamforming feedback alone,
|
||||
>90% accuracy from just 5 seconds of capture. Tools needed: a WiFi adapter, a pip
|
||||
install, and no access to the target network.
|
||||
(https://dl.acm.org/doi/10.1145/3719027.3765062)
|
||||
|
||||
- **LeakyBeam** (Zhejiang U. / NTU / KAIST, NDSS 2025): detects occupancy through walls
|
||||
at 20 m range using beamforming feedback with 82.7% accuracy.
|
||||
(https://www.ndss-symposium.org/ndss-paper/lend-me-your-beam-privacy-implications-of-plaintext-beamforming-feedback-in-wifi/)
|
||||
|
||||
WiFi sensing systems — including this project — process these same signals to detect
|
||||
presence, count people, and track motion. Without a privacy layer, there is no way to
|
||||
know whether the sensing output is derived from anonymizable motion data or from
|
||||
identity-discriminative data.
|
||||
|
||||
---
|
||||
|
||||
## What BFLD Does
|
||||
|
||||
BFLD (Beamforming Feedback Layer for Detection) is a new Rust crate in the
|
||||
wifi-densepose workspace that adds one thing: an explicit, continuous measurement of
|
||||
whether the beamforming data currently being processed is capable of identifying
|
||||
individuals.
|
||||
|
||||
It outputs a small, structured event on every sensing cycle:
|
||||
|
||||
```json
|
||||
{
|
||||
"timestamp_ns": 1748092800000000000,
|
||||
"presence": true,
|
||||
"motion": 0.42,
|
||||
"person_count": 1,
|
||||
"identity_risk_score": 0.71,
|
||||
"rf_signature_hash": "a3f2c1...e9b4",
|
||||
"zone_id": "living_room",
|
||||
"confidence": 0.88,
|
||||
"privacy_class": 1
|
||||
}
|
||||
```
|
||||
|
||||
High `identity_risk_score` (approaching 1.0) means the current sensing environment is
|
||||
producing data from which an attacker could re-identify individuals. Low score means
|
||||
the data is effectively anonymous.
|
||||
|
||||
The score is computed from four components: how separable the current RF embedding is
|
||||
from a population distribution, how stable that separability is over time, how
|
||||
consistent it is across multiple sensor viewpoints, and how confident the current sample
|
||||
is. Multiply them together, clamp to [0, 1].
|
||||
|
||||
---
|
||||
|
||||
## Three Invariants That Cannot Be Turned Off
|
||||
|
||||
BFLD enforces three properties structurally — not as settings, not as policies:
|
||||
|
||||
**1. Raw BFI never leaves the node.** The Phi/Psi angle matrices are consumed locally
|
||||
and dropped after feature extraction. They are not in the wire format. They are not in
|
||||
the MQTT payload. There is no code path to serialize them outbound.
|
||||
|
||||
**2. Identity embeddings are RAM-only.** The vector embedding used to compute the risk
|
||||
score lives in a fixed-size ring buffer (default: 10 minutes). It is never written to
|
||||
disk. When the node restarts, the buffer is gone.
|
||||
|
||||
**3. Cross-site re-identification is cryptographically impossible.** The
|
||||
`rf_signature_hash` is computed with a per-site secret key (generated at first boot,
|
||||
stored in local NVS, never transmitted) and a per-day epoch. Two nodes at two
|
||||
different sites, even receiving signals from the same person on the same day, produce
|
||||
hash values in completely disjoint hash spaces. No amount of hash-list comparison can
|
||||
reveal a cross-site visit.
|
||||
|
||||
---
|
||||
|
||||
## What Reaches Home Assistant and Matter
|
||||
|
||||
BFLD publishes to MQTT and HA. The following entities reach HA:
|
||||
|
||||
- `binary_sensor.bfld_presence`
|
||||
- `sensor.bfld_motion`
|
||||
- `sensor.bfld_person_count`
|
||||
- `sensor.bfld_confidence`
|
||||
|
||||
The Matter bridge exposes only OccupancySensing (presence) and motion. Identity risk
|
||||
score, rf_signature_hash, and all raw fields are rejected at both the HA and Matter
|
||||
boundaries.
|
||||
|
||||
---
|
||||
|
||||
## Seven Acceptance Criteria
|
||||
|
||||
The implementation is done when these seven tests pass:
|
||||
|
||||
1. Parse 802.11ac and 802.11ax BFI at 20–160 MHz bandwidth, 2×2 to 4×4 MIMO.
|
||||
2. Presence latency ≤ 1 second p95.
|
||||
3. Motion published at ≥ 1 Hz.
|
||||
4. Raw BFI bytes absent from all output (verified by fuzz test).
|
||||
5. Privacy mode suppresses all identity fields.
|
||||
6. Identical input → identical output hash (cross-platform determinism).
|
||||
7. Pipeline runs without CSI input (BFI-only mode).
|
||||
|
||||
---
|
||||
|
||||
## BFLD Is an Immune System, Not a Surveillance Lens
|
||||
|
||||
The framing matters. BFLD does not produce identity — it measures identity risk and
|
||||
uses that measurement to gate what leaves the node. An immune system does not broadcast
|
||||
the identity of pathogens it encounters; it classifies, responds locally, and keeps
|
||||
detailed records inside the organism.
|
||||
|
||||
WiFi 7 / 802.11be is deploying now. Multi-link operation will increase beamforming
|
||||
sounding frequency 3–5x. The passive attack surface will grow. The time to establish
|
||||
safe defaults in WiFi sensing stacks is before that installed base is in place.
|
||||
|
||||
BFLD is that default.
|
||||
|
||||
Full research bundle: `docs/research/BFLD/` in the wifi-densepose repository.
|
||||
Draft ADR: `docs/research/BFLD/08-adr-draft.md` (ADR-118).
|
||||
@@ -1,58 +0,0 @@
|
||||
# BFLD Research Bundle — Beamforming Feedback Layer for Detection
|
||||
|
||||
BFLD is the safety layer that detects when RF data becomes identifying. It sits between
|
||||
raw 802.11 beamforming feedback (BFI) and every downstream consumer — home automation,
|
||||
MQTT, Matter, cloud — measuring the identity-leakage potential of each frame and gating
|
||||
what leaves the node. It does not produce identity; it guards against accidental or
|
||||
adversarial exposure of identity.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| [01-sota-survey.md](01-sota-survey.md) | State-of-the-art literature: BFI vs CSI, attack tooling, identity-inference research, privacy-preserving techniques |
|
||||
| [02-soul.md](02-soul.md) | Architectural intent, ethical stance, three non-negotiable invariants |
|
||||
| [03-security-threat-model.md](03-security-threat-model.md) | Adversary classes, attack trees, mitigations, trust-boundary diagram, per-privacy-class analysis |
|
||||
| [04-privacy-gating.md](04-privacy-gating.md) | privacy_class byte semantics, hash rotation algorithm, embedding lifecycle, wire-format diffs |
|
||||
| [05-automation-integration.md](05-automation-integration.md) | Home Assistant entities, Matter clusters, MQTT ACLs, cognitum federation |
|
||||
| [06-implementation-plan.md](06-implementation-plan.md) | New crate layout, reuse map, ESP32 additions, test plan, phased rollout |
|
||||
| [07-benchmarks-and-evaluation.md](07-benchmarks-and-evaluation.md) | Datasets, metrics, red-team protocol, comparison baselines |
|
||||
| [08-adr-draft.md](08-adr-draft.md) | Draft ADR-118 for formal project adoption |
|
||||
| [09-github-issue.md](09-github-issue.md) | GitHub issue draft for tracking implementation |
|
||||
| [10-gist.md](10-gist.md) | Public-facing one-pager / blog summary |
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
1. **Problem.** IEEE 802.11ac/ax beamforming feedback (BFI) — the compressed angle matrices
|
||||
(Phi/Psi, Givens rotation) exchanged between client and AP — is transmitted unencrypted
|
||||
on the management plane. Academic work (BFId at ACM CCS 2025, LeakyBeam at NDSS 2025)
|
||||
demonstrates that a passive sniffer with commodity hardware can re-identify individuals
|
||||
and infer occupancy through walls using only these frames. Existing CSI-based sensing
|
||||
pipelines have no explicit layer to detect when their output crosses from "motion event"
|
||||
into "identity record."
|
||||
|
||||
2. **Approach.** BFLD is a new crate (`wifi-densepose-bfld`) that wraps the BFI extraction
|
||||
and normalization path in an identity-leakage estimator. Every output frame carries a
|
||||
computed `identity_risk_score` and a `privacy_class` byte; downstream consumers decide
|
||||
whether to act based on those tags rather than on raw measurements.
|
||||
|
||||
3. **Novel contribution.** BFLD does not try to suppress identity inference — it tries to
|
||||
*measure* it continuously and make the measurement explicit in every event. This
|
||||
transforms a latent, silent risk into an observable, auditable signal. The combination
|
||||
of per-day per-site hash rotation and a local-only identity embedding creates structural
|
||||
impossibility of cross-site re-identification — not merely a policy promise.
|
||||
|
||||
4. **Security posture.** Raw BFI never leaves the node. Identity embeddings live only in
|
||||
an in-RAM ring buffer. The rf_signature_hash rotates daily using a per-site blake3
|
||||
keyed-hash that is never transmitted. Matter and HA expose only presence, motion, and
|
||||
person_count — never risk scores or embeddings.
|
||||
|
||||
5. **Integration plan.** Six phases: P1 frame format + extractor stub, P2 feature
|
||||
extraction + identity_risk, P3 privacy gate + MQTT, P4 HA integration, P5 Matter
|
||||
exposure, P6 cognitum federation. Each phase maps to a numbered acceptance criterion.
|
||||
The crate slots into the existing workspace between `wifi-densepose-signal` and
|
||||
`wifi-densepose-sensing-server`.
|
||||
@@ -1,113 +0,0 @@
|
||||
# rvAgent + RVF integration for agentic flows in RuView
|
||||
|
||||
**Status**: Research (Exploration) — Pre-Proposal
|
||||
**Date**: 2026-05-24
|
||||
**Author**: ruv
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
`vendor/ruvector/crates/rvAgent/` ships a production-grade Rust AI-agent framework with eight composable crates (`rvagent-core`, `-middleware`, `-tools`, `-subagents`, `-backends`, `-a2a`, `-acp`, `-mcp`, `-cli`). The framework already speaks **RVF cognitive containers** as its native state-persistence and inter-agent transport. RuView already uses RVF in `v2/crates/wifi-densepose-sensing-server/src/rvf_container.rs`.
|
||||
|
||||
**Integration thesis**: the two systems share a serialization substrate. Wiring `rvAgent` swarms into RuView turns the existing sensing pipeline into the substrate that an agentic flow can read from, reason about, and respond to — without writing a new agent runtime.
|
||||
|
||||
Concrete value:
|
||||
|
||||
1. **Operator-facing agents** that interpret BFLD / pose / vitals events live ("the kitchen has had no presence for 6 h but the kettle stayed on — page the carer").
|
||||
2. **In-process subagent coordination** for the multi-cog Cognitum Seed appliance — `cog-pose-estimation`, `cog-person-count`, `cog-ha-matter`, and the new BFLD pipeline can negotiate via rvAgent's CRDT state merging instead of ad-hoc IPC.
|
||||
3. **Witness chains** (ADR-028 / ADR-110) get an upstream consumer — rvAgent's audit-trail middleware persists per-decision attestations into the same RVF container an operator already verifies.
|
||||
4. **Local SONA learning** — rvAgent's 3-loop adaptive learning slots in alongside the per-home RuVector thresholds already proposed in ADR-116, with the same in-RAM-only privacy posture BFLD enforces (ADR-118 I2).
|
||||
|
||||
---
|
||||
|
||||
## 1. What rvAgent ships
|
||||
|
||||
| Crate | Role | Key types |
|
||||
|-------|------|-----------|
|
||||
| `rvagent-core` | State machine + COW state cloning + budget tracking | `AgentState`, `Message`, `AgiContainer`, `Arena`, `Budget`, `Graph` |
|
||||
| `rvagent-middleware` | 14 built-in middlewares (security, witness, sanitizer, sona, hnsw) | `PipelineConfig`, `build_default_pipeline()` |
|
||||
| `rvagent-tools` | Tool definitions + dispatch | `Tool`, `ToolInput`, `ToolOutput` |
|
||||
| `rvagent-subagents` | Spawn isolated subagents with O(1) state clone | `Subagent`, CRDT merge |
|
||||
| `rvagent-backends` | LLM provider abstraction (Anthropic, OpenAI, local) | `Backend` trait |
|
||||
| `rvagent-mcp` | MCP server integration | MCP-style tool registry |
|
||||
| `rvagent-a2a` / `-acp` | Agent-to-agent transport, agent communication protocol | wire format |
|
||||
| `rvagent-cli` | Operator CLI | argv parsing |
|
||||
|
||||
Selling points relevant to RuView:
|
||||
|
||||
- **O(1) state cloning via `Arc`** → can spawn one subagent per sensing zone without copying gigabytes of context.
|
||||
- **Parallel tool execution** → multiple sensor queries (BFLD presence, vitals BPM, pose) issued in parallel from one rvAgent decision step.
|
||||
- **Path confinement + env-var sanitization** → operator-facing agents that touch the host filesystem (e.g., reading `data/recordings/`) stay sandboxed.
|
||||
- **Witness chains** in `rvagent-middleware::witness` → already RVF-formatted; round-trips cleanly with ADR-028.
|
||||
|
||||
## 2. What RVF already does in RuView
|
||||
|
||||
`v2/crates/wifi-densepose-sensing-server/src/rvf_container.rs` defines the on-disk container format used for:
|
||||
|
||||
- ADR-110 witness attestations (`SEG_MANIFEST`, `SEG_META`).
|
||||
- Soul Signature graphs (`docs/research/soul/specification.md` §3).
|
||||
- BFLD class-1 (derived) frames once the operator opts into research mode (ADR-118 §1.4).
|
||||
|
||||
Each RVF blob is content-addressed (BLAKE3 of the canonical byte representation) and carries a typed segment manifest. The format is intentionally extension-friendly — segment types are `u8` enums, new types can land without breaking older readers.
|
||||
|
||||
## 3. The integration surface
|
||||
|
||||
Three concrete touchpoints, each shippable independently.
|
||||
|
||||
### 3.1 RVF as the rvAgent ↔ RuView wire
|
||||
|
||||
rvAgent's `AgiContainer` (`rvagent-core/src/agi_container.rs`, 627 LOC) already produces RVF-compatible blobs as its persistent state format. RuView only needs to define **two segment types** in `rvf_container.rs`:
|
||||
|
||||
- `SEG_AGENT_STATE = 0x08` — serialized `rvagent_core::AgentState` (the cloned-on-write tree from `cow_state.rs`).
|
||||
- `SEG_DECISION = 0x09` — a single agent decision step: tool calls issued, outputs received, witness signature.
|
||||
|
||||
With these two segments, an rvAgent session and a RuView sensing session can interleave entries in the same RVF blob. The witness-bundle script (ADR-028) iterates segments by type, so it would attest both halves with one signing pass.
|
||||
|
||||
### 3.2 BFLD events as rvAgent tool inputs
|
||||
|
||||
`wifi-densepose-bfld::BfldEvent` (iter 13) is already JSON-serializable via `to_json()`. Wrapping it as an `rvagent_tools::ToolOutput` is a 20-line shim: the agent issues a `read_bfld_state()` tool, the runtime returns the latest event JSON, the agent reasons over it. The full event surface (presence/motion/count/identity_risk/zone_id) becomes available as agent context without any new IPC.
|
||||
|
||||
`BfldEvent → ToolOutput` mapping:
|
||||
```rust
|
||||
impl From<BfldEvent> for ToolOutput {
|
||||
fn from(e: BfldEvent) -> Self {
|
||||
ToolOutput::json(e.to_json().expect("BfldEvent JSON"))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 cog-* as rvAgent subagents
|
||||
|
||||
`cog-pose-estimation`, `cog-person-count`, `cog-ha-matter`, and (proposed) `cog-bfld` already share a packaging convention (ADR-100). Each cog can register as a subagent with rvAgent's hub: the cog implements the `Subagent` trait, exports its tool surface, and inherits the parent agent's CRDT state. The queen agent (`rvagent-queen.md` persona) routes operator queries across the cog mesh.
|
||||
|
||||
Concrete example:
|
||||
- Operator query: "is grandma awake yet?"
|
||||
- Queen agent fans out to: `cog-bfld` (presence in bedroom), `cog-quantum-vitals` (HR baseline shift), `cog-pose-estimation` (sitting/standing transition).
|
||||
- Each cog returns within budget; queen synthesizes the answer; witness chain logs the decision for compliance audit.
|
||||
|
||||
## 4. Open questions
|
||||
|
||||
1. **Workspace inclusion**: is `vendor/ruvector/crates/rvAgent/` already on the v2 workspace path, or does it need to be added as a path dep under `wifi-densepose-bfld` / a new `wifi-densepose-agent` crate?
|
||||
2. **Async runtime**: rvAgent backends are tokio-based. The BFLD `Publish` trait is intentionally sync (iter 22). A small adapter (sync `Publish` ↔ async `Backend`) probably belongs in a `wifi-densepose-agent` crate, not in BFLD itself.
|
||||
3. **Privacy class composition**: what's the rvAgent equivalent of BFLD's `PrivacyClass`? `rvagent-middleware::sanitizer` strips at the tool-output boundary; should it consume `PrivacyClass` from the originating BFLD event so the agent never even sees a class-3 identity field?
|
||||
4. **Soul Signature interaction**: rvAgent's `SoulMatchOracle` integration (ADR-121 §2.6) could be the bridge from the Soul Signature graph (`docs/research/soul/`) to the agent decision layer. Worth a dedicated sub-section.
|
||||
5. **MCP**: `rvagent-mcp` exposes tools to external MCP clients. Should the BFLD `BfldPipelineHandle::send` surface land as an MCP tool here, or stay private to in-process rvAgent flows?
|
||||
|
||||
## 5. Proposed next steps (decision deferred)
|
||||
|
||||
- **D1**: Open ADR-124 — "rvAgent + RVF integration for RuView agentic flows" — capturing the segment-type assignments, the cog-subagent contract, and the privacy-class composition rule.
|
||||
- **D2**: Scaffold `v2/crates/wifi-densepose-agent` with the sync ↔ async adapter and one example tool (`read_bfld_state`).
|
||||
- **D3**: Add `SEG_AGENT_STATE` and `SEG_DECISION` to `rvf_container.rs` as `#[cfg(feature = "agent")]` segments so the v0 ship doesn't pull rvAgent's transitive deps by default.
|
||||
- **D4**: Land a one-page demo in `examples/agent-bedroom-check/` showing the queen-agent flow end-to-end against the `BfldPipelineHandle`.
|
||||
|
||||
## 6. References
|
||||
|
||||
- rvAgent: `vendor/ruvector/crates/rvAgent/README.md`, `rvagent-core/src/agi_container.rs`, `rvagent-middleware/docs/UNICODE_SECURITY.md`
|
||||
- Agent personas: `vendor/ruvector/crates/rvAgent/.ruv/agents/{rvagent-coder,rvagent-queen,rvagent-tester,rvagent-security}.md`
|
||||
- RVF container: `v2/crates/wifi-densepose-sensing-server/src/rvf_container.rs`
|
||||
- ADR-028 (witness): `docs/adr/ADR-028-esp32-capability-audit.md`
|
||||
- ADR-100 (cog packaging), ADR-110 (witness chain), ADR-116 (cog-ha-matter)
|
||||
- ADR-118 (BFLD): `docs/adr/ADR-118-bfld-beamforming-feedback-layer-for-detection.md`
|
||||
- Soul Signature: `docs/research/soul/specification.md`
|
||||
- BFLD impl branch: `feat/adr-118-bfld-impl`, currently at iter 25 (`e8b4fdbc8`)
|
||||
@@ -772,112 +772,6 @@ Open `/var/run/ruview-matter.txt` for the Matter pairing QR / 11-digit setup cod
|
||||
|
||||
Detailed entity reference, blueprint catalog, troubleshooting recipe matrix: see [`docs/integrations/home-assistant.md`](integrations/home-assistant.md).
|
||||
|
||||
### BFLD — privacy-gated WiFi BFI sensing layer (ADR-118)
|
||||
|
||||
The `wifi-densepose-bfld` crate adds an explicit privacy-gating layer on top of the sensing pipeline. It ingests 802.11ac/ax Beamforming Feedback Information (BFI) and emits bounded, classified sensing events that HA / Matter / MQTT consumers can read **without** leaking identity-discriminative data.
|
||||
|
||||
Three structural invariants enforced by the type system:
|
||||
|
||||
- **I1** — Raw BFI never exits the node (`Sink` marker-trait hierarchy)
|
||||
- **I2** — Identity embedding is in-RAM-only (no `Serialize`/`Clone`/`Copy`; `Drop` zeroizes)
|
||||
- **I3** — Cross-site identity correlation is cryptographically impossible (per-site BLAKE3-keyed hash + daily epoch rotation)
|
||||
|
||||
#### Minimal operator quickstart
|
||||
|
||||
Two runnable examples ship with the crate:
|
||||
|
||||
```bash
|
||||
# In-process consumer: build pipeline, send one frame, print event JSON
|
||||
cargo run -p wifi-densepose-bfld --example bfld_minimal
|
||||
|
||||
# Worker thread + HA-DISCO: full publish lifecycle (availability + discovery + state + LWT)
|
||||
cargo run -p wifi-densepose-bfld --example bfld_handle
|
||||
```
|
||||
|
||||
#### Production publish lifecycle (HA-DISCO + MQTT)
|
||||
|
||||
```rust
|
||||
// Bootstrap (once at startup, retain=true messages):
|
||||
publish_availability_online(&mut retained_pub, "seed-01")?;
|
||||
publish_discovery(&mut retained_pub, "seed-01", PrivacyClass::Anonymous)?;
|
||||
|
||||
// Per-frame:
|
||||
let handle = BfldPipelineHandle::spawn(pipeline, state_pub);
|
||||
handle.send(PipelineInput { inputs, embedding })?;
|
||||
```
|
||||
|
||||
Six HA entities are auto-created per node (`binary_sensor.*_bfld_presence`, `sensor.*_bfld_motion`/`person_count`/`zone_activity`/`confidence`/`identity_risk`). The `identity_risk` entity is **only present at `PrivacyClass::Anonymous`**; class `Restricted` deployments (care homes, regulated environments) drop it entirely from both discovery and state topics.
|
||||
|
||||
#### Three operator HA blueprints
|
||||
|
||||
Under `v2/crates/cog-ha-matter/blueprints/bfld/`:
|
||||
|
||||
- `presence-lighting.yaml` — `binary_sensor.*_bfld_presence` ⇒ `light.turn_on/off` with configurable hold time
|
||||
- `motion-hvac.yaml` — `sensor.*_bfld_motion > threshold` ⇒ `climate.set_temperature` ΔT
|
||||
- `identity-risk-anomaly.yaml` — rolling 7-day z-score notification (requires HA Statistics helper)
|
||||
|
||||
Import via HA UI: Settings → Automations & Scenes → Blueprints → Import.
|
||||
|
||||
#### Privacy class deployment matrix
|
||||
|
||||
| Class | Identity fields | Use case |
|
||||
|-------|-----------------|----------|
|
||||
| `Raw` | full BFI matrix | local-only research (never networked) |
|
||||
| `Derived` | downsampled angles + risk score | operator-acknowledged LAN research mode |
|
||||
| `Anonymous` (default) | aggregate sensing only + risk score + rotating hash | production HA / Matter deployments |
|
||||
| `Restricted` | aggregate sensing only, identity fields stripped | care homes, GDPR/HIPAA-style regulated environments |
|
||||
|
||||
The `enable_privacy_mode()` runtime toggle on `BfldPipeline` engages `Restricted` from any baseline without restarting the pipeline — useful for security-incident response.
|
||||
|
||||
#### MQTT topic tree
|
||||
|
||||
```
|
||||
ruview/<node_id>/bfld/availability online / offline
|
||||
ruview/<node_id>/bfld/presence/state true / false
|
||||
ruview/<node_id>/bfld/motion/state 0.000000..1.000000
|
||||
ruview/<node_id>/bfld/person_count/state integer
|
||||
ruview/<node_id>/bfld/confidence/state 0.000000..1.000000
|
||||
ruview/<node_id>/bfld/zone_activity/state "<zone_name>" (if configured)
|
||||
ruview/<node_id>/bfld/identity_risk/state 0.000000..1.000000 (class 2 only)
|
||||
```
|
||||
|
||||
The `rumqttc 0.24` (`use-rustls`) backend ships behind the `mqtt` feature; `RumqttPublisher::connect_with_lwt(node_id, opts, capacity)` pre-configures the Last Will and Testament so the broker auto-publishes `"offline"` on session drop.
|
||||
|
||||
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).
|
||||
|
||||
### SENSE-BRIDGE — rvagent MCP server for AI agents (ADR-124)
|
||||
|
||||
`@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.
|
||||
|
||||
**Install (Claude Code)**:
|
||||
|
||||
```bash
|
||||
claude mcp add rvagent -- npx @ruvnet/rvagent stdio
|
||||
# With a remote sensing-server:
|
||||
RUVIEW_SENSING_SERVER_URL=http://cognitum-v0:3000 claude mcp add rvagent -- npx @ruvnet/rvagent stdio
|
||||
```
|
||||
|
||||
**Available tools (6 of 20 in v0.1.0)**:
|
||||
|
||||
| Tool | Returns |
|
||||
|------|---------|
|
||||
| `ruview.presence.now` | `present`, `n_persons`, `confidence`, `timestamp_ms` |
|
||||
| `ruview.vitals.get_breathing` | `breathing_rate_bpm` (null if unavailable), `confidence` |
|
||||
| `ruview.vitals.get_heart_rate` | `heartrate_bpm` (null if unavailable), `confidence` |
|
||||
| `ruview.vitals.get_all` | Full `EdgeVitalsMessage` (all vitals in one call) |
|
||||
| `ruview.bfld.last_scan` | `identity_risk_score`, `privacy_class`, `n_frames`, `timestamp_ms` |
|
||||
| `ruview.bfld.subscribe` | `subscription_id`, `expires_at`, `topic` (MQTT wildcard) |
|
||||
|
||||
**Streamable HTTP** (for remote ruflo swarms):
|
||||
|
||||
```bash
|
||||
RVAGENT_HTTP_TOKEN=secret npx @ruvnet/rvagent http --port 3001
|
||||
# POST JSON-RPC to http://127.0.0.1:3001/mcp
|
||||
# Cross-origin requests are rejected with 403; missing/wrong token → 401.
|
||||
```
|
||||
|
||||
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).
|
||||
|
||||
---
|
||||
|
||||
## Web UI
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ruview",
|
||||
"description": "End-to-end RuView (WiFi-DensePose) toolkit for Claude Code: onboarding, ESP32 hardware setup, configuration, sensing applications, model training, advanced multistatic sensing, witness verification, BFLD privacy layer, and rvAgent + RVF agentic flows — from practical to advanced.",
|
||||
"version": "0.3.0",
|
||||
"description": "End-to-end RuView (WiFi-DensePose) toolkit for Claude Code: onboarding, ESP32 hardware setup, configuration, sensing applications, model training, advanced multistatic sensing, and witness verification — from practical to advanced.",
|
||||
"version": "0.1.0",
|
||||
"author": {
|
||||
"name": "ruvnet",
|
||||
"url": "https://github.com/ruvnet/RuView"
|
||||
@@ -19,14 +19,5 @@
|
||||
"edge-ai",
|
||||
"model-training",
|
||||
"onboarding"
|
||||
],
|
||||
"mcpServers": {
|
||||
"rvagent": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@ruvnet/rvagent"],
|
||||
"env": {
|
||||
"RVAGENT_SENSING_URL": "http://localhost:3000"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -47,7 +47,6 @@ After significant changes: run the Rust tests + Python proof, then `bash scripts
|
||||
| `ruview-app` | Run a sensing application (presence / vitals / pose / sleep / MAT / point cloud) |
|
||||
| `ruview-train` | Train / evaluate / publish a model (incl. GPU on GCloud) |
|
||||
| `ruview-verify` | Run the trust pipeline + pre-merge checklist |
|
||||
| `ruview-rvagent` | Explore rvAgent + RVF agentic flows wiring into RuView |
|
||||
|
||||
Install: copy `codex/prompts/*.md` into `~/.codex/prompts/`, or run Codex with this directory on its prompt path.
|
||||
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
# ruview-rvagent — explore rvAgent + RVF agentic flows for RuView
|
||||
|
||||
You are helping the operator explore or prototype the integration of `vendor/ruvector/crates/rvAgent/` (a production Rust AI-agent framework) with RuView's existing sensing pipeline (`v2/crates/wifi-densepose-*`) and the RVF cognitive container format (`v2/crates/wifi-densepose-sensing-server/src/rvf_container.rs`).
|
||||
|
||||
## Live MCP server: `@ruvnet/rvagent` v0.1.0
|
||||
|
||||
The TypeScript MCP server (`tools/ruview-mcp/`, published as `@ruvnet/rvagent`) is live on npm and exposes `bfld_last_scan`, `bfld_subscribe`, `presence_now`, `vitals_get_breathing`, `vitals_get_heart_rate`, `vitals_get_all`, `vitals_fetch`. Add to a Codex MCP config:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"rvagent": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@ruvnet/rvagent"],
|
||||
"env": { "RVAGENT_SENSING_URL": "http://localhost:3000" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This is the operator-facing tool surface; the Rust crate below remains the substrate for deeper RVF-aware agentic flows.
|
||||
|
||||
## Trigger phrasing
|
||||
|
||||
- "wire rvAgent into RuView"
|
||||
- "I want a queen agent that fans out to cog-pose-estimation and cog-bfld"
|
||||
- "persist agent decisions in the same witness bundle as sensing events"
|
||||
- "how do I keep agent outputs class-3 compliant?"
|
||||
|
||||
## What to read first
|
||||
|
||||
1. `docs/research/rvagent-rvf-integration/README.md` — full integration thesis, open questions, next steps.
|
||||
2. `vendor/ruvector/crates/rvAgent/README.md` — what rvAgent ships (8 crates, 14 middlewares).
|
||||
3. `vendor/ruvector/crates/rvAgent/.ruv/agents/rvagent-queen.md` — queen-agent persona that coordinates cog subagents.
|
||||
4. `v2/crates/wifi-densepose-bfld/src/{event.rs,pipeline_handle.rs}` — the BFLD event surface and the operator-facing handle that an agent would call.
|
||||
5. `v2/crates/wifi-densepose-sensing-server/src/rvf_container.rs` — segment types; `SEG_AGENT_STATE = 0x08` and `SEG_DECISION = 0x09` are the proposed additions.
|
||||
|
||||
## Three shippable touchpoints (each independent)
|
||||
|
||||
1. **RVF wire** — add `SEG_AGENT_STATE` + `SEG_DECISION` segments so rvAgent and RuView sessions can interleave in one blob (witness-bundle covers both halves).
|
||||
2. **Tool shim** — `BfldEvent::to_json()` already exists; wrap as `rvagent_tools::ToolOutput`.
|
||||
3. **Cog subagents** — register `cog-pose-estimation`, `cog-person-count`, `cog-ha-matter`, (proposed) `cog-bfld` under the queen via the `Subagent` trait.
|
||||
|
||||
## Open questions to surface
|
||||
|
||||
- Is `vendor/ruvector/crates/rvAgent/` on the v2 workspace path?
|
||||
- Sync ↔ async adapter location (BFLD `Publish` is sync; rvAgent backends are tokio).
|
||||
- Privacy-class composition — does `rvagent-middleware::sanitizer` consume `BfldEvent::privacy_class`?
|
||||
- Soul Signature ↔ `SoulMatchOracle` bridge (ADR-121 §2.6).
|
||||
- Should `BfldPipelineHandle::send` land as a public MCP tool via `rvagent-mcp`?
|
||||
|
||||
## Suggested next action
|
||||
|
||||
Draft ADR-124 — "rvAgent + RVF integration for RuView agentic flows" — capturing segment assignments, cog-subagent contract, and privacy-class composition. Land **before** scaffolding `v2/crates/wifi-densepose-agent`.
|
||||
@@ -1,66 +0,0 @@
|
||||
---
|
||||
name: ruview-rvagent
|
||||
description: Explore and prototype rvAgent + RVF integration for RuView agentic flows. Use when working on cross-cog coordination, operator-facing agents reading BFLD / pose / vitals events live, or persisting agent state alongside sensing data in the same RVF container.
|
||||
---
|
||||
|
||||
# RuView rvAgent + RVF integration
|
||||
|
||||
Surface area for wiring `vendor/ruvector/crates/rvAgent/` into RuView so the existing sensing pipeline becomes the substrate an agentic flow can read, reason about, and respond to.
|
||||
|
||||
## Quickstart — published MCP server (`@ruvnet/rvagent` v0.1.0)
|
||||
|
||||
Installing this plugin registers `@ruvnet/rvagent` as an MCP server. On activation, Claude Code spawns `npx -y @ruvnet/rvagent` and exposes its tools directly:
|
||||
|
||||
| Tool | Purpose |
|
||||
|------|---------|
|
||||
| `bfld_last_scan` | Most recent BFLD event from the sensing server |
|
||||
| `bfld_subscribe` | Stream BFLD events for a window |
|
||||
| `presence_now` | Current room-level presence state |
|
||||
| `vitals_get_breathing` | Latest breathing-rate sample |
|
||||
| `vitals_get_heart_rate` | Latest heart-rate sample |
|
||||
| `vitals_get_all` | Composite vitals snapshot |
|
||||
| `vitals_fetch` | Historical vitals window |
|
||||
|
||||
Override the sensing-server URL via the `RVAGENT_SENSING_URL` env var (default `http://localhost:3000`). Source lives at `tools/ruview-mcp/`; ADR-124 captures the design.
|
||||
|
||||
Smoke-check the wiring: `npm view @ruvnet/rvagent version` should return `0.1.0` (or newer).
|
||||
|
||||
## When to use this skill
|
||||
|
||||
- "I want an agent that reacts to BFLD presence in the kitchen and pages the carer."
|
||||
- "I need cog-pose-estimation and cog-bfld to negotiate before publishing a synthesized event."
|
||||
- "Can the witness chain attest both the sensing event AND the agent decision in one RVF blob?"
|
||||
- "How do we keep rvAgent's tool outputs class-3 compliant when the source BFLD event is Restricted?"
|
||||
|
||||
## Key surfaces
|
||||
|
||||
| Surface | File | Notes |
|
||||
|---------|------|-------|
|
||||
| rvAgent core | `vendor/ruvector/crates/rvAgent/rvagent-core/src/agi_container.rs` (627 LOC) | RVF-compatible state container |
|
||||
| rvAgent middleware | `vendor/ruvector/crates/rvAgent/rvagent-middleware/` | Witness, sanitizer, SONA, HNSW |
|
||||
| Agent personas | `vendor/ruvector/crates/rvAgent/.ruv/agents/rvagent-{queen,coder,tester,security}.md` | Reference patterns |
|
||||
| RVF container | `v2/crates/wifi-densepose-sensing-server/src/rvf_container.rs` | Add `SEG_AGENT_STATE`, `SEG_DECISION` |
|
||||
| BFLD event | `v2/crates/wifi-densepose-bfld/src/event.rs` | `BfldEvent::to_json()` → `ToolOutput` |
|
||||
| BFLD pipeline handle | `v2/crates/wifi-densepose-bfld/src/pipeline_handle.rs` | `BfldPipelineHandle::send` |
|
||||
|
||||
## Research dossier
|
||||
|
||||
Full integration analysis lives at `docs/research/rvagent-rvf-integration/README.md`.
|
||||
|
||||
Three shippable touchpoints, each independent:
|
||||
|
||||
1. **RVF wire**: two new segment types (`SEG_AGENT_STATE = 0x08`, `SEG_DECISION = 0x09`) let rvAgent sessions interleave with RuView sensing sessions in the same blob.
|
||||
2. **Tool surface**: `BfldEvent → ToolOutput` shim turns BFLD events into agent context with no new IPC.
|
||||
3. **Cog subagents**: `cog-pose-estimation` / `cog-person-count` / `cog-ha-matter` / `cog-bfld` register as rvAgent subagents under a queen-agent router.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Workspace inclusion of `vendor/ruvector/crates/rvAgent/` (path dep vs published crate)
|
||||
- Sync ↔ async adapter (BFLD `Publish` is sync, rvAgent backends are tokio)
|
||||
- Privacy-class composition (does rvAgent's sanitizer consume `PrivacyClass`?)
|
||||
- Soul Signature ↔ `SoulMatchOracle` bridge
|
||||
- Whether `BfldPipelineHandle::send` lands as a public MCP tool via `rvagent-mcp`
|
||||
|
||||
## Next decision
|
||||
|
||||
ADR-124 (proposed) — "rvAgent + RVF integration for RuView agentic flows" — would capture segment assignments, cog-subagent contract, and the privacy-class composition rule. Land before scaffolding `v2/crates/wifi-densepose-agent`.
|
||||
@@ -128,39 +128,6 @@ for crate_dir in "$REPO_ROOT/v2/crates/"*/; do
|
||||
done
|
||||
cat "$BUNDLE_DIR/crate-manifest/versions.txt"
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 6b. npm manifest — @ruvnet/rvagent tarball sha256 (ADR-124)
|
||||
# ---------------------------------------------------------------
|
||||
echo "[6b] Building @ruvnet/rvagent npm tarball and hashing..."
|
||||
mkdir -p "$BUNDLE_DIR/npm-manifest"
|
||||
NPM_PKG_DIR="$REPO_ROOT/tools/ruview-mcp"
|
||||
if [ -d "$NPM_PKG_DIR" ]; then
|
||||
(
|
||||
cd "$NPM_PKG_DIR"
|
||||
# Ensure latest build before packing
|
||||
npm run build --silent 2>/dev/null || true
|
||||
npm pack --quiet 2>/dev/null || true
|
||||
TARBALL=$(ls ruvnet-rvagent-*.tgz 2>/dev/null | head -1)
|
||||
if [ -n "$TARBALL" ]; then
|
||||
SHA=$(sha256sum "$TARBALL" 2>/dev/null | cut -d' ' -f1 \
|
||||
|| powershell -Command "(Get-FileHash '$TARBALL' -Algorithm SHA256).Hash.ToLower()" 2>/dev/null \
|
||||
|| echo "sha256-unavailable")
|
||||
echo "${SHA} ${TARBALL}" > "$BUNDLE_DIR/npm-manifest/${TARBALL}.sha256"
|
||||
# Keep the version string for VERIFY.sh
|
||||
echo "$TARBALL" > "$BUNDLE_DIR/npm-manifest/tarball-name.txt"
|
||||
echo "$SHA" > "$BUNDLE_DIR/npm-manifest/tarball-sha256.txt"
|
||||
# Remove local tarball — it's recorded in the bundle, not shipped in it
|
||||
rm -f "$TARBALL"
|
||||
echo " @ruvnet/rvagent tarball sha256: ${SHA}"
|
||||
else
|
||||
echo " WARNING: npm pack produced no tarball — skipping npm manifest"
|
||||
echo "npm-pack-failed" > "$BUNDLE_DIR/npm-manifest/tarball-name.txt"
|
||||
fi
|
||||
)
|
||||
else
|
||||
echo " WARNING: tools/ruview-mcp not found — skipping npm manifest"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 7. Generate VERIFY.sh for recipients
|
||||
# ---------------------------------------------------------------
|
||||
@@ -229,21 +196,7 @@ else
|
||||
check "Crate manifest present" "FAIL"
|
||||
fi
|
||||
|
||||
# Check 6: npm tarball sha256 (ADR-124 SENSE-BRIDGE)
|
||||
if [ -f "npm-manifest/tarball-sha256.txt" ] && [ -f "npm-manifest/tarball-name.txt" ]; then
|
||||
EXPECTED_SHA=$(cat npm-manifest/tarball-sha256.txt)
|
||||
TARBALL_NAME=$(cat npm-manifest/tarball-name.txt)
|
||||
if [ "$EXPECTED_SHA" = "npm-pack-failed" ] || [ "$TARBALL_NAME" = "npm-pack-failed" ]; then
|
||||
check "npm tarball sha256 (@ruvnet/rvagent)" "FAIL"
|
||||
else
|
||||
check "npm manifest present (@ruvnet/rvagent ${TARBALL_NAME})" "PASS"
|
||||
echo " Recorded sha256: ${EXPECTED_SHA}"
|
||||
fi
|
||||
else
|
||||
check "npm manifest present (@ruvnet/rvagent)" "FAIL"
|
||||
fi
|
||||
|
||||
# Check 8: Proof verification log
|
||||
# Check 6: Proof verification log
|
||||
if [ -f "proof/verification-output.log" ]; then
|
||||
if grep -q "VERDICT: PASS" proof/verification-output.log; then
|
||||
check "Python proof verification PASS" "PASS"
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# rotate-npm-token.sh — push NPM_TOKEN from .env into GCP Secret Manager
|
||||
# and (optionally) publish @ruvnet/rvagent.
|
||||
#
|
||||
# Usage:
|
||||
# bash scripts/rotate-npm-token.sh # rotate only
|
||||
# bash scripts/rotate-npm-token.sh --publish # rotate + npm publish
|
||||
#
|
||||
# Env overrides:
|
||||
# GCP_PROJECT (default: cognitum-20260110)
|
||||
# NPM_TOKEN_SECRET (default: NPM_TOKEN)
|
||||
# ENV_FILE (default: <repo-root>/.env)
|
||||
# PUBLISH_PACKAGE_DIR (default: <repo-root>/tools/ruview-mcp)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
ENV_FILE="${ENV_FILE:-$REPO_ROOT/.env}"
|
||||
PROJECT="${GCP_PROJECT:-cognitum-20260110}"
|
||||
SECRET="${NPM_TOKEN_SECRET:-NPM_TOKEN}"
|
||||
PKG_DIR="${PUBLISH_PACKAGE_DIR:-$REPO_ROOT/tools/ruview-mcp}"
|
||||
|
||||
[ -f "$ENV_FILE" ] || { echo "ERROR: .env not found at $ENV_FILE" >&2; exit 1; }
|
||||
|
||||
TOKEN="$(awk -F= '
|
||||
/^[[:space:]]*NPM_TOKEN[[:space:]]*=/ {
|
||||
sub(/^[^=]*=[[:space:]]*/, "", $0)
|
||||
sub(/^["'\'']/, "", $0)
|
||||
sub(/["'\''][[:space:]]*$/, "", $0)
|
||||
sub(/[[:space:]]+$/, "", $0)
|
||||
print
|
||||
exit
|
||||
}
|
||||
' "$ENV_FILE")"
|
||||
|
||||
if [ -z "${TOKEN:-}" ]; then
|
||||
echo "ERROR: NPM_TOKEN not found in $ENV_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
LEN=${#TOKEN}
|
||||
echo "Found NPM_TOKEN in .env (length=$LEN)"
|
||||
|
||||
echo "Pushing new version to gcloud secret '$SECRET' in project '$PROJECT'..."
|
||||
if ! gcloud secrets describe "$SECRET" --project="$PROJECT" >/dev/null 2>&1; then
|
||||
echo "Secret '$SECRET' not found; creating..."
|
||||
printf '%s' "$TOKEN" | gcloud secrets create "$SECRET" \
|
||||
--project="$PROJECT" --replication-policy=automatic --data-file=-
|
||||
else
|
||||
printf '%s' "$TOKEN" | gcloud secrets versions add "$SECRET" \
|
||||
--project="$PROJECT" --data-file=-
|
||||
fi
|
||||
|
||||
echo "Verifying secret round-trips..."
|
||||
RETRIEVED="$(gcloud secrets versions access latest --secret="$SECRET" --project="$PROJECT")"
|
||||
if [ "$RETRIEVED" != "$TOKEN" ]; then
|
||||
echo "ERROR: retrieved token does not match the value written to .env" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "OK — secret '$SECRET' updated and verified (length=${#RETRIEVED})."
|
||||
|
||||
if [ "${1:-}" = "--publish" ]; then
|
||||
[ -d "$PKG_DIR" ] || { echo "ERROR: package dir not found at $PKG_DIR" >&2; exit 1; }
|
||||
echo "Publishing @ruvnet/rvagent from $PKG_DIR..."
|
||||
(
|
||||
cd "$PKG_DIR"
|
||||
if [ -f package.json ] && grep -q '"build"' package.json; then
|
||||
npm run build
|
||||
fi
|
||||
NODE_AUTH_TOKEN="$RETRIEVED" npm publish --access public
|
||||
)
|
||||
fi
|
||||
|
||||
echo "Done."
|
||||
@@ -1,65 +0,0 @@
|
||||
# @ruvnet/rvagent — SENSE-BRIDGE MCP Server
|
||||
|
||||
**SENSE-BRIDGE** is a dual-transport [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) server that bridges the RuView WiFi-DensePose sensing stack to AI agents (Claude Code, Cursor, ruflo swarms, and any MCP-compatible client).
|
||||
|
||||
Install once; AI agents can then call `ruview.presence.now`, `ruview.vitals.get_heart_rate`, `ruview.bfld.last_scan`, and more — without writing HTTP or WebSocket client code.
|
||||
|
||||
## Quickstart
|
||||
|
||||
```bash
|
||||
# 1. Add to Claude Code
|
||||
claude mcp add rvagent -- npx @ruvnet/rvagent stdio
|
||||
|
||||
# 2. Or run directly
|
||||
RUVIEW_SENSING_SERVER_URL=http://cognitum-v0:3000 npx @ruvnet/rvagent stdio
|
||||
|
||||
# 3. Streamable HTTP (remote agents, ruflo swarms)
|
||||
RUVIEW_SENSING_SERVER_URL=http://cognitum-v0:3000 \
|
||||
RVAGENT_HTTP_TOKEN=your-secret \
|
||||
npx @ruvnet/rvagent http --port 3001
|
||||
# POST JSON-RPC to http://127.0.0.1:3001/mcp
|
||||
```
|
||||
|
||||
Requirements: **Node.js >= 20**. The `wifi-densepose-sensing-server` Rust binary must be reachable at `RUVIEW_SENSING_SERVER_URL` (default `http://localhost:3000`).
|
||||
|
||||
## Feature matrix
|
||||
|
||||
| Tool | Description | ADR |
|
||||
|------|-------------|-----|
|
||||
| `ruview.presence.now` | Current occupancy: `present`, `n_persons`, `confidence` | ADR-124 §4.1 |
|
||||
| `ruview.vitals.get_breathing` | Breathing rate bpm (null if unavailable) | ADR-124 §4.1 |
|
||||
| `ruview.vitals.get_heart_rate` | Heart rate bpm (null if unavailable) | ADR-124 §4.1 |
|
||||
| `ruview.vitals.get_all` | Full `EdgeVitalsMessage` surface | ADR-124 §4.1 |
|
||||
| `ruview.bfld.last_scan` | Latest BFLD scan: `identity_risk_score`, `privacy_class`, `n_frames` | ADR-118/124 |
|
||||
| `ruview.bfld.subscribe` | Subscribe to `ruview/<node_id>/bfld/*` events for `duration_s` seconds | ADR-122/124 |
|
||||
| *(next iters)* | `pose.latest`, `primitives.*`, `node.*`, `vector.*`, `policy.*` | ADR-124 §4.1/4.1a |
|
||||
|
||||
**Transport security (ADR-124 §6)**:
|
||||
- **stdio**: process-level isolation — no auth needed for local Claude Code / Cursor.
|
||||
- **Streamable HTTP** (`POST /mcp`): Origin header validation (cross-origin → 403), optional bearer token (`RVAGENT_HTTP_TOKEN` → 401 on mismatch), binds `127.0.0.1` by default per MCP spec.
|
||||
|
||||
**Schema validation**: every tool call runs `zod.safeParse` before dispatch; invalid arguments return `McpError(InvalidParams)` rather than a wrapped string.
|
||||
|
||||
**Policy layer** (ADR-124 §4.1a): `ruview.policy.*` tools gate every sensing call — `vitals.*` is default-deny until a policy grant is registered via `npx @ruvnet/rvagent policy grant`. Presence and node-list are allow by default.
|
||||
|
||||
## ADR cross-reference
|
||||
|
||||
| ADR | Decision |
|
||||
|-----|----------|
|
||||
| [ADR-124](../../docs/adr/ADR-124-rvagent-mcp-ruvector-npm-integration.md) | SENSE-BRIDGE: dual-transport MCP server + ruvector npm + ruflo integration |
|
||||
| [ADR-118](../../docs/adr/ADR-118-bfld-beamforming-feedback-layer-for-detection.md) | BFLD pipeline — source of `bfld.last_scan` wire format |
|
||||
| [ADR-122](../../docs/adr/ADR-122-bfld-ruview-ha-matter-exposure.md) | MQTT topic routing `ruview/<node_id>/bfld/*` |
|
||||
| [ADR-115](../../docs/adr/ADR-115-home-assistant-integration.md) | `EdgeVitalsMessage` WebSocket surface (`ws.py:74-88` parity) |
|
||||
| [ADR-055](../../docs/adr/ADR-055-integrated-sensing-server.md) | Sensing-server REST API (`/api/v1/*`) |
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
cd tools/ruview-mcp
|
||||
npm install
|
||||
npm run build # tsc
|
||||
npm test # jest — 93 tests across 7 suites
|
||||
```
|
||||
|
||||
Source: `tools/ruview-mcp/src/`. Tests: `tools/ruview-mcp/tests/`.
|
||||
Tracking issue: [#787](https://github.com/ruvnet/RuView/issues/787).
|
||||
Generated
+5
-95
@@ -1,23 +1,21 @@
|
||||
{
|
||||
"name": "@ruvnet/rvagent",
|
||||
"version": "0.1.0",
|
||||
"name": "@ruv/ruview-mcp",
|
||||
"version": "0.0.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@ruvnet/rvagent",
|
||||
"version": "0.1.0",
|
||||
"name": "@ruv/ruview-mcp",
|
||||
"version": "0.0.1",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"bin": {
|
||||
"ruview-mcp": "dist/index.js",
|
||||
"rvagent": "dist/index.js"
|
||||
"ruview-mcp": "dist/index.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^20.14.0",
|
||||
"jest": "^29.7.0",
|
||||
@@ -1061,52 +1059,6 @@
|
||||
"@babel/types": "^7.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/body-parser": {
|
||||
"version": "1.19.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/connect": "*",
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/connect": {
|
||||
"version": "3.4.38",
|
||||
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
||||
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/express": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
|
||||
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/body-parser": "*",
|
||||
"@types/express-serve-static-core": "^5.0.0",
|
||||
"@types/serve-static": "^2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/express-serve-static-core": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz",
|
||||
"integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"@types/qs": "*",
|
||||
"@types/range-parser": "*",
|
||||
"@types/send": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/graceful-fs": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz",
|
||||
@@ -1117,13 +1069,6 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/http-errors": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
|
||||
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/istanbul-lib-coverage": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
|
||||
@@ -1387,41 +1332,6 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/qs": {
|
||||
"version": "6.15.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
|
||||
"integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/range-parser": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
|
||||
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/send": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
|
||||
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/serve-static": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
|
||||
"integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/http-errors": "*",
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/stack-utils": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",
|
||||
|
||||
@@ -1,25 +1,16 @@
|
||||
{
|
||||
"name": "@ruvnet/rvagent",
|
||||
"version": "0.1.0",
|
||||
"description": "SENSE-BRIDGE: dual-transport MCP server (stdio + Streamable HTTP) exposing RuView WiFi-DensePose sensing primitives to AI agents",
|
||||
"name": "@ruv/ruview-mcp",
|
||||
"version": "0.0.1",
|
||||
"description": "RuView MCP server — expose WiFi-DensePose sensing capabilities as MCP tools for Claude Code, Cursor, and other MCP-compatible agents",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"bin": {
|
||||
"rvagent": "dist/index.js",
|
||||
"ruview-mcp": "dist/index.js"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md",
|
||||
"CHANGELOG.md"
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
@@ -31,32 +22,19 @@
|
||||
},
|
||||
"keywords": [
|
||||
"mcp",
|
||||
"rvagent",
|
||||
"ruview",
|
||||
"wifi",
|
||||
"csi",
|
||||
"pose-estimation",
|
||||
"cognitum",
|
||||
"sense-bridge",
|
||||
"ruvnet"
|
||||
"cognitum"
|
||||
],
|
||||
"author": "ruv <ruv@ruv.net>",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/ruvnet/RuView.git",
|
||||
"directory": "tools/ruview-mcp"
|
||||
},
|
||||
"homepage": "https://github.com/ruvnet/RuView/tree/main/tools/ruview-mcp",
|
||||
"bugs": {
|
||||
"url": "https://github.com/ruvnet/RuView/issues"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^20.14.0",
|
||||
"jest": "^29.7.0",
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
/**
|
||||
* Streamable HTTP transport scaffold for @ruvnet/rvagent (ADR-124 §3).
|
||||
*
|
||||
* Binds to 127.0.0.1 by default and mounts a POST /mcp endpoint backed by
|
||||
* StreamableHTTPServerTransport from @modelcontextprotocol/sdk.
|
||||
*
|
||||
* Security model (ADR-124 §6):
|
||||
* - Origin validation: requests from origins other than the configured
|
||||
* allowlist are rejected with 403 Forbidden before reaching the MCP layer.
|
||||
* - Default allowlist: ['http://localhost', 'http://127.0.0.1'] — covers
|
||||
* Claude Code and Cursor on the same machine.
|
||||
* - Bearer token: when RVAGENT_HTTP_TOKEN is set, requests must carry
|
||||
* Authorization: Bearer <token>; missing/wrong tokens → 401.
|
||||
* - Bind address: defaults to 127.0.0.1 per MCP spec security requirement.
|
||||
* Set RVAGENT_HTTP_HOST=0.0.0.0 only for intentional fleet deployment.
|
||||
*
|
||||
* Usage:
|
||||
* import { createHttpTransport } from './http-transport.js';
|
||||
* const { server: httpServer, transport } = await createHttpTransport(mcpServer);
|
||||
* // httpServer is a node:http.Server — call httpServer.close() to shut down.
|
||||
*/
|
||||
|
||||
import { createServer, type Server as HttpServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||
import type { Server as McpServer } from "@modelcontextprotocol/sdk/server/index.js";
|
||||
|
||||
export interface HttpTransportOptions {
|
||||
/** TCP host to bind (default: 127.0.0.1). */
|
||||
host?: string;
|
||||
/** TCP port to listen on (default: 3001). */
|
||||
port?: number;
|
||||
/**
|
||||
* Allowed Origin header values. Requests with an Origin not in this list
|
||||
* are rejected with 403. Use '*' to disable Origin validation entirely
|
||||
* (not recommended outside of local-dev flags).
|
||||
*/
|
||||
allowedOrigins?: string[];
|
||||
/**
|
||||
* Bearer token for HTTP transport. When set, every request must supply
|
||||
* Authorization: Bearer <token>; omitted or wrong token → 401.
|
||||
* Defaults to process.env.RVAGENT_HTTP_TOKEN (undefined = auth disabled).
|
||||
*/
|
||||
bearerToken?: string;
|
||||
}
|
||||
|
||||
export interface HttpTransportResult {
|
||||
/** The raw Node.js HTTP server — call .close() to shut down. */
|
||||
httpServer: HttpServer;
|
||||
/** The MCP Streamable HTTP transport instance wired to the MCP server. */
|
||||
transport: StreamableHTTPServerTransport;
|
||||
/** The bound address string (e.g. "http://127.0.0.1:3001"). */
|
||||
boundAddress: string;
|
||||
}
|
||||
|
||||
const DEFAULT_HOST = "127.0.0.1";
|
||||
const DEFAULT_PORT = 3001;
|
||||
const LOCALHOST_ORIGINS = new Set([
|
||||
"http://localhost",
|
||||
"http://127.0.0.1",
|
||||
"https://localhost",
|
||||
"https://127.0.0.1",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Validate Origin header against the allowlist.
|
||||
* Returns true if the request should be allowed, false if it should be rejected.
|
||||
*
|
||||
* An absent Origin header is allowed (same-origin non-browser requests, curl, etc.).
|
||||
* A present Origin that is not in the allowlist is rejected.
|
||||
*/
|
||||
export function isOriginAllowed(
|
||||
origin: string | undefined,
|
||||
allowedOrigins: string[]
|
||||
): boolean {
|
||||
if (origin === undefined) return true; // no Origin = not a cross-origin browser request
|
||||
if (allowedOrigins.includes("*")) return true;
|
||||
return allowedOrigins.some((o) => o === origin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build and wire a Streamable HTTP transport to the provided MCP server.
|
||||
* Returns the Node.js HTTP server (not yet listening) plus the transport.
|
||||
* Call httpServer.listen(port, host) or rely on createHttpTransport which
|
||||
* does that for you.
|
||||
*/
|
||||
export function buildHttpApp(
|
||||
mcpServer: McpServer,
|
||||
opts: HttpTransportOptions = {}
|
||||
): { httpServer: HttpServer; transport: StreamableHTTPServerTransport } {
|
||||
const allowedOrigins: string[] = opts.allowedOrigins ?? [
|
||||
...LOCALHOST_ORIGINS,
|
||||
];
|
||||
const bearerToken = opts.bearerToken ?? process.env["RVAGENT_HTTP_TOKEN"];
|
||||
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
});
|
||||
|
||||
const httpServer = createServer(
|
||||
(req: IncomingMessage, res: ServerResponse) => {
|
||||
// ── Origin validation ────────────────────────────────────────────────
|
||||
const origin = req.headers["origin"] as string | undefined;
|
||||
if (!isOriginAllowed(origin, allowedOrigins)) {
|
||||
res.writeHead(403, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "Forbidden: cross-origin request rejected" }));
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Bearer token auth ────────────────────────────────────────────────
|
||||
if (bearerToken !== undefined && bearerToken !== "") {
|
||||
const authHeader = req.headers["authorization"] as string | undefined;
|
||||
const supplied = authHeader?.startsWith("Bearer ")
|
||||
? authHeader.slice("Bearer ".length)
|
||||
: undefined;
|
||||
if (supplied !== bearerToken) {
|
||||
res.writeHead(401, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "Unauthorized: missing or invalid bearer token" }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Route: POST /mcp ─────────────────────────────────────────────────
|
||||
if (req.method === "POST" && req.url === "/mcp") {
|
||||
let body = "";
|
||||
req.on("data", (chunk: Buffer) => { body += chunk.toString(); });
|
||||
req.on("end", () => {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(body);
|
||||
} catch {
|
||||
res.writeHead(400, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "Bad Request: invalid JSON body" }));
|
||||
return;
|
||||
}
|
||||
void transport.handleRequest(req, res, parsed);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Fallback ─────────────────────────────────────────────────────────
|
||||
res.writeHead(404, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "Not found. MCP endpoint: POST /mcp" }));
|
||||
}
|
||||
);
|
||||
|
||||
return { httpServer, transport };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and start the Streamable HTTP transport, resolving once the server
|
||||
* is bound and listening.
|
||||
*/
|
||||
export async function createHttpTransport(
|
||||
mcpServer: McpServer,
|
||||
opts: HttpTransportOptions = {}
|
||||
): Promise<HttpTransportResult> {
|
||||
const host = opts.host ?? process.env["RVAGENT_HTTP_HOST"] ?? DEFAULT_HOST;
|
||||
const port = opts.port ?? Number(process.env["RVAGENT_HTTP_PORT"] ?? DEFAULT_PORT);
|
||||
|
||||
const { httpServer, transport } = buildHttpApp(mcpServer, opts);
|
||||
|
||||
// Wire MCP server to the transport only after the HTTP server is built.
|
||||
// Cast needed: StreamableHTTPServerTransport implements Transport but
|
||||
// exactOptionalPropertyTypes causes a false incompatibility on optional
|
||||
// callback properties; the cast is safe — the SDK types are consistent.
|
||||
await mcpServer.connect(transport as Parameters<typeof mcpServer.connect>[0]);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
httpServer.once("error", reject);
|
||||
httpServer.listen(port, host, () => resolve());
|
||||
});
|
||||
|
||||
return {
|
||||
httpServer,
|
||||
transport,
|
||||
boundAddress: `http://${host}:${port}`,
|
||||
};
|
||||
}
|
||||
@@ -29,8 +29,6 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
McpError,
|
||||
ErrorCode,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
|
||||
import { loadConfig } from "./config.js";
|
||||
@@ -44,16 +42,9 @@ import {
|
||||
jobStatusSchema,
|
||||
jobStatus,
|
||||
} from "./tools/train-count.js";
|
||||
import { TOOL_INPUT_SCHEMAS } from "./schemas/index.js";
|
||||
import { bfldLastScan } from "./tools/bfld-last-scan.js";
|
||||
import { bfldSubscribe } from "./tools/bfld-subscribe.js";
|
||||
import { presenceNow } from "./tools/presence-now.js";
|
||||
import { vitalsGetBreathing } from "./tools/vitals-get-breathing.js";
|
||||
import { vitalsGetHeartRate } from "./tools/vitals-get-heart-rate.js";
|
||||
import { vitalsGetAll } from "./tools/vitals-get-all.js";
|
||||
|
||||
const PACKAGE_VERSION = "0.1.0";
|
||||
const SERVER_NAME = "rvagent";
|
||||
const PACKAGE_VERSION = "0.0.1";
|
||||
const SERVER_NAME = "ruview";
|
||||
|
||||
// ── Tool registry ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -225,126 +216,6 @@ const TOOLS = [
|
||||
return jobStatus(input, config);
|
||||
},
|
||||
},
|
||||
// ── ADR-124 BFLD tools (Phase 4 Refinement) ──────────────────────────────
|
||||
{
|
||||
name: "ruview.bfld.last_scan",
|
||||
description:
|
||||
"Return the most recent BFLD scan result for a node (ADR-118/ADR-121). " +
|
||||
"Fields: node_id, identity_risk_score [0,1], privacy_class, n_frames, timestamp_ms. " +
|
||||
"Proxied from sensing-server GET /api/v1/bfld/<node_id>/last_scan which aggregates " +
|
||||
"the MQTT state topics ruview/<node_id>/bfld/* (ADR-122 §2.2).",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
node_id: {
|
||||
type: "string",
|
||||
description: "Target node id. Omit to use the single active node.",
|
||||
},
|
||||
sensing_server_url: {
|
||||
type: "string",
|
||||
description: "Override sensing-server URL for this call only.",
|
||||
},
|
||||
},
|
||||
},
|
||||
handler: async (args: unknown, config: ReturnType<typeof loadConfig>) => {
|
||||
return bfldLastScan(args as Parameters<typeof bfldLastScan>[0], config);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ruview.bfld.subscribe",
|
||||
description:
|
||||
"Subscribe to BFLD events on ruview/<node_id>/bfld/* for duration_s seconds (ADR-122). " +
|
||||
"Returns {ok, subscription_id, expires_at, topic}. When the sensing-server is unreachable, " +
|
||||
"returns a synthetic envelope with ok:false,warn:true so the caller can distinguish " +
|
||||
"a network error from an invalid request.",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
required: ["duration_s"],
|
||||
properties: {
|
||||
node_id: {
|
||||
type: "string",
|
||||
description: "Target node id. Omit to use the single active node.",
|
||||
},
|
||||
duration_s: {
|
||||
type: "number",
|
||||
minimum: 0,
|
||||
maximum: 3600,
|
||||
description: "Subscription duration in seconds (max 3600).",
|
||||
},
|
||||
sensing_server_url: {
|
||||
type: "string",
|
||||
description: "Override sensing-server URL for this call only.",
|
||||
},
|
||||
},
|
||||
},
|
||||
handler: async (args: unknown, config: ReturnType<typeof loadConfig>) => {
|
||||
return bfldSubscribe(args as Parameters<typeof bfldSubscribe>[0], config);
|
||||
},
|
||||
},
|
||||
// ── ADR-124 Presence + Vitals tools (Phase 4 Refinement iter 5) ──────────
|
||||
{
|
||||
name: "ruview.presence.now",
|
||||
description:
|
||||
"Return current occupancy for a node: present, n_persons, confidence, timestamp_ms. " +
|
||||
"Wraps EdgeVitalsMessage.presence + n_persons (ADR-124 §4.1, ws.py:74-88).",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
node_id: { type: "string", description: "Target node id." },
|
||||
sensing_server_url: { type: "string", description: "Override sensing-server URL." },
|
||||
},
|
||||
},
|
||||
handler: async (args: unknown, config: ReturnType<typeof loadConfig>) =>
|
||||
presenceNow(args as Parameters<typeof presenceNow>[0], config),
|
||||
},
|
||||
{
|
||||
name: "ruview.vitals.get_breathing",
|
||||
description:
|
||||
"Return breathing rate for a node: breathing_rate_bpm (null if unavailable), " +
|
||||
"confidence, timestamp_ms. Wraps EdgeVitalsMessage.breathing_rate_bpm (ws.py:82).",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
node_id: { type: "string", description: "Target node id." },
|
||||
window_s: { type: "number", description: "Averaging window in seconds (max 300)." },
|
||||
sensing_server_url: { type: "string", description: "Override sensing-server URL." },
|
||||
},
|
||||
},
|
||||
handler: async (args: unknown, config: ReturnType<typeof loadConfig>) =>
|
||||
vitalsGetBreathing(args as Parameters<typeof vitalsGetBreathing>[0], config),
|
||||
},
|
||||
{
|
||||
name: "ruview.vitals.get_heart_rate",
|
||||
description:
|
||||
"Return heart rate for a node: heartrate_bpm (null if unavailable), " +
|
||||
"confidence, timestamp_ms. Wraps EdgeVitalsMessage.heartrate_bpm (ws.py:83).",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
node_id: { type: "string", description: "Target node id." },
|
||||
window_s: { type: "number", description: "Averaging window in seconds (max 300)." },
|
||||
sensing_server_url: { type: "string", description: "Override sensing-server URL." },
|
||||
},
|
||||
},
|
||||
handler: async (args: unknown, config: ReturnType<typeof loadConfig>) =>
|
||||
vitalsGetHeartRate(args as Parameters<typeof vitalsGetHeartRate>[0], config),
|
||||
},
|
||||
{
|
||||
name: "ruview.vitals.get_all",
|
||||
description:
|
||||
"Return the full EdgeVitalsMessage for a node (all fields except raw): " +
|
||||
"presence, n_persons, confidence, breathing_rate_bpm, heartrate_bpm, motion, zone_id. " +
|
||||
"Full surface of ws.py:74-88.",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
node_id: { type: "string", description: "Target node id." },
|
||||
sensing_server_url: { type: "string", description: "Override sensing-server URL." },
|
||||
},
|
||||
},
|
||||
handler: async (args: unknown, config: ReturnType<typeof loadConfig>) =>
|
||||
vitalsGetAll(args as Parameters<typeof vitalsGetAll>[0], config),
|
||||
},
|
||||
] as const;
|
||||
|
||||
// ── Server bootstrap ────────────────────────────────────────────────────────
|
||||
@@ -373,10 +244,7 @@ async function main(): Promise<void> {
|
||||
})),
|
||||
}));
|
||||
|
||||
// Call tool handler — uniform Zod validation gate (ADR-124 §3 Architecture).
|
||||
// If TOOL_INPUT_SCHEMAS has a schema for the tool name, run safeParse first.
|
||||
// Parse failures throw McpError(InvalidParams) so the client sees a typed
|
||||
// JSON-RPC error rather than a wrapped string error.
|
||||
// Call tool handler.
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const { name, arguments: args } = request.params;
|
||||
const tool = TOOLS.find((t) => t.name === name);
|
||||
@@ -396,20 +264,6 @@ async function main(): Promise<void> {
|
||||
};
|
||||
}
|
||||
|
||||
// Schema validation gate — applies to all tools registered in TOOL_INPUT_SCHEMAS.
|
||||
const schemaEntry = Object.prototype.hasOwnProperty.call(TOOL_INPUT_SCHEMAS, name)
|
||||
? TOOL_INPUT_SCHEMAS[name as keyof typeof TOOL_INPUT_SCHEMAS]
|
||||
: undefined;
|
||||
if (schemaEntry !== undefined) {
|
||||
const parsed = schemaEntry.safeParse(args ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new McpError(
|
||||
ErrorCode.InvalidParams,
|
||||
`Invalid arguments for tool "${name}": ${parsed.error.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await tool.handler(args ?? {}, config);
|
||||
return {
|
||||
@@ -421,7 +275,6 @@ async function main(): Promise<void> {
|
||||
],
|
||||
};
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof McpError) throw e; // propagate typed errors unchanged
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
return {
|
||||
content: [
|
||||
@@ -444,7 +297,7 @@ async function main(): Promise<void> {
|
||||
|
||||
// Log to stderr so it doesn't interfere with the MCP stdio protocol.
|
||||
process.stderr.write(
|
||||
`[@ruvnet/rvagent] Server v${PACKAGE_VERSION} started. ` +
|
||||
`[ruview-mcp] Server v${PACKAGE_VERSION} started. ` +
|
||||
`Sensing server: ${config.sensingServerUrl}\n`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
/**
|
||||
* Shared Zod sub-schemas reused across the ADR-124 §4.1 tool catalog.
|
||||
*
|
||||
* All constraints are sourced from the ADR-124 decision record; comments cite
|
||||
* the specific table row or section that defines the constraint.
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
// ── Shared primitives ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Optional node_id — present on almost every tool. Defaults to the single
|
||||
* active node when only one is registered; required for multi-node fleets.
|
||||
*/
|
||||
export const NodeIdSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe("Target node id. Omit to use the single active node.");
|
||||
|
||||
/**
|
||||
* Subscription duration in seconds. ADR-124 policy layer caps this at the
|
||||
* value returned by ruview.policy.can_subscribe.max_duration_s; the schema
|
||||
* enforces a hard ceiling of 3600 s (1 h) as a first-line guard.
|
||||
*/
|
||||
export const DurationSSchema = z
|
||||
.number()
|
||||
.positive()
|
||||
.max(3600)
|
||||
.describe("Subscription duration in seconds (max 3600).");
|
||||
|
||||
/**
|
||||
* Optional window in seconds for vitals averaging. Positive, max 300 s.
|
||||
* ADR-124 §4.1 rows vitals.get_breathing / vitals.get_heart_rate.
|
||||
*/
|
||||
export const WindowSSchema = z
|
||||
.number()
|
||||
.positive()
|
||||
.max(300)
|
||||
.optional()
|
||||
.describe("Averaging window in seconds (max 300).");
|
||||
|
||||
/**
|
||||
* The 10 semantic primitive kinds defined in ADR-115 and mirrored in
|
||||
* python/wifi_densepose/client/primitives.py:36-45.
|
||||
*/
|
||||
export const SemanticPrimitiveKindSchema = z.enum([
|
||||
"presence",
|
||||
"n_persons",
|
||||
"fall_detected",
|
||||
"breathing_rate",
|
||||
"heart_rate",
|
||||
"gesture",
|
||||
"zone_entry",
|
||||
"zone_exit",
|
||||
"movement_intensity",
|
||||
"sleep_quality",
|
||||
]);
|
||||
|
||||
export type SemanticPrimitiveKind = z.infer<typeof SemanticPrimitiveKindSchema>;
|
||||
|
||||
/**
|
||||
* A single 17-keypoint COCO pose result as stored and returned by the
|
||||
* ruvector HNSW index (ADR-016). Used by ruview.vector.store_pose input.
|
||||
*/
|
||||
export const PosePersonResultSchema = z.object({
|
||||
keypoints: z
|
||||
.array(z.tuple([z.number(), z.number()]))
|
||||
.length(17)
|
||||
.describe("17 COCO keypoints as [x,y] pairs in image-normalised coords."),
|
||||
confidence: z.number().min(0).max(1).describe("Pose confidence score [0,1]."),
|
||||
person_id: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("AETHER re-ID token, if available."),
|
||||
});
|
||||
|
||||
export type PosePersonResult = z.infer<typeof PosePersonResultSchema>;
|
||||
@@ -1,9 +0,0 @@
|
||||
/**
|
||||
* Barrel re-export for @ruvnet/rvagent schema layer.
|
||||
*
|
||||
* Import from this module to get all Zod input schemas, shared sub-schemas,
|
||||
* the TOOL_NAMES catalog, and the TOOL_INPUT_SCHEMAS dispatch map.
|
||||
*/
|
||||
|
||||
export * from "./common.js";
|
||||
export * from "./tools.js";
|
||||
@@ -1,242 +0,0 @@
|
||||
/**
|
||||
* Zod input schemas for all 20 ADR-124 MCP tools.
|
||||
*
|
||||
* §4.1 — 15 sensing tools (presence, vitals, pose, primitives, bfld, node, vector)
|
||||
* §4.1a — 5 policy / governance tools (RUVIEW-POLICY)
|
||||
*
|
||||
* Each exported schema is named `<CamelCase>InputSchema` matching the tool
|
||||
* name from the ADR-124 §4.1 catalog table. The parallel `TOOL_NAMES` array
|
||||
* is the single source of truth asserted by the schema-coverage test.
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
import {
|
||||
NodeIdSchema,
|
||||
DurationSSchema,
|
||||
WindowSSchema,
|
||||
SemanticPrimitiveKindSchema,
|
||||
PosePersonResultSchema,
|
||||
} from "./common.js";
|
||||
|
||||
// ── §4.1 Presence ──────────────────────────────────────────────────────────
|
||||
|
||||
/** ruview.presence.now */
|
||||
export const PresenceNowInputSchema = z.object({
|
||||
node_id: NodeIdSchema,
|
||||
});
|
||||
|
||||
// ── §4.1 Vitals ───────────────────────────────────────────────────────────
|
||||
|
||||
/** ruview.vitals.get_breathing */
|
||||
export const VitalsGetBreathingInputSchema = z.object({
|
||||
node_id: NodeIdSchema,
|
||||
window_s: WindowSSchema,
|
||||
});
|
||||
|
||||
/** ruview.vitals.get_heart_rate */
|
||||
export const VitalsGetHeartRateInputSchema = z.object({
|
||||
node_id: NodeIdSchema,
|
||||
window_s: WindowSSchema,
|
||||
});
|
||||
|
||||
/** ruview.vitals.get_all */
|
||||
export const VitalsGetAllInputSchema = z.object({
|
||||
node_id: NodeIdSchema,
|
||||
});
|
||||
|
||||
// ── §4.1 Pose ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** ruview.pose.latest */
|
||||
export const PoseLatestInputSchema = z.object({
|
||||
node_id: NodeIdSchema,
|
||||
});
|
||||
|
||||
/** ruview.pose.subscribe */
|
||||
export const PoseSubscribeInputSchema = z.object({
|
||||
node_id: NodeIdSchema,
|
||||
duration_s: DurationSSchema,
|
||||
callback_url: z
|
||||
.string()
|
||||
.url()
|
||||
.optional()
|
||||
.describe("Webhook URL to receive PoseDataMessage events (optional)."),
|
||||
});
|
||||
|
||||
// ── §4.1 Primitives ───────────────────────────────────────────────────────
|
||||
|
||||
/** ruview.primitives.get */
|
||||
export const PrimitivesGetInputSchema = z.object({
|
||||
node_id: NodeIdSchema,
|
||||
primitive: SemanticPrimitiveKindSchema,
|
||||
});
|
||||
|
||||
/** ruview.primitives.list_active */
|
||||
export const PrimitivesListActiveInputSchema = z.object({
|
||||
node_id: NodeIdSchema,
|
||||
});
|
||||
|
||||
/** ruview.primitives.subscribe */
|
||||
export const PrimitivesSubscribeInputSchema = z.object({
|
||||
node_id: NodeIdSchema,
|
||||
primitive: SemanticPrimitiveKindSchema.optional().describe(
|
||||
"Subscribe to a specific primitive. Omit to receive all active primitives."
|
||||
),
|
||||
duration_s: DurationSSchema,
|
||||
});
|
||||
|
||||
// ── §4.1 BFLD ────────────────────────────────────────────────────────────
|
||||
|
||||
/** ruview.bfld.last_scan */
|
||||
export const BfldLastScanInputSchema = z.object({
|
||||
node_id: NodeIdSchema,
|
||||
});
|
||||
|
||||
/** ruview.bfld.subscribe */
|
||||
export const BfldSubscribeInputSchema = z.object({
|
||||
node_id: NodeIdSchema,
|
||||
duration_s: DurationSSchema,
|
||||
});
|
||||
|
||||
// ── §4.1 Node ────────────────────────────────────────────────────────────
|
||||
|
||||
/** ruview.node.list — empty input per ADR-124 §4.1 table */
|
||||
export const NodeListInputSchema = z.object({});
|
||||
|
||||
/** ruview.node.status */
|
||||
export const NodeStatusInputSchema = z.object({
|
||||
node_id: z.string().min(1).describe("Node id to query status for."),
|
||||
});
|
||||
|
||||
// ── §4.1 Vector ──────────────────────────────────────────────────────────
|
||||
|
||||
/** ruview.vector.search_pose */
|
||||
export const VectorSearchPoseInputSchema = z.object({
|
||||
query_embedding: z
|
||||
.array(z.number())
|
||||
.min(1)
|
||||
.describe("Dense embedding vector to query against the HNSW index."),
|
||||
k: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.max(100)
|
||||
.optional()
|
||||
.default(10)
|
||||
.describe("Number of nearest neighbours to return (default 10, max 100)."),
|
||||
node_id: NodeIdSchema,
|
||||
});
|
||||
|
||||
/** ruview.vector.store_pose */
|
||||
export const VectorStorePoseInputSchema = z.object({
|
||||
pose: PosePersonResultSchema,
|
||||
node_id: z.string().min(1).describe("Node id that observed this pose."),
|
||||
});
|
||||
|
||||
// ── §4.1a Policy / governance tools ──────────────────────────────────────
|
||||
|
||||
/** ruview.policy.can_access_vitals */
|
||||
export const PolicyCanAccessVitalsInputSchema = z.object({
|
||||
agent_id: z.string().min(1).describe("Calling agent identifier."),
|
||||
node_id: z.string().min(1).describe("Target sensing node."),
|
||||
vital: z
|
||||
.enum(["breathing", "heart_rate", "all"])
|
||||
.describe("Which vital the agent is requesting."),
|
||||
});
|
||||
|
||||
/** ruview.policy.can_query_presence */
|
||||
export const PolicyCanQueryPresenceInputSchema = z.object({
|
||||
agent_id: z.string().min(1),
|
||||
scope: z
|
||||
.enum(["node", "fleet"])
|
||||
.describe("node = single node; fleet = all nodes / aggregated count."),
|
||||
node_id: NodeIdSchema,
|
||||
zone: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Named zone within a node (e.g. 'living_room')."),
|
||||
});
|
||||
|
||||
/** ruview.policy.can_subscribe */
|
||||
export const PolicyCanSubscribeInputSchema = z.object({
|
||||
agent_id: z.string().min(1),
|
||||
topic: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe("MQTT topic or tool name the agent wishes to subscribe to."),
|
||||
duration_s: DurationSSchema,
|
||||
});
|
||||
|
||||
/** ruview.policy.redact_identity_fields */
|
||||
export const PolicyRedactIdentityFieldsInputSchema = z.object({
|
||||
payload: z.record(z.unknown()).describe("Tool return value to redact."),
|
||||
agent_id: z.string().min(1),
|
||||
});
|
||||
|
||||
/** ruview.policy.audit_log */
|
||||
export const PolicyAuditLogInputSchema = z.object({
|
||||
agent_id: z.string().optional().describe("Filter to a specific agent."),
|
||||
since_ts: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe("Return events after this Unix timestamp (ms)."),
|
||||
});
|
||||
|
||||
// ── Catalog ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Single source of truth: every tool name in the ADR-124 §4.1 + §4.1a catalog.
|
||||
* The schema-coverage test asserts this list exactly matches the exported schemas.
|
||||
*/
|
||||
export const TOOL_NAMES = [
|
||||
// §4.1 — 15 sensing tools
|
||||
"ruview.presence.now",
|
||||
"ruview.vitals.get_breathing",
|
||||
"ruview.vitals.get_heart_rate",
|
||||
"ruview.vitals.get_all",
|
||||
"ruview.pose.latest",
|
||||
"ruview.pose.subscribe",
|
||||
"ruview.primitives.get",
|
||||
"ruview.primitives.list_active",
|
||||
"ruview.primitives.subscribe",
|
||||
"ruview.bfld.last_scan",
|
||||
"ruview.bfld.subscribe",
|
||||
"ruview.node.list",
|
||||
"ruview.node.status",
|
||||
"ruview.vector.search_pose",
|
||||
"ruview.vector.store_pose",
|
||||
// §4.1a — 5 policy tools
|
||||
"ruview.policy.can_access_vitals",
|
||||
"ruview.policy.can_query_presence",
|
||||
"ruview.policy.can_subscribe",
|
||||
"ruview.policy.redact_identity_fields",
|
||||
"ruview.policy.audit_log",
|
||||
] as const;
|
||||
|
||||
export type ToolName = (typeof TOOL_NAMES)[number];
|
||||
|
||||
/**
|
||||
* Map from tool name → its Zod input schema. Used by the MCP server's
|
||||
* CallTool handler for uniform schema-validation before dispatch.
|
||||
*/
|
||||
export const TOOL_INPUT_SCHEMAS: Record<ToolName, z.ZodTypeAny> = {
|
||||
"ruview.presence.now": PresenceNowInputSchema,
|
||||
"ruview.vitals.get_breathing": VitalsGetBreathingInputSchema,
|
||||
"ruview.vitals.get_heart_rate": VitalsGetHeartRateInputSchema,
|
||||
"ruview.vitals.get_all": VitalsGetAllInputSchema,
|
||||
"ruview.pose.latest": PoseLatestInputSchema,
|
||||
"ruview.pose.subscribe": PoseSubscribeInputSchema,
|
||||
"ruview.primitives.get": PrimitivesGetInputSchema,
|
||||
"ruview.primitives.list_active": PrimitivesListActiveInputSchema,
|
||||
"ruview.primitives.subscribe": PrimitivesSubscribeInputSchema,
|
||||
"ruview.bfld.last_scan": BfldLastScanInputSchema,
|
||||
"ruview.bfld.subscribe": BfldSubscribeInputSchema,
|
||||
"ruview.node.list": NodeListInputSchema,
|
||||
"ruview.node.status": NodeStatusInputSchema,
|
||||
"ruview.vector.search_pose": VectorSearchPoseInputSchema,
|
||||
"ruview.vector.store_pose": VectorStorePoseInputSchema,
|
||||
"ruview.policy.can_access_vitals": PolicyCanAccessVitalsInputSchema,
|
||||
"ruview.policy.can_query_presence": PolicyCanQueryPresenceInputSchema,
|
||||
"ruview.policy.can_subscribe": PolicyCanSubscribeInputSchema,
|
||||
"ruview.policy.redact_identity_fields": PolicyRedactIdentityFieldsInputSchema,
|
||||
"ruview.policy.audit_log": PolicyAuditLogInputSchema,
|
||||
};
|
||||
@@ -1,111 +0,0 @@
|
||||
/**
|
||||
* MCP tool: ruview.bfld.last_scan
|
||||
*
|
||||
* Returns the most recent BFLD scan result for a node, sourced from the
|
||||
* sensing-server's REST proxy of the BFLD MQTT state topics defined in
|
||||
* ADR-122 §2.2. The sensing-server aggregates the per-entity state topics
|
||||
* (presence, person_count, confidence, identity_risk) into a single JSON
|
||||
* object at GET /api/v1/bfld/<node_id>/last_scan.
|
||||
*
|
||||
* Wire format (ADR-118 BfldEvent, class-permissive fields only):
|
||||
* node_id string — originating node
|
||||
* identity_risk_score number — [0,1], None at privacy_class Restricted
|
||||
* privacy_class number — 0=raw,1=derived,2=anonymous,3=restricted
|
||||
* n_frames number — person_count proxy (frames accumulated)
|
||||
* timestamp_ms number — capture timestamp in ms since epoch
|
||||
*
|
||||
* Returns {ok:false, warn:true} when the sensing-server is not reachable
|
||||
* so the caller can treat unavailability as a soft warning rather than
|
||||
* a hard error (mirrors the pattern in csi-latest.ts).
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
import type { RuviewConfig } from "../types.js";
|
||||
import { sensingGet } from "../http.js";
|
||||
|
||||
export const bfldLastScanSchema = z.object({
|
||||
node_id: z
|
||||
.string()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe("Target node id. Omit to use the single active node."),
|
||||
sensing_server_url: z
|
||||
.string()
|
||||
.url()
|
||||
.optional()
|
||||
.describe("Override sensing-server URL for this call only."),
|
||||
});
|
||||
|
||||
export type BfldLastScanInput = z.infer<typeof bfldLastScanSchema>;
|
||||
|
||||
/** Shape returned by the sensing-server BFLD last-scan proxy endpoint. */
|
||||
interface BfldScanResponse {
|
||||
node_id: string;
|
||||
identity_risk_score: number | null;
|
||||
privacy_class: number;
|
||||
person_count: number;
|
||||
confidence: number;
|
||||
presence: boolean;
|
||||
timestamp_ns: number;
|
||||
}
|
||||
|
||||
/** ADR-124 §4.1 output contract for ruview.bfld.last_scan. */
|
||||
export interface BfldLastScanResult {
|
||||
ok: true;
|
||||
node_id: string;
|
||||
identity_risk_score: number | null;
|
||||
privacy_class: number;
|
||||
/** person_count used as n_frames proxy (ADR-118 BfldEvent.person_count). */
|
||||
n_frames: number;
|
||||
/** Converted from BfldEvent.timestamp_ns (nanoseconds → milliseconds). */
|
||||
timestamp_ms: number;
|
||||
}
|
||||
|
||||
export async function bfldLastScan(
|
||||
input: BfldLastScanInput,
|
||||
config: RuviewConfig
|
||||
): Promise<object> {
|
||||
const baseUrl = input.sensing_server_url ?? config.sensingServerUrl;
|
||||
const nodeId = input.node_id ?? "default";
|
||||
|
||||
const result = await sensingGet<BfldScanResponse>(
|
||||
baseUrl,
|
||||
`/api/v1/bfld/${encodeURIComponent(nodeId)}/last_scan`,
|
||||
config.apiToken
|
||||
);
|
||||
|
||||
if (!result.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
warn: true,
|
||||
error: result.error,
|
||||
hint:
|
||||
"Ensure the sensing-server is running and the BFLD pipeline is active " +
|
||||
"(ADR-118). The node must have published at least one BfldEvent since " +
|
||||
"the last server restart.",
|
||||
};
|
||||
}
|
||||
|
||||
const data = result.data;
|
||||
|
||||
// Validate the minimum required fields are present.
|
||||
if (typeof data.node_id !== "string" || typeof data.timestamp_ns !== "number") {
|
||||
return {
|
||||
ok: false,
|
||||
warn: true,
|
||||
error: "Sensing-server returned an unexpected BFLD response shape.",
|
||||
raw_response: data,
|
||||
};
|
||||
}
|
||||
|
||||
const out: BfldLastScanResult = {
|
||||
ok: true,
|
||||
node_id: data.node_id,
|
||||
identity_risk_score: data.identity_risk_score ?? null,
|
||||
privacy_class: data.privacy_class,
|
||||
n_frames: data.person_count,
|
||||
timestamp_ms: Math.round(data.timestamp_ns / 1_000_000),
|
||||
};
|
||||
|
||||
return out;
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
/**
|
||||
* MCP tool: ruview.bfld.subscribe
|
||||
*
|
||||
* Registers interest in BFLD events for `duration_s` seconds by instructing
|
||||
* the sensing-server to forward MQTT messages from topic
|
||||
* `ruview/<node_id>/bfld/*` (ADR-122 §2.2) to a server-side event buffer.
|
||||
*
|
||||
* This is a stateless stub that does NOT require a running MQTT broker in
|
||||
* the MCP server process. Instead it proxies the subscription request to the
|
||||
* sensing-server's webhook/subscription registry at
|
||||
* POST /api/v1/bfld/<node_id>/subscribe, which returns a subscription_id.
|
||||
*
|
||||
* When the sensing-server is unreachable, the handler returns {ok:false,warn:true}
|
||||
* rather than throwing, consistent with the ruview-mcp soft-failure convention.
|
||||
*
|
||||
* In environments where no real broker is available (unit tests, dev machines
|
||||
* without mosquitto) the handler synthesises a valid subscription envelope
|
||||
* locally so the MCP schema-validation gate can be exercised independently.
|
||||
*
|
||||
* ADR-124 §4.1 output: { subscription_id: string, expires_at: number }
|
||||
*/
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { z } from "zod";
|
||||
import type { RuviewConfig } from "../types.js";
|
||||
import { sensingGet } from "../http.js";
|
||||
|
||||
export const bfldSubscribeSchema = z.object({
|
||||
node_id: z
|
||||
.string()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe("Target node id. Omit to use the single active node."),
|
||||
duration_s: z
|
||||
.number()
|
||||
.positive()
|
||||
.max(3600)
|
||||
.describe("Subscription duration in seconds (max 3600)."),
|
||||
sensing_server_url: z
|
||||
.string()
|
||||
.url()
|
||||
.optional()
|
||||
.describe("Override sensing-server URL for this call only."),
|
||||
});
|
||||
|
||||
export type BfldSubscribeInput = z.infer<typeof bfldSubscribeSchema>;
|
||||
|
||||
/** Shape returned by the sensing-server subscription endpoint. */
|
||||
interface SubscribeResponse {
|
||||
subscription_id: string;
|
||||
expires_at: number;
|
||||
topic: string;
|
||||
}
|
||||
|
||||
export interface BfldSubscribeResult {
|
||||
ok: true;
|
||||
subscription_id: string;
|
||||
/** Unix timestamp (ms) when the subscription expires. */
|
||||
expires_at: number;
|
||||
/** MQTT wildcard topic this subscription covers. */
|
||||
topic: string;
|
||||
}
|
||||
|
||||
export async function bfldSubscribe(
|
||||
input: BfldSubscribeInput,
|
||||
config: RuviewConfig
|
||||
): Promise<object> {
|
||||
const baseUrl = input.sensing_server_url ?? config.sensingServerUrl;
|
||||
const nodeId = input.node_id ?? "default";
|
||||
const topic = `ruview/${nodeId}/bfld/*`;
|
||||
|
||||
// Attempt to register via sensing-server proxy.
|
||||
// The endpoint accepts query params: ?duration_s=<n>
|
||||
const result = await sensingGet<SubscribeResponse>(
|
||||
baseUrl,
|
||||
`/api/v1/bfld/${encodeURIComponent(nodeId)}/subscribe?duration_s=${input.duration_s}`,
|
||||
config.apiToken
|
||||
);
|
||||
|
||||
if (!result.ok) {
|
||||
// Sensing-server unreachable — synthesise a local subscription envelope
|
||||
// so the agent knows the call was received and can correlate via the UUID.
|
||||
// The subscription won't receive real events, but the envelope is valid.
|
||||
const subscriptionId = randomUUID();
|
||||
const expiresAt = Date.now() + input.duration_s * 1_000;
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
warn: true,
|
||||
subscription_id: subscriptionId,
|
||||
expires_at: expiresAt,
|
||||
topic,
|
||||
error: result.error,
|
||||
hint:
|
||||
"Sensing-server not reachable — subscription envelope is synthetic. " +
|
||||
"No live BFLD events will be delivered. Ensure the sensing-server is " +
|
||||
"running and connected to the MQTT broker (ADR-122).",
|
||||
};
|
||||
}
|
||||
|
||||
const data = result.data;
|
||||
|
||||
if (typeof data.subscription_id !== "string" || typeof data.expires_at !== "number") {
|
||||
// Malformed response — still return a synthetic envelope.
|
||||
return {
|
||||
ok: false,
|
||||
warn: true,
|
||||
subscription_id: randomUUID(),
|
||||
expires_at: Date.now() + input.duration_s * 1_000,
|
||||
topic,
|
||||
error: "Sensing-server returned unexpected subscription shape.",
|
||||
raw_response: data,
|
||||
};
|
||||
}
|
||||
|
||||
const out: BfldSubscribeResult = {
|
||||
ok: true,
|
||||
subscription_id: data.subscription_id,
|
||||
expires_at: data.expires_at,
|
||||
topic: data.topic ?? topic,
|
||||
};
|
||||
|
||||
return out;
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
/**
|
||||
* MCP tool: ruview.presence.now (ADR-124 §4.1)
|
||||
* Output: { ok, node_id, present, n_persons, confidence, timestamp_ms }
|
||||
*/
|
||||
import { z } from "zod";
|
||||
import type { RuviewConfig } from "../types.js";
|
||||
import { fetchVitals, resolveNodeId } from "./vitals-fetch.js";
|
||||
|
||||
export const presenceNowSchema = z.object({
|
||||
node_id: z.string().min(1).optional().describe("Target node id."),
|
||||
sensing_server_url: z.string().url().optional(),
|
||||
});
|
||||
export type PresenceNowInput = z.infer<typeof presenceNowSchema>;
|
||||
|
||||
export async function presenceNow(input: PresenceNowInput, config: RuviewConfig): Promise<object> {
|
||||
const nodeId = resolveNodeId(input.node_id);
|
||||
const baseUrl = input.sensing_server_url ?? config.sensingServerUrl;
|
||||
const r = await fetchVitals(nodeId, baseUrl, config.apiToken);
|
||||
if (!r.ok) return r;
|
||||
return {
|
||||
ok: true,
|
||||
node_id: r.data.node_id,
|
||||
present: r.data.presence,
|
||||
n_persons: r.data.n_persons,
|
||||
confidence: r.data.confidence,
|
||||
timestamp_ms: r.data.timestamp_ms,
|
||||
};
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
/**
|
||||
* Shared helper: fetch EdgeVitalsMessage from the sensing-server.
|
||||
*
|
||||
* All four vitals/presence tools call this once; each projects a subset of
|
||||
* the returned fields into its own ADR-124 §4.1 output shape.
|
||||
*
|
||||
* Endpoint: GET /api/v1/vitals/<node_id>/latest
|
||||
* Returns: EdgeVitalsMessage | {ok:false, warn:true, error, hint}
|
||||
*/
|
||||
|
||||
import type { RuviewConfig, EdgeVitalsMessage } from "../types.js";
|
||||
import { sensingGet } from "../http.js";
|
||||
|
||||
export type VitalsFetchOk = { ok: true; data: EdgeVitalsMessage };
|
||||
export type VitalsFetchErr = { ok: false; warn: true; error: string; hint: string };
|
||||
export type VitalsFetchResult = VitalsFetchOk | VitalsFetchErr;
|
||||
|
||||
const HINT =
|
||||
"Ensure the sensing-server is running and a node is streaming CSI data. " +
|
||||
"Start with `cargo run -p wifi-densepose-sensing-server` or set " +
|
||||
"RUVIEW_SENSING_SERVER_URL to the correct address.";
|
||||
|
||||
export async function fetchVitals(
|
||||
nodeId: string,
|
||||
baseUrl: string,
|
||||
token: string | undefined
|
||||
): Promise<VitalsFetchResult> {
|
||||
const result = await sensingGet<EdgeVitalsMessage>(
|
||||
baseUrl,
|
||||
`/api/v1/vitals/${encodeURIComponent(nodeId)}/latest`,
|
||||
token
|
||||
);
|
||||
if (!result.ok) {
|
||||
return { ok: false, warn: true, error: result.error, hint: HINT };
|
||||
}
|
||||
const d = result.data;
|
||||
if (typeof d.node_id !== "string" || typeof d.timestamp_ms !== "number") {
|
||||
return { ok: false, warn: true, error: "Unexpected vitals response shape.", hint: HINT };
|
||||
}
|
||||
return { ok: true, data: d };
|
||||
}
|
||||
|
||||
/** Resolve node id: use supplied value or fall back to "default". */
|
||||
export function resolveNodeId(nodeId: string | undefined): string {
|
||||
return nodeId ?? "default";
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
/**
|
||||
* MCP tool: ruview.vitals.get_all (ADR-124 §4.1)
|
||||
* Output: EdgeVitalsResult — full EdgeVitalsMessage minus `raw`.
|
||||
*/
|
||||
import { z } from "zod";
|
||||
import type { RuviewConfig } from "../types.js";
|
||||
import { fetchVitals, resolveNodeId } from "./vitals-fetch.js";
|
||||
|
||||
export const vitalsGetAllSchema = z.object({
|
||||
node_id: z.string().min(1).optional().describe("Target node id."),
|
||||
sensing_server_url: z.string().url().optional(),
|
||||
});
|
||||
export type VitalsGetAllInput = z.infer<typeof vitalsGetAllSchema>;
|
||||
|
||||
export async function vitalsGetAll(
|
||||
input: VitalsGetAllInput,
|
||||
config: RuviewConfig
|
||||
): Promise<object> {
|
||||
const nodeId = resolveNodeId(input.node_id);
|
||||
const baseUrl = input.sensing_server_url ?? config.sensingServerUrl;
|
||||
const r = await fetchVitals(nodeId, baseUrl, config.apiToken);
|
||||
if (!r.ok) return r;
|
||||
// Return the full EdgeVitalsMessage; `raw` field is never present in the
|
||||
// sensing-server response (stripped server-side per ADR-124 §4.1 spec).
|
||||
return { ok: true, ...r.data };
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
/**
|
||||
* MCP tool: ruview.vitals.get_breathing (ADR-124 §4.1)
|
||||
* Output: { ok, node_id, breathing_rate_bpm | null, confidence, timestamp_ms }
|
||||
*/
|
||||
import { z } from "zod";
|
||||
import type { RuviewConfig } from "../types.js";
|
||||
import { fetchVitals, resolveNodeId } from "./vitals-fetch.js";
|
||||
|
||||
export const vitalsGetBreathingSchema = z.object({
|
||||
node_id: z.string().min(1).optional().describe("Target node id."),
|
||||
window_s: z.number().positive().max(300).optional().describe("Averaging window (s, max 300)."),
|
||||
sensing_server_url: z.string().url().optional(),
|
||||
});
|
||||
export type VitalsGetBreathingInput = z.infer<typeof vitalsGetBreathingSchema>;
|
||||
|
||||
export async function vitalsGetBreathing(
|
||||
input: VitalsGetBreathingInput,
|
||||
config: RuviewConfig
|
||||
): Promise<object> {
|
||||
const nodeId = resolveNodeId(input.node_id);
|
||||
const baseUrl = input.sensing_server_url ?? config.sensingServerUrl;
|
||||
const r = await fetchVitals(nodeId, baseUrl, config.apiToken);
|
||||
if (!r.ok) return r;
|
||||
return {
|
||||
ok: true,
|
||||
node_id: r.data.node_id,
|
||||
breathing_rate_bpm: r.data.breathing_rate_bpm,
|
||||
confidence: r.data.confidence,
|
||||
timestamp_ms: r.data.timestamp_ms,
|
||||
};
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
/**
|
||||
* MCP tool: ruview.vitals.get_heart_rate (ADR-124 §4.1)
|
||||
* Output: { ok, node_id, heartrate_bpm | null, confidence, timestamp_ms }
|
||||
*/
|
||||
import { z } from "zod";
|
||||
import type { RuviewConfig } from "../types.js";
|
||||
import { fetchVitals, resolveNodeId } from "./vitals-fetch.js";
|
||||
|
||||
export const vitalsGetHeartRateSchema = z.object({
|
||||
node_id: z.string().min(1).optional().describe("Target node id."),
|
||||
window_s: z.number().positive().max(300).optional().describe("Averaging window (s, max 300)."),
|
||||
sensing_server_url: z.string().url().optional(),
|
||||
});
|
||||
export type VitalsGetHeartRateInput = z.infer<typeof vitalsGetHeartRateSchema>;
|
||||
|
||||
export async function vitalsGetHeartRate(
|
||||
input: VitalsGetHeartRateInput,
|
||||
config: RuviewConfig
|
||||
): Promise<object> {
|
||||
const nodeId = resolveNodeId(input.node_id);
|
||||
const baseUrl = input.sensing_server_url ?? config.sensingServerUrl;
|
||||
const r = await fetchVitals(nodeId, baseUrl, config.apiToken);
|
||||
if (!r.ok) return r;
|
||||
return {
|
||||
ok: true,
|
||||
node_id: r.data.node_id,
|
||||
heartrate_bpm: r.data.heartrate_bpm,
|
||||
confidence: r.data.confidence,
|
||||
timestamp_ms: r.data.timestamp_ms,
|
||||
};
|
||||
}
|
||||
@@ -126,24 +126,6 @@ export interface JobStatusResult {
|
||||
epochs_total?: number | undefined;
|
||||
}
|
||||
|
||||
// ── Vitals (ADR-124 §6 Python surface parity: ws.py:74-88) ───────────────
|
||||
|
||||
/**
|
||||
* Mirrors python/wifi_densepose/client/ws.py EdgeVitalsMessage (ws.py:74-88).
|
||||
* Returned by sensing-server GET /api/v1/vitals/<node_id>/latest.
|
||||
*/
|
||||
export interface EdgeVitalsMessage {
|
||||
node_id: string;
|
||||
timestamp_ms: number;
|
||||
presence: boolean;
|
||||
n_persons: number;
|
||||
confidence: number;
|
||||
breathing_rate_bpm: number | null;
|
||||
heartrate_bpm: number | null;
|
||||
motion: number;
|
||||
zone_id?: string | undefined;
|
||||
}
|
||||
|
||||
// ── Config ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Runtime configuration, typically sourced from env vars. */
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
/**
|
||||
* ADR-124 Phase 4 (Refinement) — BFLD tool family tests.
|
||||
*
|
||||
* Tests bfld-last-scan and bfld-subscribe handlers in isolation (no live
|
||||
* sensing-server or MQTT broker). Exercises the schema-validation gate wired
|
||||
* in Phase 3 (iter 3) by calling handlers through the same Zod parse path
|
||||
* the MCP CallTool handler uses.
|
||||
*
|
||||
* Covered:
|
||||
* bfldLastScan:
|
||||
* 1. Returns {ok:false, warn:true} when sensing-server is not reachable
|
||||
* 2. Returns {ok:false, warn:true} on malformed response shape
|
||||
* 3. Converts timestamp_ns → timestamp_ms correctly
|
||||
* 4. Passes identity_risk_score through as null when absent
|
||||
* 5. Schema accepts empty object (node_id optional)
|
||||
* 6. Schema rejects node_id as empty string
|
||||
*
|
||||
* bfldSubscribe:
|
||||
* 7. Returns subscription_id + future expires_at when server unreachable (synthetic)
|
||||
* 8. subscription_id is a valid UUID v4 in the synthetic path
|
||||
* 9. expires_at is >= Date.now() + duration_s * 1000 (approximately)
|
||||
* 10. topic matches ruview/<node_id>/bfld/* pattern
|
||||
* 11. Schema rejects duration_s > 3600
|
||||
* 12. Schema rejects duration_s = 0 (must be positive)
|
||||
*/
|
||||
|
||||
import os from "node:os";
|
||||
import type { RuviewConfig } from "../src/types.js";
|
||||
import { bfldLastScan, bfldLastScanSchema as BfldLastScanInputSchema } from "../src/tools/bfld-last-scan.js";
|
||||
import { bfldSubscribe, bfldSubscribeSchema as BfldSubscribeInputSchema } from "../src/tools/bfld-subscribe.js";
|
||||
|
||||
const testConfig: RuviewConfig = {
|
||||
sensingServerUrl: "http://127.0.0.1:19998", // nothing listening
|
||||
apiToken: undefined,
|
||||
poseCogBinary: "nonexistent-cog-pose-estimation",
|
||||
countCogBinary: "nonexistent-cog-person-count",
|
||||
jobsDir: os.tmpdir(),
|
||||
};
|
||||
|
||||
// ── bfldLastScan tests ────────────────────────────────────────────────────
|
||||
|
||||
describe("ruview.bfld.last_scan handler", () => {
|
||||
it("1. returns {ok:false, warn:true} when sensing-server is not reachable", async () => {
|
||||
const r = await bfldLastScan({}, testConfig) as Record<string, unknown>;
|
||||
expect(r["ok"]).toBe(false);
|
||||
expect(r["warn"]).toBe(true);
|
||||
expect(typeof r["error"]).toBe("string");
|
||||
expect(r["hint"]).toMatch(/sensing-server/i);
|
||||
});
|
||||
|
||||
it("2. returns {ok:false, warn:true} on malformed response shape (missing node_id)", async () => {
|
||||
// We simulate a malformed response by pointing to a server returning bad JSON.
|
||||
// Since no server is listening we still get the network error path — that's fine.
|
||||
// The malformed-shape guard is unit-tested separately via direct invocation.
|
||||
const r = await bfldLastScan({ node_id: "test-node" }, testConfig) as Record<string, unknown>;
|
||||
expect(r["ok"]).toBe(false);
|
||||
expect(r["warn"]).toBe(true);
|
||||
});
|
||||
|
||||
it("3. converts timestamp_ns → timestamp_ms correctly (property-based check)", () => {
|
||||
// Verify the arithmetic directly: 1_000_000 ns === 1 ms
|
||||
const ns = 1_700_000_000_000_000_000; // 2023-11-14T22:13:20.000Z in ns
|
||||
const expectedMs = Math.round(ns / 1_000_000);
|
||||
expect(expectedMs).toBe(1_700_000_000_000); // 2023-11-14T22:13:20.000Z in ms
|
||||
});
|
||||
|
||||
it("4. identity_risk_score is null when absent in wire payload", () => {
|
||||
// The null coalescing in the handler: data.identity_risk_score ?? null
|
||||
const raw: null = null;
|
||||
expect(raw ?? null).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ruview.bfld.last_scan schema (BfldLastScanInputSchema)", () => {
|
||||
it("5. accepts empty object (node_id optional)", () => {
|
||||
expect(() => BfldLastScanInputSchema.parse({})).not.toThrow();
|
||||
});
|
||||
|
||||
it("6. rejects node_id as empty string", () => {
|
||||
expect(() => BfldLastScanInputSchema.parse({ node_id: "" })).toThrow();
|
||||
});
|
||||
|
||||
it("accepts node_id + sensing_server_url", () => {
|
||||
const r = BfldLastScanInputSchema.parse({
|
||||
node_id: "cognitum-seed-1",
|
||||
sensing_server_url: "http://localhost:3000",
|
||||
});
|
||||
expect(r.node_id).toBe("cognitum-seed-1");
|
||||
});
|
||||
});
|
||||
|
||||
// ── bfldSubscribe tests ───────────────────────────────────────────────────
|
||||
|
||||
describe("ruview.bfld.subscribe handler", () => {
|
||||
it("7. returns subscription_id + future expires_at (synthetic path — server unreachable)", async () => {
|
||||
const before = Date.now();
|
||||
const r = await bfldSubscribe({ duration_s: 60 }, testConfig) as Record<string, unknown>;
|
||||
// Both ok:true (server responded) and ok:false,warn:true (synthetic) are valid here.
|
||||
// Since no server is running we expect the synthetic warn path.
|
||||
expect(r["subscription_id"]).toBeDefined();
|
||||
expect(typeof r["subscription_id"]).toBe("string");
|
||||
expect(typeof r["expires_at"]).toBe("number");
|
||||
const expiresAt = r["expires_at"] as number;
|
||||
expect(expiresAt).toBeGreaterThanOrEqual(before + 60_000 - 50); // 50 ms tolerance
|
||||
});
|
||||
|
||||
it("8. subscription_id in synthetic path is a valid UUID v4", async () => {
|
||||
const r = await bfldSubscribe({ duration_s: 30 }, testConfig) as Record<string, unknown>;
|
||||
const id = r["subscription_id"] as string;
|
||||
const uuidV4Re = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
expect(uuidV4Re.test(id)).toBe(true);
|
||||
});
|
||||
|
||||
it("9. expires_at is approximately Date.now() + duration_s * 1000", async () => {
|
||||
const duration = 120;
|
||||
const before = Date.now();
|
||||
const r = await bfldSubscribe({ duration_s: duration }, testConfig) as Record<string, unknown>;
|
||||
const expiresAt = r["expires_at"] as number;
|
||||
const after = Date.now();
|
||||
expect(expiresAt).toBeGreaterThanOrEqual(before + duration * 1000 - 50);
|
||||
expect(expiresAt).toBeLessThanOrEqual(after + duration * 1000 + 50);
|
||||
});
|
||||
|
||||
it("10. topic matches ruview/<node_id>/bfld/* pattern", async () => {
|
||||
const r = await bfldSubscribe({ node_id: "seed-1", duration_s: 10 }, testConfig) as Record<string, unknown>;
|
||||
expect(r["topic"]).toBe("ruview/seed-1/bfld/*");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ruview.bfld.subscribe schema (BfldSubscribeInputSchema)", () => {
|
||||
it("11. rejects duration_s > 3600", () => {
|
||||
expect(() => BfldSubscribeInputSchema.parse({ duration_s: 3601 })).toThrow();
|
||||
});
|
||||
|
||||
it("12. rejects duration_s = 0 (must be positive)", () => {
|
||||
expect(() => BfldSubscribeInputSchema.parse({ duration_s: 0 })).toThrow();
|
||||
});
|
||||
|
||||
it("accepts valid duration_s with optional node_id", () => {
|
||||
const r = BfldSubscribeInputSchema.parse({ duration_s: 300, node_id: "node-x" });
|
||||
expect(r.duration_s).toBe(300);
|
||||
expect(r.node_id).toBe("node-x");
|
||||
});
|
||||
});
|
||||
@@ -1,167 +0,0 @@
|
||||
/**
|
||||
* ADR-124 §3 Architecture — Streamable HTTP transport security tests.
|
||||
*
|
||||
* Tests the Origin-validation middleware and bearer-token auth gate.
|
||||
* No live MCP server needed for the guard logic — buildHttpApp is tested
|
||||
* with a minimal stub McpServer that never actually processes JSON-RPC.
|
||||
*
|
||||
* Covered:
|
||||
* 1. isOriginAllowed() unit tests — the pure function driving the gate
|
||||
* 2. POST /mcp with cross-origin Origin → 403
|
||||
* 3. POST /mcp with allowed Origin → passes Origin gate (non-403)
|
||||
* 4. POST /mcp with no Origin header → passes Origin gate (non-403)
|
||||
* 5. Bearer token required, wrong token → 401
|
||||
* 6. Bearer token required, correct token + wildcard origin → passes (non-401)
|
||||
*/
|
||||
|
||||
import * as http from "node:http";
|
||||
import { isOriginAllowed, buildHttpApp } from "../src/http-transport.js";
|
||||
import { Server as McpServer } from "@modelcontextprotocol/sdk/server/index.js";
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeMockMcpServer(): McpServer {
|
||||
return new McpServer(
|
||||
{ name: "test-rvagent", version: "0.0.0" },
|
||||
{ capabilities: { tools: {} } }
|
||||
);
|
||||
}
|
||||
|
||||
async function post(
|
||||
port: number,
|
||||
path: string,
|
||||
headers: Record<string, string>,
|
||||
body: string
|
||||
): Promise<{ status: number; body: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request(
|
||||
{
|
||||
hostname: "127.0.0.1",
|
||||
port,
|
||||
method: "POST",
|
||||
path,
|
||||
headers: { "Content-Type": "application/json", ...headers },
|
||||
},
|
||||
(res) => {
|
||||
let data = "";
|
||||
res.on("data", (chunk: Buffer) => { data += chunk.toString(); });
|
||||
res.on("end", () => resolve({ status: res.statusCode ?? 0, body: data }));
|
||||
}
|
||||
);
|
||||
req.on("error", reject);
|
||||
req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function startServer(
|
||||
opts: Parameters<typeof buildHttpApp>[1],
|
||||
basePort: number
|
||||
): Promise<{ port: number; close: () => Promise<void> }> {
|
||||
const port = basePort + Math.floor(Math.random() * 100);
|
||||
const { httpServer } = buildHttpApp(makeMockMcpServer(), opts);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
httpServer.once("error", reject);
|
||||
httpServer.listen(port, "127.0.0.1", () => resolve());
|
||||
});
|
||||
const close = () =>
|
||||
new Promise<void>((res, rej) =>
|
||||
httpServer.close((e) => (e ? rej(e) : res()))
|
||||
);
|
||||
return { port, close };
|
||||
}
|
||||
|
||||
const MCP_BODY = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" });
|
||||
|
||||
// ── 1. isOriginAllowed unit tests ──────────────────────────────────────────
|
||||
|
||||
describe("isOriginAllowed()", () => {
|
||||
const allow = ["http://localhost", "http://127.0.0.1"];
|
||||
|
||||
it("allows undefined origin (non-browser request, no Origin header)", () => {
|
||||
expect(isOriginAllowed(undefined, allow)).toBe(true);
|
||||
});
|
||||
|
||||
it("allows an origin in the allowlist", () => {
|
||||
expect(isOriginAllowed("http://localhost", allow)).toBe(true);
|
||||
expect(isOriginAllowed("http://127.0.0.1", allow)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an origin NOT in the allowlist", () => {
|
||||
expect(isOriginAllowed("https://evil.example.com", allow)).toBe(false);
|
||||
});
|
||||
|
||||
it("allows anything when allowedOrigins includes '*'", () => {
|
||||
expect(isOriginAllowed("https://evil.example.com", ["*"])).toBe(true);
|
||||
});
|
||||
|
||||
it("is case-sensitive per RFC 6454", () => {
|
||||
expect(isOriginAllowed("HTTP://localhost", allow)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 2-4. Origin-validation integration tests ───────────────────────────────
|
||||
|
||||
describe("HTTP transport Origin-validation middleware", () => {
|
||||
let port: number;
|
||||
let close: () => Promise<void>;
|
||||
|
||||
beforeAll(async () => {
|
||||
const srv = await startServer(
|
||||
{ allowedOrigins: ["http://localhost", "http://127.0.0.1"] },
|
||||
49200
|
||||
);
|
||||
port = srv.port;
|
||||
close = srv.close;
|
||||
});
|
||||
|
||||
afterAll(async () => { await close(); });
|
||||
|
||||
it("rejects cross-origin POST /mcp with 403", async () => {
|
||||
const r = await post(port, "/mcp", { Origin: "https://evil.example.com" }, MCP_BODY);
|
||||
expect(r.status).toBe(403);
|
||||
const body = JSON.parse(r.body) as Record<string, unknown>;
|
||||
expect(body["error"]).toMatch(/cross-origin/i);
|
||||
});
|
||||
|
||||
it("passes Origin gate for http://localhost — status is not 403", async () => {
|
||||
const r = await post(port, "/mcp", { Origin: "http://localhost" }, MCP_BODY);
|
||||
expect(r.status).not.toBe(403);
|
||||
});
|
||||
|
||||
it("passes Origin gate with no Origin header — status is not 403", async () => {
|
||||
const r = await post(port, "/mcp", {}, MCP_BODY);
|
||||
expect(r.status).not.toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 5-6. Bearer-token auth integration tests ──────────────────────────────
|
||||
|
||||
describe("HTTP transport bearer-token auth gate", () => {
|
||||
const SECRET = "test-secret-token-xyz";
|
||||
let port: number;
|
||||
let close: () => Promise<void>;
|
||||
|
||||
beforeAll(async () => {
|
||||
const srv = await startServer({ allowedOrigins: ["*"], bearerToken: SECRET }, 49400);
|
||||
port = srv.port;
|
||||
close = srv.close;
|
||||
});
|
||||
|
||||
afterAll(async () => { await close(); });
|
||||
|
||||
it("rejects missing Authorization header with 401", async () => {
|
||||
const r = await post(port, "/mcp", {}, MCP_BODY);
|
||||
expect(r.status).toBe(401);
|
||||
});
|
||||
|
||||
it("rejects wrong bearer token with 401", async () => {
|
||||
const r = await post(port, "/mcp", { Authorization: "Bearer wrong" }, MCP_BODY);
|
||||
expect(r.status).toBe(401);
|
||||
});
|
||||
|
||||
it("passes auth gate with correct bearer token — status is not 401", async () => {
|
||||
const r = await post(port, "/mcp", { Authorization: `Bearer ${SECRET}` }, MCP_BODY);
|
||||
expect(r.status).not.toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -1,101 +0,0 @@
|
||||
/**
|
||||
* ADR-124 §2 manifest validation test.
|
||||
*
|
||||
* Guards that package.json satisfies every structural decision from ADR-124 §2:
|
||||
* 1. Package name is @ruvnet/rvagent
|
||||
* 2. Version is >= 0.1.0
|
||||
* 3. engines.node is >= 20
|
||||
* 4. bin includes the "rvagent" key (npx @ruvnet/rvagent invocation)
|
||||
* 5. exports["." ] includes both "import" and "types" keys (ESM + types in tarball)
|
||||
* 6. publishConfig.access === "public" (scoped package must be explicit)
|
||||
* 7. @modelcontextprotocol/sdk is a runtime dependency (dual-transport server)
|
||||
* 8. zod is a runtime dependency (input schema validation)
|
||||
* 9. type === "module" (ESM-first, Node.js 20+ native)
|
||||
* 10. license === "Apache-2.0"
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const pkgPath = resolve(__dirname, "../package.json");
|
||||
|
||||
// Parse once; keep raw for snapshot assertions.
|
||||
const raw = readFileSync(pkgPath, "utf-8");
|
||||
const pkg = JSON.parse(raw) as Record<string, unknown>;
|
||||
|
||||
// Helper to assert string field value.
|
||||
function assertField(field: string, expected: string): void {
|
||||
expect(pkg[field]).toBe(expected);
|
||||
}
|
||||
|
||||
// Helper to get a nested value.
|
||||
function nested<T>(obj: Record<string, unknown>, ...keys: string[]): T {
|
||||
let cur: unknown = obj;
|
||||
for (const k of keys) {
|
||||
if (typeof cur !== "object" || cur === null) {
|
||||
throw new Error(`Expected object at key "${k}"`);
|
||||
}
|
||||
cur = (cur as Record<string, unknown>)[k];
|
||||
}
|
||||
return cur as T;
|
||||
}
|
||||
|
||||
describe("@ruvnet/rvagent package.json (ADR-124 §2)", () => {
|
||||
it("§2.1 — name is @ruvnet/rvagent", () => {
|
||||
assertField("name", "@ruvnet/rvagent");
|
||||
});
|
||||
|
||||
it("§2.2 — version is semver >= 0.1.0", () => {
|
||||
const version = pkg["version"] as string;
|
||||
expect(typeof version).toBe("string");
|
||||
const [major, minor] = version.split(".").map(Number);
|
||||
const isAtLeast010 = (major ?? 0) > 0 || (minor ?? 0) >= 1;
|
||||
expect(isAtLeast010).toBe(true);
|
||||
});
|
||||
|
||||
it("§2.3 — engines.node requires Node.js >= 20", () => {
|
||||
const nodeRange = nested<string>(pkg, "engines", "node");
|
||||
expect(typeof nodeRange).toBe("string");
|
||||
// Accept >=20 or >=20.0.0 patterns.
|
||||
expect(nodeRange).toMatch(/>=\s*20/);
|
||||
});
|
||||
|
||||
it("§2.4 — bin.rvagent is defined (npx @ruvnet/rvagent invocation)", () => {
|
||||
const bin = nested<Record<string, string>>(pkg, "bin");
|
||||
expect(typeof bin["rvagent"]).toBe("string");
|
||||
expect(bin["rvagent"]).toMatch(/dist\/index\.js/);
|
||||
});
|
||||
|
||||
it("§2.5 — exports['.'] has import + types keys (ESM + TypeScript declarations)", () => {
|
||||
const exports = nested<Record<string, Record<string, string>>>(pkg, "exports");
|
||||
const dotExport = exports["."];
|
||||
expect(dotExport).toBeDefined();
|
||||
expect(typeof dotExport?.["import"]).toBe("string");
|
||||
expect(typeof dotExport?.["types"]).toBe("string");
|
||||
});
|
||||
|
||||
it("§2.6 — publishConfig.access is 'public' (scoped package requirement)", () => {
|
||||
const access = nested<string>(pkg, "publishConfig", "access");
|
||||
expect(access).toBe("public");
|
||||
});
|
||||
|
||||
it("§2.7 — @modelcontextprotocol/sdk is a runtime dependency", () => {
|
||||
const deps = nested<Record<string, string>>(pkg, "dependencies");
|
||||
expect(typeof deps["@modelcontextprotocol/sdk"]).toBe("string");
|
||||
});
|
||||
|
||||
it("§2.8 — zod is a runtime dependency", () => {
|
||||
const deps = nested<Record<string, string>>(pkg, "dependencies");
|
||||
expect(typeof deps["zod"]).toBe("string");
|
||||
});
|
||||
|
||||
it("§2.9 — type is 'module' (ESM-first, Node.js 20+ native)", () => {
|
||||
assertField("type", "module");
|
||||
});
|
||||
|
||||
it("§2.10 — license is Apache-2.0", () => {
|
||||
assertField("license", "Apache-2.0");
|
||||
});
|
||||
});
|
||||
@@ -1,208 +0,0 @@
|
||||
/**
|
||||
* ADR-124 §4.1 / §4.1a schema coverage tests.
|
||||
*
|
||||
* Guards:
|
||||
* 1. Every catalogued tool name appears in TOOL_NAMES and TOOL_INPUT_SCHEMAS.
|
||||
* 2. TOOL_INPUT_SCHEMAS has no extra (undocumented) keys.
|
||||
* 3. Each schema accepts its documented happy-path input without throwing.
|
||||
* 4. Each schema rejects structurally invalid input (Zod parse failure).
|
||||
* 5. Shared sub-schemas (NodeId, DurationS, SemanticPrimitiveKind) enforce
|
||||
* their documented constraints.
|
||||
*/
|
||||
|
||||
import {
|
||||
TOOL_NAMES,
|
||||
TOOL_INPUT_SCHEMAS,
|
||||
SemanticPrimitiveKindSchema,
|
||||
DurationSSchema,
|
||||
NodeIdSchema,
|
||||
PosePersonResultSchema,
|
||||
PresenceNowInputSchema,
|
||||
VitalsGetBreathingInputSchema,
|
||||
PrimitivesGetInputSchema,
|
||||
BfldLastScanInputSchema,
|
||||
NodeStatusInputSchema,
|
||||
VectorSearchPoseInputSchema,
|
||||
VectorStorePoseInputSchema,
|
||||
PolicyCanAccessVitalsInputSchema,
|
||||
PolicyCanSubscribeInputSchema,
|
||||
PolicyRedactIdentityFieldsInputSchema,
|
||||
} from "../src/schemas/index.js";
|
||||
|
||||
// ── 1. Catalog completeness ────────────────────────────────────────────────
|
||||
|
||||
describe("TOOL_NAMES catalog (ADR-124 §4.1 + §4.1a)", () => {
|
||||
const EXPECTED_COUNT = 20; // 15 sensing + 5 policy
|
||||
|
||||
it("contains exactly 20 tools", () => {
|
||||
expect(TOOL_NAMES).toHaveLength(EXPECTED_COUNT);
|
||||
});
|
||||
|
||||
it("contains all 15 §4.1 sensing tool names", () => {
|
||||
const sensing = [
|
||||
"ruview.presence.now",
|
||||
"ruview.vitals.get_breathing",
|
||||
"ruview.vitals.get_heart_rate",
|
||||
"ruview.vitals.get_all",
|
||||
"ruview.pose.latest",
|
||||
"ruview.pose.subscribe",
|
||||
"ruview.primitives.get",
|
||||
"ruview.primitives.list_active",
|
||||
"ruview.primitives.subscribe",
|
||||
"ruview.bfld.last_scan",
|
||||
"ruview.bfld.subscribe",
|
||||
"ruview.node.list",
|
||||
"ruview.node.status",
|
||||
"ruview.vector.search_pose",
|
||||
"ruview.vector.store_pose",
|
||||
];
|
||||
for (const name of sensing) {
|
||||
expect(TOOL_NAMES).toContain(name);
|
||||
}
|
||||
});
|
||||
|
||||
it("contains all 5 §4.1a policy tool names", () => {
|
||||
const policy = [
|
||||
"ruview.policy.can_access_vitals",
|
||||
"ruview.policy.can_query_presence",
|
||||
"ruview.policy.can_subscribe",
|
||||
"ruview.policy.redact_identity_fields",
|
||||
"ruview.policy.audit_log",
|
||||
];
|
||||
for (const name of policy) {
|
||||
expect(TOOL_NAMES).toContain(name);
|
||||
}
|
||||
});
|
||||
|
||||
it("TOOL_INPUT_SCHEMAS has a schema for every catalogued tool name", () => {
|
||||
for (const name of TOOL_NAMES) {
|
||||
// Use Object.prototype.hasOwnProperty to avoid Jest's dotted-path
|
||||
// interpretation of toHaveProperty (dots = nested path in Jest).
|
||||
expect(Object.prototype.hasOwnProperty.call(TOOL_INPUT_SCHEMAS, name)).toBe(true);
|
||||
expect(TOOL_INPUT_SCHEMAS[name]).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("TOOL_INPUT_SCHEMAS has no extra keys beyond the catalog", () => {
|
||||
const schemaKeys = Object.keys(TOOL_INPUT_SCHEMAS).sort();
|
||||
const catalogKeys = [...TOOL_NAMES].sort();
|
||||
expect(schemaKeys).toEqual(catalogKeys);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 2. Happy-path parse ────────────────────────────────────────────────────
|
||||
|
||||
describe("Schema happy-path acceptance", () => {
|
||||
it("PresenceNow — accepts empty object (node_id optional)", () => {
|
||||
expect(() => PresenceNowInputSchema.parse({})).not.toThrow();
|
||||
});
|
||||
|
||||
it("PresenceNow — accepts object with node_id", () => {
|
||||
const r = PresenceNowInputSchema.parse({ node_id: "node-abc" });
|
||||
expect(r.node_id).toBe("node-abc");
|
||||
});
|
||||
|
||||
it("VitalsGetBreathing — accepts window_s and node_id", () => {
|
||||
const r = VitalsGetBreathingInputSchema.parse({ window_s: 30, node_id: "n1" });
|
||||
expect(r.window_s).toBe(30);
|
||||
});
|
||||
|
||||
it("PrimitivesGet — accepts valid primitive kind", () => {
|
||||
const r = PrimitivesGetInputSchema.parse({ primitive: "fall_detected" });
|
||||
expect(r.primitive).toBe("fall_detected");
|
||||
});
|
||||
|
||||
it("BfldLastScan — accepts empty object", () => {
|
||||
expect(() => BfldLastScanInputSchema.parse({})).not.toThrow();
|
||||
});
|
||||
|
||||
it("NodeStatus — accepts node_id string", () => {
|
||||
const r = NodeStatusInputSchema.parse({ node_id: "cognitum-seed-1" });
|
||||
expect(r.node_id).toBe("cognitum-seed-1");
|
||||
});
|
||||
|
||||
it("VectorSearchPose — applies default k=10", () => {
|
||||
const r = VectorSearchPoseInputSchema.parse({ query_embedding: [0.1, 0.2, 0.3] });
|
||||
expect(r.k).toBe(10);
|
||||
});
|
||||
|
||||
it("VectorStorePose — accepts a valid 17-keypoint pose", () => {
|
||||
const kpts = Array.from({ length: 17 }, (_, i) => [i * 0.05, i * 0.03] as [number, number]);
|
||||
const r = VectorStorePoseInputSchema.parse({
|
||||
pose: { keypoints: kpts, confidence: 0.92 },
|
||||
node_id: "node-x",
|
||||
});
|
||||
expect(r.pose.keypoints).toHaveLength(17);
|
||||
});
|
||||
|
||||
it("PolicyCanAccessVitals — accepts valid vital value", () => {
|
||||
const r = PolicyCanAccessVitalsInputSchema.parse({
|
||||
agent_id: "agent-007",
|
||||
node_id: "node-1",
|
||||
vital: "heart_rate",
|
||||
});
|
||||
expect(r.vital).toBe("heart_rate");
|
||||
});
|
||||
|
||||
it("PolicyCanSubscribe — accepts valid duration_s", () => {
|
||||
const r = PolicyCanSubscribeInputSchema.parse({
|
||||
agent_id: "agent-007",
|
||||
topic: "ruview.vitals.get_all",
|
||||
duration_s: 300,
|
||||
});
|
||||
expect(r.duration_s).toBe(300);
|
||||
});
|
||||
|
||||
it("PolicyRedactIdentityFields — accepts arbitrary payload record", () => {
|
||||
const r = PolicyRedactIdentityFieldsInputSchema.parse({
|
||||
payload: { sta_mac: "AA:BB:CC:DD:EE:FF", n_persons: 2 },
|
||||
agent_id: "agent-007",
|
||||
});
|
||||
expect(r.payload).toHaveProperty("sta_mac");
|
||||
});
|
||||
});
|
||||
|
||||
// ── 3. Constraint rejection ────────────────────────────────────────────────
|
||||
|
||||
describe("Schema constraint enforcement", () => {
|
||||
it("NodeIdSchema — rejects empty string", () => {
|
||||
expect(() => NodeIdSchema.parse("")).toThrow();
|
||||
});
|
||||
|
||||
it("DurationSSchema — rejects zero", () => {
|
||||
expect(() => DurationSSchema.parse(0)).toThrow();
|
||||
});
|
||||
|
||||
it("DurationSSchema — rejects value > 3600", () => {
|
||||
expect(() => DurationSSchema.parse(3601)).toThrow();
|
||||
});
|
||||
|
||||
it("SemanticPrimitiveKind — rejects unknown primitive", () => {
|
||||
expect(() => SemanticPrimitiveKindSchema.parse("unknown_primitive")).toThrow();
|
||||
});
|
||||
|
||||
it("PosePersonResult — rejects keypoints array with wrong length", () => {
|
||||
const badKpts = Array.from({ length: 5 }, () => [0, 0] as [number, number]);
|
||||
expect(() => PosePersonResultSchema.parse({ keypoints: badKpts, confidence: 0.9 })).toThrow();
|
||||
});
|
||||
|
||||
it("VectorSearchPose — rejects k > 100", () => {
|
||||
expect(() =>
|
||||
VectorSearchPoseInputSchema.parse({ query_embedding: [0.1], k: 101 })
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("PolicyCanAccessVitals — rejects unknown vital value", () => {
|
||||
expect(() =>
|
||||
PolicyCanAccessVitalsInputSchema.parse({
|
||||
agent_id: "a",
|
||||
node_id: "n",
|
||||
vital: "temperature",
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("NodeStatus — rejects missing node_id", () => {
|
||||
expect(() => NodeStatusInputSchema.parse({})).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,177 +0,0 @@
|
||||
/**
|
||||
* ADR-124 Phase 4 (Refinement) iter 5 — Presence + Vitals tool tests.
|
||||
*
|
||||
* All four tools share the fetchVitals helper; tests exercise:
|
||||
* - Soft-failure path (sensing-server unreachable)
|
||||
* - Field projection correctness from a fixture EdgeVitalsMessage
|
||||
* - Schema acceptance / rejection
|
||||
*
|
||||
* The fixture is injected via a custom sensing_server_url that points to a
|
||||
* port with nothing listening — identical to the BFLD tests pattern.
|
||||
*/
|
||||
|
||||
import os from "node:os";
|
||||
import type { RuviewConfig, EdgeVitalsMessage } from "../src/types.js";
|
||||
import { presenceNow, presenceNowSchema } from "../src/tools/presence-now.js";
|
||||
import { vitalsGetBreathing, vitalsGetBreathingSchema } from "../src/tools/vitals-get-breathing.js";
|
||||
import { vitalsGetHeartRate, vitalsGetHeartRateSchema } from "../src/tools/vitals-get-heart-rate.js";
|
||||
import { vitalsGetAll, vitalsGetAllSchema } from "../src/tools/vitals-get-all.js";
|
||||
import { fetchVitals, resolveNodeId } from "../src/tools/vitals-fetch.js";
|
||||
|
||||
const testConfig: RuviewConfig = {
|
||||
sensingServerUrl: "http://127.0.0.1:19997", // nothing listening
|
||||
apiToken: undefined,
|
||||
poseCogBinary: "nonexistent",
|
||||
countCogBinary: "nonexistent",
|
||||
jobsDir: os.tmpdir(),
|
||||
};
|
||||
|
||||
/** Fixture that mirrors a realistic EdgeVitalsMessage from a live node. */
|
||||
const FIXTURE: EdgeVitalsMessage = {
|
||||
node_id: "cognitum-seed-1",
|
||||
timestamp_ms: 1_716_500_000_000,
|
||||
presence: true,
|
||||
n_persons: 2,
|
||||
confidence: 0.87,
|
||||
breathing_rate_bpm: 14.5,
|
||||
heartrate_bpm: 72.0,
|
||||
motion: 0.12,
|
||||
zone_id: "living_room",
|
||||
};
|
||||
|
||||
// ── resolveNodeId ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("resolveNodeId()", () => {
|
||||
it("returns supplied node_id", () => expect(resolveNodeId("node-x")).toBe("node-x"));
|
||||
it("returns 'default' when undefined", () => expect(resolveNodeId(undefined)).toBe("default"));
|
||||
});
|
||||
|
||||
// ── fetchVitals soft-failure ──────────────────────────────────────────────
|
||||
|
||||
describe("fetchVitals()", () => {
|
||||
it("returns {ok:false, warn:true} when server unreachable", async () => {
|
||||
const r = await fetchVitals("default", "http://127.0.0.1:19997", undefined);
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.warn).toBe(true);
|
||||
expect(typeof r.error).toBe("string");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── ruview.presence.now ───────────────────────────────────────────────────
|
||||
|
||||
describe("ruview.presence.now handler", () => {
|
||||
it("soft-fails when sensing-server unreachable", async () => {
|
||||
const r = await presenceNow({}, testConfig) as Record<string, unknown>;
|
||||
expect(r["ok"]).toBe(false);
|
||||
expect(r["warn"]).toBe(true);
|
||||
});
|
||||
|
||||
it("projects correct fields from fixture (unit check)", () => {
|
||||
// Direct projection logic — mirrors what the handler does after fetchVitals succeeds.
|
||||
const out = {
|
||||
ok: true,
|
||||
node_id: FIXTURE.node_id,
|
||||
present: FIXTURE.presence,
|
||||
n_persons: FIXTURE.n_persons,
|
||||
confidence: FIXTURE.confidence,
|
||||
timestamp_ms: FIXTURE.timestamp_ms,
|
||||
};
|
||||
expect(out.present).toBe(true);
|
||||
expect(out.n_persons).toBe(2);
|
||||
expect(out.confidence).toBe(0.87);
|
||||
expect(out.node_id).toBe("cognitum-seed-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("presenceNowSchema", () => {
|
||||
it("accepts empty object", () => expect(() => presenceNowSchema.parse({})).not.toThrow());
|
||||
it("rejects empty string node_id", () => {
|
||||
expect(() => presenceNowSchema.parse({ node_id: "" })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── ruview.vitals.get_breathing ───────────────────────────────────────────
|
||||
|
||||
describe("ruview.vitals.get_breathing handler", () => {
|
||||
it("soft-fails when sensing-server unreachable", async () => {
|
||||
const r = await vitalsGetBreathing({}, testConfig) as Record<string, unknown>;
|
||||
expect(r["ok"]).toBe(false);
|
||||
expect(r["warn"]).toBe(true);
|
||||
});
|
||||
|
||||
it("projects breathing_rate_bpm from fixture", () => {
|
||||
const out = {
|
||||
ok: true,
|
||||
node_id: FIXTURE.node_id,
|
||||
breathing_rate_bpm: FIXTURE.breathing_rate_bpm,
|
||||
confidence: FIXTURE.confidence,
|
||||
timestamp_ms: FIXTURE.timestamp_ms,
|
||||
};
|
||||
expect(out.breathing_rate_bpm).toBe(14.5);
|
||||
});
|
||||
|
||||
it("breathing_rate_bpm is null when fixture has null", () => {
|
||||
const nullFixture: EdgeVitalsMessage = { ...FIXTURE, breathing_rate_bpm: null };
|
||||
expect(nullFixture.breathing_rate_bpm).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("vitalsGetBreathingSchema", () => {
|
||||
it("accepts window_s up to 300", () => {
|
||||
expect(() => vitalsGetBreathingSchema.parse({ window_s: 300 })).not.toThrow();
|
||||
});
|
||||
it("rejects window_s > 300", () => {
|
||||
expect(() => vitalsGetBreathingSchema.parse({ window_s: 301 })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── ruview.vitals.get_heart_rate ──────────────────────────────────────────
|
||||
|
||||
describe("ruview.vitals.get_heart_rate handler", () => {
|
||||
it("soft-fails when sensing-server unreachable", async () => {
|
||||
const r = await vitalsGetHeartRate({}, testConfig) as Record<string, unknown>;
|
||||
expect(r["ok"]).toBe(false);
|
||||
expect(r["warn"]).toBe(true);
|
||||
});
|
||||
|
||||
it("projects heartrate_bpm from fixture", () => {
|
||||
const out = { ok: true, heartrate_bpm: FIXTURE.heartrate_bpm };
|
||||
expect(out.heartrate_bpm).toBe(72.0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("vitalsGetHeartRateSchema", () => {
|
||||
it("accepts empty object", () => {
|
||||
expect(() => vitalsGetHeartRateSchema.parse({})).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── ruview.vitals.get_all ─────────────────────────────────────────────────
|
||||
|
||||
describe("ruview.vitals.get_all handler", () => {
|
||||
it("soft-fails when sensing-server unreachable", async () => {
|
||||
const r = await vitalsGetAll({}, testConfig) as Record<string, unknown>;
|
||||
expect(r["ok"]).toBe(false);
|
||||
expect(r["warn"]).toBe(true);
|
||||
});
|
||||
|
||||
it("spreads all fixture fields (no raw field present)", () => {
|
||||
const out = { ok: true, ...FIXTURE };
|
||||
expect(out.node_id).toBe("cognitum-seed-1");
|
||||
expect(out.presence).toBe(true);
|
||||
expect(out.breathing_rate_bpm).toBe(14.5);
|
||||
expect(out.heartrate_bpm).toBe(72.0);
|
||||
expect(out.motion).toBe(0.12);
|
||||
expect(out.zone_id).toBe("living_room");
|
||||
expect((out as Record<string, unknown>)["raw"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("vitalsGetAllSchema", () => {
|
||||
it("accepts node_id", () => {
|
||||
const r = vitalsGetAllSchema.parse({ node_id: "seed-1" });
|
||||
expect(r.node_id).toBe("seed-1");
|
||||
});
|
||||
});
|
||||
Generated
+5
-75
@@ -16,7 +16,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures 0.2.17",
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -198,12 +198,6 @@ dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrayref"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.6"
|
||||
@@ -462,20 +456,6 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blake3"
|
||||
version = "1.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"arrayvec",
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"constant_time_eq 0.4.2",
|
||||
"cpufeatures 0.3.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
@@ -1108,12 +1088,6 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc"
|
||||
|
||||
[[package]]
|
||||
name = "constant_time_eq"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.4.0"
|
||||
@@ -1199,30 +1173,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc"
|
||||
version = "3.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d"
|
||||
dependencies = [
|
||||
"crc-catalog",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc-catalog"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
@@ -1432,7 +1382,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"cpufeatures",
|
||||
"curve25519-dalek-derive",
|
||||
"digest",
|
||||
"fiat-crypto",
|
||||
@@ -7050,7 +7000,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
@@ -7061,7 +7011,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
@@ -7305,12 +7255,6 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "static_assertions"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||
|
||||
[[package]]
|
||||
name = "strength_reduce"
|
||||
version = "0.2.4"
|
||||
@@ -9189,20 +9133,6 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-bfld"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"crc",
|
||||
"proptest",
|
||||
"rumqttc",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"static_assertions",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-cli"
|
||||
version = "0.3.0"
|
||||
@@ -10449,7 +10379,7 @@ dependencies = [
|
||||
"aes",
|
||||
"byteorder",
|
||||
"bzip2",
|
||||
"constant_time_eq 0.1.5",
|
||||
"constant_time_eq",
|
||||
"crc32fast",
|
||||
"crossbeam-utils",
|
||||
"flate2",
|
||||
|
||||
@@ -42,11 +42,6 @@ members = [
|
||||
# ADR-115 MQTT publisher as a Seed-installable artifact with
|
||||
# mDNS, embedded broker, RuVector thresholds, Ed25519 witness.
|
||||
"crates/cog-ha-matter",
|
||||
# ADR-118: BFLD — Beamforming Feedback Layer for Detection. The
|
||||
# privacy/safety layer that measures and gates identity leakage from
|
||||
# WiFi BFI captures. Sub-ADRs: 119 (frame), 120 (privacy class),
|
||||
# 121 (identity risk), 122 (HA/Matter), 123 (capture path).
|
||||
"crates/wifi-densepose-bfld",
|
||||
# rvCSI — edge RF sensing runtime (ADR-095 platform, ADR-096 FFI/crate layout):
|
||||
# lives in its own repo (https://github.com/ruvnet/rvcsi), vendored here as
|
||||
# `vendor/rvcsi` and published to crates.io as `rvcsi-*` 0.3.x. Depend on the
|
||||
|
||||
@@ -6,6 +6,7 @@ authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Cognitum Cog: Home Assistant + Matter integration for the Seed (ADR-116). Wraps ADR-115's HA-DISCO + HA-MIND publisher as a Seed-installable artifact with mDNS, embedded broker, RuVector-backed thresholds, and Ed25519 witness."
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
name = "cog-ha-matter"
|
||||
@@ -29,7 +30,7 @@ tokio = { workspace = true, features = ["full"] }
|
||||
|
||||
# ADR-115 publisher is the heart of this cog — we wrap it.
|
||||
# default-features = false matches the sensing-server's pattern.
|
||||
wifi-densepose-sensing-server = { version = "0.3.1", path = "../wifi-densepose-sensing-server", default-features = false, features = ["mqtt"] }
|
||||
wifi-densepose-sensing-server = { version = "0.3.0", path = "../wifi-densepose-sensing-server", default-features = false, features = ["mqtt"] }
|
||||
|
||||
# Hardware crate for SyncPacket + NodeState bridging (ADR-110 substrate).
|
||||
wifi-densepose-hardware = { version = "0.3.0", path = "../wifi-densepose-hardware" }
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
# BFLD HA Blueprints
|
||||
|
||||
Operator-ready Home Assistant automation blueprints for the BFLD entities
|
||||
published by `wifi-densepose-bfld`. Sourced from **ADR-122 §2.6**.
|
||||
|
||||
## Installing
|
||||
|
||||
Copy each `.yaml` file into your HA `blueprints/automation/` directory (or
|
||||
import via the HA UI: Settings → Automations & Scenes → Blueprints → Import).
|
||||
|
||||
## Available blueprints
|
||||
|
||||
| File | Purpose | BFLD entity consumed |
|
||||
|---|---|---|
|
||||
| `presence-lighting.yaml` | Turn a light on/off with BFLD occupancy | `binary_sensor.<node>_bfld_presence` |
|
||||
| `motion-hvac.yaml` | Adjust HVAC setpoint when motion crosses a threshold | `sensor.<node>_bfld_motion` |
|
||||
| `identity-risk-anomaly.yaml` | Notify operator on identity-risk z-score spike | `sensor.<node>_bfld_identity_risk` |
|
||||
|
||||
## Privacy notes
|
||||
|
||||
- `identity-risk-anomaly.yaml` requires `sensor.<node>_bfld_identity_risk` which is **only present at `privacy_class = Anonymous`** (per ADR-122 §2.1). At `privacy_class = Restricted` (e.g., care-home deployments) the entity is not advertised to HA at all, and this blueprint will fail validation — by design.
|
||||
- The `statistics_entity` input for `identity-risk-anomaly.yaml` requires the operator to first create an HA Statistics helper for the BFLD identity-risk sensor with a 7-day window. The blueprint reads `mean` + `standard_deviation` attributes.
|
||||
|
||||
## Source-of-truth blueprint structure tests
|
||||
|
||||
`v2/crates/wifi-densepose-bfld/tests/ha_blueprints.rs` validates each YAML at build time via `include_str!` and asserts the presence of the required HA-blueprint fields (`blueprint.name`, `blueprint.domain`, `input` block, `trigger`, `action`, `mode`).
|
||||
@@ -1,76 +0,0 @@
|
||||
blueprint:
|
||||
name: BFLD Identity-Risk Anomaly Notification
|
||||
description: >
|
||||
Notify the operator when BFLD's identity-risk score deviates significantly
|
||||
from its rolling 7-day baseline — a signal that the RF environment has
|
||||
shifted toward a higher-leakage regime (new AP firmware, attacker-grade
|
||||
sniffer in range, unusual propagation). Sourced from ADR-122 §2.6 and
|
||||
ADR-121 §2.4.
|
||||
domain: automation
|
||||
source_url: https://github.com/ruvnet/RuView/blob/main/v2/crates/cog-ha-matter/blueprints/bfld/identity-risk-anomaly.yaml
|
||||
input:
|
||||
bfld_identity_risk:
|
||||
name: BFLD Identity Risk sensor
|
||||
description: The `sensor.<node>_bfld_identity_risk` entity (only present at privacy_class = Anonymous).
|
||||
selector:
|
||||
entity:
|
||||
domain: sensor
|
||||
integration: mqtt
|
||||
notify_target:
|
||||
name: Notify target service
|
||||
description: HA notify service to call (e.g., notify.mobile_app_<phone>).
|
||||
selector:
|
||||
text: {}
|
||||
spike_threshold:
|
||||
name: Absolute spike threshold
|
||||
description: Trigger immediately when raw score >= this value.
|
||||
default: 0.8
|
||||
selector:
|
||||
number:
|
||||
min: 0.5
|
||||
max: 0.99
|
||||
step: 0.01
|
||||
z_score_threshold:
|
||||
name: Rolling z-score threshold
|
||||
description: Trigger when deviation from 7-day mean exceeds this many sigmas.
|
||||
default: 3.0
|
||||
selector:
|
||||
number:
|
||||
min: 1.5
|
||||
max: 6.0
|
||||
step: 0.5
|
||||
statistics_entity:
|
||||
name: Statistics helper entity for the 7-day baseline
|
||||
description: >
|
||||
An HA `statistics` integration entity computing mean + standard
|
||||
deviation of the BFLD identity-risk sensor over a 7-day window.
|
||||
Configure via Settings → Devices & Services → Helpers → Statistics.
|
||||
selector:
|
||||
entity:
|
||||
domain: sensor
|
||||
|
||||
trigger:
|
||||
- platform: numeric_state
|
||||
entity_id: !input bfld_identity_risk
|
||||
above: !input spike_threshold
|
||||
id: absolute_spike
|
||||
- platform: template
|
||||
value_template: >
|
||||
{% set raw = states(trigger.entity_id) | float(0) %}
|
||||
{% set mean = state_attr(!input statistics_entity, 'mean') | float(0) %}
|
||||
{% set sigma = state_attr(!input statistics_entity, 'standard_deviation') | float(0.01) %}
|
||||
{{ (raw - mean) / sigma >= z_score_threshold }}
|
||||
id: z_score_spike
|
||||
|
||||
variables:
|
||||
z_score_threshold: !input z_score_threshold
|
||||
|
||||
action:
|
||||
- service: !input notify_target
|
||||
data:
|
||||
title: BFLD Identity-Risk Anomaly
|
||||
message: >
|
||||
Node {{ trigger.entity_id }} identity-risk score is {{ states(trigger.entity_id) }}.
|
||||
Investigate possible RF-environment shift (new AP firmware, nearby sniffer,
|
||||
unusual multipath). See ADR-118 / ADR-121 for context.
|
||||
mode: single
|
||||
@@ -1,87 +0,0 @@
|
||||
blueprint:
|
||||
name: BFLD Motion-Aware HVAC
|
||||
description: >
|
||||
Adjust an HVAC climate entity's setpoint when BFLD's normalized motion
|
||||
score crosses a threshold, indicating active occupancy. Off-trigger
|
||||
restores the original setpoint after a debounce window. Sourced from
|
||||
ADR-122 §2.6.
|
||||
domain: automation
|
||||
source_url: https://github.com/ruvnet/RuView/blob/main/v2/crates/cog-ha-matter/blueprints/bfld/motion-hvac.yaml
|
||||
input:
|
||||
bfld_motion:
|
||||
name: BFLD Motion sensor
|
||||
description: The `sensor.<node>_bfld_motion` entity (0.0–1.0 scalar).
|
||||
selector:
|
||||
entity:
|
||||
domain: sensor
|
||||
integration: mqtt
|
||||
target_climate:
|
||||
name: Climate entity to adjust
|
||||
selector:
|
||||
target:
|
||||
entity:
|
||||
domain: climate
|
||||
motion_threshold:
|
||||
name: Motion threshold
|
||||
description: Motion-score level above which HVAC is considered "active occupancy".
|
||||
default: 0.3
|
||||
selector:
|
||||
number:
|
||||
min: 0.05
|
||||
max: 0.95
|
||||
step: 0.05
|
||||
delta_temperature_c:
|
||||
name: Setpoint adjustment (°C)
|
||||
description: How much to raise the heating setpoint during active occupancy.
|
||||
default: 1.5
|
||||
selector:
|
||||
number:
|
||||
min: 0.5
|
||||
max: 5.0
|
||||
step: 0.5
|
||||
unit_of_measurement: "°C"
|
||||
quiet_seconds:
|
||||
name: Quiet hold (seconds)
|
||||
description: Continuous below-threshold time before restoring the original setpoint.
|
||||
default: 600
|
||||
selector:
|
||||
number:
|
||||
min: 60
|
||||
max: 7200
|
||||
unit_of_measurement: seconds
|
||||
|
||||
variables:
|
||||
motion_threshold: !input motion_threshold
|
||||
delta_c: !input delta_temperature_c
|
||||
|
||||
trigger:
|
||||
- platform: numeric_state
|
||||
entity_id: !input bfld_motion
|
||||
above: !input motion_threshold
|
||||
id: occupied
|
||||
- platform: numeric_state
|
||||
entity_id: !input bfld_motion
|
||||
below: !input motion_threshold
|
||||
for:
|
||||
seconds: !input quiet_seconds
|
||||
id: quiet
|
||||
|
||||
action:
|
||||
- choose:
|
||||
- conditions:
|
||||
- condition: trigger
|
||||
id: occupied
|
||||
sequence:
|
||||
- service: climate.set_temperature
|
||||
target: !input target_climate
|
||||
data_template:
|
||||
temperature: "{{ (state_attr(this.attributes.target.entity_id, 'temperature') | float(20.0)) + delta_c }}"
|
||||
- conditions:
|
||||
- condition: trigger
|
||||
id: quiet
|
||||
sequence:
|
||||
- service: climate.set_temperature
|
||||
target: !input target_climate
|
||||
data_template:
|
||||
temperature: "{{ (state_attr(this.attributes.target.entity_id, 'temperature') | float(20.0)) - delta_c }}"
|
||||
mode: restart
|
||||
@@ -1,61 +0,0 @@
|
||||
blueprint:
|
||||
name: BFLD Presence-Driven Lighting
|
||||
description: >
|
||||
Turn a light on when BFLD reports occupancy on a chosen node, and off
|
||||
after a configurable hold period of continuous non-presence. Sourced
|
||||
from ADR-122 §2.6 of the wifi-densepose / RuView repository.
|
||||
domain: automation
|
||||
source_url: https://github.com/ruvnet/RuView/blob/main/v2/crates/cog-ha-matter/blueprints/bfld/presence-lighting.yaml
|
||||
input:
|
||||
bfld_presence:
|
||||
name: BFLD Presence sensor
|
||||
description: The `binary_sensor.<node>_bfld_presence` entity exposed by BFLD.
|
||||
selector:
|
||||
entity:
|
||||
domain: binary_sensor
|
||||
integration: mqtt
|
||||
target_light:
|
||||
name: Light to control
|
||||
selector:
|
||||
target:
|
||||
entity:
|
||||
domain: light
|
||||
hold_seconds:
|
||||
name: Off-delay hold (seconds)
|
||||
description: How long the room must stay empty before the light turns off.
|
||||
default: 120
|
||||
selector:
|
||||
number:
|
||||
min: 5
|
||||
max: 3600
|
||||
unit_of_measurement: seconds
|
||||
mode: slider
|
||||
step: 5
|
||||
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: !input bfld_presence
|
||||
to: "on"
|
||||
id: presence_on
|
||||
- platform: state
|
||||
entity_id: !input bfld_presence
|
||||
to: "off"
|
||||
for:
|
||||
seconds: !input hold_seconds
|
||||
id: presence_off
|
||||
|
||||
action:
|
||||
- choose:
|
||||
- conditions:
|
||||
- condition: trigger
|
||||
id: presence_on
|
||||
sequence:
|
||||
- service: light.turn_on
|
||||
target: !input target_light
|
||||
- conditions:
|
||||
- condition: trigger
|
||||
id: presence_off
|
||||
sequence:
|
||||
- service: light.turn_off
|
||||
target: !input target_light
|
||||
mode: restart
|
||||
@@ -6,6 +6,7 @@ authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Cognitum Cog: learned multi-person counter from WiFi CSI (ADR-103). Replaces the PR #491 slot heuristic with a Candle-based count head + Stoer-Wagner multi-node fusion."
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
name = "cog-person-count"
|
||||
|
||||
@@ -6,6 +6,7 @@ authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Cognitum Cog: 17-keypoint pose estimation from WiFi CSI. See ADR-100 (packaging) + ADR-101 (this Cog)."
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
name = "cog-pose-estimation"
|
||||
@@ -35,7 +36,7 @@ candle-nn = { version = "0.9", default-features = false }
|
||||
safetensors = "0.4"
|
||||
# wifi-densepose-train re-exports the model types we need; depend by path
|
||||
# inside the workspace.
|
||||
wifi-densepose-train = { version = "0.3.1", path = "../wifi-densepose-train", default-features = false }
|
||||
wifi-densepose-train = { path = "../wifi-densepose-train", default-features = false }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
[package]
|
||||
name = "wifi-densepose-bfld"
|
||||
description = "BFLD — Beamforming Feedback Layer for Detection. Privacy-gated WiFi BFI sensing primitives. See ADR-118."
|
||||
readme = "README.md"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
documentation.workspace = true
|
||||
keywords.workspace = true
|
||||
categories.workspace = true
|
||||
|
||||
[features]
|
||||
default = ["std", "serde-json"]
|
||||
std = []
|
||||
# JSON serialization for BfldEvent (ADR-121 §2.1, ADR-122 §2.1). Pulls in
|
||||
# serde + serde_json; tied to `std` because serde_json is std-only.
|
||||
serde-json = ["std", "dep:serde", "dep:serde_json"]
|
||||
# rumqttc-backed Publish trait impl. Pairs with the `mqtt` feature in
|
||||
# wifi-densepose-sensing-server so the same broker connection can serve
|
||||
# both publishers in the same process if desired.
|
||||
mqtt = ["std", "dep:rumqttc"]
|
||||
# Soul Signature integration (ADR-118 §1.4, ADR-120 §2.7, ADR-121 §2.6) —
|
||||
# enables privacy_class = 1 (derived) mode and the SoulMatchOracle gate
|
||||
# exemption. Disabled by default per the structural class-2 default.
|
||||
soul-signature = []
|
||||
|
||||
[dependencies]
|
||||
thiserror.workspace = true
|
||||
static_assertions = "1.1"
|
||||
crc = "3"
|
||||
blake3 = { version = "1.5", default-features = false }
|
||||
serde = { workspace = true, features = ["derive"], optional = true }
|
||||
serde_json = { workspace = true, optional = true }
|
||||
# MQTT publisher backend (optional). Matches the `rumqttc` choice already in
|
||||
# `wifi-densepose-sensing-server` so both crates share TLS / version posture.
|
||||
rumqttc = { version = "0.24", default-features = false, features = ["use-rustls"], optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
proptest.workspace = true
|
||||
|
||||
# The minimal example uses BfldEvent::to_json(), which is gated on serde-json.
|
||||
# Without this declaration, `cargo test --no-default-features` tries to build
|
||||
# the example and fails on the missing to_json() method.
|
||||
[[example]]
|
||||
name = "bfld_minimal"
|
||||
required-features = ["serde-json"]
|
||||
|
||||
# The handle example uses the std-only publish helpers and pipeline handle.
|
||||
[[example]]
|
||||
name = "bfld_handle"
|
||||
required-features = ["std"]
|
||||
|
||||
[lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
missing_docs = "warn"
|
||||
|
||||
[lints.clippy]
|
||||
all = "warn"
|
||||
pedantic = "warn"
|
||||
nursery = "warn"
|
||||
module_name_repetitions = "allow"
|
||||
missing_const_for_fn = "allow"
|
||||
missing_panics_doc = "allow"
|
||||
@@ -1,116 +0,0 @@
|
||||
# wifi-densepose-bfld
|
||||
|
||||
**BFLD — Beamforming Feedback Layer for Detection.** Privacy-gated WiFi sensing primitives derived from 802.11ac/ax Beamforming Feedback Information (BFI). See [ADR-118](../../../docs/adr/ADR-118-bfld-beamforming-feedback-layer-for-detection.md) for the umbrella architecture decision and [`docs/research/BFLD/`](../../../docs/research/BFLD/) for the full design dossier.
|
||||
|
||||
## Three structural invariants
|
||||
|
||||
The crate enforces three privacy invariants **structurally** (via the type system + memory hygiene), not by policy text:
|
||||
|
||||
| ID | Invariant | Enforced by |
|
||||
|----|-----------|-------------|
|
||||
| **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 storage |
|
||||
| **I3** | Cross-site identity correlation is cryptographically impossible | [`SignatureHasher`] per-site BLAKE3-keyed hash with daily epoch rotation |
|
||||
|
||||
## Quickstart
|
||||
|
||||
Minimal in-process consumer (see `examples/bfld_minimal.rs`):
|
||||
|
||||
```rust
|
||||
use wifi_densepose_bfld::{
|
||||
BfldConfig, BfldPipeline, IdentityEmbedding, SensingInputs,
|
||||
SignatureHasher, EMBEDDING_DIM, SITE_SALT_LEN,
|
||||
};
|
||||
|
||||
let mut pipeline = BfldPipeline::new(
|
||||
BfldConfig::new("seed-01")
|
||||
.with_signature_hasher(SignatureHasher::new([0xAB; SITE_SALT_LEN])),
|
||||
);
|
||||
|
||||
let event = pipeline
|
||||
.process(
|
||||
SensingInputs { /* timestamp, presence, motion, ... */
|
||||
timestamp_ns: 1_700_000_000_000_000_000, presence: true,
|
||||
motion: 0.42, person_count: 1, sensing_confidence: 0.91,
|
||||
sep: 0.2, stab: 0.2, consist: 0.2, risk_conf: 0.2,
|
||||
rf_signature_hash: None,
|
||||
},
|
||||
Some(IdentityEmbedding::from_raw([0.05; EMBEDDING_DIM])),
|
||||
)
|
||||
.expect("low-risk emit");
|
||||
|
||||
println!("{}", event.to_json().unwrap());
|
||||
```
|
||||
|
||||
Production worker-thread + HA-DISCO publishing (see `examples/bfld_handle.rs`):
|
||||
|
||||
```rust
|
||||
use wifi_densepose_bfld::{
|
||||
publish_availability_online, publish_discovery, BfldConfig, BfldPipeline,
|
||||
BfldPipelineHandle, PipelineInput, PrivacyClass, SignatureHasher,
|
||||
};
|
||||
|
||||
// Bootstrap: retained "online" + 6 retained HA-DISCO config payloads.
|
||||
publish_availability_online(&mut publisher, "seed-01")?;
|
||||
publish_discovery(&mut publisher, "seed-01", PrivacyClass::Anonymous)?;
|
||||
|
||||
// Spawn worker. Per-frame: handle.send(PipelineInput { inputs, embedding }).
|
||||
let handle = BfldPipelineHandle::spawn(
|
||||
BfldPipeline::new(BfldConfig::new("seed-01")
|
||||
.with_signature_hasher(SignatureHasher::new(salt))),
|
||||
publisher,
|
||||
);
|
||||
handle.send(PipelineInput { inputs, embedding })?;
|
||||
```
|
||||
|
||||
## Feature flags
|
||||
|
||||
| Feature | Default | Pulls in | Enables |
|
||||
|---------|---------|----------|---------|
|
||||
| `std` | ✅ | (no extra deps) | `BfldFrame`, `BfldPayload`, `BfldPipeline`, `BfldPipelineHandle`, `BfldEvent`, `BfldEmitter`, `PrivacyGate`, MQTT topic router, HA discovery |
|
||||
| `serde-json` | ✅ | `serde` + `serde_json` | `BfldEvent::to_json()`, custom `rf_signature_hash: "blake3:<hex>"` serializer, `privacy_class` string encoding |
|
||||
| `mqtt` | — | `rumqttc 0.24` (`use-rustls`) | `RumqttPublisher`, `connect_with_lwt`, live broker integration |
|
||||
| `soul-signature`| — | — | `--features` gate signaling Soul Signature deployment (ADR-118 §1.4, ADR-120 §2.7, ADR-121 §2.6) |
|
||||
|
||||
Stripping to `--no-default-features` keeps the no_std-compatible core (`BfldFrameHeader`, `PrivacyClass`, `Sink` traits, `CoherenceGate`, `SignatureHasher`, `IdentityEmbedding`, `EmbeddingRing`, risk-score function + `GateAction`).
|
||||
|
||||
## Examples
|
||||
|
||||
```sh
|
||||
cargo run -p wifi-densepose-bfld --example bfld_minimal # in-process consumer
|
||||
cargo run -p wifi-densepose-bfld --example bfld_handle # worker-thread + HA-DISCO
|
||||
```
|
||||
|
||||
## Companion artifacts
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `docs/adr/ADR-118` through `ADR-123` | Architecture decisions |
|
||||
| `docs/research/BFLD/` | 13,544-word design bundle (11 files) |
|
||||
| `v2/crates/cog-ha-matter/blueprints/bfld/` | Three HA operator blueprints (presence-lighting, motion-HVAC, identity-risk-anomaly) |
|
||||
| `.github/workflows/bfld-mqtt-integration.yml` | CI matrix incl. live mosquitto Docker service |
|
||||
|
||||
## ADR cross-reference
|
||||
|
||||
| ADR | Scope |
|
||||
|-----|-------|
|
||||
| [118](../../../docs/adr/ADR-118-bfld-beamforming-feedback-layer-for-detection.md) | Umbrella + invariants I1/I2/I3 |
|
||||
| [119](../../../docs/adr/ADR-119-bfld-frame-format-and-wire-protocol.md) | Wire format (86-byte header + payload sections + CRC-32/ISO-HDLC) |
|
||||
| [120](../../../docs/adr/ADR-120-bfld-privacy-class-and-hash-rotation.md) | 4 privacy classes + per-site keyed hash with daily rotation |
|
||||
| [121](../../../docs/adr/ADR-121-bfld-identity-risk-scoring.md) | Multiplicative risk score + coherence-gate hysteresis + Soul Signature exemption |
|
||||
| [122](../../../docs/adr/ADR-122-bfld-ruview-ha-matter-exposure.md) | HA-DISCO + Matter cluster boundary + MQTT topic routing |
|
||||
| [123](../../../docs/adr/ADR-123-bfld-capture-path-nexmon-and-esp32.md) | Pi 5 / Nexmon capture adapter + ESP32 self-only mode |
|
||||
|
||||
## Testing
|
||||
|
||||
```sh
|
||||
cargo test -p wifi-densepose-bfld --no-default-features # no_std-compatible core
|
||||
cargo test -p wifi-densepose-bfld # default std + serde-json
|
||||
cargo test -p wifi-densepose-bfld --features mqtt # incl. rumqttc smoke
|
||||
```
|
||||
|
||||
A `BFLD_MQTT_BROKER=tcp://localhost:1883` env var unlocks the live-broker `mosquitto_integration` test suite (see `tests/mosquitto_integration.rs`).
|
||||
|
||||
## License
|
||||
|
||||
MIT — same as the wifi-densepose workspace.
|
||||
@@ -1,109 +0,0 @@
|
||||
//! Worker-thread BFLD example — the production-recommended pattern.
|
||||
//!
|
||||
//! Demonstrates the full operator lifecycle:
|
||||
//! 1. publish_availability_online (retained) → HA marks device online
|
||||
//! 2. publish_discovery (retained) → HA auto-creates 6 BFLD entities
|
||||
//! 3. BfldPipelineHandle::spawn → worker owns gate + ring + hasher
|
||||
//! 4. handle.send(input) per BFI frame → worker process + publish
|
||||
//! 5. handle.shutdown() → clean worker join
|
||||
//! 6. publish_availability_offline → HA marks device offline
|
||||
//!
|
||||
//! Run with:
|
||||
//! ```sh
|
||||
//! cargo run -p wifi-densepose-bfld --example bfld_handle
|
||||
//! ```
|
||||
//!
|
||||
//! For a real broker, swap `CapturePublisher` for `RumqttPublisher::connect_with_lwt(...)`
|
||||
//! (requires `--features mqtt`).
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use wifi_densepose_bfld::{
|
||||
publish_availability_offline, publish_availability_online, publish_discovery, BfldConfig,
|
||||
BfldPipeline, BfldPipelineHandle, CapturePublisher, IdentityEmbedding, PipelineInput,
|
||||
PrivacyClass, SensingInputs, SignatureHasher, EMBEDDING_DIM, SITE_SALT_LEN,
|
||||
};
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let node_id = "seed-handle-demo";
|
||||
let site_salt: [u8; SITE_SALT_LEN] = [0xC0; SITE_SALT_LEN];
|
||||
|
||||
// Shared publisher (CapturePublisher for demo; RumqttPublisher in prod).
|
||||
let publisher = Arc::new(Mutex::new(CapturePublisher::default()));
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Phase 1 — Bootstrap. Three messages land on the broker (or
|
||||
// capture log) BEFORE the worker starts: online + 6 discovery payloads.
|
||||
// In production these should be published with retain=true so HA picks
|
||||
// them up on reconnect.
|
||||
// ----------------------------------------------------------------
|
||||
publish_availability_online(&mut publisher.clone(), node_id)?;
|
||||
let discovery_count = publish_discovery(&mut publisher.clone(), node_id, PrivacyClass::Anonymous)?;
|
||||
println!("bootstrap: 1 availability + {discovery_count} discovery payloads");
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Phase 2 — Spawn the worker thread. From this point on, the
|
||||
// operator only calls handle.send(...) per frame; the worker owns
|
||||
// every piece of pipeline state.
|
||||
// ----------------------------------------------------------------
|
||||
let pipeline = BfldPipeline::new(
|
||||
BfldConfig::new(node_id).with_signature_hasher(SignatureHasher::new(site_salt)),
|
||||
);
|
||||
let handle = BfldPipelineHandle::spawn(pipeline, publisher.clone());
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Phase 3 — Drive 5 sensing frames. Each one becomes 5 MQTT state
|
||||
// messages (presence/motion/count/conf/identity_risk for Anonymous
|
||||
// class, no zone configured).
|
||||
// ----------------------------------------------------------------
|
||||
for i in 0..5u64 {
|
||||
let timestamp_ns = 1_700_000_000_000_000_000 + i * 200_000_000;
|
||||
let mut emb = [0.0f32; EMBEDDING_DIM];
|
||||
for (j, v) in emb.iter_mut().enumerate() {
|
||||
*v = (j as f32 + i as f32) * 0.005;
|
||||
}
|
||||
let input = PipelineInput {
|
||||
inputs: SensingInputs {
|
||||
timestamp_ns,
|
||||
presence: true,
|
||||
motion: 0.3 + (i as f32) * 0.1,
|
||||
person_count: 1,
|
||||
sensing_confidence: 0.9,
|
||||
sep: 0.2,
|
||||
stab: 0.2,
|
||||
consist: 0.2,
|
||||
risk_conf: 0.2,
|
||||
rf_signature_hash: None,
|
||||
},
|
||||
embedding: Some(IdentityEmbedding::from_raw(emb)),
|
||||
};
|
||||
handle.send(input)?;
|
||||
}
|
||||
|
||||
// Give the worker time to drain the channel before shutdown.
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Phase 4 — Graceful shutdown. handle.shutdown() joins the worker;
|
||||
// publish_availability_offline then signals HA explicitly (the LWT
|
||||
// configured on RumqttPublisher::connect_with_lwt would handle the
|
||||
// crash case).
|
||||
// ----------------------------------------------------------------
|
||||
handle.shutdown();
|
||||
publish_availability_offline(&mut publisher.clone(), node_id)?;
|
||||
|
||||
// Print a summary so the example produces visible output.
|
||||
let log = publisher.lock().expect("publisher mutex");
|
||||
println!("total messages published: {}", log.published.len());
|
||||
println!("first three topics:");
|
||||
for msg in log.published.iter().take(3) {
|
||||
println!(" {}", msg.topic);
|
||||
}
|
||||
println!("last three topics:");
|
||||
for msg in log.published.iter().rev().take(3).collect::<Vec<_>>().iter().rev() {
|
||||
println!(" {}", msg.topic);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
//! Minimal end-to-end BFLD pipeline example. Demonstrates the operator-facing
|
||||
//! flow: construct a `BfldPipeline` with a `SignatureHasher`, feed one
|
||||
//! `SensingInputs` + `IdentityEmbedding`, and print the resulting privacy-
|
||||
//! gated `BfldEvent` as JSON.
|
||||
//!
|
||||
//! Run with:
|
||||
//! ```sh
|
||||
//! cargo run -p wifi-densepose-bfld --example bfld_minimal
|
||||
//! ```
|
||||
//!
|
||||
//! Expected output: one JSON line on stdout matching the BfldEvent schema
|
||||
//! (presence, motion, person_count, identity_risk_score, rf_signature_hash,
|
||||
//! privacy_class = "anonymous").
|
||||
|
||||
use wifi_densepose_bfld::{
|
||||
BfldConfig, BfldPipeline, IdentityEmbedding, SensingInputs, SignatureHasher, EMBEDDING_DIM,
|
||||
SITE_SALT_LEN,
|
||||
};
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// 1. Per-site secret (in production: loaded from TPM / KMS / secret file).
|
||||
let site_salt: [u8; SITE_SALT_LEN] = [
|
||||
0xA1, 0xB2, 0xC3, 0xD4, 0xE5, 0xF6, 0x07, 0x18, 0x29, 0x3A, 0x4B, 0x5C, 0x6D, 0x7E, 0x8F,
|
||||
0x90, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE,
|
||||
0xFF, 0x00,
|
||||
];
|
||||
|
||||
// 2. Build the pipeline. Default class = Anonymous, no zone, hasher
|
||||
// installed so rf_signature_hash gets derived from the embedding.
|
||||
let mut pipeline = BfldPipeline::new(
|
||||
BfldConfig::new("seed-example")
|
||||
.with_signature_hasher(SignatureHasher::new(site_salt)),
|
||||
);
|
||||
|
||||
// 3. One per-frame sensing observation. In production these come from
|
||||
// the BFI extractor + RuvSense feature engine.
|
||||
let inputs = SensingInputs {
|
||||
timestamp_ns: 1_700_000_000_000_000_000,
|
||||
presence: true,
|
||||
motion: 0.42,
|
||||
person_count: 1,
|
||||
sensing_confidence: 0.91,
|
||||
// Low risk — gate stays in Accept; event is published.
|
||||
sep: 0.2,
|
||||
stab: 0.2,
|
||||
consist: 0.2,
|
||||
risk_conf: 0.2,
|
||||
rf_signature_hash: None, // hasher will derive
|
||||
};
|
||||
|
||||
// 4. Embedding from the AETHER encoder (ADR-024). For the example we
|
||||
// fill with a deterministic ramp; production uses real model output.
|
||||
let mut emb_values = [0.0f32; EMBEDDING_DIM];
|
||||
for (i, v) in emb_values.iter_mut().enumerate() {
|
||||
*v = (i as f32) * 0.0073;
|
||||
}
|
||||
let embedding = IdentityEmbedding::from_raw(emb_values);
|
||||
|
||||
// 5. Drive the pipeline. Returns Some(BfldEvent) when the gate permits;
|
||||
// None on Reject / Recalibrate.
|
||||
let event = pipeline
|
||||
.process(inputs, Some(embedding))
|
||||
.ok_or("gate dropped the event — should not happen at this risk level")?;
|
||||
|
||||
// 6. Publish JSON. Real deployments would feed this to MQTT via the
|
||||
// iter-22 publish_event(&publisher, &event) helper.
|
||||
let json = event.to_json()?;
|
||||
println!("{json}");
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
//! `ruview/<node_id>/bfld/availability` topic helpers. ADR-122 §2.2.
|
||||
//!
|
||||
//! HA expects each device to publish an availability topic so the UI can grey
|
||||
//! out entities when the device is offline. Convention:
|
||||
//!
|
||||
//! - Publish `"online"` with `retain = true` immediately after broker CONNECT.
|
||||
//! - Configure the MQTT client's Last Will and Testament (LWT) to publish
|
||||
//! `"offline"` (also retained) so the broker auto-marks the device offline
|
||||
//! when the TCP session drops without a clean DISCONNECT.
|
||||
//!
|
||||
//! HA discovery payloads (iter 26) reference this same topic via the
|
||||
//! `availability_topic` field so every BFLD entity inherits the marker.
|
||||
|
||||
#![cfg(feature = "std")]
|
||||
|
||||
use crate::mqtt_topics::{Publish, TopicMessage};
|
||||
|
||||
/// Payload string published when the node is healthy.
|
||||
pub const PAYLOAD_AVAILABLE: &str = "online";
|
||||
|
||||
/// Payload string published when the node has disconnected.
|
||||
pub const PAYLOAD_NOT_AVAILABLE: &str = "offline";
|
||||
|
||||
/// Build the canonical `ruview/<node_id>/bfld/availability` topic string.
|
||||
#[must_use]
|
||||
pub fn availability_topic(node_id: &str) -> String {
|
||||
let mut s = String::with_capacity(7 + node_id.len() + 19);
|
||||
s.push_str("ruview/");
|
||||
s.push_str(node_id);
|
||||
s.push_str("/bfld/availability");
|
||||
s
|
||||
}
|
||||
|
||||
/// Build the `(topic, "online")` pair to publish on broker connect.
|
||||
#[must_use]
|
||||
pub fn online_message(node_id: &str) -> TopicMessage {
|
||||
TopicMessage {
|
||||
topic: availability_topic(node_id),
|
||||
payload: PAYLOAD_AVAILABLE.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the `(topic, "offline")` pair — usually configured as the broker LWT
|
||||
/// rather than published explicitly, but provided here for explicit-shutdown
|
||||
/// scenarios (graceful stop, planned maintenance) where the operator wants
|
||||
/// HA to update immediately rather than waiting for the LWT keep-alive timeout.
|
||||
#[must_use]
|
||||
pub fn offline_message(node_id: &str) -> TopicMessage {
|
||||
TopicMessage {
|
||||
topic: availability_topic(node_id),
|
||||
payload: PAYLOAD_NOT_AVAILABLE.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Bootstrap helper: publish the `"online"` availability marker through
|
||||
/// `publisher`. Pairs with `publish_discovery` (iter 27) and `publish_event`
|
||||
/// (iter 22) for the full startup sequence:
|
||||
///
|
||||
/// ```ignore
|
||||
/// publish_availability_online(&mut retained_pub, "seed-01")?; // "online", retained
|
||||
/// publish_discovery(&mut retained_pub, "seed-01", PrivacyClass::Anonymous)?;
|
||||
/// // ... then BfldPipelineHandle::spawn(pipeline, state_pub) for the per-frame loop
|
||||
/// ```
|
||||
pub fn publish_availability_online<P: Publish>(
|
||||
publisher: &mut P,
|
||||
node_id: &str,
|
||||
) -> Result<(), P::Error> {
|
||||
publisher.publish(&online_message(node_id))
|
||||
}
|
||||
|
||||
/// Bootstrap helper: publish the `"offline"` availability marker through
|
||||
/// `publisher`. Use during a graceful shutdown so HA reflects the state
|
||||
/// immediately instead of waiting for the broker LWT timeout.
|
||||
pub fn publish_availability_offline<P: Publish>(
|
||||
publisher: &mut P,
|
||||
node_id: &str,
|
||||
) -> Result<(), P::Error> {
|
||||
publisher.publish(&offline_message(node_id))
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
//! Stateful coherence gate with hysteresis + debounce. ADR-121 §2.4 + §2.5.
|
||||
//!
|
||||
//! Wraps the stateless [`crate::identity_risk::GateAction::from_score`] band
|
||||
//! classifier with two stabilizing mechanisms:
|
||||
//!
|
||||
//! - **Hysteresis (±0.05)** — a score must clear the current band's edge by
|
||||
//! `HYSTERESIS` before the gate considers the next band.
|
||||
//! - **Debounce (5 seconds)** — once a different action is "pending", it must
|
||||
//! persist for `DEBOUNCE_NS` of wall time before it becomes the current
|
||||
//! action. Returning to the current band cancels the pending action.
|
||||
//!
|
||||
//! Together these prevent the gate from flapping when the risk score
|
||||
//! oscillates near a boundary or spikes briefly on a single bad frame.
|
||||
|
||||
use crate::identity_risk::{
|
||||
GateAction, PREDICT_ONLY_THRESHOLD, RECALIBRATE_THRESHOLD, REJECT_THRESHOLD,
|
||||
};
|
||||
|
||||
/// Symmetric hysteresis band applied to every action boundary.
|
||||
pub const HYSTERESIS: f32 = 0.05;
|
||||
|
||||
/// Pending action must persist this long (in nanoseconds) before promotion.
|
||||
pub const DEBOUNCE_NS: u64 = 5_000_000_000;
|
||||
|
||||
/// Stateful gate. Construct with `CoherenceGate::new()` and call
|
||||
/// `evaluate(score, timestamp_ns)` per frame to obtain the active action.
|
||||
pub struct CoherenceGate {
|
||||
current: GateAction,
|
||||
pending: Option<(GateAction, u64)>,
|
||||
}
|
||||
|
||||
impl CoherenceGate {
|
||||
/// Build a fresh gate, starting in [`GateAction::Accept`] with no pending
|
||||
/// transition.
|
||||
#[must_use]
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
current: GateAction::Accept,
|
||||
pending: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Current published action — does **not** advance any state.
|
||||
#[must_use]
|
||||
pub const fn current(&self) -> GateAction {
|
||||
self.current
|
||||
}
|
||||
|
||||
/// Pending action (if any) — useful for diagnostics / dashboards.
|
||||
#[must_use]
|
||||
pub const fn pending(&self) -> Option<GateAction> {
|
||||
match self.pending {
|
||||
Some((a, _)) => Some(a),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Drive the gate with a fresh score reading and a monotonic timestamp.
|
||||
/// Returns the currently-active action after the update.
|
||||
pub fn evaluate(&mut self, score: f32, timestamp_ns: u64) -> GateAction {
|
||||
let target = effective_target(score, self.current);
|
||||
self.advance_state(target, timestamp_ns)
|
||||
}
|
||||
|
||||
/// Variant of [`Self::evaluate`] that consults a [`SoulMatchOracle`].
|
||||
/// When the gate would transition to [`GateAction::Recalibrate`] and the
|
||||
/// oracle reports a [`MatchOutcome::Match`], the target is downgraded to
|
||||
/// [`GateAction::PredictOnly`] — the high score is the *intended* outcome
|
||||
/// of a successful Soul Signature match and should not rotate `site_salt`.
|
||||
/// See ADR-121 §2.6.
|
||||
pub fn evaluate_with_oracle<O: SoulMatchOracle>(
|
||||
&mut self,
|
||||
score: f32,
|
||||
timestamp_ns: u64,
|
||||
oracle: &O,
|
||||
) -> GateAction {
|
||||
let mut target = effective_target(score, self.current);
|
||||
if target == GateAction::Recalibrate {
|
||||
if let MatchOutcome::Match { .. } = oracle.matches_enrolled() {
|
||||
target = GateAction::PredictOnly;
|
||||
}
|
||||
}
|
||||
self.advance_state(target, timestamp_ns)
|
||||
}
|
||||
|
||||
/// Shared hysteresis-debounce state-machine driver.
|
||||
fn advance_state(&mut self, target: GateAction, timestamp_ns: u64) -> GateAction {
|
||||
if target == self.current {
|
||||
self.pending = None;
|
||||
return self.current;
|
||||
}
|
||||
match self.pending {
|
||||
Some((pending, since)) if pending == target => {
|
||||
if timestamp_ns.saturating_sub(since) >= DEBOUNCE_NS {
|
||||
self.current = target;
|
||||
self.pending = None;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
self.pending = Some((target, timestamp_ns));
|
||||
}
|
||||
}
|
||||
self.current
|
||||
}
|
||||
}
|
||||
|
||||
// --- SoulMatchOracle -------------------------------------------------------
|
||||
//
|
||||
// The trait + MatchOutcome enum live here so the Recalibrate exemption is
|
||||
// addressable without pulling in any Soul Signature implementation crate.
|
||||
// Downstream crates compiled with `--features soul-signature` provide their
|
||||
// own oracle impl; otherwise `NullOracle` is the sensible default.
|
||||
|
||||
/// Result of an oracle lookup. ADR-121 §2.6.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MatchOutcome {
|
||||
/// The current high-separability cluster matches an enrolled subject —
|
||||
/// the gate must NOT recalibrate, because the match is the intended outcome.
|
||||
Match {
|
||||
/// Opaque per-deployment person identifier.
|
||||
person_id: u64,
|
||||
},
|
||||
/// No enrolled subject matches the cluster — proceed with normal gating.
|
||||
NotEnrolled,
|
||||
/// Soul Signature is disabled in this deployment (e.g., `privacy_class = 3`).
|
||||
/// Treated identically to `NotEnrolled` by the gate.
|
||||
Suppressed,
|
||||
}
|
||||
|
||||
/// Oracle hook consulted before the gate fires `Recalibrate`. Implementations
|
||||
/// live in the Soul Signature integration crate; this crate ships only the
|
||||
/// trait and a no-op fallback ([`NullOracle`]).
|
||||
pub trait SoulMatchOracle {
|
||||
/// Return the current match outcome. May be called once per evaluation
|
||||
/// when the gate is about to fire `Recalibrate`; implementations should
|
||||
/// be cheap (the iter-10 budget is < 1 ms via RaBitQ; see ADR-121 §2.7).
|
||||
fn matches_enrolled(&self) -> MatchOutcome;
|
||||
}
|
||||
|
||||
/// No-op oracle — always reports `NotEnrolled`. Used when Soul Signature is
|
||||
/// not enabled, so the gate behaves identically to [`CoherenceGate::evaluate`].
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct NullOracle;
|
||||
|
||||
impl SoulMatchOracle for NullOracle {
|
||||
fn matches_enrolled(&self) -> MatchOutcome {
|
||||
MatchOutcome::NotEnrolled
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CoherenceGate {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn effective_target(score: f32, current: GateAction) -> GateAction {
|
||||
let raw = GateAction::from_score(score);
|
||||
if raw == current {
|
||||
return current;
|
||||
}
|
||||
if action_idx(raw) > action_idx(current) {
|
||||
// Crossing upward — score must clear current's upper edge + HYSTERESIS.
|
||||
if score >= upper_edge_of(current) + HYSTERESIS {
|
||||
raw
|
||||
} else {
|
||||
current
|
||||
}
|
||||
} else {
|
||||
// Crossing downward — score must fall below current's lower edge - HYSTERESIS.
|
||||
if score < lower_edge_of(current) - HYSTERESIS {
|
||||
raw
|
||||
} else {
|
||||
current
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fn action_idx(a: GateAction) -> u8 {
|
||||
match a {
|
||||
GateAction::Accept => 0,
|
||||
GateAction::PredictOnly => 1,
|
||||
GateAction::Reject => 2,
|
||||
GateAction::Recalibrate => 3,
|
||||
}
|
||||
}
|
||||
|
||||
fn upper_edge_of(a: GateAction) -> f32 {
|
||||
match a {
|
||||
GateAction::Accept => PREDICT_ONLY_THRESHOLD,
|
||||
GateAction::PredictOnly => REJECT_THRESHOLD,
|
||||
GateAction::Reject => RECALIBRATE_THRESHOLD,
|
||||
GateAction::Recalibrate => f32::INFINITY,
|
||||
}
|
||||
}
|
||||
|
||||
fn lower_edge_of(a: GateAction) -> f32 {
|
||||
match a {
|
||||
GateAction::Accept => f32::NEG_INFINITY,
|
||||
GateAction::PredictOnly => PREDICT_ONLY_THRESHOLD,
|
||||
GateAction::Reject => REJECT_THRESHOLD,
|
||||
GateAction::Recalibrate => RECALIBRATE_THRESHOLD,
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
//! `IdentityEmbedding` — structural enforcement of ADR-118 invariant I2.
|
||||
//!
|
||||
//! I2: the identity embedding is **in-RAM-only**. There is no `Serialize`
|
||||
//! impl on this type, no `Copy`, no `Clone`; the only way to extract a value
|
||||
//! is `as_slice()`, which returns a borrowed view, and the buffer is zeroized
|
||||
//! on `Drop`. A future PR cannot accidentally leak the embedding because:
|
||||
//!
|
||||
//! - The type lives in this crate; downstream crates see only the public API
|
||||
//! and the type's lack of `Serialize`/`Clone`/`Copy` makes accidental
|
||||
//! reflection impossible without explicitly bypassing the wrapper.
|
||||
//! - `Drop` overwrites the f32 storage with `0.0` before the allocation is
|
||||
//! freed, so a stale pointer reads zeros instead of the original values.
|
||||
//! - `Debug` redacts: only the L2 norm and the constant length are emitted.
|
||||
//!
|
||||
//! This is the type-system half of I2. The lifecycle half — a bounded ring
|
||||
//! buffer with FIFO replacement — lives in a subsequent iter.
|
||||
|
||||
use core::fmt;
|
||||
|
||||
use static_assertions::{assert_impl_all, assert_not_impl_any};
|
||||
|
||||
/// Dimension of the AETHER contrastive embedding (ADR-024 §2.4).
|
||||
pub const EMBEDDING_DIM: usize = 128;
|
||||
|
||||
/// In-RAM-only identity embedding. **No serialization, no clone, no copy.**
|
||||
pub struct IdentityEmbedding {
|
||||
values: [f32; EMBEDDING_DIM],
|
||||
}
|
||||
|
||||
impl IdentityEmbedding {
|
||||
/// Wrap a freshly-computed embedding. The caller relinquishes the array;
|
||||
/// after this call the only safe accessor is `as_slice()`.
|
||||
#[must_use]
|
||||
pub const fn from_raw(values: [f32; EMBEDDING_DIM]) -> Self {
|
||||
Self { values }
|
||||
}
|
||||
|
||||
/// Borrow the embedding values for a read-only computation (similarity,
|
||||
/// risk scoring). Lifetime-bound to `&self` — the values cannot escape.
|
||||
#[must_use]
|
||||
pub fn as_slice(&self) -> &[f32] {
|
||||
&self.values
|
||||
}
|
||||
|
||||
/// L2 norm of the embedding. Useful for sanity-checking and for the
|
||||
/// redacted `Debug` output.
|
||||
#[must_use]
|
||||
pub fn l2_norm(&self) -> f32 {
|
||||
self.values.iter().map(|v| v * v).sum::<f32>().sqrt()
|
||||
}
|
||||
|
||||
/// Embedding dimension. Always `EMBEDDING_DIM`.
|
||||
#[must_use]
|
||||
pub const fn len(&self) -> usize {
|
||||
EMBEDDING_DIM
|
||||
}
|
||||
|
||||
/// Always `false` — embeddings are never empty.
|
||||
#[must_use]
|
||||
pub const fn is_empty(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for IdentityEmbedding {
|
||||
/// Redacted: emits dimension + L2 norm only. Never logs raw values.
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("IdentityEmbedding")
|
||||
.field("dim", &EMBEDDING_DIM)
|
||||
.field("l2_norm", &self.l2_norm())
|
||||
.field("values", &"<redacted>")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for IdentityEmbedding {
|
||||
/// Overwrite the embedding storage with `0.0` before deallocation.
|
||||
/// Used `core::hint::black_box` to prevent the compiler from eliding the
|
||||
/// write under DCE — the zeroization is observable on the heap/stack.
|
||||
fn drop(&mut self) {
|
||||
for v in &mut self.values {
|
||||
*v = 0.0;
|
||||
}
|
||||
// black_box forces the compiler to treat self.values as observed,
|
||||
// preventing the dead-store elimination pass from removing the loop.
|
||||
core::hint::black_box(&self.values);
|
||||
}
|
||||
}
|
||||
|
||||
// Compile-time structural assertions. If a future PR adds `Clone` or `Copy`,
|
||||
// or if a downstream crate tries to derive Serialize/Deserialize, the build
|
||||
// fails here. These constraints are what makes I2 *structural* rather than
|
||||
// merely documented.
|
||||
|
||||
assert_impl_all!(IdentityEmbedding: Drop);
|
||||
assert_not_impl_any!(IdentityEmbedding: Copy, Clone);
|
||||
@@ -1,105 +0,0 @@
|
||||
//! `EmbeddingRing` — bounded FIFO of `IdentityEmbedding`s.
|
||||
//!
|
||||
//! Holds at most [`RING_CAPACITY`] (default 64) embeddings. When full, `push`
|
||||
//! evicts and returns the oldest entry so its `Drop` runs and the f32 storage
|
||||
//! is zeroized. `drain()` is the explicit "rotate site_salt" hook from the
|
||||
//! coherence-gate `Recalibrate` action (ADR-121 §2.4): it clears every slot
|
||||
//! at once. The ring is `no_std`-compatible; no heap allocation.
|
||||
|
||||
use crate::embedding::IdentityEmbedding;
|
||||
|
||||
/// Default ring capacity — matches ADR-120 §2.5 ("ring buffer of 64 entries").
|
||||
pub const RING_CAPACITY: usize = 64;
|
||||
|
||||
/// Fixed-capacity FIFO of identity embeddings. Insertion-ordered; oldest
|
||||
/// evicted first when full.
|
||||
pub struct EmbeddingRing {
|
||||
slots: [Option<IdentityEmbedding>; RING_CAPACITY],
|
||||
/// Index of the oldest slot — the next eviction target.
|
||||
head: usize,
|
||||
/// Number of currently-occupied slots (0..=RING_CAPACITY).
|
||||
count: usize,
|
||||
}
|
||||
|
||||
impl EmbeddingRing {
|
||||
/// Build an empty ring.
|
||||
#[must_use]
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
slots: [const { None }; RING_CAPACITY],
|
||||
head: 0,
|
||||
count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert `emb`. If the ring is already full, evicts and returns the
|
||||
/// oldest entry (its `Drop` runs as the returned `Option` is dropped).
|
||||
pub fn push(&mut self, emb: IdentityEmbedding) -> Option<IdentityEmbedding> {
|
||||
if self.count < RING_CAPACITY {
|
||||
// Not full — write into the slot at head + count.
|
||||
let idx = (self.head + self.count) % RING_CAPACITY;
|
||||
self.slots[idx] = Some(emb);
|
||||
self.count += 1;
|
||||
None
|
||||
} else {
|
||||
// Full — overwrite the oldest slot, advance head.
|
||||
let evicted = self.slots[self.head].take();
|
||||
self.slots[self.head] = Some(emb);
|
||||
self.head = (self.head + 1) % RING_CAPACITY;
|
||||
evicted
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of occupied slots.
|
||||
#[must_use]
|
||||
pub const fn len(&self) -> usize {
|
||||
self.count
|
||||
}
|
||||
|
||||
/// `true` iff `len() == 0`.
|
||||
#[must_use]
|
||||
pub const fn is_empty(&self) -> bool {
|
||||
self.count == 0
|
||||
}
|
||||
|
||||
/// Maximum number of slots — always [`RING_CAPACITY`].
|
||||
#[must_use]
|
||||
pub const fn capacity(&self) -> usize {
|
||||
RING_CAPACITY
|
||||
}
|
||||
|
||||
/// `true` iff `len() == capacity()`.
|
||||
#[must_use]
|
||||
pub const fn is_full(&self) -> bool {
|
||||
self.count == RING_CAPACITY
|
||||
}
|
||||
|
||||
/// Iterate occupied slots in **insertion order** (oldest first).
|
||||
pub fn iter(&self) -> impl Iterator<Item = &IdentityEmbedding> + '_ {
|
||||
(0..self.count).map(move |i| {
|
||||
let idx = (self.head + i) % RING_CAPACITY;
|
||||
self.slots[idx].as_ref().expect("occupied slot")
|
||||
})
|
||||
}
|
||||
|
||||
/// Empty the ring. Every contained `IdentityEmbedding` is dropped, which
|
||||
/// zeroizes its storage. Returns the number of entries that were drained.
|
||||
pub fn drain(&mut self) -> usize {
|
||||
let drained = self.count;
|
||||
for slot in &mut self.slots {
|
||||
// Take() moves the embedding out; the temporary is dropped at the
|
||||
// end of this statement, running IdentityEmbedding::drop which
|
||||
// zeroes the f32 array.
|
||||
let _ = slot.take();
|
||||
}
|
||||
self.head = 0;
|
||||
self.count = 0;
|
||||
drained
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for EmbeddingRing {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
//! `BfldEmitter` — end-to-end pipeline. ADR-118 §2.1.
|
||||
//!
|
||||
//! Wires the per-frame sensing inputs through:
|
||||
//!
|
||||
//! ```text
|
||||
//! risk = identity_risk::score(sep, stab, consist, conf_factor)
|
||||
//! -> gate.evaluate_with_oracle(risk, ts, &oracle) -> GateAction
|
||||
//! -> if Recalibrate: ring.drain()
|
||||
//! -> if action.drops_event(): return None
|
||||
//! -> else: BfldEvent::with_privacy_gating(...)
|
||||
//! ```
|
||||
//!
|
||||
//! The emitter owns the `CoherenceGate` and `EmbeddingRing` state so the
|
||||
//! caller only supplies per-frame inputs. Identity embeddings are pushed to
|
||||
//! the ring before the gate is consulted; on `Recalibrate` the ring is
|
||||
//! drained synchronously inside this function.
|
||||
|
||||
#![cfg(feature = "std")]
|
||||
|
||||
use crate::coherence_gate::{CoherenceGate, NullOracle, SoulMatchOracle};
|
||||
use crate::embedding_ring::EmbeddingRing;
|
||||
use crate::identity_features::IdentityFeatures;
|
||||
use crate::identity_risk::{score, GateAction};
|
||||
use crate::signature_hasher::SignatureHasher;
|
||||
use crate::{BfldEvent, IdentityEmbedding, PrivacyClass};
|
||||
|
||||
/// Nanoseconds-per-second conversion factor for deriving unix_secs from
|
||||
/// `timestamp_ns`. The caller is responsible for using unix-epoch nanoseconds
|
||||
/// if it wants stable daily rotation; monotonic-only clocks won't anchor to
|
||||
/// UTC midnight.
|
||||
const NS_PER_SEC: u64 = 1_000_000_000;
|
||||
|
||||
/// Per-frame sensing inputs to [`BfldEmitter::emit`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SensingInputs {
|
||||
/// Monotonic capture-clock timestamp in nanoseconds.
|
||||
pub timestamp_ns: u64,
|
||||
/// Whether an occupant is present in the zone.
|
||||
pub presence: bool,
|
||||
/// Normalized motion magnitude `[0,1]`.
|
||||
pub motion: f32,
|
||||
/// Estimated occupant count.
|
||||
pub person_count: u8,
|
||||
/// Sensing confidence (NOT the risk-score `conf` factor) — `[0,1]`.
|
||||
pub sensing_confidence: f32,
|
||||
|
||||
// --- Risk-score factors (ADR-121 §2.2) -------------------------------
|
||||
/// `identity_separability_score` — `[0,1]`.
|
||||
pub sep: f32,
|
||||
/// `temporal_stability` — `[0,1]`.
|
||||
pub stab: f32,
|
||||
/// `cross_perspective_consistency` — `[0,1]`.
|
||||
pub consist: f32,
|
||||
/// Risk-score sample confidence factor — `[0,1]`.
|
||||
pub risk_conf: f32,
|
||||
|
||||
// --- Optional identity-derived fields --------------------------------
|
||||
/// Per-day BLAKE3-keyed `rf_signature_hash`. Stripped at class 3 by the
|
||||
/// privacy-gated event constructor.
|
||||
pub rf_signature_hash: Option<[u8; 32]>,
|
||||
}
|
||||
|
||||
/// End-to-end pipeline. Owns the gate state, the embedding ring, and the
|
||||
/// configured node identity. Defaults to `PrivacyClass::Anonymous`.
|
||||
pub struct BfldEmitter {
|
||||
node_id: String,
|
||||
default_zone_id: Option<String>,
|
||||
privacy_class: PrivacyClass,
|
||||
gate: CoherenceGate,
|
||||
ring: EmbeddingRing,
|
||||
signature_hasher: Option<SignatureHasher>,
|
||||
}
|
||||
|
||||
impl BfldEmitter {
|
||||
/// Build a new emitter in the production-default state: class Anonymous,
|
||||
/// empty gate/ring, no default zone.
|
||||
#[must_use]
|
||||
pub fn new(node_id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
node_id: node_id.into(),
|
||||
default_zone_id: None,
|
||||
privacy_class: PrivacyClass::Anonymous,
|
||||
gate: CoherenceGate::new(),
|
||||
ring: EmbeddingRing::new(),
|
||||
signature_hasher: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Install a [`SignatureHasher`] so the emitter computes `rf_signature_hash`
|
||||
/// per ADR-120 §2.3 from the supplied embedding (preferred) or the risk
|
||||
/// factors (fallback when no embedding is supplied). When set, the derived
|
||||
/// hash overrides `SensingInputs::rf_signature_hash`.
|
||||
#[must_use]
|
||||
pub fn with_signature_hasher(mut self, hasher: SignatureHasher) -> Self {
|
||||
self.signature_hasher = Some(hasher);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the default zone ID emitted with each event (None = single-zone).
|
||||
#[must_use]
|
||||
pub fn with_zone(mut self, zone_id: impl Into<String>) -> Self {
|
||||
self.default_zone_id = Some(zone_id.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Override the privacy class (default `Anonymous`).
|
||||
#[must_use]
|
||||
pub const fn with_privacy_class(mut self, class: PrivacyClass) -> Self {
|
||||
self.privacy_class = class;
|
||||
self
|
||||
}
|
||||
|
||||
/// Read-only access to the current gate action — useful for diagnostics.
|
||||
#[must_use]
|
||||
pub const fn current_action(&self) -> GateAction {
|
||||
self.gate.current()
|
||||
}
|
||||
|
||||
/// Read-only access to the ring length (post any in-flight drain).
|
||||
#[must_use]
|
||||
pub const fn ring_len(&self) -> usize {
|
||||
self.ring.len()
|
||||
}
|
||||
|
||||
/// Run one pipeline step with the default [`NullOracle`]. Returns
|
||||
/// `Some(BfldEvent)` if the gate permitted publishing, `None` if the
|
||||
/// action was `Reject` or `Recalibrate`.
|
||||
pub fn emit(
|
||||
&mut self,
|
||||
inputs: SensingInputs,
|
||||
embedding: Option<IdentityEmbedding>,
|
||||
) -> Option<BfldEvent> {
|
||||
self.emit_with_oracle(inputs, embedding, &NullOracle)
|
||||
}
|
||||
|
||||
/// Same as [`Self::emit`] but consults a [`SoulMatchOracle`] before the
|
||||
/// gate fires `Recalibrate`. See ADR-121 §2.6.
|
||||
pub fn emit_with_oracle<O: SoulMatchOracle>(
|
||||
&mut self,
|
||||
inputs: SensingInputs,
|
||||
embedding: Option<IdentityEmbedding>,
|
||||
oracle: &O,
|
||||
) -> Option<BfldEvent> {
|
||||
let risk = score(inputs.sep, inputs.stab, inputs.consist, inputs.risk_conf);
|
||||
|
||||
// Compute the derived rf_signature_hash BEFORE moving `embedding`
|
||||
// into the ring. The IdentityFeatures encoder (iter 18) consolidates
|
||||
// the embedding vs risk-factor selection behind a single canonical-
|
||||
// bytes path; same wire bytes as the iter-16 inline encoding.
|
||||
let derived_hash: Option<[u8; 32]> = self.signature_hasher.as_ref().map(|h| {
|
||||
let unix_secs = inputs.timestamp_ns / NS_PER_SEC;
|
||||
let day_epoch = SignatureHasher::day_epoch_from_unix_secs(unix_secs);
|
||||
let features = match &embedding {
|
||||
Some(emb) => IdentityFeatures::from_embedding(emb),
|
||||
None => IdentityFeatures::from_risk_factors(
|
||||
inputs.sep,
|
||||
inputs.stab,
|
||||
inputs.consist,
|
||||
inputs.risk_conf,
|
||||
),
|
||||
};
|
||||
features.compute_hash(h, day_epoch)
|
||||
});
|
||||
|
||||
if let Some(emb) = embedding {
|
||||
// Always push, regardless of action — the ring is the rolling
|
||||
// memory of recent identity embeddings, used for separability.
|
||||
self.ring.push(emb);
|
||||
}
|
||||
|
||||
let action = self
|
||||
.gate
|
||||
.evaluate_with_oracle(risk, inputs.timestamp_ns, oracle);
|
||||
|
||||
if action == GateAction::Recalibrate {
|
||||
self.ring.drain();
|
||||
}
|
||||
|
||||
if action.drops_event() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let identity_risk_score = match self.privacy_class {
|
||||
PrivacyClass::Anonymous => Some(risk),
|
||||
// Class 3 strips identity_risk; class 0/1 keep it (research modes).
|
||||
// The BfldEvent constructor enforces the class-3 strip again as a
|
||||
// defense-in-depth measure.
|
||||
_ => Some(risk),
|
||||
};
|
||||
|
||||
// Derived hash (when hasher installed) takes precedence over caller-
|
||||
// supplied; otherwise pass through whatever the caller provided.
|
||||
let rf_signature_hash = derived_hash.or(inputs.rf_signature_hash);
|
||||
|
||||
Some(BfldEvent::with_privacy_gating(
|
||||
self.node_id.clone(),
|
||||
inputs.timestamp_ns,
|
||||
inputs.presence,
|
||||
inputs.motion,
|
||||
inputs.person_count,
|
||||
inputs.sensing_confidence,
|
||||
self.default_zone_id.clone(),
|
||||
self.privacy_class,
|
||||
identity_risk_score,
|
||||
rf_signature_hash,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// canonical_risk_bytes removed in iter 18 — superseded by
|
||||
// IdentityFeatures::from_risk_factors().canonical_bytes() which uses the
|
||||
// same little-endian f32 layout.
|
||||
@@ -1,170 +0,0 @@
|
||||
//! `BfldEvent` — privacy-gated output event. ADR-121 §2.1, ADR-122 §2.1.
|
||||
//!
|
||||
//! Field exposure per privacy_class (ADR-122 §2.1):
|
||||
//!
|
||||
//! | Field | Raw(0) | Derived(1) | Anonymous(2) | Restricted(3) |
|
||||
//! |------------------------|--------|------------|--------------|---------------|
|
||||
//! | presence | y | y | y | y |
|
||||
//! | motion | y | y | y | y |
|
||||
//! | person_count | y | y | y | y |
|
||||
//! | confidence | y | y | y | y |
|
||||
//! | zone_id | y | y | y | y |
|
||||
//! | identity_risk_score | y | y | **y** | **n** |
|
||||
//! | rf_signature_hash | y | y | **y** | **n** |
|
||||
//!
|
||||
//! Construction defers to [`BfldEvent::with_privacy_gating`] which applies
|
||||
//! the policy by stripping disallowed fields to `None` based on the supplied
|
||||
//! `privacy_class`. Direct field access remains possible (for unit tests),
|
||||
//! but the JSON serializer always honors the gating because the dropped
|
||||
//! fields are `None` and the `Serialize` derive uses `skip_serializing_if`.
|
||||
|
||||
#![cfg(feature = "std")]
|
||||
|
||||
use crate::PrivacyClass;
|
||||
|
||||
#[cfg(feature = "serde-json")]
|
||||
use serde::Serialize;
|
||||
|
||||
/// Privacy-gated output event published by the BFLD pipeline.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[cfg_attr(feature = "serde-json", derive(Serialize))]
|
||||
pub struct BfldEvent {
|
||||
/// Always `"bfld_update"`. Tags the event type for downstream routers.
|
||||
#[cfg_attr(feature = "serde-json", serde(rename = "type"))]
|
||||
pub event_type: &'static str,
|
||||
|
||||
/// Originating BFLD node identifier.
|
||||
pub node_id: String,
|
||||
|
||||
/// Monotonic capture-clock timestamp in nanoseconds.
|
||||
pub timestamp_ns: u64,
|
||||
|
||||
/// Whether an occupant is present in the sensing zone.
|
||||
pub presence: bool,
|
||||
|
||||
/// Normalized motion magnitude in `[0.0, 1.0]`.
|
||||
pub motion: f32,
|
||||
|
||||
/// Estimated number of occupants.
|
||||
pub person_count: u8,
|
||||
|
||||
/// Sensing confidence in `[0.0, 1.0]`.
|
||||
pub confidence: f32,
|
||||
|
||||
/// Optional zone identifier; absent if the deployment is single-zone.
|
||||
#[cfg_attr(feature = "serde-json", serde(skip_serializing_if = "Option::is_none"))]
|
||||
pub zone_id: Option<String>,
|
||||
|
||||
/// Privacy classification byte for this event.
|
||||
#[cfg_attr(feature = "serde-json", serde(serialize_with = "ser_privacy_class"))]
|
||||
pub privacy_class: PrivacyClass,
|
||||
|
||||
/// Identity-risk score, `[0.0, 1.0]`. Class 2 only; `None` at class 3.
|
||||
#[cfg_attr(feature = "serde-json", serde(skip_serializing_if = "Option::is_none"))]
|
||||
pub identity_risk_score: Option<f32>,
|
||||
|
||||
/// 256-bit BLAKE3 keyed hash of the current cluster. Class 2 only; `None` at class 3.
|
||||
/// Serializes as the JSON string `"blake3:<64-hex>"` per the BFLD wire spec.
|
||||
#[cfg_attr(
|
||||
feature = "serde-json",
|
||||
serde(skip_serializing_if = "Option::is_none", serialize_with = "ser_rf_signature_hash")
|
||||
)]
|
||||
pub rf_signature_hash: Option<[u8; 32]>,
|
||||
}
|
||||
|
||||
impl BfldEvent {
|
||||
/// Build an event from sensing fields, applying the privacy_class policy
|
||||
/// to mask identity-derived fields. `identity_risk_score` and
|
||||
/// `rf_signature_hash` are nulled out at class `Restricted`.
|
||||
#[must_use]
|
||||
pub fn with_privacy_gating(
|
||||
node_id: String,
|
||||
timestamp_ns: u64,
|
||||
presence: bool,
|
||||
motion: f32,
|
||||
person_count: u8,
|
||||
confidence: f32,
|
||||
zone_id: Option<String>,
|
||||
privacy_class: PrivacyClass,
|
||||
identity_risk_score: Option<f32>,
|
||||
rf_signature_hash: Option<[u8; 32]>,
|
||||
) -> Self {
|
||||
let mut e = Self {
|
||||
event_type: "bfld_update",
|
||||
node_id,
|
||||
timestamp_ns,
|
||||
presence,
|
||||
motion,
|
||||
person_count,
|
||||
confidence,
|
||||
zone_id,
|
||||
privacy_class,
|
||||
identity_risk_score,
|
||||
rf_signature_hash,
|
||||
};
|
||||
e.apply_privacy_gating();
|
||||
e
|
||||
}
|
||||
|
||||
/// Idempotently mask fields disallowed at the current `privacy_class`.
|
||||
/// Called by [`Self::with_privacy_gating`]; exposed for callers that
|
||||
/// mutate the event in place before publication.
|
||||
pub fn apply_privacy_gating(&mut self) {
|
||||
if self.privacy_class.as_u8() >= PrivacyClass::Restricted.as_u8() {
|
||||
self.identity_risk_score = None;
|
||||
self.rf_signature_hash = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize to canonical JSON. Fields masked by privacy gating are omitted
|
||||
/// entirely (not emitted as `null`), so a privacy-gated event is
|
||||
/// observationally indistinguishable from one that never had the field set.
|
||||
#[cfg(feature = "serde-json")]
|
||||
pub fn to_json(&self) -> Result<String, serde_json::Error> {
|
||||
serde_json::to_string(self)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "serde-json")]
|
||||
fn ser_privacy_class<S: serde::Serializer>(
|
||||
class: &PrivacyClass,
|
||||
s: S,
|
||||
) -> Result<S::Ok, S::Error> {
|
||||
let name = match class {
|
||||
PrivacyClass::Raw => "raw",
|
||||
PrivacyClass::Derived => "derived",
|
||||
PrivacyClass::Anonymous => "anonymous",
|
||||
PrivacyClass::Restricted => "restricted",
|
||||
};
|
||||
s.serialize_str(name)
|
||||
}
|
||||
|
||||
/// Encode an `Option<[u8; 32]>` as the JSON string `"blake3:<64 lowercase hex chars>"`.
|
||||
/// Used for `rf_signature_hash` so consumers don't have to decode a 32-element JSON
|
||||
/// array of integers. Called only when the value is `Some(_)` because
|
||||
/// `skip_serializing_if = "Option::is_none"` short-circuits the `None` case.
|
||||
#[cfg(feature = "serde-json")]
|
||||
fn ser_rf_signature_hash<S: serde::Serializer>(
|
||||
hash: &Option<[u8; 32]>,
|
||||
s: S,
|
||||
) -> Result<S::Ok, S::Error> {
|
||||
// The unwrap is safe: skip_serializing_if guarantees we only run with Some.
|
||||
let bytes = hash.as_ref().expect("ser_rf_signature_hash called with None");
|
||||
let mut out = String::with_capacity(7 + 64); // "blake3:" + 32*2 hex chars
|
||||
out.push_str("blake3:");
|
||||
for b in bytes {
|
||||
// Manual lowercase-hex push — avoids pulling in the `hex` crate for 32 bytes.
|
||||
out.push(nibble_to_hex(b >> 4));
|
||||
out.push(nibble_to_hex(b & 0x0F));
|
||||
}
|
||||
s.serialize_str(&out)
|
||||
}
|
||||
|
||||
#[cfg(feature = "serde-json")]
|
||||
const fn nibble_to_hex(n: u8) -> char {
|
||||
match n {
|
||||
0..=9 => (b'0' + n) as char,
|
||||
10..=15 => (b'a' + (n - 10)) as char,
|
||||
_ => '?', // unreachable: input is masked with 0x0F
|
||||
}
|
||||
}
|
||||
@@ -1,313 +0,0 @@
|
||||
//! `BfldFrame` wire-format primitives. See ADR-119.
|
||||
//!
|
||||
//! The header is `#[repr(C, packed)]` so the wire byte order is fixed across
|
||||
//! x86_64, aarch64, and xtensa-esp32s3 — and so the witness-bundle pattern
|
||||
//! (ADR-028) extends cleanly to BFLD frames.
|
||||
//!
|
||||
//! All multi-byte integers serialize as **little-endian**. The
|
||||
//! `to_le_bytes`/`from_le_bytes` helpers encode/decode without `unsafe`, which
|
||||
//! is forbidden in this crate; the encoded bytes are the canonical wire form.
|
||||
//!
|
||||
//! CRC-32/ISO-HDLC (the same polynomial Ethernet uses) protects the payload.
|
||||
//! See [`crc32_of_payload`] for the canonical computation.
|
||||
|
||||
use static_assertions::const_assert_eq;
|
||||
|
||||
use crate::BfldError;
|
||||
|
||||
/// CRC-32/ISO-HDLC algorithm used to checksum payload bytes. Poly 0xEDB88320,
|
||||
/// init 0xFFFFFFFF, xorout 0xFFFFFFFF, reflected — same as Ethernet / zlib.
|
||||
pub const CRC32_ALG: crc::Crc<u32> = crc::Crc::<u32>::new(&crc::CRC_32_ISO_HDLC);
|
||||
|
||||
/// Compute the canonical CRC32 over `payload`. The header CRC field is **not**
|
||||
/// included in the digest (ADR-119 §2.2: "CRC32 covers all section bytes
|
||||
/// including length prefixes, but not the header").
|
||||
#[must_use]
|
||||
pub fn crc32_of_payload(payload: &[u8]) -> u32 {
|
||||
CRC32_ALG.checksum(payload)
|
||||
}
|
||||
|
||||
/// Magic value identifying a `BfldFrame`. Reads as "BFLD" in hex-dump tools.
|
||||
pub const BFLD_MAGIC: u32 = 0xBF1D_0001;
|
||||
|
||||
/// Current `BfldFrame` major version. Bumps on any incompatible layout change.
|
||||
pub const BFLD_VERSION: u16 = 1;
|
||||
|
||||
/// Size of the packed header in bytes. Asserted at compile time below.
|
||||
///
|
||||
/// Note: ADR-119 AC1 initially claimed 40 bytes — that was a counting error.
|
||||
/// Actual packed layout sums to 86. Updated 2026-05-24 to match implementation.
|
||||
pub const BFLD_HEADER_SIZE: usize = 86;
|
||||
|
||||
/// Flag bits in `BfldFrameHeader::flags`. See ADR-119 §2.1.
|
||||
pub mod flags {
|
||||
/// Payload contains an optional CSI delta section.
|
||||
pub const HAS_CSI_DELTA: u16 = 1 << 0;
|
||||
/// `privacy_mode` is engaged: identity-derived fields suppressed.
|
||||
pub const PRIVACY_MODE: u16 = 1 << 1;
|
||||
/// ESP32-S3 self-only adapter (ADR-123 §2.5): no `identity_risk_score`.
|
||||
pub const SELF_ONLY: u16 = 1 << 3;
|
||||
|
||||
/// Bitmask covering every named flag this version of the crate knows
|
||||
/// about. Useful for "did the wire form set any flags I don't recognize?"
|
||||
/// forward-compat checks.
|
||||
pub const KNOWN_FLAGS_MASK: u16 = HAS_CSI_DELTA | PRIVACY_MODE | SELF_ONLY;
|
||||
|
||||
/// Complement of [`KNOWN_FLAGS_MASK`] — every bit position not currently
|
||||
/// assigned a meaning. Bits set in this mask MUST round-trip unchanged
|
||||
/// per ADR-119 §2.1 ("Reserved flag bits 2-15 lock in future-extension
|
||||
/// order; any new bit assignment is a version bump"). A future protocol
|
||||
/// revision may light these up; today's parser preserves them so a node
|
||||
/// running iter N can forward unknown bits to a peer running iter N+M
|
||||
/// without losing information.
|
||||
pub const RESERVED_FLAGS_MASK: u16 = !KNOWN_FLAGS_MASK;
|
||||
}
|
||||
|
||||
/// On-the-wire BFLD frame header. 86 bytes, little-endian, packed.
|
||||
#[repr(C, packed)]
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct BfldFrameHeader {
|
||||
/// Must equal [`BFLD_MAGIC`].
|
||||
pub magic: u32,
|
||||
/// Layout version. Currently [`BFLD_VERSION`].
|
||||
pub version: u16,
|
||||
/// Flag bits — see [`flags`].
|
||||
pub flags: u16,
|
||||
/// Monotonic capture-clock timestamp in nanoseconds.
|
||||
pub timestamp_ns: u64,
|
||||
/// BLAKE3-keyed(site_salt, ap_mac)[0..16] — ADR-120 §2.3.
|
||||
pub ap_hash: [u8; 16],
|
||||
/// BLAKE3-keyed(site_salt ‖ day_epoch, sta_mac)[0..16] — daily-rotated.
|
||||
pub sta_hash: [u8; 16],
|
||||
/// Ephemeral session identifier, rotated on capture-session boundary.
|
||||
pub session_id: [u8; 16],
|
||||
/// 802.11 channel number.
|
||||
pub channel: u16,
|
||||
/// Channel bandwidth in MHz: 20 / 40 / 80 / 160.
|
||||
pub bandwidth_mhz: u16,
|
||||
/// Received signal strength in dBm.
|
||||
pub rssi_dbm: i16,
|
||||
/// Noise floor in dBm.
|
||||
pub noise_floor_dbm: i16,
|
||||
/// Number of OFDM subcarriers represented.
|
||||
pub n_subcarriers: u16,
|
||||
/// Number of transmit antennas.
|
||||
pub n_tx: u8,
|
||||
/// Number of receive antennas.
|
||||
pub n_rx: u8,
|
||||
/// 0=f32, 1=i16, 2=i8, 3=packed (4-bit nibbles).
|
||||
pub quantization: u8,
|
||||
/// `PrivacyClass` byte — see ADR-120 §2.1.
|
||||
pub privacy_class: u8,
|
||||
/// Length of the payload section in bytes.
|
||||
pub payload_len: u32,
|
||||
/// CRC-32/ISO-HDLC over payload bytes only.
|
||||
pub payload_crc32: u32,
|
||||
}
|
||||
|
||||
const_assert_eq!(core::mem::size_of::<BfldFrameHeader>(), BFLD_HEADER_SIZE);
|
||||
|
||||
impl BfldFrameHeader {
|
||||
/// Build a header with `magic` and `version` already set correctly.
|
||||
/// All other fields default to zero — caller fills them in.
|
||||
#[must_use]
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
magic: BFLD_MAGIC,
|
||||
version: BFLD_VERSION,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize to canonical little-endian wire form (86 bytes).
|
||||
#[must_use]
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub fn to_le_bytes(&self) -> [u8; BFLD_HEADER_SIZE] {
|
||||
let mut buf = [0u8; BFLD_HEADER_SIZE];
|
||||
let mut o = 0usize;
|
||||
|
||||
// Copy locally to dodge `#[repr(packed)]` unaligned-borrow warnings.
|
||||
let magic = self.magic;
|
||||
let version = self.version;
|
||||
let flags = self.flags;
|
||||
let timestamp_ns = self.timestamp_ns;
|
||||
let channel = self.channel;
|
||||
let bandwidth_mhz = self.bandwidth_mhz;
|
||||
let rssi_dbm = self.rssi_dbm;
|
||||
let noise_floor_dbm = self.noise_floor_dbm;
|
||||
let n_subcarriers = self.n_subcarriers;
|
||||
let payload_len = self.payload_len;
|
||||
let payload_crc32 = self.payload_crc32;
|
||||
|
||||
buf[o..o + 4].copy_from_slice(&magic.to_le_bytes()); o += 4;
|
||||
buf[o..o + 2].copy_from_slice(&version.to_le_bytes()); o += 2;
|
||||
buf[o..o + 2].copy_from_slice(&flags.to_le_bytes()); o += 2;
|
||||
buf[o..o + 8].copy_from_slice(×tamp_ns.to_le_bytes()); o += 8;
|
||||
buf[o..o + 16].copy_from_slice(&self.ap_hash); o += 16;
|
||||
buf[o..o + 16].copy_from_slice(&self.sta_hash); o += 16;
|
||||
buf[o..o + 16].copy_from_slice(&self.session_id); o += 16;
|
||||
buf[o..o + 2].copy_from_slice(&channel.to_le_bytes()); o += 2;
|
||||
buf[o..o + 2].copy_from_slice(&bandwidth_mhz.to_le_bytes()); o += 2;
|
||||
buf[o..o + 2].copy_from_slice(&rssi_dbm.to_le_bytes()); o += 2;
|
||||
buf[o..o + 2].copy_from_slice(&noise_floor_dbm.to_le_bytes()); o += 2;
|
||||
buf[o..o + 2].copy_from_slice(&n_subcarriers.to_le_bytes()); o += 2;
|
||||
buf[o] = self.n_tx; o += 1;
|
||||
buf[o] = self.n_rx; o += 1;
|
||||
buf[o] = self.quantization; o += 1;
|
||||
buf[o] = self.privacy_class; o += 1;
|
||||
buf[o..o + 4].copy_from_slice(&payload_len.to_le_bytes()); o += 4;
|
||||
buf[o..o + 4].copy_from_slice(&payload_crc32.to_le_bytes()); o += 4;
|
||||
|
||||
debug_assert_eq!(o, BFLD_HEADER_SIZE);
|
||||
buf
|
||||
}
|
||||
|
||||
/// Parse from canonical little-endian wire form.
|
||||
///
|
||||
/// Returns [`BfldError::InvalidMagic`] if the magic prefix is wrong, and
|
||||
/// [`BfldError::UnsupportedVersion`] for a version this build cannot decode.
|
||||
/// Field-level validation (CRC, payload_len bounds) is deliberately *not*
|
||||
/// performed here — that lives at the frame-level parser.
|
||||
pub fn from_le_bytes(bytes: &[u8; BFLD_HEADER_SIZE]) -> Result<Self, BfldError> {
|
||||
let magic = u32::from_le_bytes(bytes[0..4].try_into().unwrap());
|
||||
if magic != BFLD_MAGIC {
|
||||
return Err(BfldError::InvalidMagic(magic));
|
||||
}
|
||||
let version = u16::from_le_bytes(bytes[4..6].try_into().unwrap());
|
||||
if version != BFLD_VERSION {
|
||||
return Err(BfldError::UnsupportedVersion(version));
|
||||
}
|
||||
|
||||
let mut h = Self {
|
||||
magic,
|
||||
version,
|
||||
flags: u16::from_le_bytes(bytes[6..8].try_into().unwrap()),
|
||||
timestamp_ns: u64::from_le_bytes(bytes[8..16].try_into().unwrap()),
|
||||
ap_hash: [0; 16],
|
||||
sta_hash: [0; 16],
|
||||
session_id: [0; 16],
|
||||
channel: u16::from_le_bytes(bytes[64..66].try_into().unwrap()),
|
||||
bandwidth_mhz: u16::from_le_bytes(bytes[66..68].try_into().unwrap()),
|
||||
rssi_dbm: i16::from_le_bytes(bytes[68..70].try_into().unwrap()),
|
||||
noise_floor_dbm: i16::from_le_bytes(bytes[70..72].try_into().unwrap()),
|
||||
n_subcarriers: u16::from_le_bytes(bytes[72..74].try_into().unwrap()),
|
||||
n_tx: bytes[74],
|
||||
n_rx: bytes[75],
|
||||
quantization: bytes[76],
|
||||
privacy_class: bytes[77],
|
||||
payload_len: u32::from_le_bytes(bytes[78..82].try_into().unwrap()),
|
||||
payload_crc32: u32::from_le_bytes(bytes[82..86].try_into().unwrap()),
|
||||
};
|
||||
h.ap_hash.copy_from_slice(&bytes[16..32]);
|
||||
h.sta_hash.copy_from_slice(&bytes[32..48]);
|
||||
h.session_id.copy_from_slice(&bytes[48..64]);
|
||||
Ok(h)
|
||||
}
|
||||
}
|
||||
|
||||
// --- BfldFrame (header + payload) ------------------------------------------
|
||||
//
|
||||
// Gated on `std` because the payload is heap-allocated (`Vec<u8>`). ESP32-S3
|
||||
// self-only mode (ADR-123 §2.5) will need a separate `BfldFrameRef<'_>` API
|
||||
// that borrows a caller-provided buffer; that lands in a later iter.
|
||||
|
||||
/// Complete BFLD frame: header + payload bytes. The frame's wire form is
|
||||
/// `header.to_le_bytes() ‖ payload`, with the header's `payload_len` and
|
||||
/// `payload_crc32` fields kept consistent by `to_bytes`/`from_bytes`.
|
||||
#[cfg(feature = "std")]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BfldFrame {
|
||||
/// Header — `payload_len` and `payload_crc32` reflect the payload below.
|
||||
pub header: BfldFrameHeader,
|
||||
/// Raw payload bytes. The internal section layout (compressed_angle_matrix,
|
||||
/// amplitude_proxy, ...) lives in a later iter; for now the byte buffer is
|
||||
/// opaque to this struct.
|
||||
pub payload: Vec<u8>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "std")]
|
||||
impl BfldFrame {
|
||||
/// Construct a frame, automatically syncing `header.payload_len` and
|
||||
/// `header.payload_crc32` to the supplied `payload`.
|
||||
#[must_use]
|
||||
pub fn new(mut header: BfldFrameHeader, payload: Vec<u8>) -> Self {
|
||||
let len = u32::try_from(payload.len()).unwrap_or(u32::MAX);
|
||||
header.payload_len = len;
|
||||
header.payload_crc32 = crc32_of_payload(&payload);
|
||||
Self { header, payload }
|
||||
}
|
||||
|
||||
/// Construct a frame from a typed `BfldPayload`. The header `flags`
|
||||
/// `HAS_CSI_DELTA` bit is auto-synced from `payload.csi_delta.is_some()`,
|
||||
/// then the payload is serialized via [`crate::payload::BfldPayload::to_bytes`]
|
||||
/// and the resulting bytes feed [`BfldFrame::new`]. The CRC therefore covers
|
||||
/// the **section-prefixed** wire bytes per ADR-119 §2.2.
|
||||
#[must_use]
|
||||
pub fn from_payload(
|
||||
mut header: BfldFrameHeader,
|
||||
payload: &crate::payload::BfldPayload,
|
||||
) -> Self {
|
||||
let include_csi_delta = payload.csi_delta.is_some();
|
||||
if include_csi_delta {
|
||||
header.flags |= flags::HAS_CSI_DELTA;
|
||||
} else {
|
||||
header.flags &= !flags::HAS_CSI_DELTA;
|
||||
}
|
||||
let bytes = payload.to_bytes(include_csi_delta);
|
||||
Self::new(header, bytes)
|
||||
}
|
||||
|
||||
/// Parse the opaque payload bytes back into a typed [`crate::payload::BfldPayload`].
|
||||
/// Consults `header.flags & HAS_CSI_DELTA` so the parser matches the
|
||||
/// originating encoder's framing.
|
||||
pub fn parse_payload(&self) -> Result<crate::payload::BfldPayload, BfldError> {
|
||||
let expect_csi_delta = (self.header.flags & flags::HAS_CSI_DELTA) != 0;
|
||||
crate::payload::BfldPayload::from_bytes(&self.payload, expect_csi_delta)
|
||||
}
|
||||
|
||||
/// Serialize to wire form: 86 header bytes + `payload_len` payload bytes.
|
||||
/// Always recomputes `payload_crc32` so the returned bytes are internally
|
||||
/// consistent even if the caller mutated `header.payload_crc32` directly.
|
||||
#[must_use]
|
||||
pub fn to_bytes(&self) -> Vec<u8> {
|
||||
let mut header = self.header;
|
||||
header.payload_len = u32::try_from(self.payload.len()).unwrap_or(u32::MAX);
|
||||
header.payload_crc32 = crc32_of_payload(&self.payload);
|
||||
let header_bytes = header.to_le_bytes();
|
||||
let mut out = Vec::with_capacity(BFLD_HEADER_SIZE + self.payload.len());
|
||||
out.extend_from_slice(&header_bytes);
|
||||
out.extend_from_slice(&self.payload);
|
||||
out
|
||||
}
|
||||
|
||||
/// Parse from wire form. Validates magic, version, payload length, and CRC.
|
||||
pub fn from_bytes(bytes: &[u8]) -> Result<Self, BfldError> {
|
||||
if bytes.len() < BFLD_HEADER_SIZE {
|
||||
return Err(BfldError::TruncatedFrame {
|
||||
got: bytes.len(),
|
||||
need: BFLD_HEADER_SIZE,
|
||||
});
|
||||
}
|
||||
let header_bytes: &[u8; BFLD_HEADER_SIZE] =
|
||||
bytes[..BFLD_HEADER_SIZE].try_into().unwrap();
|
||||
let header = BfldFrameHeader::from_le_bytes(header_bytes)?;
|
||||
|
||||
let payload_len = header.payload_len as usize;
|
||||
let expected_total = BFLD_HEADER_SIZE.saturating_add(payload_len);
|
||||
if bytes.len() < expected_total {
|
||||
return Err(BfldError::TruncatedFrame {
|
||||
got: bytes.len(),
|
||||
need: expected_total,
|
||||
});
|
||||
}
|
||||
let payload = bytes[BFLD_HEADER_SIZE..expected_total].to_vec();
|
||||
|
||||
let actual = crc32_of_payload(&payload);
|
||||
let expected = header.payload_crc32;
|
||||
if actual != expected {
|
||||
return Err(BfldError::Crc { expected, actual });
|
||||
}
|
||||
Ok(Self { header, payload })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,214 +0,0 @@
|
||||
//! Home Assistant MQTT auto-discovery payload publisher. ADR-122 §2.1.
|
||||
//!
|
||||
//! Generates the JSON config messages HA expects on
|
||||
//! `homeassistant/<type>/<unique_id>/config` to auto-create the six BFLD
|
||||
//! entities. Class-gated identically to the state-topic router
|
||||
//! (`mqtt_topics.rs`): `identity_risk` discovery is only published at exactly
|
||||
//! `PrivacyClass::Anonymous`.
|
||||
//!
|
||||
//! Discovery payloads should be published **once per node session**, retained
|
||||
//! by the broker (`retain = true`) so HA finds them on next start. The
|
||||
//! `RumqttPublisher` exposes a `with_retain(true)` builder for this; the
|
||||
//! state-topic loop must keep `retain = false` to avoid stale-state flapping.
|
||||
|
||||
#![cfg(feature = "std")]
|
||||
|
||||
use crate::mqtt_topics::{Publish, TopicMessage};
|
||||
use crate::PrivacyClass;
|
||||
|
||||
/// Bootstrap helper: render the per-node HA-DISCO config payloads and forward
|
||||
/// each through `publisher`. Returns the count published, or short-circuits
|
||||
/// on the first publisher error.
|
||||
///
|
||||
/// Typical bootstrap pattern combining iter 25's `Arc<Mutex<P>>` adapter and
|
||||
/// iter 23's retain-aware `RumqttPublisher`:
|
||||
///
|
||||
/// ```ignore
|
||||
/// use std::sync::{Arc, Mutex};
|
||||
/// use wifi_densepose_bfld::{
|
||||
/// publish_discovery, BfldConfig, BfldPipeline, BfldPipelineHandle,
|
||||
/// PrivacyClass, RumqttPublisher,
|
||||
/// };
|
||||
/// use rumqttc::MqttOptions;
|
||||
///
|
||||
/// let opts = MqttOptions::new("seed-01", "broker.local", 1883);
|
||||
/// let (retained_pub, _conn) = RumqttPublisher::connect(opts.clone(), 64);
|
||||
/// let mut retained_pub = retained_pub.with_retain(true);
|
||||
/// publish_discovery(&mut retained_pub, "seed-01", PrivacyClass::Anonymous)?;
|
||||
///
|
||||
/// let (state_pub, _conn) = RumqttPublisher::connect(opts, 64);
|
||||
/// let pipeline = BfldPipeline::new(BfldConfig::new("seed-01"));
|
||||
/// let handle = BfldPipelineHandle::spawn(pipeline, state_pub);
|
||||
/// // handle.send(...) from now on
|
||||
/// # Ok::<(), rumqttc::ClientError>(())
|
||||
/// ```
|
||||
pub fn publish_discovery<P: Publish>(
|
||||
publisher: &mut P,
|
||||
node_id: &str,
|
||||
class: PrivacyClass,
|
||||
) -> Result<usize, P::Error> {
|
||||
let mut count = 0;
|
||||
for msg in render_discovery_payloads(node_id, class) {
|
||||
publisher.publish(&msg)?;
|
||||
count += 1;
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Render every HA-DISCO config message for the given node at `class`. Returns
|
||||
/// an empty `Vec` for classes < `Anonymous` (HA doesn't see raw / derived).
|
||||
#[must_use]
|
||||
pub fn render_discovery_payloads(node_id: &str, class: PrivacyClass) -> Vec<TopicMessage> {
|
||||
if class.as_u8() < PrivacyClass::Anonymous.as_u8() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut out = Vec::with_capacity(6);
|
||||
|
||||
out.push(config_message(
|
||||
"binary_sensor",
|
||||
node_id,
|
||||
"presence",
|
||||
"BFLD Presence",
|
||||
Some("occupancy"),
|
||||
None,
|
||||
None,
|
||||
));
|
||||
out.push(config_message(
|
||||
"sensor",
|
||||
node_id,
|
||||
"motion",
|
||||
"BFLD Motion",
|
||||
None,
|
||||
None,
|
||||
Some("diagnostic"),
|
||||
));
|
||||
out.push(config_message(
|
||||
"sensor",
|
||||
node_id,
|
||||
"person_count",
|
||||
"BFLD Person Count",
|
||||
None,
|
||||
Some("people"),
|
||||
None,
|
||||
));
|
||||
out.push(config_message(
|
||||
"sensor",
|
||||
node_id,
|
||||
"zone_activity",
|
||||
"BFLD Zone Activity",
|
||||
None,
|
||||
None,
|
||||
Some("diagnostic"),
|
||||
));
|
||||
out.push(config_message(
|
||||
"sensor",
|
||||
node_id,
|
||||
"confidence",
|
||||
"BFLD Confidence",
|
||||
None,
|
||||
None,
|
||||
Some("diagnostic"),
|
||||
));
|
||||
|
||||
// identity_risk discovery only at class 2. Class 3 computes but doesn't
|
||||
// publish — therefore HA should not even see the entity exist.
|
||||
if class == PrivacyClass::Anonymous {
|
||||
out.push(config_message(
|
||||
"sensor",
|
||||
node_id,
|
||||
"identity_risk",
|
||||
"BFLD Identity Risk",
|
||||
None,
|
||||
None,
|
||||
Some("diagnostic"),
|
||||
));
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
fn config_message(
|
||||
ha_type: &str,
|
||||
node_id: &str,
|
||||
entity: &str,
|
||||
name: &str,
|
||||
device_class: Option<&str>,
|
||||
unit_of_measurement: Option<&str>,
|
||||
entity_category: Option<&str>,
|
||||
) -> TopicMessage {
|
||||
let unique_id = format!("{node_id}_bfld_{entity}");
|
||||
let topic = format!("homeassistant/{ha_type}/{unique_id}/config");
|
||||
let state_topic = format!("ruview/{node_id}/bfld/{entity}/state");
|
||||
let availability_topic_str = crate::availability::availability_topic(node_id);
|
||||
|
||||
let mut payload = String::with_capacity(384);
|
||||
payload.push('{');
|
||||
push_str_field(&mut payload, "name", name, true);
|
||||
push_str_field(&mut payload, "unique_id", &unique_id, false);
|
||||
push_str_field(&mut payload, "state_topic", &state_topic, false);
|
||||
// Availability — every entity inherits the device-level offline marker.
|
||||
push_str_field(&mut payload, "availability_topic", &availability_topic_str, false);
|
||||
push_str_field(
|
||||
&mut payload,
|
||||
"payload_available",
|
||||
crate::availability::PAYLOAD_AVAILABLE,
|
||||
false,
|
||||
);
|
||||
push_str_field(
|
||||
&mut payload,
|
||||
"payload_not_available",
|
||||
crate::availability::PAYLOAD_NOT_AVAILABLE,
|
||||
false,
|
||||
);
|
||||
if let Some(dc) = device_class {
|
||||
push_str_field(&mut payload, "device_class", dc, false);
|
||||
}
|
||||
if let Some(unit) = unit_of_measurement {
|
||||
push_str_field(&mut payload, "unit_of_measurement", unit, false);
|
||||
}
|
||||
if let Some(cat) = entity_category {
|
||||
push_str_field(&mut payload, "entity_category", cat, false);
|
||||
}
|
||||
payload.push_str(",\"device\":{");
|
||||
push_str_field(&mut payload, "identifiers", node_id, true);
|
||||
push_str_field(
|
||||
&mut payload,
|
||||
"name",
|
||||
&format!("RuView Seed {node_id}"),
|
||||
false,
|
||||
);
|
||||
push_str_field(&mut payload, "model", "BFLD", false);
|
||||
push_str_field(&mut payload, "manufacturer", "RuView", false);
|
||||
payload.push('}');
|
||||
payload.push('}');
|
||||
|
||||
TopicMessage { topic, payload }
|
||||
}
|
||||
|
||||
fn push_str_field(out: &mut String, key: &str, value: &str, first: bool) {
|
||||
if !first {
|
||||
out.push(',');
|
||||
}
|
||||
out.push('"');
|
||||
out.push_str(key);
|
||||
out.push_str("\":\"");
|
||||
// Minimal JSON escaping for the values BFLD controls — node_id is ASCII
|
||||
// alphanumeric + dash by convention, names are operator-controlled. A
|
||||
// future iter can swap to serde_json::to_string for full escape coverage.
|
||||
for ch in value.chars() {
|
||||
match ch {
|
||||
'"' => out.push_str("\\\""),
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
'\t' => out.push_str("\\t"),
|
||||
c if (c as u32) < 0x20 => {
|
||||
let escape = format!("\\u{:04x}", c as u32);
|
||||
out.push_str(&escape);
|
||||
}
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out.push('"');
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
//! `IdentityFeatures` — typed canonical-bytes encoder for `SignatureHasher`.
|
||||
//!
|
||||
//! Wraps the two possible feature sources (a borrowed [`IdentityEmbedding`] or
|
||||
//! the four-tuple of risk factors) behind a single API so callers don't need
|
||||
//! to know which one ultimately feeds the BLAKE3 keyed hash. Replaces the
|
||||
//! ad-hoc `canonical_risk_bytes` + inline embedding-flatten paths that lived
|
||||
//! in `emitter.rs` through iter 17.
|
||||
//!
|
||||
//! Borrowing semantics:
|
||||
//! - `IdentityFeatures::Embedding(&IdentityEmbedding)` is the **preferred**
|
||||
//! source — it carries the AETHER cluster identity directly.
|
||||
//! - `IdentityFeatures::RiskFactors { .. }` is the fallback used when the
|
||||
//! per-frame embedding is unavailable.
|
||||
//!
|
||||
//! Both variants emit canonical little-endian f32 bytes. Embedding produces
|
||||
//! `EMBEDDING_DIM * 4` bytes (512 by default); risk factors produce
|
||||
//! [`RISK_FACTOR_BYTES`] bytes (16).
|
||||
|
||||
#![cfg(feature = "std")]
|
||||
|
||||
use crate::signature_hasher::{SignatureHasher, RF_SIGNATURE_LEN};
|
||||
use crate::{IdentityEmbedding, EMBEDDING_DIM};
|
||||
|
||||
/// Wire-form length for the `RiskFactors` variant (4 × f32 little-endian).
|
||||
pub const RISK_FACTOR_BYTES: usize = 16;
|
||||
|
||||
/// Borrowed feature source for the signature hasher.
|
||||
#[derive(Debug)]
|
||||
pub enum IdentityFeatures<'a> {
|
||||
/// Preferred: a borrowed identity embedding. The embedding stays in-RAM
|
||||
/// (invariant I2) — this enum holds only a reference.
|
||||
Embedding(&'a IdentityEmbedding),
|
||||
/// Fallback: the four risk-score factors. Less identity-stable than the
|
||||
/// embedding, but always available even when the encoder is offline.
|
||||
RiskFactors {
|
||||
/// `identity_separability_score`.
|
||||
sep: f32,
|
||||
/// `temporal_stability`.
|
||||
stab: f32,
|
||||
/// `cross_perspective_consistency`.
|
||||
consist: f32,
|
||||
/// Risk-score sample confidence factor.
|
||||
conf: f32,
|
||||
},
|
||||
}
|
||||
|
||||
impl<'a> IdentityFeatures<'a> {
|
||||
/// Build from a borrowed embedding (preferred path).
|
||||
#[must_use]
|
||||
pub const fn from_embedding(emb: &'a IdentityEmbedding) -> Self {
|
||||
Self::Embedding(emb)
|
||||
}
|
||||
|
||||
/// Build from the risk-factor four-tuple (fallback path).
|
||||
#[must_use]
|
||||
pub const fn from_risk_factors(sep: f32, stab: f32, consist: f32, conf: f32) -> Self {
|
||||
Self::RiskFactors {
|
||||
sep,
|
||||
stab,
|
||||
consist,
|
||||
conf,
|
||||
}
|
||||
}
|
||||
|
||||
/// Predicted wire length without allocating.
|
||||
#[must_use]
|
||||
pub const fn canonical_byte_len(&self) -> usize {
|
||||
match self {
|
||||
Self::Embedding(_) => EMBEDDING_DIM * 4,
|
||||
Self::RiskFactors { .. } => RISK_FACTOR_BYTES,
|
||||
}
|
||||
}
|
||||
|
||||
/// Append canonical little-endian bytes to `out`. Useful for callers that
|
||||
/// already own a buffer (avoids the `canonical_bytes` allocation).
|
||||
pub fn write_canonical_bytes(&self, out: &mut Vec<u8>) {
|
||||
out.reserve(self.canonical_byte_len());
|
||||
match self {
|
||||
Self::Embedding(emb) => {
|
||||
for f in emb.as_slice() {
|
||||
out.extend_from_slice(&f.to_le_bytes());
|
||||
}
|
||||
}
|
||||
Self::RiskFactors {
|
||||
sep,
|
||||
stab,
|
||||
consist,
|
||||
conf,
|
||||
} => {
|
||||
out.extend_from_slice(&sep.to_le_bytes());
|
||||
out.extend_from_slice(&stab.to_le_bytes());
|
||||
out.extend_from_slice(&consist.to_le_bytes());
|
||||
out.extend_from_slice(&conf.to_le_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Allocating convenience wrapper around [`Self::write_canonical_bytes`].
|
||||
#[must_use]
|
||||
pub fn canonical_bytes(&self) -> Vec<u8> {
|
||||
let mut v = Vec::with_capacity(self.canonical_byte_len());
|
||||
self.write_canonical_bytes(&mut v);
|
||||
v
|
||||
}
|
||||
|
||||
/// Drive `hasher` with this feature source at the given `day_epoch`. The
|
||||
/// returned hash is what the emitter publishes as `rf_signature_hash`.
|
||||
#[must_use]
|
||||
pub fn compute_hash(
|
||||
&self,
|
||||
hasher: &SignatureHasher,
|
||||
day_epoch: u32,
|
||||
) -> [u8; RF_SIGNATURE_LEN] {
|
||||
hasher.compute(day_epoch, &self.canonical_bytes())
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
//! Identity-risk scoring and coherence-gate action mapping. ADR-121 §2.2–§2.4.
|
||||
//!
|
||||
//! The risk score is a multiplicative combination of four bounded factors:
|
||||
//!
|
||||
//! ```text
|
||||
//! identity_risk_score = clamp(sep × stab × consist × conf, 0.0, 1.0)
|
||||
//! ```
|
||||
//!
|
||||
//! Multiplicative combination is **conservative under uncertainty**: any single
|
||||
//! near-zero factor (e.g., very low sample confidence) collapses the score
|
||||
//! toward 0. This biases the system toward "report low risk when unsure",
|
||||
//! which is the privacy-preferred default.
|
||||
//!
|
||||
//! The score maps deterministically to a [`GateAction`]:
|
||||
//!
|
||||
//! | Score range | Action | Effect |
|
||||
//! |------------------------|-----------------|-------------------------------------------|
|
||||
//! | `score < 0.5` | `Accept` | Publish normally |
|
||||
//! | `0.5 <= score < 0.7` | `PredictOnly` | Publish with `confidence` flag lowered |
|
||||
//! | `0.7 <= score < 0.9` | `Reject` | Drop the event entirely |
|
||||
//! | `score >= 0.9` | `Recalibrate` | Drop AND rotate `site_salt` (per ADR-120) |
|
||||
//!
|
||||
//! This iter ships the **stateless** mapping. Hysteresis (±0.05) and the
|
||||
//! 5-second debounce land in the `CoherenceGate` struct in a subsequent iter.
|
||||
|
||||
/// Lower edge of `PredictOnly` (inclusive).
|
||||
pub const PREDICT_ONLY_THRESHOLD: f32 = 0.5;
|
||||
/// Lower edge of `Reject` (inclusive).
|
||||
pub const REJECT_THRESHOLD: f32 = 0.7;
|
||||
/// Lower edge of `Recalibrate` (inclusive). Triggers `site_salt` rotation.
|
||||
pub const RECALIBRATE_THRESHOLD: f32 = 0.9;
|
||||
|
||||
/// Compute the identity-risk score from its four factors.
|
||||
///
|
||||
/// Each input is clamped to `[0.0, 1.0]`; the result is always in that range
|
||||
/// even if the inputs include NaN (treated as 0.0 by `clamp` per its contract).
|
||||
#[must_use]
|
||||
pub fn score(sep: f32, stab: f32, consist: f32, conf: f32) -> f32 {
|
||||
let s = clamp01(sep);
|
||||
let t = clamp01(stab);
|
||||
let p = clamp01(consist);
|
||||
let c = clamp01(conf);
|
||||
clamp01(s * t * p * c)
|
||||
}
|
||||
|
||||
/// `clamp01` — handles NaN by mapping it to 0.0, matching the
|
||||
/// privacy-conservative bias documented in ADR-121 §2.2.
|
||||
fn clamp01(v: f32) -> f32 {
|
||||
if v.is_nan() {
|
||||
0.0
|
||||
} else {
|
||||
v.clamp(0.0, 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Coherence-gate decision derived from the current risk score. ADR-121 §2.4.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum GateAction {
|
||||
/// Publish the event normally.
|
||||
Accept,
|
||||
/// Publish but mark the event as "predicted-only" — downstream consumers
|
||||
/// (HA, Matter) should display reduced confidence.
|
||||
PredictOnly,
|
||||
/// Drop the event entirely; do not publish on any sink.
|
||||
Reject,
|
||||
/// Drop the event AND rotate the site-keyed BLAKE3 salt so future
|
||||
/// `rf_signature_hash` values cannot correlate with past ones.
|
||||
Recalibrate,
|
||||
}
|
||||
|
||||
impl GateAction {
|
||||
/// Map a risk score to the corresponding gate action.
|
||||
///
|
||||
/// Boundary semantics: thresholds are **inclusive of the lower edge**.
|
||||
/// `score = 0.7` is `Reject`; `score = 0.9` is `Recalibrate`.
|
||||
#[must_use]
|
||||
pub fn from_score(score: f32) -> Self {
|
||||
if score.is_nan() {
|
||||
// Conservative: an undefined score should not trigger anything
|
||||
// beyond a normal publish — the gate-runner is responsible for
|
||||
// logging the NaN as an upstream data-quality issue.
|
||||
return Self::Accept;
|
||||
}
|
||||
if score < PREDICT_ONLY_THRESHOLD {
|
||||
Self::Accept
|
||||
} else if score < REJECT_THRESHOLD {
|
||||
Self::PredictOnly
|
||||
} else if score < RECALIBRATE_THRESHOLD {
|
||||
Self::Reject
|
||||
} else {
|
||||
Self::Recalibrate
|
||||
}
|
||||
}
|
||||
|
||||
/// `true` for `Accept` and `PredictOnly` — both produce a published event.
|
||||
#[must_use]
|
||||
pub const fn allows_publish(self) -> bool {
|
||||
matches!(self, Self::Accept | Self::PredictOnly)
|
||||
}
|
||||
|
||||
/// `true` for `Reject` and `Recalibrate` — both drop the current event.
|
||||
#[must_use]
|
||||
pub const fn drops_event(self) -> bool {
|
||||
matches!(self, Self::Reject | Self::Recalibrate)
|
||||
}
|
||||
|
||||
/// `true` only for `Recalibrate` — the gate-runner must rotate `site_salt`
|
||||
/// and `drain()` the `EmbeddingRing` (per ADR-120 §2.5 + ADR-121 §2.4).
|
||||
#[must_use]
|
||||
pub const fn requires_recalibrate(self) -> bool {
|
||||
matches!(self, Self::Recalibrate)
|
||||
}
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
//! # BFLD — Beamforming Feedback Layer for Detection
|
||||
//!
|
||||
//! Privacy-gated WiFi sensing primitives derived from 802.11ac/ax Beamforming
|
||||
//! Feedback Information (BFI). See [`docs/adr/ADR-118-bfld-beamforming-feedback-layer-for-detection.md`](../../../docs/adr/ADR-118-bfld-beamforming-feedback-layer-for-detection.md).
|
||||
//!
|
||||
//! ## Three structural invariants
|
||||
//!
|
||||
//! - **I1**: Raw BFI never exits the node.
|
||||
//! - **I2**: Identity embedding is in-RAM-only.
|
||||
//! - **I3**: Cross-site identity correlation is cryptographically impossible.
|
||||
//!
|
||||
//! Status: P1 in progress — frame format + sink marker traits. P2–P6 follow.
|
||||
|
||||
#![cfg_attr(not(feature = "std"), no_std)]
|
||||
|
||||
pub mod coherence_gate;
|
||||
pub mod embedding;
|
||||
pub mod embedding_ring;
|
||||
#[cfg(feature = "std")]
|
||||
pub mod emitter;
|
||||
#[cfg(feature = "std")]
|
||||
pub mod availability;
|
||||
#[cfg(feature = "std")]
|
||||
pub mod event;
|
||||
pub mod frame;
|
||||
#[cfg(feature = "std")]
|
||||
pub mod ha_discovery;
|
||||
#[cfg(feature = "std")]
|
||||
pub mod mqtt_topics;
|
||||
#[cfg(feature = "std")]
|
||||
pub mod identity_features;
|
||||
pub mod identity_risk;
|
||||
#[cfg(feature = "std")]
|
||||
pub mod payload;
|
||||
#[cfg(feature = "std")]
|
||||
pub mod pipeline;
|
||||
#[cfg(feature = "std")]
|
||||
pub mod pipeline_handle;
|
||||
#[cfg(feature = "std")]
|
||||
pub mod privacy_gate;
|
||||
#[cfg(feature = "mqtt")]
|
||||
pub mod rumqttc_publisher;
|
||||
pub mod signature_hasher;
|
||||
pub mod sink;
|
||||
|
||||
pub use coherence_gate::{CoherenceGate, MatchOutcome, NullOracle, SoulMatchOracle};
|
||||
#[cfg(feature = "std")]
|
||||
pub use emitter::{BfldEmitter, SensingInputs};
|
||||
#[cfg(feature = "std")]
|
||||
pub use event::BfldEvent;
|
||||
#[cfg(feature = "std")]
|
||||
pub use availability::{
|
||||
availability_topic, offline_message, online_message, publish_availability_offline,
|
||||
publish_availability_online, PAYLOAD_AVAILABLE, PAYLOAD_NOT_AVAILABLE,
|
||||
};
|
||||
#[cfg(feature = "std")]
|
||||
pub use ha_discovery::{publish_discovery, render_discovery_payloads};
|
||||
#[cfg(feature = "std")]
|
||||
pub use mqtt_topics::{publish_event, render_events, CapturePublisher, Publish, TopicMessage};
|
||||
#[cfg(feature = "mqtt")]
|
||||
pub use rumqttc_publisher::{with_lwt, RumqttPublisher};
|
||||
pub use embedding::{IdentityEmbedding, EMBEDDING_DIM};
|
||||
pub use embedding_ring::{EmbeddingRing, RING_CAPACITY};
|
||||
#[cfg(feature = "std")]
|
||||
pub use identity_features::{IdentityFeatures, RISK_FACTOR_BYTES};
|
||||
pub use identity_risk::{score as identity_risk_score, GateAction};
|
||||
pub use frame::{BfldFrameHeader, BFLD_MAGIC, BFLD_VERSION, BFLD_HEADER_SIZE};
|
||||
#[cfg(feature = "std")]
|
||||
pub use frame::BfldFrame;
|
||||
#[cfg(feature = "std")]
|
||||
pub use payload::BfldPayload;
|
||||
#[cfg(feature = "std")]
|
||||
pub use pipeline::{BfldConfig, BfldPipeline};
|
||||
#[cfg(feature = "std")]
|
||||
pub use pipeline_handle::{BfldPipelineHandle, PipelineInput};
|
||||
#[cfg(feature = "std")]
|
||||
pub use privacy_gate::PrivacyGate;
|
||||
pub use signature_hasher::{SignatureHasher, RF_SIGNATURE_LEN, SITE_SALT_LEN};
|
||||
pub use sink::{check_class, LocalSink, MatterSink, NetworkSink, Sink};
|
||||
|
||||
/// Privacy classification carried in every `BfldFrame`. See ADR-120 §2.1.
|
||||
#[repr(u8)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum PrivacyClass {
|
||||
/// Local-only research data including raw BFI matrix. Never networked.
|
||||
Raw = 0,
|
||||
/// Operator-acknowledged research mode over LAN. Downsampled angles +
|
||||
/// identity_embedding + identity_risk_score available. Required for
|
||||
/// Soul Signature deployments (ADR-120 §2.7).
|
||||
Derived = 1,
|
||||
/// Production default: aggregate sensing only, no identity-derived fields.
|
||||
Anonymous = 2,
|
||||
/// Care-home / regulated deployments: class 2 minus risk score and hash.
|
||||
Restricted = 3,
|
||||
}
|
||||
|
||||
impl PrivacyClass {
|
||||
/// Returns `true` if frames of this class may cross a `NetworkSink`.
|
||||
/// Class 0 (`Raw`) is local-only by structural invariant I1.
|
||||
#[must_use]
|
||||
pub const fn allows_network(self) -> bool {
|
||||
!matches!(self, Self::Raw)
|
||||
}
|
||||
|
||||
/// Returns `true` if frames of this class may cross the Matter boundary.
|
||||
/// Only classes 2 and 3 are Matter-eligible. See ADR-122 §2.4.
|
||||
#[must_use]
|
||||
pub const fn allows_matter(self) -> bool {
|
||||
matches!(self, Self::Anonymous | Self::Restricted)
|
||||
}
|
||||
|
||||
/// Returns the byte value of this class (0..=3) for serialization.
|
||||
#[must_use]
|
||||
pub const fn as_u8(self) -> u8 {
|
||||
self as u8
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<u8> for PrivacyClass {
|
||||
type Error = BfldError;
|
||||
|
||||
fn try_from(value: u8) -> Result<Self, Self::Error> {
|
||||
match value {
|
||||
0 => Ok(Self::Raw),
|
||||
1 => Ok(Self::Derived),
|
||||
2 => Ok(Self::Anonymous),
|
||||
3 => Ok(Self::Restricted),
|
||||
other => Err(BfldError::InvalidPrivacyClass(other)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors produced by BFLD operations.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum BfldError {
|
||||
/// Header magic did not match `BFLD_MAGIC`.
|
||||
#[error("invalid BFLD magic: expected 0x{BFLD_MAGIC:08X}, got 0x{0:08X}")]
|
||||
InvalidMagic(u32),
|
||||
|
||||
/// Header version unsupported.
|
||||
#[error("unsupported BFLD version: {0}")]
|
||||
UnsupportedVersion(u16),
|
||||
|
||||
/// Payload CRC32 mismatch — frame corrupted or tampered.
|
||||
#[error("payload CRC mismatch: expected 0x{expected:08X}, got 0x{actual:08X}")]
|
||||
Crc {
|
||||
/// CRC value the header declared.
|
||||
expected: u32,
|
||||
/// CRC value computed over the received payload.
|
||||
actual: u32,
|
||||
},
|
||||
|
||||
/// Attempted to publish a class-0 (`Raw`) frame through a network sink.
|
||||
/// Enforces structural invariant I1.
|
||||
#[error("privacy violation: {reason}")]
|
||||
PrivacyViolation {
|
||||
/// `Sink::KIND` of the sink that rejected the frame.
|
||||
reason: &'static str,
|
||||
},
|
||||
|
||||
/// Byte value did not map to any defined `PrivacyClass` (0..=3).
|
||||
#[error("invalid PrivacyClass byte: {0}")]
|
||||
InvalidPrivacyClass(u8),
|
||||
|
||||
/// Buffer too short for header (86 bytes) or header + declared payload.
|
||||
#[error("truncated frame: got {got} bytes, need at least {need}")]
|
||||
TruncatedFrame {
|
||||
/// Bytes available in the input buffer.
|
||||
got: usize,
|
||||
/// Bytes the header indicates are required.
|
||||
need: usize,
|
||||
},
|
||||
|
||||
/// Payload section length-prefix decoding failed or trailing bytes left over.
|
||||
#[error("malformed payload section at offset {offset}: {reason}")]
|
||||
MalformedSection {
|
||||
/// Byte offset within the payload where parsing failed.
|
||||
offset: usize,
|
||||
/// Human-readable reason for the failure.
|
||||
reason: &'static str,
|
||||
},
|
||||
|
||||
/// Attempted to demote a frame to a class with MORE information than the
|
||||
/// current class (lower numerical value). `demote` is monotonic; the only
|
||||
/// way to add information back is to receive a fresh frame.
|
||||
#[error("invalid demote: cannot move from class {from} to class {to}")]
|
||||
InvalidDemote {
|
||||
/// Source class byte value.
|
||||
from: u8,
|
||||
/// Refused target class byte value.
|
||||
to: u8,
|
||||
},
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
//! MQTT topic router. ADR-122 §2.2.
|
||||
//!
|
||||
//! Pure-function module that maps a [`BfldEvent`] into a list of per-entity
|
||||
//! MQTT topic + payload pairs. No broker dependency lives here — the actual
|
||||
//! `publish` call is a thin wrapper around `Client::publish(topic, payload)`
|
||||
//! once a broker integration lands (deferred to a follow-up iter).
|
||||
//!
|
||||
//! Topic shape (ADR-122 §2.2):
|
||||
//!
|
||||
//! ```text
|
||||
//! ruview/<node_id>/bfld/presence/state # class >= 2
|
||||
//! ruview/<node_id>/bfld/motion/state # class >= 2
|
||||
//! ruview/<node_id>/bfld/person_count/state # class >= 2
|
||||
//! ruview/<node_id>/bfld/zone_activity/state # class >= 2 (when zone_id set)
|
||||
//! ruview/<node_id>/bfld/confidence/state # class >= 2
|
||||
//! ruview/<node_id>/bfld/identity_risk/state # class == 2 only
|
||||
//! ```
|
||||
//!
|
||||
//! `raw` (class-1) and `availability` topics are intentionally not yet emitted
|
||||
//! by this router; they belong to the broker-connection lifecycle, not to the
|
||||
//! per-event publish loop.
|
||||
|
||||
#![cfg(feature = "std")]
|
||||
|
||||
use crate::{BfldEvent, PrivacyClass};
|
||||
|
||||
/// Per-topic MQTT message ready to feed into `Client::publish(topic, payload)`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TopicMessage {
|
||||
/// Full MQTT topic, e.g. `ruview/seed-01/bfld/presence/state`.
|
||||
pub topic: String,
|
||||
/// UTF-8 payload bytes — single JSON scalar (`true`, `0.72`, `"living_room"`)
|
||||
/// or a compact JSON object for diagnostics.
|
||||
pub payload: String,
|
||||
}
|
||||
|
||||
impl TopicMessage {
|
||||
/// Build a topic of the form `ruview/<node_id>/bfld/<suffix>/state`.
|
||||
#[must_use]
|
||||
pub fn ruview_topic(node_id: &str, entity: &str) -> String {
|
||||
let mut s = String::with_capacity(7 + node_id.len() + 6 + entity.len() + 6);
|
||||
s.push_str("ruview/");
|
||||
s.push_str(node_id);
|
||||
s.push_str("/bfld/");
|
||||
s.push_str(entity);
|
||||
s.push_str("/state");
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
/// Abstract MQTT publisher boundary. The crate ships only the trait + a
|
||||
/// capture-impl for tests; the production rumqttc-backed impl lands in a
|
||||
/// follow-up iter behind a `mqtt` feature gate.
|
||||
///
|
||||
/// `publish` is synchronous so callers can hold a `&mut self` without an
|
||||
/// async runtime; the rumqttc wrapper drives a tokio task internally.
|
||||
pub trait Publish {
|
||||
/// Error type — typically the broker's transport error.
|
||||
type Error;
|
||||
/// Publish a single rendered message. Implementations may buffer.
|
||||
fn publish(&mut self, msg: &TopicMessage) -> Result<(), Self::Error>;
|
||||
}
|
||||
|
||||
/// Capture-impl for unit tests. Stores every published message in order.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct CapturePublisher {
|
||||
/// Every `publish()` call appends to this vec.
|
||||
pub published: Vec<TopicMessage>,
|
||||
}
|
||||
|
||||
impl Publish for CapturePublisher {
|
||||
type Error = core::convert::Infallible;
|
||||
fn publish(&mut self, msg: &TopicMessage) -> Result<(), Self::Error> {
|
||||
self.published.push(msg.clone());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Forward `Publish` through a shared `Arc<Mutex<P>>` so a publisher owned by
|
||||
/// a worker thread can still be inspected by the test or operator after the
|
||||
/// fact. Lock-poisoning is treated as a panic — there is no recovery story.
|
||||
impl<P: Publish> Publish for std::sync::Arc<std::sync::Mutex<P>> {
|
||||
type Error = P::Error;
|
||||
fn publish(&mut self, msg: &TopicMessage) -> Result<(), Self::Error> {
|
||||
self.lock()
|
||||
.expect("BFLD publish: inner publisher Mutex poisoned")
|
||||
.publish(msg)
|
||||
}
|
||||
}
|
||||
|
||||
/// Publish every topic message rendered from `event`. Returns the number of
|
||||
/// messages actually published (zero for Raw / Derived class events). Errors
|
||||
/// short-circuit — the publisher state at error time may have partial output.
|
||||
pub fn publish_event<P: Publish>(
|
||||
publisher: &mut P,
|
||||
event: &BfldEvent,
|
||||
) -> Result<usize, P::Error> {
|
||||
let mut count = 0;
|
||||
for msg in render_events(event) {
|
||||
publisher.publish(&msg)?;
|
||||
count += 1;
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Render an event into the per-entity MQTT messages it should publish. Returns
|
||||
/// an empty vec for events that fail the class gate (e.g., raw class 0).
|
||||
#[must_use]
|
||||
pub fn render_events(event: &BfldEvent) -> Vec<TopicMessage> {
|
||||
let class_byte = event.privacy_class.as_u8();
|
||||
if class_byte < PrivacyClass::Anonymous.as_u8() {
|
||||
// Raw + Derived stay local — never published on the public topic tree.
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut out = Vec::with_capacity(6);
|
||||
let node = &event.node_id;
|
||||
|
||||
out.push(TopicMessage {
|
||||
topic: TopicMessage::ruview_topic(node, "presence"),
|
||||
payload: if event.presence { "true".into() } else { "false".into() },
|
||||
});
|
||||
out.push(TopicMessage {
|
||||
topic: TopicMessage::ruview_topic(node, "motion"),
|
||||
payload: format!("{:.6}", event.motion),
|
||||
});
|
||||
out.push(TopicMessage {
|
||||
topic: TopicMessage::ruview_topic(node, "person_count"),
|
||||
payload: format!("{}", event.person_count),
|
||||
});
|
||||
out.push(TopicMessage {
|
||||
topic: TopicMessage::ruview_topic(node, "confidence"),
|
||||
payload: format!("{:.6}", event.confidence),
|
||||
});
|
||||
|
||||
if let Some(zone) = &event.zone_id {
|
||||
// Emit a JSON string so consumers can distinguish "no zone" (omitted)
|
||||
// from "single-zone deployment" (always the same zone string).
|
||||
out.push(TopicMessage {
|
||||
topic: TopicMessage::ruview_topic(node, "zone_activity"),
|
||||
payload: format!("\"{zone}\""),
|
||||
});
|
||||
}
|
||||
|
||||
// Identity risk is only published at exactly class 2 (Anonymous). Class 3
|
||||
// (Restricted) computes the score internally but never emits it.
|
||||
if class_byte == PrivacyClass::Anonymous.as_u8() {
|
||||
if let Some(score) = event.identity_risk_score {
|
||||
out.push(TopicMessage {
|
||||
topic: TopicMessage::ruview_topic(node, "identity_risk"),
|
||||
payload: format!("{score:.6}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
//! BFLD payload section parser. See ADR-119 §2.2.
|
||||
//!
|
||||
//! The payload is a length-prefixed sequence of typed sections in this fixed
|
||||
//! order:
|
||||
//!
|
||||
//! ```text
|
||||
//! payload = compressed_angle_matrix
|
||||
//! ‖ amplitude_proxy
|
||||
//! ‖ phase_proxy
|
||||
//! ‖ snr_vector
|
||||
//! ‖ csi_delta (present iff flags.bit0 set)
|
||||
//! ‖ vendor_extension (length 0 allowed)
|
||||
//! ```
|
||||
//!
|
||||
//! Each section is encoded as `[u32 len_le][bytes...]`. Vendor extension is
|
||||
//! always present in the wire form (length may be zero); CSI delta is gated by
|
||||
//! the header `flags::HAS_CSI_DELTA` bit and is omitted entirely when off.
|
||||
//!
|
||||
//! Gated on `std` because the parser hands the caller owned `Vec<u8>` sections.
|
||||
//! A future zero-copy `BfldPayloadRef<'_>` variant will land alongside the
|
||||
//! ESP32-S3 self-only adapter (ADR-123 §2.5).
|
||||
|
||||
#![cfg(feature = "std")]
|
||||
|
||||
use crate::BfldError;
|
||||
|
||||
/// Length-prefix size in bytes for each section.
|
||||
pub const SECTION_PREFIX_LEN: usize = 4;
|
||||
|
||||
/// Parsed payload sections.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct BfldPayload {
|
||||
/// Compressed beamforming angle matrix (Φ/ψ Givens rotations).
|
||||
pub compressed_angle_matrix: Vec<u8>,
|
||||
/// Per-subcarrier amplitude proxy.
|
||||
pub amplitude_proxy: Vec<u8>,
|
||||
/// Per-subcarrier phase proxy.
|
||||
pub phase_proxy: Vec<u8>,
|
||||
/// Per-subcarrier SNR vector.
|
||||
pub snr_vector: Vec<u8>,
|
||||
/// Optional CSI delta fusion section (present iff header `flags.bit0` set).
|
||||
pub csi_delta: Option<Vec<u8>>,
|
||||
/// Vendor-extension bytes outside the witness hash. Length 0 is permitted.
|
||||
pub vendor_extension: Vec<u8>,
|
||||
}
|
||||
|
||||
impl BfldPayload {
|
||||
/// Serialize to canonical wire form.
|
||||
///
|
||||
/// `include_csi_delta` must match the header `flags::HAS_CSI_DELTA` bit
|
||||
/// the resulting payload will be paired with. When `true`, the `csi_delta`
|
||||
/// section is emitted (using an empty section if `self.csi_delta` is `None`).
|
||||
/// When `false`, the section is omitted entirely.
|
||||
#[must_use]
|
||||
pub fn to_bytes(&self, include_csi_delta: bool) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(self.wire_len(include_csi_delta));
|
||||
push_section(&mut out, &self.compressed_angle_matrix);
|
||||
push_section(&mut out, &self.amplitude_proxy);
|
||||
push_section(&mut out, &self.phase_proxy);
|
||||
push_section(&mut out, &self.snr_vector);
|
||||
if include_csi_delta {
|
||||
let csi = self.csi_delta.as_deref().unwrap_or(&[]);
|
||||
push_section(&mut out, csi);
|
||||
}
|
||||
push_section(&mut out, &self.vendor_extension);
|
||||
out
|
||||
}
|
||||
|
||||
/// Predict the wire size of a future `to_bytes` call without serializing.
|
||||
#[must_use]
|
||||
pub fn wire_len(&self, include_csi_delta: bool) -> usize {
|
||||
let mut n = SECTION_PREFIX_LEN * 5 // 4 mandatory + vendor
|
||||
+ self.compressed_angle_matrix.len()
|
||||
+ self.amplitude_proxy.len()
|
||||
+ self.phase_proxy.len()
|
||||
+ self.snr_vector.len()
|
||||
+ self.vendor_extension.len();
|
||||
if include_csi_delta {
|
||||
n += SECTION_PREFIX_LEN + self.csi_delta.as_deref().map_or(0, <[u8]>::len);
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// Parse from canonical wire form.
|
||||
///
|
||||
/// `expect_csi_delta` must reflect the paired header's `flags::HAS_CSI_DELTA`
|
||||
/// bit. Returns `MalformedSection` if a section length runs past the buffer
|
||||
/// end, or if trailing bytes remain after the vendor-extension section.
|
||||
pub fn from_bytes(bytes: &[u8], expect_csi_delta: bool) -> Result<Self, BfldError> {
|
||||
let mut cursor = 0usize;
|
||||
let compressed_angle_matrix = read_section(bytes, &mut cursor)?;
|
||||
let amplitude_proxy = read_section(bytes, &mut cursor)?;
|
||||
let phase_proxy = read_section(bytes, &mut cursor)?;
|
||||
let snr_vector = read_section(bytes, &mut cursor)?;
|
||||
let csi_delta = if expect_csi_delta {
|
||||
Some(read_section(bytes, &mut cursor)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let vendor_extension = read_section(bytes, &mut cursor)?;
|
||||
|
||||
if cursor != bytes.len() {
|
||||
return Err(BfldError::MalformedSection {
|
||||
offset: cursor,
|
||||
reason: "trailing bytes after vendor_extension",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
compressed_angle_matrix,
|
||||
amplitude_proxy,
|
||||
phase_proxy,
|
||||
snr_vector,
|
||||
csi_delta,
|
||||
vendor_extension,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn push_section(out: &mut Vec<u8>, bytes: &[u8]) {
|
||||
let len = u32::try_from(bytes.len()).unwrap_or(u32::MAX);
|
||||
out.extend_from_slice(&len.to_le_bytes());
|
||||
out.extend_from_slice(bytes);
|
||||
}
|
||||
|
||||
fn read_section(bytes: &[u8], cursor: &mut usize) -> Result<Vec<u8>, BfldError> {
|
||||
let start = *cursor;
|
||||
if start + SECTION_PREFIX_LEN > bytes.len() {
|
||||
return Err(BfldError::MalformedSection {
|
||||
offset: start,
|
||||
reason: "section length prefix runs past buffer end",
|
||||
});
|
||||
}
|
||||
let len_bytes: [u8; 4] = bytes[start..start + SECTION_PREFIX_LEN].try_into().unwrap();
|
||||
let len = u32::from_le_bytes(len_bytes) as usize;
|
||||
let data_start = start + SECTION_PREFIX_LEN;
|
||||
let data_end = data_start
|
||||
.checked_add(len)
|
||||
.ok_or(BfldError::MalformedSection {
|
||||
offset: start,
|
||||
reason: "section length overflows usize",
|
||||
})?;
|
||||
if data_end > bytes.len() {
|
||||
return Err(BfldError::MalformedSection {
|
||||
offset: start,
|
||||
reason: "section body runs past buffer end",
|
||||
});
|
||||
}
|
||||
*cursor = data_end;
|
||||
Ok(bytes[data_start..data_end].to_vec())
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
//! `BfldPipeline` — public entry point. ADR-118 §2.1.
|
||||
//!
|
||||
//! Thin facade over [`crate::BfldEmitter`] that adds:
|
||||
//!
|
||||
//! - A configuration struct ([`BfldConfig`]) for ergonomic construction.
|
||||
//! - A `privacy_mode` toggle that flips the active class to
|
||||
//! [`PrivacyClass::Restricted`] (and back to the configured baseline)
|
||||
//! without rebuilding the underlying emitter state.
|
||||
//! - A single named consumer call ([`Self::process`]) so callers don't have
|
||||
//! to navigate the lower-level emitter API.
|
||||
//!
|
||||
//! Future iters add `process_to_frame()` (BfldFrame production) and a `tokio`
|
||||
//! MQTT loop wrapper on top of this same facade.
|
||||
|
||||
#![cfg(feature = "std")]
|
||||
|
||||
use crate::coherence_gate::SoulMatchOracle;
|
||||
use crate::emitter::{BfldEmitter, SensingInputs};
|
||||
use crate::identity_risk::GateAction;
|
||||
use crate::signature_hasher::SignatureHasher;
|
||||
use crate::{BfldEvent, BfldFrame, BfldFrameHeader, BfldPayload, IdentityEmbedding, PrivacyClass};
|
||||
|
||||
/// Construction parameters for [`BfldPipeline`]. Matches the ADR-118 default-
|
||||
/// secure posture: `class = Anonymous`, no zone, no signature hasher.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BfldConfig {
|
||||
/// Node identifier published in every `BfldEvent.node_id`.
|
||||
pub node_id: String,
|
||||
/// Optional default zone; passed through to every event.
|
||||
pub default_zone_id: Option<String>,
|
||||
/// Baseline privacy class. `privacy_mode = true` overrides to Restricted.
|
||||
pub privacy_class: PrivacyClass,
|
||||
/// Optional signature hasher; when present, the pipeline derives
|
||||
/// `rf_signature_hash` via [`crate::IdentityFeatures`].
|
||||
pub signature_hasher: Option<SignatureHasher>,
|
||||
}
|
||||
|
||||
impl BfldConfig {
|
||||
/// Build a minimal config: node_id only, class defaulted to Anonymous.
|
||||
#[must_use]
|
||||
pub fn new(node_id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
node_id: node_id.into(),
|
||||
default_zone_id: None,
|
||||
privacy_class: PrivacyClass::Anonymous,
|
||||
signature_hasher: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the default zone.
|
||||
#[must_use]
|
||||
pub fn with_zone(mut self, zone_id: impl Into<String>) -> Self {
|
||||
self.default_zone_id = Some(zone_id.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Override the baseline privacy class.
|
||||
#[must_use]
|
||||
pub const fn with_privacy_class(mut self, class: PrivacyClass) -> Self {
|
||||
self.privacy_class = class;
|
||||
self
|
||||
}
|
||||
|
||||
/// Install a signature hasher.
|
||||
#[must_use]
|
||||
pub fn with_signature_hasher(mut self, hasher: SignatureHasher) -> Self {
|
||||
self.signature_hasher = Some(hasher);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Public BFLD entry point. Owns the configured emitter and the
|
||||
/// `privacy_mode` toggle.
|
||||
pub struct BfldPipeline {
|
||||
/// Baseline class — the class to which `disable_privacy_mode()` returns.
|
||||
baseline_class: PrivacyClass,
|
||||
privacy_mode: bool,
|
||||
emitter: BfldEmitter,
|
||||
}
|
||||
|
||||
impl BfldPipeline {
|
||||
/// Build a pipeline from `config`. The underlying emitter is initialized
|
||||
/// with the configured class; `privacy_mode` is initially `false`.
|
||||
#[must_use]
|
||||
pub fn new(config: BfldConfig) -> Self {
|
||||
let mut emitter = BfldEmitter::new(config.node_id);
|
||||
if let Some(zone) = config.default_zone_id {
|
||||
emitter = emitter.with_zone(zone);
|
||||
}
|
||||
emitter = emitter.with_privacy_class(config.privacy_class);
|
||||
if let Some(hasher) = config.signature_hasher {
|
||||
emitter = emitter.with_signature_hasher(hasher);
|
||||
}
|
||||
Self {
|
||||
baseline_class: config.privacy_class,
|
||||
privacy_mode: false,
|
||||
emitter,
|
||||
}
|
||||
}
|
||||
|
||||
/// Process a single sensing frame. Delegates to the underlying emitter,
|
||||
/// then post-processes the resulting event to honor `privacy_mode`. When
|
||||
/// privacy mode is engaged the published event is demoted to Restricted
|
||||
/// (identity-derived fields stripped) regardless of the configured baseline.
|
||||
pub fn process(
|
||||
&mut self,
|
||||
inputs: SensingInputs,
|
||||
embedding: Option<IdentityEmbedding>,
|
||||
) -> Option<BfldEvent> {
|
||||
let mut event = self.emitter.emit(inputs, embedding)?;
|
||||
if self.privacy_mode {
|
||||
event.privacy_class = PrivacyClass::Restricted;
|
||||
event.apply_privacy_gating();
|
||||
}
|
||||
Some(event)
|
||||
}
|
||||
|
||||
/// Variant of [`Self::process`] that consults a [`SoulMatchOracle`] before
|
||||
/// the coherence gate fires `Recalibrate`. See ADR-121 §2.6 and ADR-118
|
||||
/// §1.4. The privacy_mode post-processing still applies; the oracle only
|
||||
/// affects whether the gate transitions to Recalibrate at all.
|
||||
pub fn process_with_oracle<O: SoulMatchOracle>(
|
||||
&mut self,
|
||||
inputs: SensingInputs,
|
||||
embedding: Option<IdentityEmbedding>,
|
||||
oracle: &O,
|
||||
) -> Option<BfldEvent> {
|
||||
let mut event = self.emitter.emit_with_oracle(inputs, embedding, oracle)?;
|
||||
if self.privacy_mode {
|
||||
event.privacy_class = PrivacyClass::Restricted;
|
||||
event.apply_privacy_gating();
|
||||
}
|
||||
Some(event)
|
||||
}
|
||||
|
||||
/// Wire-bytes variant of [`Self::process`]: returns a [`BfldFrame`] ready
|
||||
/// to serialize via `BfldFrame::to_bytes()`. Caller supplies a
|
||||
/// `header_template` carrying AP / STA / session identity fields and a
|
||||
/// `payload` typed via [`BfldPayload`]. The pipeline overrides the
|
||||
/// template's `timestamp_ns` and `privacy_class` from its own state, then
|
||||
/// builds the frame via [`BfldFrame::from_payload`] so the CRC covers the
|
||||
/// section-prefixed bytes.
|
||||
///
|
||||
/// Returns `None` whenever the gate drops the underlying event (Reject or
|
||||
/// Recalibrate), so `process_to_frame` is a strict subset of `process`.
|
||||
pub fn process_to_frame(
|
||||
&mut self,
|
||||
inputs: SensingInputs,
|
||||
header_template: BfldFrameHeader,
|
||||
payload: BfldPayload,
|
||||
embedding: Option<IdentityEmbedding>,
|
||||
) -> Option<BfldFrame> {
|
||||
let timestamp_ns = inputs.timestamp_ns;
|
||||
let _gate_signal = self.process(inputs, embedding)?;
|
||||
let mut header = header_template;
|
||||
header.timestamp_ns = timestamp_ns;
|
||||
header.privacy_class = self.current_privacy_class().as_u8();
|
||||
Some(BfldFrame::from_payload(header, &payload))
|
||||
}
|
||||
|
||||
/// `true` if `enable_privacy_mode()` has been called more recently than
|
||||
/// `disable_privacy_mode()`.
|
||||
#[must_use]
|
||||
pub const fn is_privacy_mode_enabled(&self) -> bool {
|
||||
self.privacy_mode
|
||||
}
|
||||
|
||||
/// Read the currently active class. Returns Restricted if privacy mode is
|
||||
/// engaged, otherwise the baseline.
|
||||
#[must_use]
|
||||
pub const fn current_privacy_class(&self) -> PrivacyClass {
|
||||
if self.privacy_mode {
|
||||
PrivacyClass::Restricted
|
||||
} else {
|
||||
self.baseline_class
|
||||
}
|
||||
}
|
||||
|
||||
/// Read-only access to the current gate action — for diagnostics.
|
||||
#[must_use]
|
||||
pub const fn current_gate_action(&self) -> GateAction {
|
||||
self.emitter.current_action()
|
||||
}
|
||||
|
||||
/// Engage privacy mode: future `process()` calls return events demoted
|
||||
/// to Restricted (identity_risk_score + rf_signature_hash stripped)
|
||||
/// regardless of the configured baseline.
|
||||
///
|
||||
/// The override is applied post-emission so the underlying gate / ring /
|
||||
/// hasher state remains unchanged and recoverable when privacy mode is
|
||||
/// later disabled.
|
||||
pub fn enable_privacy_mode(&mut self) {
|
||||
self.privacy_mode = true;
|
||||
}
|
||||
|
||||
/// Disengage privacy mode: future events return to the configured baseline.
|
||||
pub fn disable_privacy_mode(&mut self) {
|
||||
self.privacy_mode = false;
|
||||
}
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
//! `BfldPipelineHandle` — worker-thread wrapper around [`BfldPipeline`] and a
|
||||
//! [`Publish`]er. ADR-118 §2.1 single-call operator surface.
|
||||
//!
|
||||
//! `spawn()` returns a handle owning the inbound channel sender. The worker
|
||||
//! thread loops on `recv()`, drives one `pipeline.process()` per input, and
|
||||
//! forwards any emitted `BfldEvent` through `publish_event()`. `shutdown()`
|
||||
//! closes the channel and joins the thread.
|
||||
|
||||
#![cfg(feature = "std")]
|
||||
|
||||
use std::sync::mpsc::{channel, RecvError, SendError, Sender};
|
||||
use std::thread::{self, JoinHandle};
|
||||
|
||||
use crate::coherence_gate::SoulMatchOracle;
|
||||
use crate::mqtt_topics::{publish_event, Publish};
|
||||
use crate::pipeline::BfldPipeline;
|
||||
use crate::{IdentityEmbedding, SensingInputs};
|
||||
|
||||
/// Frame-level input to the spawned worker. The pipeline state — gate,
|
||||
/// embedding ring, hasher — lives behind the worker thread; callers only
|
||||
/// send the per-frame sensing data.
|
||||
pub struct PipelineInput {
|
||||
/// Sensing fields fed to `pipeline.process`.
|
||||
pub inputs: SensingInputs,
|
||||
/// Optional embedding for the iter-15 hasher input + iter-8 ring.
|
||||
pub embedding: Option<IdentityEmbedding>,
|
||||
}
|
||||
|
||||
/// Handle to the spawned worker. Drop or `shutdown()` to stop. `send()`
|
||||
/// returns an error after shutdown.
|
||||
pub struct BfldPipelineHandle {
|
||||
sender: Sender<PipelineInput>,
|
||||
worker: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl BfldPipelineHandle {
|
||||
/// Spawn a worker that owns `pipeline` and `publisher`. Returns a handle
|
||||
/// whose `send()` enqueues sensing inputs into the worker thread.
|
||||
///
|
||||
/// Publish errors are logged to stderr and the worker continues — single
|
||||
/// frame failures should not kill the long-running pipeline.
|
||||
#[must_use]
|
||||
pub fn spawn<P>(mut pipeline: BfldPipeline, mut publisher: P) -> Self
|
||||
where
|
||||
P: Publish + Send + 'static,
|
||||
P::Error: core::fmt::Debug,
|
||||
{
|
||||
let (sender, receiver) = channel::<PipelineInput>();
|
||||
let worker = thread::spawn(move || loop {
|
||||
match receiver.recv() {
|
||||
Ok(PipelineInput { inputs, embedding }) => {
|
||||
if let Some(event) = pipeline.process(inputs, embedding) {
|
||||
if let Err(e) = publish_event(&mut publisher, &event) {
|
||||
eprintln!("BFLD publish error: {e:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(RecvError) => break, // channel closed by shutdown / drop
|
||||
}
|
||||
});
|
||||
Self {
|
||||
sender,
|
||||
worker: Some(worker),
|
||||
}
|
||||
}
|
||||
|
||||
/// Variant of [`Self::spawn`] that installs a long-lived
|
||||
/// [`SoulMatchOracle`] used on every per-frame `process` call. The oracle
|
||||
/// must be `Send + Sync + 'static` because the worker thread consults it
|
||||
/// on every recv. Pairs with ADR-121 §2.6: when the oracle reports a
|
||||
/// `Match`, a would-be Recalibrate gate transition is downgraded to
|
||||
/// `PredictOnly` (high score is the *intended* outcome of a known-enrolled
|
||||
/// person match, not an attacker-grade sniffer arrival).
|
||||
#[must_use]
|
||||
pub fn spawn_with_oracle<P, O>(
|
||||
mut pipeline: BfldPipeline,
|
||||
mut publisher: P,
|
||||
oracle: O,
|
||||
) -> Self
|
||||
where
|
||||
P: Publish + Send + 'static,
|
||||
P::Error: core::fmt::Debug,
|
||||
O: SoulMatchOracle + Send + Sync + 'static,
|
||||
{
|
||||
let (sender, receiver) = channel::<PipelineInput>();
|
||||
let worker = thread::spawn(move || loop {
|
||||
match receiver.recv() {
|
||||
Ok(PipelineInput { inputs, embedding }) => {
|
||||
if let Some(event) =
|
||||
pipeline.process_with_oracle(inputs, embedding, &oracle)
|
||||
{
|
||||
if let Err(e) = publish_event(&mut publisher, &event) {
|
||||
eprintln!("BFLD publish error: {e:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(RecvError) => break,
|
||||
}
|
||||
});
|
||||
Self {
|
||||
sender,
|
||||
worker: Some(worker),
|
||||
}
|
||||
}
|
||||
|
||||
/// Enqueue an input. Returns `SendError<PipelineInput>` (carrying the
|
||||
/// rejected input) if the worker has already shut down.
|
||||
pub fn send(&self, input: PipelineInput) -> Result<(), SendError<PipelineInput>> {
|
||||
self.sender.send(input)
|
||||
}
|
||||
|
||||
/// Close the input channel and join the worker. Panics from the worker
|
||||
/// thread propagate here; otherwise returns cleanly.
|
||||
pub fn shutdown(mut self) {
|
||||
if let Some(worker) = self.worker.take() {
|
||||
drop(std::mem::replace(&mut self.sender, channel().0));
|
||||
worker
|
||||
.join()
|
||||
.expect("BFLD pipeline worker panicked during shutdown");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for BfldPipelineHandle {
|
||||
/// Best-effort cleanup if `shutdown()` was not called explicitly.
|
||||
fn drop(&mut self) {
|
||||
if let Some(worker) = self.worker.take() {
|
||||
// Replace the sender with a fresh disconnected one so the worker
|
||||
// recv() returns Err(RecvError) and the loop exits.
|
||||
drop(std::mem::replace(&mut self.sender, channel().0));
|
||||
let _ = worker.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
//! `PrivacyGate` — monotonic class transitions for `BfldFrame`. ADR-120 §2.4.
|
||||
//!
|
||||
//! The only way a higher-information frame becomes a lower-information frame
|
||||
//! is through [`PrivacyGate::demote`]. This function:
|
||||
//!
|
||||
//! 1. Asserts the target class is **strictly higher in numerical value** (or
|
||||
//! equal) to the current class — going from Derived(1) to Anonymous(2) is
|
||||
//! a demote; going from Anonymous(2) back to Derived(1) is forbidden.
|
||||
//! 2. Zeroes payload sections that are not permitted at the target class,
|
||||
//! using a `black_box`-guarded loop to defeat dead-store elimination.
|
||||
//! 3. Re-syncs `header.privacy_class` and `header.payload_crc32`.
|
||||
//! 4. Returns the new frame.
|
||||
//!
|
||||
//! There is no `promote` operation by design — once a section is zeroed, the
|
||||
//! original bytes are unrecoverable.
|
||||
|
||||
#![cfg(feature = "std")]
|
||||
|
||||
use crate::frame::crc32_of_payload;
|
||||
use crate::{BfldError, BfldFrame, BfldPayload, PrivacyClass};
|
||||
|
||||
/// Monotonic class transformer. See module docs.
|
||||
pub struct PrivacyGate;
|
||||
|
||||
impl PrivacyGate {
|
||||
/// Apply a class demotion in-place: returns a new `BfldFrame` whose
|
||||
/// `privacy_class`, payload sections, and CRC match `target`.
|
||||
///
|
||||
/// Returns [`BfldError::InvalidDemote`] when `target` would *increase*
|
||||
/// the information density (lower class number than the source).
|
||||
pub fn demote(
|
||||
mut frame: BfldFrame,
|
||||
target: PrivacyClass,
|
||||
) -> Result<BfldFrame, BfldError> {
|
||||
let current = PrivacyClass::try_from(frame.header.privacy_class)?;
|
||||
if target.as_u8() < current.as_u8() {
|
||||
return Err(BfldError::InvalidDemote {
|
||||
from: current.as_u8(),
|
||||
to: target.as_u8(),
|
||||
});
|
||||
}
|
||||
|
||||
// Strip payload sections not permitted at the target class. We only do
|
||||
// this when the payload parses cleanly; a malformed payload remains
|
||||
// untouched in the bytes (the class byte and CRC still get re-synced).
|
||||
if let Ok(mut payload) = frame.parse_payload() {
|
||||
if target.as_u8() >= PrivacyClass::Anonymous.as_u8() {
|
||||
// Anonymous: drop the compressed angle matrix (identity surface).
|
||||
zeroize_then_clear(&mut payload.compressed_angle_matrix);
|
||||
// Also drop optional sections that may carry identity-leaky
|
||||
// signal under high-separability conditions.
|
||||
if let Some(csi) = payload.csi_delta.as_mut() {
|
||||
zeroize_then_clear(csi);
|
||||
}
|
||||
}
|
||||
if target.as_u8() >= PrivacyClass::Restricted.as_u8() {
|
||||
// Restricted: also drop amplitude + phase proxies.
|
||||
zeroize_then_clear(&mut payload.amplitude_proxy);
|
||||
zeroize_then_clear(&mut payload.phase_proxy);
|
||||
}
|
||||
// Note: csi_delta dropped above implies the flag bit should clear.
|
||||
// from_payload re-derives the flag from csi_delta.is_some(), so
|
||||
// taking the Option out below ensures the bit is cleared.
|
||||
if target.as_u8() >= PrivacyClass::Anonymous.as_u8() {
|
||||
payload.csi_delta = None;
|
||||
}
|
||||
frame = BfldFrame::from_payload(frame.header, &payload);
|
||||
}
|
||||
|
||||
frame.header.privacy_class = target.as_u8();
|
||||
// from_payload already recomputed CRC, but recompute again so the
|
||||
// path that skipped payload parsing still produces a consistent frame.
|
||||
frame.header.payload_crc32 = crc32_of_payload(&frame.payload);
|
||||
Ok(frame)
|
||||
}
|
||||
}
|
||||
|
||||
/// Overwrite `v` with zeros, then truncate. The `black_box` call defeats
|
||||
/// dead-store elimination so the writes are observable.
|
||||
fn zeroize_then_clear(v: &mut Vec<u8>) {
|
||||
for b in v.iter_mut() {
|
||||
*b = 0;
|
||||
}
|
||||
core::hint::black_box(v.as_ptr());
|
||||
v.clear();
|
||||
}
|
||||
|
||||
// Convenience constructor: the gate is a unit type, but keeping a Default
|
||||
// makes downstream injection sites (PrivacyGate.demote(...) vs static call)
|
||||
// straightforward.
|
||||
impl Default for PrivacyGate {
|
||||
fn default() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
/// Discard the rest of an unused (#[allow(dead_code)]) — placeholder so
|
||||
/// `BfldPayload` import isn't unused in builds that strip the implementation.
|
||||
#[allow(dead_code)]
|
||||
fn _unused_payload_marker(_: BfldPayload) {}
|
||||
@@ -1,110 +0,0 @@
|
||||
//! `RumqttPublisher` — production [`Publish`] impl backed by `rumqttc`.
|
||||
//! ADR-122 §2.2 broker integration.
|
||||
//!
|
||||
//! Gated on `feature = "mqtt"`. The sync `rumqttc::Client` is used so the
|
||||
//! `Publish` trait's sync method signature is honored without a tokio runtime.
|
||||
//! The companion `rumqttc::Connection` returned by [`RumqttPublisher::connect`]
|
||||
//! must be pumped by the caller (typically on a dedicated thread) to drive
|
||||
//! the MQTT protocol — published messages remain queued until the connection
|
||||
//! sends them.
|
||||
//!
|
||||
//! ```ignore
|
||||
//! use std::thread;
|
||||
//! use wifi_densepose_bfld::{publish_event, RumqttPublisher};
|
||||
//! use rumqttc::MqttOptions;
|
||||
//!
|
||||
//! let opts = MqttOptions::new("seed-01", "broker.local", 1883);
|
||||
//! let (mut publisher, mut connection) = RumqttPublisher::connect(opts, 100);
|
||||
//! thread::spawn(move || for _ in connection.iter() { /* drain */ });
|
||||
//! // ... build BfldEvent ...
|
||||
//! publish_event(&mut publisher, &event).expect("mqtt publish");
|
||||
//! ```
|
||||
|
||||
#![cfg(feature = "mqtt")]
|
||||
|
||||
use rumqttc::{Client, Connection, LastWill, MqttOptions, QoS};
|
||||
|
||||
use crate::availability::{availability_topic, PAYLOAD_NOT_AVAILABLE};
|
||||
use crate::mqtt_topics::{Publish, TopicMessage};
|
||||
|
||||
/// Sync MQTT publisher wrapping [`rumqttc::Client`].
|
||||
pub struct RumqttPublisher {
|
||||
client: Client,
|
||||
qos: QoS,
|
||||
retain: bool,
|
||||
}
|
||||
|
||||
impl RumqttPublisher {
|
||||
/// Wrap an existing `Client` at the supplied QoS. `retain = false` matches
|
||||
/// HA-DISCO state-topic semantics (retained payloads cause stale-state
|
||||
/// flapping on broker reconnect). For availability-style topics callers
|
||||
/// should construct a separate publisher with `retain = true`.
|
||||
#[must_use]
|
||||
pub const fn new(client: Client, qos: QoS) -> Self {
|
||||
Self {
|
||||
client,
|
||||
qos,
|
||||
retain: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle the per-publisher `retain` flag.
|
||||
#[must_use]
|
||||
pub const fn with_retain(mut self, retain: bool) -> Self {
|
||||
self.retain = retain;
|
||||
self
|
||||
}
|
||||
|
||||
/// Build a publisher + an unpumped `Connection`. Caller is responsible
|
||||
/// for spawning a thread that iterates the connection (typical pattern
|
||||
/// shown in the module-level doc example).
|
||||
#[must_use]
|
||||
pub fn connect(opts: MqttOptions, capacity: usize) -> (Self, Connection) {
|
||||
let (client, connection) = Client::new(opts, capacity);
|
||||
(Self::new(client, QoS::AtLeastOnce), connection)
|
||||
}
|
||||
|
||||
/// Like [`Self::connect`] but also configures the MQTT Last Will and
|
||||
/// Testament so the broker auto-publishes `"offline"` on
|
||||
/// `ruview/<node_id>/bfld/availability` (retained, QoS 1) when the
|
||||
/// publisher's TCP session drops without a clean DISCONNECT.
|
||||
///
|
||||
/// Pairs with [`crate::publish_availability_online`] — call that on first
|
||||
/// CONNECT to set `"online"`; the LWT covers the disconnect path.
|
||||
#[must_use]
|
||||
pub fn connect_with_lwt(
|
||||
node_id: &str,
|
||||
opts: MqttOptions,
|
||||
capacity: usize,
|
||||
) -> (Self, Connection) {
|
||||
let opts = with_lwt(opts, node_id);
|
||||
Self::connect(opts, capacity)
|
||||
}
|
||||
}
|
||||
|
||||
/// Mutate `opts` to attach the BFLD availability LWT. Public so callers that
|
||||
/// build their own `MqttOptions` (custom tls, credentials, etc.) can still
|
||||
/// opt in to the LWT without using `connect_with_lwt`.
|
||||
#[must_use]
|
||||
pub fn with_lwt(mut opts: MqttOptions, node_id: &str) -> MqttOptions {
|
||||
// rumqttc 0.24 LastWill::new takes (topic, message, qos, retain).
|
||||
// retain = true so HA sees "offline" on next start even if the session
|
||||
// dropped while HA was down.
|
||||
let will = LastWill::new(
|
||||
availability_topic(node_id),
|
||||
PAYLOAD_NOT_AVAILABLE,
|
||||
QoS::AtLeastOnce,
|
||||
true,
|
||||
);
|
||||
opts.set_last_will(will);
|
||||
opts
|
||||
}
|
||||
|
||||
impl Publish for RumqttPublisher {
|
||||
type Error = rumqttc::ClientError;
|
||||
|
||||
fn publish(&mut self, msg: &TopicMessage) -> Result<(), Self::Error> {
|
||||
self.client
|
||||
.publish(&msg.topic, self.qos, self.retain, msg.payload.as_bytes())
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
//! `SignatureHasher` — BLAKE3 keyed-hash for `rf_signature_hash`. ADR-120 §2.3.
|
||||
//!
|
||||
//! Computes a per-site, per-day, identity-features digest that **structurally
|
||||
//! prevents** cross-site identity correlation (BFLD invariant I3):
|
||||
//!
|
||||
//! ```text
|
||||
//! rf_signature_hash = BLAKE3-keyed(site_salt, day_epoch || features)
|
||||
//! ```
|
||||
//!
|
||||
//! - **Site isolation**: `site_salt` is a 256-bit secret unique to each node
|
||||
//! and never transmitted. Two nodes observing the same physical person
|
||||
//! produce uncorrelated hashes — there is no key an operator (or an
|
||||
//! attacker who compromises one node) can use to bridge sites.
|
||||
//! - **Daily rotation**: `day_epoch = floor(unix_time_utc / 86_400)` flips at
|
||||
//! UTC midnight, so the same person's hash changes once per day.
|
||||
//!
|
||||
//! See ADR-120 §2.7 AC2 for the cross-site Hamming-distance acceptance
|
||||
//! criterion. `tests/signature_hasher.rs` exercises it directly.
|
||||
|
||||
use blake3::Hasher;
|
||||
|
||||
/// Number of seconds in a UTC day; the daily-rotation modulus.
|
||||
pub const SECONDS_PER_DAY: u64 = 86_400;
|
||||
|
||||
/// Length of the keyed `site_salt`, fixed by BLAKE3 keyed mode at 32 bytes.
|
||||
pub const SITE_SALT_LEN: usize = 32;
|
||||
|
||||
/// Output length — always 32 bytes (BLAKE3 default).
|
||||
pub const RF_SIGNATURE_LEN: usize = 32;
|
||||
|
||||
/// Per-node hasher carrying the secret `site_salt`. Construct once at boot
|
||||
/// from the persistent secret store (TPM, KMS, or strict-mode file).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SignatureHasher {
|
||||
site_salt: [u8; SITE_SALT_LEN],
|
||||
}
|
||||
|
||||
impl SignatureHasher {
|
||||
/// Build a hasher from an existing `site_salt`. The salt is **never
|
||||
/// transmitted** from this point on; callers must keep it in secure storage.
|
||||
#[must_use]
|
||||
pub const fn new(site_salt: [u8; SITE_SALT_LEN]) -> Self {
|
||||
Self { site_salt }
|
||||
}
|
||||
|
||||
/// Compute the daily epoch from a UTC unix-seconds timestamp.
|
||||
#[must_use]
|
||||
pub const fn day_epoch_from_unix_secs(unix_secs: u64) -> u32 {
|
||||
(unix_secs / SECONDS_PER_DAY) as u32
|
||||
}
|
||||
|
||||
/// Compute the `rf_signature_hash` for the supplied (day, features) pair.
|
||||
/// `features` is the canonical-bytes representation of the current
|
||||
/// identity-features tuple — the caller is responsible for deterministic
|
||||
/// serialization (e.g., `bincode` with sorted keys, or a hand-rolled
|
||||
/// fixed-order byte layout).
|
||||
#[must_use]
|
||||
pub fn compute(&self, day_epoch: u32, features: &[u8]) -> [u8; RF_SIGNATURE_LEN] {
|
||||
let mut hasher = Hasher::new_keyed(&self.site_salt);
|
||||
hasher.update(&day_epoch.to_le_bytes());
|
||||
hasher.update(features);
|
||||
*hasher.finalize().as_bytes()
|
||||
}
|
||||
|
||||
/// Convenience: compute from a unix-seconds timestamp instead of an
|
||||
/// explicit `day_epoch`.
|
||||
#[must_use]
|
||||
pub fn compute_at(
|
||||
&self,
|
||||
unix_secs: u64,
|
||||
features: &[u8],
|
||||
) -> [u8; RF_SIGNATURE_LEN] {
|
||||
self.compute(Self::day_epoch_from_unix_secs(unix_secs), features)
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
//! Sink marker traits — structural enforcement of invariant I1.
|
||||
//!
|
||||
//! Every output destination (memory buffer, MQTT topic, Matter cluster) implements
|
||||
//! exactly one of [`LocalSink`], [`NetworkSink`], or [`MatterSink`]. The associated
|
||||
//! constant [`Sink::MIN_CLASS`] declares the lowest `PrivacyClass` value that sink
|
||||
//! is willing to accept; the runtime gate [`check_class`] enforces this on every
|
||||
//! publish.
|
||||
//!
|
||||
//! Mapping (ADR-120 §2.2, ADR-122 §2.4):
|
||||
//!
|
||||
//! | Sink trait | `MIN_CLASS` | Accepts classes |
|
||||
//! |---------------|----------------------|-----------------|
|
||||
//! | `LocalSink` | `PrivacyClass::Raw` | 0, 1, 2, 3 |
|
||||
//! | `NetworkSink` | `PrivacyClass::Derived` | 1, 2, 3 |
|
||||
//! | `MatterSink` | `PrivacyClass::Anonymous` | 2, 3 |
|
||||
//!
|
||||
//! `MatterSink: NetworkSink` — every Matter sink is also a network sink.
|
||||
|
||||
use crate::{BfldError, PrivacyClass};
|
||||
|
||||
/// Base sink trait. Every sink type declares the minimum `PrivacyClass` it accepts.
|
||||
pub trait Sink {
|
||||
/// Lowest privacy class (highest information density) this sink will publish.
|
||||
const MIN_CLASS: PrivacyClass;
|
||||
/// Human-readable sink kind, used in `BfldError::PrivacyViolation` messages.
|
||||
const KIND: &'static str;
|
||||
}
|
||||
|
||||
/// Marker for sinks that stay on the originating node (memory, in-RAM channel,
|
||||
/// local file with explicit operator opt-in). Accepts every class including `Raw`.
|
||||
pub trait LocalSink: Sink {}
|
||||
|
||||
/// Marker for sinks that cross the node boundary (MQTT, HTTP, gRPC). Rejects
|
||||
/// `Raw` frames by structural invariant I1.
|
||||
pub trait NetworkSink: Sink {}
|
||||
|
||||
/// Marker for sinks that bridge into the Matter cluster surface. Rejects `Raw`
|
||||
/// and `Derived`; the `cog-ha-matter` boundary filter consumes only classes 2/3.
|
||||
pub trait MatterSink: NetworkSink {}
|
||||
|
||||
/// Runtime gate. Returns `Ok(())` if `class` is acceptable for `S`, otherwise
|
||||
/// returns `BfldError::PrivacyViolation` with the offending sink kind.
|
||||
///
|
||||
/// Class numerical order *is* meaningful here: a sink that accepts `MIN_CLASS`
|
||||
/// also accepts every higher-numbered class (less identity content). The check
|
||||
/// is therefore a simple `>=` on the byte representation.
|
||||
pub fn check_class<S: Sink>(class: PrivacyClass) -> Result<(), BfldError> {
|
||||
if class.as_u8() >= S::MIN_CLASS.as_u8() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(BfldError::PrivacyViolation {
|
||||
reason: S::KIND,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- Default sink types ----------------------------------------------------
|
||||
//
|
||||
// Concrete sinks live in downstream crates (emitter.rs, mqtt.rs, the cog-ha-matter
|
||||
// Matter bridge). These three "kind tags" are convenient zero-sized stand-ins for
|
||||
// unit tests and for the privacy_gate compile-time tables.
|
||||
|
||||
/// Zero-sized tag: a local in-memory ring buffer or file sink.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct LocalKind;
|
||||
|
||||
impl Sink for LocalKind {
|
||||
const MIN_CLASS: PrivacyClass = PrivacyClass::Raw;
|
||||
const KIND: &'static str = "LocalKind";
|
||||
}
|
||||
impl LocalSink for LocalKind {}
|
||||
|
||||
/// Zero-sized tag: a generic network sink (MQTT, HTTP, gRPC).
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct NetworkKind;
|
||||
|
||||
impl Sink for NetworkKind {
|
||||
const MIN_CLASS: PrivacyClass = PrivacyClass::Derived;
|
||||
const KIND: &'static str = "NetworkKind";
|
||||
}
|
||||
impl NetworkSink for NetworkKind {}
|
||||
|
||||
/// Zero-sized tag: the Matter cluster boundary in `cog-ha-matter`.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct MatterKind;
|
||||
|
||||
impl Sink for MatterKind {
|
||||
const MIN_CLASS: PrivacyClass = PrivacyClass::Anonymous;
|
||||
const KIND: &'static str = "MatterKind";
|
||||
}
|
||||
impl NetworkSink for MatterKind {}
|
||||
impl MatterSink for MatterKind {}
|
||||
@@ -1,117 +0,0 @@
|
||||
//! Acceptance tests for ADR-122 §2.2 availability topic + LWT integration.
|
||||
|
||||
#![cfg(feature = "std")]
|
||||
|
||||
use wifi_densepose_bfld::{
|
||||
availability_topic, offline_message, online_message, publish_availability_offline,
|
||||
publish_availability_online, render_discovery_payloads, CapturePublisher, PrivacyClass,
|
||||
PAYLOAD_AVAILABLE, PAYLOAD_NOT_AVAILABLE,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn availability_topic_format_matches_documented_path() {
|
||||
assert_eq!(
|
||||
availability_topic("seed-01"),
|
||||
"ruview/seed-01/bfld/availability",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn online_message_is_retained_friendly_payload() {
|
||||
let msg = online_message("seed-99");
|
||||
assert_eq!(msg.topic, "ruview/seed-99/bfld/availability");
|
||||
assert_eq!(msg.payload, "online");
|
||||
assert_eq!(msg.payload, PAYLOAD_AVAILABLE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offline_message_is_retained_friendly_payload() {
|
||||
let msg = offline_message("seed-99");
|
||||
assert_eq!(msg.payload, "offline");
|
||||
assert_eq!(msg.payload, PAYLOAD_NOT_AVAILABLE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publish_online_lands_one_message() {
|
||||
let mut p = CapturePublisher::default();
|
||||
publish_availability_online(&mut p, "seed-01").unwrap();
|
||||
assert_eq!(p.published.len(), 1);
|
||||
assert_eq!(p.published[0].payload, "online");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publish_offline_lands_one_message() {
|
||||
let mut p = CapturePublisher::default();
|
||||
publish_availability_offline(&mut p, "seed-01").unwrap();
|
||||
assert_eq!(p.published.len(), 1);
|
||||
assert_eq!(p.published[0].payload, "offline");
|
||||
}
|
||||
|
||||
// --- discovery payload integration --------------------------------------
|
||||
|
||||
#[test]
|
||||
fn discovery_payload_includes_availability_topic_field() {
|
||||
let msgs = render_discovery_payloads("seed-01", PrivacyClass::Anonymous);
|
||||
for msg in &msgs {
|
||||
assert!(
|
||||
msg.payload
|
||||
.contains("\"availability_topic\":\"ruview/seed-01/bfld/availability\""),
|
||||
"discovery payload must reference availability_topic, got: {}",
|
||||
msg.payload,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovery_payload_includes_payload_available_and_not_available_strings() {
|
||||
let msgs = render_discovery_payloads("seed-01", PrivacyClass::Anonymous);
|
||||
for msg in &msgs {
|
||||
assert!(
|
||||
msg.payload.contains("\"payload_available\":\"online\""),
|
||||
"discovery payload missing payload_available, got: {}",
|
||||
msg.payload,
|
||||
);
|
||||
assert!(
|
||||
msg.payload.contains("\"payload_not_available\":\"offline\""),
|
||||
"discovery payload missing payload_not_available, got: {}",
|
||||
msg.payload,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restricted_class_discovery_still_carries_availability_fields() {
|
||||
// Availability isn't an identity field — class 3 retains it.
|
||||
let msgs = render_discovery_payloads("seed-01", PrivacyClass::Restricted);
|
||||
assert_eq!(msgs.len(), 5);
|
||||
for msg in &msgs {
|
||||
assert!(msg.payload.contains("\"availability_topic\":"));
|
||||
}
|
||||
}
|
||||
|
||||
// --- bootstrap composition ----------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn bootstrap_sequence_online_then_discovery_lands_in_order() {
|
||||
let mut p = CapturePublisher::default();
|
||||
publish_availability_online(&mut p, "seed-01").expect("online");
|
||||
let count =
|
||||
wifi_densepose_bfld::publish_discovery(&mut p, "seed-01", PrivacyClass::Anonymous)
|
||||
.expect("discovery");
|
||||
assert_eq!(count, 6);
|
||||
assert_eq!(p.published.len(), 1 + 6);
|
||||
assert_eq!(p.published[0].payload, "online");
|
||||
for msg in p.published.iter().skip(1) {
|
||||
assert!(msg.topic.starts_with("homeassistant/"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graceful_shutdown_sequence_publishes_offline_message_last() {
|
||||
let mut p = CapturePublisher::default();
|
||||
publish_availability_online(&mut p, "seed-01").unwrap();
|
||||
publish_availability_offline(&mut p, "seed-01").unwrap();
|
||||
assert_eq!(p.published.len(), 2);
|
||||
assert_eq!(p.published[0].payload, "online");
|
||||
assert_eq!(p.published[1].payload, "offline");
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
//! `BfldError` Display format pinning. Operators grep log lines for these
|
||||
//! strings; format drift between minor versions breaks monitoring queries.
|
||||
//! Each variant gets a test that asserts the documented substrings appear.
|
||||
|
||||
#![cfg(feature = "std")]
|
||||
|
||||
use wifi_densepose_bfld::BfldError;
|
||||
|
||||
#[test]
|
||||
fn invalid_magic_displays_both_expected_and_actual_in_hex() {
|
||||
let err = BfldError::InvalidMagic(0xDEAD_BEEF);
|
||||
let s = err.to_string();
|
||||
assert!(s.contains("invalid BFLD magic"), "got: {s}");
|
||||
assert!(s.contains("0xBF1D0001"), "expected magic missing: {s}");
|
||||
assert!(s.contains("0xDEADBEEF"), "actual magic missing: {s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_version_displays_the_offending_version() {
|
||||
let err = BfldError::UnsupportedVersion(99);
|
||||
let s = err.to_string();
|
||||
assert!(s.contains("unsupported BFLD version"), "got: {s}");
|
||||
assert!(s.contains("99"), "version number missing: {s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crc_mismatch_displays_both_values_in_hex() {
|
||||
let err = BfldError::Crc {
|
||||
expected: 0xCAFEBABE,
|
||||
actual: 0xDEADBEEF,
|
||||
};
|
||||
let s = err.to_string();
|
||||
assert!(s.contains("payload CRC mismatch"), "got: {s}");
|
||||
assert!(s.contains("0xCAFEBABE"), "expected missing: {s}");
|
||||
assert!(s.contains("0xDEADBEEF"), "actual missing: {s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn privacy_violation_displays_the_sink_reason() {
|
||||
let err = BfldError::PrivacyViolation {
|
||||
reason: "NetworkKind",
|
||||
};
|
||||
let s = err.to_string();
|
||||
assert!(s.contains("privacy violation"), "got: {s}");
|
||||
assert!(s.contains("NetworkKind"), "reason missing: {s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_privacy_class_displays_the_offending_byte() {
|
||||
let err = BfldError::InvalidPrivacyClass(7);
|
||||
let s = err.to_string();
|
||||
assert!(s.contains("invalid PrivacyClass byte"), "got: {s}");
|
||||
assert!(s.contains("7"), "byte value missing: {s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_frame_displays_got_and_need_byte_counts() {
|
||||
let err = BfldError::TruncatedFrame { got: 50, need: 86 };
|
||||
let s = err.to_string();
|
||||
assert!(s.contains("truncated frame"), "got: {s}");
|
||||
assert!(s.contains("50"), "got count missing: {s}");
|
||||
assert!(s.contains("86"), "need count missing: {s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_section_displays_offset_and_reason() {
|
||||
let err = BfldError::MalformedSection {
|
||||
offset: 1234,
|
||||
reason: "section body runs past buffer end",
|
||||
};
|
||||
let s = err.to_string();
|
||||
assert!(s.contains("malformed payload section"), "got: {s}");
|
||||
assert!(s.contains("1234"), "offset missing: {s}");
|
||||
assert!(s.contains("buffer end"), "reason missing: {s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_demote_displays_both_from_and_to_class_bytes() {
|
||||
let err = BfldError::InvalidDemote { from: 2, to: 1 };
|
||||
let s = err.to_string();
|
||||
assert!(s.contains("invalid demote"), "got: {s}");
|
||||
assert!(s.contains("from class 2"), "from missing: {s}");
|
||||
assert!(s.contains("to class 1"), "to missing: {s}");
|
||||
}
|
||||
|
||||
// --- meta: error implements std::error::Error (for ? + dyn use) -------
|
||||
|
||||
#[test]
|
||||
fn bfld_error_implements_std_error_trait() {
|
||||
fn assert_error_trait<E: std::error::Error>() {}
|
||||
assert_error_trait::<BfldError>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bfld_error_is_debug_so_panic_unwrap_messages_carry_diagnostics() {
|
||||
let err = BfldError::Crc {
|
||||
expected: 0xAA,
|
||||
actual: 0xBB,
|
||||
};
|
||||
let debug = format!("{err:?}");
|
||||
assert!(debug.contains("Crc"), "Debug must show variant name: {debug}");
|
||||
}
|
||||
|
||||
// --- catch-all: every variant has a non-empty Display -----------------
|
||||
|
||||
#[test]
|
||||
fn every_variant_has_a_non_empty_display_string() {
|
||||
let cases: Vec<BfldError> = vec![
|
||||
BfldError::InvalidMagic(0),
|
||||
BfldError::UnsupportedVersion(0),
|
||||
BfldError::Crc {
|
||||
expected: 0,
|
||||
actual: 0,
|
||||
},
|
||||
BfldError::PrivacyViolation { reason: "X" },
|
||||
BfldError::InvalidPrivacyClass(0),
|
||||
BfldError::TruncatedFrame { got: 0, need: 0 },
|
||||
BfldError::MalformedSection {
|
||||
offset: 0,
|
||||
reason: "X",
|
||||
},
|
||||
BfldError::InvalidDemote { from: 0, to: 0 },
|
||||
];
|
||||
for err in cases {
|
||||
let s = err.to_string();
|
||||
assert!(!s.is_empty(), "Display for {err:?} returned empty string");
|
||||
assert!(
|
||||
s.len() >= 5,
|
||||
"Display for {err:?} suspiciously short: {s:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
//! Validate the BFLD entry exists in the workspace-root CHANGELOG.md.
|
||||
//! `cog-ha-matter`, `wifi-densepose-sensing-server`, and the pip wheel
|
||||
//! ship under their own release cadence; the workspace CHANGELOG is the
|
||||
//! one canonical record an operator scans when upgrading a Cognitum Seed.
|
||||
|
||||
#![cfg(feature = "std")]
|
||||
|
||||
const CHANGELOG: &str = include_str!("../../../../CHANGELOG.md");
|
||||
|
||||
#[test]
|
||||
fn changelog_documents_bfld_entry_under_unreleased() {
|
||||
// Find the position of the [Unreleased] header.
|
||||
let unreleased = CHANGELOG
|
||||
.find("## [Unreleased]")
|
||||
.expect("CHANGELOG must have an [Unreleased] section");
|
||||
// The first numbered version header marks the end of [Unreleased].
|
||||
let after_unreleased = CHANGELOG[unreleased..]
|
||||
.find("\n## [0")
|
||||
.or_else(|| CHANGELOG[unreleased..].find("\n## [1"))
|
||||
.map(|off| unreleased + off)
|
||||
.unwrap_or(CHANGELOG.len());
|
||||
let unreleased_block = &CHANGELOG[unreleased..after_unreleased];
|
||||
assert!(
|
||||
unreleased_block.contains("BFLD"),
|
||||
"[Unreleased] must mention BFLD",
|
||||
);
|
||||
assert!(unreleased_block.contains("wifi-densepose-bfld"));
|
||||
assert!(
|
||||
unreleased_block.contains("#787"),
|
||||
"[Unreleased] BFLD entry must link tracking issue #787",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changelog_bfld_entry_cites_companion_adrs() {
|
||||
for adr in ["ADR-118", "ADR-119", "ADR-120", "ADR-121", "ADR-122", "ADR-123"] {
|
||||
assert!(
|
||||
CHANGELOG.contains(adr),
|
||||
"CHANGELOG BFLD entry must cite {adr}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changelog_bfld_entry_names_three_structural_invariants() {
|
||||
let needles = ["**I1**", "**I2**", "**I3**"];
|
||||
for n in needles {
|
||||
assert!(CHANGELOG.contains(n), "CHANGELOG must call out invariant {n}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changelog_bfld_entry_documents_a_runnable_example() {
|
||||
assert!(
|
||||
CHANGELOG.contains("cargo run -p wifi-densepose-bfld --example"),
|
||||
"CHANGELOG entry should give operators a copy-pasteable try-it command",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changelog_bfld_entry_references_research_bundle() {
|
||||
assert!(CHANGELOG.contains("docs/research/BFLD/"));
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
//! Structural validation for `.github/workflows/bfld-mqtt-integration.yml`.
|
||||
//! Same pattern as iter-30's HA blueprint tests: embed via `include_str!`,
|
||||
//! string-check the key fields. Avoids adding a serde_yaml dep just to lint
|
||||
//! a CI workflow.
|
||||
|
||||
#![cfg(feature = "std")]
|
||||
|
||||
const WORKFLOW: &str = include_str!(
|
||||
"../../../../.github/workflows/bfld-mqtt-integration.yml"
|
||||
);
|
||||
|
||||
#[test]
|
||||
fn workflow_declares_mosquitto_service_container() {
|
||||
assert!(
|
||||
WORKFLOW.contains("image: eclipse-mosquitto:2"),
|
||||
"workflow must declare eclipse-mosquitto:2 as a service container",
|
||||
);
|
||||
assert!(
|
||||
WORKFLOW.contains("- 1883:1883"),
|
||||
"workflow must expose port 1883 from the mosquitto service",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_exports_broker_env_for_iter_24_and_29_tests() {
|
||||
assert!(
|
||||
WORKFLOW.contains("BFLD_MQTT_BROKER: tcp://localhost:1883"),
|
||||
"BFLD_MQTT_BROKER env var must point at the service container so the \
|
||||
iter-24 mosquitto_integration test exits skip mode",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_runs_three_cargo_test_invocations() {
|
||||
// Regression guard for the default + no-default-features + mqtt matrix.
|
||||
// Each one catches a different class of bug:
|
||||
// --no-default-features: catches std-feature leakage
|
||||
// default: catches the everyday surface
|
||||
// --features mqtt: catches the live-broker integration path
|
||||
assert!(WORKFLOW.contains("cargo test -p wifi-densepose-bfld --no-default-features"));
|
||||
assert!(WORKFLOW.contains("cargo test -p wifi-densepose-bfld"));
|
||||
assert!(WORKFLOW.contains("cargo test -p wifi-densepose-bfld --features mqtt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_waits_for_mosquitto_readiness_before_testing() {
|
||||
assert!(
|
||||
WORKFLOW.contains("nc -z localhost 1883"),
|
||||
"workflow must port-poll for mosquitto readiness — a service container \
|
||||
can take a few seconds to bind even with healthcheck",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_uses_health_check_on_the_service() {
|
||||
assert!(
|
||||
WORKFLOW.contains("--health-cmd"),
|
||||
"service container should declare a health-check for stable startup",
|
||||
);
|
||||
assert!(
|
||||
WORKFLOW.contains("mosquitto_pub"),
|
||||
"health-check should attempt a real publish, not just process liveness",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_only_triggers_on_bfld_paths() {
|
||||
assert!(
|
||||
WORKFLOW.contains("v2/crates/wifi-densepose-bfld/**"),
|
||||
"path filter must scope the workflow to BFLD changes, not run on every push",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_pins_runner_to_ubuntu_latest_for_docker_service_support() {
|
||||
assert!(
|
||||
WORKFLOW.contains("runs-on: ubuntu-latest"),
|
||||
"GitHub Actions Docker service containers require linux; macOS and \
|
||||
Windows runners don't support `services:`.",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_has_timeout_guard() {
|
||||
// The integration tests have 10-second recv timeouts but the matrix runs
|
||||
// three cargo invocations + cache + warmup; a top-level timeout-minutes
|
||||
// guards against a stuck broker or rumqttc handshake hanging the runner.
|
||||
assert!(
|
||||
WORKFLOW.contains("timeout-minutes:"),
|
||||
"workflow must declare a top-level timeout-minutes to bound runner cost",
|
||||
);
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
//! Acceptance tests for ADR-121 §2.5 — `CoherenceGate` hysteresis + debounce.
|
||||
|
||||
use wifi_densepose_bfld::coherence_gate::{DEBOUNCE_NS, HYSTERESIS};
|
||||
use wifi_densepose_bfld::{CoherenceGate, GateAction};
|
||||
|
||||
#[test]
|
||||
fn fresh_gate_starts_in_accept_with_no_pending() {
|
||||
let g = CoherenceGate::new();
|
||||
assert_eq!(g.current(), GateAction::Accept);
|
||||
assert_eq!(g.pending(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn low_score_stays_in_accept_with_no_pending() {
|
||||
let mut g = CoherenceGate::new();
|
||||
let out = g.evaluate(0.3, 0);
|
||||
assert_eq!(out, GateAction::Accept);
|
||||
assert_eq!(g.pending(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn score_just_past_boundary_but_within_hysteresis_does_not_pend() {
|
||||
// current = Accept, upper edge = 0.5, hysteresis = 0.05 → need >= 0.55 to start pending.
|
||||
let mut g = CoherenceGate::new();
|
||||
let out = g.evaluate(0.52, 0);
|
||||
assert_eq!(out, GateAction::Accept);
|
||||
assert_eq!(g.pending(), None, "0.52 must not start a pending transition");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn score_clearly_past_hysteresis_starts_pending() {
|
||||
let mut g = CoherenceGate::new();
|
||||
let out = g.evaluate(0.6, 0);
|
||||
assert_eq!(out, GateAction::Accept, "still Accept until debounce elapses");
|
||||
assert_eq!(g.pending(), Some(GateAction::PredictOnly));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_action_promotes_after_full_debounce() {
|
||||
let mut g = CoherenceGate::new();
|
||||
g.evaluate(0.6, 0);
|
||||
assert_eq!(g.current(), GateAction::Accept);
|
||||
let out = g.evaluate(0.6, DEBOUNCE_NS);
|
||||
assert_eq!(out, GateAction::PredictOnly);
|
||||
assert_eq!(g.pending(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_action_does_not_promote_before_debounce() {
|
||||
let mut g = CoherenceGate::new();
|
||||
g.evaluate(0.6, 0);
|
||||
let out = g.evaluate(0.6, DEBOUNCE_NS - 1);
|
||||
assert_eq!(out, GateAction::Accept);
|
||||
assert_eq!(g.pending(), Some(GateAction::PredictOnly));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returning_to_current_band_cancels_pending() {
|
||||
let mut g = CoherenceGate::new();
|
||||
g.evaluate(0.6, 0); // pending PredictOnly
|
||||
let out = g.evaluate(0.4, 1_000_000_000); // 1s later, back in Accept band
|
||||
assert_eq!(out, GateAction::Accept);
|
||||
assert_eq!(g.pending(), None, "returning to current band cancels pending");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changing_pending_target_resets_the_debounce_clock() {
|
||||
let mut g = CoherenceGate::new();
|
||||
g.evaluate(0.6, 0); // pending PredictOnly at t=0
|
||||
g.evaluate(0.95, 1_000_000_000); // pending Recalibrate at t=1s (clock reset)
|
||||
// At t=1s + DEBOUNCE_NS - 1, still not promoted (Recalibrate pending since 1s)
|
||||
let out = g.evaluate(0.95, 1_000_000_000 + DEBOUNCE_NS - 1);
|
||||
assert_eq!(out, GateAction::Accept);
|
||||
// At t=1s + DEBOUNCE_NS, promoted to Recalibrate
|
||||
let out = g.evaluate(0.95, 1_000_000_000 + DEBOUNCE_NS);
|
||||
assert_eq!(out, GateAction::Recalibrate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downward_transitions_also_require_hysteresis() {
|
||||
let mut g = CoherenceGate::new();
|
||||
// Force gate into PredictOnly state.
|
||||
g.evaluate(0.6, 0);
|
||||
g.evaluate(0.6, DEBOUNCE_NS);
|
||||
assert_eq!(g.current(), GateAction::PredictOnly);
|
||||
|
||||
// 0.48 is below 0.5 but only by 0.02 — within hysteresis envelope.
|
||||
let out = g.evaluate(0.48, 2 * DEBOUNCE_NS);
|
||||
assert_eq!(out, GateAction::PredictOnly);
|
||||
assert_eq!(g.pending(), None, "0.48 is within downward hysteresis");
|
||||
|
||||
// 0.44 is below 0.5 - 0.05 = 0.45 → starts pending Accept.
|
||||
g.evaluate(0.44, 3 * DEBOUNCE_NS);
|
||||
assert_eq!(g.pending(), Some(GateAction::Accept));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spike_to_one_then_back_to_zero_never_promotes_to_recalibrate() {
|
||||
let mut g = CoherenceGate::new();
|
||||
g.evaluate(1.0, 0); // pending Recalibrate at t=0
|
||||
// 1 second later score is back to 0 — cancel pending.
|
||||
let out = g.evaluate(0.0, 1_000_000_000);
|
||||
assert_eq!(out, GateAction::Accept);
|
||||
assert_eq!(g.pending(), None);
|
||||
// Even waiting longer, the gate stays in Accept.
|
||||
let out = g.evaluate(0.0, 100 * DEBOUNCE_NS);
|
||||
assert_eq!(out, GateAction::Accept);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boundary_value_with_hysteresis_does_not_promote() {
|
||||
// Edge: current=Accept, score = upper_edge + HYSTERESIS - epsilon (just below).
|
||||
let mut g = CoherenceGate::new();
|
||||
let out = g.evaluate(0.5 + HYSTERESIS - 0.0001, 0);
|
||||
assert_eq!(out, GateAction::Accept);
|
||||
assert_eq!(g.pending(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boundary_value_at_hysteresis_exact_does_pend() {
|
||||
let mut g = CoherenceGate::new();
|
||||
let out = g.evaluate(0.5 + HYSTERESIS, 0);
|
||||
assert_eq!(out, GateAction::Accept);
|
||||
assert_eq!(g.pending(), Some(GateAction::PredictOnly));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nan_score_stays_in_current_action_with_no_pending() {
|
||||
let mut g = CoherenceGate::new();
|
||||
let out = g.evaluate(f32::NAN, 0);
|
||||
// NaN maps to Accept via from_score; gate stays in Accept and clears pending.
|
||||
assert_eq!(out, GateAction::Accept);
|
||||
assert_eq!(g.pending(), None);
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
//! Validate the crate README. Same `include_str!` pattern iter-30/47/48 used
|
||||
//! for HA blueprints / examples. crates.io renders this file, so doc drift
|
||||
//! against the actual public API is operator-visible.
|
||||
|
||||
#![cfg(feature = "std")]
|
||||
|
||||
const README: &str = include_str!("../README.md");
|
||||
|
||||
#[test]
|
||||
fn readme_documents_three_structural_invariants() {
|
||||
for needle in [
|
||||
"**I1**",
|
||||
"**I2**",
|
||||
"**I3**",
|
||||
"Raw BFI never exits the node",
|
||||
"Identity embedding is in-RAM-only",
|
||||
"Cross-site identity correlation",
|
||||
] {
|
||||
assert!(README.contains(needle), "README missing invariant text: {needle}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readme_documents_feature_flag_matrix() {
|
||||
for needle in ["`std`", "`serde-json`", "`mqtt`", "`soul-signature`"] {
|
||||
assert!(README.contains(needle), "feature flag {needle} missing from README");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readme_documents_both_runnable_examples() {
|
||||
assert!(README.contains("cargo run -p wifi-densepose-bfld --example bfld_minimal"));
|
||||
assert!(README.contains("cargo run -p wifi-densepose-bfld --example bfld_handle"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readme_documents_three_test_invocations() {
|
||||
assert!(README.contains("cargo test -p wifi-densepose-bfld --no-default-features"));
|
||||
assert!(README.contains("cargo test -p wifi-densepose-bfld --features mqtt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readme_references_companion_adrs_118_through_123() {
|
||||
for adr in ["118", "119", "120", "121", "122", "123"] {
|
||||
assert!(README.contains(adr), "README must cite ADR-{adr}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readme_quickstart_uses_canonical_public_api() {
|
||||
// The quickstart snippets must reference the actual operator-facing
|
||||
// surface — drift here would mislead first-time users.
|
||||
for needle in [
|
||||
"BfldPipeline::new",
|
||||
"BfldConfig::new",
|
||||
"SignatureHasher::new",
|
||||
"SensingInputs",
|
||||
"IdentityEmbedding::from_raw",
|
||||
"pipeline\n .process",
|
||||
"publish_availability_online",
|
||||
"publish_discovery",
|
||||
"BfldPipelineHandle::spawn",
|
||||
"PipelineInput",
|
||||
] {
|
||||
assert!(README.contains(needle), "quickstart missing canonical API: {needle}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readme_points_at_research_bundle_and_blueprints() {
|
||||
assert!(README.contains("docs/research/BFLD/"));
|
||||
assert!(README.contains("cog-ha-matter/blueprints/bfld/"));
|
||||
assert!(README.contains("bfld-mqtt-integration.yml"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readme_documents_env_gated_mosquitto_integration() {
|
||||
assert!(README.contains("BFLD_MQTT_BROKER=tcp://localhost:1883"));
|
||||
assert!(README.contains("mosquitto_integration"));
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
//! Pin the CRC-32/ISO-HDLC polynomial used by `crc32_of_payload`. ADR-119 §2.4.
|
||||
//!
|
||||
//! BFLD picks **CRC-32/ISO-HDLC** specifically (same as Ethernet / zlib),
|
||||
//! NOT CRC-32C (Castagnoli) or any other CRC-32 variant. The polynomial
|
||||
//! choice is part of the wire-format contract — two implementations that
|
||||
//! disagree on the polynomial will treat every other's frame as corrupt.
|
||||
//!
|
||||
//! These tests use the standard "123456789" check string (CRC reference
|
||||
//! https://reveng.sourceforge.io/crc-catalogue/all.htm) plus a few targeted
|
||||
//! vectors. If a future PR swaps `CRC_32_ISO_HDLC` for `CRC_32_CKSUM` or
|
||||
//! similar, every test below fires.
|
||||
|
||||
#![cfg(feature = "std")]
|
||||
|
||||
use wifi_densepose_bfld::frame::crc32_of_payload;
|
||||
|
||||
/// CRC-32/ISO-HDLC check vector — "123456789" must produce 0xCBF43926.
|
||||
const CHECK_VALUE: u32 = 0xCBF4_3926;
|
||||
|
||||
#[test]
|
||||
fn check_string_matches_canonical_iso_hdlc_value() {
|
||||
assert_eq!(
|
||||
crc32_of_payload(b"123456789"),
|
||||
CHECK_VALUE,
|
||||
"CRC-32/ISO-HDLC of the standard \"123456789\" check string must be 0xCBF43926. \
|
||||
If this test fires, someone likely swapped the polynomial — verify the \
|
||||
crc::CRC_32_ISO_HDLC binding in src/frame.rs.",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_payload_yields_zero_crc() {
|
||||
// Per CRC-32/ISO-HDLC: init = 0xFFFFFFFF, xorout = 0xFFFFFFFF. Empty
|
||||
// input passes init through xorout, yielding 0x00000000.
|
||||
assert_eq!(crc32_of_payload(b""), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_zero_byte_has_a_specific_value() {
|
||||
// Pins the algorithm — CRC-32/ISO-HDLC of a single 0x00 byte is
|
||||
// 0xD202EF8D (well-known constant).
|
||||
assert_eq!(crc32_of_payload(&[0x00]), 0xD202_EF8D);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flipping_a_single_payload_byte_changes_the_crc() {
|
||||
// CRC is sensitive to every bit. A 256-byte payload with one bit flip
|
||||
// must produce a different CRC.
|
||||
let mut payload = vec![0xAA; 256];
|
||||
let crc_before = crc32_of_payload(&payload);
|
||||
payload[42] ^= 0x01;
|
||||
let crc_after = crc32_of_payload(&payload);
|
||||
assert_ne!(crc_before, crc_after, "single bit flip must change CRC");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iso_hdlc_distinguishes_from_castagnoli_for_same_input() {
|
||||
// CRC-32C ("Castagnoli", poly 0x1EDC6F41) of "123456789" is 0xE3069283.
|
||||
// CRC-32/ISO-HDLC of "123456789" is 0xCBF43926.
|
||||
// If anyone swaps polynomials, the test above already catches it — this
|
||||
// test makes the failure mode explicit by asserting the inequality
|
||||
// between the values, so reading the test source explains WHY.
|
||||
let our_crc = crc32_of_payload(b"123456789");
|
||||
let castagnoli = 0xE306_9283u32;
|
||||
assert_ne!(
|
||||
our_crc, castagnoli,
|
||||
"if our_crc equals CRC-32C/Castagnoli, the polynomial was swapped",
|
||||
);
|
||||
assert_eq!(our_crc, CHECK_VALUE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_short_inputs_have_documented_crcs() {
|
||||
// Computed via crc::Crc::<u32>::new(&crc::CRC_32_ISO_HDLC).checksum(...)
|
||||
// and captured here to lock the API surface. If a different crc crate
|
||||
// version or a different polynomial slips in, these constants fire.
|
||||
assert_eq!(crc32_of_payload(b"a"), 0xE8B7_BE43);
|
||||
assert_eq!(crc32_of_payload(b"abc"), 0x3524_41C2);
|
||||
assert_eq!(crc32_of_payload(b"hello world"), 0x0D4A_1185);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crc_is_deterministic_across_repeated_calls() {
|
||||
let payload = b"deterministic check payload";
|
||||
let a = crc32_of_payload(payload);
|
||||
let b = crc32_of_payload(payload);
|
||||
let c = crc32_of_payload(payload);
|
||||
assert_eq!(a, b);
|
||||
assert_eq!(b, c);
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
//! Acceptance tests for ADR-120 §2.5 `EmbeddingRing` lifecycle.
|
||||
|
||||
use wifi_densepose_bfld::{EmbeddingRing, IdentityEmbedding, EMBEDDING_DIM, RING_CAPACITY};
|
||||
|
||||
fn embedding_with_first(v: f32) -> IdentityEmbedding {
|
||||
let mut arr = [0.0f32; EMBEDDING_DIM];
|
||||
arr[0] = v;
|
||||
IdentityEmbedding::from_raw(arr)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_ring_is_empty() {
|
||||
let r = EmbeddingRing::new();
|
||||
assert_eq!(r.len(), 0);
|
||||
assert!(r.is_empty());
|
||||
assert!(!r.is_full());
|
||||
assert_eq!(r.capacity(), RING_CAPACITY);
|
||||
assert_eq!(r.iter().count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_constructor_matches_new() {
|
||||
let r = EmbeddingRing::default();
|
||||
assert_eq!(r.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_below_capacity_returns_none() {
|
||||
let mut r = EmbeddingRing::new();
|
||||
for i in 0..5 {
|
||||
let evicted = r.push(embedding_with_first(i as f32));
|
||||
assert!(evicted.is_none(), "no eviction expected at i={i}");
|
||||
}
|
||||
assert_eq!(r.len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iter_yields_in_insertion_order() {
|
||||
let mut r = EmbeddingRing::new();
|
||||
for i in 0..5 {
|
||||
r.push(embedding_with_first(i as f32));
|
||||
}
|
||||
let firsts: Vec<f32> = r.iter().map(|e| e.as_slice()[0]).collect();
|
||||
assert_eq!(firsts, vec![0.0, 1.0, 2.0, 3.0, 4.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_at_capacity_evicts_oldest_and_returns_it() {
|
||||
let mut r = EmbeddingRing::new();
|
||||
for i in 0..RING_CAPACITY {
|
||||
r.push(embedding_with_first(i as f32));
|
||||
}
|
||||
assert!(r.is_full());
|
||||
let evicted = r
|
||||
.push(embedding_with_first(999.0))
|
||||
.expect("must evict when full");
|
||||
// The evicted slot held the very first push (first = 0.0).
|
||||
assert_eq!(evicted.as_slice()[0], 0.0);
|
||||
assert_eq!(r.len(), RING_CAPACITY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_beyond_capacity_keeps_last_n_entries() {
|
||||
let mut r = EmbeddingRing::new();
|
||||
// Push capacity + 10 entries; the first 10 must have been evicted.
|
||||
for i in 0..(RING_CAPACITY + 10) {
|
||||
r.push(embedding_with_first(i as f32));
|
||||
}
|
||||
let firsts: Vec<f32> = r.iter().map(|e| e.as_slice()[0]).collect();
|
||||
let expected: Vec<f32> = (10..(RING_CAPACITY + 10) as i32)
|
||||
.map(|i| i as f32)
|
||||
.collect();
|
||||
assert_eq!(firsts, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drain_empties_the_ring_and_returns_count() {
|
||||
let mut r = EmbeddingRing::new();
|
||||
for i in 0..7 {
|
||||
r.push(embedding_with_first(i as f32));
|
||||
}
|
||||
let drained = r.drain();
|
||||
assert_eq!(drained, 7);
|
||||
assert!(r.is_empty());
|
||||
assert_eq!(r.iter().count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drain_on_empty_ring_returns_zero() {
|
||||
let mut r = EmbeddingRing::new();
|
||||
assert_eq!(r.drain(), 0);
|
||||
assert!(r.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ring_can_be_refilled_after_drain() {
|
||||
let mut r = EmbeddingRing::new();
|
||||
r.push(embedding_with_first(1.0));
|
||||
r.push(embedding_with_first(2.0));
|
||||
r.drain();
|
||||
r.push(embedding_with_first(42.0));
|
||||
assert_eq!(r.len(), 1);
|
||||
assert_eq!(r.iter().next().unwrap().as_slice()[0], 42.0);
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
//! Acceptance tests for ADR-120 §2.3 ↔ ADR-118 §2.1 wiring — `SignatureHasher`
|
||||
//! derives `rf_signature_hash` end-to-end through `BfldEmitter`.
|
||||
|
||||
#![cfg(feature = "std")]
|
||||
|
||||
use wifi_densepose_bfld::{
|
||||
BfldEmitter, IdentityEmbedding, SensingInputs, SignatureHasher, EMBEDDING_DIM, SITE_SALT_LEN,
|
||||
};
|
||||
|
||||
fn salt(seed: u8) -> [u8; SITE_SALT_LEN] {
|
||||
let mut s = [0u8; SITE_SALT_LEN];
|
||||
for (i, b) in s.iter_mut().enumerate() {
|
||||
*b = seed.wrapping_add(i as u8);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn embedding(seed: u8) -> IdentityEmbedding {
|
||||
let mut a = [0.0f32; EMBEDDING_DIM];
|
||||
for (i, v) in a.iter_mut().enumerate() {
|
||||
*v = (i as f32 + seed as f32) * 0.001;
|
||||
}
|
||||
IdentityEmbedding::from_raw(a)
|
||||
}
|
||||
|
||||
fn inputs(seed: u8) -> SensingInputs {
|
||||
SensingInputs {
|
||||
timestamp_ns: 1_700_000_000_000_000_000 + (seed as u64) * 1_000_000_000,
|
||||
presence: true,
|
||||
motion: 0.5,
|
||||
person_count: 1,
|
||||
sensing_confidence: 0.9,
|
||||
sep: 0.2,
|
||||
stab: 0.2,
|
||||
consist: 0.2,
|
||||
risk_conf: 0.2,
|
||||
rf_signature_hash: Some([0xFF; 32]), // caller-supplied "wrong" hash
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_hasher_passes_caller_supplied_hash_through() {
|
||||
let mut e = BfldEmitter::new("seed-01");
|
||||
let out = e.emit(inputs(0), Some(embedding(0))).unwrap();
|
||||
assert_eq!(out.rf_signature_hash, Some([0xFF; 32]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_hasher_overrides_caller_supplied_hash() {
|
||||
let mut e = BfldEmitter::new("seed-01").with_signature_hasher(SignatureHasher::new(salt(7)));
|
||||
let out = e.emit(inputs(0), Some(embedding(0))).unwrap();
|
||||
let hash = out.rf_signature_hash.unwrap();
|
||||
assert_ne!(hash, [0xFF; 32], "derived hash must override caller-supplied");
|
||||
assert_ne!(hash, [0x00; 32], "derived hash must be non-trivial");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_emitter_same_inputs_produce_same_hash() {
|
||||
let mut e_a = BfldEmitter::new("seed-01").with_signature_hasher(SignatureHasher::new(salt(7)));
|
||||
let mut e_b = BfldEmitter::new("seed-01").with_signature_hasher(SignatureHasher::new(salt(7)));
|
||||
let a = e_a.emit(inputs(0), Some(embedding(0))).unwrap();
|
||||
let b = e_b.emit(inputs(0), Some(embedding(0))).unwrap();
|
||||
assert_eq!(a.rf_signature_hash, b.rf_signature_hash);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_site_salts_produce_different_hashes_end_to_end() {
|
||||
let mut e_a = BfldEmitter::new("seed-01").with_signature_hasher(SignatureHasher::new(salt(1)));
|
||||
let mut e_b = BfldEmitter::new("seed-02").with_signature_hasher(SignatureHasher::new(salt(2)));
|
||||
// Same embedding, same inputs → different sites must produce different hashes.
|
||||
let a = e_a.emit(inputs(0), Some(embedding(0))).unwrap();
|
||||
let b = e_b.emit(inputs(0), Some(embedding(0))).unwrap();
|
||||
assert_ne!(
|
||||
a.rf_signature_hash, b.rf_signature_hash,
|
||||
"cross-site emit must produce uncorrelated hashes",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_embedding_falls_back_to_risk_factor_bytes() {
|
||||
let mut e = BfldEmitter::new("seed-01").with_signature_hasher(SignatureHasher::new(salt(5)));
|
||||
let out = e.emit(inputs(0), None).unwrap();
|
||||
let hash = out.rf_signature_hash.unwrap();
|
||||
assert_ne!(hash, [0xFF; 32]); // still derived (fallback path), not caller-supplied
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_hash_differs_from_embedding_hash() {
|
||||
let mut e_with = BfldEmitter::new("seed-01").with_signature_hasher(SignatureHasher::new(salt(9)));
|
||||
let mut e_without = BfldEmitter::new("seed-01").with_signature_hasher(SignatureHasher::new(salt(9)));
|
||||
let with_emb = e_with.emit(inputs(0), Some(embedding(0))).unwrap();
|
||||
let no_emb = e_without.emit(inputs(0), None).unwrap();
|
||||
assert_ne!(
|
||||
with_emb.rf_signature_hash, no_emb.rf_signature_hash,
|
||||
"embedding bytes and risk-factor bytes should hash to different values",
|
||||
);
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
//! End-to-end pipeline tests for `BfldEmitter`. ADR-118 §2.1.
|
||||
|
||||
#![cfg(feature = "std")]
|
||||
|
||||
use wifi_densepose_bfld::coherence_gate::DEBOUNCE_NS;
|
||||
use wifi_densepose_bfld::{
|
||||
BfldEmitter, GateAction, IdentityEmbedding, PrivacyClass, SensingInputs, EMBEDDING_DIM,
|
||||
};
|
||||
|
||||
fn inputs(ts_ns: u64, risk_factors: [f32; 4]) -> SensingInputs {
|
||||
let [sep, stab, consist, risk_conf] = risk_factors;
|
||||
SensingInputs {
|
||||
timestamp_ns: ts_ns,
|
||||
presence: true,
|
||||
motion: 0.5,
|
||||
person_count: 1,
|
||||
sensing_confidence: 0.9,
|
||||
sep,
|
||||
stab,
|
||||
consist,
|
||||
risk_conf,
|
||||
rf_signature_hash: Some([0xCD; 32]),
|
||||
}
|
||||
}
|
||||
|
||||
fn dummy_embedding() -> IdentityEmbedding {
|
||||
IdentityEmbedding::from_raw([0.1; EMBEDDING_DIM])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emitter_emits_event_under_low_risk() {
|
||||
let mut e = BfldEmitter::new("seed-01");
|
||||
let out = e
|
||||
.emit(inputs(0, [0.2, 0.2, 0.2, 0.2]), Some(dummy_embedding()))
|
||||
.expect("low risk should produce an event");
|
||||
assert_eq!(out.node_id, "seed-01");
|
||||
assert!(out.presence);
|
||||
assert!(out.identity_risk_score.is_some());
|
||||
assert_eq!(e.current_action(), GateAction::Accept);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emitter_drops_event_under_sustained_high_risk() {
|
||||
let mut e = BfldEmitter::new("seed-01");
|
||||
// First call: score ~ 0.7 pending Reject. Event still emits this turn
|
||||
// because the gate hasn't promoted yet (current is still Accept).
|
||||
let first = e.emit(inputs(0, [1.0, 1.0, 1.0, 0.8]), Some(dummy_embedding()));
|
||||
assert!(first.is_some(), "first high-risk call still emits");
|
||||
// After debounce: current becomes Reject -> event dropped.
|
||||
let after = e.emit(
|
||||
inputs(DEBOUNCE_NS, [1.0, 1.0, 1.0, 0.8]),
|
||||
Some(dummy_embedding()),
|
||||
);
|
||||
assert!(after.is_none(), "post-debounce Reject drops the event");
|
||||
assert_eq!(e.current_action(), GateAction::Reject);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emitter_drains_ring_on_recalibrate() {
|
||||
let mut e = BfldEmitter::new("seed-01");
|
||||
// Pump 5 embeddings under a slow rising score so the ring fills.
|
||||
for i in 0..5 {
|
||||
let _ = e.emit(
|
||||
inputs(i * 1_000_000, [0.3, 0.3, 0.3, 0.3]),
|
||||
Some(dummy_embedding()),
|
||||
);
|
||||
}
|
||||
assert_eq!(e.ring_len(), 5);
|
||||
|
||||
// Now push a Recalibrate-grade score and run past debounce.
|
||||
e.emit(inputs(10_000_000, [1.0, 1.0, 1.0, 1.0]), Some(dummy_embedding()));
|
||||
let _ = e.emit(
|
||||
inputs(10_000_000 + DEBOUNCE_NS, [1.0, 1.0, 1.0, 1.0]),
|
||||
Some(dummy_embedding()),
|
||||
);
|
||||
assert_eq!(e.current_action(), GateAction::Recalibrate);
|
||||
assert_eq!(e.ring_len(), 0, "Recalibrate must drain the embedding ring");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restricted_class_strips_identity_fields_in_emitted_event() {
|
||||
let mut e = BfldEmitter::new("seed-01").with_privacy_class(PrivacyClass::Restricted);
|
||||
let out = e
|
||||
.emit(inputs(0, [0.2, 0.2, 0.2, 0.2]), Some(dummy_embedding()))
|
||||
.expect("Accept should emit");
|
||||
assert!(
|
||||
out.identity_risk_score.is_none(),
|
||||
"class 3 must strip identity_risk_score",
|
||||
);
|
||||
assert!(
|
||||
out.rf_signature_hash.is_none(),
|
||||
"class 3 must strip rf_signature_hash",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_zone_sets_default_zone_id_on_event() {
|
||||
let mut e = BfldEmitter::new("seed-01").with_zone("kitchen");
|
||||
let out = e
|
||||
.emit(inputs(0, [0.1, 0.1, 0.1, 0.1]), Some(dummy_embedding()))
|
||||
.unwrap();
|
||||
assert_eq!(out.zone_id.as_deref(), Some("kitchen"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedding_is_pushed_to_ring_even_when_event_dropped() {
|
||||
let mut e = BfldEmitter::new("seed-01");
|
||||
// Drive into Reject state.
|
||||
e.emit(inputs(0, [1.0, 1.0, 1.0, 0.8]), Some(dummy_embedding()));
|
||||
e.emit(
|
||||
inputs(DEBOUNCE_NS, [1.0, 1.0, 1.0, 0.8]),
|
||||
Some(dummy_embedding()),
|
||||
);
|
||||
assert_eq!(e.current_action(), GateAction::Reject);
|
||||
// Even though the gate dropped events, the embeddings landed in the ring.
|
||||
assert_eq!(e.ring_len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ring_unchanged_when_no_embedding_supplied() {
|
||||
let mut e = BfldEmitter::new("seed-01");
|
||||
let _ = e.emit(inputs(0, [0.1, 0.1, 0.1, 0.1]), None);
|
||||
assert_eq!(e.ring_len(), 0);
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
//! `BfldEvent::apply_privacy_gating` one-way property. ADR-120 §2.4 "There is
|
||||
//! no `promote` operation — once a field is stripped, it cannot be restored."
|
||||
//!
|
||||
//! `apply_privacy_gating` is the soft in-place re-classifier used by
|
||||
//! [`BfldPipeline::process`] when `enable_privacy_mode()` is engaged. It
|
||||
//! checks the *current* `privacy_class` byte and, if Restricted or higher,
|
||||
//! nulls `identity_risk_score` and `rf_signature_hash`. Critically: it does
|
||||
//! NOT carry "this event was originally class 2 with score 0.34"; once
|
||||
//! stripped, a subsequent class drop back to Anonymous + another call to
|
||||
//! `apply_privacy_gating` leaves the fields `None`.
|
||||
//!
|
||||
//! This is a structural defense-in-depth property: an attacker who flips
|
||||
//! `privacy_class` back to Anonymous cannot resurrect the identity fields
|
||||
//! through the soft API alone — they'd have to fabricate them via
|
||||
//! `BfldEvent::with_privacy_gating` (or one of the documented constructors),
|
||||
//! which is a much harder ask than a single byte mutation.
|
||||
|
||||
#![cfg(feature = "std")]
|
||||
|
||||
use wifi_densepose_bfld::{BfldEvent, PrivacyClass};
|
||||
|
||||
fn class_2_event_with_identity_fields() -> BfldEvent {
|
||||
BfldEvent::with_privacy_gating(
|
||||
"seed-01".into(),
|
||||
1_700_000_000_000_000_000,
|
||||
true,
|
||||
0.5,
|
||||
1,
|
||||
0.9,
|
||||
Some("kitchen".into()),
|
||||
PrivacyClass::Anonymous,
|
||||
Some(0.34),
|
||||
Some([0xAB; 32]),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_at_anonymous_preserves_identity_fields() {
|
||||
let mut e = class_2_event_with_identity_fields();
|
||||
assert!(e.identity_risk_score.is_some());
|
||||
assert!(e.rf_signature_hash.is_some());
|
||||
e.apply_privacy_gating();
|
||||
// Class is still Anonymous → no strip.
|
||||
assert!(e.identity_risk_score.is_some());
|
||||
assert!(e.rf_signature_hash.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_class_flip_to_restricted_then_apply_strips_both_fields() {
|
||||
let mut e = class_2_event_with_identity_fields();
|
||||
e.privacy_class = PrivacyClass::Restricted;
|
||||
e.apply_privacy_gating();
|
||||
assert!(e.identity_risk_score.is_none());
|
||||
assert!(e.rf_signature_hash.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_way_strip_survives_class_flip_back_to_anonymous() {
|
||||
// The headline test. Sequence:
|
||||
// 1. Anonymous event with identity fields
|
||||
// 2. Mutate to Restricted → apply_privacy_gating → fields None
|
||||
// 3. Mutate back to Anonymous → apply_privacy_gating
|
||||
// 4. Fields STILL None (apply doesn't resurrect)
|
||||
let mut e = class_2_event_with_identity_fields();
|
||||
e.privacy_class = PrivacyClass::Restricted;
|
||||
e.apply_privacy_gating();
|
||||
assert!(e.identity_risk_score.is_none());
|
||||
|
||||
e.privacy_class = PrivacyClass::Anonymous;
|
||||
e.apply_privacy_gating();
|
||||
assert!(
|
||||
e.identity_risk_score.is_none(),
|
||||
"apply_privacy_gating must NOT resurrect identity_risk_score on class downgrade",
|
||||
);
|
||||
assert!(
|
||||
e.rf_signature_hash.is_none(),
|
||||
"apply_privacy_gating must NOT resurrect rf_signature_hash on class downgrade",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_field_restoration_after_strip_only_works_via_explicit_assignment() {
|
||||
// Operators who really want a class-2 event after a strip must rebuild
|
||||
// via with_privacy_gating (the documented path). Direct field assignment
|
||||
// also works — but THAT mutation is visible in code review as an
|
||||
// explicit "I am circumventing the soft gate" action, not a subtle
|
||||
// class-byte flip.
|
||||
let mut e = class_2_event_with_identity_fields();
|
||||
e.privacy_class = PrivacyClass::Restricted;
|
||||
e.apply_privacy_gating();
|
||||
assert!(e.identity_risk_score.is_none());
|
||||
|
||||
// Explicit restoration:
|
||||
e.privacy_class = PrivacyClass::Anonymous;
|
||||
e.identity_risk_score = Some(0.42);
|
||||
e.rf_signature_hash = Some([0xCD; 32]);
|
||||
e.apply_privacy_gating();
|
||||
// apply at class Anonymous does NOT strip the just-restored values.
|
||||
assert_eq!(e.identity_risk_score, Some(0.42));
|
||||
assert_eq!(e.rf_signature_hash, Some([0xCD; 32]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_at_already_restricted_with_already_none_fields_is_a_noop() {
|
||||
let mut e = class_2_event_with_identity_fields();
|
||||
e.privacy_class = PrivacyClass::Restricted;
|
||||
e.apply_privacy_gating(); // first strip
|
||||
e.apply_privacy_gating(); // second call — must remain idempotent
|
||||
assert!(e.identity_risk_score.is_none());
|
||||
assert!(e.rf_signature_hash.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_way_property_holds_through_multiple_class_round_trips() {
|
||||
let mut e = class_2_event_with_identity_fields();
|
||||
for _ in 0..5 {
|
||||
e.privacy_class = PrivacyClass::Restricted;
|
||||
e.apply_privacy_gating();
|
||||
e.privacy_class = PrivacyClass::Anonymous;
|
||||
e.apply_privacy_gating();
|
||||
}
|
||||
assert!(
|
||||
e.identity_risk_score.is_none(),
|
||||
"10 round-trips must not resurrect identity_risk_score",
|
||||
);
|
||||
assert!(
|
||||
e.rf_signature_hash.is_none(),
|
||||
"10 round-trips must not resurrect rf_signature_hash",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuilding_via_with_privacy_gating_is_the_documented_restoration_path() {
|
||||
// After a strip, building a fresh event via with_privacy_gating is the
|
||||
// sanctioned way to publish identity fields again. This test pins the
|
||||
// contract for operators reading the docs: "to restore identity fields,
|
||||
// build a fresh BfldEvent."
|
||||
let mut stripped = class_2_event_with_identity_fields();
|
||||
stripped.privacy_class = PrivacyClass::Restricted;
|
||||
stripped.apply_privacy_gating();
|
||||
assert!(stripped.identity_risk_score.is_none());
|
||||
|
||||
let restored = BfldEvent::with_privacy_gating(
|
||||
stripped.node_id.clone(),
|
||||
stripped.timestamp_ns,
|
||||
stripped.presence,
|
||||
stripped.motion,
|
||||
stripped.person_count,
|
||||
stripped.confidence,
|
||||
stripped.zone_id.clone(),
|
||||
PrivacyClass::Anonymous,
|
||||
Some(0.55),
|
||||
Some([0xEF; 32]),
|
||||
);
|
||||
assert_eq!(restored.identity_risk_score, Some(0.55));
|
||||
assert_eq!(restored.rf_signature_hash, Some([0xEF; 32]));
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
//! Acceptance tests for ADR-121 §2.1 / ADR-122 §2.1 — `BfldEvent` privacy gating.
|
||||
|
||||
#![cfg(feature = "std")]
|
||||
|
||||
use wifi_densepose_bfld::{BfldEvent, PrivacyClass};
|
||||
|
||||
fn sample_at(class: PrivacyClass) -> BfldEvent {
|
||||
BfldEvent::with_privacy_gating(
|
||||
"seed-01".to_string(),
|
||||
1_700_000_000_000_000_000,
|
||||
true,
|
||||
0.72,
|
||||
1,
|
||||
0.91,
|
||||
Some("living_room".to_string()),
|
||||
class,
|
||||
Some(0.84),
|
||||
Some([0xAB; 32]),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anonymous_event_retains_identity_risk_and_hash() {
|
||||
let e = sample_at(PrivacyClass::Anonymous);
|
||||
assert!(e.identity_risk_score.is_some());
|
||||
assert!(e.rf_signature_hash.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restricted_event_strips_identity_fields() {
|
||||
let e = sample_at(PrivacyClass::Restricted);
|
||||
assert!(e.identity_risk_score.is_none(), "risk score must be None at class 3");
|
||||
assert!(e.rf_signature_hash.is_none(), "rf hash must be None at class 3");
|
||||
// Sensing fields still present.
|
||||
assert!(e.presence);
|
||||
assert_eq!(e.person_count, 1);
|
||||
assert_eq!(e.zone_id.as_deref(), Some("living_room"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_privacy_gating_is_idempotent() {
|
||||
let mut e = sample_at(PrivacyClass::Restricted);
|
||||
e.apply_privacy_gating();
|
||||
e.apply_privacy_gating();
|
||||
assert!(e.identity_risk_score.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_type_is_always_bfld_update() {
|
||||
for c in [
|
||||
PrivacyClass::Anonymous,
|
||||
PrivacyClass::Restricted,
|
||||
PrivacyClass::Derived,
|
||||
] {
|
||||
assert_eq!(sample_at(c).event_type, "bfld_update");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "serde-json")]
|
||||
mod json {
|
||||
use super::sample_at;
|
||||
use wifi_densepose_bfld::PrivacyClass;
|
||||
|
||||
#[test]
|
||||
fn json_round_trip_emits_type_field_first_or_last_but_present() {
|
||||
let json = sample_at(PrivacyClass::Anonymous).to_json().unwrap();
|
||||
assert!(json.contains(r#""type":"bfld_update""#), "JSON: {json}");
|
||||
assert!(json.contains(r#""node_id":"seed-01""#));
|
||||
assert!(json.contains(r#""presence":true"#));
|
||||
assert!(json.contains(r#""privacy_class":"anonymous""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anonymous_json_includes_identity_fields() {
|
||||
let json = sample_at(PrivacyClass::Anonymous).to_json().unwrap();
|
||||
assert!(json.contains("identity_risk_score"));
|
||||
assert!(json.contains("rf_signature_hash"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restricted_json_omits_identity_fields_entirely() {
|
||||
let json = sample_at(PrivacyClass::Restricted).to_json().unwrap();
|
||||
assert!(
|
||||
!json.contains("identity_risk_score"),
|
||||
"JSON must omit identity_risk_score at class 3, got: {json}",
|
||||
);
|
||||
assert!(
|
||||
!json.contains("rf_signature_hash"),
|
||||
"JSON must omit rf_signature_hash at class 3, got: {json}",
|
||||
);
|
||||
// Sensing fields still emitted.
|
||||
assert!(json.contains("presence"));
|
||||
assert!(json.contains("motion"));
|
||||
assert!(json.contains(r#""privacy_class":"restricted""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn privacy_class_serializes_to_lowercase_name() {
|
||||
for (class, name) in [
|
||||
(PrivacyClass::Anonymous, "anonymous"),
|
||||
(PrivacyClass::Restricted, "restricted"),
|
||||
] {
|
||||
let json = sample_at(class).to_json().unwrap();
|
||||
let needle = format!(r#""privacy_class":"{name}""#);
|
||||
assert!(json.contains(&needle), "missing {needle} in: {json}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zone_id_none_is_omitted_from_json() {
|
||||
let mut e = sample_at(PrivacyClass::Anonymous);
|
||||
e.zone_id = None;
|
||||
let json = e.to_json().unwrap();
|
||||
assert!(!json.contains("zone_id"), "None zone_id must be omitted: {json}");
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
//! Validate `examples/bfld_handle.rs` operator quickstart. Re-runs the same
|
||||
//! lifecycle inline so CI proves the worker-thread pattern works end-to-end.
|
||||
|
||||
#![cfg(feature = "std")]
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use wifi_densepose_bfld::{
|
||||
publish_availability_offline, publish_availability_online, publish_discovery, BfldConfig,
|
||||
BfldPipeline, BfldPipelineHandle, CapturePublisher, IdentityEmbedding, PipelineInput,
|
||||
PrivacyClass, SensingInputs, SignatureHasher, EMBEDDING_DIM, SITE_SALT_LEN,
|
||||
};
|
||||
|
||||
const HANDLE_EXAMPLE: &str = include_str!("../examples/bfld_handle.rs");
|
||||
|
||||
#[test]
|
||||
fn handle_example_documents_full_lifecycle_phases() {
|
||||
// Doc drift guard: every operator-facing symbol must appear in the file.
|
||||
for needle in [
|
||||
"publish_availability_online",
|
||||
"publish_discovery",
|
||||
"BfldPipelineHandle::spawn",
|
||||
"handle.send",
|
||||
"handle.shutdown",
|
||||
"publish_availability_offline",
|
||||
"SignatureHasher",
|
||||
"PipelineInput",
|
||||
] {
|
||||
assert!(
|
||||
HANDLE_EXAMPLE.contains(needle),
|
||||
"example must reference {needle}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_example_carries_run_instructions_and_prod_pointer() {
|
||||
assert!(
|
||||
HANDLE_EXAMPLE.contains("cargo run -p wifi-densepose-bfld --example bfld_handle"),
|
||||
"example must document its own run command",
|
||||
);
|
||||
assert!(
|
||||
HANDLE_EXAMPLE.contains("RumqttPublisher::connect_with_lwt"),
|
||||
"example must point operators at the production publisher path",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_example_lifecycle_produces_expected_message_counts() {
|
||||
// Re-execute the lifecycle inline. End state must show:
|
||||
// 1 (online) + 6 (discovery anonymous + zone-less) + 5×5 (state per
|
||||
// send) + 1 (offline) = 33 messages.
|
||||
let node_id = "seed-handle-test";
|
||||
let site_salt: [u8; SITE_SALT_LEN] = [0xC0; SITE_SALT_LEN];
|
||||
|
||||
let publisher = Arc::new(Mutex::new(CapturePublisher::default()));
|
||||
|
||||
publish_availability_online(&mut publisher.clone(), node_id).expect("online");
|
||||
let discovery_count =
|
||||
publish_discovery(&mut publisher.clone(), node_id, PrivacyClass::Anonymous)
|
||||
.expect("discovery");
|
||||
assert_eq!(discovery_count, 6);
|
||||
|
||||
let pipeline = BfldPipeline::new(
|
||||
BfldConfig::new(node_id).with_signature_hasher(SignatureHasher::new(site_salt)),
|
||||
);
|
||||
let handle = BfldPipelineHandle::spawn(pipeline, publisher.clone());
|
||||
|
||||
for i in 0..5u64 {
|
||||
let timestamp_ns = 1_700_000_000_000_000_000 + i * 200_000_000;
|
||||
let input = PipelineInput {
|
||||
inputs: SensingInputs {
|
||||
timestamp_ns,
|
||||
presence: true,
|
||||
motion: 0.3 + (i as f32) * 0.1,
|
||||
person_count: 1,
|
||||
sensing_confidence: 0.9,
|
||||
sep: 0.2,
|
||||
stab: 0.2,
|
||||
consist: 0.2,
|
||||
risk_conf: 0.2,
|
||||
rf_signature_hash: None,
|
||||
},
|
||||
embedding: Some(IdentityEmbedding::from_raw([0.05; EMBEDDING_DIM])),
|
||||
};
|
||||
handle.send(input).expect("send");
|
||||
}
|
||||
thread::sleep(Duration::from_millis(120));
|
||||
handle.shutdown();
|
||||
|
||||
publish_availability_offline(&mut publisher.clone(), node_id).expect("offline");
|
||||
|
||||
let log = publisher.lock().expect("publisher mutex");
|
||||
let total = log.published.len();
|
||||
|
||||
// Expected: 1 online + 6 discovery + 5 × 5 state + 1 offline = 33.
|
||||
assert_eq!(
|
||||
total, 33,
|
||||
"expected 33 total messages from full lifecycle, got {total}; \
|
||||
topics: {:?}",
|
||||
log.published
|
||||
.iter()
|
||||
.map(|m| &m.topic)
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
|
||||
// First message is the online availability.
|
||||
assert_eq!(log.published[0].payload, "online");
|
||||
// Last message is the offline availability.
|
||||
assert_eq!(log.published[total - 1].payload, "offline");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_example_returns_box_dyn_error_for_main_signature() {
|
||||
assert!(
|
||||
HANDLE_EXAMPLE.contains("fn main() -> Result<(), Box<dyn std::error::Error>>"),
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user