# 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 BOTH `src/i18n/en.json` and `es.json`. `npm run pretest` must pass. If you use a template key (`t(\`x.${v}\`)`) add a domain rule to `src/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/ -q -p no:cacheprovider` Tests use `quantumancy_test` automatically — 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.py` or `SeancePage.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 to `username`. - `bio: str | None` (<= 280 chars) — public. - `gender: str | None` — `male` / `female` / `unspecified`; defaults to `unspecified` when absent. Public. - `avatar_form: str | None` — one of `wisp`/`banshee`/`fairy`/`shade`. - `avatar_hue: int | None` — 0..359. - `profile_public: bool` default `True`. 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) -> int` - `progress_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 — mirror `routes/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 when `profile_public` is 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 `GhostGlyph` live), 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: , 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 with `profile_public=False` still 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 own `GET /api/messages` response 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 whose `profile_public` is False** from being *linkable*, but still count them in the total (an anonymous contact is still a contact). Include a `total_encounters` count. - 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. 1. Session cookie may lack `Secure` for internet visitors because uvicorn doesn't trust `X-Forwarded-Proto` from the off-box Cloudflare Tunnel. (Check `routes/auth.py` cookie logic and how it decides `secure`.) 2. Remounting `/seance` while `POST /auth/guest` is in flight could provision a second wanderer, orphaning the first and burning two of five hourly rate-limit slots. (Check the `guestAttempted` ref guard.) 3. When guest provisioning is rate-limited the visitor is redirected to `/enter` with no explanation of what happened. 4. Rate limiters never evict keys — unbounded memory growth in `rate_limit.py` over a long-running process. 5. `_summon`'s `state.entity is None` check can race hardware telemetry ingestion vs. the browser's auto-summon, double-processing one logical summon (duplicate essence/item). 6. Entities minted before the `traits` migration are stuck at degenerate defaults (all 0.5), making `trust` always correct and `cross_over` unreachable 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.