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>
This commit is contained in:
Indiana
2026-07-30 02:51:15 +00:00
parent d37bb71e5d
commit 3656b6b0c4

View File

@@ -0,0 +1,205 @@
# 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/<file> -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: <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 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.