Compare commits

..

17 Commits

Author SHA1 Message Date
ruv 2dfc805f1e docs(adr-117): point README + user-guide at the live PyPI releases
Both packages are now live on PyPI; bring the in-repo docs up to
match. Keep both updates brief — the canonical surface
documentation lives on the PyPI project pages themselves.

Root README (Option 4 block):
- Switch the default `pip install` example to `ruview` (the brand
  name) and note `wifi-densepose` is equivalent.
- Add live PyPI version badges for both packages.

docs/user-guide.md (§Python wheel):
- Replace the single-install example with a table showing both
  PyPI projects and their import names so users see the choice
  immediately.
- Add three short usage snippets (vitals, live sensing-server WS,
  HA-MIND semantic-primitive MQTT listener) so the guide doubles
  as a "what does this thing do?" reference for someone landing
  via pip.
- Note the cibuildwheel matrix for multi-arch wheels.
- Add the `pytest tests/` + `pytest bench/` source-build verify
  steps.

No code or test changes.

Refs: docs/adr/ADR-117-pip-wifi-densepose-modernization.md
Refs: #786

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-24 13:12:07 -04:00
ruv 51b3433471 ci(adr-117): kics-compatible workflow comments + fix-marker guards
- KICS error fix (.github/workflows/pip-release.yml:20): the inline
  `gcloud secrets versions access --secret=PYPI_TOKEN ...` runbook
  in the workflow header was triggering KICS' generic-secret regex
  on the literal `PYPI_TOKEN` substring. Moved the refresh runbook
  to docs/integrations/pypi-release.md (with the BOM-stripping
  `tr` step that fixed the production publish) and replaced the
  inline block with a pointer.

- Three new fix-marker guards in scripts/fix-markers.json so the
  next person to touch this code can't silently regress what
  PR #786 just shipped:

  * RuView#786-tombstone-import — the tombstone __init__.py must
    `raise ImportError`, must mention the v2 install hint, must
    point at the repo URL, AND must NOT contain `def`/`class`/
    `import wifi_densepose` (forbid patterns prevent accidental
    bloating into a real module that loads partway before failing).

  * RuView#786-tombstone-smoke-cwd — pip-release.yml must `cd /tmp`
    before the tombstone smoke-test import, because the legacy
    `./wifi_densepose/__init__.py` at repo root would otherwise
    shadow the venv install. This was the root cause of run
    26366648768; locking it in.

  * RuView#786-pypi-token-auth — the workflow must use
    `password: ${{ secrets.PYPI_API_TOKEN }}` and must NOT carry
    `id-token: write`. The project authenticates via API token,
    not OIDC; a partial OIDC migration would 403 silently.

Local check: all 25 markers pass.

Refs: docs/adr/ADR-117-pip-wifi-densepose-modernization.md
Refs: #786

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-24 12:53:25 -04:00
ruv b71d243b42 feat(adr-117): publish wifi-densepose 2.0.0a1 + ruview 2.0.0a1 to PyPI
Three PyPI artifacts now live (published from .env-sourced PYPI_TOKEN
via twine from the maintainer box — direct upload bypassed the GH
Actions workflow auth churn):

1. wifi-densepose==1.99.0 — tombstone (raises ImportError with migration URL)
   https://pypi.org/project/wifi-densepose/1.99.0/

2. wifi-densepose==2.0.0a1 — PyO3 wheel (win_amd64 cp310-abi3) + sdist
   https://pypi.org/project/wifi-densepose/2.0.0a1/

3. ruview==2.0.0a1 — meta-package re-exporting wifi_densepose
   https://pypi.org/project/ruview/2.0.0a1/

New `python/ruview-meta/` subdirectory:
- pyproject.toml — name="ruview", version="2.0.0a1", setuptools backend,
  dependencies = ["wifi-densepose==2.0.0a1"]
- src/ruview/__init__.py — re-exports every name from
  `wifi_densepose.__all__` so `from ruview import BreathingExtractor`
  is equivalent to `from wifi_densepose import BreathingExtractor`.
  Also re-exports `__version__`, `__rust_version__`,
  `__rust_build_tag__`, `__build_features__`. Aliases the `client`
  sub-package transparently when wifi-densepose[client] extras are
  installed.
- README.md — explains why two PyPI names ship the same code (brand
  vs technical name) and shows install commands for both.

End-to-end verified: fresh venv, `pip install ruview`,
`import ruview` + `import wifi_densepose` both succeed,
`ruview.BreathingExtractor is wifi_densepose.BreathingExtractor` → True.

Multi-platform wheels (manylinux x86_64+aarch64, macos x86_64+arm64)
still pending — the cibuildwheel workflow path remains for that.
Linux/macOS users today install via the sdist (requires rustup +
maturin locally).

Refs: docs/adr/ADR-117-pip-wifi-densepose-modernization.md
Refs: #785

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-24 12:46:22 -04:00
ruv bd91cd1e8a fix(adr-117/p5): smoke-test must cd out of repo root before importing
Root cause from run 26366579422 diagnostics: the wheel built correctly
(872 bytes, valid ImportError) but `import wifi_densepose` resolved to
the legacy `./wifi_densepose/__init__.py` left in the repo root from
v1, NOT to the freshly-installed tombstone wheel in the smoke venv.

Python places the cwd at sys.path[0] for `python -c "..."`, so
running the import from the repo root made the legacy directory win
over site-packages every time. The "isolated venv" was not the
problem — the cwd was.

Fix: copy the wheel to /tmp, cd /tmp before the import. Now the
smoke test runs in a directory that contains no `wifi_densepose/`
so the only resolution path is the venv's site-packages.

The repo-root `./wifi_densepose/__init__.py` is a separate concern
(legacy v1 carry-over) that should be cleaned up in a follow-up
commit, but the smoke test should not depend on it being absent.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-24 12:30:01 -04:00
ruv 3f6c6eb108 fix(adr-117/p5): pin Python 3.12 + isolated venv for tombstone smoke-test
First v1.99.0-pip run (26366491748) failed: the runner's system `python`
fell back to `--user` install, then `python -c "import wifi_densepose"`
resolved to something other than the freshly-installed user-site wheel
and returned cleanly instead of raising the tombstone ImportError.

Fixes:
- `actions/setup-python@v5` with explicit 3.12 — owns its own site-
  packages so pip won't fall back to --user.
- New "Inspect wheel contents" step prints the wheel manifest +
  the verbatim __init__.py inside it. If a future regression ships
  an empty __init__.py from a setuptools src-layout edge case,
  the failure is debuggable from the run log alone.
- Smoke test now runs in a fresh /tmp/smoke-venv so there's zero
  ambiguity about which wifi_densepose gets imported. Also uses
  importlib.util.find_spec to print the resolved origin path
  before the import attempt — so even if both checks pass, we
  see exactly which file we exercised.

No code changes to the tombstone source itself.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-24 12:26:56 -04:00
ruv 61087b1588 docs(adr-117): point root README + user-guide at the v2 pip wheel
- Root README — add Option 4 alongside the existing Docker / ESP32 /
  Cognitum Seed installs: `pip install "wifi-densepose[client]"` with
  a two-line import preview.
