Files
vyndr/specs/phase-1-truth-infrastructure.md
T
builtbykev 2c79373a3b 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>
2026-07-10 20:49:28 -04:00

5.3 KiB
Raw Blame History

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.