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