Commit Graph

125 Commits

Author SHA1 Message Date
Indiana
c22b2f9c08 fix: reward guards now survive a reconnect
The three replay guards shipped earlier lived on the in-memory SeanceState,
which made them per-CONNECTION. I flagged that as an open residual at the
time: drop the socket and reconnect — or just open a second one — and the
client got a fresh empty guard and could be paid again for the same spirit.
summon_limiter bounded the rate of that, never the total.

`award_claims` is the durable form: one row per (seeker, presence,
milestone), with a UNIQUE constraint doing the actual enforcement. The claim
is a bare INSERT and losing the race raises IntegrityError, which is caught
and read as "already paid" — a check-then-insert would let two sockets both
read "unclaimed" and both pay. `crossing` is claimed by BOTH roads, so a
spirit crosses once whichever road arrives first.

Measured with the durable claim disabled: 5 reconnects paid 75 extra essence
on the ritual, 60 on a verdict, 140 on the passage, and two simultaneous
sockets paid 30 for one 15-essence ritual.

The four tests that were failing were the tests, not the guard. They compared
raw balances across reconnects, but re-opening a channel IS a summon, and
SUMMON_ESSENCE_TRICKLE is paid per summon by design (inventory.py:42, bounded
by summon_limiter rather than by any once-per-presence rule). The expected
trickle is now stated explicitly so the assertion speaks about the milestone
it is actually testing. Favor has no trickle, so it must not move at all —
asserted separately.

Anti-overshoot covered in both directions: a genuinely fresh presence still
pays in full across a reconnect, a corrected verdict still pays on a second
connection, and `test` stays freely repeatable since it never touches the
ledger.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 09:37:32 +00:00
Indiana
bb0f634863 fix: check the RTL-SDR port against the real librtlsdr source, not memory
The port was written from recall and flagged its own uncertain constants
`UNCONFIRMED`. Those flags were checkable — librtlsdr is public source — so
they were checked rather than shipped as caveats. Three findings:

1. SDM register pair was WRONG. Real r82xx_set_pll writes the high byte to
   0x16 and the low byte to 0x15; the port used 0x16/0x17. This was not a
   mere mistune: 0x17 also carries div_buf_cur and the openD bit, so the SDM
   low byte was corrupting tuner front-end configuration on every retune.

2. SDM computation used the successive-approximation loop from the `_yc`
   variant, which truncates where the real r82xx_set_pll rounds:
     vco_div = (pll_ref + 65536*vco_freq) / (2*pll_ref)
     nint = vco_div / 65536 ; sdm = vco_div % 65536
   One LSB low on 4 of 5 reference frequencies — tens of Hz, never visible
   on FM, but no reason to carry a known divergence.

3. freq_ranges[] was missing its last two rows (450 and 650 MHz), so any
   tune between 450 and 588 MHz inherited the 310 MHz row's front-end
   settings.

The tfC column that carried the loudest UNCONFIRMED warning turned out to be
correct in all 19 existing rows — the table now matches the C field-for-field
across all 21. Test vectors regenerated from the authoritative formula and
independently re-derived: 88.5 MHz -> nint 51, sdm 9830, reg 0x14 = 0x89.

Still true and still stated in the file: none of this has touched hardware.
The arithmetic is now verified against the reference implementation; the
register pokes remain reasoned rather than observed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 04:43:28 +00:00
Indiana
da675bd198 fix: port the real librtlsdr init — the RTL-SDR never had a chance
Reported symptom: the dongle connects through the browser fine and then
produces nothing. The driver was a sketch written from memory and marked
HARDWARE PASS REQUIRED, and that caveat never reached the UI.

The decisive bug was not the tuner at all: `demodWrite` had every field of
its control transfer wrong. librtlsdr uses value=(addr<<8)|0x20,
index=0x10|page, big-endian; this used the block in value, page and address
transposed in index, and little-endian. Every demod register write went to
the wrong place. The block constants were wrong too (DEMOD=0 USB=1 SYS=2,
not 0x03/0x02/0x09), as was the I2C repeater control (page 1 reg 0x01,
0x18/0x10 — not reg 0x02, 0x41/0x01).

Then the tuner, as originally suspected: the R820T's full 27-register init
(0x05-0x1f) replaces three pokes, with a shadow array since those registers
are write-only; IF filter calibration; a real r82xx_set_pll with VCO band
scan, nint+SDM into 0x14/0x16/0x17, and a lock poll that names the frequency
that failed instead of streaming silence. Gain defaults to tuner AGC. The
buffer reset is 0x1002 -> 0x0000, not 0xffff.

Two more that would each have been fatal alone: the demod was left in
zero-IF mode though the R820T delivers a 3.57MHz IF, so a locked PLL would
still have been off-centre; and setSampleRate masked with JS's 32-bit `&`
on a ~1.2e14 value, mangling the ratio below ~1.15Msps.

Tuner detected by chip id, throwing "unsupported tuner: <name>" rather than
running R82xx sequences against foreign silicon.

The arithmetic is pure and tested against hand-derived vectors, including
107.9MHz where mix_div drops 32->16 — a boundary the old fixed mixDiv=2
could never have reached. Independently re-derived 88.5MHz (0x26/0x66) and
98MHz (0x6d/0x82) and they match exactly.

