Files
qtalker---/docs/superpowers/specs/2026-07-30-hunters-and-messages-design.md
Indiana 3656b6b0c4 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>
2026-07-30 02:51:15 +00:00

206 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.