From 3656b6b0c4a67abe87f251e5803ba4267e0287f9 Mon Sep 17 00:00:00 2001 From: Indiana Date: Thu, 30 Jul 2026 02:51:15 +0000 Subject: [PATCH] docs: contract spec for hunters, messages, and the encounter record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../2026-07-30-hunters-and-messages-design.md | 205 ++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-30-hunters-and-messages-design.md diff --git a/docs/superpowers/specs/2026-07-30-hunters-and-messages-design.md b/docs/superpowers/specs/2026-07-30-hunters-and-messages-design.md new file mode 100644 index 0000000..db5de5d --- /dev/null +++ b/docs/superpowers/specs/2026-07-30-hunters-and-messages-design.md @@ -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/ -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.