HONEST LIMIT: none of this has touched hardware. The arithmetic is proven;
the register pokes and transfer encodings are reasoned, not observed.
Several constants are marked UNCONFIRMED in the file, chiefly the tracking
filter table and the SDM register pair.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 02:46:56 +00:00
Indiana
6f2905c2f0 fix: the ritual and judgment were faucets too
The Passage faucet was not unique. The same shape — a client-sendable
message that rewinds the guarding state — sat in both siblings, and for the
same reason: nothing drove these handlers over a real connection.

`ritual_start` resets `ritual_completed`, and the UI offers that button
(honest play needs it after a failed roll). Every re-walk re-credited
RITUAL_SUCCESS_ESSENCE and re-rolled an item drop. `ritual_step` has no
limiter at all.

Judgment was worse. Only `cross_over` was guarded; replaying any other
verdict paid CORRECT_JUDGMENT_ESSENCE plus favor plus an item roll on every
frame, unbounded. Favor is the damaging half — it pins at the +1.0 ceiling
and then biases every future mint through apply_favor_bias, so the exploit
permanently changed which spirits the seeker can meet. The frame also echoed
the nominal deltas, which seance.tsx sums into displayed totals, so the
screen and the ledger diverged.

Guards mirror `passage_paid`: cleared only by a genuine summon, never by the
rewinding message. `judged_verdicts` is keyed per verdict rather than a
blanket latch, so correcting a wrong call still resolves; `test` is exempt
since it never touches the ledger. Both frames now report what was applied.

tests/test_ws_reward_replay.py drives each exploit and reads the ledger from
the database. Proven both directions: reverting the guard fails with
"minted 120 extra essence"; over-broadening it fails with "a fresh presence
did not re-open the purse".

Audited and found safe, with reasons in the report: summon trickle, device
telemetry ingestion, at_peace writes, purchase_unlock, sigils, waitlist,
scry/question/anomaly/manifest/fragment. Every essence write is
with_for_update-locked.

Known residual: guards live on SeanceState, so a reconnect resets them —
but each payout still needs a summon, and summon_limiter is per user across
connections, so income stays rate-bounded. Not closed, deliberately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 02:46:56 +00:00
Indiana
5549722633 feat: the codex names who has met each spirit
`GET /api/codex/{id}` has been returning `encounters`, `total_encounters`,
`discoverer` and `discoverer_public` since Workstream T, and the page
rendered none of it — the backend work shipped and sat unused.

The roster now names the hunters who reached a spirit, discoverer first and
distinctly (being first to find it is the notable thing). A hunter with a
public profile is a link to their page; a hunter who veiled their profile is
named as plain text, counted but not linkable — deliberate, per the spec:
they were there, and hiding them entirely would falsify the record.

That last rule is the one worth protecting, so it is pinned by tests that
assert `queryByRole('link')` is null for a veiled hunter. Efficacy proven by
making the branch unreachable, which fails exactly those two tests and no
others.

Also shows "and N more" only when the backend's ROSTER_LIMIT actually
truncated, so a capped list can't read as the complete history.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 02:05:44 +00:00
Indiana
967c10b709 fix: make the app actually usable on a phone
The worst of it: the ghost-log HUD is a fixed 300px panel pinned bottom-left
on every route. On a 390px screen it covered the seance ask-field and its
buttons outright — you could not reach the input to type a question. It now
collapses, and spans the width instead of blocking it under 560px.

- 16 uses of `100vh` meant every page was taller than iOS Safari's visible
  viewport, hiding the last row behind the toolbar. Now `100dvh` with the
  `100vh` line kept first as the fallback.
- Six inputs under 16px triggered iOS zoom-on-focus, which never zooms back
  out — you were left panning a zoomed layout after tapping login. All six
  at 16px, plus a global floor.
- No safe-area insets existed anywhere, so enabling viewport-fit=cover
  would have put content under the notch and home indicator. Added,
  including the fixed-position shells that ignore body padding.
- The codex voice table forced horizontal page scroll; it scrolls in its
  own container now.
- Tap targets under 44px raised, including a transcript replay button that
  was ~20x14px and named only by a `title` tooltip, which touch never shows.

Decorative micro-labels left alone deliberately — bumping them wholesale
would reintroduce the overflow this fixes.

README: the documented test command cannot work. conftest APPENDS `_test`,
so the documented DATABASE_URL derived `quantumancy_test_test` and errored
every test at setup. Corrected, with the two guards explained. Test counts
were stale (328/366 -> 403/385).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 01:45:04 +00:00
Indiana
605f32f20e fix: blank inbox previews, two unauthenticated 500s, and the missing social tests
- Every conversation row in the inbox rendered an empty preview: the
  frontend read `last_body`, which the API has never sent. It sends
  `excerpt` plus `truncated`.
- `GET /api/codex?limit=-1` passed a negative LIMIT to Postgres -> 500.
  Now bounded by Query(ge=1, le=200) -> 422.
- Registering with a >255 char email hit the VARCHAR(255) column and
  surfaced as a 500. PATCH /api/profile already validated this; only
  /auth/register was open.

