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 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user