# 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.