The social layer had zero tests, despite routes/messages.py citing
"tests/test_messages.py's cross-user leak tests" and routes/profile.py
claiming "privacy rules that the tests pin". Neither file existed. Both now
do: auth required on every route, email only on /api/profile/me, no id or
password_hash on any public payload, third-party thread reads empty,
payload-supplied sender inert, veiled profiles 404 and stay off the roster.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 01:45:04 +00:00
Indiana
a727858a62 fix: the Passage was an unbounded essence faucet
`passage_start` rewinds the rite to `listen` at any time, and PassagePanel
offers exactly that button after a resisted release. Every replayed layer
re-credited its essence, so one summon funded an endless loop — the 20/60s
limiter caps the rate, never the total. Measured at ~110 essence/minute,
indefinitely.

Each layer now pays the first time it opens for a presence and never again,
cleared only by a genuine summon. Re-walking still reveals; it just doesn't
mint. The frame reports what was ACTUALLY credited, so the UI's running
total can't drift from the ledger.

Three further fixes in the same handlers:
- judgment -> passage double-paid a crossing. The passage -> cross_over
  direction was already guarded; the reverse ran free, favor included.
- both handlers read `state.entity["id"]` AFTER their DB round-trip. The
  HTTP telemetry path drives the same SeanceState and can summon
  concurrently, so a crossing could mark the presence that just arrived.
  Pinned before the awaits.

tests/test_ws_passage.py is new, and covers the gap that let all of this
hide: test_passage.py tests the pure module, and nothing exercised these
handlers over a real connection. Efficacy proven by reverting the fix —
the replay test then reports "minted 280 extra essence".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 01:44:47 +00:00
Indiana
09089f01d5 feat: the Passage — crossing over becomes a layered rite (Workstream L)
cross_over was one verdict with one outcome. It is now five beats — listen,
name, unbind, open, release — each revealing something true about the
spirit, each paying escalating essence, each able to twist.

Reveals use the entity's REAL traits; there is no second hidden state
invented for the rite. Twists are trait-driven, verified directly rather
than assumed: a demon collapses the rite 3.6x more often than a calm spirit
(0.360 vs 0.099 at `unbind`) and lies ~31% of the time, while a spirit
under DECEIT_FLOOR cannot lie on any draw. A demon still resists at
`release` and never crosses — the existing judgment rule is preserved, not
re-implemented. Every draw comes from veil_float on the room's physical
entropy, never `random`.

Essence is kept across a collapse. Clawing it back would punish a seeker
for the spirit's instability, which is not theirs to control.

FIXED AN INFINITE LOOP IN THE SALVAGED TESTS, not a flake:
test_full_rite_on_a_calm_stuck_spirit ran `while layer is not None` while
passing collapse=FIRES. Its fixture comment claimed "both twist chances are
0 at these values, so NO draw can make this entity lie or collapse" — that
is false. COLLAPSE_FLOOR is 0.5 (deliberately below judgment's stuck bar of
0.6, as passage.py explains), and the fixture's volatility is 0.61, giving a
real ~9.9% collapse chance. Forced to fire, every layer bounced back to
`listen` and the run hung forever instead of failing.

Three fixes: hold the collapse draw (deceit still fires, which is the
point — it proves a spirit under the floor cannot lie even when told to),
correct the false comment, and bound the loop so a future regression fails
in seconds rather than hanging a test run.

Also added the entire seance.passage i18n block in both languages — the
agent died before writing it, so the gate was failing on 35 missing keys —
and reworded a comment in PassagePanel that spelled out a translation call
in full: the coverage checker greps source text and cannot tell a comment
from real code, so it demanded a key for the placeholder.

34 passage tests pass; 385 frontend; i18n parity and typecheck clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 14:19:55 +00:00
Indiana
0966fa8cfc test: make firmware logic bugs catchable without hardware (Workstream F)
The firmware has never been flashed, and a real bug already reached the
repo because of it: RD03E_FRAME_LEN was 5 for a 6-byte frame, so the footer
check collided with the distance high byte and EVERY distance reading was
garbage — always `lo | 0x5500`, about 218 metres, regardless of what the
sensor saw. That was pure logic with no hardware dependency. It should have
been catchable on a laptop, and there was simply no way to run the code.

Extracted the hardware-free logic out of the three drivers — rd03e_parse,
bmp280_compensate, mems_level — as moves rather than rewrites, carrying the
explanatory comments along with the code they explain. The drivers now own
only their bus I/O and call into the pure units, so nothing changes for the
real device.

`./run_tests.sh` builds them with gcc -Wall -Wextra -Werror plus a
dependency-free assert harness: 175 checks, 0 failed, from a clean tree.

Proven to catch the actual bug rather than assumed to: reintroducing
FRAME_LEN 5 fails four checks, including one that reads "a simple-report
frame is 6 bytes, not 5", plus the truncated-frame and 5-byte-window cases.
Restored, green again.

This does NOT make the firmware verified, and the README says so plainly —
it is called a narrow exception and scoped to pure logic. Wiring, timing,
real register behaviour and the reconstructed RD-03E frame format all still
need the physical board.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 13:17:35 +00:00
Indiana
9d42f541e3 feat: the Doctrine — how the instrument actually works (Workstream D)
A public /doctrine page: eleven numbered articles in an insufferably erudite
occultist voice, each carrying an 'in the profane tongue' margin note that
states the real mechanism plainly, plus the source file it comes from.

