Persist lock-time multi-book lines to lock_lines (unblocks the staleness audit)

The over-side skew audit's confirming check — was our locked line stale-high vs
consensus AT LOCK — was BLOCKED because multi-book lines at lock were never
persisted (bookprices is Redis current-only). This persists them.

- migration 033: lock_lines table (tracked + applied to prod). One row per
  (graded prop × book) with both odds + a lock timestamp. RLS enabled, NO
  policies -> service-role only (fence). UNIQUE key -> idempotent re-runs.
- lockLineCapture.js: buildLockRows (pure, graded-props only, honest-absent
  single-book) + idempotent upsert persist. Built from the in-memory props at
  the LOCK moment (ts) -> no Redis re-read, no TTL race.
- snapshotService: persist right after `enriched` (the lock moment; gradedAt
  uses the same ts). Best-effort + fenced.

FENCE (measurement-only): lock_lines is read by NOTHING on the grade path
(gradeSlateService, snapshot dedup/indexOdds, challengers, selector, ledger) —
a grep test asserts it, and RLS locks it to the service role. Grade byte-
identical proven: runSnapshot grades are identical with persist on/off (test).

Volume ~1.5-3k rows/day (graded props x books x 5 snapshots); weeks retained,
no pruning needed short-term. Does NOT retroactively fix the existing 62 rows —
future accrual only; confirmation still needs weeks of settled rows. Full suite
3842 green, web build exit 0. No grade/locked_odds/outcome/served surface changed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VsztNChZ7vEvSR61AuMhD1
This commit is contained in:
Kev
2026-07-29 02:47:47 -04:00
parent 37261260d1
commit c7067c80c4
4 changed files with 318 additions and 0 deletions
+42
View File
@@ -0,0 +1,42 @@
-- Migration 033: lock_lines — multi-book lines at the LOCK moment (measurement-only).
--
-- The lock-time analog of `closing_captures`. As a grade LOCKS to a line, this persists
-- each book's line + both-side prices for that (graded) prop, timestamped at the lock
-- moment. A future audit joins lock_lines (lock) to closing_captures (close) to answer
-- the currently-BLOCKED question: was our locked line stale-high vs consensus AT LOCK?
--
-- FENCE: measurement/display-only. It must NOT feed the consensus-line selector, the
-- champion, any challenger, the graded line, or the ledger. Enforced by (a) a separate
-- table nothing on the grade path reads, and (b) RLS enabled with NO policies, so only
-- the service role (which bypasses RLS) can read/write it — no client, no anon.
--
-- One row per (prop × book) carrying BOTH over_odds + under_odds (the de-vig needs both;
-- one row halves volume vs closing_captures' per-side rows). Append-only; the UNIQUE key
-- makes a re-run (snapshot retry) idempotent. Honest-absent: single-book props persist
-- as one row — never a fabricated second book.
CREATE TABLE IF NOT EXISTS lock_lines (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
sport text NOT NULL,
player_key text NOT NULL,
player_name text,
stat text NOT NULL,
game_date date,
game_time timestamptz,
book text,
line_type text, -- 'sharp' (pinnacle) | 'book'
line numeric,
over_odds integer,
under_odds integer,
locked_at timestamptz NOT NULL, -- the LOCK moment (snapshot gradedAt ts)
missed_reason text,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT lock_lines_uniq UNIQUE NULLS NOT DISTINCT (sport, player_key, stat, game_date, book, locked_at)
);
CREATE INDEX IF NOT EXISTS lock_lines_join_idx ON lock_lines (sport, player_key, stat, game_date);
CREATE INDEX IF NOT EXISTS lock_lines_locked_at_idx ON lock_lines (locked_at);
-- FENCE: RLS on, no policies → service-role only. Never reachable by a client/anon,
-- and never by the grade path (which does not query this table).
ALTER TABLE lock_lines ENABLE ROW LEVEL SECURITY;