-- Migration 045: append-only Read lineage (ADDITIVE ONLY). -- -- Every statement is ADD COLUMN IF NOT EXISTS or CREATE INDEX. No column is -- dropped, renamed, retyped or backfilled. No historical row is rewritten. -- -- ── WHY THIS IS COLUMNS AND NOT A NEW CLAIM TABLE ────────────────────────── -- `model_snapshots` is ALREADY an append-only store of published claims: it is -- upserted with ignoreDuplicates on (snapshot_id, player_key, stat, line, side), -- so a row is written once per snapshot cycle and never overwritten, and -- `captured_at` is NOT NULL. Its only post-insert write is settlement -- (outcome / actual_value / settled_at), which is not part of the claim. -- -- MEASURED (mlb, 2026-08-26): 5,376 props, 4,286 with more than one row, and -- 183 whose GRADE CHANGED across those rows. The immutable claims exist. What -- was missing is the lineage over them, which is what these columns are. -- -- Building a separate revision table would have duplicated a claim that is -- already immutable, and given VYNDR two stores that can disagree. -- -- ── THE ROW'S OWN id IS THE REVISION IDENTITY ───────────────────────────── -- `model_snapshots.id` is a bigserial primary key on an append-only row. It is -- already immutable and already unique, so a parallel text `revision_id` would -- be a second spelling of the same fact. `supersedes_id` and `recaptures_id` -- point at it directly. -- -- ── CAPTURE IS NOT REVISION ─────────────────────────────────────────────── -- Most cycles re-observe an unchanged claim. `claim_digest` (sha256 over the -- market terms plus gradeFreeze.SERVED_FIELDS) is what separates a genuine new -- published state from a re-observation, so the chronology reports the ~183 -- revisions that happened rather than the ~13,000 captures that were written. -- The logical Read: stable across cycles, and deliberately NOT derived from the -- game_id string. Measured over 30,746 identity groups, 1,328 carried more than -- one game_id spelling for the same real game while only 18 carried genuinely -- different team pairs. Identity that included the raw spelling would have -- fractured 1,310 Reads that are one Read. ALTER TABLE model_snapshots ADD COLUMN IF NOT EXISTS read_id text; ALTER TABLE model_snapshots ADD COLUMN IF NOT EXISTS read_natural_key text; -- The published state. ALTER TABLE model_snapshots ADD COLUMN IF NOT EXISTS claim_digest text; ALTER TABLE model_snapshots ADD COLUMN IF NOT EXISTS revision_ordinal integer; ALTER TABLE model_snapshots ADD COLUMN IF NOT EXISTS supersedes_id bigint; ALTER TABLE model_snapshots ADD COLUMN IF NOT EXISTS recaptures_id bigint; -- ORIGIN | REVISION | RECAPTURE. A row says which it is rather than leaving a -- reader to infer it from ordinals. ALTER TABLE model_snapshots ADD COLUMN IF NOT EXISTS lineage_action text; -- LIVE | LEGACY_UNVERIFIED. Rows written before this migration keep NULL, which -- is the honest value: their position in a chronology is unknown and is not -- reconstructed. ALTER TABLE model_snapshots ADD COLUMN IF NOT EXISTS lineage_state text; ALTER TABLE model_snapshots ADD COLUMN IF NOT EXISTS lineage_version text; -- Walking one Read's chronology in order. CREATE INDEX IF NOT EXISTS model_snapshots_lineage_idx ON model_snapshots (read_id, revision_ordinal) WHERE read_id IS NOT NULL; -- Resolving a candidate's family on write. CREATE INDEX IF NOT EXISTS model_snapshots_natural_key_idx ON model_snapshots (read_natural_key) WHERE read_natural_key IS NOT NULL; -- FORK PREVENTION, not merely fork detection. -- -- Only ONE row may supersede a given parent. Two workers that each read the -- same standing revision before either wrote cannot see one another, so no -- in-memory check can catch that race — the second insert must fail, and this -- is what makes it fail. Without it the chain could silently become -- R1 -> {R2a, R2b} and no reader could say which claim VYNDR actually published. CREATE UNIQUE INDEX IF NOT EXISTS model_snapshots_supersedes_unique ON model_snapshots (supersedes_id) WHERE supersedes_id IS NOT NULL; -- ── LEDGER LINKAGE (SHADOW) ─────────────────────────────────────────────── -- A receipt should be able to name the EXACT revision it settles. These are -- written in shadow alongside the existing settlement path and are read by -- nothing; current settlement outcomes are unchanged. NULL on every existing -- row, and no historical receipt is re-pointed. ALTER TABLE ledger_entries ADD COLUMN IF NOT EXISTS read_id text; ALTER TABLE ledger_entries ADD COLUMN IF NOT EXISTS read_revision_id bigint; CREATE INDEX IF NOT EXISTS ledger_entries_read_id_idx ON ledger_entries (read_id) WHERE read_id IS NOT NULL; COMMENT ON COLUMN model_snapshots.read_id IS 'The stable identity of a logical Read across every published revision. Deliberately NOT derived from game_id: measured, 1,328 of 30,746 identity groups carry more than one game_id spelling for the same real game.'; COMMENT ON COLUMN model_snapshots.claim_digest IS 'sha256 over the market terms (line, side, book, locked_odds) plus gradeFreeze.SERVED_FIELDS. Separates a genuine new published claim from a re-observation of the standing one.'; COMMENT ON COLUMN model_snapshots.lineage_action IS 'ORIGIN | REVISION | RECAPTURE. Most cycles re-observe an unchanged claim; only a changed claim_digest advances the chain.'; COMMENT ON COLUMN ledger_entries.read_revision_id IS 'SHADOW: the exact model_snapshots row this receipt settles. Read by nothing; settlement outcomes are unchanged by migration 045.';