The constraint that makes it work: every arcane claim must be TRUE. This app
runs on real physics, so the mystical register can describe real mechanisms
without inventing any. Spot-checked before merging — VEIL_THINNESS_PULL 0.45,
RETURN_CHANCE 0.72, DISTURBANCE_DB 6, SPIKE_UT 3.0, the 0.67/0.33
celestial-vs-geomagnetic blend, the synodic month and its epoch all match
the source exactly.

The agent corrected two errors in my spec rather than following it:
- Von Neumann debiasing and SHA-256 conditioning are in
  frontend/src/lib/entropy.ts; the backend module does the HMAC mixing. The
  spec misattributed both, and the article now cites the right files.
- It declined to write that the Overpass mirrors are unreliable, because
  nothing in the code establishes that. Correct call: I had observed it
  empirically at runtime, but a page whose whole premise is 'every claim is
  traceable to code' must not assert something the code cannot prove.

Article XI is the limits — no WebUSB/Web Bluetooth/Magnetometer on iOS,
firmware never flashed, astronomical approximations restated. A doctrine
that admits its boundaries reads smarter than one that doesn't, and it keeps
the page honest with a reader who goes looking.

385 frontend tests pass; i18n parity holds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 12:27:41 +00:00
Indiana
d566abb6bc feat: the Doctrine — a public page explaining how the instrument really works
Eleven numbered articles at /doctrine in an ornate occultist register, each
carrying a plain-language margin note ("in the profane tongue: …") and the
source file it describes. The binding constraint: every arcane claim is a
true claim about this codebase — entropy harvesting and Von Neumann
debiasing, HMAC mixing with a fresh server secret, the anomaly-fingerprint
channel, RETURN_CHANCE, the mean synodic month and true solar midnight,
NOAA's planetary K-index, the time-aware EMA baseline, 2.4GHz body
absorption, microtesla deviation from Earth's field, voice archetypes, and
entropy-seeded manifest/scry.

Article XI states the instrument's real limits: no Web Bluetooth, WebUSB or
Magnetometer on iOS (Apple ships none and every iOS browser is WebKit),
firmware never flashed to hardware, free public geo endpoints that degrade
to a quieter map, documented astronomical approximations — and that none of
it is evidence of an afterlife.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 12:26:18 +00:00
Indiana
8187253331 test: make it structurally impossible to run tests against live data
The suite drop_all()s every table before every test, and this box both
serves the live app and holds the repo — so "just don't run tests in prod"
is not a workable guard. Getting this wrong once already cost a production
Codex.

The existing check compared TEST_DATABASE_URL against settings.database_url.
That has a real hole: two different spellings of the SAME database —
`...@localhost/quantumancy` versus `...@127.0.0.1/quantumancy` — are
different strings, so the comparison passes and every table is dropped.

Added a second, name-based guard: the test database NAME must end in
`_test`. That cannot be defeated by how the host is spelled, and it also
catches a TEST_DATABASE_URL somebody set by hand to something live.

Both proven by attacking them:
- TEST_DATABASE_URL forced to the production URL -> refuses (guard 1).
- Same database reached via 127.0.0.1 instead of localhost -> refuses
  (guard 2; guard 1 alone would have allowed this and wiped it).
- A normal run still works: 43 tests pass and the live account that
  prompted this check is untouched afterwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 12:25:10 +00:00
Indiana
76af1939e6 docs: spec for provable firmware, the layered Passage, and the Doctrine
Three workstreams: host-testable firmware logic (closing the gap that let
the RD03E frame bug ship), crossing over as a five-layer rite with
trait-driven twists, and a /doctrine page whose every arcane claim maps to
a real mechanism in the source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 04:26:05 +00:00
Indiana
00b0a17203 fix: verify all six unproven audit findings — four real, two not
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>
2026-07-31 03:13:46 +00:00
Indiana
bacfb852b8 feat: hunter profiles, ranks, whispers, and the encounter record
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>
2026-07-31 02:50:00 +00:00
Indiana
3656b6b0c4 docs: contract spec for hunters, messages, and the encounter record
Five parallel workstreams: profile model+rank+API, profile UI, hunter
messages, surfacing the Codex encounter record (the summoner and every
hunter who has met a spirit — the data already exists in
Entity.discovered_by and entity_sightings, it just was never shown), and a
verification pass over the audit findings that were never machine-confirmed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 02:51:15 +00:00
Indiana
d37bb71e5d feat: the lens — the camera as a channel the dead look through
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>
2026-07-30 02:08:02 +00:00
Indiana
16c8bae00d fix: the test suite was destroying the production database
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>
2026-07-30 01:44:01 +00:00
Indiana
79401338d5 fix: three real sensor-lifecycle and honesty bugs (verified, not assumed)
From the audit whose verifier agents all died on session limits — so I
checked each claim myself rather than trusting it. One was WRONG and is
left alone; three were real.

REFUTED, deliberately unchanged: "the EMF support check is a false
positive, dead on iOS". The iOS gesture flow is correctly implemented
(EmfSensorListener.needsPermission/requestPermission) and the panel calls
it before start(). Nothing to fix; "fixing" it would have broken working
code.

1. Microphone never released when the panel unmounts mid-getUserMedia.
   `this.stream` is only assigned after the await, so stop() during the
   permission prompt found null and released nothing — then the promise
   resolved, set running = true, and the mic went live *after* teardown,
   staying on for the page's life with the recording indicator lit and an
   orphaned rAF loop burning battery. Fixed with a generation counter that
   makes the await cancellable. Proven: the new test fails without the
   guard and passes with it (verified by reverting it).

