diff --git a/.github/workflows/aether-arena-harness.yml b/.github/workflows/aether-arena-harness.yml index ed56c21a..a0d946da 100644 --- a/.github/workflows/aether-arena-harness.yml +++ b/.github/workflows/aether-arena-harness.yml @@ -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 diff --git a/.github/workflows/bfld-mqtt-integration.yml b/.github/workflows/bfld-mqtt-integration.yml index a47f416c..4104b21d 100644 --- a/.github/workflows/bfld-mqtt-integration.yml +++ b/.github/workflows/bfld-mqtt-integration.yml @@ -53,6 +53,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + submodules: recursive - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 93990f5a..3342dcf7 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2c79ce45..e1836c7f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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,8 @@ 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. @@ -207,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 @@ -272,6 +277,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 + with: + submodules: recursive - name: Set up Python uses: actions/setup-python@v6 @@ -340,6 +347,8 @@ jobs: - name: Checkout code continue-on-error: true uses: actions/checkout@v4 + with: + submodules: recursive - name: Set up Docker Buildx continue-on-error: true @@ -412,6 +421,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 + with: + submodules: recursive - name: Set up Python uses: actions/setup-python@v6 diff --git a/.github/workflows/clone-tracking.yml b/.github/workflows/clone-tracking.yml index 58b1e293..79483c78 100644 --- a/.github/workflows/clone-tracking.yml +++ b/.github/workflows/clone-tracking.yml @@ -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: diff --git a/.github/workflows/cog-ha-matter-release.yml b/.github/workflows/cog-ha-matter-release.yml index 05f12716..c4e9f051 100644 --- a/.github/workflows/cog-ha-matter-release.yml +++ b/.github/workflows/cog-ha-matter-release.yml @@ -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 diff --git a/.github/workflows/dashboard-a11y.yml b/.github/workflows/dashboard-a11y.yml index 3034dbbc..d171863e 100644 --- a/.github/workflows/dashboard-a11y.yml +++ b/.github/workflows/dashboard-a11y.yml @@ -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 } diff --git a/.github/workflows/dashboard-pages.yml b/.github/workflows/dashboard-pages.yml index dc45d51c..41b187b8 100644 --- a/.github/workflows/dashboard-pages.yml +++ b/.github/workflows/dashboard-pages.yml @@ -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 diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index b9eee658..ab1f924a 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -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 diff --git a/.github/workflows/firmware-ci.yml b/.github/workflows/firmware-ci.yml index 16ece4fb..43ac4031 100644 --- a/.github/workflows/firmware-ci.yml +++ b/.github/workflows/firmware-ci.yml @@ -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 diff --git a/.github/workflows/firmware-qemu.yml b/.github/workflows/firmware-qemu.yml index 8bc08220..5fe26bf6 100644 --- a/.github/workflows/firmware-qemu.yml +++ b/.github/workflows/firmware-qemu.yml @@ -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 diff --git a/.github/workflows/fix-regression-guard.yml b/.github/workflows/fix-regression-guard.yml index d69e65d1..854f216a 100644 --- a/.github/workflows/fix-regression-guard.yml +++ b/.github/workflows/fix-regression-guard.yml @@ -22,6 +22,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + submodules: recursive - uses: actions/setup-python@v6 with: diff --git a/.github/workflows/mqtt-integration.yml b/.github/workflows/mqtt-integration.yml index 56bbb43d..059f4cf0 100644 --- a/.github/workflows/mqtt-integration.yml +++ b/.github/workflows/mqtt-integration.yml @@ -41,6 +41,8 @@ jobs: steps: - uses: actions/checkout@v4 + with: + submodules: recursive - name: Install mosquitto + clients and start with allow_anonymous run: | diff --git a/.github/workflows/nvsim-server-docker.yml b/.github/workflows/nvsim-server-docker.yml index 9222add5..3548858c 100644 --- a/.github/workflows/nvsim-server-docker.yml +++ b/.github/workflows/nvsim-server-docker.yml @@ -26,6 +26,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + submodules: recursive - uses: docker/setup-buildx-action@v3 diff --git a/.github/workflows/pip-release.yml b/.github/workflows/pip-release.yml index c985b07a..99bab495 100644 --- a/.github/workflows/pip-release.yml +++ b/.github/workflows/pip-release.yml @@ -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' diff --git a/.github/workflows/pointcloud-pages.yml b/.github/workflows/pointcloud-pages.yml index 8b3eb51e..c025d440 100644 --- a/.github/workflows/pointcloud-pages.yml +++ b/.github/workflows/pointcloud-pages.yml @@ -29,6 +29,8 @@ jobs: steps: - name: Checkout main uses: actions/checkout@v4 + with: + submodules: recursive - name: Stage viewer for Pages run: | diff --git a/.github/workflows/ruview-swarm-ci.yml b/.github/workflows/ruview-swarm-ci.yml index 3e360bb5..829619a5 100644 --- a/.github/workflows/ruview-swarm-ci.yml +++ b/.github/workflows/ruview-swarm-ci.yml @@ -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 diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index c5deb46c..d7cdc7b2 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -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,6 +167,8 @@ jobs: - name: Checkout code continue-on-error: true uses: actions/checkout@v4 + with: + submodules: recursive - name: Set up Docker Buildx 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 diff --git a/.github/workflows/threejs-pages.yml b/.github/workflows/threejs-pages.yml index a542e88f..d3a51c23 100644 --- a/.github/workflows/threejs-pages.yml +++ b/.github/workflows/threejs-pages.yml @@ -30,6 +30,8 @@ jobs: steps: - name: Checkout main uses: actions/checkout@v4 + with: + submodules: recursive - name: Stage demos for Pages run: | diff --git a/.github/workflows/verify-pipeline.yml b/.github/workflows/verify-pipeline.yml index 3e647e43..6a1f3ed5 100644 --- a/.github/workflows/verify-pipeline.yml +++ b/.github/workflows/verify-pipeline.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index f34d9feb..80896eea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ 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 P0–P5 **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 P0–P5 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 P0–P5, 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. diff --git a/docker/Dockerfile.rust b/docker/Dockerfile.rust index 8418ac03..14c02efc 100644 --- a/docker/Dockerfile.rust +++ b/docker/Dockerfile.rust @@ -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) diff --git a/docs/adr/ADR-262-rufield-ruview-integration.md b/docs/adr/ADR-262-rufield-ruview-integration.md index 2484dc65..64702aaf 100644 --- a/docs/adr/ADR-262-rufield-ruview-integration.md +++ b/docs/adr/ADR-262-rufield-ruview-integration.md @@ -2,7 +2,7 @@ | Field | Value | |-------|-------| -| **Status** | Proposed — P1 implemented | +| **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) | @@ -30,7 +30,15 @@ This project has been publicly accused of "AI slop." This ADR answers with **evi - **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). -**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 — P1 takes a dedicated `Signer` param (the §8 open question 1 key-ownership decision is unresolved). P2's BLAKE3-embed, P3 (live `/ws/field` surfacing — the bridge is **not** wired into the running server yet), and P4 (multi-modality) remain future work. **No accuracy is claimed** (§0 / §6) — P1 is tested plumbing + a safe privacy mapping. +**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`). --- diff --git a/docs/user-guide.md b/docs/user-guide.md index fc62b728..cf71df73 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -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 (P3–P5) 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) diff --git a/v2/Cargo.lock b/v2/Cargo.lock index ed0a4a6c..75d85a5f 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -11142,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", diff --git a/v2/crates/wifi-densepose-rufield/src/lib.rs b/v2/crates/wifi-densepose-rufield/src/lib.rs index 6beaa0b5..5bbd7a37 100644 --- a/v2/crates/wifi-densepose-rufield/src/lib.rs +++ b/v2/crates/wifi-densepose-rufield/src/lib.rs @@ -80,6 +80,44 @@ pub use snapshot::{ // Re-export the rufield surface a bridge consumer needs, so callers depend on // one crate. -pub use rufield_core::{FieldEvent, Modality, PrivacyClass}; +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 +} diff --git a/v2/crates/wifi-densepose-sensing-server/Cargo.toml b/v2/crates/wifi-densepose-sensing-server/Cargo.toml index e95b411f..110aca54 100644 --- a/v2/crates/wifi-densepose-sensing-server/Cargo.toml +++ b/v2/crates/wifi-densepose-sensing-server/Cargo.toml @@ -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). diff --git a/v2/crates/wifi-densepose-sensing-server/src/lib.rs b/v2/crates/wifi-densepose-sensing-server/src/lib.rs index a6c91ab8..65d509f7 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/lib.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/lib.rs @@ -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; diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index 93376acd..de16a72c 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -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>` 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>; `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) diff --git a/v2/crates/wifi-densepose-sensing-server/src/rufield_surface.rs b/v2/crates/wifi-densepose-sensing-server/src/rufield_surface.rs new file mode 100644 index 00000000..26c614fe --- /dev/null +++ b/v2/crates/wifi-densepose-sensing-server/src/rufield_surface.rs @@ -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, + /// Broadcast topic for `/ws/field` (JSON-serialized `FieldEvent`s). + tx: broadcast::Sender, + /// 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 { + self.tx.subscribe() + } + + /// The most recent surfaced events, oldest→newest. + #[must_use] + pub fn recent(&self) -> Vec { + 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, P3–P5) are never + /// placed on the network surface; only egress-safe P1/P2 events leave. + pub fn emit(&mut self, snap: &SensingSnapshot) -> Option { + // 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, + 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>; + +/// `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) -> Json { + 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) -> 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) { + // 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); + } +} diff --git a/v2/crates/wifi-densepose-sensing-server/tests/rufield_surface_test.rs b/v2/crates/wifi-densepose-sensing-server/tests/rufield_surface_test.rs new file mode 100644 index 00000000..e4f10ca2 --- /dev/null +++ b/v2/crates/wifi-densepose-sensing-server/tests/rufield_surface_test.rs @@ -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 { + 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)); + } +}