Files
vyndr/specs/phase-1-truth-infrastructure.md
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

103 lines
5.3 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.