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>
This commit is contained in:
parent
f250149e94
commit
df617145d6
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -41,6 +41,8 @@ jobs:
|
|||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Install mosquitto + clients and start with allow_anonymous
|
||||
run: |
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ jobs:
|
|||
steps:
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Stage viewer for Pages
|
||||
run: |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ jobs:
|
|||
steps:
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Stage demos for Pages
|
||||
run: |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, P3–P5) 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));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue