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>
11 KiB
Hunters, messages, and the encounter record
Five parallel workstreams. Registering already keeps your codex and unlocks device pairing; this gives an account an identity other hunters can see, a rank that grows with real activity, simple messages between hunters, and a Codex that shows who summoned each spirit and who else has met it.
Binding rules (every workstream)
- Additive only. Nothing removed, no restructuring outside your files.
- Guests stay first-class. A
wanderer-account must keep working exactly as today. Profile fields are absent/defaulted for them. Never gate the séance. Guests may NOT send messages (see S) — that is the one deliberate account-only capability besides device pairing. - i18n parity is a hard gate. Every user-facing string via
t(), keys in BOTHsrc/i18n/en.jsonandes.json.npm run pretestmust pass. If you use a template key (t(\x.${v}`)) add a domain rule tosrc/i18n/coverage-check.mjs— base names only, it derives*Title` itself. - Tone: in-fiction throughout (seeker / hunter / the veil / presence).
- Backend tests:
cd backend && set -a && source ../.env && set +a && source venv/bin/activate && python -m pytest tests/<file> -q -p no:cacheproviderTests usequantumancy_testautomatically — never point them at the live DB. - Frontend:
npx tsc --noEmit -p .,npm run pretest,npx vitest run. - Mobile: usable at 390px, 44px touch targets, honour
@media (prefers-reduced-motion: reduce). - Shared files: if you must touch
main.py,App.tsx,schemas.pyorSeancePage.tsx, keep the diff to the fewest possible lines — the integrator merges several workstreams into each and resolves conflicts.
Workstream P — profile model, rank, profile API (backend only)
Files: models/user.py, main.py (migration lines only), rank.py (new),
routes/profile.py (new), schemas.py, tests.
New nullable User columns (so every existing row, guests included, stays
valid). Note email ALREADY EXISTS — do not re-add it.
display_name: str | None— shown publicly; falls back tousername.bio: str | None(<= 280 chars) — public.gender: str | None—male/female/unspecified; defaults tounspecifiedwhen absent. Public.avatar_form: str | None— one ofwisp/banshee/fairy/shade.avatar_hue: int | None— 0..359.profile_public: booldefaultTrue.
Migration: idempotent ALTER TABLE ... ADD COLUMN IF NOT EXISTS in
main.py's lifespan, matching the existing style exactly.
Avatar is procedural (form + hue, rendered by the existing GhostGlyph).
No uploads: no moderation surface, no EXIF, no storage. Columns are shaped
so a future avatar_url slots in without changing anything else.
rank.py — pure, no DB, no I/O, fully unit-tested:
level_for(encounters, essence, favor) -> intprogress_for(...) -> {level, title, encounters, next_at, progress}- Thresholds grow progressively (document the curve's reasoning in a comment; no unexplained magic table). Level 1 must be reachable from a single séance so a new hunter sees progress immediately.
- Titles, one word each, in-fiction: e.g. curious → sensitive → channeler → medium → adept → oracle.
- Total and clamped: safe for negative essence, zero encounters, absurd values.
Endpoints:
PATCH /api/profile(auth) — update display_name, bio, gender, avatar_form, avatar_hue, profile_public, email. Validate everything (length caps, enums, hue range, email shape — mirrorroutes/shop.py's regex style). Rate limit modestly.GET /api/profile/me(auth) — own profile INCLUDING email, plus rank/progress.GET /api/hunters/{username}(public) — display_name, bio, gender, avatar, level/title, encounter count, joined date, and up to 12 recently-contacted entities. 404 whenprofile_publicis False. NEVER include email.GET /api/hunters(public) — top ~24 public hunters by encounter count.
encounter_count = distinct entity_id in that user's EntitySighting
rows. The roster's counts must come from ONE aggregate query — no N+1.
Tests: guests unaffected; email absent from both public payloads (assert explicitly); private profile 404s; validation rejects bad enums/lengths/ hues; rank maths; roster excludes private profiles.
Workstream Q — profile UI (frontend only)
Files: pages/ProfilePage.tsx + css (/profile), pages/HunterPage.tsx +
css (/hunters/:username), pages/HuntersPage.tsx + css (/hunters),
routes in App.tsx, one topbar link in SeancePage.tsx.
Code against P's endpoint shapes exactly as specified above; P is building
in parallel and will NOT exist in your worktree. Keep all fetching in ONE
module (e.g. additions to src/api.ts) so a shape change is a one-file fix.
Component tests must stub fetch — never depend on a live backend.
- Own profile: edit display name, bio (live char count), gender (three
chips), avatar picker (4 forms × hue slider, previewing the real
GhostGlyphlive), public/private toggle, optional email field with a clear "recovery only, never shown publicly" note. Await the response and show a saved state. - Public hunter page: large glyph, display name, title + level with a
progress bar, encounter count, bio, recent-entity grid (reuse Codex card
styling), and a "send a message" link to
/messages/:username(S owns that route; just link to it). - Roster: grid of hunter cards linking to each profile.
- Guests (
wanderer-prefix): show the page with an in-fiction prompt to claim a name — do NOT hide or break it.
Workstream S — hunter messages (backend + frontend)
Files: models/message.py (new), routes/messages.py (new),
main.py (import + include_router + migration), schemas.py,
pages/MessagesPage.tsx + css, pages/ThreadPage.tsx + css (or one page
handling both — implementer's call), App.tsx routes, tests.
Deliberately simple: plain text, no attachments, no editing, no groups.
Model Message: id, sender_id (FK users, index), recipient_id (FK
users, index), body (Text, <= 1000 chars), created_at, read_at
(nullable).
Endpoints (all auth required):
POST /api/messages—{to: <username>, body: str}. Rate limit meaningfully (e.g. 20/hour/user). Guests cannot send — return 403 with an in-fiction message telling them to claim a name. Reject empty/oversized bodies, reject sending to yourself, 404 unknown recipient. Recipients withprofile_public=Falsestill receive (privacy hides the profile, not the mailbox).GET /api/messages— conversation list: each correspondent with their display name/avatar, last message excerpt, unread count. ONE aggregate query for unread counts, no N+1.GET /api/messages/{username}— the thread, newest last, paginated (limit ~50). Marks the caller's inbound messages read.- Unread total exposed for a badge — either on this router or as a field on
GET /api/profile/me(coordinate: prefer your ownGET /api/messagesresponse so you do not depend on P).
Tests: guest send is 403; can't message yourself; unknown recipient 404; oversized body rejected; a user only ever sees their OWN threads (assert no cross-user leak — this is the security-critical one); unread counts correct; reading marks read.
UI: conversation list, a thread view with a send box, in-fiction empty states. Sanitize nothing into HTML — render message bodies as plain text.
Workstream T — the encounter record (Codex)
Files: routes/codex.py, pages/CodexEntityPage.tsx (+ css), tests.
The data ALREADY exists — Entity.discovered_by is recorded and returned,
and every contact is an EntitySighting(entity_id, user_id, session_id, seen_at) row. This workstream surfaces it.
- Extend
GET /api/codex/{id}with:discovered_by: keep the existing username, and add whether that hunter's profile is public so the UI knows whether to link it.encounters: up to 20 distinct hunters who have contacted this spirit — username, display name if set, avatar form/hue, times contacted, last seen. Newest-first. Exclude hunters whoseprofile_publicis False from being linkable, but still count them in the total (an anonymous contact is still a contact). Include atotal_encounterscount.- ONE aggregate query for the roster — no N+1 per hunter.
- Entity page: a "who has met this spirit" section — the summoner marked
distinctly as the discoverer, then the roster. Link public profiles to
/hunters/{username}; render private ones as plain, unlinked text. - Tests: discoverer surfaced; roster aggregates correctly; private hunters counted but not linkable; an entity nobody has met doesn't crash.
Workstream V — verify the unverified audit findings (read-mostly)
An earlier adversarial audit produced findings but its verifier agents all died, so NONE were confirmed. Two were later verified and fixed by hand (the mic-release leak and the EVP failure misattribution); one was REFUTED (iOS EMF works — the permission flow is correctly wired, do not "fix" it).
For EACH remaining claim below: read the actual code, decide if it is real, and ONLY fix the ones that are. Report refutations explicitly — a confidently-refuted finding is as valuable as a fix, and "fixing" working code is worse than leaving it.
- Session cookie may lack
Securefor internet visitors because uvicorn doesn't trustX-Forwarded-Protofrom the off-box Cloudflare Tunnel. (Checkroutes/auth.pycookie logic and how it decidessecure.) - Remounting
/seancewhilePOST /auth/guestis in flight could provision a second wanderer, orphaning the first and burning two of five hourly rate-limit slots. (Check theguestAttemptedref guard.) - When guest provisioning is rate-limited the visitor is redirected to
/enterwith no explanation of what happened. - Rate limiters never evict keys — unbounded memory growth in
rate_limit.pyover a long-running process. _summon'sstate.entity is Nonecheck can race hardware telemetry ingestion vs. the browser's auto-summon, double-processing one logical summon (duplicate essence/item).- Entities minted before the
traitsmigration are stuck at degenerate defaults (all 0.5), makingtrustalways correct andcross_overunreachable for them. (Check whether any such rows can still exist.)
Fix only what you prove. Add a regression test for each real fix.
Integration (controller)
Merge order: P, T, S, Q, V. Controller resolves main.py / App.tsx /
schemas.py / SeancePage.tsx overlaps, runs both full suites plus the
i18n gate, builds, deploys, restarts the service, verifies live endpoints,
and pushes.