# A1 Session 10 — Public Ledger Profiles v1

Stage-3 seed (NORTH STAR): every bettor gets a CLV-verified public record —
Strava for betting. v1 is the minimum honest version: a handle, one explicit
publish toggle, and the user's ENTIRE settled record on a public page.

## Operating rules
- Zero out-of-pocket. Existing Supabase + Express + Next only.
- PRIVATE BY DEFAULT. `published` defaults false; publishing is one explicit
  toggle with copy that says exactly what it does.
- Same n-gating as the model record: no percentage under 20 settles
  (`ledgerService.MIN_AGG_SAMPLE`).
- Data semantics: only real settled ledger rows. Nothing curated — a public
  profile shows ALL settled reads including misses. That is the point.
- No existence leak: unknown handle and unpublished handle return the SAME
  404 body.

## Data
Migration `supabase/migrations/022_public_profiles.sql` (committed, NOT
applied — the founder applies migrations):

```
public_profiles (
  user_id    uuid PK REFERENCES auth.users ON DELETE CASCADE,
  handle     text UNIQUE NOT NULL CHECK (handle ~ '^[a-z0-9_]{3,20}$'),
  published  boolean NOT NULL DEFAULT false,
  created_at timestamptz NOT NULL DEFAULT now()
)
```
RLS: anon + authenticated SELECT where `published = true`; owner SELECTs own
row; NO client write policies — all writes via the service role.

## Endpoints (`src/routes/profiles.js`, mounted at /api/profiles)
- `GET /api/profiles/me` (requireAuth) → `{ profile: {handle, published,
  created_at} | null }`.
- `POST /api/profiles/me` (requireAuth) → upsert own `{ handle, published }`.
  Handle validated against `^[a-z0-9_]{3,20}$` (400 on invalid); a handle
  owned by another user → 409. Service-role write.
- `GET /api/profiles/:handle` (public, 60/min) → published profile:
  `{ handle, aggregate, entries, min_sample }` where
  - `aggregate` = `ledgerService.getModelAggregate({ userId })` — a new
    `userId` option swaps the `.is('user_id', null)` public scoping for
    `.eq('user_id', uid)`. The public default is untouched.
  - `entries` = that user's SETTLED rows, newest 50, same columns as
    /api/ledger.
  Unknown OR unpublished → 404 with the identical body (no existence leak).

Next proxies: `web/src/app/api/profiles/me/route.ts` (GET/POST, forwards
Authorization) and `web/src/app/api/profiles/[handle]/route.ts` (GET).

## Frontend
- `/u/[handle]` — PUBLIC (never gated). Server component shell with metadata
  `CLV-verified record — @handle · VYNDR` + client content: record header
  (hit% + beat-close% at n≥20, else RECORD BUILDING), settled rows with
  outcome + CLV chips (ledger card patterns), OG card via
  `opengraph-image.tsx` in the segment (Node runtime — NEVER edge, S53 rule).
- `/settings` PUBLIC PROFILE section: claim handle + publish toggle. Copy
  makes public-by-choice explicit: "Publishing puts your ENTIRE settled
  record on a public page — wins and misses. Private by default."

## Acceptance criteria
1. Migration committed, unapplied, documented in the report.
2. `getModelAggregate` default scope unchanged; `userId` scope unit-tested
   both ways.
3. Unpublished and unknown handles return byte-identical 404 bodies.
4. Handle regex enforced at API AND database CHECK.
5. `/u/...` reachable anonymous; aggregate renders no % under 20 settles.
6. Full jest suite green; `web` build exit 0.

## Test plan
- `tests/unit/ledgerAggregateScope.test.js` — public vs userId scoping.
- `tests/integration/profilesRoutes.test.js` — handle validation, 409 on
  taken handle, publish flow, 404-no-leak (mock supabase per
  ledgerRoutes.test.js pattern).
- `tests/unit/publicProfilePage.test.js` — page/OG/settings source
  assertions (metadata string, no edge runtime, RECORD BUILDING state,
  private-by-default copy, /u stays ungated).
