feat: implement ADR-292/293/294/295/296 — provenance, UDP hardening, multi-node, model gates, CSI policy

ADR-292 (sensing-server): SourceState enum + pure transition (provenance.rs);
auth-error/unknown can never resolve to LiveVerified; synthetic exports
watermarked. Pose-fusion simulator starts SYNTHETIC and only shows LIVE on a
real decoded frame (#1557); sensing client no longer labels an unauthorized
status endpoint as live (#1526).
ADR-294 (sensing-server): NodeInference distinct from RoomInference (inference.rs);
deterministic freshness-weighted fuse_room; RateLimiter re-keyed to
(NodeId,EntityKind) so nodes do not starve each other (#1541); stale nodes go
unavailable not frozen-online (#1555).
ADR-293 (sensing-server): --udp-bind (default 127.0.0.1) + --udp-allow allowlist
+ fail-closed refusal of routable bind without allowlist unless --udp-insecure-lan
(udp_bind.rs); crate SECURITY.md documents the threat model and the deferred
per-device-auth step two.
ADR-295 (train): model_gates.rs — constant-output, unreachable-boundary (the
issue-1521 degenerate presence head), class-balance, baseline, and
metric-name-provenance gates.
ADR-296 (ci): scripts/csi-data-policy-check.sh (+ allowlist) and a workflow that
fails on tracked CSI-format/oversized-JSONL files; 6/6 self-tests pass.

Per-crate suites reported green by the swarm; CSI policy self-test 6/6 and JS
syntax verified here. Full workspace re-verification deferred until the
concurrent phase-1 spine build frees the target dir (disk pressure).

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_015TcKegTS7QqhWPC2L2SzaS
This commit is contained in:
Claude
2026-08-11 00:47:29 +00:00
parent 8bb55aac05
commit 34c9804002
17 changed files with 2706 additions and 53 deletions

View File

@@ -55,11 +55,15 @@ export class CsiSimulator {
this.ws = new WebSocket(url);
this.ws.binaryType = 'arraybuffer';
this.ws.onmessage = (evt) => this._handleLiveFrame(evt.data);
this.ws.onopen = () => { this.mode = 'live'; resolve(true); };
// ADR-292 (issue #1557): a socket that merely *opened* is NOT live —
// synthetic demo data keeps flowing until a real frame is decoded. We
// stay in demo mode (watermarked) on open; `_handleLiveFrame` flips to
// live only once it has parsed a verified frame.
this.ws.onopen = () => { this.socketOpen = true; resolve(true); };
this.ws.onerror = () => resolve(false);
this.ws.onclose = () => { this.mode = 'demo'; };
this.ws.onclose = () => { this.mode = 'demo'; this.verifiedFrame = false; this.socketOpen = false; };
// Timeout after 3s
setTimeout(() => { if (this.mode !== 'live') resolve(false); }, 3000);
setTimeout(() => { if (!this.socketOpen) resolve(false); }, 3000);
} catch {
resolve(false);
}
@@ -69,9 +73,12 @@ export class CsiSimulator {
disconnect() {
if (this.ws) { this.ws.close(); this.ws = null; }
this.mode = 'demo';
this.verifiedFrame = false;
this.socketOpen = false;
}
get isLive() { return this.mode === 'live'; }
/** True only once a real frame has been decoded — not merely on socket open. */
get isLive() { return this.mode === 'live' && this.verifiedFrame === true; }
/**
* Update person state from video detection (for correlated demo data).
@@ -292,6 +299,15 @@ export class CsiSimulator {
this._liveAmplitude[i] = Math.sqrt(real * real + imag * imag) / 2048;
this._livePhase[i] = Math.atan2(imag, real);
}
// ADR-292 (issue #1557): a real frame was decoded — only now is this live.
this._markVerifiedFrame();
}
/** ADR-292: promote from watermarked demo to live once a real frame lands. */
_markVerifiedFrame() {
this.verifiedFrame = true;
this.mode = 'live';
if (typeof this.onVerifiedFrame === 'function') this.onVerifiedFrame();
}
_handleJsonFrame(msg) {
@@ -311,6 +327,8 @@ export class CsiSimulator {
for (let i = 0; i < n; i++) {
this._liveAmplitude[i] = Math.abs(ampArr[i]) * scale;
}
// ADR-292 (issue #1557): a real frame carrying amplitude was decoded.
this._markVerifiedFrame();
}
// Phase from node (if available)

View File

@@ -151,12 +151,21 @@ function init() {
if (wsUrlInput) wsUrlInput.value = defaultWsUrl;
// ADR-272: exchange the stored bearer for a single-use ?ticket= before the
// upgrade — a browser cannot set an Authorization header on a WebSocket.
withWsTicket(defaultWsUrl).then(u => csiSimulator.connectLive(u)).then(ok => {
if (ok && connectWsBtn) {
// ADR-292 (issue #1557): opening the socket does NOT mean live — the
// simulator keeps producing watermarked SYNTHETIC data until a real CSI frame
// is decoded. Only the verified-frame callback promotes the label to LIVE.
csiSimulator.onVerifiedFrame = () => {
if (connectWsBtn) {
connectWsBtn.textContent = '✓ Live ESP32';
connectWsBtn.classList.add('active');
statusLabel.textContent = 'LIVE CSI';
statusDot.classList.remove('offline');
}
statusLabel.textContent = 'LIVE CSI';
statusDot.classList.remove('offline');
};
withWsTicket(defaultWsUrl).then(u => csiSimulator.connectLive(u)).then(ok => {
if (ok) {
// Socket open, but no verified frame yet — stay honest.
statusLabel.textContent = 'SYNTHETIC';
}
});

View File

@@ -304,25 +304,41 @@ class SensingService {
* hardware or simulation. Called once on WebSocket open.
*/
async _detectServerSource() {
// ADR-292 (issue #1526): an unreachable or unauthorized status endpoint is
// an *unknown* state — it must NOT collapse to "live". Prefer the canonical
// `source_state` the server now returns; on any error stay conservative
// (server-simulated) until a real frame's `source` field promotes us.
try {
const resp = await fetch('/api/v1/status');
if (resp.ok) {
const json = await resp.json();
this._applyServerSource(json.source);
this._applyServerSource(json.source, json.source_state);
} else {
// Can't reach status endpoint — assume live until first frame tells us
this._setDataSource('live');
this._setDataSource('server-simulated');
}
} catch {
this._setDataSource('live');
this._setDataSource('server-simulated');
}
}
/**
* Map a raw server source string to the UI data-source label.
* Map a raw server source string (and optional canonical ADR-292
* `source_state`) to the UI data-source label.
*/
_applyServerSource(rawSource) {
_applyServerSource(rawSource, sourceState) {
this._serverSource = rawSource;
// ADR-292: only the verified/unverified live states may show "live"; any
// synthetic/stale/disconnected state must not.
if (sourceState) {
if (sourceState === 'live_verified' || sourceState === 'live_unverified') {
this._setDataSource('live');
} else if (sourceState === 'synthetic') {
this._setDataSource('server-simulated');
} else {
this._setDataSource('server-simulated');
}
return;
}
if (rawSource === 'esp32' || rawSource === 'wifi' || rawSource === 'live') {
this._setDataSource('live');
} else if (rawSource === 'simulated' || rawSource === 'simulate') {