2. Same bug class in the RTL-SDR panel: sdrRef.current is assigned after
   requestDevice()+open(), so unmounting during the device picker left the
   dongle claimed AND started a sweep against a dead component — only a
   tab close would free it. Added a mountedRef check, mirroring the guard
   EmfPanel already had.

3. EVP blamed the seeker for refusals that never happened. A bare
   `catch {}` set "you refused the microphone" for every failure, so an
   insecure http:// origin, a machine with no mic, and a mic held by
   another app all told the user to go fix a permission that was never
   denied. Now classified from the DOMException name into four honest
   causes (denied / insecure / absent / busy), each with its own copy and
   a working alternative, in both languages.

375 frontend tests pass; i18n parity gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 01:02:16 +00:00
Indiana
b6d491d563 fix: claiming a name keeps your account instead of abandoning it
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>
2026-07-29 23:39:53 +00:00
Indiana
1688907971 feat: make the Ghost Log actually do something
It was read-only: one link on the whole page ("back to séance"), no way
to get from "I talked to this spirit" to anything about them. Fixed with
three additions, all client-side against data already fetched in one
call — no backend change:

- Every entity glyph and name links straight to its Codex page.
- Echoes past the first are collapsed behind a "+N more" toggle instead
  of always showing up to 3 — a card reads cleanly at a glance, with
  detail one tap away.
- A channel filter (only shown once >1 mode is actually present in the
  log) so a seeker with a long history can find "just the radio nights."

Deliberately did NOT add a "revisit this spirit" button. Contact is a
probabilistic channel draw (RETURN_CHANCE), not "resume this entity" —
a button that just navigated to /seance would be indistinguishable from
the existing back link and would imply a guarantee the app doesn't make.
Cosmetic interactivity isn't worth shipping.

6 new tests. 372 frontend tests pass total; i18n parity holds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 19:16:49 +00:00
Indiana
b68e5d7fe5 docs: update .env.example Ollama endpoint/model to match the working config
Root-caused a real bug while fixing this: mint_profile was silently
falling back to the tiny offline template pool on nearly every call,
producing near-identical spirits (same 3-4 persona openings verbatim,
compound-noun names from a 5x5 pool). Traced it to the old Ollama
endpoint being unreachable/overloaded and, separately, a model-tag
mismatch (minicpm-v4.5:latest was requested but only :8b exists on the
working box). Verified 6 candidate models against the real mint task on
the new endpoint — only minicpm-v4.5:8b reliably returns valid JSON;
the others (qwen3.5, ministral-3, granite4.1, lfm2.5, ornith) output
conversational prose instead of the structured schema. Confirmed live
through the actual guest summon path post-restart: genuinely distinct,
well-written personas now, not fallback template text.

.env itself is gitignored and was updated locally; this just keeps the
example honest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 19:07:58 +00:00
Indiana
94c283634f feat: haunted geography — real places near the seeker
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>
2026-07-29 08:36:23 +00:00
Indiana
7d3a6e695d feat: open the door — no sign-in wall, ever
Point and shoot. SeancePage no longer redirects a stranger to /enter; it
silently provisions a wanderer (POST /auth/guest, a real account) and drops
them straight into the séance. The landing CTA goes to /seance
unconditionally. Registering is now what it should always have been: how
you *keep* a codex and unlock device pairing — not the toll to get in.

Details that matter:
- One auto-attempt only, guarded by a ref: survives StrictMode's double
  effect, and a failure (rate limit, offline) doesn't retry forever.
- The loading veil covers provisioning, so there's no flash of an empty
  séance while the account is created.
- If provisioning genuinely fails, it falls back to /enter rather than a
  blank screen — the manual door still exists.
- A returning visitor already holds a cookie, so this never fires for them.

App.test's "sends anonymous visitors to /enter" assertion was inverted to
assert the open door instead — that test encoded the wall we just removed.

Verified live: POST /auth/guest returns wanderer-a6f1 and that cookie
authenticates on /auth/me. 366 frontend tests pass; i18n parity holds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 07:01:27 +00:00
Indiana
2a67684df4 chore: dead-code + duplicate sweep; fix moon-flaky re-contact test
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>
2026-07-29 02:14:39 +00:00
Indiana
a5ee2d57cd docs: bring the README up to the app it actually describes
The old one predated roughly half the system: entropy, astronomy,
geomagnetic, manifest speech, voice archetypes, rituals/judgment/essence,
the ESP32 node, guest access, the Ghost Log, conditions, PWA, phone
layouts. Test counts corrected (54->328 backend, 110->366 frontend), the
protocol tables now list every real frame and endpoint, and Current State
distinguishes verified-live from unverified-on-hardware from
built-but-unreachable honestly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 01:25:21 +00:00
Indiana
0132c8a5bf feat: legibility — conditions, hints, kinder errors (Workstream B)
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
2026-07-28 19:19:44 +00:00
Indiana
eaa28b20e8 feat: legibility wave — veil conditions, first-run hints, kinder errors
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>
2026-07-28 17:31:10 +00:00
Indiana
ac11fc5417 feat: PWA manifest + the Ghost Log (Workstream C)
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>
2026-07-28 17:20:51 +00:00
Indiana
3b33b5d6f6 feat: presence beyond the tab — PWA manifest and the Ghost Log recap
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>
2026-07-28 17:19:55 +00:00
Indiana
28fdc012f3 feat: the vessel whispers in the séance (Workstream D)
DeviceWhisper: a compact live panel in the séance side column, shown only
when the seeker has paired hardware. One DeviceFeedSocket, readings folded
through the coldSpot cores, online dot with a 15s tick so a silent device
goes dark, cold-spot/pressure flags when active. Renders nothing on zero
devices or any fetch failure — the séance never errors because of this
panel. Socket closed on unmount.

