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
+20
View File
@@ -288,6 +288,10 @@ async function runSnapshot(sport, opts = {}) {
// Fenced: written to its own `bookprices:{sport}` key, read by nothing on
// the grade path. Injectable; a failure never touches grading.
captureBookPrices: opts.captureBookPrices || require('./bookPriceStore').captureBookPrices,
// Lock-line persistence (measurement-only). Persists multi-book lines to the DB at
// the lock moment so a future audit can run the currently-BLOCKED staleness check.
// Fenced: writes its own `lock_lines` table, read by nothing on the grade path.
lockLineCapture: opts.lockLineCapture || require('./lockLineCapture'),
refreshTeamStats: opts.refreshTeamStats
|| (process.env.NODE_ENV === 'test'
? async () => null
@@ -557,6 +561,22 @@ async function runSnapshot(sport, opts = {}) {
};
});
// LOCK-LINE PERSISTENCE (measurement-only). At THIS moment the grades have locked to
// their lines (`gradedAt` uses `ts`), so `props` is the multi-book snapshot AS IT
// EXISTED AT LOCK. Persist each graded prop's per-book lines to `lock_lines`,
// timestamped `ts`, so a future audit can check whether our locked line was stale-high
// vs consensus at lock. Built from the in-memory `props` (no Redis re-read → no TTL
// race). Best-effort + structurally fenced: nothing on the grade path reads lock_lines,
// and the graded slate is byte-identical whether or not this runs.
try {
const gradedKeys = new Set(enriched.map((g) => `${norm(g.player || g.player_name)}|${String(g.stat_type || g.stat || '').toLowerCase()}`));
const lockRows = deps.lockLineCapture.buildLockRows(sp, props, gradedKeys, { lockedAt: ts });
const lr = await deps.lockLineCapture.persist(lockRows);
console.log(`[lock-lines] ${sp}: ${lr.written}/${lr.attempted} multi-book rows persisted at lock${lr.skipped ? ' (skipped — no supabase env)' : ''}${lr.error ? ` ERROR: ${lr.error}` : ''}`);
} catch (e) {
console.warn(`[lock-lines] ${sp} persist failed (measurement-only, snapshot continues):`, e.message);
}
// Session 64 — retention persists HERE, after enrichment, so archetype/team/
// opponent are populated. Feature values were captured at grade time and are
// NOT touched by the merge (mergeEnrichment only fills the three null fields).