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>
Third sub-project: physical hardware (ESP32-P4, presence + BME280 env
sensors, experimental RTL-SDR USB-host module) pairs with a user's account
and streams telemetry that feeds the SAME anomaly/summon pipeline the
browser-based modes already use — not a passive dashboard, the actual
business model (selling devices that summon spirits). Defines device
pairing/auth (reusing the existing session-token hash convention),
a generic/extensible sensor-reading shape, and the live-broadcast +
anomaly-detection contract split across 5 workstreams (G/K backend,
H frontend, I/J firmware).
Renaming: "Armory" read too militaristic for a séance app. Landed on "The
Reliquary" (not "The Threshold" — that name was already taken by the
landing-page back-link).
Spec addendum: added a visible essence currency (earned per summon, spent
on unlocks — distinct from the hidden favor score) and a fourth judgment
verdict, cross_over, for compassionately helping a genuinely benevolent
"stuck" spirit move on rather than just trusting or banishing it. Updates
workstreams B/C/E/F accordingly before any of them are dispatched.
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.
First sub-project of the "make contact feel real" arc (candle rituals,
quantum RNG, tuning, progression, escalation to follow as separate specs).
Defines the stability-score contract shared between the backend audio
degradation and frontend text-glitch halves so they can build in parallel.
The gap-g-readme merge commit (57a8914) staged this fix but never
re-staged it after editing, so the merge landed with the pre-fix content —
the working tree had the correction but git didn't. No functional change,
just closing the gap between what was intended and what was committed.
Took Gap G's version (comprehensive rewrite) over Gap A's smaller README
edit, then manually stripped the 4 remaining SESSION_SECRET mentions Gap G
didn't know about (Gap A dropped that config field entirely) — README now
correctly lists only DATABASE_URL and OLLAMA_BASE_URL as required.
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.
auth_sessions rows were never deleted after expiry, only rejected
on read. Adds a background sweep (every 30 min) in the app lifespan,
plus a tested pure delete_expired_sessions() function.
Session security already comes from a cryptographically random
256-bit token (secrets.token_urlsafe) hashed before storage —
SESSION_SECRET was required config that nothing ever read.
StaticFiles defaults to check_dir=True, which raises at import time if
frontend/dist/assets is missing on restart — taking down /healthz and
/auth/* along with the frontend. Pass check_dir=False so the mount never
crashes the app, and make the SPA fallback return a clear 503 instead of
an unhandled 500 when index.html is absent.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013PphXq1s43DNRj1uWKGXof
Reordered to put a real browsable site first (React frontend served
by FastAPI, Tasks 1-2) ahead of backend-only plumbing (Ollama queue,
Piper TTS, WebSocket session channel, Tasks 3-5) that Plans 3-6 will
build spirit-mode logic on top of.
Added test_hits_expire_after_window_elapses() which uses unittest.mock.patch
to deterministically advance time and verify that expired hits are evicted from
the rolling window. This exercises the while loop in RateLimiter.allow() that
was previously untested.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013PphXq1s43DNRj1uWKGXof
httpx's cookie jar only auto-attaches Secure cookies to https:// requests.
Switching the ASGITransport client fixture's base_url from http://test to
https://test (no real socket is opened either way) makes it behave like a
browser talking to the Cloudflare-Tunnel-terminated HTTPS edge in
production, eliminating the need for manual client.cookies.set(...)
re-injection workarounds in test_auth.py.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013PphXq1s43DNRj1uWKGXof
Addresses three Important-severity review findings inherited from Task 4's
plan reference code:
- login() now sets secure=True on the session cookie (safe behind the
Cloudflare Tunnel, which terminates TLS at the edge).
- logout() looks up and deletes the matching AuthSession row before
clearing the cookie, so a leaked raw token can no longer be replayed
after logout.
- login() always performs exactly one verify_password call regardless of
whether the username exists (against a module-level dummy hash for
nonexistent users), removing the timing oracle that let unauthenticated
requests distinguish registered from unregistered usernames.
Adds two tests: nonexistent-username login rejection, and logout revoking
the session server-side. Also adjusts two cookie-propagation touch points
in test_auth.py to manually re-inject the qm_session cookie, since
httpx's cookie jar won't auto-attach a Secure cookie to the test
transport's plain http://test base_url (a real browser talking to the
HTTPS tunnel edge wouldn't have this problem).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013PphXq1s43DNRj1uWKGXof
Production code shouldn't branch on `"pytest" in sys.modules` — it's
fragile and couples db.py to test tooling. Instead, conftest.py now
builds its own dedicated NullPool engine directly from settings, used
only for the drop_all/create_all reset and the get_db override. The
production engine in app/db.py is untouched and never exercised during
tests, so this fully preserves the event-loop fix while keeping prod
code test-agnostic.
Task 3's conftest.py (per plan) reuses the module-level app.db.engine
singleton across every test. pytest-asyncio 0.24 gives each test function
its own event loop by default, and asyncpg connections are bound to the
loop they were opened on. Pooling a connection from a prior test's loop
made subsequent tests fail with "got Future attached to a different loop"
as soon as more than one DB-touching test ran in the same session.
NullPool is applied only when running under pytest (detected via
sys.modules), so production keeps normal connection pooling and only the
test suite pays the cost of a fresh connection per checkout.
- Restored .worktrees/ line that was lost when .gitignore was overwritten in commit 1736dd0
- Added backend/tests/__init__.py as a package marker (was untracked on disk)
- Test suite verified: backend tests pass (1/1)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Task 1 now scaffolds a Python venv and a systemd unit template
instead of a Dockerfile/Compose stack. Task 3 installs Postgres
directly via apt and creates dev/test databases on the same
instance. All test-run commands switched from `docker compose run`
to plain `pytest` with exported env vars.
User's Proxmox LXC CTs run apps as plain venv + systemd services,
not containers. Updated spec sections 2, 6, and 9 to reflect a
bare-metal deployment: Postgres via apt, FastAPI app via uvicorn
under a systemd unit.
Captures the v1 architecture for the self-hosted spirit-communication
web app: FastAPI + React stack, four launch modes (Spirit Radio, EVP,
Wire Ghost, Ouija/Planchette), hybrid entity/Codex system backed by a
remote CPU-only Ollama instance, and deployment via Docker Compose
behind a Cloudflare Tunnel. ESP32-P4/C6 hardware is scoped out for a
future spec.