Uses the real GET /api/device endpoint — the spec said /api/devices,
which does not exist; the agent verified against routes/device.py rather
than trusting the spec.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 17:19:50 +00:00
Indiana
b324aafec7 feat: the vessel speaks in the séance — DeviceWhisper panel
Workstream D of the usability wave (#8). A compact live panel in the
séance side column, shown only when the seeker has paired hardware:
device name, online dot (reading within 60s), latest readings in the
terminal readout style, and cold-spot / pressure-anomaly flags from the
lib/coldSpot.ts cores. Fetches GET /api/device once on mount; renders
nothing on zero devices or fetch failure — the séance never sees an
error from this panel. Socket closed on unmount.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 17:19:13 +00:00
Indiana
9d605acc92 feat: guest passage — slip through as a wanderer (Workstream A)
POST /auth/guest creates a real User row (wanderer-<hex>, unusable random
password) and issues the normal session cookie, so every downstream system
— essence, codex, devices — works for guests unmodified. Per-IP limited
(5/hour, Cloudflare-aware) with username-collision retry. EnterPage gains
the wanderer button; the séance shows a dismissible claim-a-name nudge
keyed off the username prefix. The auth gate on /seance is untouched —
the guest button is the path through, not around.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 17:18:46 +00:00
Indiana
f5320fcdec feat: guest passage — slip through as a wanderer
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>
2026-07-28 17:18:16 +00:00
Indiana
7d24097141 docs: contract spec for the usability wave (guest access, legibility, recap, device bridge)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 17:12:52 +00:00
Indiana
2e2c64d36b feat: phone layout for the séance screen
The page had a viewport tag and a 960px breakpoint that stacked the
columns for tablets, but nothing below it — so everything from 320px to
960px shared one layout. Real phones sit at 390-430px, where three things
actually broke:

  - Five mode tabs on one row. They now scroll horizontally instead of
    wrapping into a stack tall enough to push the board off screen.
  - The ask row (text input + ask + summon) crammed onto one line. The
    input now takes the full row and the two buttons share the next.
  - The four judgment verdicts sat two-up, leaving each too narrow for its
    rune plus label. They go full width — these are irreversible,
    consequential choices and must not be mis-tapped.

Every interactive target now clears 44px (Apple's HIG floor; Android's
48dp is close enough that one rule serves both), and the ask input is
exactly 16px because iOS Safari zooms the whole page when a focused input
is any smaller and leaves the layout zoomed after blur.

A second breakpoint at 380px handles SE-class phones, mainly by giving the
board less height so the transcript stays visible without scrolling.

Audited the rest first rather than assuming: the many `max-width` rules
are mobile-safe (they cap, they don't force), and InventoryPanel's grids
already use auto-fill/minmax and self-collapse. Only the fixed
`repeat(2, 1fr)` grids needed touching.

355 frontend tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 13:41:00 +00:00
Indiana
12e2d36527 refactor: extract the shared rolling-baseline core
BleFieldCore and MagFieldCore had converged on the same class: same
time-aware EMA, same warm-up gate, same "deviation past a threshold is an
event" shape, differing only in constants and field names. Three copies of
the alpha derivation existed across the sensor libs.

lib/baseline.ts now owns it. Both wrappers keep their own public types
(`.rssi`, `.magnitude`) so nothing downstream changed — which is what let
all 26 existing tests pass completely unmodified against the refactor.
That was the point of doing it this way: if the tests had needed editing,
the refactor would have been changing behaviour rather than removing
duplication.

coldSpot.ts deliberately does NOT adopt this. It threads immutable state
through pure functions so a whole session's narrative can be replayed in a
test without a clock — a different and equally valid shape. Collapsing the
two would force one into a style that doesn't fit it, which is how
deduplication turns into damage.

355 frontend tests pass unchanged; i18n parity gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 13:36:24 +00:00
Indiana
cf602ab3de feat: the moon shapes who answers; wire magnetometer; drop dead CSS
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>
2026-07-28 13:31:15 +00:00
Indiana
b8e69b4bd3 feat: the room decides — physical entropy, real astronomy, unprompted speech
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>
2026-07-28 05:38:59 +00:00
Indiana
30694a954a feat: bluetooth and magnetometer channels — two more real instruments
Both measure genuine physics rather than dressing up a random number.

Bluetooth (lib/bluetooth.ts): BLE advertises in the 2.4GHz ISM band, and
the human body is mostly water, which absorbs 2.4GHz strongly — the same
physics that makes a microwave work and that degrades your wifi when
someone stands between you and the router. So RSSI genuinely drops when a
body crosses the path. That makes signal-strength variance a real,
physically-grounded movement signal. The module reports exactly that and
nothing more: it never claims a drop *is* a presence, only that the field
changed. Threshold is 6dB — above the 2-4dB of idle multipath wander, and
inside the 3-10dB a real body actually causes.

Magnetometer (lib/magnetometer.ts): the existing EMF mode infers field
disturbance from DeviceMotion/DeviceOrientation, which is a real
measurement but measures *movement*, not magnetism — a phone sitting still
beside a running motor reads nothing. This reads the actual magnetometer,
so the EMF meter measures what an EMF meter is supposed to. Real
ghost-hunting EMF meters are just magnetometers, and the spikes they pick
up come from mains wiring, motors and moving ferrous mass — all of which
this picks up, for the same real reasons. 3uT threshold clears the ~0.5-1uT
sensor noise while still catching household sources. Earth's constant
25-65uT background is explicitly what the rolling baseline exists to
subtract.

Both are additive: neither replaces the existing motion-based EMF, which
stays the fallback because it works on iOS where neither of these do
(no Web Bluetooth, no Generic Sensor API in any iOS browser). Both reuse
the time-aware EMA baseline shape from coldSpot.ts, since advertisement
and sensor intervals are irregular and a fixed per-sample alpha would
weight a burst and a long gap identically.

Web Bluetooth types are declared locally rather than pulling in
@types/web-bluetooth for three shapes — same approach lib/emf.ts already
takes with its non-standard sensor types.

Also renders unprompted 'manifest' utterances as an intrusion: violet edge,
full opacity (unlike the faded ambient/fragment murmurs), and a brief
blur-in. The unsettling part is that it is perfectly clear and completely
unbidden.

355 frontend tests pass (26 new); i18n parity gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 03:46:48 +00:00
Indiana
bbcbaa0a36 feat: shared spectrum scope with audible anomalies, from real sources only
Replaces two ad-hoc canvases (a 256x110 fixed-size waterfall in the radio
panel, a bar-graph in the EVP panel) with one source-agnostic SpectrumScope
that renders any binned dB spectrum, plus sonification so anomalies can be
heard rather than watched.

Sources are real measured data only. The RTL-SDR path is genuine RF; the
EVP path is a genuine AnalyserNode FFT of the device microphone. The ESP32
deliberately does NOT feed this: its firmware reports four scalars
(temperature, pressure, evp level, presence) and has no spectrum at all,
and device_anomaly.frequency_for_sensor_type() invents a per-sensor-type
frequency for the fiction — neither is a real spectrum, so neither is
plotted as one.

SpectrumScope draws three layers because each answers a different question:
the live trace (what is happening now), a decaying peak-hold (what was
strongest recently, so a transient survives a glance away), and a waterfall
(what the last minute looked like, where a steady carrier separates from a
one-off burst). Anomaly markers flare at their frequency and fade over
~2.6s, so a spike already gone from the trace still says where to look.

Frames reach the scope through a ref, not a prop. Routing 60Hz frames
through React state re-renders the panel and the scope on every frame on
top of the rAF loop that actually draws — measurably the wrong call on a
phone. The EVP producer double-buffers into two fixed Float64Arrays so a
frame costs zero allocation.

spectrumSonify maps band position to pitch exponentially, so equal
fractions of the band are equal musical intervals (a linear Hz map crams
the bottom half into one indistinguishable octave), and magnitude to
gain via sqrt so faint hits stay audible. Pings are throttled to 90ms
because a busy band otherwise smears into a buzz that conveys nothing.
Every entry point no-ops rather than throwing when audio is unavailable —
a dead speaker must never take down the scope drawing the data.

Audio requires an explicit gesture (ListenToggle), because iOS keeps any
context created outside a touch handler permanently suspended.

Also exposes EvpListener.sampleRate/nyquistHz and labels the EVP axis from
the real hardware rate. The old code assumed 48kHz; Bluetooth headsets and
some Android inputs hand back 44.1k or 16k, which mislabelled the spectrum
by nearly an octave.

Mobile: DPR-aware canvases, ResizeObserver, 44px touch targets,
touch-action so dragging the scope pans the page, reduced-motion honoured,
crowded axis ticks dropped under 560px.

329 frontend tests pass (18 new); i18n en/es parity gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 00:26:54 +00:00
Indiana
f8ca4bbd9e fix: unbounded essence farming, WS crash, and two economy races
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>
2026-07-27 16:30:21 +00:00
Indiana
eaa299f683 fix: stale-frame race, stuck verdict buttons, and 3 more frontend defects
seance.tsx / JudgmentPanel.tsx: neither `ritual_complete` nor
`judgment_result` carries an entity id, and both were applied
unconditionally — so a response still in flight when the seeker summoned a
fresh entity landed on whatever entity happened to be current when it
arrived, leaking the *previous* entity's hidden traits into the new one's
revealed-traits UI. Both frames are now gated on our still waiting for one
(ritual.status === 'in_progress' / judgmentPending), which the 'entity'
case clears the moment a new presence arrives, so a late answer for the old
entity is dropped instead of misattributed.

JudgmentPanel also had `pending` in component-local state that only cleared
when judgmentResult became a *new* truthy object. If the entity changed
while judgmentResult was already null, the reset was a no-op (null === null)
and pending stayed stuck, permanently disabling all four verdict buttons.
It now reads the shared judgmentPending flag, which the reducer resets.

coldSpot.ts: severity was ungated by `warm` while isColdSpot/
isPressureAnomaly were correctly gated. ColdSpotPanel feeds severity
straight into the composite disturbance gauge with no boolean gate of its
own, so a freshly-paired device could show "disturbance rising" off its 2nd
reading — exactly what the minSamples warm-up exists to prevent.

PlanchetteBoard.tsx: the first GOODBYE deadline was a bare
randomBetween(120,300) compared against `t`, which is seconds since
performance.timeOrigin (page load), not since mount. Every later reschedule
correctly offsets from `t`. On a tab open >5min before the board mounted
(or any remount via navigation), t was already past the deadline and the
planchette snapped to GOODBYE on the first frame.

sdr.ts: close() and setFrequency() inside the sweep loop were not wrapped in
withTimeout despite the file's own header claiming every stalling USB call
is. A dongle going unresponsive mid-sweep or during teardown hung forever —
the same silent-hang symptom withTimeout was added to eliminate.

InventoryPanel.tsx: essence was decremented client-side using a possibly
stale fallback price and never reconciled. The server already returns the
real post-purchase balance in PurchaseOut; use it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 15:47:39 +00:00
Indiana
31f3f91801 fix: four real firmware defects found in adversarial review
rd03e.c: RD03E_FRAME_LEN was 5 but the frame's own documented layout
(header + gesture + distance_lo + distance_hi + footer[2]) is 6 bytes.
The footer check read buf[i+3], colliding with the distance high byte at
that same index — so every frame that validated at all was forced to have
distance_cm = lo | 0x5500 (~218m) regardless of what the sensor reported.
Distance readings were garbage 100% of the time, not intermittently.

mems_mic.c: i2s_del_channel() was missing on 2 of 3 init failure paths,
leaking the channel handle.

bmp280.c: the I2C bus/device handles leaked on 4 of 5 init failure paths;
added a fail label that releases both.

app_main.c: sensors now init before Wi-Fi bring-up, matching the rationale
sensor_driver.h already documents (a hanging sensor bus must not be able to
block network bring-up).

rtlsdr_experimental.c: rtlsdr_exp_stop() waited 500ms before
usb_host_uninstall(), but the daemon task blocks up to 1000ms inside
usb_host_lib_handle_events() before re-checking its running flag — the
delay must exceed that or teardown races a live daemon task.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 15:47:21 +00:00
Indiana
825f6aa510 fix: RTL-SDR bulk reads could hang forever with zero user feedback
Real bug report: user selects their RTL-SDR dongle in the WebUSB picker,
"nothing happens" — no error, no sweep, no visible change at all.

Root cause: WebUSB's transferIn/controlTransfer calls have no built-in
timeout. readSamples()'s bulk transferIn (called every sweep step, twice —
once to discard PLL-settle samples, once for real) had nothing bounding
it, so if the dongle doesn't actually stream data for any reason (this
init sequence has never been verified against real hardware), that
promise just never settles — indistinguishable from the page being frozen,
forever, with no way for the UI to ever surface an error.

