The audit that produced these had every verifier agent die, so none were
confirmed. Checked each against the running system rather than guessing.
1. COOKIE Secure FLAG — REAL, fixed. The Cloudflare Tunnel runs OFF this box
(observed source 10.30.20.67, 155 requests in the journal) and uvicorn
only honours X-Forwarded-* from --forwarded-allow-ips, default 127.0.0.1.
Proven by hitting the LAN IP with X-Forwarded-Proto: https and watching
Secure vanish from Set-Cookie. Every internet visitor's session cookie
was going out without it.
Fixed in the unit drop-in with --proxy-headers and an allow-list scoped
to the tunnel host — NOT "*", because trusting that header from anywhere
would let a LAN client forge the IP the per-IP limiters key on. Verified
both directions: trusted source + header gets Secure, plain LAN http
correctly does not, and a spoof from an untrusted host is ignored.
2. DOUBLE GUEST ON REMOUNT — REAL but narrow, left alone. The guestAttempted
ref already covers StrictMode's double-effect (refs survive it). The only
hole is unmounting during the in-flight request, which needs navigating
away and back inside ~200ms and costs one unused row. Not worth
complicating the open door's happy path for.
3. SILENT REDIRECT WHEN RATE-LIMITED — REAL, fixed. A visitor whose guest
provisioning was refused got bounced to /enter with no explanation — and
at 5/hour/IP a household or cafe behind one NAT reaches that easily. The
failure reason (the backend's own in-fiction line) now rides along in
router state and /enter shows it, so nobody is silently handed a login
form they never asked for.
4. RATE LIMITER KEYS NEVER EVICTED — REAL, fixed. defaultdict entries
survived forever even once their hit list emptied. The open door made
this materially worse: every visitor is now a real account, so every
visitor permanently added a key across eleven limiter instances. Added an
opportunistic sweep every 512 admitted calls — no background task, cost
lands on whoever generates the load. Three tests; verified they catch it
by disabling the sweep and watching one fail.
5. SUMMON RACE vs TELEMETRY — REAL, fixed. Nothing serialised summoning.
_handle_anomaly checks `state.entity is None` then awaits a summon
containing a multi-second LLM mint, and the ESP32's HTTP ingestion path
calls _handle_anomaly on the SAME SeanceState — which is the entire point
of the device integration. Both could pass the check: two entities
minted, two essence credits, two item rolls, state.entity clobbered by
whichever finished last. Now guarded by a per-session asyncio.Lock.
6. LEGACY ENTITIES STUCK AT DEFAULT TRAITS — mechanism REAL, zero rows
affected here. The ALTER defaults traits to '{}' with no backfill and
roll_traits only runs at mint, so a pre-migration spirit would read 0.5
for everything — making `trust` always correct and `cross_over`
unreachable. This install has 0 such rows. Added a signature-seeded
backfill anyway, guarded to empty-traits rows so it can never touch a
spirit that already has a real nature.
(A seventh claim from the same batch — that iOS EMF is silently dead — was
refuted earlier and deliberately left untouched.)
34 targeted tests pass; deployed and verified live.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
All five agents died mid-flight (three on session limits, two on 529s), but
their worktrees held real work — 17 files. Salvaged everything, wrote the
missing pieces, and finished the integration by hand.
PROFILES + RANK
User gains display_name, bio, gender, avatar_form, avatar_hue and
profile_public — all nullable, so every existing row including the guest
`wanderer-` accounts stays valid with no backfill. The avatar is procedural
(a GhostForm plus a hue, drawn by the same GhostGlyph that renders
entities): no uploads means no moderation surface, no EXIF and no blob
storage, and an `avatar_url` still slots in later without changing anything.
rank.py converts encounters, essence and favor into one "standing" currency
and maps it onto six one-word titles. An encounter is worth ten points to
ten essence's one, because contact is what the app is about — a seeker who
only buys unlocks climbs very slowly. Negative essence and favor floor at
zero rather than subtracting, so a bad judgment can never demote you: rank
is a record of what you have done. Level 1 costs exactly one encounter, so a
new hunter sees the bar move after their first séance.
Privacy invariants, verified live rather than assumed:
- `email` is returned by GET /api/profile/me and by nothing else. Confirmed
against the running server: zero occurrences in both public payloads.
- A hidden profile 404s rather than 403s — confirming the account exists
would leak exactly what hiding it was meant to prevent.
WHISPERS BETWEEN HUNTERS
Plain text, no attachments, no editing. Guests can RECEIVE but not send:
that gives registering a felt purpose beyond keeping a codex, and closes the
obvious spam vector since guest accounts are free and automatic. Verified
live: alice→bob delivers, a guest send returns 403, and a third party's
conversation list comes back empty — no cross-user leak.
Message bodies are rendered as text nodes, never as HTML, and wrap with
overflow-wrap:anywhere so a long unbroken string can't blow out the layout.
THE ENCOUNTER RECORD
The Codex already knew all of this — Entity.discovered_by has always been
recorded and every contact was already an entity_sightings row. Nobody ever
showed it. Now an entity page names its summoner and lists every hunter who
has met it. Hunters who opted out of a public profile are still COUNTED but
not linkable: an anonymous contact is still a contact, so a spirit's history
stays honest without exposing anyone.
Live on production data: Mabel Crump, discovered by Charly, 1 encounter;
Charly ranks channeler (level 2) from 5 real sightings — all computed from
data that was already sitting there.
385 frontend tests pass; i18n parity holds across both languages.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A new séance mode. The seeker opens their camera, presses "let it look",
and the entity speaks about what is ACTUALLY in the room — the configured
chat model (minicpm-v4.5:8b) is vision-capable, so this is real perception,
not invented description. Same principle as every other channel here: real
measurement first, interpretation second.
Verified live end-to-end through the real WebSocket: given a synthetic room
(pale doorway, red flame on dark boards), "Bessie L. Carter" reported the
gray rectangle and red square on a dark surface with faint shadows, then
misread it as her pen feeling heavy the night before Mr. Edgerton's birdseed
arrived. Accuracy followed by wrongness, which is the whole effect.
Privacy is the load-bearing design constraint, not a footnote:
- "Camera open" and "the entity saw something" are deliberately separate
states. Opening the lens transmits NOTHING; only an explicit press sends
one still. There is no timer and no background capture path.
- Frames are downscaled to 768px and JPEG-compressed client-side, then
passed to the model and dropped. Never written to disk, never logged,
never attached to an event row — only the resulting utterance is stored,
exactly like any other thing a spirit says.
- The prompt forbids describing faces or guessing anyone's identity, age or
appearance; a person present is spoken of only as a presence.
- A closed lens is covered by an opaque veil in the UI, so there is never
ambiguity about whether the camera is live.
Robustness:
- CameraEye carries the same generation guard the EVP listener needed:
closing during the permission prompt releases the late-arriving stream
instead of letting the camera go live after teardown.
- Failures are classified (denied / insecure / absent / busy / unknown)
rather than always blaming the seeker for a refusal.
- Scrying is the heaviest request this app makes of a CPU-only Ollama box,
so it gets the tightest limiter of any channel (4/min/user, 8/min/IP).
- Frames are size-capped BEFORE reaching the queue, and a vision failure
emits an error frame instead of killing the socket — both covered by
tests asserting the model was never called.
10 new frontend tests, 5 new backend tests. 385 frontend + backend suites
pass; i18n parity holds across both languages.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The worst bug of the session. tests/conftest.py built its engine from
settings.database_url — the live database — and an autouse fixture calls
drop_all() before EVERY test. So every backend run silently annihilated the
real install: accounts, discovered spirits, Ghost Logs, devices, all of it.
Found it because /sitemap.xml listed zero entities minutes after I had
watched live séances mint real ones.
Tests now use TEST_DATABASE_URL, or `<configured-db>_test` derived from it,
and refuse to start at all if that ever resolves back to the production URL
— this box both serves the app and holds the repo, so "don't run tests in
prod" is not a workable guard.
Proven: inserted a canary row into production, ran 50 tests, canary
survived. Before this it would have been dropped.
Also in this commit:
SEO (routes/seo.py, lib/pageMeta.ts)
- Live /sitemap.xml generated from real entity rows, and /robots.txt, both
registered BEFORE the SPA catch-all or they'd be served index.html.
Crawlers are disallowed from /seance specifically because the open door
provisions a guest on arrival — a crawler would fill the users table with
wanderers who never existed.
- Per-route <title>, description, canonical and JSON-LD. The Codex is the
indexable asset here (every spirit is unique long-form prose) and all of
it previously shared one static title, so entities competed with each
other instead of ranking. Entities are marked up as fictional Persons so
a rich result can never imply a record of a real dead human.
- public_base_url setting: absolute URLs for crawlers can't be derived from
the request, since behind the tunnel the app only sees an internal host.
Camera channel, first half (lib/camera.ts, llm scry path)
- OllamaClient.generate() now accepts `images`; the configured chat model
(minicpm-v4.5:8b) is vision-capable, so the entity can speak about what
the seeker's camera actually shows. Verified against a synthetic room
image: it named the pale column and the small red cube, then misread them
as oak in a farmhouse parlor — real perception, in character.
- Frames are captured only on an explicit act, downscaled to 768px and
JPEG-compressed, never stored, and the prompt forbids describing faces or
guessing identity. CameraEye carries the same generation guard as the EVP
listener so closing during the permission prompt can't leave the camera
live after teardown.
338 backend tests pass; 375 frontend; i18n parity holds.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The séance tells guests "claim a name to keep your codex". It was a lie.
register() unconditionally created a brand-new User row with a fresh UUID,
so a wanderer's essence, discovered entities, sightings, ritual/judgment
history and Ghost Log — every one of them foreign-keyed to the guest's
user_id — were silently orphaned the moment they registered. Now that
every visitor starts as a guest, that hit essentially everyone who ever
signed up.
A wanderer hitting /register now renames that same row in place, keeping
all relationships intact. The existing AuthSession stays valid (same
user_id), so claiming a name doesn't even log you out.
Scoped deliberately to wanderers. My first attempt rejected ANY
authenticated caller with a 409, which broke registering a second account
while logged in — a legitimate flow (shared computer, alt account) that
tests/test_device.py::test_device_feed_only_broadcasts_to_the_owning_user
caught immediately: its second register 409'd, its login then failed, and
"user B's" device got paired to user A, silently defeating a
cross-user-isolation assertion. A named caller's cookie is now ignored and
the normal create-a-new-row path runs.
Adds get_optional_current_user (None instead of 401) for endpoints that
behave differently for anonymous vs. authenticated callers but must stay
reachable without auth.
This was one of eight findings from an adversarial audit whose verifier
agents all died on session limits, so nothing was machine-verified — I
confirmed this one by reading the code and then proving it end-to-end.
The other seven remain unchecked.
331 backend tests pass. Verified live: guest 23846555 -> livehunter1, same
id, same session still valid.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
app/haunts.py merges two free, keyless, properly-licensed APIs rather than
scraping: Wikipedia geosearch+extracts (CC BY-SA) and OSM Overpass (ODbL).
Every haunt carries its source and a link back.
The Wikipedia-article requirement doubles as a notability gate: no article,
no pin. That keeps the map to documented history rather than rumour and
makes every entry independently checkable.
Deliberately excluded — recent crimes at residential addresses. People live
in those houses now and get harassed; the families are usually still alive.
So crime-framed entries must clear HISTORICAL_CUTOFF_YEAR, anything
residential is blurred to ~250m (street, never a door number), and an entry
that reads as a crime with no legible date is excluded rather than assumed
old. Battlefields, plague pits, gaols, executions and famous historical
cases are unaffected.
Privacy: the seeker's exact coordinate never leaves the process. Queries
snap to a ~1km grid before going upstream — far finer than the search
radius, coarse enough that Wikipedia and OSM never learn where anyone is,
and it makes the cache shared across a neighbourhood.
Two bugs found and fixed by testing against the live services rather than
assuming:
- Overpass answered 504. The naive query built 28 separate `around:`
searches (14 kinds x 2 element types); regrouping to one regex-alternated
clause per tag key with `nwr` cuts it to four.
- The flat keyword filter put "Fenchurch Street railway station" on the map
because its article mentions a fire. Hints are now split into strong
(qualify alone) and weak (need two), verified against live results.
Known limitation, honestly: all three public Overpass mirrors currently
time out or return empty from this host, so the map is Wikipedia-only in
practice right now. fetch_overpass already returns [] on any failure, so
this degrades quietly and self-heals if a mirror recovers.
Also adds the hunter-profiles contract spec.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Dead code: removed an unused `settings` import (main.py), an unused
`RITUAL_HOLD_MS` import (RitualPanel), and an unused `beforeEach`
(SigilDesigner test). The `_refs` keep-alive in sdr.ts is deliberate
(holds hardware-pass constants) and stays; the orphaned .evp-scope /
.radio-waterfall CSS was already removed in an earlier lint pass.
Duplicate: coldSpot.ts and baseline.ts each carried the same time-aware
EMA alpha formula. Extracted it as baseline.emaAlpha(dtMs, tauMs) and
pointed both at it. coldSpot's pure-function core is deliberately NOT
merged into the stateful ThresholdBaseline class — different contract
(immutable-state-threaded vs internal-threshold), and forcing them
together would be an overhaul that risks the tested cold-spot logic.
Determinism fix: test_familiar_presence_answers_again_on_a_known_channel
pinned RETURN_CHANCE=1.0 but not the sky. Since the astronomy wiring made
the real return chance RETURN_CHANCE*(1 - veil_thinness*PULL), and
veil_thinness reads the *actual current moon phase*, a full-moon test run
dragged the effective chance to ~0.55 and the test failed ~45% of the
time. Now also pins VEIL_THINNESS_PULL=0 to isolate re-contact from the
veil influence (which has its own tests). Verified 12/12 consecutive
passes; it was ~7/12 before.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
GET /api/conditions surfaces what the backend already computes: moon
phase, veil thinness, geomagnetic state — junk lon degrades to moon-only
instead of a 422, missing NOAA data means fewer lines, never an error.
VeilConditions renders it in the séance side column, polling every 10 min.
ModeHint: one in-fiction line per mode after 15s of an unused sensor,
dismissed forever via localStorage. Error copy in evp/radio now
detect-and-redirects (mic denied -> 'the board needs no ear'; no WebUSB
-> try EVP) instead of dead-ending.
No geolocation prompt from the conditions strip — asking for location
from a passive readout would be hostile; ?lon= stays supported for
callers that have it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# Conflicts:
# frontend/src/pages/SeancePage.tsx
Workstream B of the usability wave (#2 hints, #3 conditions, #4 errors):
- GET /api/conditions (new backend/app/routes/conditions.py): composes
celestial veil_thinness with the cached NOAA Kp reading; lenient lon
parsing (junk degrades to moon-only, never 422); geomagnetic may be
null on a cold cache. Route tests stub the cache — no live NOAA calls.
- VeilConditions strip in the séance side column: moon glyph + phase,
% lit, veil-thinness phrase, Kp line only when data exists. Polls
every 10 min; renders nothing while loading; no error state.
- ModeHint: per-mode in-fiction one-liner after ~15s idle, suppressed
once the mode's sensor runs this session, dismissal persisted in
localStorage (qm_hint_<mode>). One mount line per panel.
- Error copy upgraded to detect-and-redirect: mic denied points at site
settings and the ouija board/wire; WebUSB-unsupported suggests EVP.
- i18n en/es parity for every new string; coverage-check template
domains extended for the new template-key call sites.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
manifest.webmanifest referencing the icon assets that actually exist
(sizes verified with file, not asserted), standalone display, no service
worker — offline séance is meaningless and SW cache bugs are not worth it.
GET /api/seances/recent: last 12 sessions in exactly three queries
(sessions+entity join, one GROUP BY for counts, one window-function query
for up to 3 echoes per session). Echoes filter on utterance payload kinds
because DB event kinds carry no greeting/manifest — the agent verified
where _speak actually writes rather than trusting the spec's phrasing.
/log page: entity glyphs, relative in-fiction timestamps, counts, echo
lines in transcript style, gated like the séance, 390px-safe.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Usability wave Workstream C (#5, #7):
- manifest.webmanifest with existing 192/512/180 icons, linked in
index.html with theme-color aligned to #07070d; no service worker.
- GET /api/seances/recent: last 12 of the seeker's own séances with
entity, per-kind event counts (one GROUP BY) and up to 3 spirit
echoes (one windowed query) — no per-session N+1.
- /log Ghost Log page: cards with GhostGlyph, relative in-fiction
timestamps, counts and echo lines; LOG link in the séance topbar;
en/es i18n parity.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
POST /auth/guest mints a real user row (wanderer-<4 hex>, collision
retry, unusable random password) and issues the normal session cookie,
per-IP rate limited at 5/hour. EnterPage gains the guest action;
the séance shows a dismissible claim-a-name note for wanderer- users.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
MOON INFLUENCE ON SUMMONING
Astronomy previously only decided whether a channel's familiar spirit
returned. It now shapes *who comes through*:
- Rarity skews with real moon illumination. At full moon the rare and
mythic weights roughly triple while common recedes, so a mythic
summoning becomes a reason to go out on the right night rather than a
flat lottery. Deliberately a skew and never a gate — every tier stays
reachable on every night, because someone who can only play midweek
should not be locked out of the good spirits.
- Hidden traits take a small moonlit nudge: power and volatility rise,
alignment drifts slightly darker. Capped at 0.12 and clamped to [0,1],
so a full moon intensifies what a spirit already is instead of
rewriting it. Deceptiveness is untouched — whether a spirit lies is its
own nature, not the sky's doing.
- The mint prompt is told the phase, and explicitly told the entity must
never mention or seem aware of it. It shapes who they are, not their
dialogue; a ghost remarking on the moonlight would break the illusion
instantly.
Tests assert the outcomes shift in practice (mythic rate over 4000 draws,
rare-tier counts across 300 fallback profiles), not merely that the code
runs. test_mint_prompt_never_receives_traits now allows `sky` while still
forbidding `traits`: moon phase is public, observable state anyone can look
up, hidden ground truth is not.
GEOMAGNETIC (app/geomagnetic.py)
Real NOAA SWPC planetary K-index, verified against the live endpoint —
which caught a real bug: I had written the parser against an
array-of-arrays shape, and the actual feed serves a list of objects
(`estimated_kp` float, `kp_index` int, `kp` a display string with a letter
suffix). Fixed, and the tests now use the real captured shape. Cached,
never blocking, and a failed refresh keeps serving the last real value —
an hour-old genuine measurement beats nothing, and geomagnetic conditions
do not change fast enough for that to mislead.
MAGNETOMETER WIRED
MagnetometerListener existed but was never connected. The EMF panel now
runs it alongside the motion listener where the hardware exists, so the
"EMF meter" measures actual magnetic field in µT rather than only
inferring disturbance from movement. Additive: the motion path is
untouched and remains the only option on iOS. Its field jitter also feeds
the entropy pool.
DEAD CODE
Removed .evp-scope and .radio-waterfall, orphaned when both panels moved to
the shared SpectrumScope. Audited every other flagged export first and left
them alone — they are used internally, and "not imported elsewhere" is not
the same as dead.
Adds docs/CHANNELS.md recording what each channel measures and, honestly,
what has actually been verified against hardware versus only written
carefully.
311 backend + 355 frontend tests pass; i18n parity gate passes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three changes that together replace "deterministic hash decides everything"
with "the physical world genuinely participates".
PHYSICAL ENTROPY (app/entropy.py, lib/entropy.ts)
Contact was a database lookup: signature_from_anomalies() hashed the
anomaly pattern, so identical conditions always produced an identical
spirit. Now the client harvests real thermal/acoustic/RF noise from the
microphone and receiver noise floors — Von Neumann debiased, SHA-256
conditioned — and contributes it to every summon.
The client is untrusted by construction. A contribution is never a seed:
every draw is HMAC-SHA256(fresh server secret, client bytes || context).
Because fresh CSPRNG server bytes are always present, the output is
unpredictable and uniform no matter what the client sends — all-zeros, a
replayed value, or one chosen adversarially. The room can only ever ADD
unpredictability, never steer the result. Tests assert this directly:
400 replays of one contribution stay uniformly distributed.
A signature now identifies a *channel*, not a spirit. Whether the familiar
presence answers or something else picks up is a real draw
(RETURN_CHANCE). The Codex stays collectable; it is just no longer
guaranteed. test_same_signature_recontacts_same_entity became two tests —
one pinning the probability to prove re-contact works, one pinning it to
zero to prove something else can answer — because at 0.72 the original
would have passed ~72% of the time, which is worse than failing.
REAL ASTRONOMY (app/celestial.py)
Moon phase from the standard mean-synodic approximation, and true solar
midnight from the seeker's own longitude — the real witching hour for
where they are standing, not clock 3am. Computed, never fetched: an API
that can fail would mean the veil silently changes behaviour during
someone else's outage. Validated against published ephemeris dates (2024
full moons, 2025 new moons) rather than against its own output. A thinner
veil erodes the familiar presence's claim on a channel, so a full moon at
solar midnight makes strangers likelier. Only longitude is kept, never a
full coordinate; a denied location degrades to moon-only, silently.
GENERATION FROM NOTHING (SpiritService.manifest)
Not chat_stream with an empty question. The prompt contains no seeker
input at all — only measured room state, rendered as measurements
("deviation above the floor: 31.4") rather than interpretations
("terrifying spike"), so the horror comes from the entity instead of from
us. And the Ollama `seed` is derived from the physical entropy harvested
in that room, which fixes the token-sampling path: the room genuinely
selects the words. Change the noise, get different speech. Two rooms
cannot produce the same utterance.
Rendered as an intrusion rather than a reply — violet edge, full opacity
against the faded ambient murmurs, brief blur-in. The unsettling part is
that it is perfectly clear and completely unbidden.
Also fixes a hang I introduced: the two new summon tests consumed the
shared module-level per-IP budget, so test_summon_rate_limited_* blocked
forever on an entity frame that had been rate-limited away. They now scope
their own limiters.
264 backend + 355 frontend tests pass; i18n parity gate passes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Unbounded essence/item farming: every other reward trigger (summon,
question, fragment) had both a per-user and per-IP limiter, but
ritual_start and judgment had none at all — and judgment has no
"already resolved" state either. A scripted client could replay
{"type":"judgment","verdict":"cross_over"} in a tight loop and mint
CROSS_OVER_ESSENCE (25) plus a 20% item roll every iteration, forever.
Same for ritual_start -> 4x ritual_step. Added ritual/judgment limiters
in both flavors, matching the existing pattern.
WS session crash: _handle_question did `if state.entity is None:
await _handle_summon(state)` then `assert state.entity is not None`.
_handle_summon returns early *without* setting state.entity when the
seeker is rate-limited, so the assert fired unhandled — and the message
loop only catches WebSocketDisconnect, so it killed the whole connection.
Reachable with no malice: click summon a few times impatiently, then ask a
question. Now returns cleanly (the rate_limited frame was already sent).
Essence double-spend: purchase_unlock() deliberately uses SELECT ... FOR
UPDATE to serialize concurrent purchases, but the three credit_essence
call sites in ws.py did an unlocked db.get() read-modify-write. An
unlocked read doesn't block on a row lock, so a reward computed from a
pre-purchase balance could be written after the purchase committed,
silently reverting the deduction — user keeps the unlock and the essence.
All three now lock the row the same way.
Entity mint collision: _summon does a racy check-then-insert against
Entity.signature and Entity.name, both DB-unique, with no IntegrityError
handling — a concurrent mint of the same signature crashed the session.
Forceable by a user with two accounts (anomaly frequency/magnitude are
client-controlled), and plausible without malice in wire mode, where
sample_network() reads host-wide /proc/net/dev counters so two idle
sessions genuinely measure the same traffic. Now retries once, which
re-runs the match against whatever the winner committed.
Also added a unique constraint on unlocks(user_id, unlock_key) as
defense-in-depth, with an idempotent catalog-guarded migration.
221 backend tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The old prompt asked for "an evocative spirit name" and "2-3 sentences of
lore" — abstract enough that the LLM defaulted to poetic ghost-vagueness
(static, voids, ancient sorrow) rather than anything resembling a specific
dead person. Horror fiction's actual technique for selling "this was once
a real human" is the opposite: mundane, unglamorous specificity (an
ordinary job, an approximate age/decade, one small habit or possession)
placed right up against the uncanny.
Refined MINT_SYSTEM and MINT_PROMPT to require the model silently work out
a name, occupation, age/era of death, one small mundane detail, and a
plain (not epic) unfinished-business hook before writing persona/quotes —
and to make at least one quote a mundane human fragment rather than
cosmic riddle-speak. JSON schema and mint_prompt()'s signature are
unchanged, so nothing downstream needs updating — this is a pure prompt
refinement. 20/20 prompt/entity tests pass, 221/221 full suite.
Mic: confirmed via its pinout (L/R, WS, SCK, SD, VCC, GND) that the third
target module is a standard I2S digital MEMS mic (INMP441-family). Added
mems_mic.c/h using ESP-IDF's current driver/i2s_std.h API — reports RMS
audio level in dBFS as sensor_type "evp" rather than attempting on-device
voice-band FFT (the browser EVP mode's approach); the backend's existing
statistical anomaly detector handles spike detection from the raw level,
same as it already does for temperature/pressure/presence.
Also fixes a real gap Workstream B's report flagged: User.essence (a live
model column used throughout merged code — /auth/me, inventory purchases,
summon trickle) had no migration line in main.py's lifespan, which would
have broken on the actual production Postgres database.
Implements Workstream B of the character-depth-ghost-log spec:
ritual_start/ritual_step/judgment WS handlers, the pure judgment.py
logic module, and the User.favor / Entity.at_peace columns + migration.
- app/judgment.py: pure ritual success roll (base 65%, floored at 30%,
driven by an entity's power+deceptiveness difficulty), the "stuck
spirit" cross_over rule (alignment >= 0.5 and volatility > 0.6, ~20%
of entities), judgment correctness/favor-delta/essence-delta/
consequence resolution for all four verdicts, favor clamping, the
favor-to-trait-roll bias applied at mint time, and tell-line
generation (opaque behavioral flavor text, never a raw stat).
- app/ws.py: wires ritual_start/ritual_step/judgment frames, emits
ritual_complete/tell/judgment_result/item_drop per the spec's
Contract; traits are added to serialize_entity for internal
server-side use but stripped from the outbound `entity` frame via a
new _public_entity helper so hidden ground truth never reaches the
client outside ritual_complete; _summon excludes at-peace entities
from signature re-contact and mints a fresh (salted-signature) entity
instead; new entities' traits are nudged by the discovering user's
favor before being persisted.
- models/user.py, models/entity.py, main.py: User.favor and
Entity.at_peace columns plus their idempotent ADD COLUMN IF NOT
EXISTS migration lines in lifespan, alongside the existing ones.
- tests/test_judgment.py, tests/test_ws_ritual_judgment.py: 56 new
tests covering the ritual/judgment correctness matrix, favor
clamping/bias, essence crediting, at_peace persistence + re-contact,
and the entity-frame trait leak guard.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Both G and K were built independently against the paper contract and
correctly left this connection point for the integrator (documented in
both their reports). Calls process_device_reading_for_summon from
_process_reading, skipping array-valued readings (no defined single
scalar baseline for those). Adds an end-to-end integration test that
connects a real /ws/session, posts device telemetry through it, and
confirms an anomalous reading actually reaches the live session as an
anomaly_ack — proving the two independently-built pieces genuinely
connect, not just that each compiles.
165/165 backend tests pass.
Resolved conflict in main.py: combined both workstreams' router imports
and registrations (device_router from G, inventory_router from C).
143/143 backend tests pass after cleaning stray pollution from an
earlier parallel workstream run against the shared test DB.
Implements the backend REST surface and WS wiring for
docs/superpowers/specs/2026-07-23-character-depth-ghost-log-design.md's
Workstream C:
- New models: UnlockRecord (unlocks), InventoryItem (inventory_items),
Sigil (sigils) — brand-new tables, picked up by main.py's existing
create_all.
- New app/inventory.py: unlock price table, item drop table/odds,
essence economy constants, sigil design validation, and an atomic
(row-locked) purchase_unlock() that guards against double-spend races.
- New app/routes/inventory.py: GET unlocks/items/sigils, POST sigils
(validates the placeholder {points, rune} shape, points capped at 12),
POST unlocks/{unlock_key} (402 on insufficient essence, 404 on unknown
key, idempotent re-buy).
- GET /auth/me now includes unlocks: list[str] and essence: int.
- ws.py: wires essence trickle + item_drop rolls into the one trigger
point that exists in this worktree today (_handle_summon, covering
every successful summon plus high-rarity summons); the other two
contract trigger points (correct judgment, successful ritual) belong
to Workstream B's not-yet-landed ritual/judgment WS handlers, which
should call app.inventory's same helpers once they land.
- User.essence: int added (Workstream B owns this column per the spec;
added here per orchestrator instruction so this workstream is
independently testable — merge controller reconciles the duplicate
edit).
Also fast-forwarded this worktree's branch onto master (it had fallen
behind several commits) so the files this workstream depends on
(shop.py, ws.py, entities.py, etc.) were actually present to build
against.
Tests: 109 passed (drop-roll statistical sanity with seeded RNG,
inventory/sigil CRUD, purchase success/insufficient-funds/idempotency/
unknown-key paths, /auth/me shape, ws summon-trickle and item-drop
wiring).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
Implements the backend half of the ESP32-P4 sensor node spec's pairing,
ingestion, and live-broadcast contract:
- New Device model (backend/app/models/device.py): id, user_id FK, name,
token_hash (unique+indexed), created_at, last_seen_at. Reuses
generate_session_token()/hash_token() from auth_session.py verbatim for
the one-time raw pairing token / stored hash.
- POST /api/device, GET /api/device (session-cookie authenticated REST
pairing endpoints) and POST /api/device/telemetry (device bearer-token
authenticated ingestion, per-device rate limited, 16KB body cap, 64
reading cap, strict shape validation — never a 500 on garbage input) in
backend/app/routes/device.py.
- /ws/device-feed live dashboard WS (qm_session cookie authenticated),
fanning out ingested readings to the owning user's connected dashboard
sockets via an in-process dict[user_id, connections] registry, each with
its own send-queue + single sender task (mirrors app.ws's
SeanceState/_sender convention).
- last_seen_at updates on every successful ingestion.
- _process_reading(device, reading) left as an explicit no-op handoff point
for Workstream K's summon-pipeline integration.
Backend suite: 102 passed.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Entity.traits (alignment/power/volatility/deceptiveness, 0.0-1.0 each) is
rolled once at mint time in entities.py, seeded from the entity's
signature via random.Random(f"traits:{signature}") — a separate rng
namespace from normalize_profile's existing "norm:" rng, and never fed
into mint_prompt, so persona text stays fully decoupled from ground
truth. normalize_profile now includes "traits" in its returned dict;
fallback_profile inherits it for free since it already delegates to
normalize_profile.
Adds the new JSONB column to the Entity model (default {}) and the
idempotent `ALTER TABLE entities ADD COLUMN IF NOT EXISTS traits ...`
migration line to main.py's lifespan, per the live-Postgres migration
convention this spec introduces (no Alembic in this repo).
Tests cover trait value ranges, signature-determinism, and
persona/trait independence (same persona template pairs with a wide
spread of alignment rolls across signatures), plus a regression check
that mint_prompt's signature never grows a traits parameter.
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.
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.
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
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.