Compare commits

...

5 Commits

Author SHA1 Message Date
dependabot[bot] ccef656221 chore(deps): bump docker/setup-buildx-action from 3 to 4
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-14 17:58:13 +00:00
rUv df617145d6 feat(ADR-262 P3): live /api/field + /ws/field — RuView sensing speaks RuField (fail-closed egress) (#1071)
* feat(ADR-262 P3): live RuField surface — RuView sensing speaks RuField on /api/field + /ws/field

Wire the P1 `wifi-densepose-rufield` bridge into the live
`wifi-densepose-sensing-server` so the governed sensing cycle emits real
signed RuField `FieldEvent`s on two additive endpoints.

- Cargo: add the `wifi-densepose-rufield` path dep (the single coupling
  point, ADR-262 §5.4 — no new RuView-internal coupling).
- New `src/rufield_surface.rs` (kept out of the 8k-line main.rs):
  `FieldSurface` holds a dedicated ed25519 `Signer` + a bounded ring of
  recent events + the `/ws/field` broadcast topic; `GET /api/field` and
  `GET /ws/field` handlers; a standalone `router()` for isolated testing.
- Signer (defers the P2 key decision, ADR-262 §8 Q1): a STANDALONE
  dev/sensing key from `WDP_RUFIELD_SIGNING_SEED`, else a deterministic
  dev default with a logged WARN. Reusing the `cog-ha-matter` Ed25519
  key is the deferred P2 call — P3 does not pre-empt it.
- Tap: at the ESP32 governed-trust cycle (`main.rs` ~5886 observe_cycle
  / ~5938 SensingUpdate build), `emit_rufield_event` joins the cycle's
  features/classification/signal_field with the engine's
  effective_class/demoted trust state into a `SensingSnapshot` and
  surfaces it via the bridge. Existing endpoints (`/ws/sensing` etc.)
  are unchanged — purely additive.
- Privacy egress: `network_egress_allowed` is fail-closed for an
  unattended live surface — only P1/P2 leave the box; P0 raw and
  P3/P4/P5 (identity/biometric/aggregate) are held edge-local. A
  `Derived` cycle maps to P4/P5 and never surfaces.
- No-phantom: `emit` drops no-presence cycles (no fabricated events).

Gates (tests/rufield_surface_test.rs, tower::oneshot, 4/0): well-formed
signed event (WifiCsi, P2 not P1, is_fusable, real timestamp); empty
cycle → no phantom; Derived trust never surfaces; mixed stream surfaces
only egress-safe events.

Honesty (ADR-262 §0/§6): real plumbing on a live endpoint, NOT accuracy.
Single-link CSI with its existing caveats (no validated room-coordinate
accuracy); dedicated dev signing key pending the P2 ownership decision;
no accuracy claim.

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs(ADR-262 P3): mark P1+P3 implemented; document /api/field + /ws/field; CHANGELOG

- ADR-262 Status → "P1 + P3 implemented"; add a P3 implementation-status
  block (tap site, endpoints, dedicated dev signer deferring the §8 Q1
  key decision, fail-closed egress, gates). Keep the honesty framing:
  real plumbing on a live endpoint, not accuracy.
- CHANGELOG [Unreleased]: add the ADR-262 P3 entry.
- user-guide: add `/api/field` to the REST table + a "RuField surface
  (ADR-262 P3)" section covering `/api/field` + `/ws/field`, the
  fail-closed P1/P2-only egress, the WDP_RUFIELD_SIGNING_SEED dev key,
  and the no-accuracy honesty note.

Co-Authored-By: claude-flow <ruv@ruv.net>

* ci: checkout submodules everywhere + Dockerfile copies vendor/rufield

Making wifi-densepose-rufield (ADR-262 bridge) a v2 workspace member means
EVERY cargo-on-workspace context must have the vendor/rufield submodule
present (cargo loads all member manifests). P1 only fixed the rust-tests
job; this adds `submodules: recursive` to all workflow checkouts that run
cargo (mqtt-integration was failing on the missing submodule manifest), and
makes Dockerfile.rust COPY vendor/rufield/ to /vendor/rufield (matches the
bridge's ../../../vendor/rufield path-dep under the collapsed Docker layout).
update-submodules.yml left alone (it manages submodules itself).

Co-Authored-By: claude-flow <ruv@ruv.net>

---------

Co-authored-by: ruv <ruvnet@gmail.com>
2026-06-14 13:55:41 -04:00
rUv f250149e94 feat(ADR-262 P1): wifi-densepose-rufield bridge — RuView sensing → signed RuField FieldEvents (fail-closed privacy map) (#1070)
* feat(rufield): ADR-262 P1 — wifi-densepose-rufield anti-corruption bridge

New v2 workspace member that converts RuView WiFi-CSI sensing output into
signed RuField FieldEvents. Path-deps the vendor/rufield submodule crates
(rufield-core/-provenance/-privacy/-fusion); single coupling point between
RuView and the standalone RuField MFS spec (ADR-262 §5.4).

- SensingSnapshot: owned primitives mirroring SensingUpdate + TrustedOutput
  (no dependency on wifi-densepose-sensing-server).
- snapshot_to_field_event(): builds a WifiCsi FieldTensor + Observation,
  derives a real position from the signal-field peak (never fabricated),
  real sha256 provenance + ed25519 signature (synthetic=false).
- map_privacy() (§3.3 crux): maps by information content, NEVER byte value —
  Derived (byte 1) → P4/P5, never P1; fail-closed demotion floor to P2.

P1 gates (tests/p1_gates.rs): round-trip serde, is_fusable verified receipt,
RuFieldFusion::ingest accept + infer runs, privacy-safety (Derived never P1),
full §3.3 table, fail-closed demotion, determinism, no-fabricated-position.
15 tests pass (5 unit + 9 integration + 1 doc), 0 failed.

Honesty: P1 plumbing (tested conversion + safe privacy mapping), NOT wired
into the live server (P3) and NOT an accuracy claim.

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs(adr-262): mark P1 implemented + CI submodules:recursive + CHANGELOG/CLAUDE

- ADR-262 Status → "Proposed — P1 implemented"; add §0.1 Implementation
  status (the bridge crate + the five P1 gates that pass; defers the
  provenance-carrier reuse, P3 live wiring, and P4 multi-modality).
- ci.yml: add `submodules: recursive` to the rust-tests checkout so the new
  crate's `vendor/rufield` path-deps resolve in CI (they fail otherwise even
  though the workspace build passes locally with the submodule present).
- CHANGELOG [Unreleased]: P1 bridge entry (kept alongside the upstream
  ADR-262 research entry).
- CLAUDE.md: crate table row for `wifi-densepose-rufield`.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-14 12:46:58 -04:00
rUv faca0530de docs(adr-262): RuField↔RuView integration design (Proposed) (#1069)
Researched integration ADR: thin wifi-densepose-rufield bridge crate
(rvcsi pattern), live SensingServerAdapter emitting signed FieldEvents,
vertical fusion composition (ruvsense within-WiFi → rufield cross-modal),
and ONE canonical privacy/provenance model (RuView effective_class →
RuField P0-P5 at egress; reuse cog-ha-matter SHA-256+Ed25519 receipt).
Key finding: RuView has 2 privacy enums + 3 witness mechanisms; the
Derived(byte=1)<Anonymous(byte=2)-but-carries-identity trap means the
bridge must map by information content, not byte value. Plumbing
architecture, not accuracy (real-CSI is unlabeled replay today).

Co-authored-by: ruv <ruvnet@gmail.com>
2026-06-14 12:03:16 -04:00
rUv 6f6c867629 feat(rufield): CsiReplayAdapter — first real WiFi-CSI adapter (submodule bump) (#1068)
Bumps vendor/rufield to include CsiReplayAdapter: RuField now ingests real
captured WiFi CSI (.csi.jsonl) → FieldTensor → CSI-variance motion/presence
proxy → signed FieldEvents → fusion. Measured on 199 real frames: 182 fused
inferences (115 breathing, 67 person_present) from real signal. Replay-from-file,
unlabeled (proxy not validated accuracy) — live streaming + labeled accuracy
remain roadmap; mmWave/thermal stay synthetic.

Co-authored-by: ruv <ruvnet@gmail.com>
2026-06-14 11:45:50 -04:00
40 changed files with 1967 additions and 12 deletions
@@ -33,6 +33,8 @@ jobs:
working-directory: v2
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Install Rust toolchain
run: rustup show && rustc --version
@@ -53,6 +53,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
+6
View File
@@ -42,6 +42,8 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
submodules: recursive
- name: Determine deployment environment
id: determine-env
@@ -86,6 +88,8 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
submodules: recursive
- name: Set up kubectl
uses: azure/setup-kubectl@v3
@@ -132,6 +136,8 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
submodules: recursive
- name: Set up kubectl
uses: azure/setup-kubectl@v3
+17 -1
View File
@@ -29,6 +29,7 @@ jobs:
continue-on-error: true
uses: actions/checkout@v4
with:
submodules: recursive
fetch-depth: 0
- name: Set up Python
@@ -82,6 +83,13 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
submodules: recursive
# ADR-262 P1: `wifi-densepose-rufield` path-deps the `vendor/rufield`
# submodule. Without a recursive checkout the workspace build fails to
# resolve those path deps in CI even though it passes locally.
with:
submodules: recursive
# `wifi-densepose-desktop` is a Tauri v2 app — `glib-sys`, `gtk-sys`,
# `webkit2gtk-sys`, etc. need the Linux dev libraries via pkg-config or the
@@ -202,6 +210,8 @@ jobs:
- name: Checkout code
continue-on-error: true
uses: actions/checkout@v4
with:
submodules: recursive
- name: Set up Python ${{ matrix.python-version }}
continue-on-error: true
@@ -267,6 +277,8 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
submodules: recursive
- name: Set up Python
uses: actions/setup-python@v6
@@ -335,10 +347,12 @@ jobs:
- name: Checkout code
continue-on-error: true
uses: actions/checkout@v4
with:
submodules: recursive
- name: Set up Docker Buildx
continue-on-error: true
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Log in to Container Registry
continue-on-error: true
@@ -407,6 +421,8 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
submodules: recursive
- name: Set up Python
uses: actions/setup-python@v6
+2
View File
@@ -35,6 +35,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Fetch /traffic/clones + /traffic/views from GitHub
env:
@@ -28,6 +28,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
@@ -78,6 +80,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
@@ -145,6 +149,8 @@ jobs:
vars.HAS_GCP_CREDENTIALS == 'true'
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Download x86_64 artifact
uses: actions/download-artifact@v4
+2
View File
@@ -20,6 +20,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- uses: dtolnay/rust-toolchain@stable
with: { targets: wasm32-unknown-unknown }
+2
View File
@@ -26,6 +26,8 @@ jobs:
steps:
- name: Checkout main
uses: actions/checkout@v4
with:
submodules: recursive
- name: Install Rust + wasm32 target
uses: dtolnay/rust-toolchain@stable
+6
View File
@@ -28,6 +28,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup Node.js
uses: actions/setup-node@v6
@@ -83,6 +85,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup Node.js
uses: actions/setup-node@v6
@@ -131,6 +135,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive
- name: Download all artifacts
uses: actions/download-artifact@v4
+4
View File
@@ -22,6 +22,8 @@ jobs:
if: github.ref_type == 'tag'
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Check firmware version.txt == tag
run: |
# Tag form: vX.Y.Z-esp32 → expect version.txt to contain X.Y.Z
@@ -71,6 +73,8 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Build firmware (${{ matrix.variant }})
working-directory: firmware/esp32-csi-node
+8
View File
@@ -100,6 +100,8 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Download QEMU artifact
uses: actions/download-artifact@v4
@@ -214,6 +216,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Install clang
run: |
@@ -263,6 +267,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Install NVS generator
run: pip install esp-idf-nvs-partition-gen
@@ -317,6 +323,8 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Download QEMU artifact
uses: actions/download-artifact@v4
@@ -22,6 +22,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- uses: actions/setup-python@v6
with:
+2
View File
@@ -41,6 +41,8 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Install mosquitto + clients and start with allow_anonymous
run: |
+3 -1
View File
@@ -26,8 +26,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- uses: docker/setup-buildx-action@v3
- uses: docker/setup-buildx-action@v4
- uses: docker/login-action@v3
with:
+6
View File
@@ -76,6 +76,8 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
# Linux aarch64 needs QEMU for cross-build on x86_64 runners.
- name: Set up QEMU
@@ -121,6 +123,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Install maturin
run: pip install maturin>=1.7
- name: Build sdist
@@ -144,6 +148,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- uses: actions/setup-python@v5
with:
python-version: '3.12'
+2
View File
@@ -29,6 +29,8 @@ jobs:
steps:
- name: Checkout main
uses: actions/checkout@v4
with:
submodules: recursive
- name: Stage viewer for Pages
run: |
+8
View File
@@ -40,6 +40,8 @@ jobs:
- { label: 'full+train', flags: '--features full,train' }
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- uses: dtolnay/rust-toolchain@stable
- name: Cache cargo
uses: actions/cache@v4
@@ -60,6 +62,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
# v2/rust-toolchain.toml pins channel "1.89" with profile "minimal" (no
# clippy). dtolnay@stable installs clippy on the floating "stable"
# toolchain, but the override makes cargo use the separate "1.89"
@@ -93,6 +97,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- uses: dtolnay/rust-toolchain@stable
- name: Cache cargo
uses: actions/cache@v4
@@ -127,6 +133,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: publish = false is present (no accidental crates.io publish)
run: |
CARGO=v2/crates/ruview-swarm/Cargo.toml
+13 -1
View File
@@ -28,6 +28,7 @@ jobs:
continue-on-error: true
uses: actions/checkout@v4
with:
submodules: recursive
fetch-depth: 0
- name: Set up Python
@@ -97,6 +98,8 @@ jobs:
- name: Checkout code
continue-on-error: true
uses: actions/checkout@v4
with:
submodules: recursive
- name: Set up Python
continue-on-error: true
@@ -164,10 +167,12 @@ jobs:
- name: Checkout code
continue-on-error: true
uses: actions/checkout@v4
with:
submodules: recursive
- name: Set up Docker Buildx
continue-on-error: true
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Build Docker image for scanning
continue-on-error: true
@@ -245,6 +250,8 @@ jobs:
- name: Checkout code
continue-on-error: true
uses: actions/checkout@v4
with:
submodules: recursive
- name: Run Checkov IaC scan
continue-on-error: true
@@ -307,6 +314,7 @@ jobs:
continue-on-error: true
uses: actions/checkout@v4
with:
submodules: recursive
fetch-depth: 0
- name: Run TruffleHog secret scan
@@ -341,6 +349,8 @@ jobs:
- name: Checkout code
continue-on-error: true
uses: actions/checkout@v4
with:
submodules: recursive
- name: Set up Python
continue-on-error: true
@@ -378,6 +388,8 @@ jobs:
- name: Checkout code
continue-on-error: true
uses: actions/checkout@v4
with:
submodules: recursive
- name: Check security policy files
continue-on-error: true
+1 -1
View File
@@ -58,7 +58,7 @@ jobs:
# by the runner, not built on a separate arm64 host).
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- uses: docker/setup-buildx-action@v4
- name: Log in to Docker Hub
# Bypassing docker/login-action@v3: the action kept emitting
+2
View File
@@ -30,6 +30,8 @@ jobs:
steps:
- name: Checkout main
uses: actions/checkout@v4
with:
submodules: recursive
- name: Stage demos for Pages
run: |
+2
View File
@@ -30,6 +30,8 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
submodules: recursive
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
+4
View File
@@ -8,6 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **ADR-262 P3 — live RuField surface: RuView's running sensing-server now speaks RuField on `/api/field` + `/ws/field`.** Wires the P1 `wifi-densepose-rufield` bridge into the live `wifi-densepose-sensing-server` (the bridge is the only added coupling, ADR-262 §5.4). A new `src/rufield_surface.rs` module (kept out of the 8k-line `main.rs`) holds a `FieldSurface` with a **dedicated ed25519 `Signer`**, a bounded ring buffer of recent signed events (`FIELD_RING_CAPACITY = 64`), and the `/ws/field` broadcast topic; it exposes `GET /api/field` (latest signed `FieldEvent`s + signer pubkey + a `dev_signing_key` flag) and `GET /ws/field` (per-cycle stream, mirroring `/ws/sensing`), plus a standalone `router()` for isolated testing. **Tap:** at the ESP32 governed-trust cycle (`main.rs` `observe_cycle` ~`:5886` / `SensingUpdate` build ~`:5938`), `emit_rufield_event` joins the cycle's real `SensingUpdate` (features/classification/signal_field) with the engine's recorded `effective_class`/`demoted` trust state into a `SensingSnapshot` and surfaces a signed `FieldEvent`**existing endpoints (`/ws/sensing` etc.) are unchanged; this is purely additive.** **Signer (defers the P2 key decision, §8 Q1):** a **standalone dev/sensing key** from `WDP_RUFIELD_SIGNING_SEED` (64-hex or ≥32-byte value), else a deterministic dev default with a logged `WARN` — reusing the `cog-ha-matter` Ed25519 key is the deferred P2 call, so P3 does not pre-empt it. **Egress privacy (fail-closed):** `network_egress_allowed` is *stricter* than `DefaultPrivacyGuard` for an unattended live surface — only **P1/P2** leave the box; P0 (raw) and P3/P4/P5 are held edge-local, so a `Derived → P4/P5` cycle **never** surfaces; no-presence cycles emit **no phantom event**. **P3 acceptance gates (`tests/rufield_surface_test.rs`, 4 integration via `tower::oneshot` + 4 module unit, 0 failed):** a well-formed **signed** event (`Modality::WifiCsi`, P2 not P1, `is_fusable` ed25519-verified, real timestamp); empty cycle → no phantom; **privacy-safety** — an injected `Derived` trust never surfaces; a mixed stream surfaces only egress-safe events. **Honest scope (ADR-262 §0/§6):** real plumbing on a **live endpoint**, **NOT accuracy** — single-link CSI with its existing caveats (no validated room-coordinate accuracy — `field_localize`), a dedicated dev signing key pending the P2 ownership decision, no accuracy claim. The win is narrowly: "RuView's live sensing now speaks RuField on `/ws/field`."
- **ADR-262 P1 — `wifi-densepose-rufield` anti-corruption bridge: RuView WiFi-CSI sensing → signed RuField `FieldEvent`s.** A new v2 workspace member (the *single coupling point* between RuView and the standalone RuField MFS spec, ADR-262 §5.4) that **path-deps** the `vendor/rufield` submodule crates (`rufield-core`/`-provenance`/`-privacy`/`-fusion` — pure-Rust, `--no-default-features`-buildable: serde/sha2/ed25519/toml only, no tch/openblas/ndarray/candle) and **no** RuView internal crate. The bridge takes owned primitives — `SensingSnapshot` mirrors the `/ws/sensing` `SensingUpdate` (features + classification + signal_field) joined with the `TrustedOutput` trust state (`trust_class`/`demoted`/`identity_bound`) — and `snapshot_to_field_event()` emits one **signed** `FieldEvent` (`Modality::WifiCsi`, axis `[Frequency]`): a real `FieldTensor` from the feature scalars with the real `timestamp_ns`; an `Observation` whose `range_m`/`motion_vector`/`space_cell` are derived from the strongest **signal-field peak** when present (else `None` — coordinates are **never fabricated**, per the `field_localize` caveat) and `confidence` from the classification; a real `ProvenanceRef` (sha256 over the tensor bytes, `synthetic=false`) **ed25519-signed** so `rufield_provenance::is_fusable` passes. **The §3.3 privacy mapping is the critical correctness item**, implemented as `map_privacy()` mapping RuView's class onto RuField P0P5 **by information content, NEVER by byte value** and **fail-closed**: RuView `Derived` (byte `1`, which sorts *below* `Anonymous` byte `2`) carries an identity embedding → maps to **P4** (or **P5** if identity-bound), **never P1** (the single most dangerous mapping mistake); `Raw → P0`, `Anonymous → P2`, `Restricted → P2`; a governed-engine `demoted` cycle floors the egress class to ≥ P2 with raw suppressed. **P1 acceptance gates (15 tests / 0 failed — 5 unit + 9 integration + 1 doc):** round-trip (`SensingSnapshot → FieldEvent →` serde `→` equal), `is_fusable` (verified ed25519 receipt), `RuFieldFusion::ingest` accept + `infer()` runs, **privacy-safety** (`gate_privacy_safety_derived_never_maps_to_low_privacy``Derived → P4/P5`, never P1; a table test over every RuView class; fail-closed demotion), and determinism (same snapshot + same signer seed → byte-identical event). **Honest scope:** this is **P1 plumbing** — a tested conversion + a safe privacy mapping. It is **not** wired into the live server (that is P3) and makes **no accuracy claim** (RuField v0.1 is synthetic; RuView's single-link CSI carries its own caveats). CI: the `rust-tests` workflow checkout gains `submodules: recursive` so the path-deps resolve. Python deterministic proof unchanged (off the signal proof path).
- **ADR-262 (Proposed): RuField MFS ↔ RuView integration — a live `SensingServerAdapter`, a privacy/provenance bridge, MAPPED not papered-over.** Researched integration design for wiring RuField into RuView. Recommends: a thin **`wifi-densepose-rufield` bridge crate** (anti-corruption layer, path-deps on the `vendor/rufield` submodule — the `vendor/rvcsi` pattern, since rufield crates are unpublished); a **live `SensingServerAdapter`** that taps the real `SensingUpdate` emit site joined with `TrustedOutput` trust state and emits one signed `FieldEvent`/cycle (the file-based `CsiReplayAdapter` stays for offline replay); **vertical fusion composition** (ruvsense fuses *within* WiFi → one `wifi_csi` event → rufield-fusion graph fuses *across* modalities above it); and **one canonical privacy/provenance model** (RuView `effective_class` is source-of-truth, mapped to RuField P0P5 at egress; reuse the existing `cog-ha-matter` SHA-256+Ed25519 chain for the `ProvenanceReceipt`). **Key honest finding:** RuView has **two privacy enums + three witness mechanisms across two hash algorithms** that do not map 1:1 onto P0P5, and a real trap — RuView's `Derived` privacy byte (`1`) sorts *below* `Anonymous` (`2`) yet carries identity embeddings, so the bridge must map by **information content** (`Derived → P4/P5`), never by byte value, or it would leak identity as low-privacy P1. 4 independently-shippable phases, each with a test gate (round-trip / `is_fusable` / privacy-monotonicity / ed25519-verify). Honest scope: this is **plumbing architecture, not accuracy** — RuField v0.1 is synthetic and RuView's only real-CSI path is unlabeled replay; the ADR claims only architecture, gated by round-trip/monotonicity/signature tests.
- **RuField `CsiReplayAdapter` — first real (non-synthetic) WiFi-CSI adapter (ADR-260 §17).** RuField now ingests **real captured WiFi CSI** instead of only the synthetic simulator. New `rufield-adapters::csi_replay` parses RuView's `.csi.jsonl` recording format (`{timestamp, subcarriers[]}`), normalizes each frame to a `FieldTensor` (`WifiCsi`, real amplitudes + real `timestamp_ns`), establishes a per-subcarrier Welford **empty-room baseline** via `calibrate()`, derives a **physically-grounded CSI-variance motion/presence proxy** (normalized MAD vs baseline → P2 motion/presence, else P1), and emits `FieldEvent`s with a **real sha256 + ed25519 provenance receipt** (`synthetic=false`). **Measured on 199 real captured frames:** 184 presence-proxy / 69 motion-proxy → fed through `RuFieldFusion`**182 fused inferences (115 breathing, 67 person_present) from real signal.** 12 tests (9 unit + 3 integration over real-CSI fixtures), deterministic (byte-identical stream per file). **Honest caveats (stated everywhere):** it's **replay from file, not live hardware**; recordings are **unlabeled**, so the motion/presence output is a **proxy, NOT validated accuracy** (no pose, no accuracy numbers); live streaming + labeled validation remain roadmap; mmWave/thermal stay synthetic. The win is "RuField ingests real WiFi CSI and produces fused events from it." [`ruvnet/rufield`](https://github.com/ruvnet/rufield) `crates/rufield-adapters`; `vendor/rufield` submodule bumped.
- **RuField `rufield-viewer` web dashboard — completes ADR-260 §27.9 (all §27 criteria 110 now PASS).** A read-only Axum + vanilla-JS dashboard (no build step — `cargo run -p rufield-viewer`) that streams the deterministic SyntheticSim→fusion camera-free room-intelligence demo: live room-state inferences with confidence, a scrolling event log where every event carries its modality + a colour-coded **P0P5 privacy badge**, the fusion graph (supporting=green / contradicting=red per inference), and a click-to-open **provenance-receipt modal** (sha256 + ed25519 signer + verified ✓ / fusable ✓) — behind a permanent, undismissable `SYNTHETIC — simulated sensors, no hardware` banner. Endpoints `/` · `/app.js` · `/health` · `/api/run` (full deterministic JSON) · `/events` (SSE). 12 new tests. Honest scope: a read-only SYNTHETIC demo viewer, **not** a device-management console — fleet/real-adapter management is a separate later milestone. Lives in [`ruvnet/rufield`](https://github.com/ruvnet/rufield) (`crates/rufield-viewer`, repo now 7 crates / 72 tests); `vendor/rufield` submodule bumped to include it.
- **ADR-261: RuVector graph-ANN index — a real HNSW baseline + a SymphonyQG-style quantized variant, MEASURED (honest negative).** Closes the [ADR-156 §5 #1](docs/adr/ADR-156-ruvector-fusion-beyond-sota.md) gap: the SymphonyQG (SIGMOD 2025) **3.517× QPS-over-HNSW** claim was CLAIMED-only because **no HNSW baseline existed to compare against**. This adds one. New pure-Rust, `--no-default-features`-buildable modules in `wifi-densepose-ruvector`: `hnsw.rs` (a correct float HNSW — Malkov & Yashunin: multi-layer NSW graph, `ef_construction`/`ef_search`, Algorithm-4 neighbour selection, **seeded-deterministic** level assignment via SplitMix64, L2 + cosine, full degenerate-case guards), `hnsw_quantized.rs` (the SymphonyQG-style variant — the **same** graph traversed by a cheap **1-bit Hamming** score over the RaBitQ Pass-2 rotated sign code, then **exact-float rerank**), `ann_measure.rs` + `benches/ann_bench.rs` (one shared deterministic planted-cluster fixture; the `ann_bench_report` test is the source of truth). **MEASURED (dim=128, N=10k, K=10, `--release`):** float HNSW = **~25× QPS over linear scan at recall ≥0.99** (the baseline this gap needed; recall@10 correctness gate ≥0.95 holds, L2 + cosine). **Honest negative:** the 1-bit quantized traversal is **too coarse to beat float HNSW at equal recall at this scale** — its best recall is **0.738**, never reaching the ≥0.90 equal-recall point, so there is **no QPS win** over float HNSW; the 3.517× is **not reproduced** by our 1-bit construction here. The recall gate also **caught a real index-out-of-bounds bug** in the insert path (disclosed in ADR-261 §4). Caveat: this is **our** HNSW + **our** 1-bit quant, not SymphonyQG's exact system — it tests the *direction* of the claim, with the expected crossover at large N + a multi-bit traversal code. **We did not tune to manufacture a speedup.** +20 tests (ruvector lib 131→151, 0 failed). ADR-156 §5 #1 / §8 backlog: CLAIMED → **MEASURED-direction-tested**. Python deterministic proof unchanged (off the signal proof path).
- **ADR-261 Milestone-2: multi-bit quantized HNSW traversal + large-N scaling study — MEASURED (honest negative).** Extends ADR-261's quantized index from 1-bit to **`b`-bit-per-dimension** (`b ∈ {1,2,4}`, 16/32/64 B/node) over the Pass-2 rotated coordinates, and runs a deterministic scaling study (N ∈ {10k, 100k, 250k}) to test M1's *prediction* of a large-N crossover. **Result: no crossover at any measured (N, b), and the trend refutes the prediction.** At N=10k more bits lift the equal-recall QPS ratio (0.19×→0.46×→0.48×) and let b≥2 reach the 0.90 recall bar 1-bit missed — but quant stays slower than float HNSW at equal recall; at N=100k/250k quant recall *collapses* (b=4: 1.000→0.788→0.624, never ≥0.90) while float holds ≥0.92 (denser graph → low-bit codes can't separate near-neighbours, beam goes off-path faster than the float-distance saving repays). Caveat: our HNSW + our per-node multi-bit code, not SymphonyQG's RaBitQ-fused graph — refutes the *direction* at ≤250k, not their million-scale numbers. ruvector lib **151→156** (+5 tests; `scaling_report` `#[ignore]` produced the table). A published negative with the mechanism explained. ADR-261 §11.
+2 -1
View File
@@ -22,7 +22,8 @@ Dual codebase: Python v1 (`v1/`) and Rust port (`v2/`).
| `wifi-densepose-vitals` | ESP32 CSI-grade vital sign extraction (ADR-021) |
| `nvsim` | Deterministic NV-diamond magnetometer pipeline simulator (ADR-089) — standalone leaf, WASM-ready |
| `vendor/rvcsi` (submodule) | **rvCSI** — edge RF sensing runtime (ADR-095/096): 9 crates (`rvcsi-core`/`-dsp`/`-events`/`-adapter-file`/`-adapter-nexmon`/`-ruvector`/`-runtime`/`-node`/`-cli`). Lives in its own repo ([github.com/ruvnet/rvcsi](https://github.com/ruvnet/rvcsi)), vendored here under `vendor/rvcsi`, published to crates.io as `rvcsi-* 0.3.x` and to npm as `@ruv/rvcsi`. Not a `v2/` workspace member — depend on the published crates (or the submodule's `crates/rvcsi-*` paths). Normalized `CsiFrame`/`CsiWindow`/`CsiEvent` schema, validate-before-FFI, reusable DSP, typed confidence-scored events, the napi-c Nexmon shim (real nexmon_csi `.pcap` from a Raspberry Pi 5 / 4 / 3B+ — BCM43455c0), the napi-rs SDK, the `rvcsi` CLI, a Claude Code plugin. |
| `vendor/rufield` (submodule) | **RuField MFS** — the open spec for camera-free multimodal field sensing (ADR-260). A common `FieldEvent`/`FieldTensor`/`FusionGraph`/`PrivacyClass`/`ProvenanceReceipt` model *above* WiFi CSI/CIR/BFLD, UWB, BLE Channel Sounding, mmWave radar, ultrasound, subsonic, infrared, and quantum sensors. Lives in its own repo ([github.com/ruvnet/rufield](https://github.com/ruvnet/rufield)), vendored here under `vendor/rufield`. Not a `v2/` workspace member. v0.1 reference stack = 7 crates (`rufield-core`/`-provenance`/`-privacy`/`-adapters`/`-fusion`/`-bench`/`-viewer`), 72 tests/0 failed; `rufield-viewer` is an Axum + vanilla-JS read-only dashboard (`cargo run -p rufield-viewer`) completing ADR-260 §27.9. All benchmark metrics are **SYNTHETIC** (simulator ground truth, no hardware — real adapters are roadmap). |
| `vendor/rufield` (submodule) | **RuField MFS** — the open spec for camera-free multimodal field sensing (ADR-260). A common `FieldEvent`/`FieldTensor`/`FusionGraph`/`PrivacyClass`/`ProvenanceReceipt` model *above* WiFi CSI/CIR/BFLD, UWB, BLE Channel Sounding, mmWave radar, ultrasound, subsonic, infrared, and quantum sensors. Lives in its own repo ([github.com/ruvnet/rufield](https://github.com/ruvnet/rufield)), vendored here under `vendor/rufield`. Not a `v2/` workspace member. v0.1 reference stack = 7 crates (`rufield-core`/`-provenance`/`-privacy`/`-adapters`/`-fusion`/`-bench`/`-viewer`), 72 tests/0 failed; `rufield-viewer` is an Axum + vanilla-JS read-only dashboard (`cargo run -p rufield-viewer`) completing ADR-260 §27.9. The WiFi-CSI modality is now **real-replay-backed** via `CsiReplayAdapter` (ingests real captured `.csi.jsonl` → fused presence/breathing inferences; replay-from-file, unlabeled CSI-variance proxy, not validated accuracy); mmWave/thermal + all synthetic-bench F1 numbers remain **SYNTHETIC** (no live hardware — live streaming + labeled accuracy are roadmap). |
| `wifi-densepose-rufield` | ADR-262 P1 **anti-corruption bridge** — converts RuView WiFi-CSI sensing output (`SensingSnapshot` mirroring `SensingUpdate` + `TrustedOutput`, owned primitives, no dep on `wifi-densepose-sensing-server`) into **signed RuField `FieldEvent`s** (`Modality::WifiCsi`, real `timestamp_ns`, sha256 + ed25519 provenance, `synthetic=false`). The single coupling point between RuView and the standalone RuField MFS spec (§5.4); path-deps the `vendor/rufield` submodule crates (`rufield-core`/`-provenance`/`-privacy`/`-fusion`). **Critical §3.3 privacy mapping** (`map_privacy`): maps RuView class → RuField P0P5 by **information content, never byte value**, fail-closed (`Derived → P4/P5`, never P1; `demoted` floors to ≥ P2). 15 tests / 0 failed (round-trip / `is_fusable` / fusion-ingest / privacy-safety / determinism). P1 plumbing — not wired into the live server (P3), no accuracy claim. |
| `ruview-swarm` | Drone swarm control system (ADR-148) — hierarchical-mesh topology, Raft consensus, MARL, CSI sensing payload, MAVLink/PX4 compat, Ruflo AI-agent integration |
### RuvSense Modules (`signal/src/ruvsense/`)
+7
View File
@@ -14,6 +14,13 @@ COPY v2/crates/ ./crates/
# Copy vendored RuVector crates
COPY vendor/ruvector/ /build/vendor/ruvector/
# Copy vendored RuField submodule — the `wifi-densepose-rufield` bridge crate
# (ADR-262) path-deps `../../../vendor/rufield/crates/*`, which from the Docker
# build layout (v2/ collapsed into /build) resolves to /vendor/rufield. Copy the
# whole tree so the rufield workspace Cargo.toml (workspace-dep inheritance) and
# the four bridged crates (rufield-core/-provenance/-privacy/-fusion) all resolve.
COPY vendor/rufield/ /vendor/rufield/
# 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)
@@ -0,0 +1,207 @@
# ADR-262: RuField MFS ↔ RuView integration — a live SensingServerAdapter, a privacy/provenance bridge, MAPPED not papered-over
| Field | Value |
|-------|-------|
| **Status** | Proposed — **P1 + P3 implemented** (live `/api/field` + `/ws/field`; P3 signs with a **dedicated dev/sensing key**, deferring the §8 Q1 `cog-ha-matter` key-ownership decision to P2) |
| **Date** | 2026-06-14 |
| **Deciders** | ruv |
| **Codebase target** | New thin bridge crate `wifi-densepose-rufield` (v2 workspace member); taps `wifi-densepose-sensing-server` emit path + `wifi-densepose-engine` `TrustedOutput`; depends on `vendor/rufield/crates/rufield-*` via path (the `vendor/rvcsi` pattern) |
| **Relates to** | ADR-260 (RuField MFS spec + v0.1 reference stack), ADR-261 (RuVector graph-ANN), ADR-141 (BFLD privacy control-plane / modes / attestation), ADR-137 (fusion-engine quality scoring / contradiction), ADR-032 (multistatic mesh security hardening / witness), ADR-116 (cog tamper-evident audit log — `cog-ha-matter` SHA-256+Ed25519), ADR-095/096 (`rvcsi` vendored-submodule precedent) |
| **Scope** | Decide **how** RuView's live WiFi-CSI sensing-server emits RuField `FieldEvent`s, **whether** RuView's ruvsense fusion composes with or is wrapped by rufield-fusion, and **how** to reconcile RuView's existing privacy/witness/provenance machinery with RuField's P0P5 + ed25519 `ProvenanceReceipt`. The privacy/provenance reconciliation is the crux. |
---
## 0. PROOF discipline (this ADR's contract)
This project has been publicly accused of "AI slop." This ADR answers with **evidence, not adjectives** — every "RuView already does X" carries a `file:line`, and every external/SOTA claim is graded.
- **No accuracy is claimed.** RuField v0.1 is **SYNTHETIC** end-to-end by its own admission (ADR-260 "Honest statement", line 386390: *"Every metric here is simulator-based. No ESP32 CSI, mmWave, or thermal capture was used."*). RuView's only real-CSI rufield path today would be **replay of recorded `.csi.jsonl`, unlabeled**`rufield-adapters::CsiReplayAdapter`'s own module doc (`vendor/rufield/crates/rufield-adapters/src/csi_replay.rs:19-31`) states it is *"real signal, replay from file not live hardware, unlabeled ⇒ proxy not validated accuracy."* This ADR therefore proposes **plumbing**, and grades its own claims as "ARCHITECTURE" (a design decision, testable by a round-trip/compile gate) vs "ACCURACY" (which it explicitly does not assert).
- The privacy/provenance section reports an **honest conflict**: RuView has **three** witness mechanisms across two hash algorithms, and **two** privacy enums, none of which map 1:1 onto RuField's P0P5. We map them and recommend the cleanest reconciliation rather than asserting they already align.
- Each phase below ships an **independently testable gate** (a round-trip test, a privacy-monotonicity test, a signature-verify test) so the integration is provable, not aspirational.
---
## 0.1 Implementation status
**P1 (§4) is implemented** as the `wifi-densepose-rufield` bridge crate (`v2/crates/wifi-densepose-rufield/`, a new v2 workspace member; path-deps the `vendor/rufield` submodule per §5.4):
- **Input** — `SensingSnapshot` (owned primitives mirroring `SensingUpdate` features/classification/signal_field joined with the `TrustedOutput` `trust_class`/`demoted`/`identity_bound`); the bridge does **not** depend on `wifi-densepose-sensing-server` (anti-corruption layer).
- **Conversion** — `snapshot_to_field_event(&snap, &Signer)` emits a signed `FieldEvent` (`Modality::WifiCsi`, axis `[Frequency]`, real `timestamp_ns`); position derived from the signal-field peak when present (never fabricated); real sha256 `ProvenanceRef` + ed25519 signature, `synthetic = false`.
- **Privacy (§3.3 crux)** — `map_privacy()` maps by information content, **fail-closed**: `Raw → P0`, `Derived → P4` (or `P5` if identity-bound — **never P1**), `Anonymous → P2`, `Restricted → P2`; a `demoted` cycle floors egress to ≥ P2.
- **Gates that pass** (`tests/p1_gates.rs`, 15 tests / 0 failed = 5 unit + 9 integration + 1 doc): round-trip (snapshot → `FieldEvent` → serde → equal); `is_fusable` (verified ed25519 receipt); `RuFieldFusion::ingest` accept + `infer()` runs; **privacy-safety** (`gate_privacy_safety_derived_never_maps_to_low_privacy``Derived → P4/P5`, never P1; full §3.3 table; fail-closed demotion); determinism (same snapshot + same signer seed → byte-identical event).
**P3 (§4) is implemented** as the live RuField surface in `wifi-densepose-sensing-server` (the bridge is now wired into the running server):
- **Tap** — at the ESP32 governed-trust cycle (`main.rs` `observe_cycle` ~`:5886` / `SensingUpdate` build ~`:5938`), a new `emit_rufield_event` joins the cycle's `SensingUpdate` (features / classification / signal_field) with the engine's recorded `effective_class` / `demoted` trust state into a `wifi_densepose_rufield::SensingSnapshot`, then `snapshot_to_field_event(&snap, &signer)`. Existing endpoints (`/ws/sensing` etc.) are **unchanged** — purely additive.
- **Surface** — `GET /api/field` (latest signed `FieldEvent`s + signer pubkey + a `dev_signing_key` flag) and `GET /ws/field` (broadcast stream, mirroring `/ws/sensing`), both mounted on the HTTP port and `/ws/field` also on the WS port. A small bounded ring buffer (`FIELD_RING_CAPACITY = 64`) holds recent **network-surfaced** events. New handler code lives in `src/rufield_surface.rs`, not in the 8k-line `main.rs`.
- **Signer (defers the P2 key decision)** — a **dedicated standalone `Signer`** held in server state, seeded from `WDP_RUFIELD_SIGNING_SEED` (64-hex or ≥32-byte value), else a deterministic dev default with a logged `WARN`. Reusing the `cog-ha-matter` Ed25519 key (§8 Q1) is the **deferred P2** decision — P3 uses a standalone sensing key so it does not pre-empt that call.
- **Egress privacy (fail-closed)** — `network_egress_allowed` is *stricter* than `DefaultPrivacyGuard` for an unattended live surface: only **P1/P2** leave the box; P0 (raw) and P3/P4/P5 (identity/biometric/aggregate above the default P2 ceiling) are held edge-local. A `Derived` cycle maps to P4/P5 and is therefore **never** surfaced. No-presence cycles emit nothing (no phantom events).
- **Gates that pass** (`tests/rufield_surface_test.rs`, 4 integration via `tower::oneshot` + 4 module unit, 0 failed): a well-formed **signed** event (`Modality::WifiCsi`, P2 not P1, `is_fusable` ed25519-verified, real timestamp); **empty cycle → no phantom**; **privacy-safety** — an injected `Derived` trust never surfaces on `/api/field`; a mixed stream surfaces only egress-safe events.
**Deferred:** the §3.3 *provenance carrier* recommendation (reuse the `cog-ha-matter` SHA-256+Ed25519 chain + embed the BLAKE3 engine witness) is **not** in P1/P3 — both take a dedicated `Signer` (the §8 open question 1 key-ownership decision is unresolved; P3 uses a standalone dev/sensing key precisely so it does not pre-empt P2). P2's `cog-ha-matter` key reuse + BLAKE3-embed, and P4 (multi-modality), remain future work. **No accuracy is claimed** (§0 / §6) — P1/P3 are tested plumbing on a live endpoint + a safe privacy mapping; the live surface is single-link CSI with its existing caveats (no validated room-coordinate accuracy — `field_localize`).
---
## 1. Context — two architectures, mapped
### 1.1 RuField MFS (ADR-260, `vendor/rufield/`)
A standalone pure-Rust Cargo workspace (serde, serde_json, toml, sha2, ed25519-dalek; **no tch/ndarray/candle**), vendored here as a git submodule (`git submodule status vendor/rufield``ba66e2e…`), **not** a v2 workspace member — exactly the `vendor/rvcsi` precedent (ADR-095/096). **Not published to crates.io**: every internal dep is a path dep with a nominal `version = "0.1.0"` (`vendor/rufield/Cargo.toml:31-37`); the `docs.rs/rufield-*` URLs are aspirational.
The data model (graded ARCHITECTURE, evidence read directly):
- **`FieldEvent`** (`vendor/rufield/crates/rufield-core/src/event.rs:96-112`): `spec_version, event_id, timestamp_ns: u64, sensor: SensorDescriptor, tensor: FieldTensor, observation: Observation, provenance: ProvenanceRef`.
- **`Observation`** (`event.rs:25-51`): `zone_id, space_cell, range_m, velocity_mps, motion_vector, confidence: f32, features: BTreeMap<String,f32>` (the derived P1 scalars the fusion engine actually reads), `labels: Vec<String>` (ground-truth, **never read by fusion**), `privacy_class: PrivacyClass`.
- **`PrivacyClass`** (`rufield-core/src/privacy.rs:8-25`): `P0..P5`, `#[serde(rename_all="UPPERCASE")]`, `Ord` by declaration order so **P0 < P1 < … < P5** — higher = more private; `level()->u8` returns 0..=5 (`privacy.rs:27-40`).
- **`ProvenanceRef`** (on-wire, `event.rs:73-93`): `raw_hash, firmware_hash` (`sha256:…`), `model_id, calibration_id, synthetic: bool`, optional `signature_hex` / `signer_pubkey_hex` (detached ed25519).
- The four traits (`rufield-core/src/traits.rs`): **`FieldAdapter`** (`:26-38`, `next_event() -> Result<Option<FieldEvent>>`), **`FieldEncoder`** (`:41-51`, **unimplemented in v0.1** — an open seam), **`FusionEngine`** (`:54-63`, `ingest(event)` + `infer(&query)`), **`PrivacyGuard`** (`:86-97`, `authorize(class, Destination, consent, identity_bound) -> PrivacyDecision{Allow|Deny|RequiresConsent}`).
- **`CsiReplayAdapter`** (`rufield-adapters/src/csi_replay.rs`): constructed from **already-loaded text** (`from_jsonl(&str)` `:249-251`; `from_jsonl_with(text, device_id, &[u8;32])` `:254-323`) — **not** a path/`Read`/`Iterator`. Deserializes `CsiFrameRecord { timestamp: f64 (seconds), subcarriers: Vec<f64> }` (`:74-80`), buffers all frames into a `Vec<CsiFrame>`, then streams via a cursor (`next_event` `:550-557`). Maps each frame → `FieldEvent` with `Modality::WifiCsi`, axes `[Frequency]`, a Welford motion proxy, observation `privacy_class = P2 if presence else P1` (`:439-443`), real `sha256` raw-hash, and a **real ed25519 signature** (`signer.sign_event` `:507-510`). `max_privacy_class = P2`.
- **`RuFieldFusion`** (`rufield-fusion/src/engine.rs:55-78`): `ingest()` **rejects non-fusable events on its first line**`if !is_fusable(&event) { return Err(NotFusable) }` (`:212-215`) — then reads `event.observation.features` into a bounded temporal window; `infer()` applies TOML rules (`WeightedBayes` noisy-OR / `TemporalWindow`) → `Vec<FieldInference>`. TOML rule struct: `inputs, method, feature, threshold, privacy_max, window_ms, requires_consent` (`rules.rs:17-35`).
- **`is_fusable`** (`rufield-provenance/src/lib.rs:179-184`): `synthetic == true` **OR** `verify_event().is_ok()` — the §11 invariant. Signing key is `ed25519_dalek 2.1`, deterministic from a 32-byte seed; raw hash is `sha256_hex``"sha256:<hex>"` (`:26-35`).
- **`DefaultPrivacyGuard`** (`rufield-privacy/src/lib.rs:38-110`): default `network_max = P2`, `allow_p0_network = false`. P5-no-identity → `Deny`; P4-no-consent → `RequiresConsent`; `EdgeLocal``Allow`; `Network` denies P0 and `class > network_max`.
- **`rufield-viewer`** (Axum 0.7): **self-contained, consumes `SyntheticSim` only** — all routes are read-only GET/SSE (`GET /api/run`, `GET /events`); **there is no ingest endpoint** (`vendor/rufield/crates/rufield-viewer/src/server.rs:63-72`). Feeding it a live stream requires adding a route.
### 1.2 RuView (the integration target)
- **Sensing-server is Axum** (`v2/crates/wifi-densepose-sensing-server/src/main.rs:7498-7629`), two listeners (WS `:8765`, HTTP). CSI does **not** arrive over WS/HTTP — it arrives over **UDP** from ESP32 nodes (`use tokio::net::UdpSocket`, `main.rs:53`; `recv_from` loop `main.rs:5286-5299`), parsed by magic `0xC511_0001`**`Esp32Frame`** (`types.rs:84-100`: `node_id, n_subcarriers, ppdu_type, amplitudes: Vec<f64>, phases: Vec<f64>`, rssi/freq/sequence) → pushed into per-node `NodeState.frame_history: VecDeque<Vec<f64>>` (`main.rs:441-497`).
- **`/ws/sensing` emits a `SensingUpdate`** (`main.rs:267-317`), broadcast over a `tokio::sync::broadcast` channel (`s.tx.send(json)` `main.rs:5938-5991`; the WS handler just subscribes and forwards, `main.rs:3021-3073`). `SensingUpdate` carries `nodes`, `features`, `classification {motion_level, presence, confidence}`, `signal_field`, `persons: Vec<PersonDetection>` (17 COCO keypoints + `position:[f64;3]` from `field_localize`, `main.rs:403-428`), pose, vitals. **`field_localize` (PR #1050) is a module, not a route** (`mod field_localize` `main.rs:17`; honesty caveat `field_localize.rs:16-27` — a single ESP32 link cannot resolve true room position, `position` is "strongest field peak").
- **ruvsense fusion is strictly WITHIN-WiFi-modality.** `MultistaticFuser::fuse(&[MultiBandCsiFrame]) -> FusedSensingFrame` (`v2/crates/wifi-densepose-signal/src/ruvsense/multistatic.rs:285-288`) attention-weights **multiple WiFi CSI nodes/viewpoints** (every input is ESP32 CSI; `multistatic_bridge.rs:50-62` builds the frames from `NodeState` amplitude with `HardwareType::Esp32S3`). `coherence_gate.rs:18-37` is the `GateDecision{Accept|PredictOnly|Reject|Recalibrate}`; `pose_tracker.rs:255-263` is the 17-keypoint Kalman tracker with 128-dim AETHER re-ID; `field_model.rs:301-308` does SVD room-eigenstructure perturbation extraction. **No camera/mmWave/audio enters this path** — ruvsense is a multi-link WiFi-CSI fuser.
- **The governed-trust cycle** runs in the separate **`wifi-densepose-engine`** crate. `StreamingEngine::process_cycle` (`v2/crates/wifi-densepose-engine/src/lib.rs:409`, `run_cycle` `:434-533`) produces **`TrustedOutput`** (`:82-112`): `semantic_id, quality: QualityScore, effective_class: PrivacyClass, demoted: bool, provenance: SemanticProvenance, witness: [u8;32]` (BLAKE3 over `evidence‖model‖calibration‖privacy_decision‖class`, `witness_of` `:598-613`), `recalibration_recommended`. **Crucially, none of this trust metadata is on the `SensingUpdate` wire today** — it is exposed only out-of-band on `GET /api/v1/status` (`main.rs:4173-4178`) and as a single live effect: `EngineBridge::suppress_raw_outputs()` strips per-node amplitude when `effective_class >= Restricted` (`engine_bridge.rs:240-243`, applied `main.rs:5908-5932`). The honest scope is stated in `engine_bridge.rs:14-27`: the governed engine runs *alongside* the bare fusion path; derived outputs are "published ungoverned."
---
## 2. Decision
1. **Build a thin RuView-side bridge crate `wifi-densepose-rufield`** (a new v2 workspace member) that depends on `vendor/rufield/crates/rufield-core` (+ `rufield-provenance`, `rufield-privacy`, `rufield-fusion`) **via path** — mirroring the `vendor/rvcsi` pattern. RuView does **not** depend on published rufield crates (there are none) and does **not** vendor rufield into the v2 workspace; rufield stays a standalone submodule and the bridge is the only coupling point (an anti-corruption layer).
2. **Emit `FieldEvent`s from the live server via an in-process `SensingServerAdapter`**, not by re-using the file-based `CsiReplayAdapter` on the hot path. The bridge taps the existing `SensingUpdate` build site and the `EngineBridge` trust state, joins them, and emits one signed `FieldEvent` per cycle on a new `tokio::broadcast` topic / optional `/ws/field` endpoint. `CsiReplayAdapter` is retained for the **offline/replay** path (recorded `.csi.jsonl` → events) because it already reads RuView's recording format (`recording.rs` writes `{session}.csi.jsonl`).
3. **Compose the two fusion engines vertically, do not merge them.** ruvsense stays the **WiFi-modality node** (multi-link fusion → one fused WiFi belief); rufield-fusion sits **above** it as the **cross-modality** graph. ruvsense's `FusedSensingFrame`/`TrustedOutput` becomes one `FieldEvent` (modality `wifi_csi`); rufield fuses it against future mmWave/thermal/`rvcsi` events. They do not conflict because ruvsense has no cross-modality fusion to collide with (§1.2 evidence).
4. **Reconcile privacy/provenance with ONE canonical model + a documented mapping** (§3, the crux): RuView's `effective_class` is the **source of truth**, mapped onto RuField `PrivacyClass` at the bridge; RuView's existing **`cog-ha-matter` SHA-256+Ed25519 witness chain** (already RuField's exact crypto) is adopted as the carrier for RuField `ProvenanceReceipt`, with the live BLAKE3 engine witness embedded as a hashed field. We do **not** maintain two parallel signed-receipt systems.
---
## 3. Privacy & provenance reconciliation (the crux)
This is the most important section. RuView and RuField genuinely **overlap and partially conflict**. We map both honestly.
### 3.1 What RuView actually has (implemented, with evidence)
- **TWO privacy enums, not one ladder.** `PrivacyClass`**4 variants** `Raw=0, Derived=1, Anonymous=2, Restricted=3` (`v2/crates/wifi-densepose-bfld/src/lib.rs:103-116`, `#[repr(u8)]`, higher byte = more private, **non-monotonic in information**`Derived=1` carries *more* identity than `Anonymous=2`). And `PrivacyMode`**5 variants** `RawResearch, PrivateHome, EnterpriseAnonymous, CareWithConsent, StrictNoIdentity` (`bfld/src/privacy_mode.rs:18-31`), each mapping to a `PrivacyClass` via `target_class()` (`:63-70`; two modes collapse to `Anonymous`).
- **THREE witness mechanisms across TWO hash algorithms:**
- BFLD `PrivacyAttestationProof`**BLAKE3, unsigned**, attests mode/class continuity only; **built but NOT on the live path** (ADR-141 status line ~597; `bfld/src/privacy_mode.rs:121-148`).
- Engine-cycle `TrustedOutput.witness: [u8;32]`**BLAKE3, unsigned**, over the full trust decision; **LIVE every cycle** (`wifi-densepose-engine/src/lib.rs:598-613`).
- `cog-ha-matter::WitnessChain`**SHA-256 hash chain + Ed25519 signatures** (`v2/crates/cog-ha-matter/src/witness.rs:138-151`; `witness_signing.rs:39-76`), JSONL-persisted, `verify()` + `verify_signature()`. Implemented for ADR-116 (cog/Matter audit log); **standalone, not wired to BFLD/engine**. Its `WitnessHash` newtype doc explicitly anticipates a hash-algo migration (`witness.rs:37-41`).
- **No numeric trust score.** "Trust" in code = `base_coherence: f32∈[0,1]` + `penalized_coherence()` (`signal/.../fusion_quality.rs:99,122-126`) + a **boolean** `forces_privacy_demotion()` (`:116`). Demotion is monotonic and irreversible (`demote_one` clamps at Restricted, `engine/src/lib.rs:617-619`).
- **Structured provenance exists, but no signed "receipt" on the sensing path.** `SemanticProvenance { evidence, model_version, calibration_version, privacy_decision }` (`v2/crates/wifi-densepose-worldgraph/src/model.rs:137-147`) is attached to every belief and is the *input* to the BLAKE3 witness — but it is unsigned and not called a receipt.
### 3.2 Side-by-side, graded
| Dimension | RuView (file:line) | RuField | Alignment |
|---|---|---|---|
| Privacy ladder | `PrivacyClass` 4 (`bfld/lib.rs:103`) **or** `PrivacyMode` 5 (`bfld/privacy_mode.rs:18`) | `PrivacyClass` 6 (P0P5, `rufield-core/privacy.rs:8`) | **PARTIAL→CONFLICT** — no clean 1:1; counts differ (4/5 vs 6); RuView class ordering non-monotonic |
| Demotion direction | higher = more private, irreversible (`engine/lib.rs:617`) | higher P# = more private, `Ord` by decl order (`privacy.rs:8-25`) | **STRONG** (same direction) |
| Provenance receipt | `SemanticProvenance` unsigned (`worldgraph/model.rs:137`) | `ProvenanceRef` + ed25519 (`event.rs:73`) | **PARTIAL** — structured but unsigned |
| Witness crypto (live path) | BLAKE3 `[u8;32]`, unsigned (`engine/lib.rs:598`) | sha256 + ed25519 (`rufield-provenance/lib.rs:26,135`) | **CONFLICT** (algo + signing) |
| Witness crypto (cog-ha-matter) | **SHA-256 + Ed25519** (`cog-ha-matter/witness.rs`, `witness_signing.rs`) | **sha256 + ed25519** | **STRONG** — RuField's exact crypto, already in-repo, but unwired and in another bounded context |
| Trust / confidence | `penalized_coherence: f32` + boolean demote (`fusion_quality.rs:122`) | `confidence: f32` per observation | **WEAK** — RuView has no graded trust object; confidence maps, demotion is binary |
### 3.3 The recommendation (the key call)
**Adopt ONE canonical model with a documented, lossy-but-monotonic mapping — do not run two parallel schemes.** Concretely:
1. **Privacy: RuView `effective_class` is the source of truth; the bridge maps it onto RuField `PrivacyClass`** at the egress boundary. The honest mapping (graded ARCHITECTURE — it is a *policy* decision, and it is **monotonicity-testable**, not an accuracy claim):
| RuView `PrivacyClass` | → RuField | Rationale |
|---|---|---|
| `Raw` (raw CSI amplitude) | `P0` | raw waveform |
| `Derived` (identity embedding, LAN-only) | `P4` *(or P5 if identity-bound)* | derived **identity** features ⇒ biometric/identity tier, **not** P1 — RuView's non-monotonic `Derived=1` is the trap; map by *information content*, not byte value |
| `Anonymous` (occupancy/aggregate) | `P2`/`P3` | occupancy → P2, room-count aggregate → P3 |
| `Restricted` (zeroized) | `P2`-capped, raw suppressed | matches `suppress_raw_outputs` (`engine_bridge.rs:240`) |
The bridge **must** map `Derived → P4/P5`, never P1, because RuView's `Derived` carries `identity_embedding` (§3.1) — this is the single most dangerous mapping mistake and gets a dedicated test (P2 in §4). `PrivacyMode` (5) is the better *operator-facing* join to RuField's 6 levels but the **class** is what gates egress, so the class mapping is canonical.
2. **Provenance: adopt `cog-ha-matter`'s SHA-256+Ed25519 chain as the carrier for RuField `ProvenanceReceipt`** — it is already RuField's exact crypto (graded STRONG above), already implemented, already tamper-evident. The bridge constructs the RuField `ProvenanceRef` by: `raw_hash = sha256(csi bytes)`, `model_id`/`calibration_id` from `SemanticProvenance`, and **embeds the live BLAKE3 engine witness `[u8;32]` as a hashed provenance field** (it is already computed every cycle — do not throw it away), then **signs with ed25519** so `is_fusable` passes for live (non-synthetic) events. We do **not** add a second BLAKE3-vs-ed25519 argument: BLAKE3 stays RuView's internal fast cycle-fingerprint; ed25519 is the *external* attestation RuField requires. One signer, one chain.
3. **Trust: map `penalized_coherence` → `Observation.confidence`; keep demotion binary.** RuView has no graded trust object to reconcile; the coherence scalar is the honest analog and the demotion boolean already drives `effective_class`.
This is a **bridge-with-canonical-source**, not "keep both forever." RuView owns the privacy decision (it has the live governed cycle); RuField owns the *external wire shape* (P0P5 + signed receipt). The bridge is the one-directional translation, and it is the only place the two schemes meet.
---
## 4. Phased plan (each phase independently shippable + testable)
**P1 — `SensingServerAdapter` emitting `FieldEvent`s (ARCHITECTURE).**
New crate `wifi-densepose-rufield` with a `SensingServerAdapter` that consumes a `(SensingUpdate, TrustedOutput)` pair (tapped at `main.rs:5886`/`:5938`) and emits a signed `FieldEvent` (`Modality::WifiCsi`, axes `[Frequency]`, observation features from `SensingUpdate.features`, `confidence` from `penalized_coherence`). Offline path: keep `CsiReplayAdapter` for recorded `.csi.jsonl`. **Gate:** a round-trip test — emit a `FieldEvent` from a fixture `SensingUpdate`, assert it serializes, `is_fusable` passes (ed25519-signed), and `RuFieldFusion::ingest` accepts it. No server changes required beyond exposing the tap; the adapter is a library.
**P2 — privacy/provenance bridge (the crux, ARCHITECTURE).**
Implement the §3.3 mapping: `effective_class → PrivacyClass`, `cog-ha-matter` ed25519 signer for the receipt, BLAKE3 witness embedded. **Gates (three, all monotonicity/safety, not accuracy):** (a) `Derived → P4|P5` never P1 (the dangerous-mapping test); (b) privacy monotonicity — `demoted == true` ⇒ emitted `PrivacyClass >= P2` and raw suppressed; (c) signature round-trip — sign with the cog-ha-matter key, `rufield_provenance::verify_event` passes. This phase is shippable without P3 (events emitted on an internal topic, not yet on the public wire).
**P3 — surface in `/ws` + viewer (ARCHITECTURE).**
Add an opt-in `/ws/field` endpoint (or a `field_events` array on `SensingUpdate` behind a flag) carrying the signed `FieldEvent` + a privacy badge. Add an ingest route to `rufield-viewer` (it has none today — `server.rs:63-72`) so it can replay RuView's live feed instead of only `SyntheticSim`. **Gate:** a WS integration test asserting a connected client receives a privacy-badged, signature-verifiable `FieldEvent`; a viewer test asserting the new ingest route renders a live event. The `cognitum` appliance can speak RuField by consuming this endpoint (it already runs `ruview-vitals-worker`); deferred to its own ADR.
**P4 — fusion composition + multi-modality (ARCHITECTURE, optional).**
Wire a second modality (cheapest: an `rvcsi`-sourced event, or recorded mmWave) into `RuFieldFusion` alongside the WiFi event, proving cross-modality fusion above ruvsense. **Gate:** a fusion test with two modalities producing ≥1 cross-modal inference, with provenance coverage 100%.
---
## 5. Decision matrix
### 5.1 Data-path emission (P1)
| Option | Latency | Reuse | Live-fit | Risk | Verdict |
|---|---|---|---|---|---|
| Re-use `CsiReplayAdapter` on hot path | poor (file buffer, `&str` ctor) | high | **bad** — it's a file-cursor, not a live source | low | **Reject for live** (keep for replay) |
| In-process `SensingServerAdapter` (tap `SensingUpdate`+`TrustedOutput`) | good | medium | **good** — taps the real emit + real trust state | low | **CHOSEN** |
| Server publishes `FieldEvent` on its own topic (no adapter trait) | good | low | good | medium (bypasses `FieldAdapter` contract) | Reject — loses the trait seam |
### 5.2 Fusion relationship (P3/P4)
| Option | Verdict |
|---|---|
| Merge ruvsense into rufield-fusion | **Reject** — different scopes; ruvsense is within-WiFi multi-link, rufield is cross-modality |
| rufield-fusion wraps ruvsense (vertical compose) | **CHOSEN** — ruvsense → one WiFi `FieldEvent` → rufield cross-modality graph |
| Run both as peers, reconcile after | Reject — duplicates fusion semantics, two contradiction models |
### 5.3 Privacy/provenance reconciliation (P2)
| Option | Verdict |
|---|---|
| (a) Map RuView classes onto RuField P0P5, RuView canonical | **CHOSEN (privacy)**`effective_class` is the live source of truth |
| (b) Adopt RuField ed25519 receipts as RuView's provenance | **CHOSEN (provenance)** — via the already-present `cog-ha-matter` SHA-256+Ed25519 chain |
| (c) Keep both schemes with a permanent bridge | **Reject** — two signed-receipt systems is the duplication we must not ship |
### 5.4 Dependency direction
| Option | Verdict |
|---|---|
| Depend on published rufield crates | **Reject** — not published (`vendor/rufield/Cargo.toml:31-37`) |
| Make rufield a v2 workspace member | **Reject** — breaks the standalone-spec/`rvcsi` precedent |
| Thin `wifi-densepose-rufield` bridge → path deps on submodule | **CHOSEN** — anti-corruption layer, single coupling point |
---
## 6. Security & honesty notes
- **No accuracy claim.** Live RuField events from RuView are derived from the same single-link CSI whose own caveats are on record (`field_localize.rs:16-27`); the offline path is unlabeled replay (`csi_replay.rs:19-31`). This ADR ships **plumbing with monotonicity/signature gates**, not validated F1.
- **The dangerous mapping is `Derived → P1`.** RuView's `Derived` byte value (1) is numerically below `Anonymous` (2) but carries identity (`bfld/lib.rs`); a naive byte-mapping would leak identity-bearing features as low-privacy P1. P2's gate (a) exists specifically to prevent this.
- **One signer, not two.** Adding a second ed25519 keypair alongside `cog-ha-matter`'s would create two roots of trust. The bridge reuses the cog-ha-matter signing key (`witness_signing.rs`).
- **`is_fusable` is a real gate, not decoration** (`rufield-provenance/lib.rs:179-184`): live events that fail to sign are rejected by `RuFieldFusion::ingest` — we must not paper over a signing failure with `synthetic = true` on a real event (that would be the §11 invariant violation the spec forbids).
- BLAKE3 stays internal; ed25519 is the external attestation. We do not relitigate RuView's BLAKE3 cycle-witness — it is embedded, not replaced.
## 7. Consequences
**Positive:** RuView becomes one honest adapter in the larger RuField ecosystem (ADR-260 goal §9) without forking its fusion or privacy engine; the three witness mechanisms get a single external attestation path; cross-modality fusion becomes possible above the existing WiFi fusion; the `cognitum` appliance gains a standard wire format. The bridge is the only coupling point, so rufield can evolve as a standalone spec.
**Negative:** a fourth crate to maintain; the privacy mapping is lossy (4/5 → 6) and must be kept honest by tests; reusing the `cog-ha-matter` key crosses a bounded-context boundary (cog/Matter ↔ sensing) that ADR-116 kept separate — that coupling needs review. The live trust metadata (`witness`, `effective_class`) is **currently decoupled** from `SensingUpdate` (§1.2), so P1 must do real join work, not a field read.
## 8. Open questions
1. **Signer ownership:** should the bridge reuse the `cog-ha-matter` Ed25519 key, or mint a dedicated RuView-sensing key with its own rotation? (Reuse couples bounded contexts; a new key adds a second root of trust.)
2. **`PrivacyMode` vs `PrivacyClass` as the canonical map target:** class gates egress (chosen), but the 5-mode ladder is the cleaner join to 6 levels — do we expose mode in the receipt too?
3. **Where does the BLAKE3 engine witness live in the RuField receipt** — a `firmware_hash`-style field, an extension field, or a `CalibrationReceipt.data_hash`? (RuField's `ProvenanceRef` has no spare slot; needs a spec extension or reuse of `model_id`.)
4. **Should `field_localize` positions ride in `Observation.space_cell`/`motion_vector`** given the explicit single-link caveat, or stay RuView-only until multi-node calibration lands?
5. **`rvcsi` relationship:** `rvcsi` has its own `CsiFrame`/`CsiWindow` and could implement `FieldAdapter` directly — should the second modality in P4 be `rvcsi`, making RuField the convergence point for *both* vendored sensing runtimes?
6. **Transport:** RuField ADR-260 §29 leaves default transport open (MQTT/NATS/WS/MCP). RuView is WS + UDP + broadcast; does `/ws/field` suffice, or does the appliance need MQTT to match the cog stack?
## 9. Recommendation
Proceed with P1+P2 behind a feature flag. They are independently shippable, carry real gates (round-trip, monotonicity, signature-verify), and require no change to RuView's fusion or privacy engine — only a tap and a translation. Defer P3/P4 and the appliance/transport questions to follow-up ADRs once the bridge round-trips on recorded `.csi.jsonl` and on one live cycle.
+19
View File
@@ -522,6 +522,25 @@ Base URL: `http://localhost:3000` (Docker) or `http://localhost:8080` (binary de
| `GET` | `/api/v1/mesh` | ADR-110 fleet-wide mesh sync map ([iter 29](adr/ADR-110-esp32-c6-firmware-extension.md)) | `{"nodes":{"9":{...},"12":{...}},"total":2}` |
| `GET` | `/api/v1/nodes/:id/sync` | Single-node mesh sync snapshot (or 404) | `{"offset_us":1163565,"is_leader":false,...}` |
| `GET` | `/api/v1/mesh/metrics` | ADR-110 mesh state in Prometheus exposition format ([iter 36](adr/ADR-110-esp32-c6-firmware-extension.md)) | `wifi_densepose_mesh_offset_us{node="9"} 1163565\n…` |
| `GET` | `/api/field` | ADR-262 P3 — latest **signed RuField `FieldEvent`s** from the live sensing cycle, plus the signer pubkey + a `dev_signing_key` flag. Only egress-safe (P1/P2) events are surfaced; identity/biometric (P4/P5) and raw (P0) are held edge-local | `{"spec":"rufield","signer_pubkey_hex":"…","dev_signing_key":true,"events":[…]}` |
### RuField surface (ADR-262 P3)
RuView's live WiFi-CSI sensing now also speaks the standalone **RuField MFS** wire format. Each governed sensing cycle is converted (via the `wifi-densepose-rufield` anti-corruption bridge) into a **signed** `FieldEvent` (`Modality::WifiCsi`, ed25519 `ProvenanceRef`) and surfaced on two additive endpoints:
- `GET /api/field` — the most recent signed events (JSON).
- `GET /ws/field` — a WebSocket that streams each cycle's signed event (mirrors `/ws/sensing`).
```bash
curl -s http://localhost:3000/api/field | python -m json.tool # latest signed FieldEvents
python -c "import asyncio,websockets; asyncio.run((lambda: websockets.connect('ws://localhost:8765/ws/field'))())" # stream
```
Privacy is fail-closed: only egress-safe **P1/P2** events leave the box — raw (P0) and identity/biometric/aggregate (P3P5) cycles are held **edge-local** and never appear on these endpoints; a no-presence cycle emits **no event**.
**Signing key:** the surface signs with a **dedicated dev/sensing key**, seeded from `WDP_RUFIELD_SIGNING_SEED` (a 64-char hex string or a ≥32-byte value); when unset it falls back to a deterministic dev default and logs a `WARN` (the `dev_signing_key` flag in `/api/field` reflects this). This is a standalone key pending the ADR-262 §8 Q1 key-ownership decision — set `WDP_RUFIELD_SIGNING_SEED` for any real deployment.
> **Honesty (ADR-262 §0/§6):** this is real plumbing on a live endpoint, **not an accuracy claim.** It is the single-link CSI sensing with its existing caveats (no validated room-coordinate accuracy — positions are the "strongest field peak", not calibrated triangulation).
### Example: Get fleet mesh state (ADR-110)
Generated
+49
View File
@@ -7085,6 +7085,42 @@ dependencies = [
"smallvec",
]
[[package]]
name = "rufield-core"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "rufield-fusion"
version = "0.1.0"
dependencies = [
"rufield-core",
"rufield-provenance",
"serde",
"toml 0.8.23",
]
[[package]]
name = "rufield-privacy"
version = "0.1.0"
dependencies = [
"rufield-core",
]
[[package]]
name = "rufield-provenance"
version = "0.1.0"
dependencies = [
"ed25519-dalek",
"rufield-core",
"serde",
"serde_json",
"sha2",
]
[[package]]
name = "rumqttc"
version = "0.24.0"
@@ -11045,6 +11081,18 @@ dependencies = [
"tower-http",
]
[[package]]
name = "wifi-densepose-rufield"
version = "0.3.0"
dependencies = [
"rufield-core",
"rufield-fusion",
"rufield-privacy",
"rufield-provenance",
"serde",
"serde_json",
]
[[package]]
name = "wifi-densepose-ruvector"
version = "0.3.2"
@@ -11094,6 +11142,7 @@ dependencies = [
"wifi-densepose-engine",
"wifi-densepose-geo",
"wifi-densepose-hardware",
"wifi-densepose-rufield",
"wifi-densepose-signal",
"wifi-densepose-wifiscan",
"wifi-densepose-worldgraph",
+5
View File
@@ -72,6 +72,11 @@ members = [
"crates/homecore-assist", # ADR-133 — HOMECORE voice assistant + ruflo bridge
"crates/homecore-server", # iter-9 — HOMECORE integration binary (all 8 crates wired together)
"crates/ruview-swarm", # ADR-148 — drone swarm control system
# ADR-262 P1 — anti-corruption bridge converting RuView WiFi-CSI sensing
# output into signed RuField FieldEvents. Path-deps the `vendor/rufield`
# submodule crates (rufield-core/-provenance/-privacy/-fusion); single
# coupling point between RuView and the standalone RuField MFS spec.
"crates/wifi-densepose-rufield",
]
# ADR-040: WASM edge crate targets wasm32-unknown-unknown (no_std),
# excluded from workspace to avoid breaking `cargo test --workspace`.
@@ -0,0 +1,26 @@
[package]
name = "wifi-densepose-rufield"
version = "0.3.0"
edition = "2021"
description = "ADR-262 anti-corruption bridge: converts RuView WiFi-CSI sensing output into signed RuField FieldEvents (P0P5 privacy mapping + ed25519 provenance)"
license.workspace = true
authors.workspace = true
repository.workspace = true
# ADR-262 §5.4: this crate is the single coupling point ("anti-corruption
# layer") between RuView and the standalone RuField MFS spec. It depends on the
# `vendor/rufield` submodule crates **via path** (the `vendor/rvcsi` pattern) —
# RuView does NOT depend on published rufield crates (there are none) and does
# NOT make rufield a v2 workspace member. The four crates below are pure-Rust
# (serde / serde_json / toml / sha2 / ed25519-dalek only — no tch / openblas /
# ndarray / candle), so they build under `--no-default-features`.
[dependencies]
rufield-core = { path = "../../../vendor/rufield/crates/rufield-core" }
rufield-provenance = { path = "../../../vendor/rufield/crates/rufield-provenance" }
rufield-privacy = { path = "../../../vendor/rufield/crates/rufield-privacy" }
rufield-fusion = { path = "../../../vendor/rufield/crates/rufield-fusion" }
serde = { workspace = true }
serde_json = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }
@@ -0,0 +1,206 @@
//! The conversion: `SensingSnapshot` → signed `FieldEvent` (ADR-262 P1).
//!
//! This is the in-process `SensingServerAdapter` core (ADR-262 §4 P1 / §5.1):
//! it consumes a `(SensingUpdate, TrustedOutput)` join — modelled here as a
//! [`SensingSnapshot`] of owned primitives — and emits one signed
//! [`FieldEvent`] (`Modality::WifiCsi`, axis `[Frequency]`) per cycle.
use crate::privacy::egress_class;
use crate::snapshot::{SensingSnapshot, SignalField};
use rufield_core::{
FieldAxis, FieldEvent, FieldTensor, Modality, Observation, PrivacyClass, ProvenanceRef,
SensorDescriptor,
};
use rufield_provenance::{sha256_hex, Signer};
use std::collections::BTreeMap;
/// Model id stamped on emitted events (ADR-262 — derived features come from
/// RuView's `/ws/sensing` pipeline, not a trained encoder).
const MODEL_ID: &str = "ruview_sensing_server_v1";
/// Firmware hash placeholder until the real ESP32 firmware image hash is wired
/// through (ADR-262 §8 open question 3 — the BLAKE3 engine witness slot). A
/// stable `sha256:` over the model id keeps it a real digest, not a fake.
fn firmware_hash() -> String {
sha256_hex(MODEL_ID.as_bytes())
}
/// Squash a non-negative power-like scalar into `[0, 1]` deterministically.
/// `x / (x + 1)` — monotone, no panics, no calibration claim.
fn squash(x: f64) -> f32 {
if !x.is_finite() || x <= 0.0 {
return 0.0;
}
(x / (x + 1.0)) as f32
}
/// Build the `Observation.features` map the RuField fusion engine reads
/// (`rufield-fusion/engine.rs:217-228`: `motion_energy`, `breathing_band`,
/// `transient`, `presence`, `range_m`, plus `posture_height`).
fn build_features(snap: &SensingSnapshot, range_m: Option<f32>) -> BTreeMap<String, f32> {
let f = &snap.features;
let mut m = BTreeMap::new();
m.insert("motion_energy".to_string(), squash(f.motion_band_power));
m.insert("breathing_band".to_string(), squash(f.breathing_band_power));
m.insert("transient".to_string(), squash(f.change_points as f64));
m.insert(
"presence".to_string(),
if snap.classification.presence { 1.0 } else { 0.0 },
);
if let Some(r) = range_m {
m.insert("range_m".to_string(), r);
}
m
}
/// Derive a real range (metres) and motion vector from the strongest signal
/// field peak, if a field is present. Returns `(range_m, motion_vector,
/// space_cell)` — all `None` when there is no field (we do NOT fabricate
/// coordinates, per ADR-262 §4 P1).
fn derive_position(
field: Option<&SignalField>,
) -> (Option<f32>, Option<[f32; 3]>, Option<[i32; 3]>) {
let Some(field) = field else {
return (None, None, None);
};
let Some(cell) = field.peak_cell() else {
return (None, None, None);
};
// Range from origin in grid-cell units (real readout, not calibrated
// metres — the honesty caveat from `field_localize.rs:16-27`).
let [x, y, z] = cell;
let range = ((x * x + y * y + z * z) as f32).sqrt();
let mag = if range > 0.0 { range } else { 1.0 };
let motion_vector = [x as f32 / mag, y as f32 / mag, z as f32 / mag];
(Some(range), Some(motion_vector), Some(cell))
}
/// Stable, deterministic event id from `(node_id, timestamp_ns)`. No RNG, so
/// the same snapshot always yields the same id (required for the determinism
/// gate).
fn event_id(snap: &SensingSnapshot) -> String {
format!("ruview-{}-{}", snap.node_id, snap.timestamp_ns)
}
/// Convert a [`SensingSnapshot`] to a **signed** [`FieldEvent`] (ADR-262 P1).
///
/// 1. Builds a `FieldTensor` (`Modality::WifiCsi`, axis `[Frequency]`) whose
/// values are the RuView feature scalars, with the real `timestamp_ns`.
/// 2. Builds an `Observation` — `motion_vector`/`range_m`/`space_cell` derived
/// from the signal-field peak when present (else `None`; coordinates are
/// never fabricated), `confidence` from the classification, labels from
/// motion-level/presence.
/// 3. Stamps the §3.3 egress privacy class (information-content mapping with
/// the demotion floor) on both tensor and observation.
/// 4. Builds a real `ProvenanceRef` (sha256 raw hash over the tensor/feature
/// bytes, `synthetic = false`) and **signs** it with the supplied ed25519
/// [`Signer`] so `rufield_provenance::is_fusable` passes.
///
/// Determinism: with no RNG anywhere and a deterministic ed25519 signer, the
/// same `snap` + same signer seed yields a byte-identical event.
#[must_use]
pub fn snapshot_to_field_event(snap: &SensingSnapshot, signer: &Signer) -> FieldEvent {
let class = egress_class(snap.trust_class, snap.identity_bound, snap.demoted);
let (range_m, motion_vector, space_cell) = derive_position(snap.signal_field.as_ref());
// ── 1. Tensor ──────────────────────────────────────────────────────────
// The frequency-domain feature scalars, in a stable order.
let f = &snap.features;
let values: Vec<f32> = vec![
f.mean_rssi as f32,
f.variance as f32,
f.motion_band_power as f32,
f.breathing_band_power as f32,
f.dominant_freq_hz as f32,
f.spectral_power as f32,
];
let confidence = (snap.classification.confidence as f32).clamp(0.0, 1.0);
let noise_floor = f.variance.max(0.0) as f32;
let calibration_id = format!("ruview_node_{}", snap.node_id);
// `FieldTensor::new` only errors on a shape/axis mismatch; our shape
// exactly matches `values.len()` and one axis, so this is infallible here.
let tensor = FieldTensor::new(
snap.timestamp_ns,
Modality::WifiCsi,
vec![FieldAxis::Frequency],
vec![values.len()],
values,
confidence,
noise_floor,
Some(calibration_id.clone()),
class,
)
.expect("feature tensor shape is well-formed by construction");
// ── 2. Observation ─────────────────────────────────────────────────────
let observation = Observation {
zone_id: Some(snap.node_id.clone()),
space_cell,
range_m,
velocity_mps: None,
motion_vector,
confidence,
features: build_features(snap, range_m),
labels: build_labels(snap),
privacy_class: class,
};
// ── 3. Provenance (real sha256 over the tensor bytes) ───────────────────
let raw_hash = sha256_hex(
&serde_json::to_vec(&tensor).expect("tensor serializes to JSON for hashing"),
);
let provenance = ProvenanceRef {
raw_hash,
firmware_hash: firmware_hash(),
model_id: MODEL_ID.to_string(),
calibration_id,
synthetic: false, // a real (non-synthetic) live/replay event
signature_hex: None,
signer_pubkey_hex: None,
};
let sensor = SensorDescriptor {
modality: "wifi_csi".to_string(),
vendor: "esp32".to_string(),
device_id: snap.node_id.clone(),
placement: "unknown".to_string(),
clock_domain: "local".to_string(),
};
let mut event = FieldEvent::new(
event_id(snap),
snap.timestamp_ns,
sensor,
tensor,
observation,
provenance,
);
// ── 4. Sign (ed25519) so `is_fusable` passes for this real event ────────
signer
.sign_event(&mut event)
.expect("ed25519 signing of a serializable event is infallible");
event
}
/// Labels from the classification. These are descriptive (`person_present`,
/// `motion_<level>`); the RuField fusion engine never reads labels
/// (`event.rs:45-48`), so this carries no identity.
fn build_labels(snap: &SensingSnapshot) -> Vec<String> {
let mut labels = Vec::new();
if snap.classification.presence {
labels.push("person_present".to_string());
}
labels.push(format!("motion_{}", snap.classification.motion_level));
labels
}
/// Convenience: the privacy class that *would* be stamped for a snapshot,
/// without building the whole event. Useful for egress badges (P3) and tests.
#[must_use]
pub fn snapshot_egress_class(snap: &SensingSnapshot) -> PrivacyClass {
egress_class(snap.trust_class, snap.identity_bound, snap.demoted)
}
+123
View File
@@ -0,0 +1,123 @@
//! # wifi-densepose-rufield
//!
//! ADR-262 **anti-corruption bridge**: converts RuView's live WiFi-CSI sensing
//! output into signed RuField [`FieldEvent`](rufield_core::FieldEvent)s.
//!
//! This crate is the **single coupling point** (ADR-262 §5.4) between RuView and
//! the standalone RuField MFS spec (`vendor/rufield`, ADR-260). It depends on
//! the four pure-Rust rufield crates **via path** — `rufield-core`,
//! `-provenance`, `-privacy`, `-fusion` — and on **no** RuView internal crate.
//! Inputs are owned primitives ([`SensingSnapshot`]) that mirror what RuView's
//! sensing cycle produces, so the bridge never imports `SensingUpdate` /
//! `TrustedOutput` directly.
//!
//! ## What P1 ships (honesty — ADR-262 §0 / §6)
//!
//! This is **P1 plumbing**: a tested `SensingSnapshot → FieldEvent` conversion
//! plus the **fail-closed privacy mapping** that is the §3.3 correctness item.
//! It is **not** wired into the live server (that is P3) and makes **no accuracy
//! claim** — RuField v0.1 is synthetic end-to-end and RuView's single-link CSI
//! carries its own caveats. The gates here are round-trip / fusability /
//! privacy-safety / determinism, not validated F1.
//!
//! ## The critical correctness item: the privacy mapping (§3.3)
//!
//! RuView's `Derived` class has byte value `1` (below `Anonymous = 2`) yet
//! carries an identity embedding. The bridge maps it to **P4/P5 by information
//! content, never P1** — see [`map_privacy`]. Mapping off the byte would leak
//! identity as low-privacy; [`map_privacy`] (and its dedicated test
//! `derived_identity_never_maps_to_low_privacy`) exist specifically to prevent
//! that.
//!
//! ## Example
//!
//! ```
//! use wifi_densepose_rufield::{
//! snapshot_to_field_event, SensingSnapshot, SensingFeatures, SensingClass,
//! RuViewPrivacyClass,
//! };
//! use rufield_provenance::{Signer, is_fusable};
//!
//! let snap = SensingSnapshot {
//! timestamp_ns: 1_791_986_400_000_000_000,
//! features: SensingFeatures {
//! mean_rssi: -55.0,
//! variance: 0.4,
//! motion_band_power: 2.0,
//! breathing_band_power: 0.3,
//! dominant_freq_hz: 0.25,
//! change_points: 1,
//! spectral_power: 3.0,
//! },
//! classification: SensingClass {
//! motion_level: "low".into(),
//! presence: true,
//! confidence: 0.82,
//! },
//! signal_field: None,
//! trust_class: RuViewPrivacyClass::Anonymous,
//! demoted: false,
//! identity_bound: false,
//! node_id: "esp32_room_01".into(),
//! };
//!
//! let signer = Signer::from_seed(b"adr-262-bridge-seed-32-bytes-ok!");
//! let event = snapshot_to_field_event(&snap, &signer);
//! assert!(is_fusable(&event)); // ed25519-signed, non-synthetic ⇒ fusable
//! ```
#![forbid(unsafe_code)]
pub mod bridge;
pub mod privacy;
pub mod snapshot;
pub use bridge::{snapshot_egress_class, snapshot_to_field_event};
pub use privacy::{apply_demotion_floor, egress_class, map_privacy};
pub use snapshot::{
RuViewPrivacyClass, SensingClass, SensingFeatures, SensingSnapshot, SignalField,
};
// Re-export the rufield surface a bridge consumer needs, so callers depend on
// one crate.
pub use rufield_core::{Destination, FieldEvent, Modality, PrivacyClass, PrivacyDecision};
pub use rufield_fusion::RuFieldFusion;
pub use rufield_privacy::{DefaultPrivacyGuard, PrivacyPolicy};
pub use rufield_provenance::{is_fusable, verify_event, Signer};
/// Whether a mapped [`PrivacyClass`] may be surfaced on a **network** egress
/// (ADR-262 §4 P3 — the live `/api/field` / `/ws/field` surface must respect
/// the same default §10 network policy `/ws/sensing` honours, never emitting
/// above-policy data).
///
/// **Fail-closed for a live, unattended surface.** The live RuView surface has
/// **no per-event consent or identity-binding ceremony** — so this is *stricter*
/// than [`DefaultPrivacyGuard::authorize`]: it requires BOTH that the default
/// guard would `Allow` the class onto [`Destination::Network`] with **no consent
/// granted**, AND that the class is at or below the default network ceiling
/// ([`PrivacyClass::P2`]). The second clause deliberately drops P4/P5 even
/// though the guard's consent/identity *exceptions* would let an explicitly
/// consented/identity-bound P4/P5 through — because the live surface cannot
/// honestly assert that consent. Net effect: only **P1/P2** leave the box; P0
/// (raw) and P3/P4/P5 are held edge-local.
///
/// This is the privacy-safety pin for the live surface: a `Derived` cycle maps
/// to P4 (or P5 when identity-bound) via [`map_privacy`] and is therefore
/// **never** surfaced as a network event — neither as a low-privacy P1 (the
/// §3.3 mapping trap) nor at all.
#[must_use]
pub fn network_egress_allowed(class: PrivacyClass, identity_bound: bool) -> bool {
use rufield_core::PrivacyGuard;
let guard_allows = matches!(
DefaultPrivacyGuard::default().authorize(
class,
Destination::Network,
false, // no per-event consent on the live network surface (fail-closed)
identity_bound,
),
PrivacyDecision::Allow
);
// Additionally cap at the default network ceiling: an unattended live
// surface never asserts the P4-consent / P5-identity exception.
guard_allows && class <= PrivacyClass::P2
}
@@ -0,0 +1,147 @@
//! The ADR-262 §3.3 privacy mapping — the critical correctness item.
//!
//! RuView's effective `PrivacyClass` (4 byte-level classes) is the source of
//! truth; the bridge maps it onto RuField's `PrivacyClass` (P0P5) **at the
//! egress boundary, by information content, NEVER by byte value**.
//!
//! ## The trap (ADR-262 §3, §6)
//!
//! RuView's `Derived` has byte value `1`, which sorts *below* `Anonymous`
//! (byte `2`). A naive byte-mapping (`Derived = 1 → P1`) would leak
//! identity-bearing features (`identity_embedding`, `identity_risk_score`) as a
//! **low-privacy P1** event. Because `Derived` carries derived *identity*, it
//! must map to the **biometric/identity tier (P4/P5)** — never P1. This is the
//! single most dangerous mapping mistake; it gets a dedicated test
//! (`derived_identity_never_maps_to_low_privacy`).
//!
//! ## Fail-closed
//!
//! [`RuViewPrivacyClass`] is a closed enum, so there is no runtime "unknown"
//! value to receive — but the mapping is written `match`-exhaustively with an
//! explicit, documented arm per class, and the `demoted`/`identity_bound`
//! overlays only ever move the result **toward more privacy**, never less.
use crate::snapshot::RuViewPrivacyClass;
use rufield_core::PrivacyClass;
/// Map a RuView effective `PrivacyClass` onto a RuField `PrivacyClass`
/// (ADR-262 §3.3), by information content.
///
/// | RuView (byte) | → RuField | Rationale |
/// |---|---|---|
/// | `Raw` (0) | `P0` | raw CSI waveform |
/// | `Derived` (1) | `P4` (or `P5` if `identity_bound`) | derived **identity** features ⇒ biometric/identity tier, **not** P1 |
/// | `Anonymous` (2) | `P2` | occupancy / motion only |
/// | `Restricted` (3) | `P2` (raw suppressed) | matches `suppress_raw_outputs` |
///
/// `identity_bound` only promotes `Derived` (already identity-derived) from P4
/// to P5; it can never lower the class.
#[must_use]
pub fn map_privacy(ruview_class: RuViewPrivacyClass, identity_bound: bool) -> PrivacyClass {
match ruview_class {
// Raw CSI amplitude → raw waveform tier.
RuViewPrivacyClass::Raw => PrivacyClass::P0,
// THE CRITICAL ARM (§3.3 / §6): `Derived` carries identity. Map by
// information content to the biometric/identity tier P4, and to P5 when
// the surface is bound to a named identity. NEVER P1.
RuViewPrivacyClass::Derived => {
if identity_bound {
PrivacyClass::P5
} else {
PrivacyClass::P4
}
}
// Anonymous occupancy / motion aggregate → P2.
RuViewPrivacyClass::Anonymous => PrivacyClass::P2,
// Restricted: occupancy with risk score / hash stripped and raw
// suppressed. Capped at P2 (occupancy tier), matching
// `EngineBridge::suppress_raw_outputs` (`engine_bridge.rs:240`).
RuViewPrivacyClass::Restricted => PrivacyClass::P2,
}
}
/// The §4 P2 gate (b) monotonicity overlay: a governed-engine **demotion**
/// (`TrustedOutput.demoted == true`) must never let the emitted class fall
/// below P2 (occupancy floor), and raw is suppressed.
///
/// This is applied *after* [`map_privacy`] and can only raise the class
/// (toward more privacy) — it is fail-closed by construction.
#[must_use]
pub fn apply_demotion_floor(class: PrivacyClass, demoted: bool) -> PrivacyClass {
if demoted && class < PrivacyClass::P2 {
PrivacyClass::P2
} else {
class
}
}
/// The full egress class for a snapshot: information-content mapping with the
/// demotion floor overlaid. This is what the bridge stamps on the emitted
/// `FieldEvent`.
#[must_use]
pub fn egress_class(
ruview_class: RuViewPrivacyClass,
identity_bound: bool,
demoted: bool,
) -> PrivacyClass {
apply_demotion_floor(map_privacy(ruview_class, identity_bound), demoted)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn derived_maps_to_identity_tier_not_p1() {
// The single most dangerous mapping mistake: Derived (byte 1) must NOT
// become P1. It carries identity ⇒ P4, or P5 if identity-bound.
assert_eq!(map_privacy(RuViewPrivacyClass::Derived, false), PrivacyClass::P4);
assert_eq!(map_privacy(RuViewPrivacyClass::Derived, true), PrivacyClass::P5);
}
#[test]
fn full_table_matches_adr_262_section_3_3() {
assert_eq!(map_privacy(RuViewPrivacyClass::Raw, false), PrivacyClass::P0);
assert_eq!(map_privacy(RuViewPrivacyClass::Derived, false), PrivacyClass::P4);
assert_eq!(map_privacy(RuViewPrivacyClass::Anonymous, false), PrivacyClass::P2);
assert_eq!(map_privacy(RuViewPrivacyClass::Restricted, false), PrivacyClass::P2);
}
#[test]
fn mapping_ignores_non_monotonic_byte_value() {
// Derived's byte (1) is *below* Anonymous's byte (2), but Derived's
// mapped class must be *above* Anonymous's mapped class — proving the
// mapping uses information content, not the byte.
assert!(RuViewPrivacyClass::Derived.raw_byte() < RuViewPrivacyClass::Anonymous.raw_byte());
assert!(
map_privacy(RuViewPrivacyClass::Derived, false)
> map_privacy(RuViewPrivacyClass::Anonymous, false)
);
}
#[test]
fn demotion_floor_only_raises_privacy() {
// Raw → P0, but a demoted cycle floors to P2 with raw suppressed.
assert_eq!(apply_demotion_floor(PrivacyClass::P0, true), PrivacyClass::P2);
// Already-high classes are never lowered by the floor.
assert_eq!(apply_demotion_floor(PrivacyClass::P5, true), PrivacyClass::P5);
// No demotion ⇒ unchanged.
assert_eq!(apply_demotion_floor(PrivacyClass::P0, false), PrivacyClass::P0);
}
#[test]
fn identity_bound_only_promotes() {
// identity_bound never lowers privacy; it only promotes Derived P4→P5.
for c in [
RuViewPrivacyClass::Raw,
RuViewPrivacyClass::Derived,
RuViewPrivacyClass::Anonymous,
RuViewPrivacyClass::Restricted,
] {
assert!(map_privacy(c, true) >= map_privacy(c, false));
}
}
}
@@ -0,0 +1,152 @@
//! Owned, primitive input types for the ADR-262 bridge.
//!
//! These deliberately **mirror** the shapes RuView's sensing cycle produces
//! (the `/ws/sensing` `SensingUpdate` build site at
//! `wifi-densepose-sensing-server/src/main.rs:~5938` and the `TrustedOutput`
//! trust state surfaced via `EngineBridge` at `main.rs:~5886`) **without
//! importing** RuView's internal crates. Keeping the bridge an anti-corruption
//! layer (ADR-262 §5.4) means it takes owned primitives, not `SensingUpdate`
//! or `TrustedOutput` directly — so this crate never depends on
//! `wifi-densepose-sensing-server`.
use serde::{Deserialize, Serialize};
/// The CSI feature scalars RuView publishes on every `/ws/sensing` cycle.
///
/// Mirrors `FeatureInfo` (`main.rs:368-377`). All values are in RuView's own
/// units; the bridge normalizes them into `Observation.features` for fusion.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SensingFeatures {
/// Mean RSSI across the CSI window (dBm).
pub mean_rssi: f64,
/// CSI amplitude variance.
pub variance: f64,
/// Motion-band spectral power (drives `motion_energy`).
pub motion_band_power: f64,
/// Breathing-band spectral power (drives `breathing_band`).
pub breathing_band_power: f64,
/// Dominant frequency of the CSI window (Hz).
pub dominant_freq_hz: f64,
/// Number of change points detected in the window (drives `transient`).
pub change_points: usize,
/// Total spectral power of the window.
pub spectral_power: f64,
}
/// The RuView classification block. Mirrors `ClassificationInfo`
/// (`main.rs:379-384`).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SensingClass {
/// Coarse motion level label (e.g. `"none"`, `"low"`, `"high"`).
pub motion_level: String,
/// Whether a person is present.
pub presence: bool,
/// Classification confidence `0.0..=1.0`.
pub confidence: f64,
}
/// A RuView signal field — a floor-plane grid of field values. Mirrors
/// `SignalField` (`main.rs:386-390`). The bridge derives a real position from
/// the strongest field peak (like `field_localize`) and **never fabricates**
/// coordinates when this is absent.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SignalField {
/// Grid dimensions `[x, y, z]`.
pub grid_size: [usize; 3],
/// Row-major flattened field values; `len() == grid_size.product()`.
pub values: Vec<f64>,
}
impl SignalField {
/// Index `[x, y, z]` of the strongest field cell, or `None` if the grid is
/// empty / all-NaN. This is the honest "strongest field peak" readout that
/// `field_localize` (`field_localize.rs:16-27`) exposes — **not** calibrated
/// triangulation.
#[must_use]
pub fn peak_cell(&self) -> Option<[i32; 3]> {
let [nx, ny, nz] = self.grid_size;
if nx == 0 || ny == 0 || nz == 0 || self.values.is_empty() {
return None;
}
let mut best_idx: Option<usize> = None;
let mut best_val = f64::NEG_INFINITY;
for (i, &v) in self.values.iter().enumerate() {
if v.is_finite() && v > best_val {
best_val = v;
best_idx = Some(i);
}
}
let idx = best_idx?;
// Row-major: idx = ((x * ny) + y) * nz + z.
let z = idx % nz;
let y = (idx / nz) % ny;
let x = idx / (nz * ny);
Some([x as i32, y as i32, z as i32])
}
}
/// RuView's effective privacy class (the `effective_class` / privacy byte on
/// `TrustedOutput`).
///
/// This **mirrors** `wifi_densepose_bfld::PrivacyClass` (`bfld/lib.rs:103-116`,
/// `#[repr(u8)]`) — the four byte-level classes. The byte values are
/// **deliberately non-monotonic in information content**: `Derived = 1` carries
/// an identity embedding yet sorts *below* `Anonymous = 2`. The bridge's
/// `map_privacy` must therefore map by information content, NEVER by byte value
/// (ADR-262 §3.3 — the central correctness item).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RuViewPrivacyClass {
/// Byte `0` — raw CSI amplitude, local-only.
Raw,
/// Byte `1` — derived **identity** features (identity_embedding +
/// identity_risk_score), LAN-only. The dangerous one (§3.3).
Derived,
/// Byte `2` — aggregate occupancy / motion, no identity.
Anonymous,
/// Byte `3` — care/regulated: occupancy minus risk score and hash;
/// raw suppressed.
Restricted,
}
impl RuViewPrivacyClass {
/// The raw byte value used by RuView's `#[repr(u8)]` enum
/// (`bfld/lib.rs:103`). Exposed only so callers can demonstrate the
/// non-monotonicity trap in tests; the bridge never maps off this byte.
#[must_use]
pub fn raw_byte(self) -> u8 {
match self {
RuViewPrivacyClass::Raw => 0,
RuViewPrivacyClass::Derived => 1,
RuViewPrivacyClass::Anonymous => 2,
RuViewPrivacyClass::Restricted => 3,
}
}
}
/// One sensing cycle, as a bridge input. Mirrors the join of `SensingUpdate`
/// (features + classification + signal_field) and the `TrustedOutput` trust
/// state (`trust_class`) that ADR-262 §1.2 / P1 say must be done at the bridge.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SensingSnapshot {
/// Capture time, nanoseconds since Unix epoch (the real `SensingUpdate`
/// timestamp, ns).
pub timestamp_ns: u64,
/// CSI feature scalars (`/ws/sensing` feature set).
pub features: SensingFeatures,
/// Classification (motion level / presence / confidence).
pub classification: SensingClass,
/// Optional signal field for a real position readout.
pub signal_field: Option<SignalField>,
/// RuView's effective privacy class (the source-of-truth, §3.3).
pub trust_class: RuViewPrivacyClass,
/// Whether the governed engine demoted this cycle (`TrustedOutput.demoted`).
/// When `true` the emitted event must be `>= P2` and raw suppressed
/// (§3.3 / §4 P2 gate (b)).
pub demoted: bool,
/// Whether this cycle's identity surface is bound to an enrolled identity
/// (RuView's `identity_bound`). Promotes `Derived` to P5 when set.
pub identity_bound: bool,
/// Stable node id (e.g. `"esp32_room_01"`).
pub node_id: String,
}
@@ -0,0 +1,172 @@
//! ADR-262 P1 acceptance gates. Each test below IS an acceptance criterion.
//!
//! - round-trip: snapshot → FieldEvent → serde → equal
//! - is_fusable: emitted event passes the §11 fusability invariant
//! - fusion ingest accept: `RuFieldFusion::ingest` accepts it + `infer` runs
//! - privacy safety: `Derived` never maps to a low-privacy class (the §3.3 trap)
//! - determinism: same snapshot + same signer seed → identical event
use rufield_core::{FusionEngine, InferenceQuery, PrivacyClass};
use rufield_fusion::RuFieldFusion;
use rufield_provenance::{is_fusable, verify_event, Signer};
use wifi_densepose_rufield::{
map_privacy, snapshot_to_field_event, RuViewPrivacyClass, SensingClass, SensingFeatures,
SensingSnapshot, SignalField,
};
const SEED: &[u8; 32] = b"adr-262-bridge-seed-32-bytes-ok!";
fn signer() -> Signer {
Signer::from_seed(SEED)
}
/// A representative snapshot with a real signal field (so a position is derived).
fn sample_snapshot() -> SensingSnapshot {
SensingSnapshot {
timestamp_ns: 1_791_986_400_123_456_789,
features: SensingFeatures {
mean_rssi: -52.5,
variance: 0.73,
motion_band_power: 2.4,
breathing_band_power: 0.6,
dominant_freq_hz: 0.27,
change_points: 2,
spectral_power: 4.1,
},
classification: SensingClass {
motion_level: "high".into(),
presence: true,
confidence: 0.88,
},
signal_field: Some(SignalField {
grid_size: [2, 1, 2],
// peak at flat index 2 → cell [1,0,0]
values: vec![0.1, 0.2, 0.9, 0.3],
}),
trust_class: RuViewPrivacyClass::Anonymous,
demoted: false,
identity_bound: false,
node_id: "esp32_room_01".into(),
}
}
#[test]
fn gate_round_trip_serde_equal() {
let ev = snapshot_to_field_event(&sample_snapshot(), &signer());
let json = serde_json::to_string(&ev).expect("serialize");
let back: rufield_core::FieldEvent = serde_json::from_str(&json).expect("deserialize");
assert_eq!(ev, back, "FieldEvent must round-trip through serde unchanged");
}
#[test]
fn gate_is_fusable_verified_receipt() {
let ev = snapshot_to_field_event(&sample_snapshot(), &signer());
// Real (non-synthetic) event must carry a verifying ed25519 signature.
assert!(!ev.provenance.synthetic, "live event must NOT be marked synthetic");
assert!(ev.provenance.signature_hex.is_some(), "must be signed");
assert!(verify_event(&ev).is_ok(), "signature must verify");
assert!(is_fusable(&ev), "verified receipt ⇒ fusable (§11 invariant)");
}
#[test]
fn gate_fusion_ingest_accepts_and_infers() {
let ev = snapshot_to_field_event(&sample_snapshot(), &signer());
let mut engine = RuFieldFusion::new();
engine.ingest(ev).expect("fusion engine must accept the signed event");
// infer() must run without error (may or may not produce inferences).
let inferences = engine
.infer(&InferenceQuery::all())
.expect("infer() must run");
// The graph recorded the event/sensor provenance nodes.
assert!(
engine.graph().node_count() >= 2,
"ingest should record sensor + event nodes"
);
let _ = inferences; // count is not an accuracy claim
}
#[test]
fn gate_privacy_safety_derived_never_maps_to_low_privacy() {
// THE critical §3.3 gate. Derived carries identity ⇒ P4/P5, NEVER P1.
let p4 = map_privacy(RuViewPrivacyClass::Derived, false);
let p5 = map_privacy(RuViewPrivacyClass::Derived, true);
assert_eq!(p4, PrivacyClass::P4);
assert_eq!(p5, PrivacyClass::P5);
assert!(p4 >= PrivacyClass::P4, "Derived must be in the identity tier");
assert_ne!(p4, PrivacyClass::P1, "Derived must NEVER be P1");
// And end-to-end: an emitted event from a Derived snapshot must be P4/P5.
let mut snap = sample_snapshot();
snap.trust_class = RuViewPrivacyClass::Derived;
let ev = snapshot_to_field_event(&snap, &signer());
assert!(
ev.observation.privacy_class >= PrivacyClass::P4,
"emitted Derived event must be P4 or P5, got {:?}",
ev.observation.privacy_class
);
assert_eq!(ev.observation.privacy_class, ev.tensor.privacy_class);
}
/// Full §3.3 table over every RuView class → expected RuField class.
#[test]
fn gate_privacy_table_over_every_ruview_class() {
let cases = [
(RuViewPrivacyClass::Raw, false, PrivacyClass::P0),
(RuViewPrivacyClass::Derived, false, PrivacyClass::P4),
(RuViewPrivacyClass::Derived, true, PrivacyClass::P5),
(RuViewPrivacyClass::Anonymous, false, PrivacyClass::P2),
(RuViewPrivacyClass::Restricted, false, PrivacyClass::P2),
];
for (ruview, id_bound, expected) in cases {
assert_eq!(
map_privacy(ruview, id_bound),
expected,
"{ruview:?} (identity_bound={id_bound}) must map to {expected:?}"
);
}
}
/// Fail-closed: a demoted Raw snapshot must NOT emit P0 (raw) — it floors to P2.
#[test]
fn gate_demotion_is_fail_closed() {
let mut snap = sample_snapshot();
snap.trust_class = RuViewPrivacyClass::Raw; // would be P0
snap.demoted = true; // governed engine demotion
let ev = snapshot_to_field_event(&snap, &signer());
assert!(
ev.observation.privacy_class >= PrivacyClass::P2,
"demoted cycle must floor to >= P2, got {:?}",
ev.observation.privacy_class
);
}
#[test]
fn gate_determinism_same_seed_identical_event() {
let snap = sample_snapshot();
let a = snapshot_to_field_event(&snap, &Signer::from_seed(SEED));
let b = snapshot_to_field_event(&snap, &Signer::from_seed(SEED));
assert_eq!(a, b, "same snapshot + same signer seed ⇒ identical event");
// Including the signature (ed25519 is deterministic).
assert_eq!(a.provenance.signature_hex, b.provenance.signature_hex);
}
#[test]
fn no_fabricated_position_when_field_absent() {
let mut snap = sample_snapshot();
snap.signal_field = None;
let ev = snapshot_to_field_event(&snap, &signer());
assert!(ev.observation.range_m.is_none(), "no field ⇒ no fabricated range");
assert!(ev.observation.space_cell.is_none(), "no field ⇒ no fabricated cell");
assert!(
ev.observation.motion_vector.is_none(),
"no field ⇒ no fabricated motion vector"
);
}
#[test]
fn derives_real_position_from_field_peak() {
let ev = snapshot_to_field_event(&sample_snapshot(), &signer());
// peak at flat index 2, grid [2,1,2] (row-major) → cell [1,0,0]
assert_eq!(ev.observation.space_cell, Some([1, 0, 0]));
assert_eq!(ev.observation.range_m, Some(1.0));
}
@@ -63,6 +63,13 @@ wifi-densepose-worldgraph = { version = "0.3.0", path = "../wifi-densepose-world
wifi-densepose-bfld = { version = "0.3.1", path = "../wifi-densepose-bfld", default-features = false }
wifi-densepose-geo = { version = "0.1.0", path = "../wifi-densepose-geo" }
# ADR-262 P3: live RuField surface. The thin anti-corruption bridge that turns
# this server's governed sensing cycle into signed RuField `FieldEvent`s on
# `/api/field` + `/ws/field`. It path-deps the standalone `vendor/rufield`
# submodule (it is the single coupling point — ADR-262 §5.4) and pulls in no
# RuView internal crate, so the dep surface added here is just the bridge.
wifi-densepose-rufield = { version = "0.3.0", path = "../wifi-densepose-rufield" }
# midstream — real-time introspection / low-latency tap (ADR-099 D1).
# Two crates only, on purpose: scheduler / neural-solver / strange-loop are
# explicitly out of scope of ADR-099 (D5).
@@ -23,6 +23,10 @@ pub mod model_format;
pub mod mqtt;
pub mod path_safety;
pub mod semantic;
/// ADR-262 P3: the live RuField surface — turns the governed sensing cycle into
/// signed RuField `FieldEvent`s on the additive `/api/field` + `/ws/field`
/// endpoints, via the `wifi-densepose-rufield` anti-corruption bridge.
pub mod rufield_surface;
pub mod rvf_container;
pub mod rvf_pipeline;
pub mod sona;
@@ -26,7 +26,7 @@ mod vital_signs;
// Training pipeline modules (exposed via lib.rs)
use wifi_densepose_sensing_server::{
dataset, embedding, error_response, graph_transformer, trainer,
dataset, embedding, error_response, graph_transformer, rufield_surface, trainer,
};
use ruvector_mincut::{DynamicMinCut, MinCutBuilder};
@@ -1093,6 +1093,14 @@ struct AppStateInner {
pub(crate) dedup_factor: f64,
/// Data directory for persisting runtime config (parent of `firmware_dir`).
pub(crate) data_dir: std::path::PathBuf,
/// ADR-262 P3: the live RuField surface. Holds the dedicated ed25519 signer
/// + a bounded ring of recent signed `FieldEvent`s + the `/ws/field`
/// broadcast topic. The governed sensing cycle calls `emit()` on it once per
/// cycle (joining `SensingUpdate` features/classification/signal_field with
/// the `TrustedOutput` trust class); `/api/field` + `/ws/field` read it.
/// Held behind its own `Arc<RwLock<_>>` so the additive field router can
/// take it as state without re-locking `AppStateInner`.
field_surface: rufield_surface::FieldState,
}
/// If no ESP32 frame arrives within this duration, source reverts to offline.
@@ -4000,6 +4008,80 @@ fn derive_single_person_pose(
/// the strongest peak so they remain co-located with real energy rather than at
/// a fake origin; if the field has no peak above threshold the position stays at
/// `[0,0,0]` and `motion_score` still reflects real motion power.
/// ADR-262 P3: emit one signed RuField `FieldEvent` for this sensing cycle.
///
/// Joins the cycle's [`SensingUpdate`] (features / classification /
/// signal_field) with the governed engine's trust state (`effective_class` /
/// `demoted`, recorded on `engine_bridge` by `observe_cycle`) into a
/// `SensingSnapshot`, then surfaces it via the P1 bridge on `/api/field` +
/// `/ws/field`. The bridge maps privacy by information content and the surface
/// applies the §10 network egress gate, so above-policy cycles never reach the
/// wire.
///
/// **No phantom events:** an empty/no-presence cycle (`presence == false`)
/// emits nothing — there is no person to describe, so no event is fabricated
/// (ADR-262 §4 P3 / §6). Cycles before the governed engine has produced a trust
/// class are likewise skipped (no class ⇒ nothing honest to stamp).
///
/// `identity_bound` is `false` on the live path: RuView's live cycle does not
/// bind an enrolled identity to the surface yet (that is a per-room-calibration
/// / AETHER concern, ADR-262 §8 Q4). This is conservative for egress — it only
/// ever *lowers* a Derived cycle from P5 to P4, both of which are already held
/// edge-local, so it cannot leak.
fn emit_rufield_event(s: &AppStateInner, update: &SensingUpdate, node_id: u8) {
// No-presence ⇒ no phantom event.
if !update.classification.presence {
return;
}
// Need a governed trust class before we can honestly stamp privacy.
let Some(effective_class) = s.engine_bridge.effective_class() else {
return;
};
let timestamp_ns = if update.timestamp.is_finite() && update.timestamp > 0.0 {
(update.timestamp * 1_000_000_000.0) as u64
} else {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0)
};
let snap = rufield_surface::build_snapshot(
timestamp_ns,
format!("esp32_node_{node_id}"),
rufield_surface::SensingFeatures {
mean_rssi: update.features.mean_rssi,
variance: update.features.variance,
motion_band_power: update.features.motion_band_power,
breathing_band_power: update.features.breathing_band_power,
dominant_freq_hz: update.features.dominant_freq_hz,
change_points: update.features.change_points,
spectral_power: update.features.spectral_power,
},
rufield_surface::SensingClass {
motion_level: update.classification.motion_level.clone(),
presence: update.classification.presence,
confidence: update.classification.confidence,
},
Some(rufield_surface::SignalField {
grid_size: update.signal_field.grid_size,
values: update.signal_field.values.clone(),
}),
rufield_surface::ruview_class_from_bfld(effective_class),
s.engine_bridge.demoted(),
false, // identity_bound — see fn-doc (conservative, cannot leak).
);
// `field_surface` is its own Arc<RwLock<_>>; `try_write` is non-blocking and
// never deadlocks against the `s` guard (a different lock). The only other
// touchers are the read-only `/api/field` / `/ws/field` handlers, so
// contention is negligible; a rare miss just drops one cycle's event.
if let Ok(mut fs) = s.field_surface.try_write() {
fs.emit(&snap);
}
}
fn attach_field_positions(update: &mut SensingUpdate) {
let Some(persons) = update.persons.as_mut() else {
return;
@@ -5990,6 +6072,18 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
if let Ok(json) = serde_json::to_string(&update) {
let _ = s.tx.send(json);
}
// ── ADR-262 P3: emit a signed RuField FieldEvent ────────
// Join this cycle's SensingUpdate (features / classification
// / signal_field) with the governed engine's trust state
// (effective_class / demoted, recorded by `observe_cycle`
// above) into a `SensingSnapshot`, and surface it on
// `/api/field` + `/ws/field` via the P1 bridge. Only cycles
// whose mapped privacy class clears the §10 network egress
// gate are surfaced (P1/P2); a `Derived → P4/P5` cycle is
// held edge-local. `presence == false` ⇒ no phantom event.
emit_rufield_event(&s, &update, node_id);
s.latest_update = Some(update);
// Evict stale nodes every 100 ticks to prevent memory leak.
@@ -7322,6 +7416,13 @@ async fn main() {
);
}
// ADR-262 P3: build the live RuField surface (dedicated ed25519 signer from
// WDP_RUFIELD_SIGNING_SEED, else a logged dev default). The same Arc is
// stored in AppStateInner (so the sensing loop can `emit()` per cycle) and
// cloned into the additive `/api/field` + `/ws/field` router below.
let field_surface: rufield_surface::FieldState =
Arc::new(RwLock::new(rufield_surface::FieldSurface::from_env()));
let state: SharedState = Arc::new(RwLock::new(AppStateInner {
latest_update: None,
rssi_history: VecDeque::new(),
@@ -7424,6 +7525,7 @@ async fn main() {
// ADR-044 §5.3: runtime-configurable dedup factor (persisted).
dedup_factor: runtime_config.dedup_factor,
data_dir: data_dir.clone(),
field_surface: field_surface.clone(),
}));
// Start background tasks from the resolved plan (issue #1004).
@@ -7497,11 +7599,15 @@ async fn main() {
let ws_app = Router::new()
.route("/ws/sensing", get(ws_sensing_handler))
.route("/health", get(health))
.with_state(ws_state)
// ADR-262 P3: additive `/ws/field` (+ `/api/field`) on the WS port too,
// so a client on :8765 can stream signed RuField FieldEvents alongside
// `/ws/sensing`. Merged with its own FieldState (different state type).
.merge(rufield_surface::router(field_surface.clone()))
.layer(axum::middleware::from_fn_with_state(
host_allowlist.clone(),
wifi_densepose_sensing_server::host_validation::require_allowed_host,
))
.with_state(ws_state);
));
let ws_addr = SocketAddr::from((bind_ip, args.ws_port));
let ws_listener = tokio::net::TcpListener::bind(ws_addr)
@@ -7615,15 +7721,24 @@ async fn main() {
bearer_auth_state.clone(),
wifi_densepose_sensing_server::bearer_auth::require_bearer,
))
.with_state(state.clone())
// ADR-262 P3: additive RuField surface (`/api/field` + `/ws/field`).
// Merged AFTER `.with_state` (so http_app is already `Router<()>` and
// can absorb the field router's own `FieldState`). These routes sit
// OUTSIDE `/api/v1/*` so they are not bearer-gated, but the
// host-validation layer below still applies (it is added last, so it
// runs first, over the whole merged router). The surface's own §10
// egress gate is what keeps above-policy classes off the wire.
.merge(rufield_surface::router(field_surface.clone()))
// DNS-rebinding defense: applied last so it runs first on the request
// path (axum layers run outermost-in). Rejects requests whose `Host`
// header is not in the allowlist before any handler — including
// `/health` and `/ws/*` — observes the body.
// `/health`, `/ws/*`, and the merged `/api/field` + `/ws/field` —
// observes the body.
.layer(axum::middleware::from_fn_with_state(
host_allowlist.clone(),
wifi_densepose_sensing_server::host_validation::require_allowed_host,
))
.with_state(state.clone());
));
let http_addr = SocketAddr::from((bind_ip, args.http_port));
let http_listener = tokio::net::TcpListener::bind(http_addr)
@@ -0,0 +1,439 @@
//! ADR-262 **P3** — the live RuField surface.
//!
//! This is the data-path wiring that turns RuView's governed sensing cycle into
//! signed RuField [`FieldEvent`]s on two **additive** network endpoints:
//!
//! - `GET /api/field` — the most recent surfaced `FieldEvent`(s) as JSON;
//! - `GET /ws/field` — a WebSocket that streams each cycle's `FieldEvent`
//! (mirrors the `/ws/sensing` broadcast-subscribe pattern).
//!
//! It is purely additive: `/ws/sensing` and every existing endpoint are
//! unchanged. The conversion itself lives entirely in the P1
//! [`wifi_densepose_rufield`] anti-corruption bridge (ADR-262 §5.4 — the single
//! coupling point); this module only (a) holds the dedicated signer + a bounded
//! ring buffer of recent events in server state, (b) builds a
//! [`SensingSnapshot`] from the **same real data** the cycle already produced
//! (`SensingUpdate` features/classification/signal_field joined with the
//! governed-engine [`TrustedOutput`] trust state at `main.rs:~5886`/`:~5938`),
//! and (c) applies the §10 network egress gate so above-policy classes never
//! reach the wire.
//!
//! ## Honesty (ADR-262 §0 / §6)
//!
//! This wires **real** RuView sensing into RuField events on a live endpoint,
//! but: (a) it is the **single-link CSI** sensing with its existing caveats —
//! there is **no validated room-coordinate accuracy** (`field_localize` says so;
//! positions are "strongest field peak", not triangulation); (b) the signing
//! key is a **dedicated dev/sensing key** pending the ADR-262 §8 Q1 ownership
//! decision (reusing the `cog-ha-matter` Ed25519 key is the **deferred P2**
//! call — P3 deliberately uses a standalone key so it does not pre-empt that);
//! (c) **no accuracy is claimed.** The win is narrowly: "RuView's live sensing
//! now speaks RuField on `/ws/field`."
use std::collections::VecDeque;
use std::sync::Arc;
use axum::{
extract::{
ws::{Message, WebSocket, WebSocketUpgrade},
State,
},
response::{IntoResponse, Json},
};
use tokio::sync::{broadcast, RwLock};
// Re-export the bridge input types `main.rs` needs to build a snapshot, so the
// server-side call site depends only on `rufield_surface` (the server seam).
pub use wifi_densepose_rufield::{
network_egress_allowed, snapshot_to_field_event, FieldEvent, RuViewPrivacyClass,
SensingClass, SensingFeatures, SensingSnapshot, Signer, SignalField,
};
/// How many recent surfaced `FieldEvent`s the ring buffer retains. Small and
/// bounded — this is a live tap, not a store (ADR-262 §4 P3 "small bounded ring
/// buffer of recent events").
pub const FIELD_RING_CAPACITY: usize = 64;
/// Broadcast channel depth for `/ws/field`. Matches the `/ws/sensing` `tx`
/// channel size (256) so a slow field client drops messages rather than
/// stalling the sensing loop.
pub const FIELD_BROADCAST_CAPACITY: usize = 256;
/// Environment variable carrying the 32-byte hex/raw signing seed for the
/// dedicated RuField sensing signer. When unset, a deterministic dev default is
/// used (with a logged warning). See [`FieldSurface::from_env`].
pub const SIGNING_SEED_ENV: &str = "WDP_RUFIELD_SIGNING_SEED";
/// Deterministic dev signing seed used when [`SIGNING_SEED_ENV`] is unset. This
/// is a **dev/sensing key**, intentionally standalone (ADR-262 §8 Q1 — the
/// `cog-ha-matter` key reuse is the deferred P2 decision, not pre-empted here).
const DEV_SIGNING_SEED: &[u8; 32] = b"adr262-ruview-rufield-dev-seed!!";
/// The live RuField surface state held in `AppStateInner` (ADR-262 P3).
///
/// Owns the **dedicated** ed25519 [`Signer`], a bounded ring buffer of the most
/// recent network-surfaced events, and the `/ws/field` broadcast sender.
pub struct FieldSurface {
signer: Signer,
/// Bounded ring of recent **network-surfaced** events (most recent last).
recent: VecDeque<FieldEvent>,
/// Broadcast topic for `/ws/field` (JSON-serialized `FieldEvent`s).
tx: broadcast::Sender<String>,
/// True when the dev default seed is in use (drives a one-time warning and
/// is surfaced in `/api/field` metadata so operators can see they are on a
/// dev key).
using_dev_key: bool,
}
impl FieldSurface {
/// Build a surface with an explicit 32-byte seed (deterministic signer).
#[must_use]
pub fn from_seed(seed: &[u8; 32], using_dev_key: bool) -> Self {
let (tx, _rx) = broadcast::channel(FIELD_BROADCAST_CAPACITY);
Self {
signer: Signer::from_seed(seed),
recent: VecDeque::with_capacity(FIELD_RING_CAPACITY),
tx,
using_dev_key,
}
}
/// Build a surface from the environment (ADR-262 §4 P3 / open-question 1).
///
/// Reads [`SIGNING_SEED_ENV`] as either a 64-char hex string or a raw 32+
/// byte UTF-8 value (first 32 bytes used). When unset/invalid it falls back
/// to the deterministic [`DEV_SIGNING_SEED`] and logs a `WARN` — the key is
/// a standalone **dev/sensing** key, NOT the deferred-P2 `cog-ha-matter`
/// key.
#[must_use]
pub fn from_env() -> Self {
match std::env::var(SIGNING_SEED_ENV).ok().and_then(|v| parse_seed(&v)) {
Some(seed) => {
tracing::info!(
"ADR-262 P3: RuField surface using signing seed from {SIGNING_SEED_ENV} \
(dedicated sensing key)"
);
Self::from_seed(&seed, false)
}
None => {
tracing::warn!(
"ADR-262 P3: {SIGNING_SEED_ENV} unset/invalid — RuField surface using the \
DETERMINISTIC DEV signing key. This is a dev/sensing key pending the \
ADR-262 §8 Q1 (P2) key-ownership decision; set {SIGNING_SEED_ENV} (64-hex \
or 32-byte value) for a real deployment."
);
Self::from_seed(DEV_SIGNING_SEED, true)
}
}
}
/// The public key of the dedicated signer (hex), so consumers can verify
/// receipts without the private seed.
#[must_use]
pub fn signer_pubkey_hex(&self) -> String {
self.signer.public_hex()
}
/// Whether the dev default key is in use.
#[must_use]
pub fn using_dev_key(&self) -> bool {
self.using_dev_key
}
/// A `/ws/field` subscription.
#[must_use]
pub fn subscribe(&self) -> broadcast::Receiver<String> {
self.tx.subscribe()
}
/// The most recent surfaced events, oldest→newest.
#[must_use]
pub fn recent(&self) -> Vec<FieldEvent> {
self.recent.iter().cloned().collect()
}
/// Convert one cycle's [`SensingSnapshot`] into a signed [`FieldEvent`],
/// apply the §10 network egress gate, and — **iff** the event may leave the
/// box — push it into the ring + broadcast it on `/ws/field`.
///
/// Returns `Some(event)` when an event was surfaced, `None` when the cycle
/// was held edge-local (above network policy — e.g. a `Derived → P4/P5`
/// cycle) or carried no presence. Two structural guarantees live here, so
/// they hold regardless of caller:
///
/// - **no phantom events** — a no-presence cycle (`presence == false`)
/// surfaces nothing (ADR-262 §4 P3 / §6); there is no person to describe.
/// - **privacy-safety pin** — above-policy classes (P0, P3P5) are never
/// placed on the network surface; only egress-safe P1/P2 events leave.
pub fn emit(&mut self, snap: &SensingSnapshot) -> Option<FieldEvent> {
// No-presence ⇒ no phantom event (fabricating one would be dishonest).
if !snap.classification.presence {
return None;
}
let event = snapshot_to_field_event(snap, &self.signer);
// §10 network egress gate (ADR-262 §4 P3): only P1/P2 leave the box by
// default; P0 raw and P3/P4/P5 (above the default P2 ceiling, or
// identity/biometric) are held edge-local. A `Derived` cycle is P4/P5
// ⇒ never surfaced as a low-privacy network event.
if !network_egress_allowed(event.observation.privacy_class, snap.identity_bound) {
tracing::trace!(
privacy_class = ?event.observation.privacy_class,
"ADR-262 P3: cycle held edge-local (above network policy), not surfaced on /api/field"
);
return None;
}
if self.recent.len() == FIELD_RING_CAPACITY {
self.recent.pop_front();
}
self.recent.push_back(event.clone());
if let Ok(json) = serde_json::to_string(&event) {
let _ = self.tx.send(json);
}
Some(event)
}
}
/// Parse [`SIGNING_SEED_ENV`] as 64-char hex or a raw 32+ byte UTF-8 value.
fn parse_seed(v: &str) -> Option<[u8; 32]> {
let v = v.trim();
// 64 hex chars → 32 bytes.
if v.len() == 64 && v.bytes().all(|b| b.is_ascii_hexdigit()) {
let mut out = [0u8; 32];
for (i, chunk) in v.as_bytes().chunks(2).enumerate() {
let hi = (chunk[0] as char).to_digit(16)?;
let lo = (chunk[1] as char).to_digit(16)?;
out[i] = ((hi << 4) | lo) as u8;
}
return Some(out);
}
// Otherwise: first 32 bytes of the raw value (must be at least 32 long so a
// short/typo'd value fails closed to the dev key rather than a weak key).
let bytes = v.as_bytes();
if bytes.len() >= 32 {
let mut out = [0u8; 32];
out.copy_from_slice(&bytes[..32]);
return Some(out);
}
None
}
/// Build a [`SensingSnapshot`] from the real per-cycle values (ADR-262 P3 §4.2).
///
/// This is the join the ADR mandates: `SensingUpdate` features / classification
/// / signal-field **plus** the governed engine's `effective_class` / `demoted`
/// / `identity_bound` trust state. All inputs are the same real data the cycle
/// already computed — nothing is fabricated. `signal_field` is passed through as
/// the honest "strongest field peak" readout (no calibrated coordinates).
#[allow(clippy::too_many_arguments)]
#[must_use]
pub fn build_snapshot(
timestamp_ns: u64,
node_id: String,
features: SensingFeatures,
classification: SensingClass,
signal_field: Option<SignalField>,
trust_class: RuViewPrivacyClass,
demoted: bool,
identity_bound: bool,
) -> SensingSnapshot {
SensingSnapshot {
timestamp_ns,
features,
classification,
signal_field,
trust_class,
demoted,
identity_bound,
node_id,
}
}
/// Map RuView's live governed-engine `bfld::PrivacyClass` (the `effective_class`
/// on `TrustedOutput`) onto the bridge's [`RuViewPrivacyClass`] input.
///
/// This is a **lossless, same-meaning** re-encoding of the four byte-level
/// classes — both enums are `Raw/Derived/Anonymous/Restricted` in the same
/// order. It exists only so `main.rs` can pass the engine's class into the
/// bridge without the bridge depending on `wifi-densepose-bfld` (keeping it an
/// anti-corruption layer, ADR-262 §5.4). The information-content privacy
/// mapping (the §3.3 correctness item) happens *inside* the bridge.
#[must_use]
pub fn ruview_class_from_bfld(class: wifi_densepose_bfld::PrivacyClass) -> RuViewPrivacyClass {
use wifi_densepose_bfld::PrivacyClass as B;
match class {
B::Raw => RuViewPrivacyClass::Raw,
B::Derived => RuViewPrivacyClass::Derived,
B::Anonymous => RuViewPrivacyClass::Anonymous,
B::Restricted => RuViewPrivacyClass::Restricted,
}
}
// ── Handlers ────────────────────────────────────────────────────────────────
/// Shared state for the field surface handlers. Generic over the lock guard so
/// the module can be tested in isolation with a tiny state (ADR-262 P3 test
/// gate) and wired into the full `AppStateInner` in `main.rs` via an adapter.
pub type FieldState = Arc<RwLock<FieldSurface>>;
/// `GET /api/field` — the most recent network-surfaced `FieldEvent`s as JSON,
/// plus surface metadata (the signer pubkey + whether a dev key is in use).
///
/// When no event has been surfaced yet (empty room / above-policy cycles only)
/// the `events` array is empty — an **explicit empty payload**, never a
/// fabricated event (ADR-262 §4 P3 / §6 honesty).
pub async fn api_field(State(state): State<FieldState>) -> Json<serde_json::Value> {
let s = state.read().await;
Json(serde_json::json!({
"spec": "rufield",
"endpoint": "/api/field",
"signer_pubkey_hex": s.signer_pubkey_hex(),
"dev_signing_key": s.using_dev_key(),
"events": s.recent(),
}))
}
/// `GET /ws/field` — upgrade to a WebSocket that streams each surfaced
/// `FieldEvent` (JSON) as the sensing loop emits it. Mirrors `/ws/sensing`:
/// subscribe to the broadcast topic and forward.
pub async fn ws_field(ws: WebSocketUpgrade, State(state): State<FieldState>) -> impl IntoResponse {
let rx = {
let s = state.read().await;
s.subscribe()
};
ws.on_upgrade(move |socket| handle_ws_field_client(socket, rx))
}
async fn handle_ws_field_client(mut socket: WebSocket, mut rx: broadcast::Receiver<String>) {
// Forward broadcast events; exit on client close or fatal lag.
loop {
match rx.recv().await {
Ok(json) => {
if socket.send(Message::Text(json)).await.is_err() {
break; // client gone
}
}
Err(broadcast::error::RecvError::Lagged(_)) => {
// Slow client missed events — keep going from the latest.
continue;
}
Err(broadcast::error::RecvError::Closed) => break,
}
}
}
/// Build the additive field-surface router. Mounted into the main HTTP router
/// in `main.rs`; also used standalone by the integration tests (ADR-262 P3
/// gate, `tower::oneshot`).
#[must_use]
pub fn router(state: FieldState) -> axum::Router {
use axum::routing::get;
axum::Router::new()
.route("/api/field", get(api_field))
.route("/ws/field", get(ws_field))
.with_state(state)
}
#[cfg(test)]
mod tests {
use super::*;
use wifi_densepose_rufield::{is_fusable, PrivacyClass};
fn features() -> SensingFeatures {
SensingFeatures {
mean_rssi: -55.0,
variance: 0.4,
motion_band_power: 2.0,
breathing_band_power: 0.3,
dominant_freq_hz: 0.25,
change_points: 1,
spectral_power: 3.0,
}
}
fn present_class() -> SensingClass {
SensingClass {
motion_level: "low".into(),
presence: true,
confidence: 0.82,
}
}
#[test]
fn parse_seed_hex_and_raw_and_short() {
// 64 hex chars → 32 bytes.
let hex = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff";
let parsed = parse_seed(hex).expect("valid hex seed");
assert_eq!(parsed[0], 0x00);
assert_eq!(parsed[31], 0xff);
// Raw 32-byte value.
assert!(parse_seed("0123456789abcdef0123456789abcdef").is_some());
// Too short → fail closed (None → dev key).
assert!(parse_seed("short").is_none());
}
#[test]
fn anonymous_cycle_surfaces_fusable_event() {
let mut surface = FieldSurface::from_seed(DEV_SIGNING_SEED, true);
let snap = build_snapshot(
1_791_986_400_000_000_000,
"esp32_room_01".into(),
features(),
present_class(),
None,
RuViewPrivacyClass::Anonymous, // → P2, network-allowed
false,
false,
);
let ev = surface.emit(&snap).expect("anonymous P2 cycle is surfaced");
assert_eq!(ev.observation.privacy_class, PrivacyClass::P2);
assert!(is_fusable(&ev), "live event must be ed25519-signed & fusable");
assert_eq!(surface.recent().len(), 1);
}
#[test]
fn derived_cycle_never_surfaces_low_privacy() {
// The privacy-safety pin: a Derived (identity) cycle maps to P4/P5 and
// is held edge-local — it must NEVER appear on the network surface.
let mut surface = FieldSurface::from_seed(DEV_SIGNING_SEED, true);
for identity_bound in [false, true] {
let snap = build_snapshot(
1_791_986_400_000_000_000,
"esp32_room_01".into(),
features(),
present_class(),
None,
RuViewPrivacyClass::Derived,
false,
identity_bound,
);
assert!(
surface.emit(&snap).is_none(),
"Derived cycle (identity_bound={identity_bound}) must be held edge-local"
);
}
assert!(surface.recent().is_empty(), "no Derived event may reach the surface");
}
#[test]
fn ring_buffer_is_bounded() {
let mut surface = FieldSurface::from_seed(DEV_SIGNING_SEED, true);
for i in 0..(FIELD_RING_CAPACITY + 10) {
let snap = build_snapshot(
1_791_986_400_000_000_000 + i as u64,
"esp32_room_01".into(),
features(),
present_class(),
None,
RuViewPrivacyClass::Anonymous,
false,
false,
);
surface.emit(&snap);
}
assert_eq!(surface.recent().len(), FIELD_RING_CAPACITY);
}
}
@@ -0,0 +1,178 @@
//! ADR-262 **P3** acceptance gate — the live RuField surface.
//!
//! In-process integration test (mirrors the `/ws/sensing` / #1050 oneshot
//! style with `tower::ServiceExt::oneshot`): drives synthetic sensing cycles
//! through the real `FieldSurface` + the real `/api/field` router, and asserts:
//!
//! 1. an injected `Anonymous` (occupancy) cycle surfaces a **well-formed signed
//! `FieldEvent`** — `Modality::WifiCsi`, privacy class consistent with the
//! trust (P2, never P1), `is_fusable` (ed25519 receipt verifies), real
//! timestamp;
//! 2. an empty / no-presence cycle produces **no phantom event** (explicit
//! empty payload);
//! 3. the **privacy-safety pin** — an injected `Derived` (identity) trust state
//! never surfaces as a low-privacy event on `/api/field` (held edge-local).
//!
//! These gates are plumbing + privacy-safety, NOT accuracy (ADR-262 §0 / §6).
use std::sync::Arc;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use tokio::sync::RwLock;
use tower::ServiceExt; // `oneshot`
use wifi_densepose_rufield::{is_fusable, verify_event, FieldEvent, Modality, PrivacyClass};
use wifi_densepose_sensing_server::rufield_surface::{
self, FieldState, FieldSurface, RuViewPrivacyClass, SensingClass, SensingFeatures, SignalField,
};
/// A fixed dev seed for deterministic, signed events under test.
const TEST_SEED: &[u8; 32] = b"adr262-p3-integration-test-seed!";
fn features() -> SensingFeatures {
SensingFeatures {
mean_rssi: -55.0,
variance: 0.4,
motion_band_power: 2.0,
breathing_band_power: 0.3,
dominant_freq_hz: 0.25,
change_points: 1,
spectral_power: 3.0,
}
}
fn class(presence: bool) -> SensingClass {
SensingClass {
motion_level: if presence { "low".into() } else { "none".into() },
presence,
confidence: if presence { 0.82 } else { 0.05 },
}
}
/// A small 2×1×2 signal field with a clear peak, so the bridge derives a real
/// (non-fabricated) position from the strongest cell.
fn signal_field() -> SignalField {
SignalField {
grid_size: [2, 1, 2],
values: vec![0.1, 0.2, 0.9, 0.3], // peak at index 2
}
}
/// Build a `FieldState` + the real `/api/field` + `/ws/field` router over it.
fn surface_router() -> (FieldState, axum::Router) {
let state: FieldState = Arc::new(RwLock::new(FieldSurface::from_seed(TEST_SEED, true)));
let app = rufield_surface::router(state.clone());
(state, app)
}
/// Drive one cycle into the surface (the in-process equivalent of the live
/// sensing loop calling `emit()` per cycle).
async fn inject(state: &FieldState, trust: RuViewPrivacyClass, presence: bool, identity_bound: bool) {
let snap = rufield_surface::build_snapshot(
1_791_986_400_000_000_000,
"esp32_node_7".into(),
features(),
class(presence),
Some(signal_field()),
trust,
false, // demoted
identity_bound,
);
state.write().await.emit(&snap);
}
/// `GET /api/field` and parse the `events` array.
async fn get_field_events(app: &axum::Router) -> Vec<FieldEvent> {
let resp = app
.clone()
.oneshot(
Request::builder()
.uri("/api/field")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK, "/api/field must return 200");
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["spec"], "rufield");
serde_json::from_value(v["events"].clone()).expect("events array deserializes to FieldEvents")
}
#[tokio::test]
async fn gate_anonymous_cycle_surfaces_wellformed_signed_event() {
let (state, app) = surface_router();
inject(&state, RuViewPrivacyClass::Anonymous, true, false).await;
let events = get_field_events(&app).await;
assert_eq!(events.len(), 1, "one occupancy cycle ⇒ exactly one surfaced event");
let ev = &events[0];
// Well-formed: WiFi-CSI modality, real timestamp.
assert_eq!(ev.tensor.modality, Modality::WifiCsi);
assert_eq!(ev.timestamp_ns, 1_791_986_400_000_000_000);
assert!(ev.timestamp_ns > 0, "real (non-zero) timestamp");
// Privacy consistent with the injected trust: Anonymous → P2, NEVER P1.
assert_eq!(ev.observation.privacy_class, PrivacyClass::P2);
assert_ne!(ev.observation.privacy_class, PrivacyClass::P1);
// Signed + fusable: the ed25519 receipt verifies (real, non-synthetic).
assert!(!ev.provenance.synthetic, "live event is non-synthetic");
assert!(verify_event(ev).is_ok(), "ed25519 signature must verify");
assert!(is_fusable(ev), "verified receipt ⇒ fusable");
// Real position derived from the signal-field peak (not fabricated).
assert!(ev.observation.range_m.is_some(), "field peak ⇒ a real range readout");
}
#[tokio::test]
async fn gate_empty_cycle_produces_no_phantom_event() {
let (state, app) = surface_router();
// A no-presence cycle: nothing to describe.
inject(&state, RuViewPrivacyClass::Anonymous, false, false).await;
let events = get_field_events(&app).await;
assert!(
events.is_empty(),
"no-presence cycle must surface no phantom event (explicit empty payload)"
);
}
#[tokio::test]
async fn gate_derived_trust_never_surfaces_low_privacy() {
// The privacy-safety pin (ADR-262 §3.3 / §6): a Derived (identity) trust
// state maps to P4/P5 and is held edge-local — it must NEVER appear on the
// network surface, and certainly never as a low-privacy (P1/P2) event.
for identity_bound in [false, true] {
let (state, app) = surface_router();
inject(&state, RuViewPrivacyClass::Derived, true, identity_bound).await;
let events = get_field_events(&app).await;
assert!(
events.is_empty(),
"Derived cycle (identity_bound={identity_bound}) must not surface on /api/field"
);
}
}
#[tokio::test]
async fn gate_mixed_stream_surfaces_only_egress_safe_events() {
// Determinism / privacy-safety over a stream: Anonymous cycles surface,
// interleaved Derived cycles are dropped — the surface only ever carries
// egress-safe (P1/P2) events.
let (state, app) = surface_router();
inject(&state, RuViewPrivacyClass::Anonymous, true, false).await; // P2 → surfaced
inject(&state, RuViewPrivacyClass::Derived, true, false).await; // P4 → dropped
inject(&state, RuViewPrivacyClass::Anonymous, true, false).await; // P2 → surfaced
inject(&state, RuViewPrivacyClass::Derived, true, true).await; // P5 → dropped
let events = get_field_events(&app).await;
assert_eq!(events.len(), 2, "only the two Anonymous cycles surface");
for ev in &events {
assert_eq!(ev.observation.privacy_class, PrivacyClass::P2);
assert!(is_fusable(ev));
}
}
+1 -1