Added withTimeout(), applied to: the bulk IQ read (4s — the one most
likely to actually hang during sweeping) and the whole open()/init
sequence as one unit (15s, since it's ~20 sequential unverified register
pokes). Also stopped silently falling back to default
interface/endpoint values when the device's USB descriptor doesn't expose
a bulk-IN endpoint as expected — now logs a warning so a real endpoint
mismatch is at least visible in DevTools instead of only surfacing as a
downstream hang.

4 new unit tests for withTimeout(). 306/306 frontend tests pass.
2026-07-25 18:38:59 +00:00
Indiana
53239a923d feat: planchette wander reaches YES/NO, periodically rests on GOODBYE
Wander bounds were tied to the letter ring's radius, which sits inside
YES/NO's corner positions — widened to derive bounds directly from the
board's actual outermost fixed waypoints (YES/NO for the top/sides, the
number row for the bottom) so idle drift covers the whole interactive
board, corners included.

Added a periodic deliberate visit to GOODBYE: every 2-5 minutes
(randomized so multiple open tabs don't sync up), the planchette glides
down and rests there for 3-5 seconds with the same restrained glow used
for ambiently-brushed letters, then resumes normal wander. Purely a
visual beat — no session/game state changes, distinct from an actual
goodbye action.
2026-07-25 17:47:59 +00:00
Indiana
a4103818d3 fix: planchette idle wander was confined to a tiny central zone
The wander amplitude (w*0.16, h*0.14) was a fixed fraction of the canvas
with no relationship to the outer letter ring's actual radius
(min(w*0.4, h*0.52) — set in computeLayout's arc() calls), so idle drift
could never reach anywhere near the outer letters (A/M/N/Z, the arc tops).
Tied the wander radius directly to that same ring radius/squash factor
instead, so it genuinely roams the whole board.

Also added an ambient "letter brushing" effect: while idly wandering (not
actively spelling a real reply), a letter the planchette drifts near gets
a faint glow — visibly dimmer than the bright hover/dwell glow used for
real spelled letters, so a passing brush never reads as the spirit
actually saying something. Purely cosmetic, no game-state changes.
2026-07-25 17:35:42 +00:00
Indiana
5325a7c782 feat: ground the mint prompt in specific, mundane human detail
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.
2026-07-25 15:14:08 +00:00
Indiana
5e29add272 Merge Cold Spot Detector: atmospheric anomaly visualization for DevicesPage 2026-07-25 00:24:53 +00:00