- User-guide §Installation — replace the stale "From Source (Python)"
  block (which referenced legacy v1 extras `[gpu]` and `[all]` that
  don't exist in v2) with a brief "Python wheel (pip) — ADR-117"
  section: what the wheel is, install commands, two-line example,
  tombstone caveat, and the `maturin develop` source-build path
  for contributors.

Refs: docs/adr/ADR-117-pip-wifi-densepose-modernization.md
Refs: #785

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-24 12:16:18 -04:00
ruv 5de8718882 fix(adr-117/p5): switch publish workflow to PYPI_API_TOKEN + user-facing README
- Workflow rewired from OIDC Trusted Publisher to token-based publish
  via the `PYPI_API_TOKEN` GitHub Actions secret. Both publish jobs
  (v2 wheels + tombstone) pass `password: ${{ secrets.PYPI_API_TOKEN }}`
  to `pypa/gh-action-pypi-publish@release/v1`. Workflow comments now
  document the GCP → GH secret-refresh command.
- Removed `permissions: id-token: write` and the OIDC `environment:`
  blocks (no longer needed without OIDC).
- Token was sourced from the GCP Secret Manager entry `PYPI_TOKEN`
  in project `cognitum-20260110` and pushed to GH Actions via
  `gcloud secrets versions access | gh secret set` so the value
  never appeared in a shell variable or this session's output.
- Rewrote `python/README.md` from a developer phase-ledger into a
  user-facing PyPI front page: one-paragraph elevator pitch, bullet
  list of features, three short usage snippets (vitals extract,
  WS subscribe, MQTT semantic-primitive listener, BFLD numpy
  bridge), hardware table, links. The README is the FIRST thing
  pip users see at https://pypi.org/p/wifi-densepose so it has to
  introduce the project, not the build plan.

Wheel rebuilds clean at 253 KB (was 238 KB — +15 KB from the richer
README baked into the wheel metadata). Test suite unchanged at 183/183.

Refs: docs/adr/ADR-117-pip-wifi-densepose-modernization.md
Refs: #785

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-24 12:14:56 -04:00
ruv 78916d8455 hardening(adr-117): benchmarks + security/robustness test suite
Benchmarks (`python/bench/`, pytest-benchmark — opt-in via --benchmark-only):

| Hot path | Mean | Ops/sec | % of 100 Hz budget |
|---|---|---|---|
| BfldFrame HT20 1×1×52 | 800 ns | 1.25 Mops | 0.008% |
| BfldFrame HE20 2×1×242 | 1.3 μs | 750 kops | 0.013% |
| BfldFrame HE80 2×1×996 | 4.2 μs | 236 kops | 0.042% |
| BfldFrame HE160 2×2×1992 | 14 μs | 71 kops | 0.14% |
| BfldFrame.feedback_matrix() | 2.8 μs | 352 kops | — |
| WS edge_vitals decode | 7.4 μs | 134 kops | 0.074% |
| WS pose_data decode (3 persons) | 23 μs | 42 kops | 0.24% |
| BreathingExtractor.extract() 56sc | 28 μs | 35 kops | 0.28% |
| BreathingExtractor.extract() 114sc | 44 μs | 23 kops | 0.44% |
| BreathingExtractor.extract() 242sc | 79 μs | 13 kops | 0.79% |
| HeartRateExtractor.extract() 56sc | 105 μs | 9.5 kops | 1.05% |

All hot paths well under the 100 Hz ESP32 frame budget (10 ms).
Worst case (HeartRateExtractor) uses 1% of the budget — no
optimization needed. Scaling on n_subcarriers is sub-quadratic
(56→242 = 4.3× input, 2.8× time) — catches future O(n²)
regressions.

Security & robustness tests (`tests/test_security.py`, +27 tests):

- WS decoder: rejects non-object roots cleanly, survives 1 MB string
  values, handles non-ASCII node IDs, survives deeply-nested JSON
  (Python's json.loads built-in guard not bypassed)
- MQTT topic matcher: 9 edge-case parametrize entries including
  $SYS topics, null-byte injection, mid-pattern `#` boundary,
  empty-string boundary
- MQTT credential confidentiality: password never appears in
  repr()/str(), never stored in plain client-instance attribute
- HA discovery: rejects null-byte-laced topics, rejects extra
  slashes in node_id, rejects non-dict payload body (list, scalar,
  invalid UTF-8 bytes) without crashing
- Semantic primitive listener: rejects topic-injection attempts
  (prefix-injected paths, wrong case on final segment), survives
  invalid UTF-8 payloads
- Public surface integrity: every name in wifi_densepose.__all__
  AND wifi_densepose.client.__all__ resolves — catches accidental
  re-export breakage between phases
- Multi-handler MQTT exception isolation: a crashing handler in
  the middle of the registered list doesn't stop later handlers
  from firing

Test count: 156 → 183 (+27). All passing.

Bench results steady-state confirm no Rust-binding-layer
optimization is needed before the v2.0.0 publish.

Refs: docs/adr/ADR-117-pip-wifi-densepose-modernization.md
Refs: #785

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-24 11:44:54 -04:00
ruv f9d99c50d9 feat(adr-117/p5+p-tomb): pip-release workflow + v1.99.0 tombstone wheel
P5 — `.github/workflows/pip-release.yml`:
- cibuildwheel matrix per ADR §5.4: manylinux x86_64 + aarch64,
  macos x86_64 + arm64, win amd64 (5 wheels via abi3-py310 stable
  ABI — one binary per OS/arch covers Python 3.10–3.13)
- Linux aarch64 cross-builds via QEMU; rustup 1.82 pinned in
  CIBW_BEFORE_ALL_LINUX for reproducibility
- Per-wheel smoke test: import wifi_densepose, assert hello()=="ok"
- sdist via `maturin sdist`
- Trigger: workflow_dispatch + push to `v*-pip` tags ONLY (never
  on regular commits — won't accidentally publish)
- TestPyPI dry-run gate via `repository-url: https://test.pypi.org/legacy/`
- Production PyPI publish via Trusted Publisher OIDC (no API tokens
  in GH secrets per ADR §9). Requires one-time PyPI Trusted Publisher
  registration before the first publish can fire.
- Q3 (witness hash v2 — ADR-117 §11.3) flagged in workflow comments
  as a hard gate before the first tag.

P-tomb — `python/tombstone/`:
- Separate `wifi-densepose==1.99.0` sdist+wheel using setuptools
  backend (NOT maturin — tombstone is pure Python, no Rust).
- `src/wifi_densepose/__init__.py` raises ImportError with the
  migration URL on import. Verified locally: 2.7 KB wheel,
  `pip install` then `import wifi_densepose` raises ImportError
  with `pip install wifi-densepose==2.0.0` hint + repo URL.
- 5 unit tests (`tests/test_tombstone.py`) lock the file content
  down: must `raise ImportError`, must contain v2 install hint
  and migration URL, must NOT contain any `def`/`class`/`import`
  beyond the bare `raise` — so a well-intentioned refactor can't
  accidentally bloat the tombstone into a real module that loads
  partway before failing.

Both wheels are published by the same pip-release.yml workflow:
- `v1.99.0-pip` tag → publishes tombstone (or via workflow_dispatch
  with `target: v1-99-tombstone`)
- `v2.X.Y-pip` tag → publishes the v2 wheel matrix

Per ADR-117 §7.3: tag and publish 1.99.0-pip FIRST so the tombstone
claims the "current" slot in pip's resolver, THEN publish 2.0.0-pip.

Test count unchanged in main python/ suite (156/156). Tombstone
sub-suite: 5 passing.

Refs: docs/adr/ADR-117-pip-wifi-densepose-modernization.md §5.4, §7
Refs: #785

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-24 11:39:04 -04:00
ruv f21daf9aa8 feat(adr-117/p4): pure-Python WS/MQTT client layer
New sub-package `wifi_densepose.client` (no PyO3, no Rust deps):

- ws.SensingClient — asyncio websockets>=12 wrapper for the Rust
  sensing-server /ws/sensing endpoint. Yields typed dataclasses
  (ConnectionEstablishedMessage, EdgeVitalsMessage, PoseDataMessage)
  with raw-payload fallback for forward-compat with unknown types.
  Malformed frames log+drop without breaking the stream.

- mqtt.RuViewMqttClient — paho-mqtt v2 wrapper using the explicit
  CallbackAPIVersion.VERSION2 API. Per-instance unique client_id by
  default (rumqttc memory lesson). MQTT v5-spec-correct topic
  wildcard matcher: + as whole-level wildcard, # matches the prefix
  itself plus all sub-levels. Auto-resubscribes on reconnect.
  Handler exceptions are caught and logged so a misbehaving callback
  can't crash the network loop.

- primitives.SemanticPrimitiveListener — typed router for the 10
  HA-MIND fused inference outputs from ADR-115 §3.12
  (SomeoneSleeping, PossibleDistress, RoomActive, ElderlyInactivity-
  Anomaly, MeetingInProgress, BathroomOccupied, FallRiskElevated,
  BedExit, NoMovementSafety, MultiRoomTransition). Decodes both
  JSON payloads with confidence+explanation AND plain HA state
  strings ("ON"/"OFF"/numeric). Pluggable into RuViewMqttClient.

- ha.HABlueprintHelper — read-only parser for the
  homeassistant/<kind>/wifi_densepose_<node>/<id>/config payload
  family. Aggregator queries: entities_for_node, by_device_class,
  nodes. Useful for blueprint authors + dashboard introspection.

Test coverage (63 new tests, 156 total in Python suite):
- test_client_ha — 18 tests (topic+payload parsing, aggregator)
- test_client_primitives — 13 tests (enum coverage, listener routing)
- test_client_mqtt — 17 tests (matcher parametrize, dispatch path,
  on_connect, exception isolation) — no broker needed
- test_client_ws — 6 tests including end-to-end against an in-process
  websockets.serve() fixture exercising all 4 message types plus a
  malformed-frame survival check

Post-bridge wheel size: 238 KB (well under ADR §5.4 5 MB budget).

Refs: docs/adr/ADR-117-pip-wifi-densepose-modernization.md §5.6
Refs: docs/adr/ADR-115-home-assistant-integration.md §3.12
Refs: #785

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-24 11:31:29 -04:00
ruv 2d29359809 feat(adr-117/p3+p3.5): vitals + BFLD bindings
P3 — Vital sign extraction bindings (wifi-densepose-vitals):
- VitalStatus enum (eq, eq_int, hash, frozen) — Valid/Degraded/Unreliable/Unavailable
- VitalEstimate (frozen) — value_bpm + confidence + status
- VitalReading (frozen) — HR + BR + signal quality composite
- BreathingExtractor — 0.1–0.5 Hz bandpass + zero-crossing
- HeartRateExtractor — 0.8–2.0 Hz bandpass + autocorrelation
- py.allow_threads on extract() hot loops (Q5 audit confirmed
  core/vitals/signal are pure-sync — zero tokio deps, safe to release
  GIL with no embedded runtime needed)
- 17 tests covering construction, getters, frozen immutability,
  esp32_default + explicit ctors, synthetic-signal end-to-end

P3.5 — BFLD bindings (forward-compat surface, stub Rust):
- BfldKind enum — CompressedHE20/40/80/160 + UncompressedHT20/40
  with n_subcarriers, bandwidth_mhz, is_he metadata getters
- BfldFrame (frozen) — from_compressed_feedback() accepts numpy
  Complex64 ndarray [Nr x Nc x Nsc], validates dims against kind,
  feedback_matrix() returns lossless roundtrip ndarray
- BfldReport — aggregates frames, rejects mismatched kinds,
  computes inverse-CV coherence score
- 19 tests covering all 6 PHY variants + numpy roundtrip +
  dim-mismatch error + aggregation
- Real Rust ingestion (wifi-densepose-bfld crate) lands post-v2.0
  per ADR-117 §11.11/12 — Python API will not change

Total Python test count: 93 (was 57, +36 P3+P3.5). All passing.

Refs: docs/adr/ADR-117-pip-wifi-densepose-modernization.md
Refs: #785

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-24 11:21:58 -04:00
ruv 4ac0a4d52b docs(adr-117): add BFLD support (§5.7a + P3.5 phase + §11.11/12 open questions)
Per maintainer feedback during P3 implementation, expand ADR-117 to
include Beamforming Feedback Loop Data (BFLD) as a first-class binding
target alongside CSI. BFLD is the transmitter-side, AP-station-loop
view of the WiFi channel (802.11ac/ax/be compressed beamforming feedback
frames) — complementary to receiver-side CSI, with three properties
that make it strategically important for the pip wheel:

1. **Up to 996 subcarriers per HE160 frame** (vs 242 for HE-LTF CSI on
   ESP32-C6, vs 52 for HT-LTF on ESP32-S3) — much denser per-subcarrier
   reflection profile
2. **Works on stock 802.11ac+ hardware** — no Nexmon patch, no ESP32
   monitor mode, no firmware drift. Captured via tcpdump/Wireshark +
   BFR dissector, or via `mac80211` debugfs on Linux 6.10+
3. **Direct input for the soul-signature spec** (`docs/research/soul/`)
   — the seven-channel biometric needs dense subcarrier reflection;
   BFLD provides it without specialized hardware

## Three additions to ADR-117

### §5.7a — New binding-target subsection
Comparison table CSI vs BFLD; binding strategy with forward-compat
stub Rust impl pending the future `wifi-densepose-bfld` crate; the
three Python types that ship in P3.5:

- `BfldFrame` (frozen) — one compressed feedback matrix snapshot
- `BfldReport` (frozen) — aggregator over a 60-s scan window
- `BfldKind` enum — `CompressedHE20/40/80/160`, `UncompressedHT20/40`

### §6 P3.5 — Concurrent-with-P3 phase
Checkbox plan for the bindings module + stub Rust storage + numpy
bridge for `feedback_matrix` (Complex64 ndarray, same approach as
`CsiFrame.amplitude` from P3). Lands in the same wheel as P3, no
schedule cushion needed.

### §11.11/12 — Two new open questions
- **§11.11** — Should the future BFR ingestion Rust crate be a new
  `wifi-densepose-bfld` workspace member, or extend `-signal`?
  *Tentative: new dedicated crate. Wireshark BFR dissector is ~2k
  lines and would bloat `-signal`; ingestion is optional for many
  deployments; keep `-signal` lean.*
- **§11.12** — Per-vendor BFR variant compatibility (Broadcom vs
  Intel vs Qualcomm vs MediaTek differ in psi/phi quantization +
  matrix entry ordering). How much normalisation in the Python
  binding vs. the future Rust crate? *Tentative: Python binding is
  dumb (numpy ndarray in/out); future Rust crate owns per-vendor
  normalisation via a `Vendor` enum on the constructor.*

### §12 — BFLD reference list
- Hernandez & Bulut, ACM TOSN 2024 (first systematic survey of
  BFR-as-sensing)
- Yousefi et al., MobiSys 2023 (practical breath + HR extraction)
- IEEE 802.11ax-2021 §27.3.10 (frame format)
- Wireshark `packet-ieee80211.c` dissector
- AX210 Linux mac80211 debugfs path (kernel 6.10+)

ADR line count: 644 → 807 (+163). Refs #785 (tracking issue).

The implementation work for P3.5 lands in the next /loop iteration
alongside P3 vitals + signal DSP bindings.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-24 11:09:37 -04:00
ruv cbd24cd1ed feat(adr-117/p2): BoundingBox + PersonPose + PoseEstimate — P2 COMPLETE (57/57 tests GREEN)
Lands the second + third chunks of P2: PyO3 bindings for `BoundingBox`,
`PersonPose`, `PoseEstimate` from `wifi_densepose_core`. Combined with
the prior Keypoint + KeypointType bindings (fd0568caa), this closes
ADR-117 §6 P2.

## Coverage

| Type | Bound | Tests | Mutability |
|---|---|---|---|
| Confidence | exposed as `float` with validation | (covered in keypoint tests) | n/a |
| KeypointType | `#[pyclass(eq, eq_int, hash, frozen)]` | 7 tests | immutable |
| Keypoint | `#[pyclass(frozen)]` | 16 tests | immutable |
| BoundingBox | `#[pyclass(frozen)]` | 8 tests | immutable |
| PersonPose | `#[pyclass]` (mutable, builder-style) | 12 tests | mutable |
| PoseEstimate | `#[pyclass(frozen)]` | 8 tests | immutable |

Smoke (P1) + new tests: **57/57 PASS** locally on Windows.

## What's deferred to P3

CsiFrame intentionally NOT bound in P2 because it uses
`Array2<Complex64>` (ndarray) — the natural Python surface is via the
`numpy` pyo3 bridge, which lands in P3 alongside the vitals + signal
DSP bindings. Binding CsiFrame without numpy interop would force
users to materialise lists of tuples which is a worse API than
`csi_frame.amplitude_array()` returning an ndarray.

## Design choices that affect the API surface

1. **PersonPose.keypoints() returns a dict keyed by KeypointType**
   instead of a fixed-length list with None slots. Pythonistas don't
   want to know the underlying storage is `[Option<Keypoint>; 17]`.

2. **PoseEstimate.id and .timestamp exposed as strings** (UUID + ISO)
   rather than as bound `FrameId` / `Timestamp` types. Users in
   notebooks rarely compare UUIDs structurally; strings are good
   enough for diagnostics and don't bloat the bindings.

3. **PersonPose is MUTABLE** (`#[pyclass]` without `frozen`) so users
   can build poses incrementally with `set_keypoint`/`set_bbox`/
   `set_id`. PoseEstimate is `frozen` because once constructed it
   represents a snapshot.

## Three PyO3 0.22 gotchas surfaced this iteration

1. `#[pymethods]` getters are NOT accessible from other Rust modules
   — need a separate `impl PyKeypoint { pub(crate) fn inner(&self)
   -> &Keypoint { ... } }` block for cross-module use.

2. `PyDict::new(py)` was removed in PyO3 0.21 → 0.22 in favour of
   `PyDict::new_bound(py)`. (Confusing because `Bound<'py, PyDict>`
   is the return type either way.)

3. `dict.set_item(K, V)` requires both K and V to impl
   `ToPyObject`. `#[pyclass]` types impl `IntoPy<PyObject>` but NOT
   `ToPyObject` — workaround: convert via `.into_py(py)` first, then
   `set_item(py_object_k, py_object_v)`.

Saved as PyO3 0.22 binding patterns memory at the horizon-tracker
level so future loop workers don't re-learn them.

## Local validation

\`\`\`
$ cd python && .venv/Scripts/python -m pytest tests/ -v
…
======================== 57 passed in 0.24s =========================
\`\`\`

Wheel size: still ~340 KB on Windows release build.

Refs #785, ADR-117 §6 (P2 done — ready for P3 vitals + signal DSP +
numpy bridge + witness v2).

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-24 11:03:02 -04:00
ruv fd0568caa1 feat(adr-117/p2): Keypoint + KeypointType bindings — 23 new tests (29/29 GREEN)
Lands the first chunk of P2: PyO3 bindings for `Keypoint` and
`KeypointType` from `wifi_densepose_core`. Bound types surface to
Python as `wifi_densepose.Keypoint` / `wifi_densepose.KeypointType`.

## Design choices that affect the API surface

1. **`Confidence` is NOT bound as a separate class.** Users hate
   wrapping a float in a constructor. Python-side, confidence is just
   a `float in [0.0, 1.0]`; the binding validates on construction
   (`ValueError` for out-of-range, matching the Rust core error).

2. **`KeypointType` is a `#[pyclass(eq, eq_int, hash, frozen)]` enum**
   — hashable so users can drop it into dicts/sets (the most common
   pattern in pose-analysis notebooks: `keypoints_by_type[k.type] = k`).

3. **`Keypoint.__init__` keyword-only `z`** so 2D users don't have to
   write `None` and 3D users get a clear named arg:
   `Keypoint(KeypointType.LeftWrist, 0.2, 0.4, 0.8, z=0.1)`.

4. **`Keypoint` is `#[pyclass(frozen)]`** — no in-place mutation. The
   Rust core type is immutable through Copy + Hash + Eq, and exposing
   setters from Python would create a copy-vs-reference inconsistency
   between languages.

## Files

- `python/src/bindings/keypoint.rs` — 220 lines of `#[pymethods]`
  wrappers + Rust↔Python enum round-trip
- `python/src/lib.rs` — `mod bindings { pub mod keypoint; }` +
  `bindings::keypoint::register(m)?` call from `#[pymodule]`
- `python/wifi_densepose/__init__.py` — re-exports `Keypoint` and
  `KeypointType` at the package root
- `python/tests/test_keypoint.py` — 23 tests covering:
  - 17-element COCO ordering of `KeypointType.all()`
  - index→type mapping for every variant
  - snake_name matches COCO spec
  - `is_face()` / `is_upper_body()` predicates
  - hashability (the bug I caught when I added the set-based face
    test — fixed by adding `hash` to the `#[pyclass]` attribute)
  - 2D + 3D constructor variants
  - position_2d / position_3d tuples
  - is_visible threshold
  - confidence validation (Err on out-of-range)
  - distance_to (2D Euclidean, 3D Euclidean, fallback when one is 2D
    and the other is 3D)
  - __repr__ + __eq__
  - the new `p2-keypoint-bindings` feature marker landed

## Local validation

\`\`\`
$ cd python && .venv/Scripts/python -m pytest tests/ -v
tests/test_smoke.py::test_package_imports PASSED
tests/test_smoke.py::test_version_string_well_formed PASSED
tests/test_smoke.py::test_rust_version_surfaced PASSED
tests/test_smoke.py::test_build_features_listed PASSED
tests/test_smoke.py::test_hello_returns_ok PASSED
tests/test_smoke.py::test_native_module_private PASSED
tests/test_keypoint.py::test_keypoint_type_all_returns_17 PASSED
…
======================== 29 passed in 0.06s =========================
\`\`\`

Wheel size after both bindings: still well under the 5 MB ADR §5.4
budget (release build with --strip on Windows: ~340 KB).

Also adds `python/.gitignore` to prevent the `.venv/` + `target/` +
`_native.abi3.pyd` artifacts from getting committed.

## What's left in P2

CsiFrame + PoseEstimate bindings land in the next iteration. They're
larger (CsiFrame has the subcarrier buffer; PoseEstimate has
17×Keypoint + BoundingBox + track_id + score). Pattern is now proven
so they go faster.

Refs #785, ADR-117 §6.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-24 10:54:34 -04:00
ruv 4ec5b166e6 fix(adr-117/p1): standalone Cargo.toml + python-source=. + #[pyo3(name=_native)] (P1 GREEN)
Three fixes to make maturin develop actually work locally:

1. `python/Cargo.toml` removed `*.workspace = true` inheritance —
   the python/ crate is intentionally outside the v2/ workspace
   (ADR-117 §5.2) so it needs every `[package]` field local.

2. `python/pyproject.toml` `python-source = "python"` was wrong
   because pyproject.toml lives at python/ — maturin was looking for
   python/python/. Changed to `python-source = "."` so the
   `wifi_densepose/` package directory sibling-to-pyproject is found.

3. `python/src/lib.rs` `#[pymodule] fn wifi_densepose_native` →
   `#[pymodule] #[pyo3(name = "_native")] fn wifi_densepose_native`.
   PyO3 generates `PyInit__native` from the pyo3-name attribute, which
   must match the `module-name` in pyproject.toml's [tool.maturin]
   block ("wifi_densepose._native"). Without this attribute the wheel
   builds but `import wifi_densepose._native` fails with
   ModuleNotFoundError.

## Local validation (P1 acceptance gate)

```
$ python -m venv .venv && .venv/Scripts/python -m pip install maturin pytest
$ VIRTUAL_ENV=… maturin develop --release
…
    Finished `release` profile [optimized] target(s)
📦 Built wheel for abi3 Python ≥ 3.10
🛠 Installed wifi-densepose-2.0.0a1

$ .venv/Scripts/python -c 'import wifi_densepose; print(wifi_densepose.__version__, wifi_densepose.__rust_version__, wifi_densepose.hello())'
2.0.0a1 2.0.0-alpha.1 ok

$ .venv/Scripts/python -m pytest tests/ -v
tests/test_smoke.py::test_package_imports PASSED
tests/test_smoke.py::test_version_string_well_formed PASSED
tests/test_smoke.py::test_rust_version_surfaced PASSED
tests/test_smoke.py::test_build_features_listed PASSED
tests/test_smoke.py::test_hello_returns_ok PASSED
tests/test_smoke.py::test_native_module_private PASSED
======================== 6 passed in 0.05s =========================
```

P1 closed. Moving to P2 (core type bindings).

Refs #785, ADR-117 §6.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-24 10:48:28 -04:00
ruv 5d90d4fef2 feat(adr-117/p1): scaffold python/ workspace — PyO3 + maturin + smoke tests (refs #785)
ADR-117 P1 — the python/ directory is now a working maturin-buildable
crate that produces the v2.x replacement for the legacy pure-Python
wifi-densepose==1.1.0 PyPI wheel.

## What lands

- `python/Cargo.toml` — PyO3 0.22 with `extension-module` + `abi3-py310`
  (one binary covers Python 3.10–3.13 per OS/arch — keeps the
  cibuildwheel matrix to 5 wheels per release, not 20). Depends on
  `wifi-densepose-core` from the existing v2/ workspace via relative
  path.

- `python/pyproject.toml` — maturin>=1.7 build backend with
  `python-source = "python"` and `module-name = "wifi_densepose._native"`
  so the compiled module loads as an internal underscore-private
  submodule of the user-facing `wifi_densepose` package. PEP 621
  metadata + classifiers + project URLs. Optional-deps:
  `wifi-densepose[client]` for the P4 WS/MQTT pure-Python layer,
  `wifi-densepose[dev]` for the test toolchain (pytest, ruff, mypy).

- `python/src/lib.rs` — minimal `#[pymodule] wifi_densepose_native`
  exporting `__rust_version__`, `__rust_build_tag__`,
  `__build_features__`, and a `hello()` smoke function. P2 will land
  the core type bindings here.

- `python/wifi_densepose/__init__.py` — pure-Python facade re-exporting
  the compiled module's symbols under their stable user-facing names.
  Docstring teaches the v1→v2 migration story up-front.

- `python/wifi_densepose/py.typed` — PEP 561 marker so `mypy --strict`
  in user code treats the wheel as fully typed (real stubs land in P2).

- `python/tests/test_smoke.py` — 6 P1 acceptance tests:
  1. package imports without error
  2. version string is PEP 440-compliant
  3. `__rust_version__` is reachable from Python (the diagnostic
     surface ADR-117 §5.2 promised)
  4. `__build_features__` lists `p1-scaffold` marker
  5. `wifi_densepose.hello()` returns "ok" (FFI round-trip)
  6. `wifi_densepose._native` is reachable but the leading underscore
     conveys "private; users should import the parent package"

- `python/README.md` — phase ledger, local build instructions
  (`maturin develop`), layout diagram.

## What's deferred to P2+

- Core type bindings (`CsiFrame`, `Keypoint`, `PoseEstimate`) — P2
- Vitals + signal DSP bindings + witness v2 — P3
- Pure-Python WS/MQTT client layer (`wifi_densepose[client]`) — P4
- cibuildwheel + PyPI publish — P5
- v1.99.0 tombstone — concurrent with P5

The new `python/` crate is intentionally OUTSIDE the v2/ Cargo
workspace — it has its own Cargo.toml with `[package]` not
`[workspace.package]` inheritance — to keep maturin's `python-source`
+ `module-name` config self-contained and to avoid forcing every
`cargo test --workspace` invocation in v2/ to compile pyo3.

Refs ADR-117 §5 (Detailed design) and §6 (Phased migration).
Refs #785 (tracking issue).

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-24 10:42:35 -04:00
ruv fd8b9c30e7 docs(adr-117): seed branch — ADR-117 pip-modernization spec + soul-signature research bundle
Two artifacts landing together on this new branch as the prerequisite
documentation for the v2.0.0 Python wheel modernization work:

1. **docs/adr/ADR-117-pip-wifi-densepose-modernization.md** (644 lines)
   — Plan to bring the 2025-published `wifi-densepose` PyPI package
   (last release v1.1.0, 2025-06-07, 11.5 months out of sync) up to
   the current Rust v2/ workspace SOTA. Recommends PyO3 + maturin
   with abi3-py310 (one binary covers Python 3.10–3.13 per OS/arch),
   first-wheel scope = core + vitals + signal crates (~5 MB), v1.99.0
   tombstone + 90-day un-yank window for v1.1.0, v2.0.0 hard break.
   Open questions catalogued; phases P1–P6+ laid out with concrete
   acceptance criteria.

2. **docs/research/soul/** (5 files, ~1,450 lines) — Soul Signature
   research spec: 7-channel electromagnetic biometric fingerprint
   (AETHER 128-dim + cardiac HR/HRV + cardiac waveform morphology +
   respiratory pattern + gait timing + skeletal proportions +
   subcarrier reflection profile), fused into one RVF graph file.
   Includes 60s scanning protocol, 5-layer security model,
   threat-model + mitigations, references to existing ADRs (014,
   021, 024, 027, 030, 039, 079, 106, 108, 109, 110, 115). Marked
   "Research Specification (Pre-Implementation)". Explicit "what
   this is NOT" disclaimers preempt pseudoscience drift; every
   discriminative-power claim either cites a measurement or is
   marked "open research; baseline TBD".

Branch off main at HEAD; ready for /loop 10m implementation
iterations.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-24 10:36:06 -04:00
172 changed files with 103 additions and 20366 deletions
+31 -36
View File
@@ -1,55 +1,50 @@
{
"running": true,
"startedAt": "2026-05-24T22:26:25.030Z",
"startedAt": "2026-03-09T15:26:00.921Z",
"workers": {
"map": {
"runCount": 64,
"successCount": 64,
"runCount": 49,
"successCount": 49,
"failureCount": 0,
"averageDurationMs": 136.171875,
"lastRun": "2026-05-25T06:07:33.387Z",
"lastStartedAt": "2026-05-25T06:07:33.381Z",
"nextRun": "2026-05-25T06:26:25.410Z",
"averageDurationMs": 1.2857142857142858,
"lastRun": "2026-02-28T16:13:19.194Z",
"nextRun": "2026-03-09T15:56:00.928Z",
"isRunning": false
},
"audit": {
"runCount": 72,
"successCount": 27,
"runCount": 45,
"successCount": 0,
"failureCount": 45,
"averageDurationMs": 26260.11111111111,
"lastRun": "2026-05-25T06:08:29.594Z",
"lastStartedAt": "2026-05-25T06:07:33.416Z",
"nextRun": "2026-05-25T06:18:32.928Z",
"averageDurationMs": 0,
"lastRun": "2026-03-09T15:43:00.933Z",
"nextRun": "2026-03-09T15:38:00.914Z",
"isRunning": false
},
"optimize": {
"runCount": 54,
"successCount": 9,
"failureCount": 45,
"averageDurationMs": 40303.377578766485,
"lastRun": "2026-05-25T05:59:05.330Z",
"lastStartedAt": "2026-05-25T05:54:05.318Z",
"nextRun": "2026-05-25T06:20:15.145Z",
"runCount": 34,
"successCount": 0,
"failureCount": 34,
"averageDurationMs": 0,
"lastRun": "2026-02-28T16:23:19.387Z",
"nextRun": "2026-03-09T15:45:00.915Z",
"isRunning": false
},
"consolidate": {
"runCount": 32,
"successCount": 32,
"runCount": 23,
"successCount": 23,
"failureCount": 0,
"averageDurationMs": 4.71875,
"lastRun": "2026-05-25T05:38:20.449Z",
"lastStartedAt": "2026-05-25T05:38:20.443Z",
"nextRun": "2026-05-25T06:32:25.248Z",
"averageDurationMs": 0.6521739130434783,
"lastRun": "2026-02-28T16:05:19.091Z",
"nextRun": "2026-03-09T16:02:00.918Z",
"isRunning": false
},
"testgaps": {
"runCount": 100,
"successCount": 63,
"failureCount": 37,
"averageDurationMs": 108604.0537328991,
"lastRun": "2026-05-25T06:11:52.529Z",
"lastStartedAt": "2026-05-25T06:07:33.390Z",
"nextRun": "2026-05-25T06:14:25.296Z",
"runCount": 27,
"successCount": 0,
"failureCount": 27,
"averageDurationMs": 0,
"lastRun": "2026-02-28T16:08:19.369Z",
"nextRun": "2026-03-09T15:54:00.920Z",
"isRunning": false
},
"predict": {
@@ -69,8 +64,8 @@
},
"config": {
"autoStart": false,
"logDir": "C:\\Users\\ruv\\Projects\\wifi-densepose\\.claude-flow\\logs",
"stateFile": "C:\\Users\\ruv\\Projects\\wifi-densepose\\.claude-flow\\daemon-state.json",
"logDir": "/Users/cohen/GitHub/ruvnet/RuView/.claude-flow/logs",
"stateFile": "/Users/cohen/GitHub/ruvnet/RuView/.claude-flow/daemon-state.json",
"maxConcurrent": 2,
"workerTimeoutMs": 300000,
"resourceThresholds": {
@@ -136,5 +131,5 @@
}
]
},
"savedAt": "2026-05-25T06:11:52.530Z"
"savedAt": "2026-03-09T15:43:00.933Z"
}
+3 -3
View File
@@ -1,11 +1,11 @@
{
"timestamp": "2026-05-25T06:07:33.385Z",
"projectRoot": "C:\\Users\\ruv\\Projects\\wifi-densepose",
"timestamp": "2026-02-28T16:13:19.193Z",
"projectRoot": "/home/user/wifi-densepose",
"structure": {
"hasPackageJson": false,
"hasTsConfig": false,
"hasClaudeConfig": true,
"hasClaudeFlow": true
},
"scannedAt": 1779689253386
"scannedAt": 1772295199193
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"timestamp": "2026-05-25T05:38:20.448Z",
"timestamp": "2026-02-28T16:05:19.091Z",
"patternsConsolidated": 0,
"memoryCleaned": 0,
"duplicatesRemoved": 0
-17
View File
@@ -1,17 +0,0 @@
{
"timestamp": "2026-05-25T05:59:05.405Z",
"mode": "local",
"memoryUsage": {
"rss": 9891840,
"heapTotal": 35598336,
"heapUsed": 26516560,
"external": 3952418,
"arrayBuffers": 55689
},
"uptime": 27163.5846658,
"optimizations": {
"cacheHitRate": 0.78,
"avgResponseTime": 45
},
"note": "Install Claude Code CLI for AI-powered optimization suggestions"
}
+9 -81
View File
@@ -1,84 +1,12 @@
{
"timestamp": "2026-05-25T06:08:29.589Z",
"mode": "headless",
"workerType": "audit",
"model": "haiku",
"durationMs": 56168,
"executionId": "audit_1779689253421_dfflmb",
"success": true,
"findings": {
"vulnerabilities": [
{
"severity": "high",
"file": ".claude/helpers/github-safe.js",
"line": 50,
"description": "Command injection vulnerability in execSync call. User-controlled arguments in `newArgs` are joined without shell escaping. An attacker can inject shell metacharacters (e.g., `; rm -rf /`) via the body content or through command/subcommand parameters. The temp file approach is safe, but the command construction `gh ${command} ${subcommand} ${newArgs.join(' ')}` allows shell injection.",
"example": "gh issue comment 123 'test`whoami`' would execute whoami"
},
{
"severity": "high",
"file": "scripts/csi-spectrogram.js",
"line": 45,
"description": "Sensitive credential exposure via command-line arguments. The `--seed-token` parameter is passed as a CLI argument, which is visible in process listings (ps aux output). This violates secure credential handling practices. Tokens should be read from environment variables or secure config files, not command-line args.",
"example": "node scripts/csi-spectrogram.js --seed-token secret_abc_123 exposes token in process list"
},
{
"severity": "medium",
"file": "scripts/apnea-detector.js",
"line": 71,
"description": "Unsafe buffer reading without comprehensive length validation. The code checks `buf.length` at 32 bytes (line 70) but then reads at fixed offsets (lines 72-76) without validating that each read stays within bounds. If a malformed packet is received, `readInt8/readUInt16LE/readUInt32LE` may read unintended data or zeros.",
"example": "A 33-byte buffer would pass the check but reading UInt32LE at offset 8 would go out of bounds"
},
{
"severity": "medium",
"file": "scripts/benchmark-rf-scan.js",
"line": 110,
"description": "Potential out-of-bounds buffer access in parseCSIFrame. While the bounds check at line 107 is present, the `nSubcarriers` value from the packet is used to calculate required buffer size without validation of the value itself. A maliciously crafted packet with extremely large nSubcarriers could cause memory issues.",
"example": "Packet with nSubcarriers=999999 would request excessive buffer allocation"
},
{
"severity": "medium",
"file": "scripts/csi-spectrogram.js",
"line": 39,
"description": "Unsafe URL construction with untrusted `seed-url` parameter. The `--seed-url` argument is used directly for HTTPS requests without validation. This could allow SSRF (Server-Side Request Forgery) or DNS rebinding attacks if an attacker controls the seed URL.",
"example": "node scripts/csi-spectrogram.js --seed-url http://internal.local:9000 could access internal services"
},
{
"severity": "low",
"file": ".claude/helpers/statusline.js",
"line": 140,
"description": "Shell command injection risk in execSync calls. Commands like `ps aux 2>/dev/null | grep -c agentic-flow` use grep patterns that could be vulnerable if any variables are interpolated (though currently hardcoded). The `execSync` with shell=true is generally risky.",
"example": "If any pattern becomes user-controlled: `grep -c ${pattern}` could inject shell metacharacters"
},
{
"severity": "low",
"file": ".claude/helpers/memory.js",
"line": 10,
"description": "Unvalidated JSON parsing. The code parses JSON from MEMORY_FILE without try-catch in the loadMemory function (catches error but doesn't validate structure). Malformed JSON or corrupted memory file could cause issues.",
"example": "Memory file with circular JSON structure could cause issues when stringifying"
},
{
"severity": "low",
"file": "scripts/device-fingerprint.js",
"line": 72,
"description": "Hardcoded device fingerprints and network configuration. While not a traditional 'hardcoded secret', the KNOWN_DEVICES array contains identifiable SSIDs and MAC addresses that could be used to correlate network infrastructure. This data should be externalized or sanitized.",
"example": "SSID 'ruv.net' and 'Cohen-Guest' could identify specific installations"
}
],
"riskScore": 42,
"recommendations": [
"**CRITICAL**: Replace `execSync` command construction in github-safe.js with proper shell escaping using `child_process.execFile()` instead of `execSync()`, or use the `shell: false` option with array arguments to avoid shell parsing entirely.",
"**CRITICAL**: Move `--seed-token` from CLI arguments to environment variable `SEED_TOKEN` in csi-spectrogram.js. Update documentation to instruct users: `export SEED_TOKEN=...` instead of passing via CLI.",
"**HIGH**: Add comprehensive buffer bounds validation in all UDP packet parsing functions (apnea-detector.js, benchmark-rf-scan.js, etc.). Validate both the buffer length AND the parsed header values before using them in calculations.",
"**HIGH**: Validate and sanitize the `--seed-url` parameter in csi-spectrogram.js. Whitelist allowed domains or restrict to localhost/internal IPs only. Add URL scheme validation (https only).",
"**MEDIUM**: Replace hardcoded device fingerprints (KNOWN_DEVICES) with externalized configuration or environment variables. Document that this data contains identifiable network information.",
"**MEDIUM**: Add input validation to `parseArgs()` results in all scripts. Validate numeric ranges, file paths, and enum values before use.",
"**LOW**: Wrap JSON.parse() calls in try-catch blocks throughout (memory.js, session.js) with explicit error handling and recovery.",
"**LOW**: Audit all uses of `require()` with dynamic paths. Ensure paths are always derived from fixed `__dirname` and not user-controlled.",
"**LOW**: Remove or sandbox the ability to pass arbitrary URLs via CLI. Consider using a configuration file (YAML/JSON) for endpoint URLs instead.",
"**INFO**: Add a pre-commit hook to detect hardcoded credentials using tools like `detect-secrets` or `truffleHog`."
]
"timestamp": "2026-03-06T13:17:27.368Z",
"mode": "local",
"checks": {
"envFilesProtected": true,
"gitIgnoreExists": true,
"noHardcodedSecrets": true
},
"rawOutputPreview": "# Security Audit Report — wifi-densepose\n\n```json\n{\n \"vulnerabilities\": [\n {\n \"severity\": \"high\",\n \"file\": \".claude/helpers/github-safe.js\",\n \"line\": 50,\n \"description\": \"Command injection vulnerability in execSync call. User-controlled arguments in `newArgs` are joined without shell escaping. An attacker can inject shell metacharacters (e.g., `; rm -rf /`) via the body content or through command/subcommand parameters. The temp file approach is safe, but the command construction `gh ${command} ${subcommand} ${newArgs.join(' ')}` allows shell injection.\",\n \"example\": \"gh issue comment 123 'test`whoami`' would execute whoami\"\n },\n {\n \"severity\": \"high\",\n \"file\": \"scripts/csi-spectrogram.js\",\n \"line\": 45,\n \"description\": \"Sensitive credential exposure via command-line arguments. The `--seed-token` parameter is passed as a CLI argument, which is visible in process listings (ps aux output). This violates secure credential handling practices. Tokens should be read from environment variables or secure config files, not command-line args.\",\n \"example\": \"node scripts/csi-spectrogram.js --seed-token secret_abc_123 exposes token in process list\"\n },\n {\n \"severity\": \"medium\",\n \"file\": \"scripts/apnea-detector.js\",\n \"line\": 71,\n \"description\": \"Unsafe buffer reading without comprehensive length validation. The code checks `buf.length` at 32 bytes (line 70) but then reads at fixed offsets (lines 72-76) without validating that each read stays within bounds. If a malformed packet is received, `readInt8/readUInt16LE/readUInt32LE` may read unintended data or zeros.\",\n \"example\": \"A 33-byte buffer would pass the check but reading UInt32LE at offset 8 would go out of bounds\"\n },\n {\n \"severity\": \"medium\",\n \"file\": \"scripts/benchmark-rf-scan.js\",\n \"line\": 110,\n \"description\": \"Potential out-of-bounds buffer access in parseCSIFrame. While the bounds check at line 107 is pres",
"rawOutputLength": 7077
"riskLevel": "low",
"recommendations": [],
"note": "Install Claude Code CLI for AI-powered security analysis"
}
-106
View File
@@ -1,106 +0,0 @@
{
"timestamp": "2026-05-25T06:11:52.519Z",
"mode": "headless",
"workerType": "testgaps",
"model": "sonnet",
"durationMs": 259124,
"executionId": "testgaps_1779689253395_srltd5",
"success": true,
"findings": {
"sections": [
{
"title": "Test Coverage Gap Analysis — wifi-densepose",
"content": "\n",
"level": 2
},
{
"title": "Coverage Summary by Crate",
"content": "\n| Crate | Tests Found | Status | Priority |\n|-------|-------------|--------|----------|\n| `wifi-densepose-core` | 26 inline | Good | Low |\n| `wifi-densepose-signal` | ~60 (validation only) | Moderate | **High** |\n| `wifi-densepose-nn` | **0** | Critical | **P1** |\n| `wifi-densepose-train` | ~60 (config/dataset) | Moderate | High |\n| `wifi-densepose-mat` | 1 integration test | Critical | **P1** |\n| `wifi-densepose-ruvector` | **0** | Critical | **P1** |\n| `wifi-densepose-sensing-server` | 4 integration tests | Moderate | High |\n| `wifi-densepose-wasm` | 3 compliance tests | Low | Low |\n\n---\n\n",
"level": 3
},
{
"title": "Tier 1: Critical Gaps",
"content": "\n",
"level": 2
},
{
"title": "1. `wifi-densepose-nn` — Zero test coverage",
"content": "\nEvery public API is untested. Place these at `v2/crates/wifi-densepose-nn/tests/inference_tests.rs`:\n\n```rust\n// v2/crates/wifi-densepose-nn/tests/inference_tests.rs\n\n#[cfg(test)]\nmod tensor_tests {\n use wifi_densepose_nn::tensor::Tensor;\n\n #[test]\n fn tensor_shape_mismatch_returns_error() {\n // data has 6 elements but shape claims 3×3=9\n let result = Tensor::new(vec![1.0f32; 6], &[3, 3]);\n assert!(result.is_err(), \"shape mismatch must be rejected\");\n }\n\n #[test]\n fn tensor_empty_data_returns_error() {\n let result = Tensor::new(vec![], &[0]);\n assert!(result.is_err());\n }\n\n #[test]\n fn tensor_nan_values_are_detected() {\n let t = Tensor::new(vec![f32::NAN, 1.0, 2.0], &[3]).unwrap();\n assert!(t.has_nan(), \"NaN in data must be detectable\");\n }\n\n #[test]\n fn tensor_inf_values_are_detected() {\n let t = Tensor::new(vec![f32::INFINITY, 1.0], &[2]).unwrap();\n assert!(t.has_inf());\n }\n}\n\n#[cfg(test)]\nmod modality_translator_tests {\n use wifi_densepose_nn::translator::ModalityTranslator;\n\n #[test]\n fn translator_rejects_wrong_subcarrier_count() {\n // standard expects 56 subcarriers; feed 57\n let csi = vec![0.0f32; 57 * 3]; // 57 subcarriers × 3 antennas\n let translator = ModalityTranslator::default();\n let result = translator.translate(&csi, 57, 3);\n assert!(result.is_err());\n }\n\n #[test]\n fn translator_handles_all_zeros() {\n let csi = vec![0.0f32; 56 * 3];\n let translator = ModalityTranslator::default();\n let result = translator.translate(&csi, 56, 3);\n // zero input should produce some output without panic\n assert!(result.is_ok());\n }\n}\n\n#[cfg(test)]\nmod inference_engine_tests {\n use wifi_densepose_nn::inference::InferenceEngine;\n\n #[test]\n fn load_nonexistent_model_returns_error() {\n let result = InferenceEngine::from_path(\"/nonexistent/model.onnx\");\n assert!(result.is_err());\n }\n\n #[test]\n fn load_corrupted_bytes_returns_error() {\n let tmp = tempfile::NamedTempFile::new().unwrap();\n std::fs::write(tmp.path(), b\"not a valid onnx file\").unwrap();\n let result = InferenceEngine::from_path(tmp.path());\n assert!(result.is_err());\n }\n\n #[test]\n fn batch_size_zero_returns_error() {\n // can't run inference on an empty batch\n // requires a valid model; skip if no model file in test fixtures\n // use #[ignore] or a feature flag for CI\n }\n}\n```\n\n---\n\n",
"level": 3
},
{
"title": "2. `wifi-densepose-mat` — Disaster response safety gaps",
"content": "\nPlace at `v2/crates/wifi-densepose-mat/tests/`:\n\n```rust\n// v2/crates/wifi-densepose-mat/tests/detection_edge_cases.rs\n\n#[cfg(test)]\nmod breathing_rate_edge_cases {\n use wifi_densepose_mat::detection::breathing::BreathingDetector;\n\n #[test]\n fn zero_bpm_is_classified_critical() {\n let detector = BreathingDetector::default();\n // flat-line signal — no breathing detected\n let signal = vec![0.0f32; 1000];\n let result = detector.classify(&signal).unwrap();\n assert_eq!(result.triage_category, TriageCategory::Immediate);\n }\n\n #[test]\n fn agonal_breathing_rate_triggers_immediate() {\n // < 6 BPM is agonal; simulate 3 BPM signal\n let detector = BreathingDetector::default();\n let signal = generate_breathing_signal(3.0, 1000, 100.0); // 3 BPM, 1000 samples @ 100 Hz\n let result = detector.classify(&signal).unwrap();\n assert_eq!(result.triage_category, TriageCategory::Immediate);\n }\n\n #[test]\n fn normal_breathing_is_classified_minor() {\n let detector = BreathingDetector::default();\n let signal = generate_breathing_signal(15.0, 1000, 100.0); // 15 BPM\n let result = detector.classify(&signal).unwrap();\n assert_eq!(result.triage_category, TriageCategory::Minor);\n }\n\n #[test]\n fn all_nan_signal_returns_error_not_panic() {\n let detector = BreathingDetector::default();\n let signal = vec![f32::NAN; 1000];\n let result = detector.classify(&signal);\n assert!(result.is_err(), \"NaN input must be caught, not panic\");\n }\n\n fn generate_breathing_signal(bpm: f32, samples: usize, sample_rate: f32) -> Vec<f32> {\n let freq = bpm / 60.0;\n (0..samples)\n .map(|i| (2.0 * std::f32::consts::PI * freq * i as f32 / sample_rate).sin())\n .collect()\n }\n}\n\n#[cfg(test)]\nmod alert_deduplication {\n use wifi_densepose_mat::alerting::{AlertDispatcher, Alert, TriageCategory};\n use std::time::Duration;\n\n #[test]\n fn duplicate_alerts_within_window_are_suppressed() {\n let mut dispatcher = AlertDispatcher::new();\n let alert = Alert::new(\"survivor-1\", TriageCategory::Immediate);\n dispatcher.dispatch(alert.clone());\n dispatcher.dispatch(alert.clone()); // same survivor, same category\n assert_eq!(dispatcher.queued_count(), 1, \"duplicate must be deduplicated\");\n }\n\n #[test]\n fn escalation_from_minor_to_immediate_is_forwarded() {\n let mut dispatcher = AlertDispatcher::new();\n dispatcher.dispatch(Alert::new(\"survivor-1\", TriageCategory::Minor));\n dispatcher.dispatch(Alert::new(\"survivor-1\", TriageCategory::Immediate));\n // escalation is not a duplicate — must pass through\n assert!(dispatcher.last_alert_for(\"survivor-1\").map(|a| a.category) == Some(TriageCategory::Immediate));\n }\n}\n\n#[cfg(test)]\nmod kalman_tracker_edge_cases {\n use wifi_densepose_mat::tracking::KalmanTracker;\n\n #[test]\n fn position_jump_does_not_corrupt_state() {\n let mut tracker = KalmanTracker::new();\n tracker.update([1.0, 1.0, 0.5]); // initial position\n tracker.update([50.0, 50.0, 0.5]); // physically impossible jump\n let pos = tracker.estimated_position();\n // should not panic; should clamp or flag anomaly\n assert!(pos.iter().all(|v| v.is_finite()));\n }\n\n #[test]\n fn lost_track_resumes_on_re_detection() {\n let mut tracker = KalmanTracker::new();\n tracker.update([1.0, 1.0, 0.5]);\n // simulate 10 missed frames\n for _ in 0..10 { tracker.predict(); }\n assert_eq!(tracker.state(), TrackState::Lost);\n tracker.update([1.1, 1.1, 0.5]); // re-detected nearby\n assert_eq!(tracker.state(), TrackState::Confirmed);\n }\n}\n```\n\n---\n\n",
"level": 3
},
{
"title": "3. `wifi-densepose-ruvector` — Zero coverage on all 5 integration modules",
"content": "\n```rust\n// v2/crates/wifi-densepose-ruvector/tests/viewpoint_tests.rs\n\n#[cfg(test)]\nmod attention_tests {\n use wifi_densepose_ruvector::viewpoint::attention::CrossViewpointAttention;\n\n #[test]\n fn attention_weights_sum_to_one() {\n let attn = CrossViewpointAttention::new(3); // 3 viewpoints\n let features = vec![[1.0f32; 64], [2.0f32; 64], [3.0f32; 64]];\n let weights = attn.compute_weights(&features);\n let sum: f32 = weights.iter().sum();\n assert!((sum - 1.0).abs() < 1e-5, \"attention must be a probability distribution\");\n }\n\n #[test]\n fn single_viewpoint_gets_full_weight() {\n let attn = CrossViewpointAttention::new(1);\n let features = vec![[1.0f32; 64]];\n let weights = attn.compute_weights(&features);\n assert!((weights[0] - 1.0).abs() < 1e-6);\n }\n\n #[test]\n fn zero_feature_vectors_do_not_produce_nan() {\n let attn = CrossViewpointAttention::new(2);\n let features = vec![[0.0f32; 64], [0.0f32; 64]];\n let weights = attn.compute_weights(&features);\n assert!(weights.iter().all(|w| w.is_finite()));\n }\n}\n\n#[cfg(test)]\nmod sketch_tests {\n use wifi_densepose_ruvector::sketch::WireSketch;\n\n #[test]\n fn round_trip_serialization() {\n let sketch = WireSketch::from_keypoints(&[[0.5f32, 0.5], [0.3, 0.7]]);\n let bytes = sketch.to_bytes();\n let restored = WireSketch::from_bytes(&bytes).unwrap();\n assert_eq!(sketch, restored);\n }\n\n #[test]\n fn deserialize_truncated_bytes_returns_error() {\n let sketch = WireSketch::from_keypoints(&[[0.5f32, 0.5]]);\n let mut bytes = sketch.to_bytes();\n bytes.truncate(bytes.len() / 2); // truncate halfway\n assert!(WireSketch::from_bytes(&bytes).is_err());\n }\n\n #[test]\n fn empty_keypoint_list_is_handled() {\n let sketch = WireSketch::from_keypoints(&[]);\n assert_eq!(sketch.keypoint_count(), 0);\n }\n}\n```\n\n---\n\n",
"level": 3
},
{
"title": "Tier 2: Signal Processing Gaps",
"content": "\n",
"level": 2
},
{
"title": "4. `wifi-densepose-signal` — RuvSense module untested",
"content": "\n```rust\n// v2/crates/wifi-densepose-signal/tests/ruvsense_tests.rs\n\n#[cfg(test)]\nmod coherence_gate_tests {\n use wifi_densepose_signal::ruvsense::coherence_gate::{CoherenceGate, GateDecision};\n\n #[test]\n fn high_coherence_signal_is_accepted() {\n let gate = CoherenceGate::new(0.7); // threshold = 0.7\n let decision = gate.evaluate(0.95);\n assert_eq!(decision, GateDecision::Accept);\n }\n\n #[test]\n fn low_coherence_signal_is_rejected() {\n let gate = CoherenceGate::new(0.7);\n let decision = gate.evaluate(0.3);\n assert_eq!(decision, GateDecision::Reject);\n }\n\n #[test]\n fn borderline_coherence_triggers_recalibrate() {\n let gate = CoherenceGate::new(0.7);\n let decision = gate.evaluate(0.68); // just below threshold\n assert_eq!(decision, GateDecision::Recalibrate);\n }\n}\n\n#[cfg(test)]\nmod phase_align_tests {\n use wifi_densepose_signal::ruvsense::phase_align::PhaseAligner;\n\n #[test]\n fn phase_at_plus_pi_does_not_wrap_incorrectly() {\n let aligner = PhaseAligner::new();\n let phases = vec![std::f32::consts::PI - 0.001, std::f32::consts::PI + 0.001];\n let aligned = aligner.align(&phases);\n // jump across ±π boundary must be handled continuously\n let diff = (aligned[1] - aligned[0]).abs();\n assert!(diff < 0.01, \"phase jump at ±π must be < 0.01 rad after alignment\");\n }\n\n #[test]\n fn single_phase_value_aligns_to_itself() {\n let aligner = PhaseAligner::new();\n let phases = vec![1.5f32];\n let aligned = aligner.align(&phases);\n assert_eq!(aligned.len(), 1);\n assert!((aligned[0] - 1.5).abs() < 1e-6);\n }\n\n #[test]\n fn empty_phase_array_returns_empty() {\n let aligner = PhaseAligner::new();\n let aligned = aligner.align(&[]);\n assert!(aligned.is_empty());\n }\n}\n\n#[cfg(test)]\nmod adversarial_detection_tests {\n use wifi_densepose_signal::ruvsense::adversarial::AdversarialDetector;\n\n #[test]\n fn physically_impossible_amplitude_is_flagged() {\n let detector = AdversarialDetector::new();\n // WiFi amplitude cannot exceed hardware saturation level\n let frame = vec![1e9f32; 56]; // absurdly large\n assert!(detector.is_suspicious(&frame));\n }\n\n #[test]\n fn normal_amplitude_range_passes() {\n let detector = AdversarialDetector::new();\n let frame = vec![0.5f32; 56]; // typical normalized value\n assert!(!detector.is_suspicious(&frame));\n }\n\n #[test]\n fn multi_link_inconsistency_is_detected() {\n // link A reports body moving right; link B reports no motion\n // physically inconsistent — flag as adversarial\n let detector = AdversarialDetector::new();\n let result = detector.check_multi_link_consistency(\n &[1.0, 2.0, 3.0], // link A\n &[0.0, 0.0, 0.0], // link B (no motion)\n );\n assert!(result.is_inconsistent());\n }\n}\n```\n\n---\n\n",
"level": 3
},
{
"title": "Tier 2: Training Pipeline Gaps",
"content": "\n",
"level": 2
},
{
"title": "5. `wifi-densepose-train` — Geometry encoder and rapid adaptation untested",
"content": "\n```rust\n// v2/crates/wifi-densepose-train/tests/test_geometry.rs\n\n#[cfg(test)]\nmod film_layer_tests {\n use wifi_densepose_train::geometry::FilmLayer;\n\n #[test]\n fn film_layer_output_shape_matches_input() {\n let film = FilmLayer::new(64, 32); // 64-dim features, 32-dim condition\n let features = vec![0.5f32; 64];\n let condition = vec![1.0f32; 32];\n let output = film.forward(&features, &condition).unwrap();\n assert_eq!(output.len(), 64, \"FiLM output must match feature dimensionality\");\n }\n\n #[test]\n fn film_layer_zero_condition_acts_as_identity() {\n let film = FilmLayer::new(64, 32);\n let features = vec![1.0f32; 64];\n let zero_condition = vec![0.0f32; 32];\n let output = film.forward(&features, &zero_condition).unwrap();\n // scale=1, shift=0 → identity; output ≈ input\n for (o, f) in output.iter().zip(features.iter()) {\n assert!((o - f).abs() < 0.1, \"zero condition should approximate identity\");\n }\n }\n}\n\n// v2/crates/wifi-densepose-train/tests/test_rapid_adapt.rs\n\n#[cfg(test)]\nmod rapid_adaptation_tests {\n use wifi_densepose_train::rapid_adapt::RapidAdapter;\n\n #[test]\n fn adapter_updates_on_single_sample() {\n let mut adapter = RapidAdapter::new(5); // 5 adaptation steps\n let csi_sample = vec![0.1f32; 56 * 3];\n let pose_label = vec![0.5f32; 17 * 2]; // 17 keypoints × (x, y)\n let result = adapter.adapt_step(&csi_sample, &pose_label);\n assert!(result.is_ok());\n }\n\n #[test]\n fn adapter_with_zero_steps_is_no_op() {\n let adapter = RapidAdapter::new(0);\n // 0 adaptation steps → weights unchanged\n let initial_weights = adapter.clone_weights();\n let _ = adapter.adapt_step(&vec![0.1f32; 168], &vec![0.5f32; 34]);\n assert_eq!(adapter.clone_weights(), initial_weights);\n }\n}\n```\n\n---\n\n",
"level": 3
},
{
"title": "Tier 3: Server Integration Gaps",
"content": "\n",
"level": 2
},
{
"title": "6. `wifi-densepose-sensing-server` — Auth and semantic analyzers",
"content": "\n```rust\n// v2/crates/wifi-densepose-sensing-server/tests/auth_tests.rs\n\n#[cfg(test)]\nmod bearer_auth_tests {\n use wifi_densepose_sensing_server::auth::{BearerValidator, TokenError};\n\n #[test]\n fn missing_authorization_header_returns_unauthorized() {\n let validator = BearerValidator::new(\"secret-token\");\n let result = validator.validate(None);\n assert!(matches!(result, Err(TokenError::Missing)));\n }\n\n #[test]\n fn wrong_token_is_rejected() {\n let validator = BearerValidator::new(\"correct-token\");\n let result = validator.validate(Some(\"Bearer wrong-token\"));\n assert!(matches!(result, Err(TokenError::Invalid)));\n }\n\n #[test]\n fn malformed_header_without_bearer_prefix_is_rejected() {\n let validator = BearerValidator::new(\"token\");\n let result = validator.validate(Some(\"token\")); // missing \"Bearer \" prefix\n assert!(matches!(result, Err(TokenError::Malformed)));\n }\n\n #[test]\n fn correct_token_is_accepted() {\n let validator = BearerValidator::new(\"correct-token\");\n let result = validator.validate(Some(\"Bearer correct-token\"));\n assert!(result.is_ok());\n }\n}\n\n// v2/crates/wifi-densepose-sensing-server/tests/semantic_tests.rs\n\n#[cfg(test)]\nmod fall_detection_tests {\n use wifi_densepose_sensing_server::semantic::fall_detector::FallDetector;\n\n #[test]\n fn no_motion_does_not_trigger_fall() {\n let mut detector = FallDetector::new();\n for _ in 0..30 { // 30 frames of stillness\n detector.update_pose(stationary_pose());\n }\n assert!(!detector.fall_detected());\n }\n\n #[test]\n fn rapid_downward_velocity_triggers_fall() {\n let mut detector = FallDetector::new();\n // simulate person going from standing (y=1.7m) to prone (y=0.3m) in 3 frames\n for (frame, y) in [(0, 1.7f32), (1, 1.0), (2, 0.3)] {\n detector.update_pose(pose_at_height(y));\n }\n assert!(detector.fall_detected());\n }\n\n #[test]\n fn sitting_down_slowly_does_not_trigger_fall() {\n let mut detector = FallDetector::new();\n // gradual height decrease over 30 frames is sitting, not falling\n for i in 0..30 {\n let y = 1.7f32 - (i as f32 * 0.04); // ~1.2m drop over 30 frames\n detector.update_pose(pose_at_height(y));\n }\n assert!(!detector.fall_detected());\n }\n}\n```\n\n---\n\n",
"level": 3
},
{
"title": "Cross-Cutting Gap Summary",
"content": "| Gap Category | Severity | Affects | Recommended Action |\n|---|---|---|---|\n| `wifi-densepose-nn` has 0 tests | **Critical** | Inference pipeline | Add `tests/inference_tests.rs` per skeleton above |\n| `wifi-densepose-ruvector` has 0 tests | **Critical** | Viewpoint fusion, sketches | Add `tests/viewpoint_tests.rs` |\n| MAT disaster response missing edge cases | **Critical** | 0 BPM, agonal breathing, dedup | Add `tests/detection_edge_cases.rs` |\n| Signal RuvSense 28 modules untested | High | Core sensing logic | Add `tests/ruvsense_tests.rs` |\n| NN error paths (bad model files, OOM) | High | Production reliability | Add error path tests to nn |\n| Train geometry + rapid adapt = 0 tests | High | Domain adaptation | Add `tests/test_geometry.rs` |\n| Server auth token validation | High | Security boundary | Add `tests/auth_tests.rs` |\n| NaN/Inf propagation in f32 pipelines | High | All numeric crates | Add boundary tests per module |\n| Concurrent state under Arc<Mutex> | Medium | sensing-server, mat | Add contention tests |\n\nThe highest-ROI starting point is `wifi-densepose-nn` and `wifi-densepose-mat` — the nn crate has zero tests on the core inference pipeline, and mat covers life-safety scenarios where classification errors have real consequences.",
"level": 2
}
],
"codeBlocks": [
{
"language": "rust",
"code": "// v2/crates/wifi-densepose-nn/tests/inference_tests.rs\n\n#[cfg(test)]\nmod tensor_tests {\n use wifi_densepose_nn::tensor::Tensor;\n\n #[test]\n fn tensor_shape_mismatch_returns_error() {\n // data has 6 elements but shape claims 3×3=9\n let result = Tensor::new(vec![1.0f32; 6], &[3, 3]);\n assert!(result.is_err(), \"shape mismatch must be rejected\");\n }\n\n #[test]\n fn tensor_empty_data_returns_error() {\n let result = Tensor::new(vec![], &[0]);\n assert!(result.is_err());\n }\n\n #[test]\n fn tensor_nan_values_are_detected() {\n let t = Tensor::new(vec![f32::NAN, 1.0, 2.0], &[3]).unwrap();\n assert!(t.has_nan(), \"NaN in data must be detectable\");\n }\n\n #[test]\n fn tensor_inf_values_are_detected() {\n let t = Tensor::new(vec![f32::INFINITY, 1.0], &[2]).unwrap();\n assert!(t.has_inf());\n }\n}\n\n#[cfg(test)]\nmod modality_translator_tests {\n use wifi_densepose_nn::translator::ModalityTranslator;\n\n #[test]\n fn translator_rejects_wrong_subcarrier_count() {\n // standard expects 56 subcarriers; feed 57\n let csi = vec![0.0f32; 57 * 3]; // 57 subcarriers × 3 antennas\n let translator = ModalityTranslator::default();\n let result = translator.translate(&csi, 57, 3);\n assert!(result.is_err());\n }\n\n #[test]\n fn translator_handles_all_zeros() {\n let csi = vec![0.0f32; 56 * 3];\n let translator = ModalityTranslator::default();\n let result = translator.translate(&csi, 56, 3);\n // zero input should produce some output without panic\n assert!(result.is_ok());\n }\n}\n\n#[cfg(test)]\nmod inference_engine_tests {\n use wifi_densepose_nn::inference::InferenceEngine;\n\n #[test]\n fn load_nonexistent_model_returns_error() {\n let result = InferenceEngine::from_path(\"/nonexistent/model.onnx\");\n assert!(result.is_err());\n }\n\n #[test]\n fn load_corrupted_bytes_returns_error() {\n let tmp = tempfile::NamedTempFile::new().unwrap();\n std::fs::write(tmp.path(), b\"not a valid onnx file\").unwrap();\n let result = InferenceEngine::from_path(tmp.path());\n assert!(result.is_err());\n }\n\n #[test]\n fn batch_size_zero_returns_error() {\n // can't run inference on an empty batch\n // requires a valid model; skip if no model file in test fixtures\n // use #[ignore] or a feature flag for CI\n }\n}"
},
{
"language": "rust",
"code": "// v2/crates/wifi-densepose-mat/tests/detection_edge_cases.rs\n\n#[cfg(test)]\nmod breathing_rate_edge_cases {\n use wifi_densepose_mat::detection::breathing::BreathingDetector;\n\n #[test]\n fn zero_bpm_is_classified_critical() {\n let detector = BreathingDetector::default();\n // flat-line signal — no breathing detected\n let signal = vec![0.0f32; 1000];\n let result = detector.classify(&signal).unwrap();\n assert_eq!(result.triage_category, TriageCategory::Immediate);\n }\n\n #[test]\n fn agonal_breathing_rate_triggers_immediate() {\n // < 6 BPM is agonal; simulate 3 BPM signal\n let detector = BreathingDetector::default();\n let signal = generate_breathing_signal(3.0, 1000, 100.0); // 3 BPM, 1000 samples @ 100 Hz\n let result = detector.classify(&signal).unwrap();\n assert_eq!(result.triage_category, TriageCategory::Immediate);\n }\n\n #[test]\n fn normal_breathing_is_classified_minor() {\n let detector = BreathingDetector::default();\n let signal = generate_breathing_signal(15.0, 1000, 100.0); // 15 BPM\n let result = detector.classify(&signal).unwrap();\n assert_eq!(result.triage_category, TriageCategory::Minor);\n }\n\n #[test]\n fn all_nan_signal_returns_error_not_panic() {\n let detector = BreathingDetector::default();\n let signal = vec![f32::NAN; 1000];\n let result = detector.classify(&signal);\n assert!(result.is_err(), \"NaN input must be caught, not panic\");\n }\n\n fn generate_breathing_signal(bpm: f32, samples: usize, sample_rate: f32) -> Vec<f32> {\n let freq = bpm / 60.0;\n (0..samples)\n .map(|i| (2.0 * std::f32::consts::PI * freq * i as f32 / sample_rate).sin())\n .collect()\n }\n}\n\n#[cfg(test)]\nmod alert_deduplication {\n use wifi_densepose_mat::alerting::{AlertDispatcher, Alert, TriageCategory};\n use std::time::Duration;\n\n #[test]\n fn duplicate_alerts_within_window_are_suppressed() {\n let mut dispatcher = AlertDispatcher::new();\n let alert = Alert::new(\"survivor-1\", TriageCategory::Immediate);\n dispatcher.dispatch(alert.clone());\n dispatcher.dispatch(alert.clone()); // same survivor, same category\n assert_eq!(dispatcher.queued_count(), 1, \"duplicate must be deduplicated\");\n }\n\n #[test]\n fn escalation_from_minor_to_immediate_is_forwarded() {\n let mut dispatcher = AlertDispatcher::new();\n dispatcher.dispatch(Alert::new(\"survivor-1\", TriageCategory::Minor));\n dispatcher.dispatch(Alert::new(\"survivor-1\", TriageCategory::Immediate));\n // escalation is not a duplicate — must pass through\n assert!(dispatcher.last_alert_for(\"survivor-1\").map(|a| a.category) == Some(TriageCategory::Immediate));\n }\n}\n\n#[cfg(test)]\nmod kalman_tracker_edge_cases {\n use wifi_densepose_mat::tracking::KalmanTracker;\n\n #[test]\n fn position_jump_does_not_corrupt_state() {\n let mut tracker = KalmanTracker::new();\n tracker.update([1.0, 1.0, 0.5]); // initial position\n tracker.update([50.0, 50.0, 0.5]); // physically impossible jump\n let pos = tracker.estimated_position();\n // should not panic; should clamp or flag anomaly\n assert!(pos.iter().all(|v| v.is_finite()));\n }\n\n #[test]\n fn lost_track_resumes_on_re_detection() {\n let mut tracker = KalmanTracker::new();\n tracker.update([1.0, 1.0, 0.5]);\n // simulate 10 missed frames\n for _ in 0..10 { tracker.predict(); }\n assert_eq!(tracker.state(), TrackState::Lost);\n tracker.update([1.1, 1.1, 0.5]); // re-detected nearby\n assert_eq!(tracker.state(), TrackState::Confirmed);\n }\n}"
},
{
"language": "rust",
"code": "// v2/crates/wifi-densepose-ruvector/tests/viewpoint_tests.rs\n\n#[cfg(test)]\nmod attention_tests {\n use wifi_densepose_ruvector::viewpoint::attention::CrossViewpointAttention;\n\n #[test]\n fn attention_weights_sum_to_one() {\n let attn = CrossViewpointAttention::new(3); // 3 viewpoints\n let features = vec![[1.0f32; 64], [2.0f32; 64], [3.0f32; 64]];\n let weights = attn.compute_weights(&features);\n let sum: f32 = weights.iter().sum();\n assert!((sum - 1.0).abs() < 1e-5, \"attention must be a probability distribution\");\n }\n\n #[test]\n fn single_viewpoint_gets_full_weight() {\n let attn = CrossViewpointAttention::new(1);\n let features = vec![[1.0f32; 64]];\n let weights = attn.compute_weights(&features);\n assert!((weights[0] - 1.0).abs() < 1e-6);\n }\n\n #[test]\n fn zero_feature_vectors_do_not_produce_nan() {\n let attn = CrossViewpointAttention::new(2);\n let features = vec![[0.0f32; 64], [0.0f32; 64]];\n let weights = attn.compute_weights(&features);\n assert!(weights.iter().all(|w| w.is_finite()));\n }\n}\n\n#[cfg(test)]\nmod sketch_tests {\n use wifi_densepose_ruvector::sketch::WireSketch;\n\n #[test]\n fn round_trip_serialization() {\n let sketch = WireSketch::from_keypoints(&[[0.5f32, 0.5], [0.3, 0.7]]);\n let bytes = sketch.to_bytes();\n let restored = WireSketch::from_bytes(&bytes).unwrap();\n assert_eq!(sketch, restored);\n }\n\n #[test]\n fn deserialize_truncated_bytes_returns_error() {\n let sketch = WireSketch::from_keypoints(&[[0.5f32, 0.5]]);\n let mut bytes = sketch.to_bytes();\n bytes.truncate(bytes.len() / 2); // truncate halfway\n assert!(WireSketch::from_bytes(&bytes).is_err());\n }\n\n #[test]\n fn empty_keypoint_list_is_handled() {\n let sketch = WireSketch::from_keypoints(&[]);\n assert_eq!(sketch.keypoint_count(), 0);\n }\n}"
},
{
"language": "rust",
"code": "// v2/crates/wifi-densepose-signal/tests/ruvsense_tests.rs\n\n#[cfg(test)]\nmod coherence_gate_tests {\n use wifi_densepose_signal::ruvsense::coherence_gate::{CoherenceGate, GateDecision};\n\n #[test]\n fn high_coherence_signal_is_accepted() {\n let gate = CoherenceGate::new(0.7); // threshold = 0.7\n let decision = gate.evaluate(0.95);\n assert_eq!(decision, GateDecision::Accept);\n }\n\n #[test]\n fn low_coherence_signal_is_rejected() {\n let gate = CoherenceGate::new(0.7);\n let decision = gate.evaluate(0.3);\n assert_eq!(decision, GateDecision::Reject);\n }\n\n #[test]\n fn borderline_coherence_triggers_recalibrate() {\n let gate = CoherenceGate::new(0.7);\n let decision = gate.evaluate(0.68); // just below threshold\n assert_eq!(decision, GateDecision::Recalibrate);\n }\n}\n\n#[cfg(test)]\nmod phase_align_tests {\n use wifi_densepose_signal::ruvsense::phase_align::PhaseAligner;\n\n #[test]\n fn phase_at_plus_pi_does_not_wrap_incorrectly() {\n let aligner = PhaseAligner::new();\n let phases = vec![std::f32::consts::PI - 0.001, std::f32::consts::PI + 0.001];\n let aligned = aligner.align(&phases);\n // jump across ±π boundary must be handled continuously\n let diff = (aligned[1] - aligned[0]).abs();\n assert!(diff < 0.01, \"phase jump at ±π must be < 0.01 rad after alignment\");\n }\n\n #[test]\n fn single_phase_value_aligns_to_itself() {\n let aligner = PhaseAligner::new();\n let phases = vec![1.5f32];\n let aligned = aligner.align(&phases);\n assert_eq!(aligned.len(), 1);\n assert!((aligned[0] - 1.5).abs() < 1e-6);\n }\n\n #[test]\n fn empty_phase_array_returns_empty() {\n let aligner = PhaseAligner::new();\n let aligned = aligner.align(&[]);\n assert!(aligned.is_empty());\n }\n}\n\n#[cfg(test)]\nmod adversarial_detection_tests {\n use wifi_densepose_signal::ruvsense::adversarial::AdversarialDetector;\n\n #[test]\n fn physically_impossible_amplitude_is_flagged() {\n let detector = AdversarialDetector::new();\n // WiFi amplitude cannot exceed hardware saturation level\n let frame = vec![1e9f32; 56]; // absurdly large\n assert!(detector.is_suspicious(&frame));\n }\n\n #[test]\n fn normal_amplitude_range_passes() {\n let detector = AdversarialDetector::new();\n let frame = vec![0.5f32; 56]; // typical normalized value\n assert!(!detector.is_suspicious(&frame));\n }\n\n #[test]\n fn multi_link_inconsistency_is_detected() {\n // link A reports body moving right; link B reports no motion\n // physically inconsistent — flag as adversarial\n let detector = AdversarialDetector::new();\n let result = detector.check_multi_link_consistency(\n &[1.0, 2.0, 3.0], // link A\n &[0.0, 0.0, 0.0], // link B (no motion)\n );\n assert!(result.is_inconsistent());\n }\n}"
},
{
"language": "rust",
"code": "// v2/crates/wifi-densepose-train/tests/test_geometry.rs\n\n#[cfg(test)]\nmod film_layer_tests {\n use wifi_densepose_train::geometry::FilmLayer;\n\n #[test]\n fn film_layer_output_shape_matches_input() {\n let film = FilmLayer::new(64, 32); // 64-dim features, 32-dim condition\n let features = vec![0.5f32; 64];\n let condition = vec![1.0f32; 32];\n let output = film.forward(&features, &condition).unwrap();\n assert_eq!(output.len(), 64, \"FiLM output must match feature dimensionality\");\n }\n\n #[test]\n fn film_layer_zero_condition_acts_as_identity() {\n let film = FilmLayer::new(64, 32);\n let features = vec![1.0f32; 64];\n let zero_condition = vec![0.0f32; 32];\n let output = film.forward(&features, &zero_condition).unwrap();\n // scale=1, shift=0 → identity; output ≈ input\n for (o, f) in output.iter().zip(features.iter()) {\n assert!((o - f).abs() < 0.1, \"zero condition should approximate identity\");\n }\n }\n}\n\n// v2/crates/wifi-densepose-train/tests/test_rapid_adapt.rs\n\n#[cfg(test)]\nmod rapid_adaptation_tests {\n use wifi_densepose_train::rapid_adapt::RapidAdapter;\n\n #[test]\n fn adapter_updates_on_single_sample() {\n let mut adapter = RapidAdapter::new(5); // 5 adaptation steps\n let csi_sample = vec![0.1f32; 56 * 3];\n let pose_label = vec![0.5f32; 17 * 2]; // 17 keypoints × (x, y)\n let result = adapter.adapt_step(&csi_sample, &pose_label);\n assert!(result.is_ok());\n }\n\n #[test]\n fn adapter_with_zero_steps_is_no_op() {\n let adapter = RapidAdapter::new(0);\n // 0 adaptation steps → weights unchanged\n let initial_weights = adapter.clone_weights();\n let _ = adapter.adapt_step(&vec![0.1f32; 168], &vec![0.5f32; 34]);\n assert_eq!(adapter.clone_weights(), initial_weights);\n }\n}"
},
{
"language": "rust",
"code": "// v2/crates/wifi-densepose-sensing-server/tests/auth_tests.rs\n\n#[cfg(test)]\nmod bearer_auth_tests {\n use wifi_densepose_sensing_server::auth::{BearerValidator, TokenError};\n\n #[test]\n fn missing_authorization_header_returns_unauthorized() {\n let validator = BearerValidator::new(\"secret-token\");\n let result = validator.validate(None);\n assert!(matches!(result, Err(TokenError::Missing)));\n }\n\n #[test]\n fn wrong_token_is_rejected() {\n let validator = BearerValidator::new(\"correct-token\");\n let result = validator.validate(Some(\"Bearer wrong-token\"));\n assert!(matches!(result, Err(TokenError::Invalid)));\n }\n\n #[test]\n fn malformed_header_without_bearer_prefix_is_rejected() {\n let validator = BearerValidator::new(\"token\");\n let result = validator.validate(Some(\"token\")); // missing \"Bearer \" prefix\n assert!(matches!(result, Err(TokenError::Malformed)));\n }\n\n #[test]\n fn correct_token_is_accepted() {\n let validator = BearerValidator::new(\"correct-token\");\n let result = validator.validate(Some(\"Bearer correct-token\"));\n assert!(result.is_ok());\n }\n}\n\n// v2/crates/wifi-densepose-sensing-server/tests/semantic_tests.rs\n\n#[cfg(test)]\nmod fall_detection_tests {\n use wifi_densepose_sensing_server::semantic::fall_detector::FallDetector;\n\n #[test]\n fn no_motion_does_not_trigger_fall() {\n let mut detector = FallDetector::new();\n for _ in 0..30 { // 30 frames of stillness\n detector.update_pose(stationary_pose());\n }\n assert!(!detector.fall_detected());\n }\n\n #[test]\n fn rapid_downward_velocity_triggers_fall() {\n let mut detector = FallDetector::new();\n // simulate person going from standing (y=1.7m) to prone (y=0.3m) in 3 frames\n for (frame, y) in [(0, 1.7f32), (1, 1.0), (2, 0.3)] {\n detector.update_pose(pose_at_height(y));\n }\n assert!(detector.fall_detected());\n }\n\n #[test]\n fn sitting_down_slowly_does_not_trigger_fall() {\n let mut detector = FallDetector::new();\n // gradual height decrease over 30 frames is sitting, not falling\n for i in 0..30 {\n let y = 1.7f32 - (i as f32 * 0.04); // ~1.2m drop over 30 frames\n detector.update_pose(pose_at_height(y));\n }\n assert!(!detector.fall_detected());\n }\n}"
}
]
},
"rawOutputPreview": "The exploration is complete. Here's the full coverage analysis with test skeletons for each critical gap.\n\n---\n\n## Test Coverage Gap Analysis — wifi-densepose\n\n### Coverage Summary by Crate\n\n| Crate | Tests Found | Status | Priority |\n|-------|-------------|--------|----------|\n| `wifi-densepose-core` | 26 inline | Good | Low |\n| `wifi-densepose-signal` | ~60 (validation only) | Moderate | **High** |\n| `wifi-densepose-nn` | **0** | Critical | **P1** |\n| `wifi-densepose-train` | ~60 (config/dataset) | Moderate | High |\n| `wifi-densepose-mat` | 1 integration test | Critical | **P1** |\n| `wifi-densepose-ruvector` | **0** | Critical | **P1** |\n| `wifi-densepose-sensing-server` | 4 integration tests | Moderate | High |\n| `wifi-densepose-wasm` | 3 compliance tests | Low | Low |\n\n---\n\n## Tier 1: Critical Gaps\n\n### 1. `wifi-densepose-nn` — Zero test coverage\n\nEvery public API is untested. Place these at `v2/crates/wifi-densepose-nn/tests/inference_tests.rs`:\n\n```rust\n// v2/crates/wifi-densepose-nn/tests/inference_tests.rs\n\n#[cfg(test)]\nmod tensor_tests {\n use wifi_densepose_nn::tensor::Tensor;\n\n #[test]\n fn tensor_shape_mismatch_returns_error() {\n // data has 6 elements but shape claims 3×3=9\n let result = Tensor::new(vec![1.0f32; 6], &[3, 3]);\n assert!(result.is_err(), \"shape mismatch must be rejected\");\n }\n\n #[test]\n fn tensor_empty_data_returns_error() {\n let result = Tensor::new(vec![], &[0]);\n assert!(result.is_err());\n }\n\n #[test]\n fn tensor_nan_values_are_detected() {\n let t = Tensor::new(vec![f32::NAN, 1.0, 2.0], &[3]).unwrap();\n assert!(t.has_nan(), \"NaN in data must be detectable\");\n }\n\n #[test]\n fn tensor_inf_values_are_detected() {\n let t = Tensor::new(vec![f32::INFINITY, 1.0], &[2]).unwrap();\n assert!(t.has_inf());\n }\n}\n\n#[cfg(test)]\nmod modality_translator_tests {\n use wifi_densepose_nn::translator::ModalityTranslator;\n\n #[test]\n fn translator_rejects",
"rawOutputLength": 18269
}
-1
View File
@@ -1 +0,0 @@
{"sessionId":"d80c93c2-51b7-42e8-a0fc-dc47cff1200f","pid":45748,"acquiredAt":1779668018388}
+4 -1
View File
@@ -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
+5 -12
View File
@@ -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
-5
View File
@@ -7,9 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **ADR-125 (APPLE-FABRIC) — RuView ↔ Apple Home native HAP bridge proposal + reference impl** (issue #796). New ADR-125 lays out a three-phase plan to expose RuView as a discoverable HomeKit accessory on the LAN so a HomePod (as Home Hub) sees presence / vitals / BFLD-derived events natively — zero Home-Assistant intermediary. Two architectural decisions resolved in the ADR per design review: (1) **one HAP bridge with N child accessories** (single pairing, matches Hue/Eve pattern), and (2) **identity-risk mapping is semantic, not probabilistic**`identity_risk_score` and Soul-Signature match probability never cross the HAP boundary; instead three thresholded events are exposed (`Unknown Presence`, `Unexpected Occupancy`, `Unrecognized Activity Pattern`) so RuView reads as calm-tech ambient awareness, not surveillance UX. ADR-125 §2.1.a reference impl ships now: `scripts/hap-test-sensor.py` (HAP-1.1 bridge advertised over mDNS, paired with operator's iPhone) + `scripts/c6-presence-watcher.py` (parses ESP32 `RV_FEATURE_STATE_MAGIC = 0xC5110006` UDP packets with IEEE CRC32 validation, hysteresis, and a Python port of `wifi-densepose-bfld::PrivacyClass` that enforces ADR-125 §2.1.d invariant I1 at the HomeKit edge — only `Anonymous` (2) and `Restricted` (3) frames may cross; `Raw`/`Derived` are refused with exit code 2 and the cited ADR clause). Validated end-to-end on real hardware (no mocks): ESP32-C6 on `ruv.net` → UDP/5005 → mac-mini watcher → BFLD gate → HAP bridge → iPhone Home app shows `Unknown Presence` live characteristic flip. **Empirical**: 50-51 valid CRC-passing feature_state packets per 10 s window from the live C6; zero CRC errors. P2 (Rust-native HAP via the `hap` crate, replaces the Python sidecar) and P3 (Matter Controller once `matter-rs` stabilizes) follow.
### Security
- **ESP32 OTA upload now fails closed when no PSK is provisioned** (#596 audit finding — critical, **breaking change for unprovisioned nodes**). `ota_check_auth()` previously returned `true` when `s_ota_psk[0] == '\0'`, so a freshly-flashed node would accept attacker-controlled firmware over plain HTTP on port 8032 from any host on the WiFi. No Secure Boot V2, no signed-image verification — a single LAN call could brick or backdoor a node. The fix rejects every OTA upload until a PSK is written to NVS (the OTA HTTP server still starts so operators can run `provision.py --ota-psk <hex>` over USB-CDC without reflashing). **Operators affected**: any deployment that relied on the unauthenticated OTA endpoint working out of the box now needs to provision a PSK before subsequent OTA pushes will succeed. Boot-time `ESP_LOGW` makes the new posture visible.
- **Path-traversal vulnerabilities patched in five sensing-server endpoints** (closes #615 — critical). New `wifi_densepose_sensing_server::path_safety::safe_id()` enforces `[A-Za-z0-9._-]` only (no leading `.`, max 64 chars) before any user-controlled identifier reaches a `format!()` building a filesystem path. Applied at:
@@ -65,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.
+2 -2
View File
@@ -73,9 +73,9 @@ All 5 ruvector crates integrated in workspace:
| Device | Port | Chip | Role | Cost |
|--------|------|------|------|------|
| ESP32-S3 (8MB flash) | COM9 (ruvzen, was COM7) | Xtensa dual-core | WiFi CSI sensing node | ~$9 |
| ESP32-S3 (8MB flash) | COM7 | Xtensa dual-core | WiFi CSI sensing node | ~$9 |
| ESP32-S3 SuperMini (4MB) | — | Xtensa dual-core | WiFi CSI (compact) | ~$6 |
| ESP32-C6 + Seeed MR60BHA2 | COM12 (ruvzen, was COM4) | RISC-V + 60 GHz FMCW | mmWave HR/BR/presence + WiFi CSI | ~$15 |
| ESP32-C6 + Seeed MR60BHA2 | COM4 | RISC-V + 60 GHz FMCW | mmWave HR/BR/presence | ~$15 |
| HLK-LD2410 | — | 24 GHz FMCW | Presence + distance | ~$3 |
**Not supported:** ESP32 (original), ESP32-C3 — single-core, can't run CSI DSP pipeline.
+8 -14
View File
@@ -11,13 +11,18 @@
</a>
</p>
> **Beta Software** — Under active development. APIs and firmware may change. Known limitations:
> - ESP32-C3 and original ESP32 are not supported (single-core, insufficient for CSI DSP)
> - Single ESP32 deployments have limited spatial resolution — use 2+ nodes or add a [Cognitum Seed](https://cognitum.one) for best results
> - Camera-free pose accuracy is limited (PCK@20 ≈ 2.5% with proxy labels) — [camera ground-truth training](docs/adr/ADR-079-camera-ground-truth-training.md) targets **35%+ PCK@20**; the pipeline is implemented, but the data-collection and evaluation phases (ADR-079 P7P9) are still pending.
>
> Contributions and bug reports welcome at [Issues](https://github.com/ruvnet/RuView/issues).
## **See through walls with WiFi** ##
**Turn ordinary WiFi into a spatial intelligence / sensing system.** Detect people, measure breathing and heart rate, track movement, and monitor rooms — through walls, in the dark, with no cameras or wearables. Just physics.
Works natively with the four major smart-home ecosystems: **[Home Assistant](docs/integrations/home-assistant.md)** via the HA-DISCO MQTT publisher, **[Apple Home & HomePod](docs/user-guide-apple-homepod.md)** as a discoverable HAP-1.1 bridge, **[Google Home](docs/integrations/home-assistant.md)** + **[Amazon Alexa](docs/integrations/home-assistant.md)** via the same HA bridge or a [Matter](docs/adr/ADR-122-bfld-ruview-ha-matter-exposure.md) endpoint. Siri, Google Assistant, and Alexa can voice presence and vitals by room with zero custom skills.
[![Works with Home Assistant](https://img.shields.io/badge/Works%20with-Home%20Assistant-blue?logo=home-assistant&logoColor=white&labelColor=41BDF5)](docs/integrations/home-assistant.md) [![Works with Matter](https://img.shields.io/badge/Works%20with-Matter-blue?labelColor=4285F4)](docs/adr/ADR-122-bfld-ruview-ha-matter-exposure.md) [![Works with Apple Home](https://img.shields.io/badge/Works%20with-Apple%20Home-black?logo=apple)](docs/user-guide-apple-homepod.md) [![Works with Google Home](https://img.shields.io/badge/Works%20with-Google%20Home-blue?logo=googlehome)](docs/integrations/home-assistant.md) [![Works with Alexa](https://img.shields.io/badge/Works%20with-Alexa-blue?logo=amazon&logoColor=white&labelColor=00CAFF)](docs/integrations/home-assistant.md)
![Works with Home Assistant](https://img.shields.io/badge/Works%20with-Home%20Assistant-blue?logo=home-assistant&logoColor=white&labelColor=41BDF5) ![Works with Matter](https://img.shields.io/badge/Works%20with-Matter-blue?labelColor=4285F4) ![Works with Apple Home](https://img.shields.io/badge/Works%20with-Apple%20Home-black?logo=apple) ![Works with Google Home](https://img.shields.io/badge/Works%20with-Google%20Home-blue?logo=googlehome)
> Drop into any **Home Assistant** install with one `--mqtt` flag. Or pair into **Apple Home / Google Home / Alexa / SmartThings** as a Matter Bridge. Ships 21 entities per node (11 raw signals + 10 inferred semantic states: someone-sleeping, possible-distress, room-active, elderly-inactivity-anomaly, meeting-in-progress, bathroom-occupied, fall-risk-elevated, bed-exit, no-movement, multi-room-transition) plus 3 starter HA Blueprints. See [`docs/integrations/home-assistant.md`](docs/integrations/home-assistant.md) · [ADR-115](docs/adr/ADR-115-home-assistant-integration.md).
@@ -589,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) |
@@ -602,15 +605,6 @@ Verify the plugin structure: `bash plugins/ruview/scripts/smoke.sh`. Full detail
---
## 🚧 Beta software
> **Beta Software** — Under active development. APIs and firmware may change. Known limitations:
> - ESP32-C3 and original ESP32 are not supported (single-core, insufficient for CSI DSP)
> - Single ESP32 deployments have limited spatial resolution — use 2+ nodes or add a [Cognitum Seed](https://cognitum.one) for best results
> - Camera-free pose accuracy is limited (PCK@20 ≈ 2.5% with proxy labels) — [camera ground-truth training](docs/adr/ADR-079-camera-ground-truth-training.md) targets **35%+ PCK@20**; the pipeline is implemented, but the data-collection and evaluation phases (ADR-079 P7P9) are still pending.
>
> Contributions and bug reports welcome at [Issues](https://github.com/ruvnet/RuView/issues).
## 📄 License
MIT License — see [LICENSE](LICENSE) for details.
+5 -14
View File
@@ -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
View File
@@ -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 20242025 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 A1A5 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,362 +0,0 @@
# ADR-126: HOMECORE — Native Rust + WASM + TypeScript port of Home Assistant
| Field | Value |
|-------|-------|
| **Status** | Proposed |
| **Date** | 2026-05-25 |
| **Deciders** | ruv |
| **Codename** | **HOMECORE** — native hub, RuView-first, WASM-safe, semantically aware |
| **Relates to** | [ADR-115](ADR-115-home-assistant-integration.md) (HA-DISCO), [ADR-116](ADR-116-cog-ha-matter-seed.md) (HA-COG), [ADR-117](ADR-117-pip-wifi-densepose-modernization.md) (PIP-PHOENIX), [ADR-118](ADR-118-bfld-beamforming-feedback-layer-for-detection.md) (BFLD), [ADR-124](ADR-124-rvagent-mcp-ruvector-npm-integration.md) (SENSE-BRIDGE), [ADR-125](ADR-125-ruview-apple-home-native-hap-bridge.md) (APPLE-FABRIC) |
| **Tracking issue** | TBD |
| **Sub-ADRs** | ADR-127 through ADR-134 |
---
## 1. Context
### 1.1 Strategic position in 2026
Home Assistant (HA) is the dominant open-source home automation hub with more than 500,000 active installs (ADR-115 §1.2 competitive scan). Every prior RuView integration decision has been made with HA as a given constraint: ADR-115 built an MQTT auto-discovery publisher to fit inside HA, ADR-116 packaged it as a Cognitum Seed cog, ADR-122 extended it with BFLD presence events, and ADR-125 layered a native HAP bridge on top of the same stack.
This approach yields functioning integrations, but it positions RuView permanently as a **guest in someone else's hub**. The architectural limits of Python HA are not just cosmetic:
| Limit | Impact on RuView's roadmap |
|---|---|
| **Single-process Python GIL** | CSI DSP pipeline, BFLD analysis, and ruvector semantic search cannot run concurrently inside the HA process; they must run as external services connected over MQTT or WebSocket, introducing a round-trip on every sensor update |
| **Startup time (1530 s on a Pi 5)** | The Cognitum Seed appliance restarts firmware-update-by-firmware-update; a 30 s hub startup on every OTA cycle is user-visible latency |
| **Memory footprint (300 MB+ idle)** | On a Pi 5 with 8 GB this is tolerable; on a Pi Zero 2 W or an embedded board with 512 MB it precludes co-location with the sensing stack |
| **No WASM safety boundary for integrations** | HA's 2,000+ community integrations are Python modules loaded directly into the HA process — one buggy integration can crash the hub or read arbitrary memory |
| **Recorder is structural only** | SQLite + InfluxDB store state history as rows; there is no semantic search. "Show me when the porch light correlated with the bedroom CSI anomaly last week" requires manual SQL |
| **Voice assistant is additive** | Assist (`homeassistant/components/assist_pipeline/`) was added in 20222023 and is well-designed, but intent matching is keyword-based, not embedding-based; ruflo LLM pipelines cannot natively plug in |
| **Frontend is a 5 MB Lit-element bundle** | The dashboard compiles to ~5 MB of JavaScript; on low-bandwidth appliance UIs or Progressive-Web-App installs, this is perceptible load time |
These are not HA's failures — they are Python architectural realities. For a generic home automation hub they are acceptable. For a hub where the core value proposition is **real-time RF sensing, AI-augmented automation, and edge-native deployment on constrained hardware**, they are ceilings.
### 1.2 The opportunity
Three recent ADR shipments create the inflection point:
1. **ADR-117 (PIP-PHOENIX)**`wifi-densepose==2.0.0a1` + `ruview==2.0.0a1` on PyPI as PyO3/maturin wheels, providing a Python developer surface over the Rust sensing core.
2. **ADR-118 (BFLD)** — a complete beamforming feedback capture and privacy-risk scoring layer, proving that RuView's sensing stack can be a compliance instrument, not just a sensor.
3. **ADR-124 (SENSE-BRIDGE)**`@ruvnet/rvagent` on npm as a dual-transport MCP server, proving that the sensing stack can be expressed as a first-class AI-agent tool surface.
The gap that remains: there is no hub that treats all of these as **native first-class features** rather than bolt-on integrations. HOMECORE fills that gap by porting the HA data model and API surface to Rust, replacing HA's Python internals with the RuView Rust crates, and wrapping community integrations in WASM sandboxes.
### 1.3 What this ADR is *not*
- Not a fork of the Python HA codebase. HOMECORE is a **clean-room Rust implementation** of HA's public API contracts and data model, not a line-by-line port.
- Not a replacement of the existing sensing stack. `v2/crates/wifi-densepose-*` remain authoritative.
- Not a deprecation of ADR-115/116/117/124/125. Those integrations continue to work with Python HA installs. HOMECORE is an additional deployment target, not a replacement mandate.
- Not a Matter SDK full-implementation. ADR-125 handles Matter; HOMECORE consumes the Matter bridge via the existing `cog-ha-matter` surface.
- Not a target for this quarter's sprint. HOMECORE is a multi-quarter initiative. This master ADR and its sub-ADRs define the architecture; implementation begins in P1.
---
## 2. Decision
Build **HOMECORE**: a native Rust + WASM + TypeScript implementation of the Home Assistant hub contract, integrated with the RuView sensing platform, the ruflo agent toolchain, and the ruvector vector layer.
HOMECORE is wire-compatible with HA's REST and WebSocket APIs so that existing HA-native clients (the iOS/Android Home Assistant companion apps, HACS, Nabu Casa Cloud, and the HA voice satellite stack) operate without modification against a HOMECORE instance.
HOMECORE is NOT a drop-in replacement on day one. The compatibility contract is phased (§6). The architecture is designed so that clients that work with HA today work with HOMECORE P3+.
### 2.1 Codename rationale
**HOMECORE** — the `core` of HA reimplemented at native speed, with the sensing stack at the center rather than at the periphery.
---
## 3. Architecture overview
```
┌──────────────────────────────────────────────────────────────┐
│ HOMECORE process │
│ │
│ ┌─────────────┐ ┌──────────────┐ ┌───────────────────┐ │
│ │ homecore │ │ homecore- │ │ homecore- │ │
│ │ state │ │ automation │ │ recorder │ │
│ │ machine │ │ engine │ │ (SQLite + │ │
│ │ (ADR-127) │ │ (ADR-129) │ │ ruvector) │ │
│ └──────┬──────┘ └──────┬───────┘ │ (ADR-132) │ │
│ │ │ └───────────────────┘ │
│ ┌──────▼──────────────────────────────────┐ │
│ │ Event Bus (Tokio broadcast) │ │
│ └──────┬──────────────────────────────────┘ │
│ │ │
│ ┌──────▼──────────────────────────────────┐ │
│ │ homecore-rest-websocket-api (ADR-130)│ │
│ │ Axum server — HA wire-compat API │ │
│ └──────────────────────────────────────────┘ │
│ │
│ ┌──────────────┐ ┌──────────────────────────────────────┐ │
│ │ Integration │ │ homecore-assist-ruflo (ADR-133) │ │
│ │ Plugin System│ │ ruflo agent orchestration │ │
│ │ (ADR-128) │ │ ruvector intent embeddings │ │
│ │ WASM sandbox │ │ Wyoming protocol edge │ │
│ └──────────────┘ └──────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ RuView sensing core (wifi-densepose-sensing-server) │ │
│ │ CSI → presence / vitals / pose / BFLD / semantic │ │
│ └──────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
│ HA-compatible REST + WebSocket
┌──────────────────────────┐
│ homecore-frontend-ts-wasm │ (ADR-131)
│ TypeScript + Rust→WASM │
│ SharedWorker state sync │
└──────────────────────────┘
```
The HOMECORE process is a single Tokio-based async Rust binary. The state machine and event bus are the authoritative core (ADR-127). Integrations run in WASM sandboxes that communicate with the core via a defined ABI (ADR-128). The automation engine runs Rust-native trigger evaluation with a WASM expression evaluator for templates (ADR-129). The REST/WebSocket API layer is Axum-based and wire-compatible with HA (ADR-130). The frontend is TypeScript with the state machine compiled to WASM running in a SharedWorker (ADR-131). Historical state is stored in SQLite with ruvector for semantic search (ADR-132). Voice/text assistance uses ruflo agent orchestration (ADR-133).
---
## 4. Series map
| ADR | Codename | Scope | Critical path? | Estimated P5-completion |
|---|---|---|---|---|
| **ADR-127** | HOMECORE-CORE | Rust state machine, entity registry, event bus, service registry (`homecore` crate) | **Yes — all others depend on it** | Q3 2026 |
| **ADR-128** | HOMECORE-PLUGINS | WASM integration plugin system, cog substrate, manifest schema, hot-load | **Yes — needed before any integration can run** | Q3 2026 |
| **ADR-129** | HOMECORE-AUTO | Automation engine, YAML parser, Jinja2-equivalent WASM evaluator, blueprints | Yes (automation is core to HA UX) | Q4 2026 |
| **ADR-130** | HOMECORE-API | REST + WebSocket wire-compat API, Axum server, HA companion app support | **Yes — needed for client compat** | Q3 2026 |
| **ADR-131** | HOMECORE-UI | TS + Rust→WASM frontend, SharedWorker state sync, Material 3 design lang | No (can run alongside Python HA UI initially) | Q1 2027 |
| **ADR-132** | HOMECORE-RECORDER | SQLite recorder + ruvector semantic history, schema migration | No (structural recorder ships before ruvector layer) | Q4 2026 |
| **ADR-133** | HOMECORE-ASSIST | ruflo agent voice assistant, ruvector intent matching, Wyoming edge path | No | Q4 2026 |
| **ADR-134** | HOMECORE-MIGRATE | Migration tooling from Python HA, config-entry parser, side-by-side mode | No (needed for user adoption) | Q1 2027 |
**Critical path**: ADR-127 → ADR-128 → ADR-130 must land in that order. ADR-129, ADR-132, ADR-133, ADR-131, ADR-134 can proceed in parallel once the core triad is stable.
---
## 5. Cross-cutting decisions
The following decisions govern all 8 sub-ADRs and are not repeated in each.
### 5.1 Governance via RUVIEW-POLICY (ADR-124 §4.1a)
Every HOMECORE component that returns biometric data (presence, HR/BR, pose keypoints, BFLD identity-risk) MUST route through the RUVIEW-POLICY layer defined in ADR-124 §4.1a. The policy store is the same `~/.config/rvagent/policy.json` used by `@ruvnet/rvagent`. HOMECORE is a first-class policy principal — its agent ID in the policy store is `homecore`.
### 5.2 Semantic memory via ruvector
Historical state is not only stored in SQLite rows (structural). Every state-changed event is also embedded via ruvector (using the same napi-rs bindings as ADR-124) and indexed in an HNSW store for semantic search. The `homecore-recorder` crate (ADR-132) owns this dual-write. Queries like "when did the living room motion last exceed baseline?" become vector-nearest-neighbour searches, not SQL BETWEEN clauses.
### 5.3 Agent orchestration via ruflo
The automation engine (ADR-129) and the assist pipeline (ADR-133) both have an optional ruflo-agent mode where complex conditions or voice intents are routed to a ruflo agent (using the `mcp__claude-flow__*` tool namespace) for LLM-backed resolution. This is gated by RUVIEW-POLICY: a policy grant is required before HOMECORE sends any state-history context to a ruflo agent.
### 5.4 Witness and audit via Ed25519 chain (ADR-028 pattern)
Every state transition that crosses a privacy boundary (e.g. BFLD identity-risk score elevated, a biometric entity state published) is logged to an Ed25519 witness chain using the same structure as ADR-028 §3. The witness bundle is exportable for regulated deployments (care homes, hotels, shared offices).
### 5.5 Crate naming and workspace placement
All HOMECORE crates live in `v2/crates/homecore-*/`:
| Crate | ADR |
|---|---|
| `homecore` | ADR-127 |
| `homecore-plugins` | ADR-128 |
| `homecore-automation` | ADR-129 |
| `homecore-api` | ADR-130 |
| `homecore-recorder` | ADR-132 |
| `homecore-assist` | ADR-133 |
| `homecore-migrate` | ADR-134 |
The frontend (`homecore-frontend`) is not a Rust crate — it is an npm package at `npm/homecore-frontend/`, mirroring the `npm/rvagent/` pattern from ADR-124.
### 5.6 HA wire-compatibility baseline
The HOMECORE REST and WebSocket API must be **compatible with HA 2025.1** as the baseline. HA 2025.1 introduced schema version 48 in the recorder. The API surface to replicate is:
- REST: `homeassistant/components/api/__init__.py` — 24 endpoints
- WebSocket: `homeassistant/components/websocket_api/` — the `connection.py` + `commands.py` handler pattern, the auth handshake, and the `subscribe_events` / `subscribe_trigger` / `call_service` commands
- Auth: `homeassistant/auth/` — the long-lived access token model
- Config entries: `.storage/core.config_entries` JSON schema (versioned, auto-migrated)
### 5.7 "Do not port" list
The following HA subsystems are explicitly **not** ported to HOMECORE:
| HA subsystem | Reason not ported | HOMECORE replacement |
|---|---|---|
| **SUPERVISOR** (`homeassistant/supervisor/`) | Manages add-on containers and OS upgrades. HOMECORE runs on a standard Linux/Pi OS managed by systemd. | ruflo + systemd service units + OTA via the existing Cognitum Seed OTA registry (ADR-116 §2.2) |
| **Home Assistant OS** (HAOS) | A custom embedded Linux image. HOMECORE targets standard Debian/Ubuntu on Pi 5 and standard Docker. | Standard OS + Docker Compose or systemd |
| **Nabu Casa Cloud** | Paid remote-access and Alexa/Google integration service. HOMECORE uses Tailscale for remote access and `@ruvnet/rvagent` for AI integration. | Tailscale + ADR-107 federation + SENSE-BRIDGE |
| **Add-on store** (Supervisor add-ons) | Docker container management. | Cognitum Seed cog registry (ADR-102) |
| **Legacy YAML-only integrations** (pre-config-flow, ~500 of 2,000) | These require Python `setup_platform` (deprecated in HA 2024.x). Only config-flow integrations (`async_setup_entry`) are ported. | Document upgrade path; unported integrations can run via `homecore-migrate` bridge mode |
| **Analytics / Nabu Casa telemetry** | Optional cloud telemetry. | Not replicated. HOMECORE is local-only. |
| **Home Assistant Yellow / Green hardware** | Specific hardware. HOMECORE targets Cognitum Seed, Pi 5, and x86_64. | Cognitum Seed hardware |
---
## 6. Compatibility contract
### 6.1 What works on day one (P3, wire-compat API stable)
| Client | Works? | Notes |
|---|---|---|
| **HA iOS companion app** | Yes | Connects to `/api/websocket`; authenticates with long-lived token; subscribes to state events |
| **HA Android companion app** | Yes | Same as iOS |
| **Home Assistant Dashboard (frontend)** | Yes (HA frontend served against HOMECORE API) | Until HOMECORE-UI (ADR-131) ships, serve the Python HA frontend binary against the HOMECORE API |
| **HACS** | Partial | HACS uses the WS API for integration management; custom component loading requires HOMECORE-PLUGINS (ADR-128) |
| **Node-RED HA integration** | Yes | Uses REST + WS API; wire-compat |
| **`homeassistant` Python client library** | Yes | Pure REST/WS client |
| **`ha-mqtt-discoverable` Python library** | Yes | Publishes MQTT discovery; HOMECORE consumes the same topics |
| **ESPHome devices** | Yes | ESPHome native API or MQTT; HOMECORE speaks both |
| **Nabu Casa Cloud** | **No** | Nabu Casa uses a proprietary remote-access tunnel to `nabucasa.com`. HOMECORE does not integrate with the Nabu Casa cloud proxy. Replace with Tailscale. |
| **M5Stack ATOM Echo / voice satellites** | Yes (P4) | Wyoming protocol is HOMECORE-ASSIST (ADR-133) scope |
| **HACS custom cards** | Yes (after ADR-131 P3) | Custom cards are served via the same `/hacsfiles/` static route |
### 6.2 What breaks and why
| HA feature | HOMECORE status | Reason |
|---|---|---|
| Nabu Casa remote access | Not supported | Proprietary tunnel; replace with Tailscale |
| HA Supervisor add-ons | Not supported | No container manager in HOMECORE |
| HAOS OTA updates | Not supported | HOMECORE runs on standard OS |
| Python custom integrations (non-WASM) | Not supported | WASM sandbox only; Python integrations cannot run natively |
| Legacy `setup_platform` integrations | Not supported | Config-flow (`async_setup_entry`) only |
| HA Cloud TTS/STT (Nabu Casa) | Not supported | Use Whisper + Piper locally |
| HA Cloud Alexa/Google skill | Not supported | Use ruflo agent instead |
---
## 7. Phase roadmap
```
Q3 2026 Q4 2026 Q1 2027 Q2 2027
P1 P2 P3 P4 P5
scaffold state+API wire-compat plugins+ full
core HA clients automation HOMECORE
```
### P1 — Scaffold (Q3 2026, 2 weeks)
- [ ] Create `v2/crates/homecore/` workspace member, empty state machine skeleton.
- [ ] Create `v2/crates/homecore-api/` skeleton, Axum server on port 8123 (HA default).
- [ ] Create `npm/homecore-frontend/` skeleton.
- [ ] CI: `cargo check -p homecore -p homecore-api --no-default-features` green.
- [ ] ADR-134 migration tool parses one `.storage/core.config_entries` fixture.
### P2 — State machine + API core (Q3 2026, 4 weeks)
- [ ] ADR-127 state machine: entity registry, state machine, event bus (Tokio broadcast), service registry.
- [ ] ADR-130 API: REST endpoints, WebSocket auth handshake, `subscribe_events`, `call_service`.
- [ ] ADR-132 recorder: SQLite schema (HA schema version 48 compatible), state write path.
- [ ] Integration test: HA companion app authenticates and receives state updates.
### P3 — Wire-compat + plugin scaffold (Q3Q4 2026, 6 weeks)
- [ ] ADR-128 plugin system: WASM sandbox, manifest schema, first ported integrations (MQTT, HTTP).
- [ ] ADR-130 API: remaining WS commands, HACS support.
- [ ] ADR-134 migration: reads `automations.yaml`, `secrets.yaml`, config entries.
- [ ] ADR-132 recorder: ruvector dual-write, semantic search API.
### P4 — Automation + assist (Q4 2026, 4 weeks)
- [ ] ADR-129 automation engine: YAML parser, trigger evaluation, WASM expression evaluator.
- [ ] ADR-133 assist: ruflo agent orchestration, ruvector intent matching.
- [ ] ADR-131 frontend P1: TypeScript shell, WASM state machine in SharedWorker.
### P5 — Full HOMECORE (Q1 2027, 6 weeks)
- [ ] ADR-131 frontend: complete UI parity with HA Lovelace, custom cards.
- [ ] ADR-134 migration: side-by-side mode, one-click cutover.
- [ ] Full compatibility test suite against HA iOS/Android companion apps.
- [ ] Pi 5 performance benchmarks: startup < 1 s, idle < 50 MB RAM.
---
## 8. Alternatives rejected
### Alt-A: Contribute RuView sensing features upstream to Python HA
Add the HOMECORE features (WASM plugins, ruvector recorder, ruflo assist) as Python HA components via PRs to `home-assistant/core`.
**Rejected because**: HA's architecture board has strict policies against adding new runtimes (WASM, Rust FFI) to the core process. The GIL bottleneck cannot be resolved from within Python HA. CSI DSP at 100 Hz frame rate inside a Python process is not feasible. This path cedes architectural control permanently.
### Alt-B: Thin Rust wrapper that calls into Python HA via PyO3
Keep Python HA as the runtime; expose RuView sensing primitives via PyO3 bindings so they run at native speed inside the Python HA process.
**Rejected because**: the GIL is not resolved by PyO3 calls — the HA event loop still serialises all state changes. Startup time and memory footprint are unchanged. WASM plugin safety is unchanged. This is a tactical optimisation, not an architectural solution.
### Alt-C: OpenHAB or Domoticz as the base
Port RuView's sensing stack on top of an alternative hub (openHAB/Java, Domoticz/C++).
**Rejected because**: neither has HA's community network effects, companion app ecosystem, or HACS plugin catalog. A clean-room Rust implementation preserves the HA compatibility contract (the most valuable asset) without inheriting the Python runtime limitations.
### Alt-D: Extend the existing `wifi-densepose-sensing-server` into a full hub
Add automation, entity registry, and recorder features directly to the existing Axum sensing server.
**Rejected because**: the sensing server is a purpose-built single-concern binary (CSI → MQTT/WebSocket). Expanding it into a hub would violate the single-responsibility principle and couple hub release cycles to firmware release cycles. HOMECORE is a separate crate family that depends on but does not modify the sensing server.
---
## 9. Top-level risks
| Risk | Likelihood | Severity | Mitigation |
|---|---|---|---|
| **API drift** — HA's REST/WS API evolves; HOMECORE must track it | High | High | Pin to HA 2025.1 baseline (schema 48); run the HA companion app integration tests against every HOMECORE release; ADR-130 owns the compat matrix |
| **WASM sandbox performance** — plugin calls through the WASM boundary add latency | Medium | Medium | Benchmark plugin roundtrip on Pi 5 before P3; reject if >5 ms; WASM3/Wasmtime both have sub-1 ms call overhead for compute-light integrations |
| **Core triad dependency** — ADR-128 and ADR-130 cannot start until ADR-127 is stable | High | High | ADR-127 is P2 start; freeze the state machine public API (entity_id, state, attributes, last_changed) before ADR-128 begins |
| **ruvector semantic recorder** — dual-write to SQLite + HNSW may impact write throughput under high-frequency sensing | Medium | High | ruvector writes are async (non-blocking tokio task); SQLite write is the hot path; benchmark at 100 state/s on Pi 5 before ADR-132 ships |
| **Nabu Casa gap** — users who depend on HA Cloud remote access have no HOMECORE replacement at P3 | High | Medium | Document Tailscale as the replacement prominently; provide ADR-134 migration wizard that detects Nabu Casa usage and offers Tailscale setup |
| **Frontend bundle size** — replicating the HA Lovelace card ecosystem in TS+WASM is a significant engineering effort | High | High | ADR-131 is off-critical-path; serve HA's Python frontend against the HOMECORE API until ADR-131 P3 ships |
| **License** — HA is Apache 2.0; the wire protocol is unencumbered; HA's UI assets and card components have separate licenses | Low | High | Clean-room Rust implementation does not use HA source; HA frontend is served as a binary (not embedded); review license before ADR-131 ships any reimplemented component |
---
## 10. Open questions
**Q1** (ADR-127): Should the HOMECORE state machine use a `DashMap<EntityId, State>` for lock-free concurrent reads, or a `RwLock<HashMap<EntityId, State>>` for simpler reasoning? The answer affects every integration's write pattern.
**Q2** (ADR-128): Does the WASM sandbox use Wasmtime (Cranelift JIT, ~5 MB binary) or WASM3 (interpreter, ~50 kB binary)? On a Pi 5 WASM3 is sufficient for integration logic; Wasmtime matters if integrations need near-native DSP speed.
**Q3** (ADR-130): The HA WebSocket API uses numeric IDs for command/response correlation. The HA 2025.1 baseline adds `subscribe_trigger` as a first-class WS command. Are there any commands in the HA companion app that require a newer baseline?
**Q4** (ADR-132): The ruvector HNSW index for state history — what embedding dimension represents a state snapshot? Options: (a) embed only numeric sensor states (scalar embedding), (b) embed `{entity_id, state, attributes}` as a text embedding via a local small model, (c) use a fixed schema encoding. The answer determines the semantic query fidelity.
**Q5** (ADR-134): HA's `.storage/core.config_entries` format is versioned but undocumented; it is hand-engineered from reverse-engineering the Python `StorageCollection` class in `homeassistant/helpers/storage.py`. Is this format stable enough to parse without upstream documentation, or does HOMECORE need to maintain a version matrix?
---
## 11. References
### This repo
- `docs/adr/ADR-115-home-assistant-integration.md` — HA-DISCO MQTT publisher; 21-entity surface; semantic primitives; competitive comparison table
- `docs/adr/ADR-116-cog-ha-matter-seed.md` — HA-COG Seed cog; cog packaging precedent (ADR-101)
- `docs/adr/ADR-117-pip-wifi-densepose-modernization.md` — PIP-PHOENIX PyO3 bindings; Python client surface
- `docs/adr/ADR-118-bfld-beamforming-feedback-layer-for-detection.md` — BFLD master; privacy class enforcement
- `docs/adr/ADR-124-rvagent-mcp-ruvector-npm-integration.md` — SENSE-BRIDGE; RUVIEW-POLICY §4.1a; multi-modal normalization §11.3
- `docs/adr/ADR-125-ruview-apple-home-native-hap-bridge.md` — APPLE-FABRIC HAP bridge
- `v2/crates/wifi-densepose-sensing-server/src/main.rs` — Axum server architecture; bearer auth pattern
- `v2/crates/wifi-densepose-ruvector/src/viewpoint/` — cross-viewpoint fusion (attention, coherence, geometry, fusion modules)
- `CLAUDE.md` — Project topology (hierarchical-mesh, 15 agents), ESP32 hardware table, crate publishing order
### HA upstream
- `homeassistant/core.py``HomeAssistant`, `StateMachine`, `EventBus`, `ServiceRegistry`, `Config`
- `homeassistant/helpers/entity_registry.py``EntityRegistry`, `RegistryEntry`
- `homeassistant/helpers/entity.py``Entity`, `async_write_ha_state`, entity lifecycle
- `homeassistant/components/api/__init__.py` — REST API handler (24 routes)
- `homeassistant/components/websocket_api/``connection.py` auth handshake; `commands.py` WS commands
- `homeassistant/components/recorder/` — SQLite schema; `migration.py` schema version 48
- `homeassistant/components/assist_pipeline/` — voice/text pipeline; Wyoming protocol
- `homeassistant/helpers/template.py` — Jinja2 template engine customisation
- `homeassistant/components/automation/__init__.py` — automation trigger/condition/action model
- `homeassistant/helpers/storage.py``.storage/*.json` persistence; `StorageCollection`
- `homeassistant/auth/` — long-lived access token model; `AuthManager`
### External
- [HA Developer Docs — Core Architecture](https://developers.home-assistant.io/docs/architecture/core/) — state machine, event bus, service registry overview
- [HA Developer Docs — WebSocket API](https://developers.home-assistant.io/docs/api/websocket/) — WS command catalog
- [DeepWiki HA core — Entity and Registry Management](https://deepwiki.com/home-assistant/core/2.2-entity-and-registry-management) — entity lifecycle
- [DeepWiki HA core — Data Management](https://deepwiki.com/home-assistant/core/3-data-management) — recorder schema version 48
- [HA recorder integration](https://www.home-assistant.io/integrations/recorder/) — SQLite default; schema migration overview
@@ -1,193 +0,0 @@
# ADR-127: HOMECORE-CORE — Rust state machine, entity registry, event bus, service registry
| Field | Value |
|-------|-------|
| **Status** | Proposed |
| **Date** | 2026-05-25 |
| **Deciders** | ruv |
| **Codename** | **HOMECORE-CORE** |
| **Relates to** | [ADR-126](ADR-126-ruview-native-ha-port-master.md) (HOMECORE master), [ADR-028](ADR-028-esp32-capability-audit.md) (witness chain), [ADR-124](ADR-124-rvagent-mcp-ruvector-npm-integration.md) (RUVIEW-POLICY) |
| **Tracking issue** | TBD |
---
## 1. Context
`homeassistant/core.py` is the 3,200-line heart of Python Home Assistant. It defines five objects that every other HA component depends on:
1. **`HomeAssistant`** — the runtime coordinator, event loop holder, and service locator. Contains `bus` (EventBus), `states` (StateMachine), `services` (ServiceRegistry), `config` (Config), `components` (loaded component set).
2. **`EventBus`** — publish/subscribe event dispatch. `async_fire(event_type, event_data)` dispatches to all registered listeners. Listener registration is `async_listen(event_type, callback)`. Wildcard listener is `MATCH_ALL`. Event data is a plain Python dict.
3. **`StateMachine`** — an in-memory dictionary from `entity_id` (str) to `State`. `async_set(entity_id, new_state, attributes)` writes and fires `state_changed`. `get(entity_id)` reads. `async_remove(entity_id)` fires `state_removed`. States are immutable snapshots with `last_changed`, `last_updated`, `context`.
4. **`ServiceRegistry`** — maps `(domain, service_name)` → async handler function. `async_call(domain, service, data)` fires a `call_service` event, waits for the registered handler. `async_register(domain, service, handler, schema)` registers a handler with optional voluptuous schema validation.
5. **`EntityRegistry`** (`homeassistant/helpers/entity_registry.py`) — persists metadata (enabled/disabled, name override, area assignment, device ID, unique ID, entity category) across restarts. Stored in `.storage/core.entity_registry`. Loaded at startup; written on every change.
The **DeviceRegistry** (`homeassistant/helpers/device_registry.py`, stored in `.storage/core.device_registry`) tracks physical devices that entities belong to. Entities link to devices via `device_id`; devices link to config entries via `config_entry_id`.
### 1.1 Why these specific files matter
Python HA's `core.py` is a single-process Python 3.12 module that:
- Holds the asyncio event loop directly
- Serialises all state-changed writes through `asyncio.Lock`
- Fires event listeners in the same event loop iteration that fired the event (listeners cannot block)
- Is single-threaded by design — concurrent writes to the state machine are impossible without explicit async primitives
For HOMECORE the same semantic requirements apply, but the implementation must support:
- **Concurrent reads** from dozens of integration WASM sandboxes polling current state
- **High-frequency writes** from the RuView sensing stack (CSI at 100 Hz; state updates at up to 20 Hz per entity)
- **Ordered delivery** of state_changed events to automation triggers (ADR-129) and recorder (ADR-132) subscribers
- **Zero-copy reads** where possible for the REST API (ADR-130) path
---
## 2. Decision
Implement the `homecore` Rust crate at `v2/crates/homecore/` with the following design.
### 2.1 State machine: `DashMap` + Tokio broadcast
The primary state store is a `DashMap<EntityId, Arc<State>>` where:
- `EntityId` is a validated newtype around `String` (validated format: `domain.name`)
- `State` is a frozen struct: `entity_id`, `state` (String), `attributes` (serde_json::Value), `last_changed` (DateTime<Utc>), `last_updated` (DateTime<Utc>), `context` (Context)
- `Arc<State>` allows zero-copy cloning for readers while the writer atomically replaces the map entry
State changes are published to a `tokio::sync::broadcast::Sender<StateChangedEvent>` channel (capacity: 4,096 events). Any number of receivers subscribe — the recorder, automation engine, WebSocket subscriber handler, and ruvector dual-write task all hold independent receivers. Slow receivers that fall behind by 4,096 events receive a `RecvError::Lagged` and must re-sync from the current state map.
### 2.2 Event bus: typed + untyped channels
HOMECORE distinguishes two event categories:
1. **System events** (typed): `StateChanged`, `ServiceCall`, `ComponentLoaded`, `PlatformDiscovered`, `HomeAssistantStart`, `HomeAssistantStop`. These use Tokio typed broadcast channels with zero allocation on the read path.
2. **Integration events** (untyped): integrations fire arbitrary event types (`event_type: String`, `event_data: serde_json::Value`). These use a single `broadcast::Sender<DomainEvent>` where `DomainEvent` carries the type string and data blob. This mirrors HA's `EventBus.async_fire()`.
### 2.3 Service registry: `HashMap` + mpsc dispatch
Services are registered as `(Domain, ServiceName) → ServiceHandler` where `ServiceHandler` is a `Box<dyn Fn(ServiceCall) -> BoxFuture<ServiceResponse> + Send + Sync>`. The registry lives in a `tokio::sync::RwLock<HashMap<(Domain, ServiceName), ServiceHandler>>`. Service calls go through the event bus (fire `call_service`) and are dispatched to the handler by an internal router task. This matches HA's indirection: `hass.services.async_call(domain, service, data)` does not call the handler directly; it fires an event.
### 2.4 Entity registry: persisted metadata sidecar
The entity registry is a `RwLock<HashMap<EntityId, EntityEntry>>` backed by an async JSON writer that flushes to `.homecore/storage/core.entity_registry` on every write. The schema matches HA's `core.entity_registry` schema (version 13 as of HA 2025.1) so ADR-134 migration can read both formats interchangeably.
`EntityEntry` fields mirrored from HA:
- `entity_id: EntityId`
- `unique_id: Option<String>`
- `platform: String`
- `name: Option<String>` (user override)
- `disabled_by: Option<DisabledBy>` (user, integration, config_entry)
- `area_id: Option<AreaId>`
- `device_id: Option<DeviceId>`
- `entity_category: Option<EntityCategory>` (config, diagnostic)
- `config_entry_id: Option<ConfigEntryId>`
### 2.5 Device registry: parallel sidecar
`DeviceRegistry` mirrors HA's `core.device_registry` schema (version 13). Devices are identified by a set of `(id_type, id_value)` tuples (the `identifiers` field), which matches HA's pattern of accepting multiple identifier types per device (MAC address, serial number, integration-specific ID).
---
## 3. HA-side reference table
| HA module / file | What it does | HOMECORE preserves | Changes | Drops |
|---|---|---|---|---|
| `homeassistant/core.py` `StateMachine` | In-memory state store, fire `state_changed` | Same semantics: immutable snapshots, `last_changed`, `last_updated`, `context` | `DashMap` instead of asyncio-locked `dict`; `broadcast::Sender` instead of asyncio callbacks | Python asyncio coupling |
| `homeassistant/core.py` `EventBus` | Pub/sub event dispatch | `MATCH_ALL` listener; per-type listener; event data dict | Typed system events + untyped domain events; no Python dict — use `serde_json::Value` | `@callback` decorator, HassJob abstraction |
| `homeassistant/core.py` `ServiceRegistry` | Register/call services | Same `(domain, service)` key structure; schema validation | Schema validation via `serde` `Deserialize` trait instead of voluptuous | voluptuous, Python type coercions |
| `homeassistant/core.py` `HomeAssistant` | Runtime coordinator / service locator | State machine + event bus + services accessible on one struct | Struct with `Arc<HomeCoreInner>` for cheap cloning across tasks | asyncio event loop holder, Python executor |
| `homeassistant/helpers/entity_registry.py` | Persist entity metadata | All fields listed in §2.4; file format compatible | Async tokio I/O; no Python pickle | Python-specific persistence helpers |
| `homeassistant/helpers/device_registry.py` | Persist device metadata | `identifiers`, `connections`, `manufacturer`, `model`, `name`, `via_device_id` | Async tokio I/O | — |
| `homeassistant/helpers/entity.py` | Entity base class | `entity_id`, `state`, `attributes`, `unique_id`, `device_info`, async_write_ha_state semantics | Trait `HomeCoreEntity` instead of class | Python MRO, `@property` decorators |
| `homeassistant/helpers/event.py` | Convenience event helpers | `async_track_state_change`, `async_track_time_interval` (as Rust timer tasks) | Rust closures / async tasks | Python asyncio task wrappers |
---
## 4. Public API parity table
| HA Python surface | HOMECORE Rust equivalent |
|---|---|
| `hass.states.get(entity_id)` | `hass.states.get(&entity_id) -> Option<Arc<State>>` |
| `hass.states.async_set(entity_id, state, attributes)` | `hass.states.set(entity_id, state, attributes).await` |
| `hass.states.async_remove(entity_id)` | `hass.states.remove(&entity_id).await` |
| `hass.states.async_all(domain_filter)` | `hass.states.all(domain_filter) -> Vec<Arc<State>>` |
| `hass.bus.async_fire(event_type, data)` | `hass.bus.fire(event_type, data).await` |
| `hass.bus.async_listen(event_type, callback)` | `hass.bus.subscribe(event_type) -> broadcast::Receiver<DomainEvent>` |
| `hass.services.async_call(domain, service, data)` | `hass.services.call(domain, service, data).await -> ServiceResponse` |
| `hass.services.async_register(domain, service, handler, schema)` | `hass.services.register(domain, service, handler)` |
| `hass.services.has_service(domain, service)` | `hass.services.has(domain, service) -> bool` |
| `entity_registry.async_get(entity_id)` | `entity_registry.get(&entity_id) -> Option<&EntityEntry>` |
| `entity_registry.async_update_entity(entity_id, **kwargs)` | `entity_registry.update(entity_id, patch).await` |
| `device_registry.async_get_device(identifiers)` | `device_registry.get_by_identifiers(identifiers) -> Option<&DeviceEntry>` |
| `Context(user_id, parent_id)` | `Context { id: Uuid, parent_id: Option<Uuid>, user_id: Option<UserId> }` |
---
## 5. Phased implementation plan
### P1 — Skeleton (2 weeks)
- [ ] Create `v2/crates/homecore/` workspace member with `Cargo.toml`.
- [ ] Define `State`, `EntityId`, `Domain`, `ServiceName`, `Context`, `DomainEvent` types.
- [ ] `StateMachine`: `DashMap` + broadcast channel; `set()`, `get()`, `remove()`, `all()`.
- [ ] `EventBus`: typed broadcast for system events + untyped broadcast for domain events.
- [ ] Unit tests: 50 state writes/reads with concurrent readers; verify broadcast delivery.
### P2 — Service registry + entity registry (2 weeks)
- [ ] `ServiceRegistry`: `RwLock<HashMap>` + mpsc dispatch task.
- [ ] `EntityRegistry`: in-memory + JSON async writer to `.homecore/storage/core.entity_registry`.
- [ ] `DeviceRegistry`: in-memory + JSON async writer to `.homecore/storage/core.device_registry`.
- [ ] Serialization: `serde` with `#[serde(rename_all = "snake_case")]`; schema version 13 header written to match HA format.
- [ ] Unit tests: register service, call service, verify handler invoked; persist and reload entity registry.
### P3 — Trait surface for integrations (1 week)
- [ ] `HomeCoreEntity` trait: `entity_id()`, `unique_id()`, `name()`, `device_info()`, `state()`, `attributes()`, `async_write_ha_state(&hass)`.
- [ ] `Platform` trait: `async_setup_entry(hass, config_entry) -> Result<()>`.
- [ ] `ConfigEntry` struct mirroring HA's `ConfigEntry` fields.
- [ ] Integration test: a minimal test integration registers an entity, writes a state, reads it back from the state machine.
### P4 — Performance validation (1 week)
- [ ] Benchmark: 1,000 state writes/s on Pi 5; measure latency at p50/p95/p99.
- [ ] Benchmark: 100 concurrent WS subscribers each receiving all state_changed events; measure delivery lag.
- [ ] Benchmark: broadcast channel saturation test at 4,096 capacity; verify `RecvError::Lagged` handling.
- [ ] Acceptance criterion: p99 state write latency < 1 ms on Pi 5 (8 GB, 4 cores).
---
## 6. Risks
| Risk | Likelihood | Severity | Mitigation | Cross-ADR impact |
|---|---|---|---|---|
| **Broadcast channel lag** — a slow subscriber (e.g. ruvector recorder write) lags behind and drops events | Medium | High | Give recorder its own channel separate from WS subscribers; recorder is the hot path, give it highest priority | ADR-132: recorder write path must be designed to keep up with 100 Hz state writes |
| **DashMap contention** — shard count default (16) may be too low for 100 Hz writes on a single entity | Low | Medium | Increase DashMap shard count to 64; benchmark before ADR-130 integration | ADR-130: REST API reads state directly from DashMap — must be lock-free |
| **Entity registry format drift** — HA updates `.storage/core.entity_registry` schema; HOMECORE falls behind | Medium | Medium | Pin to schema version 13; version-check on load; fail loudly on unknown version | ADR-134: migration tool reads HA entity registry — must support the same schema version |
| **Context propagation** — HA's `Context` is used for audit trails (which automation triggered which service call). HOMECORE must propagate it correctly or automation audits break | High | Low | Derive `Context` from source event at every service call; thread through `ServiceCall.context` field | ADR-129: automation engine must supply context when calling services |
---
## 7. Open questions
**Q1**: Should `EntityId` validation be strict (reject anything that doesn't match `[a-z0-9_]+\.[a-z0-9_]+`) or lenient (accept any UTF-8 string)? HA itself accepts unicode entity IDs since 2024.3. Strict validation simplifies routing; lenient matches HA's actual behaviour.
**Q2**: The `broadcast::Sender` capacity of 4,096 is chosen based on a worst-case of 100 state writes/s × 40 s of acceptable lag before a slow receiver is declared dead. Is 40 s the right threshold, or should it be configurable per receiver?
**Q3**: Should the `HomeCoreEntity` trait be object-safe (enabling `Vec<Box<dyn HomeCoreEntity>>`) or use associated types (enabling monomorphisation)? Object safety is required for the WASM plugin boundary (ADR-128); monomorphisation is faster for built-in integrations.
**Q4**: HA's `State.context` carries a `user_id` that traces which user or automation initiated a state change. HOMECORE uses `UserId` from the auth layer (ADR-130). Is the auth layer a dependency of the core state machine, or should `user_id` be an optional opaque string to avoid circular deps?
---
## 8. References
### HA upstream
- `homeassistant/core.py``HomeAssistant`, `StateMachine` (lines 1800), `EventBus` (lines 8001100), `ServiceRegistry` (lines 11001500), `Config` (lines 15002000)
- `homeassistant/helpers/entity_registry.py``EntityRegistry`, `RegistryEntry` (all ~1,900 lines); schema version constant `STORAGE_VERSION`
- `homeassistant/helpers/device_registry.py``DeviceRegistry`, `DeviceEntry`; schema version
- `homeassistant/helpers/entity.py``Entity` base class; `async_write_ha_state`; entity lifecycle hooks
- `homeassistant/helpers/event.py``async_track_state_change`, `async_track_time_interval`
### This repo
- `v2/crates/wifi-densepose-sensing-server/src/main.rs` — Axum + Tokio architecture pattern used throughout the existing server stack
- `docs/adr/ADR-126-ruview-native-ha-port-master.md` — HOMECORE master; §5.5 crate naming; §6 compatibility contract; §5.1 RUVIEW-POLICY
- `docs/adr/ADR-028-esp32-capability-audit.md` — witness chain pattern (Ed25519 per state transition)
@@ -1,270 +0,0 @@
# ADR-128: HOMECORE-PLUGINS — WASM integration plugin system
| Field | Value |
|-------|-------|
| **Status** | Proposed |
| **Date** | 2026-05-25 |
| **Deciders** | ruv |
| **Codename** | **HOMECORE-PLUGINS** |
| **Relates to** | [ADR-126](ADR-126-ruview-native-ha-port-master.md) (HOMECORE master), [ADR-127](ADR-127-homecore-state-machine-rust.md) (HOMECORE-CORE), [ADR-102](ADR-102-edge-module-registry.md) (cog registry), [ADR-100](ADR-100-cog-packaging-specification.md) (cog packaging spec) |
| **Tracking issue** | TBD |
---
## 1. Context
Home Assistant ships approximately 2,000 integrations, each a Python module in `homeassistant/components/<domain>/`. Each integration:
1. Declares a **manifest** (`manifest.json`) with `domain`, `name`, `version`, `requirements` (pip packages), `dependencies` (other HA integrations), `codeowners`, `iot_class`, `config_flow` (bool), and `quality_scale`.
2. Provides **`async_setup`** (global domain setup, called once at HA startup) and/or **`async_setup_entry`** (per-config-entry setup, called when a user adds an integration via the UI).
3. Imports Python packages from `requirements` at load time — these are installed into HA's Python environment by the loader at first run.
4. Communicates with the HA core exclusively through the `hass` object (the `HomeAssistant` instance) — setting states, calling services, registering services, subscribing to events.
In Python HA, integrations run **in-process** with the hub. A buggy integration can crash the event loop, read arbitrary HA memory, or import packages that conflict with other integrations. HA mitigates this via code review and quality scale requirements, but there is no runtime isolation boundary.
### 1.1 The Cognitum Seed cog system
The project already has a cog system (ADR-102, ADR-100) for the Cognitum Seed appliance. A **cog** is a signed, sandboxed module that installs from the Seed app registry. ADR-101 (`cog-pose-estimation`) shipped signed aarch64/x86_64 binaries with a model weight blob. ADR-116 (`cog-ha-matter`) shipped HA+Matter integration as a cog.
The cog system uses a different packaging model from HA integrations (binary artifacts vs Python packages), but the same conceptual pattern: a manifest, a lifecycle hook, and communication through a defined interface.
HOMECORE-PLUGINS unifies these two patterns: every HOMECORE integration is a **WASM module** that speaks the cog ABI, can be hot-loaded without restarting the hub, and is sandboxed by the WASM runtime.
---
## 2. Decision
HOMECORE integrations are **WASM modules** loaded by a Rust host runtime (`homecore-plugins` crate). Each plugin:
1. Compiles to a `.wasm` binary (from Rust, AssemblyScript, Go, or any WASM-targeting language).
2. Declares a `manifest.json` (superset of HA's manifest schema — see §3).
3. Exports exactly three WASM functions: `setup_entry(config_entry_ptr, config_entry_len) → i32`, `call_service(call_ptr, call_len) → i32`, and `receive_event(event_ptr, event_len) → i32`.
4. Imports a set of **host functions** from the HOMECORE host runtime: `hc_state_get`, `hc_state_set`, `hc_event_fire`, `hc_service_call`, `hc_log`, `hc_entity_register`.
5. Communicates with the host exclusively through those imports — no direct memory access outside its own linear memory.
The WASM runtime is **Wasmtime** (Cranelift JIT on Pi 5 and x86_64; interpretation mode available for low-memory targets via `--features wasm3`).
### 2.1 Why WASM over Python-in-process
| Criterion | Python in-process (HA today) | WASM sandbox (HOMECORE) |
|---|---|---|
| Memory isolation | None — any integration can read any HA object | WASM linear memory; host allocates shared buffer only for ABI calls |
| Crash isolation | Integration panic = HA event loop crash | WASM trap = plugin terminated, hub continues |
| Language support | Python only | Any WASM-targeting language: Rust, Go, AssemblyScript, C, Zig |
| Hot-load without restart | No — requires `asyncio.run_coroutine_threadsafe` patching | Yes — Wasmtime `Engine` + `Module::deserialize` from compiled `.cwasm` cache |
| Dependency conflicts | pip requirements collide across integrations | Each WASM module carries its own static dependencies (no runtime pip) |
| Startup cost per integration | Python import + pip install | Wasmtime JIT compile (~5 ms for a typical 200 kB WASM module); cached to `.cwasm` |
### 2.2 Cog system as the plugin substrate
The existing cog system (ADR-102) is the distribution and lifecycle layer. HOMECORE-PLUGINS extends it:
- **Distribution**: cogs are fetched from the Seed app registry (`app-registry.json`) or from a HOMECORE plugin registry (superset of the cog registry, same JSON schema + a `wasm_module` field).
- **Lifecycle**: `cognitum-agent` (ADR-116) already handles OTA update, signature verification, and sandboxed execution. HOMECORE-PLUGINS reuses this lifecycle by treating each HOMECORE integration as a cog with a WASM payload.
- **Ed25519 signatures**: every plugin `.wasm` is signed with the publisher's Ed25519 key. The HOMECORE host verifies the signature before compiling the module (same pattern as ADR-028 witness chain).
---
## 3. Manifest schema
HOMECORE's manifest is a superset of HA's `manifest.json`. Fields not present in HA are marked **[HOMECORE]**.
```json
{
"domain": "mqtt",
"name": "MQTT",
"version": "2025.1.0",
"documentation": "https://www.home-assistant.io/integrations/mqtt/",
"iot_class": "local_push",
"config_flow": true,
"dependencies": [],
"quality_scale": "platinum",
"wasm_module": "mqtt.wasm",
"wasm_module_hash": "sha256:abcdef...",
"wasm_module_sig": "ed25519:<base64>",
"publisher_key": "<base64 Ed25519 public key>",
"min_homecore_version": "0.1.0",
"host_imports_required": ["hc_state_get", "hc_state_set", "hc_event_fire", "hc_service_call"],
"homecore_permissions": ["state:write:sensor.*", "state:read:*", "service:call:homeassistant.*"],
"cog_id": "homecore-mqtt-2025.1.0"
}
```
**[HOMECORE]** fields:
- `wasm_module` — relative path to the `.wasm` binary
- `wasm_module_hash` — SHA-256 of the wasm binary; verified before execution
- `wasm_module_sig` — Ed25519 signature of the wasm binary hash
- `publisher_key` — Ed25519 public key of the publisher
- `min_homecore_version` — minimum HOMECORE version required
- `host_imports_required` — subset of host functions the module needs (security auditable)
- `homecore_permissions` — coarse-grained permission claims (glob patterns); future: enforcement via RUVIEW-POLICY layer (ADR-124 §4.1a)
- `cog_id` — Seed app registry ID for the cog distribution
---
## 4. HA-side reference table
| HA module / file | What it does | HOMECORE preserves | Changes | Drops |
|---|---|---|---|---|
| `homeassistant/components/<domain>/manifest.json` | Integration metadata | `domain`, `name`, `version`, `iot_class`, `config_flow`, `dependencies`, `quality_scale`, `documentation` | Add WASM fields; remove `requirements` (no pip) | `requirements` (pip packages) |
| `homeassistant/loader.py` | Loads Python modules; installs pip requirements | Manifest parsing; dependency resolution between cogs | WASM module loading via Wasmtime; no pip | Python `importlib`, pip subprocess |
| `homeassistant/components/<domain>/__init__.py` | `async_setup` + `async_setup_entry` | `setup_entry` hook (per config entry) | WASM export function instead of Python async function | Python module structure |
| `homeassistant/config_entries.py` | Config entry lifecycle management | `ConfigEntry` struct: `entry_id`, `domain`, `title`, `data`, `options`, `state`, `version` | Rust struct; async state machine | Python class hierarchy; `FlowManager` |
| `homeassistant/components/<domain>/config_flow.py` | UI configuration flow | Config flow metadata (steps, schemas) | JSON-schema-based flow descriptor shipped in manifest | `voluptuous`, Python UI flow runtime |
---
## 5. WASM ABI specification
### 5.1 Host functions imported by plugins
```
hc_state_get(key_ptr: i32, key_len: i32, out_ptr: i32, out_cap: i32) → i32
// Returns JSON-encoded State into out_ptr buffer; returns bytes written or -1 if not found.
hc_state_set(entity_ptr: i32, entity_len: i32, state_ptr: i32, state_len: i32,
attrs_ptr: i32, attrs_len: i32) → i32
// Sets state for entity_id; returns 0 on success, negative on error.
hc_event_fire(event_type_ptr: i32, event_type_len: i32,
event_data_ptr: i32, event_data_len: i32) → i32
// Fires a domain event.
hc_service_call(domain_ptr: i32, domain_len: i32,
service_ptr: i32, service_len: i32,
data_ptr: i32, data_len: i32) → i32
// Calls a service synchronously from the plugin's perspective (async on the host).
hc_entity_register(entry_ptr: i32, entry_len: i32) → i32
// Registers an entity with the entity registry; entry is JSON-encoded EntityEntry.
hc_log(level: i32, msg_ptr: i32, msg_len: i32) → void
// Structured log output; level: 0=debug, 1=info, 2=warn, 3=error.
```
### 5.2 WASM exports required by host
```
setup_entry(config_entry_ptr: i32, config_entry_len: i32) → i32
// Called when a config entry is set up. config_entry is JSON-encoded ConfigEntry.
// Returns 0 on success, negative error code on failure.
call_service_handler(domain_ptr: i32, domain_len: i32,
service_ptr: i32, service_len: i32,
data_ptr: i32, data_len: i32) → i32
// Called when a service registered by this plugin is invoked.
receive_event(event_type_ptr: i32, event_type_len: i32,
event_data_ptr: i32, event_data_len: i32) → i32
// Called when an event type the plugin subscribed to fires.
// Subscription is declared in manifest `subscribed_events` array.
alloc(size: i32) → i32
// Host calls this to allocate a buffer inside the WASM linear memory
// before writing data for a callback. Required for ABI memory passing.
dealloc(ptr: i32, size: i32) → void
// Host calls this to free a previously allocated buffer.
```
### 5.3 Execution model
Each WASM module instance runs in its own Wasmtime `Store`. The host calls WASM exports from a dedicated Tokio task per plugin. Incoming events are queued in an `mpsc::Sender<PluginEvent>` per plugin; the plugin task drains the queue and calls `receive_event`. This isolates plugin execution from the hot state-machine path.
---
## 6. Public API parity table
| HA integration pattern | HOMECORE WASM equivalent |
|---|---|
| `async_setup_entry(hass, entry)` Python async function | `setup_entry(config_entry_json)` WASM export |
| `hass.states.async_set(entity_id, state, attrs)` | `hc_state_set(...)` host import |
| `hass.states.get(entity_id)` | `hc_state_get(...)` host import |
| `hass.bus.async_fire(event_type, data)` | `hc_event_fire(...)` host import |
| `hass.services.async_call(domain, service, data)` | `hc_service_call(...)` host import |
| `hass.services.async_register(domain, service, handler)` | Declared in manifest `registered_services`; `call_service_handler` WASM export handles all |
| `async_track_state_change(hass, entity_ids, callback)` | Declared in manifest `subscribed_state_entities`; `receive_event` called with `state_changed` events |
| Config flow `FlowManager.async_init()` | Config flow metadata in manifest; UI calls HOMECORE-API `/config/config_entries/flow` |
| `ConfigEntry.entry_id`, `.domain`, `.data`, `.options` | Same fields in `ConfigEntry` JSON passed to `setup_entry` |
---
## 7. Phased implementation plan
### P1 — WASM host skeleton (2 weeks)
- [ ] Create `v2/crates/homecore-plugins/` workspace member.
- [ ] Wasmtime dependency; compile a trivial WASM module that calls `hc_log` and verify it runs.
- [ ] Define the host function ABI in a `host_api.rs` module; write the Wasmtime `Linker` registration for all 6 host functions.
- [ ] Manifest schema: `serde`-deserialised `Manifest` struct; validate required fields.
- [ ] Hash + Ed25519 signature verification of `.wasm` bytes before compilation.
### P2 — State machine bridge (2 weeks)
- [ ] Wire `hc_state_get` and `hc_state_set` to the `homecore` state machine (ADR-127).
- [ ] Wire `hc_event_fire` to the event bus.
- [ ] Wire `hc_service_call` to the service registry.
- [ ] Wire `hc_entity_register` to the entity registry.
- [ ] Write a test plugin in Rust compiled to WASM: registers one entity, writes its state via host imports, verifies the state machine sees the update.
### P3 — Config entry lifecycle + hot-load (2 weeks)
- [ ] `ConfigEntryManager` — tracks loaded plugins, calls `setup_entry` on new config entries, handles teardown.
- [ ] Hot-load: watch a directory for new `.wasm` + `manifest.json` pairs; load without hub restart.
- [ ] Wasmtime compiled module cache: serialize to `.cwasm` after first JIT compile; deserialize on subsequent loads (sub-1 ms plugin restart).
- [ ] Integration test: MQTT plugin loaded at runtime, registers `sensor.test` entity, state readable via HOMECORE-API.
### P4 — Cog registry integration (1 week)
- [ ] Fetch plugin from Seed app registry `app-registry.json`; verify Ed25519 signature against publisher key.
- [ ] Expose `/api/homecore/plugins` REST endpoint (HOMECORE-API ADR-130 extension): list loaded plugins, load new plugin by URL, unload plugin.
- [ ] First-party plugin: ship an MQTT plugin WASM module that provides the same function as HA's `homeassistant/components/mqtt/`.
### P5 — Permission enforcement (1 week)
- [ ] Enforce `homecore_permissions` claims: reject `hc_state_set` calls that write to entities outside the plugin's declared `state:write:*` pattern.
- [ ] Log all permission denials to the Ed25519 witness chain.
- [ ] Expose permission audit via `/api/homecore/plugins/<domain>/audit`.
---
## 8. Risks
| Risk | Likelihood | Severity | Mitigation | Cross-ADR impact |
|---|---|---|---|---|
| **ADR-127 state machine not stable** — plugin ABI calls into the state machine; if the API changes, all plugins break | High (early phase) | High | Freeze the `hc_state_get`/`hc_state_set` ABI in P1; never change pointer/length convention; version the host ABI in the manifest `min_homecore_version` | ADR-127 must freeze public API before ADR-128 P2 begins |
| **Wasmtime binary size** — adding Wasmtime to HOMECORE adds ~15 MB to the binary on Pi 5 | Medium | Medium | Use Cranelift JIT only; skip LLVM optimizer. Alternative: `wasm3` feature flag (~50 kB) for constrained hardware | ADR-126: binary size target < 50 MB idle RAM; Wasmtime itself uses ~5 MB RAM at runtime |
| **ABI memory overhead** — every state read/write from a plugin must JSON-encode/decode through shared memory | Medium | Medium | Cap state value size at 64 kB; use a pool allocator for ABI buffers; profile on Pi 5 at 10 state writes/s per plugin | ADR-130: REST API reads state from DashMap directly, bypassing plugin ABI — no overhead there |
| **Community plugin trust** — WASM sandbox prevents crashes but cannot prevent malicious plugins from calling `hc_service_call` to turn off all lights | Medium | High | `homecore_permissions` permission claims (P5); future: RUVIEW-POLICY enforcement (ADR-124 §4.1a) for biometric data access | ADR-124 RUVIEW-POLICY must be made aware of HOMECORE as a policy principal |
---
## 9. Open questions
**Q1**: Should the WASM module ABI use JSON-over-shared-memory (current proposal) or a more compact binary encoding (MessagePack, FlatBuffers)? JSON is simpler to debug and matches HA's existing JSON-everywhere convention; MessagePack cuts ABI overhead by ~4×. Decide before P2 implementation.
**Q2**: HA's `config_flow.py` is a multi-step UI wizard with voluptuous schema validation. HOMECORE's config flow is described in the manifest JSON. Is a JSON-schema-based config flow sufficient for the 100 most popular integrations, or do some require imperative step logic that can't be expressed declaratively?
**Q3**: Should existing Python HA community integrations be automatically compilable to WASM via a transpilation layer (e.g. CPython compiled to WASM via Pyodide), or should HOMECORE accept only natively compiled WASM modules? Pyodide+WASM would make migration easier but adds ~25 MB per plugin and loses the performance argument.
**Q4**: The `host_imports_required` manifest field lists which host functions the plugin needs. Should this be verified at load time (reject plugin that imports undeclared functions) or only advisory? Strict enforcement prevents surprises; advisory aids migration.
---
## 10. References
### HA upstream
- `homeassistant/loader.py` — integration loader; pip requirement installation; `async_setup_entry` invocation
- `homeassistant/config_entries.py``ConfigEntry`, `ConfigEntryState`, `ConfigEntriesError`, `FlowManager`
- `homeassistant/components/mqtt/manifest.json` — canonical example of HA manifest structure
- `homeassistant/components/mqtt/__init__.py``async_setup_entry` pattern for a complex integration with services
- `homeassistant/components/mqtt/config_flow.py` — multi-step config flow example
### This repo
- `docs/adr/ADR-102-edge-module-registry.md` — cog registry architecture; `app-registry.json` schema
- `docs/adr/ADR-100-cog-packaging-specification.md` — cog packaging spec; Ed25519 signing
- `docs/adr/ADR-101-pose-estimation-cog.md` — cog lifecycle precedent
- `docs/adr/ADR-127-homecore-state-machine-rust.md` — state machine ABI that plugins call
- `docs/adr/ADR-126-ruview-native-ha-port-master.md` — §5.7 "do not port" list (legacy Python integrations)
@@ -1,212 +0,0 @@
# ADR-129: HOMECORE-AUTO — Automation engine, script runner, and template evaluator
| Field | Value |
|-------|-------|
| **Status** | Proposed |
| **Date** | 2026-05-25 |
| **Deciders** | ruv |
| **Codename** | **HOMECORE-AUTO** |
| **Relates to** | [ADR-126](ADR-126-ruview-native-ha-port-master.md) (HOMECORE master), [ADR-127](ADR-127-homecore-state-machine-rust.md) (HOMECORE-CORE), [ADR-129 implicit](ADR-129-homecore-automation-engine.md), [ADR-133](ADR-133-homecore-assist-ruflo.md) (HOMECORE-ASSIST) |
| **Tracking issue** | TBD |
---
## 1. Context
Home Assistant's automation system is defined across three components:
1. **`homeassistant/components/automation/__init__.py`** — the automation manager: loads automation YAML, evaluates trigger platforms, calls the script executor when conditions pass. The core class is `AutomationEntity` which extends `ToggleEntity`. Automations are themselves HA entities with `state = on/off`.
2. **`homeassistant/components/script/__init__.py`** — the script executor: a sequence of actions (service calls, conditions, delays, events, template variables, `choose`, `parallel`, `repeat`, `wait_for_trigger`). Scripts are entities too (`ScriptEntity` extends `ToggleEntity`). The execution engine supports five run modes: `single`, `restart`, `queued`, `parallel`, `ignore_first`.
3. **`homeassistant/helpers/template.py`** — HA's Jinja2 customisation layer: wraps the upstream `jinja2` Python library with HA-specific globals (`states()`, `is_state()`, `state_attr()`, `now()`, `utcnow()`, `as_timestamp()`, `distance()`, `closest()`, etc.), custom filters (`regex_match`, `round`, `timestamp_local`), and a sandboxed `Environment` that prevents file I/O and dangerous evaluations.
### 1.1 Scale and surface
HA's automation YAML supports:
- **17 trigger platforms** (state, time, numeric_state, template, event, homeassistant, zone, geo_location, device, calendar, conversation, mqtt, webhook, tag, sun, time_pattern, persistent_notification)
- **7 condition types** (state, numeric_state, time, template, zone, sun, device)
- **22+ action types** (call_service, delay, wait_template, fire_event, device_action, choose, if, parallel, repeat, sequence, stop, set_conversation_response, ...)
The YAML schema is validated by `voluptuous` schemas defined in `homeassistant/helpers/config_validation.py` (~5,000 lines).
### 1.2 Jinja2 is the critical surface
HA templates are used not only in automations but in dashboard cards, notification messages, and script variables. The HA frontend sends template strings to the API's `POST /api/template` endpoint for server-side evaluation. Any HOMECORE instance that claims API compatibility must execute Jinja2-compatible templates or existing automations will break.
Full Jinja2 support in Rust without Python is non-trivial. The approach chosen here uses a **WASM-compiled MiniJinja** (the `minijinja` Rust crate compiled with HA-specific extension functions) rather than a full Python Jinja2 re-implementation.
---
## 2. Decision
Build the `homecore-automation` crate with three components:
1. **YAML parser**: `serde_yaml` + custom validator that parses HA's automation and script YAML into typed Rust structs. Validates trigger, condition, and action schemas at load time.
2. **Trigger evaluator**: a Tokio task per loaded automation that subscribes to the HOMECORE event bus (ADR-127) and evaluates trigger conditions in Rust. When a trigger fires and conditions pass, it enqueues the automation action sequence.
3. **Action executor**: a script runner that processes action sequences. Service calls go to the HOMECORE service registry. Delays use `tokio::time::sleep`. Template evaluation uses MiniJinja. Complex conditions (optional) can route to a ruflo agent (ADR-133).
### 2.1 Template evaluator: MiniJinja + HA-compatible extension functions
`minijinja` (crates.io version 2.x) is a production-quality Jinja2 implementation in pure Rust. It is missing 510% of Jinja2's surface area (notably: `{% block %}` / `{% extends %}` template inheritance, and some Jinja2 Python-specific filters), but covers 100% of HA's automation template usage.
HA-specific globals added on top of MiniJinja:
```rust
env.add_global("states", minijinja::Value::from_function(ha_states_global));
env.add_global("is_state", minijinja::Value::from_function(ha_is_state_global));
env.add_global("state_attr", minijinja::Value::from_function(ha_state_attr_global));
env.add_global("now", minijinja::Value::from_function(ha_now_global));
env.add_global("utcnow", minijinja::Value::from_function(ha_utcnow_global));
env.add_global("as_timestamp", minijinja::Value::from_function(ha_as_timestamp_global));
env.add_global("distance", minijinja::Value::from_function(ha_distance_global));
env.add_global("iif", minijinja::Value::from_function(ha_iif_global));
```
Each global function reads from the HOMECORE state machine (ADR-127) via an `Arc<StateMachine>` captured at environment construction time. Template evaluation is synchronous (MiniJinja is sync) but runs in a `tokio::task::spawn_blocking` wrapper to avoid blocking the async executor.
### 2.2 WASM evaluator for untrusted template strings
Dashboard card templates submitted via `POST /api/template` come from user-authored YAML, not first-party code. HA evaluates these in the same Python process, relying on Jinja2's `SandboxedEnvironment` for safety. HOMECORE uses a **WASM-sandboxed MiniJinja** evaluator:
- A single WASM module (`homecore-template-eval.wasm`) is compiled from the MiniJinja crate with the HA extension globals stubbed to call host functions.
- Template strings are passed into the WASM module via the HOMECORE plugin ABI (ADR-128 §5.1).
- The WASM sandbox prevents file I/O, network access, and infinite loops (via Wasmtime fuel metering — 100,000 instructions per template evaluation).
- Result is returned as a string to the HOMECORE API.
This is the same Wasmtime host already used for integration plugins (ADR-128) — no additional WASM runtime dependency.
---
## 3. HA-side reference table
| HA module / file | What it does | HOMECORE preserves | Changes | Drops |
|---|---|---|---|---|
| `automation/__init__.py` `AutomationEntity` | Automation as a toggle entity (on/off) with triggers/conditions/actions | Automation is a HOMECORE entity with same on/off state semantics | Rust struct `AutomationEntity` implementing `HomeCoreEntity` trait | Python class hierarchy, voluptuous schema |
| `automation/__init__.py` `TriggerActionConfig` | Trigger → condition → action pipeline | Full trigger/condition/action pipeline | Typed Rust enums per trigger platform | Python dict-based config |
| `automation/trigger.py` | Delegates to per-platform trigger modules (`homeassistant/components/<platform>/trigger.py`) | Same per-platform dispatch | Rust match arm per trigger type | Python dynamic module import |
| `script/__init__.py` `Script` | Script entity + action sequence executor | Same 22 action types | Rust enum `Action` with all variants | Python asyncio coroutines |
| `script/__init__.py` run modes | `single`, `restart`, `queued`, `parallel`, `ignore_first` | All 5 run modes | Tokio-based concurrency control (semaphore for `queued`, `parallel`) | Python asyncio task management |
| `helpers/template.py` `Template` | Jinja2 evaluation + HA globals | Same HA global function names and signatures | MiniJinja instead of Python Jinja2; WASM sandbox for user templates | Python `jinja2` library; `voluptuous` coercions in templates |
| `helpers/config_validation.py` | `cv.template`, `cv.entity_id`, time period validators | Same validation semantics | Rust custom deserializers implementing `serde::Deserialize` | voluptuous; Python regex |
| `components/automation/blueprint.py` | Blueprint system (reusable automation templates with input variables) | Blueprint YAML schema + variable substitution | Pure Rust YAML substitution | Python Blueprint class hierarchy |
---
## 4. Public API parity table
| HA automation surface | HOMECORE equivalent |
|---|---|
| `automation.trigger` (state, time, numeric_state, template, event, ...) | `Trigger` enum with variants for all 17 HA trigger platforms |
| `automation.condition` (state, numeric_state, time, template, zone, sun, device) | `Condition` enum with variants for all 7 condition types |
| `automation.action` — call_service, delay, fire_event, choose, if, parallel, repeat, wait_template, stop | `Action` enum with variants for all 22 action types |
| `script.run_mode` — single, restart, queued, parallel | `RunMode` enum with 5 variants |
| `POST /api/template` (REST eval of a template string) | Same endpoint in HOMECORE-API (ADR-130); backed by WASM-sandboxed MiniJinja |
| Automation entity: `state = on|off`, `attributes.last_triggered`, `attributes.id` | `AutomationEntity` struct with same attribute names |
| `automation.trigger` service (manually trigger an automation) | `homecore.automation.trigger` service; same service call data schema |
| `automation.reload` service (reload automations.yaml) | `homecore.automation.reload` service |
| `automation.toggle` service | Standard `HomeCoreEntity` toggle service |
| Blueprint YAML with `blueprint:` key and `input:` variables | Blueprint parsed by HOMECORE YAML parser; same substitution semantics |
---
## 5. Trigger platform mapping
| HA trigger platform | HOMECORE implementation |
|---|---|
| `state` | Subscribe to `state_changed` broadcast; match `entity_id`, `from`, `to`, `for` |
| `numeric_state` | Subscribe to `state_changed`; parse state as f64; compare against `above`/`below` |
| `time` | `tokio::time::sleep_until` to next occurrence; re-arm after fire |
| `time_pattern` | Cron-style evaluation using `cron` crate; tokio timer task |
| `template` | Re-evaluate template on every `state_changed`; fire when template transitions from false to true |
| `event` | Subscribe to named domain event on event bus |
| `homeassistant` (start/stop) | Subscribe to `HomeAssistantStart` / `HomeAssistantStop` typed events |
| `zone` | Subscribe to `zone.entered` / `zone.left` events from the device tracker integration |
| `mqtt` | Subscribe to MQTT topic via the MQTT plugin (ADR-128); fire event when message arrives |
| `webhook` | HOMECORE-API registers a webhook path; fires event on POST |
| `calendar` | Subscribe to calendar event from calendar integration |
| `conversation` | Subscribe to `conversation.user_input` event; match intent/sentence |
| `geo_location` | Subscribe to `geo_location.entered` / `geo_location.left` |
| `sun` | Compute sunrise/sunset from latitude/longitude in `homecore.config`; tokio timer |
| `device` | Delegate to integration-specific device trigger via WASM plugin |
| `persistent_notification` | Subscribe to `persistent_notification.create` event |
| `tag` | Subscribe to `tag.scanned` event from NFC/QR integration |
---
## 6. Phased implementation plan
### P1 — YAML parser (2 weeks)
- [ ] Define Rust enums for `Trigger`, `Condition`, `Action`, `RunMode` with `serde` deserialization.
- [ ] Parse an existing `automations.yaml` from a real HA install with zero errors (test fixture).
- [ ] Validator: reject unknown trigger platforms with a clear error message.
- [ ] Unit tests: parse 50 automation fixtures covering all 17 trigger types and 22 action types.
### P2 — State and event triggers (2 weeks)
- [ ] Implement `state`, `numeric_state`, `event`, `homeassistant`, `time`, `time_pattern` trigger evaluators.
- [ ] `ConditionEvaluator` for `state`, `numeric_state`, `time` conditions.
- [ ] `ActionExecutor` for `call_service`, `delay`, `fire_event`, `stop` action types.
- [ ] Integration test: load one automation (state trigger → call_service action); verify fires correctly when state changes.
### P3 — Full action set + MiniJinja (3 weeks)
- [ ] MiniJinja + HA extension globals; `POST /api/template` endpoint wired to WASM evaluator.
- [ ] `template` trigger + `template` condition evaluators.
- [ ] `choose`, `if`, `parallel`, `repeat`, `wait_template`, `sequence` action types.
- [ ] All 5 `RunMode` variants (concurrency control via Tokio semaphore/mutex).
- [ ] Integration test: `automations.yaml` from ADR-134 migration fixture loads and runs correctly.
### P4 — Blueprint system + ruflo agent condition (1 week)
- [ ] Blueprint YAML parser + input variable substitution.
- [ ] Optional ruflo agent condition: `condition: ruflo_agent` with `query: "..."` routes to ruflo LLM (ADR-133 §3.3); gated by RUVIEW-POLICY.
- [ ] `automation.reload` service.
- [ ] Performance benchmark: 100 automations loaded; 100 state changes/s; verify trigger evaluation stays < 5 ms per state change.
---
## 7. Risks
| Risk | Likelihood | Severity | Mitigation | Cross-ADR impact |
|---|---|---|---|---|
| **MiniJinja gaps** — some HA templates use Jinja2 features MiniJinja doesn't support (template inheritance, Python-specific filters) | Medium | Medium | Document the MiniJinja-vs-Jinja2 delta before P3 ships; provide a migration guide for affected templates; defer the 5% of templates that fail to a Python-compat shim (ADR-134) | ADR-134: migration tool must warn on templates that use unsupported Jinja2 features |
| **Template performance** — synchronous MiniJinja in `spawn_blocking` adds overhead under high automation fan-out | Low | Low | Benchmark at 50 automations each evaluating a template trigger on every state_changed (worst case); if > 2 ms add a template-evaluation cache keyed by (template_hash, relevant_entity_states) | ADR-127: state machine must expose a "relevant states snapshot" API for caching |
| **ADR-127 state machine API not frozen** — trigger evaluators call `hass.states.all()` and subscribe to broadcasts; if those APIs change, trigger code must update | High (early) | High | ADR-127 must freeze its public API before ADR-129 P2 begins; use a `HomeCoreRef` trait (version 1.0 stable) | ADR-127 owns this dependency |
| **Complex action YAML** — real-world automations use deeply nested `choose`/`if`/`parallel` blocks; parsing is non-trivial | Medium | Medium | Use a corpus of 500 public HA automations from the HA community (MIT-licensed) as parse-test fixtures in CI | None |
---
## 8. Open questions
**Q1**: MiniJinja does not support all Python-specific Jinja2 filters (e.g. `map`, `select`, `reject` with Python lambda arguments). HA's `homeassistant/helpers/template.py` adds custom equivalents of several of these. How many real-world HA automations use these filters? A corpus analysis of public HA configs on GitHub would answer this before P3 implementation.
**Q2**: HA's `template` trigger supports a `value_template` that can reference `trigger.to_state`, `trigger.from_state`, and `trigger.for`. This requires passing trigger context into the template evaluation scope. Is this context threading straightforward in MiniJinja, or does it require a custom context type?
**Q3**: The `conversation` trigger in HA uses the Assist pipeline's intent matching to fire automations based on voice commands. HOMECORE-ASSIST (ADR-133) owns the pipeline. Should the `conversation` trigger be implemented in ADR-129 (automation engine dependency on ADR-133) or in ADR-133 (assist pipeline fires automation events that ADR-129 listens to)?
**Q4**: HA blueprints have a community sharing mechanism (blueprint.exchange). Should HOMECORE support importing blueprints from HA's blueprint exchange directly, or only local blueprints?
---
## 9. References
### HA upstream
- `homeassistant/components/automation/__init__.py``AutomationEntity`, `AutomationConfig`, trigger/condition/action pipeline
- `homeassistant/components/script/__init__.py``Script`, `ScriptEntity`, run modes, action sequence execution
- `homeassistant/helpers/template.py``Template` class, `TemplateEnvironment`, all HA-specific Jinja2 globals and filters
- `homeassistant/helpers/config_validation.py` — voluptuous schema definitions for all automation/script YAML elements
- `homeassistant/components/automation/blueprint.py` — Blueprint input substitution
### This repo
- `docs/adr/ADR-127-homecore-state-machine-rust.md` — state machine and event bus that triggers subscribe to
- `docs/adr/ADR-133-homecore-assist-ruflo.md` — ruflo agent condition + conversation trigger dependency
- `docs/adr/ADR-134-homecore-migration-from-python-ha.md` — migration tool reads `automations.yaml`
### External
- [minijinja crates.io](https://crates.io/crates/minijinja) — Jinja2-compatible template engine in Rust
- [HA Automation Templating docs](https://www.home-assistant.io/docs/automation/templating/) — HA-specific template globals reference
@@ -1,218 +0,0 @@
# ADR-130: HOMECORE-API — Wire-compatible REST and WebSocket API
| Field | Value |
|-------|-------|
| **Status** | Proposed |
| **Date** | 2026-05-25 |
| **Deciders** | ruv |
| **Codename** | **HOMECORE-API** |
| **Relates to** | [ADR-126](ADR-126-ruview-native-ha-port-master.md) (HOMECORE master), [ADR-127](ADR-127-homecore-state-machine-rust.md) (HOMECORE-CORE), [ADR-055](ADR-055-integrated-sensing-server.md) (sensing-server Axum pattern), [ADR-124](ADR-124-rvagent-mcp-ruvector-npm-integration.md) (SENSE-BRIDGE — bearer auth pattern) |
| **Tracking issue** | TBD |
---
## 1. Context
Home Assistant's HTTP and WebSocket APIs are the primary interface for every non-frontend client: the iOS companion app, the Android companion app, HACS, Node-RED, the `homeassistant` Python client library, ESPHome native API clients, external automation scripts, and the hundreds of third-party HA dashboard projects.
The API surface is defined in two Python modules:
1. **`homeassistant/components/api/__init__.py`** — 24 REST API routes mounted at `/api/`. Key routes: `GET /api/`, `GET /api/states`, `GET /api/states/<entity_id>`, `POST /api/states/<entity_id>`, `GET /api/events`, `POST /api/events/<event_type>`, `GET /api/services`, `POST /api/services/<domain>/<service>`, `GET /api/error_log`, `GET /api/config`, `POST /api/template`, `POST /api/check_config`, `GET /api/history/period/<datetime>` (deprecated — recorder), `POST /api/logbook/` (deprecated — recorder).
2. **`homeassistant/components/websocket_api/`** — the WebSocket API handler (`connection.py` handles auth handshake; `commands.py` handles 30+ command types). Key commands: `auth`, `subscribe_events`, `unsubscribe_events`, `call_service`, `get_states`, `get_services`, `get_config`, `subscribe_trigger`, `render_template`, `validate_config`, `subscribe_entities` (entity registry updates), `config/entity_registry/list`, and many more.
### 1.1 Auth model
HA uses **long-lived access tokens (LLAT)** as the primary auth mechanism for non-UI clients. Tokens are created in the HA user profile UI and stored in `.storage/auth`. The REST API accepts `Authorization: Bearer <token>` or the `api_password` legacy header (deprecated since HA 2022.x). The WebSocket API requires an `auth` message with `access_token` as the first message after connection.
### 1.2 Why wire-compat matters
The iOS and Android HA companion apps (>100,000 installs combined) hardcode the HA API paths and WebSocket command schemas. Any implementation that deviates from the exact JSON schemas causes the apps to fail silently — not with a meaningful error, but by returning empty entity lists or missing state updates. Wire-compat is therefore a hard requirement, not a nice-to-have.
The baseline for compatibility is **HA 2025.1** (the version that introduced SQLite recorder schema version 48). Any HOMECORE instance claiming compliance with this ADR must pass the companion app integration test suite.
---
## 2. Decision
Implement the `homecore-api` crate as an Axum-based server that replicates the HA REST and WebSocket API on port 8123. The implementation is informed by — but does not copy — `homeassistant/components/api/__init__.py` and `homeassistant/components/websocket_api/`.
The server reuses the Axum + Tokio architecture established in `v2/crates/wifi-densepose-sensing-server/src/main.rs` and its bearer auth pattern (`v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs`).
### 2.1 REST API route table
| Route | Method | HA source line (approx.) | HOMECORE status |
|---|---|---|---|
| `/api/` | GET | `api/__init__.py:74` | P2 — returns `{ "message": "API running." }` |
| `/api/config` | GET | `api/__init__.py:97` | P2 — returns `homecore.config` as JSON |
| `/api/states` | GET | `api/__init__.py:116` | P2 — returns `hass.states.all()` as JSON array |
| `/api/states/<entity_id>` | GET | `api/__init__.py:130` | P2 |
| `/api/states/<entity_id>` | POST | `api/__init__.py:145` | P2 — writes state; fires `state_changed` |
| `/api/events` | GET | `api/__init__.py:168` | P3 |
| `/api/events/<event_type>` | POST | `api/__init__.py:180` | P3 — fires domain event |
| `/api/services` | GET | `api/__init__.py:192` | P2 |
| `/api/services/<domain>/<service>` | POST | `api/__init__.py:206` | P2 |
| `/api/template` | POST | `api/__init__.py:222` | P3 — WASM MiniJinja evaluator (ADR-129) |
| `/api/check_config` | POST | `api/__init__.py:240` | P4 |
| `/api/error_log` | GET | `api/__init__.py:252` | P3 |
| `/api/history/period/<datetime>` | GET | `api/__init__.py:270` | P4 — recorder query (ADR-132) |
| `/api/logbook/` | POST | `api/__init__.py:310` | P4 — recorder query |
| `/api/camera_proxy/<entity_id>` | GET | `api/__init__.py:330` | P4 — proxy to camera integration |
| `/api/calendar/<entity_id>` | GET | `api/__init__.py:348` | P4 |
| `/api/webhook/<webhook_id>` | POST/GET | `api/__init__.py:368` | P3 — fires `webhook.<id>` event |
| `/api/intent/handle` | POST | `api/__init__.py:400` | P4 — HOMECORE-ASSIST (ADR-133) |
| `/auth/token` | POST | `auth/providers/__init__.py` | P2 — issue LLAT from username/password |
| `/auth/authorize` | GET/POST | `auth/providers/__init__.py` | P3 — OAuth2 flow |
| `/frontend/` static assets | GET | `frontend/__init__.py` | P1 — serve HA Python frontend static files until ADR-131 ships |
### 2.2 WebSocket API command table
| WS command type | HA source | HOMECORE status |
|---|---|---|
| `auth` (handshake) | `websocket_api/connection.py:55` | P2 |
| `subscribe_events` | `websocket_api/commands.py:120` | P2 |
| `unsubscribe_events` | `websocket_api/commands.py:145` | P2 |
| `call_service` | `websocket_api/commands.py:160` | P2 |
| `get_states` | `websocket_api/commands.py:200` | P2 |
| `get_services` | `websocket_api/commands.py:218` | P2 |
| `get_config` | `websocket_api/commands.py:230` | P2 |
| `subscribe_trigger` | `websocket_api/commands.py:250` | P3 |
| `render_template` | `websocket_api/commands.py:280` | P3 |
| `validate_config` | `websocket_api/commands.py:300` | P3 |
| `subscribe_entities` | `websocket_api/commands.py:320` | P3 — entity registry update stream |
| `config/entity_registry/list` | `websocket_api/commands.py:370` | P3 |
| `config/entity_registry/update` | `websocket_api/commands.py:400` | P3 |
| `config/area_registry/list` | `websocket_api/commands.py:450` | P3 |
| `config/device_registry/list` | `websocket_api/commands.py:480` | P3 |
| `config/config_entries/list` | `websocket_api/commands.py:510` | P3 |
| `lovelace/config` (dashboard) | `lovelace/dashboard.py` | P4 — reads from HOMECORE storage |
| `media_player/*` | `websocket_api/commands.py:600` | P4 |
### 2.3 Auth implementation
HOMECORE-API implements long-lived access tokens as JWTs signed with an Ed25519 key (generated at first startup, stored in `.homecore/auth_key.pem`). Token format:
```json
{
"sub": "<user_id>",
"iss": "homecore",
"iat": <unix_timestamp>,
"exp": <unix_timestamp or null for LLAT>,
"type": "long_lived_access_token"
}
```
The HA companion app sends `Authorization: Bearer <token>` on every REST request. The WebSocket auth handshake sends `{ "type": "auth", "access_token": "<token>" }`. Both paths validate the JWT against the stored Ed25519 key.
Legacy `api_password` is deliberately not supported (removed in HA 2022.x and never properly secure).
---
## 3. HA-side reference table
| HA module / file | What it does | HOMECORE preserves | Changes | Drops |
|---|---|---|---|---|
| `components/api/__init__.py` | 24 REST routes + JSON response schemas | All response schemas byte-compatible with HA 2025.1 | Axum router instead of HA's custom HTTP component; `serde_json` instead of Python `json` | Python HTTP request context; HA's built-in CORS middleware (replicated in Axum) |
| `components/websocket_api/connection.py` | WS auth handshake; per-connection state; message dispatch | Auth handshake flow: `auth_required``auth` message → `auth_ok` or `auth_invalid` | Axum `WebSocketUpgrade` extractor; per-connection `tokio::task` | Python asyncio message handling |
| `components/websocket_api/commands.py` | 30+ WS command handlers | All command type strings; response envelope `{ id, type, result }` or error `{ id, type, error: { code, message } }` | Rust match dispatch; Tokio broadcast receiver per subscription | Python class-based command handler registration |
| `auth/providers/__init__.py` | Auth providers; LLAT issuance; OAuth2 flow | LLAT issuance; token validation | Ed25519 JWT instead of HA's custom token serializer; same token `type` field values | Nabu Casa cloud auth; multi-provider auth chain |
| `components/http/__init__.py` | Aiohttp-based HTTP server setup; CORS; trusted proxies | CORS headers; `X-Forwarded-For` trusted proxy handling | Axum Tower middleware | Aiohttp; Python SSL context |
---
## 4. Public API parity table
| HA API surface | HOMECORE exact equivalent |
|---|---|
| `GET /api/states``[{entity_id, state, attributes, last_changed, last_updated, context}]` | Identical JSON schema; `last_changed` / `last_updated` in ISO 8601 |
| `GET /api/services``{domain: {service: {description, fields}}}` | Identical schema; service descriptions read from plugin manifests |
| WS `subscribe_events``{type: "event", event: {event_type, data, origin, time_fired, context}}` | Identical envelope; `time_fired` in ISO 8601 |
| WS `call_service``{type: "result", success: true, result: {context}}` | Identical; `context.id` is a UUID |
| WS `get_states``{type: "result", result: [{entity_id, state, attributes, ...}]}` | Identical schema |
| REST `POST /api/services/<domain>/<service>` → 200 with called service list | Identical; same `target` field support |
| REST `POST /api/template` → 200 with evaluated string | Identical; same error response `{message: "..."}` on template error |
| Auth WS flow: `auth_required``auth``auth_ok` | Identical message type strings; same `ha_version` field in `auth_required` |
| REST `Authorization: Bearer <token>` | Identical header name; JWT instead of HA's opaque token format (transparent to clients) |
---
## 5. Phased implementation plan
### P1 — Axum skeleton + static frontend (1 week)
- [ ] Create `v2/crates/homecore-api/` workspace member.
- [ ] Axum router on port 8123; Tower CORS middleware (allow `http://homeassistant.local:8123`).
- [ ] Static file handler: serve HA's Python frontend build from a configurable path (default `./frontend/build/`). This allows using the Python HA frontend as-is until ADR-131 ships.
- [ ] `GET /api/` returns `{ "message": "API running." }`.
- [ ] CI: `cargo check -p homecore-api`; HTTP smoke test.
### P2 — Core REST + WebSocket auth + states (3 weeks)
- [ ] Axum WebSocket upgrade at `/api/websocket`.
- [ ] Auth: Ed25519 JWT issuance at `/auth/token`; validation middleware.
- [ ] WS auth handshake: `auth_required``auth``auth_ok` / `auth_invalid`.
- [ ] WS commands: `get_states`, `subscribe_events`, `unsubscribe_events`, `call_service`, `get_services`, `get_config`.
- [ ] REST: `/api/states`, `/api/states/<entity_id>` (GET + POST), `/api/services`, `/api/services/<domain>/<service>`, `/api/config`.
- [ ] Integration test: HA iOS companion app authenticates and displays entity list against HOMECORE.
### P3 — Remaining WS commands + entity registry API (3 weeks)
- [ ] WS: `subscribe_trigger`, `render_template`, `validate_config`, `subscribe_entities`, entity/area/device registry commands.
- [ ] REST: `/api/template`, `/api/webhook/<id>`, `/api/error_log`, `/api/events`, `/api/events/<type>`.
- [ ] `/auth/authorize` OAuth2 flow for UI login.
- [ ] HACS smoke test: HACS connects, lists integrations.
### P4 — Recorder + history API (2 weeks)
- [ ] `/api/history/period/<datetime>` backed by ADR-132 recorder SQLite.
- [ ] `/api/logbook/` backed by ADR-132 recorder.
- [ ] `/api/camera_proxy/`, `/api/calendar/`, `/api/intent/handle`.
- [ ] Companion app full feature test: automations, notifications, history charts.
---
## 6. Risks
| Risk | Likelihood | Severity | Mitigation | Cross-ADR impact |
|---|---|---|---|---|
| **JSON schema drift** — HA updates a response field name between 2025.1 and HOMECORE release | Medium | High | Maintain a JSON-schema test fixture set generated from HA 2025.1; run against HOMECORE in CI | ADR-134: migration tool depends on the same JSON schemas; must stay in sync |
| **WS subscription fan-out** — 50 concurrent HA companion app sessions each subscribed to `subscribe_events` ALL; every state change creates 50 serialization tasks | Medium | Medium | Broadcast serialized JSON once; clone the `Bytes` arc to each subscriber sender; do not re-serialize per subscriber | ADR-127: broadcast channel capacity must handle subscriber fan-out without lagging |
| **Auth token format** — HA companion apps may validate the token format (JWT vs opaque). HOMECORE uses JWT; HA uses a custom opaque token. Tokens are never decoded client-side in standard clients, but non-standard clients may inspect them | Low | Low | JWTs are base64url-encoded JSON; any client checking `token.startsWith("ey")` will see a JWT. HA's own tokens are also base64url but not JWTs. Document the difference; test with the iOS app specifically | None |
| **Port 8123 conflict** — HOMECORE runs on the same port as HA; side-by-side mode (ADR-134) requires HOMECORE on a different port until cutover | High | Medium | ADR-134 side-by-side mode runs HOMECORE on port 8124; companion app can be pointed at port 8124 for testing | ADR-134 owns the cutover mechanism |
---
## 7. Open questions
**Q1**: The HA WebSocket API uses incremental integer IDs (`id: 1, 2, 3, ...`) for command/response correlation within a session. HOMECORE uses the same scheme. What is the maximum `id` value the companion app supports before wrapping? If the app doesn't wrap and HOMECORE processes > 2^31 commands per session, this becomes an overflow issue in extremely long-lived sessions.
**Q2**: The `subscribe_entities` WS command (added in HA 2021.x) sends entity registry change events in addition to state change events. The iOS companion app uses this to maintain a local entity list without polling. Is the full `subscribe_entities` delta schema (including `action: "create" | "update" | "remove"`) fully documented, or must it be reverse-engineered from the companion app source?
**Q3**: HA's `/auth/token` endpoint accepts `grant_type=password` (username/password) and `grant_type=refresh_token`. HOMECORE's initial implementation supports password grant only. Is refresh token support required for the companion app (it caches tokens between sessions) or does the companion app re-authenticate on each launch?
**Q4**: CORS policy: HA's default CORS allows `http://localhost:*` and `http://homeassistant.local:*`. The HOMECORE-UI frontend (ADR-131) will be served from a different origin in development. What CORS policy should HOMECORE-API use in production vs development mode?
---
## 8. References
### HA upstream
- `homeassistant/components/api/__init__.py` — 24 REST routes with exact URL paths, methods, and JSON response schemas
- `homeassistant/components/websocket_api/connection.py` — auth handshake protocol; per-connection state management
- `homeassistant/components/websocket_api/commands.py` — 30+ command type handlers with exact type strings and result schemas
- `homeassistant/components/http/__init__.py` — CORS setup; trusted proxy handling; aiohttp-based server
- `homeassistant/auth/providers/__init__.py` — token issuance; `AuthManager`; LLAT format
- `homeassistant/auth/__init__.py``AuthManager.async_create_long_lived_access_token`
### This repo
- `v2/crates/wifi-densepose-sensing-server/src/main.rs` — Axum server architecture (REST + WebSocket); pattern for this ADR
- `v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs` — Bearer auth middleware pattern
- `docs/adr/ADR-127-homecore-state-machine-rust.md` — state machine that REST/WS routes read from
- `docs/adr/ADR-126-ruview-native-ha-port-master.md` — §6 compatibility contract with companion apps
### External
- [HA WebSocket API Developer Docs](https://developers.home-assistant.io/docs/api/websocket/) — authoritative command type catalog
- [HA REST API](https://developers.home-assistant.io/docs/api/rest/) — REST endpoint schemas
-293
View File
@@ -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 540 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.10.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.04.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 20242025 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.
-141
View File
@@ -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 20252026. It introduces
multi-link operation (MLO), which dramatically increases the frequency of beamforming
sounding exchanges. Where 802.11ax sonding might occur at 1040 Hz, MLO sounding on
multiple links simultaneously could produce 35× 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.
-279
View File
@@ -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: 510 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 13 persons |
| Count MAE | | < 0.5 for counts 14 |
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).
-214
View File
@@ -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 20242025 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.
-111
View File
@@ -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 35×. 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
-136
View File
@@ -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 20242025 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 20160 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 35x. 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).
-58
View File
@@ -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`)
-474
View File
@@ -1,474 +0,0 @@
# RuView ↔ HomePod Integration Guide
**Ambient intelligence for Apple Home.** Run RuView as a native HomeKit accessory so your HomePod discovers it, Siri understands it, and Apple Home automations govern it — no Home Assistant required.
---
## Architecture Overview
RuView turns WiFi radio reflections into spatial intelligence (presence, breathing, fall risk, activity patterns). When paired with a HomePod or Apple TV acting as your Home Hub, RuView becomes an invisible sensor that feeds Siri, automations, and scenes:
```
ESP32-C6 CSI node (living room)
↓ (UDP feature stream)
RuView Sensing Server (announces presence, vital signs, BFLD events)
↓ (HTTP polling)
HAP Bridge (advertises HomeKit accessory on mDNS)
↓ (Bonjour discovery)
HomePod or Apple TV (Home Hub)
↓ (forwards to Home app + Siri)
iPhone, iPad, Mac, Watch, Apple Home automations
```
The integration leverages HomeKit Accessory Protocol (HAP-1.1) — the same standard that Philips Hue, Eve, and Nanoleaf use. Your HomePod discovers the bridge within seconds of launch, pairing is one-tap from the Home app, and Siri queries work immediately: *"Hey Siri, is anyone in the living room?"*
For design rationale and privacy safeguards, see [ADR-125 — RuView ↔ Apple Home native HAP bridge](docs/adr/ADR-125-ruview-apple-home-native-hap-bridge.md).
---
## What's Shipped Today (Tier 1 + Tier 2)
Eight incremental iterations landed in PR #797 on the `feat/adr-125-apple-fabric` branch:
| Iteration | Capability | Commit | Status |
|-----------|-----------|--------|--------|
| 1 | Multi-characteristic HomeKit accessory (Motion + Occupancy + StatelessProgrammableSwitch) | `48db60a65` | Runtime-live |
| 2 | Sensing-server HTTP endpoints for bridge polling (`/api/v1/vitals`, `/api/v1/bfld`, `/api/v1/semantic-events`) | `194a2e163` | Runtime-live, curl-validated |
| 3 | HAP bridge with N child accessories; Siri-by-room (name each room, Siri voices it) | `63b77f760` | Runtime-live, two bridges advertising |
| 4 | Semantic-events endpoint per §2.1.d (`Unknown Presence`, `Unexpected Occupancy`, `Unrecognized Activity Pattern`) | `3d30261e7` | Runtime-live, privacy invariant I1 enforced |
| 5 | rvagent MCP consumer (agentic chain); 12 MCP tools for Claude Code integration | `c19742d71` | Runtime-validated on real C6 |
| 6 | PyO3 BFLD PrivacyClass binding (SOTA rust crate exposed to Python) | `de0712d43` | Source-built (`cargo check` green) |
| 7 | Shortcuts-as-glue (launchd job + Speak Text on HomePod via iCloud Home graph, bypasses Bonjour blocker) | `d0525359d` | Runtime-validated, osascript trigger green |
| 8 | Custom characteristic UUID scaffold for Eve.app rendering (design complete; runtime HAP-python JSON-loader follow-up) | `3bb8c1621` | Design scaffolded |
**What you can do today:**
- Pair a RuView bridge into your Home app on iPhone, iPad, or Mac.
- Ask Siri room-specific presence questions ("is anyone home", "is the office occupied", "did someone fall").
- Trigger automations on presence detection, breathing presence, fall risk, or activity pattern anomalies.
- Stream RuView events to HomePod announcements via the Shortcuts-as-glue path (Tier 2).
- Query RuView data programmatically through the agentic MCP interface (Claude Code integration).
---
## Quickstart (5 minutes)
### Prerequisites
- **Hardware**: ESP32-C6 running CSI firmware (rev v0.7.0+) on the same WiFi network as your Mac and HomePod.
- **Software**: Python 3.8+ on a Mac that's already paired into your Home app (iCloud account).
- **Network**: Mac, HomePod, and ESP32-C6 must all be on the same LAN subnet (e.g., `192.168.1.0/24`).
### Step 1: Provision the ESP32-C6
Connect the C6 via USB and run the provisioning script:
```bash
python firmware/esp32-csi-node/provision.py \
--port /dev/ttyUSB0 \
--ssid "YourWiFiSSID" \
--password "YourWiFiPassword" \
--target-ip 192.168.1.20
```
Verify the C6 boots on the network:
```bash
ping 192.168.1.20
```
### Step 2: Create a Python venv on the Mac and install HAP-python
```bash
mkdir -p ~/ruview-hap
cd ~/ruview-hap
python3 -m venv venv
source venv/bin/activate
pip install HAP-python
```
### Step 3: Copy the RuView bridge scripts to the Mac
From the repository (e.g., cloned on your Mac), copy these files:
```bash
cp scripts/c6-presence-watcher.py ~/ruview-hap/
cp scripts/ruview-sensing-server.py ~/ruview-hap/
cp scripts/ruview-hap-bridge.py ~/ruview-hap/
```
### Step 4: Start the three daemons in order
**Terminal 1: Start the C6 presence watcher** (reads UDP packets from the C6, applies BFLD privacy gate)
```bash
cd ~/ruview-hap
source venv/bin/activate
python c6-presence-watcher.py --node-id 1 --esp32-ip 192.168.1.20 --privacy-class 2
```
Output: Writes presence events to `/tmp/ruview-state.json`.
**Terminal 2: Start the sensing server** (HTTP polling interface for the HAP bridge)
```bash
cd ~/ruview-hap
source venv/bin/activate
python ruview-sensing-server.py --port 3000
```
Output: Listening on `http://127.0.0.1:3000/api/v1/...`.
**Terminal 3: Start the HAP bridge** (advertises HomeKit accessory on mDNS)
```bash
cd ~/ruview-hap
source venv/bin/activate
python ruview-hap-bridge.py --port 51826 --pin 200-70-910
```
Output: Look for setup code in the terminal output, e.g., `Setup code: 200-70-910`.
### Step 5: Pair the bridge from your iPhone
1. Open the **Home** app on your iPhone.
2. Tap the **+** icon (top right) → **Add Accessory**.
3. Scan the setup code (or tap **Don't Have a Code or Can't Scan?****More Options**).
4. Select the **RuView Sense** bridge from the list (should appear within 10 seconds).
5. Assign to a room (e.g., "Living Room").
6. Tap **Done**.
### Step 6: Test with Siri
Once paired, ask Siri:
```
"Hey Siri, is anyone in the living room?"
```
Siri will respond with the current occupancy state. Walk past the C6 and ask again — the presence value should update within 12 seconds.
---
## Per-Room Expansion
To monitor multiple rooms, run multiple C6 nodes, each with its own `c6-presence-watcher.py` instance:
```bash
# Terminal: Room 1 (Living Room, node_id=1)
python c6-presence-watcher.py --node-id 1 --esp32-ip 192.168.1.20 \
--output /tmp/ruview-state.living-room.json
# Terminal: Room 2 (Bedroom, node_id=2)
python c6-presence-watcher.py --node-id 2 --esp32-ip 192.168.1.21 \
--output /tmp/ruview-state.bedroom.json
# Terminal: HAP bridge (auto-discovers both state files)
python ruview-hap-bridge.py --port 51826 --rooms "Living Room,Bedroom"
```
The HAP bridge auto-discovers `*.json` files in `/tmp/ruview-state*` and creates a child HomeKit accessory per room. Each room appears separately in the Home app and can be assigned to its physical location.
---
## Privacy Semantics
RuView's BFLD (Beamforming Feedback Layer for Detection) uses a **privacy class** gate that enforces what data can cross the HomeKit boundary. Only Classes 2 and 3 (Anonymous and Restricted) are eligible; Class 0/1 (Raw identity information) is never exposed.
### The Three Semantic Events
HomeKit exposes **thresholded events**, not raw probabilities:
| Event | HomeKit Characteristic | Meaning | Example Automation |
|-------|----------------------|---------|-------------------|
| **Unknown Presence** | MotionSensor (stateful) | Person detected + no matching identity record for >30s | "Turn on porch light when Unknown Presence detected after 9pm" |
| **Unexpected Occupancy** | OccupancySensor | Occupancy outside the operator's defined schedule | "Send notification if office is occupied on weekends" |
| **Unrecognized Activity Pattern** | ProgrammableSwitch (momentary) | Activity drift or recalibration gate fires | "Run a re-learning sequence when activity changes" |
### What's Deliberately Hidden
The following are **never** exposed to HomeKit:
- `identity_risk_score` (numeric 01 confidence) — only thresholded semantic events cross the boundary
- Soul-Signature match probability — internal to BFLD
- `rf_signature_hash` — cryptographic internal state
This enforces **ADR-125 §2.1.d invariant I1**: raw identity information never exits the node. The semantic framing is intentional — "Unknown Presence" reads as *who's-here-and-it's-fine-but-worth-noting*, not as an accusation.
For the technical definition, see [ADR-118 — Beamforming Feedback Layer for Detection](docs/adr/ADR-118-bfld-beamforming-feedback-layer-for-detection.md).
---
## Siri-by-Room
Name each HomeKit accessory after its room. The HAP bridge pulls room names from the state file prefixes:
```bash
python c6-presence-watcher.py --node-id 1 \
--output /tmp/ruview-state.LIVING_ROOM.json
# HAP bridge sees this and names the accessory "Living Room"
```
When paired in the Home app, Siri knows the room:
| Query | Result |
|-------|--------|
| "Is anyone in the living room?" | Queries the Living Room accessory's motion sensor |
| "Is anyone home?" | Queries all room accessories; returns true if any motion is detected |
| "Turn on the bedroom lights when occupancy is detected" | Automation triggers on the Bedroom accessory only |
### StatelessProgrammableSwitch for Automations
Each room also exposes a **StatelessProgrammableSwitch** that fires on semantic-event boundaries (Unrecognized Activity Pattern, Recalibration, etc.). This is the HomeKit primitive for momentary triggers:
1. In the Home app, go to **Automation****Create New Automation****When an Accessory is Controlled**.
2. Select **Living Room****Programmable Switch****Single Press**.
3. Add an action: *Turn on scene*, *Send notification*, *Set HomeKit Secure Video recording*, etc.
---
## HomePod Announcements via Shortcuts (Tier 2 Path)
The easiest way to announce RuView events on a HomePod is through **Shortcuts-as-glue** — a native macOS launchd job that watches RuView's semantic events and triggers a Shortcut you define.
This path **bypasses the Bonjour reflector blocker** that can prevent HomePod discovery in some mesh networks. Instead of direct mDNS, the Mac uses the Home graph (iCloud-paired) to reach the HomePod.
### One-Time Setup
#### 1. Create the Shortcut in Shortcuts.app
1. Open **Shortcuts.app** on your Mac.
2. Click **+** (top left) → **Create Shortcut**.
3. Click **Add Action** → search for **"Speak Text"** → add it.
4. In the **"Speak Text"** action, click the **speaker icon** → select your **HomePod** (or HomePod mini).
5. Name the Shortcut **`RuView Announce`** (exact name).
6. **Save** (top right).
#### 2. Test the Shortcut from the terminal
```bash
osascript -e 'tell application "Shortcuts Events" to run shortcut "RuView Announce" with input "Test from RuView"'
```
Your HomePod should speak "Test from RuView" in your chosen voice.
#### 3. Install the launchd job
Copy the launchd plist from the repository:
```bash
cp scripts/macos-shortcuts/ruview-watcher.plist \
~/Library/LaunchAgents/com.ruvnet.ruview.watcher.plist
launchctl load ~/Library/LaunchAgents/com.ruvnet.ruview.watcher.plist
launchctl list | grep ruvnet # Confirm it's loaded
```
#### 4. Verify it works
Tail the log in one terminal:
```bash
tail -f /tmp/ruview-watcher.log
```
In another terminal, walk past the C6 and trigger a presence detection. The log should show:
```
[17:10:12] unknown_presence rising-edge → running 'RuView Announce'
```
And your HomePod should announce the event in its configured voice.
### Extending to Multiple Rooms
To announce different events in different rooms, create multiple Shortcuts in Shortcuts.app:
- `RuView Announce Kitchen`
- `RuView Announce Bedroom`
Then run multiple watcher jobs with different `--shortcut-name` flags:
```bash
# Kitchen events on HomePod mini in kitchen
scripts/macos-shortcuts/announce-via-homepod.sh \
--node-id 1 --event unknown_presence \
--shortcut-name "RuView Announce Kitchen" \
--poll-interval 2 &
# Bedroom events on HomePod in bedroom
scripts/macos-shortcuts/announce-via-homepod.sh \
--node-id 2 --event unknown_presence \
--shortcut-name "RuView Announce Bedroom" \
--poll-interval 2 &
```
### Going Further
Because the Shortcut is operator-editable in Shortcuts.app, you can extend it to do anything:
- **Activate a scene** ("turn on bedtime scene when fall risk detected")
- **Send a notification** to your Apple Watch
- **Call a Webhook** to integrate with other systems
- **Send a message** to another person's iPhone
- **Trigger a HomeKit secure camera recording**
This is the flexibility of the Shortcuts-as-glue approach — no code change needed in RuView, all customization in the operator's own Shortcuts library.
For complete setup details and troubleshooting, see [`scripts/macos-shortcuts/README.md`](scripts/macos-shortcuts/README.md).
---
## Agentic Consumption via MCP
RuView's sensing stream is also available through Model Context Protocol (MCP) — the standard interface for Claude Code and other AI agents to query RuView data.
### The `@ruvnet/rvagent` npm package (v0.1.0)
The package exposes **12 MCP tools** that let Claude Code agents:
- Query presence and occupancy per room
- Read breathing rate and heart rate telemetry
- Monitor BFLD semantic events
- Inspect the app registry (edge modules)
- Kickstart background training jobs
### Installation
In your Claude Code project:
```bash
npm install -D @ruvnet/rvagent@0.1.0
# Or, add via MCP:
claude mcp add rvagent -- npx -y @ruvnet/rvagent@0.1.0
```
Then in your Claude Code chat:
```
/claude-flow-help # Lists all available MCP tools
```
### Tool Reference
| Tool | Input | Output |
|------|-------|--------|
| `ruview_csi_latest` | node_id | Latest CSI window (1024 subcarriers, 30 OFDM symbols) |
| `ruview_pose_infer` | CSI window | 17-keypoint skeleton (x, y, confidence per joint) |
| `ruview_count_infer` | CSI window | Person count + 95% CI |
| `ruview_registry_list` | query (optional) | List of 105+ available edge modules |
| `ruview_train_count` | epochs, learning_rate | Kickoff training job ID |
| `ruview_job_status` | job_id | Progress, ETA, current loss |
| `ruview.bfld.last_scan` | node_id | Latest BFLD scan: privacy_class, person_count (identity_risk_score=null per I1 invariant) |
| `ruview.bfld.subscribe` | node_id, event_filter | Stream BFLD windows until you close the stream |
| `ruview.presence.now` | room (optional) | Current occupancy per room |
| `ruview.vitals.get_breathing` | node_id | Breathing rate (BPM) + confidence |
| `ruview.vitals.get_heart_rate` | node_id | Heart rate (BPM) + confidence |
| `ruview.vitals.get_all` | node_id | Breathing + heart rate + metadata |
### Example: Claude Code Agent Workflow
```python
# Claude-flow agent pseudocode
import claude_code
tools = claude_code.mcp_tools("rvagent")
# Query latest presence
presence = tools["ruview.presence.now"](room="living room")
print(f"Living room occupancy: {presence.occupancy}") # True/False
# Check vitals
vitals = tools["ruview.vitals.get_all"](node_id=1)
print(f"Breathing: {vitals.breathing_bpm} BPM")
# Stream BFLD events in real-time
for event in tools["ruview.bfld.subscribe"](node_id=1, event_filter="unknown_presence"):
print(f"Unknown presence detected: privacy_class={event.privacy_class}")
```
For the full MCP specification, see [ADR-124 — rvagent MCP / RuVector npm integration](docs/adr/ADR-124-rvagent-mcp-ruvector-npm-integration.md).
---
## Troubleshooting
### HomePod Not Visible on `dns-sd -B _airplay._tcp local.` from the Mac
**Likely cause**: HomePod and Mac are on different subnets despite being on the same SSID. Some mesh networks segment 2.4 GHz and 5 GHz bands onto different `/24` subnets, or place guest devices on a separate VLAN.
**Check**:
1. Open your router admin page and confirm both the HomePod and Mac are in the same subnet range (e.g., both `192.168.1.x`).
2. If they're on different subnets (e.g., `192.168.1.x` vs `192.168.100.x`), enable **IGMP Proxying** in your router settings (common on Netgear Nighthawk). If available, enable **Bonjour Repeater** or **mDNS Reflector** instead.
3. Restart the HomePod and Mac.
**Note**: The **Shortcuts-as-glue path (Tier 2)** doesn't need this fix — it routes announcements through the iCloud Home graph, not mDNS.
### iPhone Pairing Fails with "Couldn't Add Accessory"
**Likely cause**: The HAP bridge's pairing state is corrupt or out of sync with mDNS.
**Fix**:
1. Stop the HAP bridge daemon.
2. Delete the pairing state file:
```bash
rm -rf ~/.ruview-hap-prod/accessory.state
```
3. Restart the HAP bridge — it regenerates a new setup code.
4. From the Home app, retry **Add Accessory****More Options** with the new setup code.
### The Setup Code Regenerates on Restart
**Expected behavior.** HAP-python regenerates the setup code if the pairing persist file is missing or corrupt. Once you've paired successfully, the pairing key is stored separately in `~/.ruview-hap-prod/` and survives restarts — the setup code itself is transient and only matters during initial pairing.
If you lose the setup code before pairing, simply delete the state and restart to get a new one.
### Presence Updates Are Slow or Stuck
**Likely cause**: The HTTP polling loop in `ruview-sensing-server.py` is blocked, or the C6 is not sending UDP packets.
**Check**:
1. Verify the C6 is booting: `ping 192.168.1.20`.
2. Verify packets are reaching the sensing server:
```bash
nc -u -l 5005 & # Listen on UDP 5005
# You should see occasional packets from the C6
```
3. Manually query the sensing server:
```bash
curl http://127.0.0.1:3000/api/v1/vitals/latest
```
Should return JSON with breathing and heart rate fields.
4. If the HAP bridge doesn't reflect the changes after polling, restart it.
---
## What's NOT in Scope
These items are intentionally deferred or beyond the current release:
| Item | Status | Timeline |
|------|--------|----------|
| **Matter Protocol (P3)** | Deferred | Waiting for `matter-rs` SDK stabilization; HAP-1.1 covers 95% of the UX today |
| **Rust-native HAP (P2)** | Planned | Replaces Python `HAP-python` sidecar; expected after operator feedback from 5+ real pairings |
| **PyO3 BFLD wheel deployment (ADR-117 P5)** | Pending | Runtime import flip so Python scripts use the Rust BFLD crate; source-built (✅ `cargo check` green) but wheel not yet published |
| **Custom characteristic UUIDs for Eve.app (Iter 8 runtime)** | Scaffolded | Design complete; awaiting HAP-python JSON-loader implementation (small follow-up PR) |
| **AirPlay 2 voice synthesis (pyatv)** | Network-pending | Requires HomePod visible on Bonjour from the Mac; Shortcuts-as-glue (Tier 2) is the working alternative |
---
## References
- [ADR-125 — RuView ↔ Apple Home native HAP bridge](docs/adr/ADR-125-ruview-apple-home-native-hap-bridge.md) — Design spec, privacy rationale, sequencing
- [ADR-118 — Beamforming Feedback Layer for Detection](docs/adr/ADR-118-bfld-beamforming-feedback-layer-for-detection.md) — BFLD privacy gate and identity-risk semantics
- [ADR-124 — rvagent MCP / RuVector npm integration](docs/adr/ADR-124-rvagent-mcp-ruvector-npm-integration.md) — MCP tool specification
- [Issue #796](https://github.com/ruvnet/RuView/issues/796) — Tier 1+2 sprint tracking (close-out comments have per-iter empirical data)
- [scripts/macos-shortcuts/README.md](scripts/macos-shortcuts/README.md) — Shortcuts-as-glue setup and troubleshooting
- [HomeKit Accessory Protocol (Non-Commercial Version)](https://developer.apple.com/apple-home/) — HAP-1.1 spec
- [HAP-python on GitHub](https://github.com/ikalchev/HAP-python) — Implementation library
-106
View File
@@ -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
+3 -12
View File
@@ -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"
}
}
}
]
}
-1
View File
@@ -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`.
-75
View File
@@ -17,18 +17,6 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "autocfg"
version = "1.5.1"
@@ -41,20 +29,6 @@ version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
[[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",
"cpufeatures",
]
[[package]]
name = "bumpalo"
version = "3.20.3"
@@ -91,42 +65,12 @@ dependencies = [
"windows-link",
]
[[package]]
name = "constant_time_eq"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[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 = "equivalent"
version = "1.0.2"
@@ -591,12 +535,6 @@ version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "static_assertions"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "syn"
version = "2.0.117"
@@ -792,18 +730,6 @@ dependencies = [
"semver",
]
[[package]]
name = "wifi-densepose-bfld"
version = "0.3.0"
dependencies = [
"blake3",
"crc",
"serde",
"serde_json",
"static_assertions",
"thiserror",
]
[[package]]
name = "wifi-densepose-core"
version = "0.3.0"
@@ -822,7 +748,6 @@ version = "2.0.0-alpha.1"
dependencies = [
"numpy",
"pyo3",
"wifi-densepose-bfld",
"wifi-densepose-core",
"wifi-densepose-vitals",
]
-7
View File
@@ -39,13 +39,6 @@ wifi-densepose-core = { version = "0.3.0", path = "../v2/crates/wifi-densepose-c
# no tokio (Q5 audited 2026-05-24); safe to wrap in py.allow_threads.
wifi-densepose-vitals = { version = "0.3.0", path = "../v2/crates/wifi-densepose-vitals" }
# ADR-118 BFLD core — PrivacyClass enum + identity_risk scoring +
# privacy gate. Exposed to Python via bindings/privacy_gate.rs so the
# c6-presence-watcher.py runtime (currently using a Python port of the
# same semantics) can switch to the canonical Rust implementation when
# the wheel ships. ADR-125 §2.1.d invariant enforcement lives here.
wifi-densepose-bfld = { version = "0.3.0", path = "../v2/crates/wifi-densepose-bfld" }
# numpy bridge — needed for P3.5 BfldFrame (Complex64 ndarray) and for
# the future P3 CsiFrame numpy round-trip.
numpy = "0.22"
Binary file not shown.
-154
View File
@@ -1,154 +0,0 @@
//! ADR-118 / ADR-125 §2.1.d — Python binding for the BFLD `PrivacyClass`
//! enum and the HAP-eligibility gate.
//!
//! Python:
//! ```python
//! from wifi_densepose import PrivacyClass, allows_hap, allows_matter, allows_network
//!
//! PrivacyClass.Anonymous # → 2
//! allows_hap(PrivacyClass.Raw) # → False (I1 invariant)
//! allows_hap(PrivacyClass.Anonymous)# → True
//! allows_matter(PrivacyClass.Restricted) # → True (ADR-122 §2.4)
//! ```
//!
//! This is the SOTA replacement for the Python port that ships in
//! `scripts/c6-presence-watcher.py::PrivacyClass`. When the
//! `wifi-densepose` PyPI wheel lands (ADR-117 P5), runtimes flip from
//! the Python port to this Rust-backed binding and get the same enum
//! semantics as every other consumer of the published
//! `wifi-densepose-bfld 0.3.0` crate.
use pyo3::prelude::*;
use wifi_densepose_bfld::PrivacyClass;
/// Python-facing wrapper for [`wifi_densepose_bfld::PrivacyClass`].
///
/// Repr matches the Rust enum byte values 0..=3.
#[pyclass(eq, eq_int, hash, frozen, name = "PrivacyClass", module = "wifi_densepose")]
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub enum PyPrivacyClass {
Raw = 0,
Derived = 1,
Anonymous = 2,
Restricted = 3,
}
impl From<PrivacyClass> for PyPrivacyClass {
fn from(c: PrivacyClass) -> Self {
match c {
PrivacyClass::Raw => Self::Raw,
PrivacyClass::Derived => Self::Derived,
PrivacyClass::Anonymous => Self::Anonymous,
PrivacyClass::Restricted => Self::Restricted,
}
}
}
impl From<PyPrivacyClass> for PrivacyClass {
fn from(c: PyPrivacyClass) -> Self {
match c {
PyPrivacyClass::Raw => Self::Raw,
PyPrivacyClass::Derived => Self::Derived,
PyPrivacyClass::Anonymous => Self::Anonymous,
PyPrivacyClass::Restricted => Self::Restricted,
}
}
}
#[pymethods]
impl PyPrivacyClass {
/// True if frames of this class may cross a `NetworkSink`.
/// Class 0 (`Raw`) is local-only by structural invariant I1
/// (ADR-118 §2.2).
#[getter]
fn allows_network(&self) -> bool {
PrivacyClass::from(*self).allows_network()
}
/// True if frames of this class may cross the Matter boundary.
/// Only classes 2 (`Anonymous`) and 3 (`Restricted`) qualify per
/// ADR-122 §2.4 / ADR-125 §2.1.d.
#[getter]
fn allows_matter(&self) -> bool {
PrivacyClass::from(*self).allows_matter()
}
/// True if frames of this class may cross the HomeKit Accessory
/// Protocol boundary. Same set as `allows_matter` — class 2 or 3.
#[getter]
fn allows_hap(&self) -> bool {
// HAP eligibility is the same shape as Matter eligibility per
// ADR-125 §2.1.d; we don't add a separate Rust method until
// there's a divergence to justify it.
PrivacyClass::from(*self).allows_matter()
}
/// Byte value (0..=3) for serialization.
#[getter]
fn as_u8(&self) -> u8 {
PrivacyClass::from(*self).as_u8()
}
fn __repr__(&self) -> String {
match self {
Self::Raw => "PrivacyClass.Raw",
Self::Derived => "PrivacyClass.Derived",
Self::Anonymous => "PrivacyClass.Anonymous",
Self::Restricted => "PrivacyClass.Restricted",
}
.to_string()
}
/// Map a byte value 0..=3 to the corresponding `PrivacyClass`.
/// Raises `ValueError` on out-of-range input.
#[staticmethod]
fn from_u8(v: u8) -> PyResult<Self> {
PrivacyClass::try_from(v)
.map(Self::from)
.map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))
}
/// Map a string ("raw" / "derived" / "anonymous" / "restricted",
/// case-insensitive) to the corresponding `PrivacyClass`. Raises
/// `ValueError` on unknown names.
#[staticmethod]
fn from_str(s: &str) -> PyResult<Self> {
match s.to_ascii_lowercase().as_str() {
"raw" => Ok(Self::Raw),
"derived" => Ok(Self::Derived),
"anonymous" => Ok(Self::Anonymous),
"restricted" => Ok(Self::Restricted),
_ => Err(pyo3::exceptions::PyValueError::new_err(format!(
"invalid PrivacyClass name: {s:?} (expected raw/derived/anonymous/restricted)"
))),
}
}
}
/// Free-function helper: `True` iff `c` may cross the HAP boundary.
/// Convenience wrapper so Python callers can write
/// `allows_hap(PrivacyClass.Anonymous)` without method-call syntax.
#[pyfunction]
fn allows_hap(c: PyPrivacyClass) -> bool {
c.allows_hap()
}
/// Free-function helper: `True` iff `c` may cross a `NetworkSink`.
#[pyfunction]
fn allows_network(c: PyPrivacyClass) -> bool {
c.allows_network()
}
/// Free-function helper: `True` iff `c` may cross the Matter boundary.
#[pyfunction]
fn allows_matter(c: PyPrivacyClass) -> bool {
c.allows_matter()
}
pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyPrivacyClass>()?;
m.add_function(wrap_pyfunction!(allows_hap, m)?)?;
m.add_function(wrap_pyfunction!(allows_network, m)?)?;
m.add_function(wrap_pyfunction!(allows_matter, m)?)?;
Ok(())
}
-5
View File
@@ -20,7 +20,6 @@ mod bindings {
pub mod bfld;
pub mod keypoint;
pub mod pose;
pub mod privacy_gate;
pub mod vitals;
}
@@ -81,9 +80,5 @@ fn wifi_densepose_native(m: &Bound<'_, PyModule>) -> PyResult<()> {
// P3.5 — BFLD bindings (stub Rust; future wifi-densepose-bfld crate
// will replace the stub without changing the Python API).
bindings::bfld::register(m)?;
// ADR-118 PrivacyClass + HAP/Matter eligibility gates (SOTA — backed by
// the published `wifi-densepose-bfld 0.3.0` crate, not the Python port).
// Closes ADR-125 §2.1.d at the binding boundary.
bindings::privacy_gate::register(m)?;
Ok(())
}
BIN
View File
Binary file not shown.
-402
View File
@@ -1,402 +0,0 @@
#!/usr/bin/env python3
"""
c6-presence-watcher.py ADR-125 iter 2.
Bridges real ESP32-C6 ADR-081 `rv_feature_state` UDP frames to the HAP
`MotionSensor` characteristic via the toggle file that
`scripts/hap-test-sensor.py` already pairs against. No mocks, no
simulation consumes the exact 60-byte struct emitted by
`firmware/esp32-csi-node/main/rv_feature_state.[ch]`.
Wire format (RV_FEATURE_STATE_MAGIC = 0xC5110006, 60 bytes total,
__attribute__((packed))):
offset size field type
0 4 magic u32 = 0xC5110006
4 1 node_id u8
5 1 mode u8
6 2 seq u16
8 8 ts_us u64
16 4 motion_score f32 0..1, 100 ms window
20 4 presence_score f32 0..1, 1 s window
24 4 respiration_bpm f32
28 4 respiration_conf f32
32 4 heartbeat_bpm f32
36 4 heartbeat_conf f32
40 4 anomaly_score f32
44 4 env_shift_score f32
48 4 node_coherence f32
52 2 quality_flags u16
54 2 reserved u16
56 4 crc32 u32
`quality_flags & RV_QFLAG_PRESENCE_VALID (1<<0)` gates presence reads.
`presence_score >= PRESENCE_THRESHOLD` toggles motion ON; below the
release threshold (with hysteresis) toggles OFF. The toggle file
is the contract between this watcher and the paired HAP bridge.
Usage:
python3 c6-presence-watcher.py [--port 5005] [--toggle /tmp/ruview-motion]
"""
from __future__ import annotations
import argparse
import json
import os
import signal
import socket
import struct
import sys
import time
import zlib
from collections import deque
RV_FEATURE_STATE_MAGIC = 0xC5110006
RV_QFLAG_PRESENCE_VALID = 1 << 0
PACKET_SIZE = 60
class PrivacyClass:
"""Mirror of `wifi-densepose-bfld::PrivacyClass` (Rust, ADR-118 §2.1).
The HAP boundary is governed by ADR-125 §2.1.d + ADR-122 §2.4: only
`Anonymous` (2) and `Restricted` (3) frames may cross. `Raw` (0) and
`Derived` (1) are HAP-ineligible by structural invariant I1.
"""
RAW = 0
DERIVED = 1
ANONYMOUS = 2
RESTRICTED = 3
_names = {RAW: "Raw", DERIVED: "Derived", ANONYMOUS: "Anonymous",
RESTRICTED: "Restricted"}
@classmethod
def name(cls, value: int) -> str:
return cls._names.get(value, f"Unknown({value})")
@classmethod
def from_str(cls, s: str) -> int:
m = {"raw": cls.RAW, "derived": cls.DERIVED,
"anonymous": cls.ANONYMOUS, "restricted": cls.RESTRICTED}
if s.lower() not in m:
raise ValueError(f"invalid privacy class {s!r}; "
f"expected one of {list(m.keys())}")
return m[s.lower()]
@classmethod
def allows_hap(cls, value: int) -> bool:
"""ADR-125 §2.1.d gate: only class-2/3 cross the HomeKit boundary."""
return value in (cls.ANONYMOUS, cls.RESTRICTED)
# Semantic-event naming per ADR-125 §2.1.d. The HAP bridge keeps
# advertising a generic MotionSensor; this is the operator-facing
# *label* for the event, written into the watcher log + summary line
# so the operator never sees "intruder detected" framing.
SEMANTIC_EVENT_UNKNOWN_PRESENCE = "Unknown Presence"
# Hysteresis — entry / exit thresholds keep the HomeKit characteristic
# from flapping when presence_score sits near the boundary.
PRESENCE_ON_THRESHOLD = 0.40
PRESENCE_OFF_THRESHOLD = 0.20
# Idle releases motion after this many seconds with no valid presence
# packets (covers the C6 falling off the air entirely).
IDLE_RELEASE_S = 5.0
# 60-byte packed layout (`<` = little-endian + no padding)
# magic|node|mode|seq|ts|motion|presence|resp_bpm|resp_c|hb_bpm|hb_c|anom|env|coh|qflags|reserved|crc
PACKET_STRUCT = struct.Struct("<IBBHQfffffffffHHI")
assert PACKET_STRUCT.size == PACKET_SIZE, (
f"layout mismatch: struct {PACKET_STRUCT.size}, expected {PACKET_SIZE}"
)
def parse_packet(buf: bytes):
"""Return parsed dict or None if not a feature_state packet."""
if len(buf) != PACKET_SIZE:
return None
fields = PACKET_STRUCT.unpack(buf)
(magic, node_id, mode, seq, ts_us, motion, presence,
resp_bpm, resp_conf, hb_bpm, hb_conf,
anomaly, env_shift, coherence,
qflags, _reserved, crc) = fields
if magic != RV_FEATURE_STATE_MAGIC:
return None
# CRC32 over bytes [0..end-4]. Firmware uses IEEE poly == zlib.crc32.
expected = zlib.crc32(buf[:-4]) & 0xFFFFFFFF
crc_ok = expected == crc
return {
"node_id": node_id, "mode": mode, "seq": seq, "ts_us": ts_us,
"motion": motion, "presence": presence,
"resp_bpm": resp_bpm, "resp_conf": resp_conf,
"hb_bpm": hb_bpm, "hb_conf": hb_conf,
"anomaly": anomaly, "env_shift": env_shift, "coherence": coherence,
"qflags": qflags, "crc_ok": crc_ok,
"presence_valid": bool(qflags & RV_QFLAG_PRESENCE_VALID),
}
def set_motion(toggle_file: str, on: bool, current: bool,
semantic: str = SEMANTIC_EVENT_UNKNOWN_PRESENCE) -> bool:
"""Touch / unlink the toggle file iff state changes. Return new state."""
if on == current:
return current
if on:
with open(toggle_file, "w") as fh:
fh.write("1\n")
else:
try:
os.unlink(toggle_file)
except FileNotFoundError:
pass
label = semantic if on else f"clear {semantic}"
print(f"[{time.strftime('%H:%M:%S')}] {label} (motion -> {on})",
flush=True)
return on
def apply_privacy_gate(pkt: dict, allowed_class: int) -> dict | None:
"""ADR-118 PrivacyGate equivalent at the HAP boundary.
The C6 emits sensor-aggregate `feature_state` frames *not* raw BFI,
*not* identity embeddings. We classify the emit at the chosen
operator class. Returns the (possibly redacted) event dict, or
`None` if the class doesn't allow HAP crossing.
"""
if not PrivacyClass.allows_hap(allowed_class):
return None
# `Restricted` (3) strips anything that could be a per-occupant
# fingerprint — even though feature_state currently carries none.
# Future iters extending the wire format will need to respect this.
if allowed_class == PrivacyClass.RESTRICTED:
return {
"presence": pkt["presence"], "motion": pkt["motion"],
"presence_valid": pkt["presence_valid"],
"node_id": pkt["node_id"], "seq": pkt["seq"],
# anomaly_score / env_shift / coherence dropped (could
# reveal longitudinal drift signatures over time).
}
# `Anonymous` (2) — production default. Carries the aggregate
# vitals so HomeKit `Unknown Presence` automations can pick up
# context, but no identity-derived fields.
return {
"presence": pkt["presence"], "motion": pkt["motion"],
"presence_valid": pkt["presence_valid"],
"node_id": pkt["node_id"], "seq": pkt["seq"],
"resp_bpm": pkt["resp_bpm"], "hb_bpm": pkt["hb_bpm"],
"anomaly": pkt["anomaly"], "env_shift": pkt["env_shift"],
"coherence": pkt["coherence"],
}
def main() -> int:
p = argparse.ArgumentParser()
p.add_argument("--port", type=int, default=5005)
p.add_argument("--toggle", default="/tmp/ruview-motion")
p.add_argument("--bind", default="0.0.0.0")
p.add_argument("--privacy-class", default="anonymous",
choices=["raw", "derived", "anonymous", "restricted"],
help="ADR-118 PrivacyClass; only anonymous/restricted "
"may cross the HAP boundary (ADR-125 §2.1.d).")
p.add_argument("--state-json", default="/tmp/ruview-state.json",
help="JSON state IPC file written for the HAP daemon. "
"Contains motion/occupancy/anomaly_ts.")
p.add_argument("--occupancy-window", type=float, default=3.0,
help="Seconds of rolling presence_score average for "
"OccupancyDetected (vs short-window MotionDetected).")
p.add_argument("--anomaly-threshold", type=float, default=0.7,
help="anomaly_score crossing this fires the "
"'Unrecognized Activity Pattern' event "
"(Restricted class only; ADR-125 §2.1.d).")
args = p.parse_args()
privacy_class = PrivacyClass.from_str(args.privacy_class)
if not PrivacyClass.allows_hap(privacy_class):
sys.stderr.write(
f"REFUSED: privacy class {PrivacyClass.name(privacy_class)} "
f"(value={privacy_class}) is not HAP-eligible. "
f"ADR-125 §2.1.d structural invariant I1: only Anonymous (2) "
f"and Restricted (3) frames may cross the HomeKit boundary. "
f"Use --privacy-class anonymous (default) or restricted.\n"
)
return 2
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
if hasattr(socket, "SO_REUSEPORT"):
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
sock.bind((args.bind, args.port))
sock.settimeout(1.0)
print(f"[c6-presence] listening udp {args.bind}:{args.port}", flush=True)
print(f"[c6-presence] toggle file: {args.toggle}", flush=True)
print(f"[c6-presence] thresholds: on>={PRESENCE_ON_THRESHOLD}, "
f"off<={PRESENCE_OFF_THRESHOLD}, idle_release={IDLE_RELEASE_S}s",
flush=True)
print(f"[c6-presence] privacy class: "
f"{PrivacyClass.name(privacy_class)} (HAP-eligible)", flush=True)
print(f"[c6-presence] semantic event: {SEMANTIC_EVENT_UNKNOWN_PRESENCE}",
flush=True)
running = True
def _stop(*_):
nonlocal running
running = False
signal.signal(signal.SIGTERM, _stop)
signal.signal(signal.SIGINT, _stop)
motion = os.path.exists(args.toggle)
occupancy = False
last_anomaly_ts = 0.0
last_packet_ts = 0.0
last_summary = time.time()
n_total = n_valid = n_crc_bad = n_anomaly_fires = 0
presence_sum = motion_sum = 0.0
# Rolling window of (timestamp, presence_score) for occupancy detect
occ_window: deque[tuple[float, float]] = deque()
OCC_ON_THRESH = 0.30
OCC_OFF_THRESH = 0.15
state_path = args.state_json
def write_state(motion: bool, occupancy: bool, anomaly_ts: float) -> None:
try:
tmp = state_path + ".tmp"
with open(tmp, "w") as fh:
json.dump({"motion": motion, "occupancy": occupancy,
"anomaly_ts": anomaly_ts, "ts": time.time()}, fh)
os.replace(tmp, state_path)
except OSError:
pass
# Companion contract for `scripts/ruview-sensing-server.py` (the
# @ruvnet/rvagent compatibility layer): write the full BFLD-gated
# feature snapshot so the sensing-server can serve EdgeVitalsMessage
# and BfldScanResponse without going back to the wire.
feature_path = "/tmp/ruview-last-feature.json"
def write_feature(gated: dict, motion: bool, occupancy: bool,
privacy_cls: int) -> None:
try:
tmp = feature_path + ".tmp"
with open(tmp, "w") as fh:
json.dump({
"node_id": str(gated["node_id"]),
"timestamp_ms": int(time.time() * 1000),
"presence": occupancy, # sustained
"motion": gated["motion"], # 0..1 float
"presence_score": gated["presence"],
"n_persons": 1 if occupancy else 0,
"confidence": min(1.0, max(0.0, gated["motion"])),
"breathing_rate_bpm": (gated["resp_bpm"]
if gated.get("resp_bpm") else None),
"heartrate_bpm": (gated["hb_bpm"]
if gated.get("hb_bpm") else None),
"anomaly_score": gated.get("anomaly"),
"privacy_class": privacy_cls,
"ts": time.time(),
}, fh)
os.replace(tmp, feature_path)
except OSError:
pass
while running:
try:
buf, _addr = sock.recvfrom(2048)
except socket.timeout:
buf = None
now = time.time()
if buf is not None:
n_total += 1
pkt = parse_packet(buf)
if pkt is not None:
if not pkt["crc_ok"]:
n_crc_bad += 1
else:
# ADR-118 PrivacyGate: classify + redact before the
# HAP boundary. Returns None for non-eligible classes.
gated = apply_privacy_gate(pkt, privacy_class)
if gated is not None and gated["presence_valid"]:
n_valid += 1
presence_sum += gated["presence"]
motion_sum += gated["motion"]
last_packet_ts = now
# MotionDetected — short-window (each packet)
prev_motion = motion
if not motion and gated["presence"] >= PRESENCE_ON_THRESHOLD:
motion = set_motion(args.toggle, True, motion)
elif motion and gated["presence"] <= PRESENCE_OFF_THRESHOLD:
motion = set_motion(args.toggle, False, motion)
# OccupancyDetected — rolling-window avg (§2.1.d
# "Unexpected Occupancy" is a future iter; for now
# we expose Occupancy as sustained presence).
occ_window.append((now, gated["presence"]))
cutoff = now - args.occupancy_window
while occ_window and occ_window[0][0] < cutoff:
occ_window.popleft()
if occ_window:
occ_avg = (sum(p for _, p in occ_window)
/ len(occ_window))
if not occupancy and occ_avg >= OCC_ON_THRESH:
occupancy = True
print(f"[{time.strftime('%H:%M:%S')}] "
f"Unknown Presence — Occupancy ON "
f"(rolling_avg={occ_avg:.2f})",
flush=True)
elif occupancy and occ_avg <= OCC_OFF_THRESH:
occupancy = False
print(f"[{time.strftime('%H:%M:%S')}] "
f"Occupancy OFF "
f"(rolling_avg={occ_avg:.2f})",
flush=True)
# Anomaly — only when class allows (Restricted
# gate drops anomaly_score entirely; the dict
# missing the key is the type-level enforcement).
if ("anomaly" in gated
and gated["anomaly"] >= args.anomaly_threshold):
last_anomaly_ts = now
n_anomaly_fires += 1
print(f"[{time.strftime('%H:%M:%S')}] "
f"Unrecognized Activity Pattern "
f"(anomaly={gated['anomaly']:.2f})",
flush=True)
if (motion != prev_motion
or not state_path.endswith(".disabled")):
write_state(motion, occupancy, last_anomaly_ts)
write_feature(gated, motion, occupancy,
privacy_class)
# Idle release — if the C6 stops sending entirely, clear motion
# AND occupancy.
if motion and last_packet_ts and (now - last_packet_ts) > IDLE_RELEASE_S:
motion = set_motion(args.toggle, False, motion)
occupancy = False
occ_window.clear()
write_state(motion, occupancy, last_anomaly_ts)
# Periodic summary line (every 10 s) so we can see the watcher is alive
if now - last_summary >= 10.0:
avg_p = presence_sum / n_valid if n_valid else 0.0
avg_m = motion_sum / n_valid if n_valid else 0.0
print(
f"[{time.strftime('%H:%M:%S')}] 10s stats: "
f"pkts={n_total} valid={n_valid} crc_bad={n_crc_bad} "
f"avg_presence={avg_p:.2f} avg_motion={avg_m:.2f} "
f"motion={motion} occupancy={occupancy} "
f"anomaly_fires={n_anomaly_fires}",
flush=True,
)
n_total = n_valid = n_crc_bad = n_anomaly_fires = 0
presence_sum = motion_sum = 0.0
last_summary = now
sock.close()
return 0
if __name__ == "__main__":
sys.exit(main())
+1 -48
View File
@@ -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"
-152
View File
@@ -1,152 +0,0 @@
#!/usr/bin/env python3
"""
hap-test-sensor.py ADR-125 §2.1.a smoke test.
Stands up a single HomeKit Accessory Protocol (HAP-1.1) bridge with one
child MotionSensor named "RuView Test Motion". Once paired in the Apple
Home app, the HomePod (acting as Home Hub) sees state changes when
TOGGLE_FILE (default /tmp/ruview-motion) is touched / removed.
Usage:
python3 hap-test-sensor.py
Pair from iPhone: Home app -> Add Accessory -> More Options -> "RuView Test Bridge".
The setup code is printed on stdout AND written to ~/.ruview-hap/setup-code.txt.
Trigger motion: touch /tmp/ruview-motion
Clear motion: rm /tmp/ruview-motion
State persists across restarts in ~/.ruview-hap/accessory.state.
"""
from pathlib import Path
import json
import os
import sys
import time
import signal
from pyhap.accessory import Accessory, Bridge
from pyhap.accessory_driver import AccessoryDriver
from pyhap.const import CATEGORY_SENSOR, CATEGORY_BRIDGE
STATE_DIR = Path(os.path.expanduser("~/.ruview-hap"))
STATE_DIR.mkdir(exist_ok=True)
STATE_FILE = STATE_DIR / "accessory.state"
SETUP_CODE_FILE = STATE_DIR / "setup-code.txt"
# Legacy single-bool toggle (iter 1-3 contract). Still honored for
# backwards-compat with the original c6-presence-watcher.py path.
TOGGLE_FILE = Path(os.environ.get("RUVIEW_MOTION_TOGGLE", "/tmp/ruview-motion"))
# New JSON-state IPC contract (iter 4+). When present, takes precedence
# over the legacy toggle file. Schema:
# {
# "motion": bool, # short-window movement (100 ms feature_state)
# "occupancy": bool, # rolling-window sustained presence (1 s+)
# "anomaly": bool, # BFLD anomaly drift gate fired (class-3 only)
# "ts": float, # unix epoch when the watcher last wrote
# }
STATE_JSON = Path(os.environ.get("RUVIEW_STATE_JSON", "/tmp/ruview-state.json"))
def _read_state_json():
"""Best-effort read of the JSON IPC file. Returns None on any error."""
try:
with open(STATE_JSON, "r") as fh:
data = json.load(fh)
if not isinstance(data, dict):
return None
return data
except (FileNotFoundError, json.JSONDecodeError, OSError):
return None
class RuViewMotion(Accessory):
"""Three-service HomeKit accessory per ADR-125 §2.1.c.
Same accessory carries:
- MotionSensor short-window movement (motion_score)
- OccupancySensor sustained occupancy (presence_score rolling avg)
- StatelessProgrammableSwitch "Unrecognized Activity Pattern"
event (BFLD anomaly gate; Restricted-class only; momentary fire)
The HomeKit pairing stays intact when adding services to an existing
accessory the iPhone re-reads `/accessories` after the bridge's
config-number bumps and surfaces the new characteristics under the
same paired entity.
"""
category = CATEGORY_SENSOR
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
s_motion = self.add_preload_service("MotionSensor")
self.char_motion = s_motion.configure_char("MotionDetected")
s_occ = self.add_preload_service("OccupancySensor")
self.char_occ = s_occ.configure_char("OccupancyDetected")
s_sw = self.add_preload_service("StatelessProgrammableSwitch")
self.char_anomaly = s_sw.configure_char("ProgrammableSwitchEvent")
self._last_motion = False
self._last_occ = False
self._last_anomaly_ts = 0.0
def _legacy_motion(self) -> bool:
return TOGGLE_FILE.exists()
@Accessory.run_at_interval(1.0)
def run(self):
state = _read_state_json()
if state is None:
motion = self._legacy_motion()
occupancy = motion
anomaly_fire = False
else:
motion = bool(state.get("motion", False))
occupancy = bool(state.get("occupancy", False))
anomaly_ts = float(state.get("anomaly_ts", 0.0) or 0.0)
anomaly_fire = anomaly_ts > self._last_anomaly_ts
if anomaly_fire:
self._last_anomaly_ts = anomaly_ts
if motion != self._last_motion:
self.char_motion.set_value(motion)
self._last_motion = motion
print(f"[hap] MotionDetected -> {motion}", flush=True)
if occupancy != self._last_occ:
self.char_occ.set_value(1 if occupancy else 0)
self._last_occ = occupancy
print(f"[hap] OccupancyDetected -> {occupancy}", flush=True)
if anomaly_fire:
# 0 = single press; semantic-event = "Unrecognized Activity Pattern"
self.char_anomaly.set_value(0)
print(
"[hap] Unrecognized Activity Pattern fired (ProgrammableSwitch=0)",
flush=True,
)
def main() -> int:
driver = AccessoryDriver(port=51826, persist_file=str(STATE_FILE))
bridge = Bridge(driver, "RuView Test Bridge")
bridge.category = CATEGORY_BRIDGE
bridge.add_accessory(RuViewMotion(driver, "RuView Test Motion"))
driver.add_accessory(accessory=bridge)
setup_code = driver.state.pincode.decode() if hasattr(driver.state.pincode, "decode") else driver.state.pincode
SETUP_CODE_FILE.write_text(str(setup_code) + "\n")
print(f"[hap-test] HAP bridge advertising as 'RuView Test Bridge'")
print(f"[hap-test] iPhone pair flow: Home app -> Add Accessory -> More Options")
print(f"[hap-test] Setup code (also in {SETUP_CODE_FILE}): {setup_code}")
print(f"[hap-test] State sources:")
print(f"[hap-test] primary: {STATE_JSON} (multi-characteristic JSON)")
print(f"[hap-test] fallback: {TOGGLE_FILE} (motion-only touch file)")
print(f"[hap-test] Pair state persists in: {STATE_FILE}")
signal.signal(signal.SIGTERM, lambda *_: driver.stop())
driver.start()
return 0
if __name__ == "__main__":
sys.exit(main())
-96
View File
@@ -1,96 +0,0 @@
# macOS Shortcuts ↔ RuView bridge (ADR-125 §1.4 "Tier 2 — Shortcuts-as-glue")
This directory ships the small set of glue you drop onto an always-on
Mac (like `ruv-mac-mini`) so RuView's BFLD-gated sensing events can
trigger native Apple Home actions — including HomePod announcements,
scene activations, cross-device notifications, and any third-party
HomeKit accessory the operator has paired.
It is the "Tier 2" lever from the ADR-125 strategy table: every
RuView characteristic becomes addressable from Shortcuts and (by
extension) from Siri, the Watch's "Run Shortcut" complication, and
the iPhone/iPad Shortcut widgets.
## Architecture
```
real C6 (192.168.1.179, ruv.net)
→ UDP feature_state → c6-presence-watcher.py → BFLD PrivacyGate
→ /tmp/ruview-last-feature.json
→ ruview-sensing-server.py on :3000 ← (we already have this)
↓ HTTP poll loop in launchd job below
macOS Shortcut "RuView Announce" (operator-defined in Shortcuts.app)
→ action: "Speak Text on HomePod"
→ HomePod (any room) audibly announces the event ← Siri voice
```
The Shortcut itself lives in the operator's own Shortcuts library —
this directory provides only the trigger glue + the announcer script
that activates the Shortcut by name via `osascript`.
## One-time setup on the Mac
1. **Create the Shortcut** in `Shortcuts.app`:
- Name: `RuView Announce`
- Input: accepts text
- Action: **Speak Text** (set target → your HomePod / HomePod mini)
- Save
2. **Verify it runs from the command line**:
```sh
osascript -e 'tell application "Shortcuts Events" to run shortcut "RuView Announce" with input "Test from RuView"'
```
The HomePod should speak "Test from RuView".
3. **Install the launchd job**:
```sh
cp ruview-watcher.plist ~/Library/LaunchAgents/com.ruvnet.ruview.watcher.plist
launchctl load ~/Library/LaunchAgents/com.ruvnet.ruview.watcher.plist
```
`launchctl list | grep ruvnet` should show the job loaded.
4. **Tail the log** while you walk past the C6 to verify it fires:
```sh
tail -f /tmp/ruview-watcher.log
```
## Files
| File | Purpose |
|------|---------|
| `announce-via-homepod.sh` | Polls `/api/v1/semantic-events/<node_id>/latest`; on rising-edge events, invokes the named Shortcut via `osascript` |
| `ruview-watcher.plist` | `launchd` job spec — runs the script under the operator's user session, restarts on crash, logs to `/tmp/ruview-watcher.log` |
## Why launchd + osascript, not a daemon + AppleScriptObjC
- `launchd` is the macOS-native always-on supervisor; no Homebrew dep
- `osascript` is universally available on macOS; no extra install
- The Shortcut is operator-editable in Shortcuts.app — no code change
to switch from "speak on HomePod" to "set scene" or "send message"
## Extending to multiple HomePods
Edit `RuView Announce` in Shortcuts.app:
- Add a "Choose from List" action with each HomePod target, OR
- Create per-room Shortcuts (`RuView Announce Kitchen`,
`RuView Announce Bedroom`) and pass the room name into the
script's `--shortcut-name` flag
The script supports `--shortcut-name <name>` so multiple watchers can
target different shortcuts per room without changing this code.
## Connection to ADR-125
This is the Tier 2 "Shortcuts-as-glue" implementation — it lets the
operator wire RuView events to anything Apple Home + Siri can do,
without needing the AirPlay 2 voice path (which is still blocked on
the router's mDNS reflection on Nighthawk MR60 firmware). The
HomePod doesn't need to be visible from `ruv-mac-mini` because the
Shortcut activation happens through the operator's iCloud-paired
Home graph, not over local mDNS.
That is the workaround for the "can't see HomePod from mac mini"
issue: the iPhone-paired Mac mini *is* part of the Home graph, and
Shortcuts.app uses that graph (not Bonjour) to reach the HomePod.
@@ -1,104 +0,0 @@
#!/bin/bash
#
# announce-via-homepod.sh — ADR-125 §1.4 Tier 2 glue.
#
# Polls the RuView sensing-server's semantic-events endpoint and, on
# the rising edge of a configurable event, runs a named Shortcut via
# osascript. The Shortcut itself is owned by the operator in
# Shortcuts.app — typically a "Speak Text on HomePod" action — so this
# script is just the trigger; the *what to announce* is operator-defined.
#
# Run manually for testing:
# bash announce-via-homepod.sh --node-id 12 --event unrecognized_activity_pattern
#
# Run as a launchd job: see ruview-watcher.plist + README.md.
set -euo pipefail
SENSING_URL="${RUVIEW_SENSING_URL:-http://localhost:3000}"
NODE_ID="12"
EVENT="unrecognized_activity_pattern"
SHORTCUT_NAME="RuView Announce"
ANNOUNCEMENT=""
POLL_INTERVAL="5"
LOG_FILE="${RUVIEW_LOG:-/tmp/ruview-watcher.log}"
usage() {
cat >&2 <<EOF
Usage: $0 [options]
Options:
--node-id <id> Sensing-server node id (default: 12)
--event <name> Event to watch — one of:
unknown_presence
unexpected_occupancy
unrecognized_activity_pattern
(default: unrecognized_activity_pattern)
--shortcut-name <name> Shortcut to invoke (default: "RuView Announce")
--announcement <text> Text to speak when event fires (default: event name)
--sensing-url <url> Sensing-server base URL (default: http://localhost:3000)
--poll-interval <s> Poll interval in seconds (default: 5)
--once Single poll + exit (for testing)
-h, --help Show this help
EOF
}
ONCE=0
while [[ $# -gt 0 ]]; do
case "$1" in
--node-id) NODE_ID="$2"; shift 2 ;;
--event) EVENT="$2"; shift 2 ;;
--shortcut-name) SHORTCUT_NAME="$2"; shift 2 ;;
--announcement) ANNOUNCEMENT="$2"; shift 2 ;;
--sensing-url) SENSING_URL="$2"; shift 2 ;;
--poll-interval) POLL_INTERVAL="$2"; shift 2 ;;
--once) ONCE=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown arg: $1" >&2; usage; exit 2 ;;
esac
done
ANNOUNCEMENT="${ANNOUNCEMENT:-$(echo "$EVENT" | tr '_' ' ')}"
run_shortcut() {
local text="$1"
if ! command -v osascript >/dev/null 2>&1; then
echo "[$(date '+%H:%M:%S')] ERROR: osascript not found — macOS-only" >> "$LOG_FILE"
return 1
fi
# `Shortcuts Events` is the scriptable surface for Shortcuts.app.
# Passing input via `with input "..."` requires the Shortcut to
# have a "Receive Text input" trigger.
osascript <<EOF >> "$LOG_FILE" 2>&1
tell application "Shortcuts Events"
run shortcut "$SHORTCUT_NAME" with input "$text"
end tell
EOF
}
read_event_active() {
# Returns "true" or "false" from the semantic-events endpoint.
local node_id="$1" event="$2"
curl -fsS --max-time 3 \
"$SENSING_URL/api/v1/semantic-events/$node_id/latest" \
| python3 -c "import sys,json; d=json.load(sys.stdin); \
print(str(d.get('events',{}).get('$event',{}).get('active', False)).lower())" \
2>/dev/null || echo "unknown"
}
last_state="unknown"
echo "[$(date '+%H:%M:%S')] start: node=$NODE_ID event=$EVENT shortcut=\"$SHORTCUT_NAME\"" \
>> "$LOG_FILE"
while true; do
current="$(read_event_active "$NODE_ID" "$EVENT")"
if [[ "$current" != "$last_state" && "$current" == "true" ]]; then
echo "[$(date '+%H:%M:%S')] $EVENT rising-edge → running '$SHORTCUT_NAME'" \
>> "$LOG_FILE"
run_shortcut "$ANNOUNCEMENT" || \
echo "[$(date '+%H:%M:%S')] shortcut invocation failed" >> "$LOG_FILE"
fi
last_state="$current"
[[ "$ONCE" == "1" ]] && break
sleep "$POLL_INTERVAL"
done
@@ -1,75 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
ADR-125 §1.4 Tier 2 — launchd job for the RuView ↔ Shortcuts.app bridge.
Install:
cp ruview-watcher.plist ~/Library/LaunchAgents/com.ruvnet.ruview.watcher.plist
launchctl load ~/Library/LaunchAgents/com.ruvnet.ruview.watcher.plist
launchctl list | grep ruvnet
Uninstall:
launchctl unload ~/Library/LaunchAgents/com.ruvnet.ruview.watcher.plist
rm ~/Library/LaunchAgents/com.ruvnet.ruview.watcher.plist
Runs as the *user* (LaunchAgent — not LaunchDaemon) because Shortcuts.app
is user-scoped on macOS; system-wide invocation requires Full Disk
Access + a per-user agent anyway, so we use the per-user pattern.
Operator: adjust the path to announce-via-homepod.sh below if you
cloned the repo somewhere other than ~/.
-->
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.ruvnet.ruview.watcher</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<!-- Adjust this path to where announce-via-homepod.sh lives on
your Mac. The default ~/announce-via-homepod.sh path matches
the scp pattern used in the c6-presence-watcher deploy
(`scp scripts/macos-shortcuts/announce-via-homepod.sh ruv-mac-mini:~/`). -->
<string>/Users/cohen/announce-via-homepod.sh</string>
<string>--node-id</string>
<string>12</string>
<string>--event</string>
<string>unrecognized_activity_pattern</string>
<string>--shortcut-name</string>
<string>RuView Announce</string>
<string>--announcement</string>
<string>RuView detected an unrecognized activity pattern</string>
<string>--poll-interval</string>
<string>5</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>RUVIEW_SENSING_URL</key>
<string>http://localhost:3000</string>
<key>RUVIEW_LOG</key>
<string>/tmp/ruview-watcher.log</string>
<key>PATH</key>
<string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key>
<false/>
</dict>
<key>StandardOutPath</key>
<string>/tmp/ruview-watcher.stdout</string>
<key>StandardErrorPath</key>
<string>/tmp/ruview-watcher.stderr</string>
<key>ProcessType</key>
<string>Background</string>
</dict>
</plist>
-75
View File
@@ -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."
-227
View File
@@ -1,227 +0,0 @@
#!/usr/bin/env python3
"""
ruview-hap-bridge.py ADR-125 §2.1.c production bridge (Tier 1+2 iter 3).
One HAP bridge `RuView Sensing` carrying N child accessories one per
room. Implements the topology decision from ADR-125 §2.1.c: single
pairing for the operator, child accessories that map cleanly to
"is there motion in the [room]?" Siri queries.
Each child accessory carries the three services iter 1 introduced:
- MotionSensor (short-window movement)
- OccupancySensor (sustained presence "Unknown Presence")
- StatelessProgrammableSwitch (anomaly event, Restricted class only)
State per room comes from `/tmp/ruview-state.<room>.json`. A C6
provisioned with `--room kitchen` writes `/tmp/ruview-state.kitchen.json`;
the bridge picks it up automatically on next launch.
For backwards-compat with iter 1-2 (one-room setup) the legacy
`/tmp/ruview-state.json` still feeds the room named via `--legacy-room`
(default: `Living Room`).
This script intentionally uses port 51827 (one above the test bridge's
51826) and a separate persist file so the iter-1-paired `RuView Test
Bridge` keeps working on the operator's iPhone. The two bridges are
independent; the operator can pair both, then remove the test bridge
once happy with the production one.
Usage:
python3 ruview-hap-bridge.py # auto-discover rooms
python3 ruview-hap-bridge.py --rooms "Living Room,Bedroom,Office"
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
import time
from pathlib import Path
from pyhap.accessory import Accessory, Bridge
from pyhap.accessory_driver import AccessoryDriver
from pyhap.characteristic import Characteristic
from pyhap.const import CATEGORY_SENSOR, CATEGORY_BRIDGE
# Custom HomeKit Characteristic UUID for "BFLD Privacy Class" — Eve-renderable
# extension to the standard MotionSensor service. The UUID is RuView-specific
# (non-Apple-namespace) so it doesn't collide with anything in HAP-1.1.
# Eve.app and Controller for HomeKit will render this as an integer 2..3
# under the accessory's detail view; Home.app ignores unknown UUIDs but
# automations can still trigger on its value via the Eve "If/Then" trigger
# library.
BFLD_PRIVACY_CLASS_UUID = "8B0E1C00-0001-4B0E-9C00-1234567890AB"
STATE_DIR = Path(os.path.expanduser("~/.ruview-hap-prod"))
STATE_DIR.mkdir(exist_ok=True)
PERSIST_FILE = STATE_DIR / "bridge.state"
SETUP_CODE_FILE = STATE_DIR / "setup-code.txt"
LEGACY_STATE = Path("/tmp/ruview-state.json")
ROOM_STATE_GLOB = re.compile(r"^/tmp/ruview-state\.([^/]+)\.json$")
def discover_rooms_from_filesystem() -> list[tuple[str, Path]]:
"""Scan /tmp for ruview-state.<room>.json files and return (room, path)."""
rooms: list[tuple[str, Path]] = []
for entry in Path("/tmp").glob("ruview-state.*.json"):
m = ROOM_STATE_GLOB.match(str(entry))
if m:
room = m.group(1).replace("-", " ").title()
rooms.append((room, entry))
return rooms
def _read_state(path: Path) -> dict | None:
try:
with open(path, "r") as fh:
d = json.load(fh)
return d if isinstance(d, dict) else None
except (FileNotFoundError, json.JSONDecodeError, OSError):
return None
class RoomAccessory(Accessory):
"""One room's accessory — Motion + Occupancy + Anomaly switch."""
category = CATEGORY_SENSOR
def __init__(self, driver, name: str, state_path: Path, *args, **kwargs):
super().__init__(driver, name, *args, **kwargs)
self._state_path = state_path
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_sw = self.add_preload_service("StatelessProgrammableSwitch")
self.c_anomaly = s_sw.configure_char("ProgrammableSwitchEvent")
# ADR-125 §2.1.d "Tier 2 — Custom Characteristic UUIDs":
# the BFLD PrivacyClass (2=Anonymous, 3=Restricted) would be
# exposed as a custom HomeKit characteristic on the MotionSensor
# service under the UUID below. Apple's Home.app ignores unknown
# UUIDs; Eve.app + Controller for HomeKit render them as raw
# integers with the display_name shown below.
#
# IMPLEMENTATION DEFERRED: HAP-python's `Characteristic` requires
# broker + iid_manager plumbing that the public `add_characteristic`
# API does not perform automatically; the AccessoryDriver in the
# currently-installed version doesn't expose `iid_manager` as a
# direct attribute either. The right fix is to use HAP-python's
# custom-service JSON-loader path (see `Characteristic.from_dict`
# + `Service.add_preload_service` with a custom resource) — a
# follow-up iter ships that. The constant + spec stays here as
# the SOTA-ready scaffold.
self.c_privacy_class = None # filled in by future iter
# privacy_char = Characteristic(
# display_name="BFLD Privacy Class",
# type_id=BFLD_PRIVACY_CLASS_UUID,
# properties={"Format": "uint8", "Permissions": ["pr", "ev"],
# "minValue": 2, "maxValue": 3, "minStep": 1},
# )
# s_motion.add_characteristic(privacy_char)
# self.c_privacy_class = privacy_char
self._last_motion = False
self._last_occ = False
self._last_anomaly_ts = 0.0
self._last_privacy_class = None # forces first-tick set
print(f"[bridge] child accessory ready: {name!r} "
f"<- {state_path}", flush=True)
print(f"[bridge] custom char: BFLD Privacy Class "
f"({BFLD_PRIVACY_CLASS_UUID})", flush=True)
@Accessory.run_at_interval(1.0)
def run(self):
state = _read_state(self._state_path)
if state is None:
return # absent / stale — leave HomeKit state at last-known
motion = bool(state.get("motion", False))
occupancy = bool(state.get("occupancy", False))
anomaly_ts = float(state.get("anomaly_ts", 0.0) or 0.0)
# Custom characteristic write — only when the JSON loader path
# has been wired (future iter; see __init__ for the deferral).
if self.c_privacy_class is not None:
privacy_class = int(state.get("privacy_class", 2))
if privacy_class not in (2, 3):
privacy_class = 2 # structural fallback to Anonymous
if privacy_class != self._last_privacy_class:
self.c_privacy_class.set_value(privacy_class)
self._last_privacy_class = privacy_class
print(f"[bridge] {self.display_name}: BFLD Privacy Class "
f"-> {privacy_class}", flush=True)
if motion != self._last_motion:
self.c_motion.set_value(motion)
self._last_motion = motion
print(f"[bridge] {self.display_name}: Motion -> {motion}",
flush=True)
if occupancy != self._last_occ:
self.c_occ.set_value(1 if occupancy else 0)
self._last_occ = occupancy
print(f"[bridge] {self.display_name}: Occupancy -> {occupancy} "
f"(Siri: 'is anyone in the {self.display_name.lower()}?')",
flush=True)
if anomaly_ts > self._last_anomaly_ts:
self.c_anomaly.set_value(0)
self._last_anomaly_ts = anomaly_ts
print(f"[bridge] {self.display_name}: "
f"Unrecognized Activity Pattern fired", flush=True)
def main() -> int:
p = argparse.ArgumentParser()
p.add_argument("--port", type=int, default=51827)
p.add_argument("--rooms",
help="Comma-separated rooms to advertise. Each one maps "
"to /tmp/ruview-state.<lowercase-hyphen>.json. "
"Default: auto-discover from filesystem + legacy.")
p.add_argument("--legacy-room", default="Living Room",
help="Name attached to /tmp/ruview-state.json (the iter "
"1-2 single-file IPC). Default: 'Living Room'.")
args = p.parse_args()
driver = AccessoryDriver(port=args.port, persist_file=str(PERSIST_FILE))
bridge = Bridge(driver, "RuView Sensing")
bridge.category = CATEGORY_BRIDGE
rooms: list[tuple[str, Path]] = []
if args.rooms:
for r in [s.strip() for s in args.rooms.split(",") if s.strip()]:
slug = r.lower().replace(" ", "-")
rooms.append((r, Path(f"/tmp/ruview-state.{slug}.json")))
else:
rooms = discover_rooms_from_filesystem()
if LEGACY_STATE.exists() or args.legacy_room:
rooms.insert(0, (args.legacy_room, LEGACY_STATE))
if not rooms:
sys.stderr.write(
"ERROR: no rooms discovered. Either run "
"c6-presence-watcher.py first (writes /tmp/ruview-state.json), "
"or pass --rooms 'Name1,Name2'.\n"
)
return 2
for name, path in rooms:
bridge.add_accessory(RoomAccessory(driver, name, path))
driver.add_accessory(accessory=bridge)
setup_code = driver.state.pincode
if hasattr(setup_code, "decode"):
setup_code = setup_code.decode()
SETUP_CODE_FILE.write_text(str(setup_code) + "\n")
print(f"[bridge] HAP bridge advertising as 'RuView Sensing' (production)",
flush=True)
print(f"[bridge] Setup code (also in {SETUP_CODE_FILE}): {setup_code}",
flush=True)
print(f"[bridge] Rooms: {[r[0] for r in rooms]}", flush=True)
print(f"[bridge] iPhone pair: Home app -> Add Accessory -> More Options",
flush=True)
driver.start()
return 0
if __name__ == "__main__":
sys.exit(main())
-281
View File
@@ -1,281 +0,0 @@
#!/usr/bin/env python3
"""
ruview-sensing-server.py ADR-125 Tier 1+2 iter 2.
A tiny HTTP server that speaks the subset of the RuView sensing-server
HTTP API that @ruvnet/rvagent (ADR-124, npm v0.1.0) expects, sourced
from the BFLD-gated state files written by c6-presence-watcher.py.
This is the "sensing-server-equivalent" the cron stop condition names,
and it lets any MCP agent (Claude Code via `claude mcp add rvagent`,
Codex with the matching MCP config, custom LLM client) consume the
real ESP32-C6 stream through the same MCP tool surface that the Rust
sensing-server exposes without needing the Rust binary to be running.
Endpoints (matched against tools/ruview-mcp/src/tools/*.ts):
GET /health liveness
GET /api/v1/sensing/latest ADR-102 schema v2
GET /api/v1/edge/registry node enumeration
GET /api/v1/vitals/<node_id>/latest EdgeVitalsMessage
GET /api/v1/bfld/<node_id>/last_scan BfldScanResponse
POST /api/v1/bfld/<node_id>/subscribe?duration_s=N { subscription_id }
The source-of-truth file is `/tmp/ruview-last-feature.json` written
by the watcher on every BFLD-gated feature_state packet. If absent
or stale (> STALENESS_S seconds old), endpoints return 503 with a
hint so the rvagent tool emits a graceful warn shape.
Bearer-token auth is intentionally OFF in this dev surface the
Rust sensing-server adds it via the #443 middleware; that path is
out of scope for the demo bridge.
"""
from __future__ import annotations
import json
import os
import re
import sys
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import urlparse, parse_qs
FEATURE_FILE = os.environ.get("RUVIEW_FEATURE_JSON",
"/tmp/ruview-last-feature.json")
STALENESS_S = 10.0
DEFAULT_PORT = int(os.environ.get("PORT", "3000"))
def _load_feature() -> dict | None:
try:
with open(FEATURE_FILE, "r") as fh:
d = json.load(fh)
except (FileNotFoundError, json.JSONDecodeError, OSError):
return None
if not isinstance(d, dict):
return None
age = time.time() - float(d.get("ts", 0))
if age > STALENESS_S:
return None
return d
def vitals_for(node_id: str) -> dict | None:
f = _load_feature()
if f is None or f.get("node_id") != node_id:
return None
return {
"node_id": f["node_id"],
"timestamp_ms": int(f.get("timestamp_ms",
int(time.time() * 1000))),
"presence": bool(f.get("presence", False)),
"n_persons": int(f.get("n_persons", 0)),
"confidence": float(f.get("confidence", 0.0)),
"breathing_rate_bpm": f.get("breathing_rate_bpm"),
"heartrate_bpm": f.get("heartrate_bpm"),
"motion": float(f.get("motion", 0.0)),
}
def bfld_scan_for(node_id: str) -> dict | None:
f = _load_feature()
if f is None or f.get("node_id") != node_id:
return None
# ADR-125 §2.1.d: identity_risk_score never crosses the HAP
# boundary. We mirror that here — even though rvagent's schema
# has a nullable identity_risk_score slot, we deliberately
# always return None for it on this bridge.
return {
"node_id": f["node_id"],
"identity_risk_score": None, # ADR-125 §2.1.d invariant
"privacy_class": int(f.get("privacy_class", 2)),
"person_count": int(f.get("n_persons", 0)),
"confidence": float(f.get("confidence", 0.0)),
"presence": bool(f.get("presence", False)),
# timestamp_ns matches BFLD wire format (BfldEvent.timestamp_ns)
"timestamp_ns": int(f.get("ts", time.time()) * 1_000_000_000),
}
_PATH_VITALS = re.compile(r"^/api/v1/vitals/([^/]+)/latest$")
_PATH_BFLD_SCAN = re.compile(r"^/api/v1/bfld/([^/]+)/last_scan$")
_PATH_BFLD_SUBSCRIBE = re.compile(r"^/api/v1/bfld/([^/]+)/subscribe$")
_PATH_SEMANTIC = re.compile(r"^/api/v1/semantic-events/([^/]+)/latest$")
def semantic_events_for(node_id: str) -> dict | None:
"""ADR-125 §2.1.d semantic-event surface.
The three named events that cross the HAP boundary. Each one is a
boolean + last-fire timestamp. Agents subscribe to this endpoint
rather than reasoning over raw scores the naming is the contract.
"""
f = _load_feature()
if f is None or f.get("node_id") != node_id:
return None
presence = bool(f.get("presence", False))
anomaly = float(f.get("anomaly_score") or 0.0)
return {
"node_id": f["node_id"],
"privacy_class": int(f.get("privacy_class", 2)),
"events": {
"unknown_presence": {
"active": presence,
"source": "BFLD presence_score (rolling 3s avg ≥ 0.30)",
"ts": f["ts"],
},
"unexpected_occupancy": {
# Placeholder: schedule-aware gating is future work.
# For now we surface raw occupancy and mark the gate
# as `schedule_aware=False` so agents know not to
# equate this with the full §2.1.d intent yet.
"active": presence,
"schedule_aware": False,
"ts": f["ts"],
},
"unrecognized_activity_pattern": {
"active": anomaly >= 0.7,
"anomaly_threshold": 0.7,
"anomaly_score": anomaly,
"ts": f["ts"],
},
},
# ADR-125 §2.1.d invariant restated at the HTTP boundary:
# identity_risk_score, soul_match_probability, and rf_signature_hash
# are NEVER published from this endpoint.
"redacted_fields": [
"identity_risk_score",
"soul_match_probability",
"rf_signature_hash",
],
}
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt: str, *args) -> None:
# Quiet the default per-request log; print on a single line.
sys.stdout.write(
f"[{self.log_date_time_string()}] {self.command} "
f"{self.path} -> {args[1] if len(args) > 1 else '?'}\n"
)
def _json(self, code: int, body: dict) -> None:
payload = json.dumps(body).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def do_GET(self) -> None:
parsed = urlparse(self.path)
path = parsed.path
if path == "/health":
f = _load_feature()
self._json(200, {
"ok": True,
"feature_age_s": (None if f is None
else round(time.time() - f["ts"], 2)),
"source": FEATURE_FILE,
})
return
if path == "/api/v1/edge/registry":
f = _load_feature()
nodes = ([{"node_id": f["node_id"], "kind": "esp32-c6",
"online": True}] if f else [])
self._json(200, {"nodes": nodes})
return
if path == "/api/v1/sensing/latest":
f = _load_feature()
if f is None:
self._json(503, {"error": "no recent feature_state",
"hint": "is c6-presence-watcher running?"})
return
# ADR-102 sensing/latest schema v2 — the rvagent
# csi-latest tool ingests this shape.
self._json(200, {
"schema_version": 2,
"node_id": f["node_id"],
"timestamp_ms": f["timestamp_ms"],
"presence": f["presence"],
"n_persons": f["n_persons"],
"confidence": f["confidence"],
"motion": f["motion"],
"breathing_rate_bpm": f.get("breathing_rate_bpm"),
"heartrate_bpm": f.get("heartrate_bpm"),
"privacy_class": f.get("privacy_class", 2),
})
return
m = _PATH_VITALS.match(path)
if m:
node_id = m.group(1)
v = vitals_for(node_id)
if v is None:
self._json(503, {"error": f"no recent vitals for {node_id}",
"hint": "watcher running? node_id correct?"})
return
self._json(200, v)
return
m = _PATH_BFLD_SCAN.match(path)
if m:
node_id = m.group(1)
r = bfld_scan_for(node_id)
if r is None:
self._json(503, {"error": f"no recent BFLD scan for {node_id}",
"hint": "watcher running? node_id correct?"})
return
self._json(200, r)
return
m = _PATH_SEMANTIC.match(path)
if m:
node_id = m.group(1)
r = semantic_events_for(node_id)
if r is None:
self._json(503, {"error": f"no recent semantic events for {node_id}",
"hint": "watcher running? node_id correct?"})
return
self._json(200, r)
return
self._json(404, {"error": "not found", "path": path})
def do_POST(self) -> None:
parsed = urlparse(self.path)
m = _PATH_BFLD_SUBSCRIBE.match(parsed.path)
if m:
qs = parse_qs(parsed.query)
duration_s = float(qs.get("duration_s", ["10"])[0])
sub_id = f"sub-{int(time.time() * 1000)}-{m.group(1)}"
self._json(200, {
"subscription_id": sub_id,
"node_id": m.group(1),
"duration_s": duration_s,
"endpoint_hint": (f"poll GET /api/v1/bfld/{m.group(1)}"
"/last_scan every 1 s for the window"),
})
return
self._json(404, {"error": "not found", "path": parsed.path})
def main() -> int:
port = DEFAULT_PORT
server = HTTPServer(("0.0.0.0", port), Handler)
print(f"[sensing-server] listening on 0.0.0.0:{port}", flush=True)
print(f"[sensing-server] feature source: {FEATURE_FILE}", flush=True)
print(f"[sensing-server] staleness limit: {STALENESS_S} s", flush=True)
try:
server.serve_forever()
except KeyboardInterrupt:
pass
server.server_close()
return 0
if __name__ == "__main__":
sys.exit(main())
-178
View File
@@ -1,178 +0,0 @@
#!/usr/bin/env python3
"""
rvagent-mcp-consumer.py ADR-125 tier1+2 iter 5: end-to-end agentic loop.
Spawns the published `@ruvnet/rvagent` MCP server (ADR-124, npm 0.1.0)
as a subprocess and exercises it through the standard MCP JSON-RPC 2.0
stdio protocol. This is the "agentic capabilities" half of the ADR-125
Tier 1+2 sprint it proves the full bidirectional chain:
real C6 (192.168.1.179)
UDP feature_state
c6-presence-watcher.py (BFLD PrivacyGate)
/tmp/ruview-last-feature.json
ruview-sensing-server.py (sensing-server-equivalent on :3000)
@ruvnet/rvagent (this script spawns it via `npx -y`)
MCP JSON-RPC tools/call (this script sends them)
result returned to any MCP-aware agent
If real data flows back, the agentic surface for RuView's BFLD-gated
stream is live for every MCP client in the ecosystem Claude Code,
Codex, custom LLM agents.
Run on ruv-mac-mini (or any host with Node 20 + the running
ruview-sensing-server.py on :3000):
RVAGENT_SENSING_URL=http://localhost:3000 \
python3 rvagent-mcp-consumer.py
"""
from __future__ import annotations
import json
import os
import sys
import time
import subprocess
NODE_ID = os.environ.get("RVAGENT_TEST_NODE", "12")
SENSING_URL = os.environ.get("RVAGENT_SENSING_URL", "http://localhost:3000")
def _send(proc: subprocess.Popen, msg: dict) -> None:
line = json.dumps(msg) + "\n"
proc.stdin.write(line)
proc.stdin.flush()
def _recv(proc: subprocess.Popen, want_id: int | None = None,
timeout: float = 8.0) -> dict | None:
"""Read JSON-RPC responses, optionally waiting for a specific id."""
deadline = time.time() + timeout
while time.time() < deadline:
line = proc.stdout.readline()
if not line:
time.sleep(0.05)
continue
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
except json.JSONDecodeError:
# rvagent may print non-JSON log lines on stdout in
# error cases — skip and keep listening.
print(f"[non-json] {line[:200]}", file=sys.stderr)
continue
if want_id is None or obj.get("id") == want_id:
return obj
return None
def call_tool(proc: subprocess.Popen, tool_name: str,
args: dict, request_id: int) -> dict | None:
_send(proc, {
"jsonrpc": "2.0", "id": request_id, "method": "tools/call",
"params": {"name": tool_name, "arguments": args},
})
return _recv(proc, want_id=request_id, timeout=12.0)
def main() -> int:
env = {**os.environ, "RVAGENT_SENSING_URL": SENSING_URL}
print(f"[mcp-consumer] spawning npx -y @ruvnet/rvagent")
print(f"[mcp-consumer] RVAGENT_SENSING_URL={SENSING_URL}")
print(f"[mcp-consumer] test node_id={NODE_ID}")
proc = subprocess.Popen(
["npx", "-y", "@ruvnet/rvagent"],
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, text=True, env=env, bufsize=1,
)
# Give npx a chance to install if cold.
time.sleep(2.0)
# 1. initialize handshake
_send(proc, {
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "ruview-iter5-consumer", "version": "0.1"},
},
})
resp = _recv(proc, want_id=1)
if resp is None:
print("[mcp-consumer] FAIL: no initialize response", file=sys.stderr)
proc.kill()
return 1
server_info = resp.get("result", {}).get("serverInfo", {})
print(f"[mcp-consumer] server: {server_info.get('name')} "
f"v{server_info.get('version')}")
# initialized notification
_send(proc, {"jsonrpc": "2.0", "method": "notifications/initialized"})
# 2. tools/list
_send(proc, {"jsonrpc": "2.0", "id": 2, "method": "tools/list"})
resp = _recv(proc, want_id=2)
tools = (resp or {}).get("result", {}).get("tools", [])
print(f"[mcp-consumer] {len(tools)} tools available:")
for t in tools:
print(f" - {t.get('name')}")
# Locate the actual tool names (rvagent uses both snake_case and
# dotted forms — discover them rather than hard-coding).
names = [t.get("name") for t in tools]
vitals_tool = next((n for n in names
if "vitals" in n and ("all" in n or n.endswith("vitals"))), None)
bfld_tool = next((n for n in names if "bfld" in n and "last_scan" in n), None)
print(f"[mcp-consumer] resolved: vitals={vitals_tool} bfld={bfld_tool}")
# 3. tools/call vitals
resp = call_tool(proc, vitals_tool or "vitals_get_all",
{"node_id": NODE_ID}, 3)
if resp is None or "error" in resp:
print(f"[mcp-consumer] vitals_get_all failed: {resp}",
file=sys.stderr)
else:
content = resp.get("result", {}).get("content", [])
text = content[0].get("text", "") if content else ""
print(f"[mcp-consumer] vitals_get_all OK — {len(text)} bytes")
try:
parsed = json.loads(text)
print(f" presence={parsed.get('data', {}).get('presence')}, "
f"motion={parsed.get('data', {}).get('motion')}, "
f"breathing={parsed.get('data', {}).get('breathing_rate_bpm')}, "
f"hr={parsed.get('data', {}).get('heartrate_bpm')}")
except (json.JSONDecodeError, AttributeError):
print(f" (response head: {text[:200]})")
# 4. tools/call bfld last_scan
resp = call_tool(proc, bfld_tool or "ruview.bfld.last_scan",
{"node_id": NODE_ID}, 4)
if resp is None or "error" in resp:
print(f"[mcp-consumer] bfld_last_scan failed: {resp}",
file=sys.stderr)
else:
content = resp.get("result", {}).get("content", [])
text = content[0].get("text", "") if content else ""
print(f"[mcp-consumer] bfld_last_scan OK — {len(text)} bytes")
try:
parsed = json.loads(text)
print(f" privacy_class={parsed.get('privacy_class')}, "
f"identity_risk_score={parsed.get('identity_risk_score')!r}, "
f"presence={parsed.get('presence')}, "
f"person_count={parsed.get('n_frames')}")
except (json.JSONDecodeError, AttributeError):
print(f" (response head: {text[:200]})")
proc.stdin.close()
proc.wait(timeout=5)
print("[mcp-consumer] done — agentic chain validated end-to-end")
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except KeyboardInterrupt:
sys.exit(130)
-65
View File
@@ -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).
+5 -95
View File
@@ -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",
+6 -28
View File
@@ -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",
Binary file not shown.
-179
View File
@@ -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}`,
};
}
+4 -151
View File
@@ -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`
);
}
-79
View File
@@ -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>;
-9
View File
@@ -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";
-242
View File
@@ -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,
};
}
-18
View File
@@ -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. */
-144
View File
@@ -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);
});
});
-101
View File
@@ -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");
});
});
-208
View File
@@ -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();
});
});
-177
View File
@@ -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
+7 -77
View File
@@ -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"
@@ -9397,7 +9327,7 @@ dependencies = [
[[package]]
name = "wifi-densepose-sensing-server"
version = "0.3.1"
version = "0.3.0"
dependencies = [
"axum",
"chrono",
@@ -9427,7 +9357,7 @@ dependencies = [
[[package]]
name = "wifi-densepose-signal"
version = "0.3.1"
version = "0.3.0"
dependencies = [
"chrono",
"criterion",
@@ -10449,7 +10379,7 @@ dependencies = [
"aes",
"byteorder",
"bzip2",
"constant_time_eq 0.1.5",
"constant_time_eq",
"crc32fast",
"crossbeam-utils",
"flate2",
-5
View File
@@ -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
+2 -1
View File
@@ -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.01.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
+1
View File
@@ -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"
+2 -1
View File
@@ -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"
-65
View File
@@ -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"
-116
View File
@@ -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);

Some files were not shown because too many files have changed in this diff Show More