From 2c79373a3b939d9c8bb783dcaf82c246e169ca7d Mon Sep 17 00:00:00 2001 From: Kev Date: Fri, 10 Jul 2026 20:49:28 -0400 Subject: [PATCH] Session 58: Phase 1 spec + ledger_entries migration (pre-apply commit) Migration 019: the truth-infrastructure table. Committed BEFORE it runs, per the Phase 1 GO instructions. Co-Authored-By: Claude Fable 5 --- specs/phase-1-truth-infrastructure.md | 102 +++++++++++++++++++++ supabase/migrations/019_ledger_entries.sql | 78 ++++++++++++++++ 2 files changed, 180 insertions(+) create mode 100644 specs/phase-1-truth-infrastructure.md create mode 100644 supabase/migrations/019_ledger_entries.sql diff --git a/specs/phase-1-truth-infrastructure.md b/specs/phase-1-truth-infrastructure.md new file mode 100644 index 0000000..9c7f5e9 --- /dev/null +++ b/specs/phase-1-truth-infrastructure.md @@ -0,0 +1,102 @@ +# Phase 1 — Truth Infrastructure (Session 58) + +Source: Phase 1 GO + amendments (Kev, Jul 10 2026). Consolidates work-order +Phase 1, the CLV schema, the SYNC threshold fix, and logs Phase 2.5. + +## Data semantics rule (system-wide) + +VYNDR never generates lines or odds — all market numbers are REAL book values +captured at a timestamp. VYNDR generates only `model_value` (projection) + +grade/edge/confidence, always labeled MODEL in the UI, never blended with +market data. Any path that would fabricate/fill a market value must show an +absent state instead. + +## 1. `ledger_entries` (migration 019) + +Full column list per the GO prompt (id, user_id nullable = public pipeline +record, player_key, player_name, sport, stat, line, side, locked_odds, book, +grade, edge, confidence, model_value, graded_at, game_id, game_date, +closing_line, closing_odds, clv, clv_result, outcome, actual_value, +settled_at, revised_from_grade). + +Decisions (deviations flagged in the report): +- **Dedupe:** `UNIQUE NULLS NOT DISTINCT (user_id, player_key, stat, line, + side, game_id)` — PG17 native; PostgREST upsert can target it directly + (the coalesce-expression-index alternative can't be used by + `on_conflict`). +- **player_key** = `nameKey()` from `src/utils/playerName.js` (normalized + slug: lowercase, accent-folded, suffix/nickname-resolved) — this IS the + work-order 1.6 canonical key; league IDs can backfill later. +- **RLS:** enabled. anon+authenticated SELECT where `user_id IS NULL` + (public model record); authenticated SELECT own rows; NO client INSERT/ + UPDATE/DELETE policies — all writes via service role. + +## 2. Write paths + +- **Pipeline (priority):** `ledgerService.recordPipelineGrades` called from + `snapshotService.runSnapshot` after the snapshot locks — upserts one row + per enriched grade, user_id null, `line`/`locked_odds` from `gradedAt` + (captured from the real odds feed at grade time), `book` from the odds + prop. Idempotent via the unique constraint (`ignoreDuplicates` upsert — + a re-run never duplicates and never overwrites the original lock). + game_id = odds event id when present, else `{sport}:{gameDateET}:{away}@{home}`. +- **User scans:** the Next `/api/scan` route (which already writes + scan_history) inserts a ledger row for AUTHENTICATED users only — + anonymous scans are NOT written (a null user_id would pollute the public + model record). Line/book from the scan body (the slate pre-fills real + book lines); locked_odds/game info enriched from the odds cache when the + prop matches. +- **Degenerate guard (work-order 1.5):** `analyzeViaEngine1` now returns + `insufficient_data: true` + `grade: null` when the model has NO projection + (no l5/l20 reference — the same reference edge_pct uses). Nothing writes + that to grades caches or the ledger; the scan UI renders + "INSUFFICIENT DATA — no read." The web gradeAdapter no longer displays + the line as the projection (the audit's model==line fabrication). + `projection` (real model number) is now attached to every graded result. + +## 3. Settlement + CLV + +- `ledgerService.captureClosing(sport, props)` runs on EVERY snapshot: for + today's unsettled public rows, overwrite closing_line/closing_odds with + the current real feed values. The last write before the game starts is + the closing line (after start the prop leaves the feed → stops updating). +- `ledgerService.settleLedger(sport)` runs in the scheduler's settle pass + (before grading, same as outcomeService): for unsettled rows with + game_date <= yesterday ET, resolve the actual stat via the same MLB + game-log source outcomeService uses; set outcome/actual_value/settled_at; + compute `clv` SIGNED BY SIDE (over: locked_line − closing_line positive + when the market moved toward the over… i.e. closing under the locked + line means you beat the number) and `clv_result` beat/faded/flat. + Idempotent: only touches rows where outcome IS NULL / clv IS NULL. +- Manual trigger: POST /api/internal/ledger/settle (internal key). + +## 4. /ledger population + +- Backend: `GET /api/ledger/mine` (auth; sport/grade filters, newest first) + + `GET /api/ledger/model` (public: user_id-null rows + 30d aggregate + {settled, hits, misses, pushes, hit_pct, clv_beat, clv_faded, clv_flat, + beat_close_pct, pending}). Next proxies for both. +- Frontend: /ledger gets MY READS | MODEL tabs. Model header shows + "hit% · beat close%" ONLY at n≥20 settles; below → "RECORD BUILDING — + every read settles here, misses included" + live pending count. +- `ModelRecord` shared component (deferred-render) mounted on landing + (proof strip footer) — player/team wiring rides the same endpoint later. + +## 5. SYNC threshold + +`SNAPSHOT_EXPECTED_INTERVAL` (seconds, env; default 18000 = the 5h max cron +gap). `/api/snapshot/summary` returns it as `expected_interval_s`. +HeartbeatBar: normal < 1.5x · amber ≥ 1.5x · red "STALE" ≥ 3x. When Phase +2.5 ships, drop the env value — no UI change needed. + +## 6. Phase 2.5 logged + +See `specs/vyndr-roadmap.md` — intraday odds-only refresh + directional +movement handling (STEAM/VALUE badges, auto re-grade on ≥1.0 move against, +public `revised_from_grade` revisions). Build after Phase 3 mobile. + +## Test plan + +Unit: ledgerService (record/idempotency/closing/settle/CLV signs/aggregate), +engine insufficient-data, gradeAdapter no-fabrication. Integration: ledger +routes (mine auth, model public), snapshot wiring. RLS verified live via SQL. diff --git a/supabase/migrations/019_ledger_entries.sql b/supabase/migrations/019_ledger_entries.sql new file mode 100644 index 0000000..5fd6ed5 --- /dev/null +++ b/supabase/migrations/019_ledger_entries.sql @@ -0,0 +1,78 @@ +-- --------------------------------------------------------------- +-- 019 — ledger_entries (Session 58, work-order Phase 1 + CLV amendments). +-- +-- The truth infrastructure: every grade — user scan AND pipeline pre-grade — +-- persists here, settles against the real result, and carries closing-line +-- value. Rows with user_id NULL are the PUBLIC model record. +-- +-- DATA SEMANTICS: `line`, `locked_odds`, `book`, `closing_line`, +-- `closing_odds` are REAL book values captured at a timestamp — never +-- generated. `model_value` / grade / edge / confidence are VYNDR model +-- output. A row must never exist with a fabricated market value. +-- +-- All writes go through the service role (pipeline + API). Clients only +-- read: their own rows, plus the public (user_id IS NULL) record. +-- --------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS public.ledger_entries ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NULL REFERENCES auth.users (id) ON DELETE CASCADE, + player_key text NOT NULL, + player_name text NOT NULL, + sport text NOT NULL, + stat text NOT NULL, + line numeric NOT NULL, + side text NOT NULL CHECK (side IN ('over', 'under')), + locked_odds text NULL, + book text NULL, + grade text NOT NULL, + edge numeric NULL, + confidence numeric NULL, + model_value numeric NULL, + graded_at timestamptz NOT NULL DEFAULT now(), + game_id text NOT NULL, + game_date date NOT NULL, + closing_line numeric NULL, + closing_odds text NULL, + clv numeric NULL, + clv_result text NULL CHECK (clv_result IN ('beat', 'faded', 'flat')), + outcome text NULL CHECK (outcome IN ('hit', 'miss', 'push')), + actual_value numeric NULL, + settled_at timestamptz NULL, + revised_from_grade text NULL +); + +-- Idempotency: pipeline re-runs and double-taps upsert into this. PG15+ +-- NULLS NOT DISTINCT makes the null-user (public record) rows dedupe too, +-- and — unlike a coalesce-expression index — PostgREST `on_conflict` can +-- target a real constraint's column list. +ALTER TABLE public.ledger_entries + ADD CONSTRAINT ledger_entries_dedupe + UNIQUE NULLS NOT DISTINCT (user_id, player_key, stat, line, side, game_id); + +CREATE INDEX IF NOT EXISTS idx_ledger_user_graded + ON public.ledger_entries (user_id, graded_at DESC); +CREATE INDEX IF NOT EXISTS idx_ledger_gamedate_outcome + ON public.ledger_entries (game_date, outcome); +CREATE INDEX IF NOT EXISTS idx_ledger_player_stat + ON public.ledger_entries (player_key, stat); +CREATE INDEX IF NOT EXISTS idx_ledger_public_clv + ON public.ledger_entries (clv_result) WHERE user_id IS NULL; +-- The settlement worker's scan: unsettled rows by date. +CREATE INDEX IF NOT EXISTS idx_ledger_unsettled + ON public.ledger_entries (game_date) WHERE outcome IS NULL; + +ALTER TABLE public.ledger_entries ENABLE ROW LEVEL SECURITY; + +-- Users read their own rows. +CREATE POLICY ledger_select_own ON public.ledger_entries + FOR SELECT TO authenticated + USING (user_id = auth.uid()); + +-- Everyone (anon + authenticated) reads the public model record. +CREATE POLICY ledger_select_public ON public.ledger_entries + FOR SELECT TO anon, authenticated + USING (user_id IS NULL); + +-- No INSERT/UPDATE/DELETE policies: client-side writes are impossible. +-- The pipeline + API write via the service role, which bypasses RLS.