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

11 KiB
Raw Permalink Blame History

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.