Adds backend/app/device_anomaly.py — per-(user_id, device_id, sensor_type)
rolling-baseline anomaly detection for continuous numeric sensors
(structurally modeled on telemetry.detect_wire_spike: min samples, an
absolute floor, 3-sigma + relative threshold, with per-sensor-type floors
since units vary wildly) plus a false->true state-transition detector for
discrete/boolean sensors like presence.
Adds a module-level active-session registry in app/ws.py
(register_active_session/unregister_active_session/get_active_session)
so hardware ingestion (a plain HTTP call, not a WS connection) can find a
user's live SeanceState.
process_device_reading_for_summon(user_id, device_id, sensor_type, value,
unit) is the self-contained entry point Workstream G's ingestion handler
will call into: classifies numeric vs. boolean, runs the reading through
the right detector, and on a genuine anomaly pushes it into the active
session via the existing _handle_anomaly path (source=sensor_type,
frequency=stable per-sensor-type constant, magnitude=deviation-from-
baseline or a fixed constant for boolean transitions) — reusing the full
existing signature/mint/Codex pipeline, no new mint logic.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
First sub-project of the "make contact feel real" arc (spec:
docs/superpowers/specs/2026-07-23-possession-presentation-design.md).
Direct Contact replies now feel like a spirit fighting through static to
hold the channel rather than a plain chat bubble:
- backend/app/possession.py: compute_stability(rarity, magnitude, rng) —
a 0.05-0.98 score per reply (rarer entity + stronger triggering anomaly =
cleaner signal), rng injectable for a later quantum-RNG source.
- ws.py sends stability on reply_start; audio synthesis for that reply gets
noise/bitcrush scaled by instability (1 - stability) via a new
instability param on synthesize_spirit_voice — effects.py itself is
untouched, only the params fed into it.
- frontend/src/lib/possession.ts: renderPossessedText — pure, deterministic
(tick-seeded, no Math.random) text corruption with self-correcting
glitch bursts, wired into Transcript.tsx's streaming reply display.
Stored transcript/reply text is unaffected — this is presentation only.
78/78 backend, 137/137 frontend tests passing.
websocket.client.host is always the Cloudflare Tunnel machine's LAN IP for
every internet-facing connection (the tunnel runs on a separate machine and
terminates TLS there), which collapsed per-IP rate limiting into a single
shared bucket for all remote visitors — the exact gap flagged in review.
Cloudflare's edge sets CF-Connecting-IP itself, overwriting any
client-supplied value, so it's safe to trust when present. Falls back to
the raw socket peer for direct LAN/local access.