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:
205
docs/superpowers/specs/2026-07-30-hunters-and-messages-design.md
Normal file
205
docs/superpowers/specs/2026-07-30-hunters-and-messages-design.md
Normal 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.
|
||||||
Reference in New Issue
Block a user