-- Migration 047: canonical event identity + publication commit (ADDITIVE ONLY). -- -- Every statement is ADD COLUMN IF NOT EXISTS or CREATE INDEX. Nothing is -- dropped, renamed, retyped or backfilled. Historical rows keep NULL. -- -- ── WHY: EVENT LABELS ARE NOT EVENT IDENTITY ───────────────────────────── -- `ledgerService.gameIdFor` derives `sport:date:away@home`. That has no -- occurrence component, so both halves of a doubleheader produce a -- BYTE-IDENTICAL string. -- -- VERIFIED against statsapi, MLB 2026 through 2026-08-27: 19 doubleheaders / -- 38 real games collapse into 19 derived labels. Real case 2026-08-17, -- St. Louis @ Cincinnati: gamePk 824514 (game 1, 17:40Z) and 824478 (game 2, -- 22:40Z). VYNDR recorded ONE id holding 171 ledger rows and FOUR distinct -- starting pitchers -- both games merged into one event. -- -- `canonical_event_id` is namespaced (`mlb:gamepk:824514`) so two sports' -- numeric ids can never collide. The legacy `game_id` is KEPT for diagnostics -- and compatibility and is explicitly not authoritative. ALTER TABLE model_snapshots ADD COLUMN IF NOT EXISTS canonical_event_id text; ALTER TABLE model_snapshots ADD COLUMN IF NOT EXISTS event_identity_source text; ALTER TABLE model_snapshots ADD COLUMN IF NOT EXISTS event_identity_method text; ALTER TABLE model_snapshots ADD COLUMN IF NOT EXISTS event_identity_version text; -- The source's own occurrence number (statsapi `gameNumber`). Never inferred. ALTER TABLE model_snapshots ADD COLUMN IF NOT EXISTS event_occurrence integer; -- ── WHY: PUBLISHED MUST MEAN COMMITTED ─────────────────────────────────── -- The authoritative served slate is ONE atomic Redis write, -- `cacheSet('snapshot:{sport}:latest')`. Retention persists BEFORE it -- (snapshotService:1137 vs :1152) and the winner is chosen earlier still, so -- neither capture nor selection means published. `published_at` records the -- commit instant and `publication_id` the slate it committed in -- the write is -- slate-atomic, so publication is slate-level and every served row shares one. -- -- `captured_at` answers when the model state was captured. `published_at` -- answers when the composed Read became authoritative. They are different -- questions and are stored separately. ALTER TABLE model_snapshots ADD COLUMN IF NOT EXISTS published_at timestamptz; ALTER TABLE model_snapshots ADD COLUMN IF NOT EXISTS publication_id text; -- Chronology is walked per canonical event. CREATE INDEX IF NOT EXISTS model_snapshots_canonical_event_idx ON model_snapshots (canonical_event_id) WHERE canonical_event_id IS NOT NULL; -- Publication parity is read per slate. CREATE INDEX IF NOT EXISTS model_snapshots_publication_idx ON model_snapshots (publication_id) WHERE publication_id IS NOT NULL; -- Ledger linkage to the canonical event, additive and read by nothing. ALTER TABLE ledger_entries ADD COLUMN IF NOT EXISTS canonical_event_id text; CREATE INDEX IF NOT EXISTS ledger_entries_canonical_event_idx ON ledger_entries (canonical_event_id) WHERE canonical_event_id IS NOT NULL; COMMENT ON COLUMN model_snapshots.canonical_event_id IS 'Namespaced canonical event identity, e.g. mlb:gamepk:824514. MLB only; other sports record UNSUPPORTED_SPORT rather than a guessed id. The legacy game_id is a derived LABEL and is not authoritative -- it cannot separate a doubleheader.'; COMMENT ON COLUMN model_snapshots.published_at IS 'When the composed Read became authoritative -- i.e. when the atomic slate write to snapshot:{sport}:latest succeeded. NOT the capture time, and never set by winner selection alone.'; COMMENT ON COLUMN model_snapshots.publication_id IS 'The slate whose atomic Redis write committed this row. Publication is slate-level because the write is.';