# Phase 2 — Backfill Canonical Layer Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Populate canonical tables (`tournaments`, `participants`, `tournament_results`, `participant_surface_elos`) from existing per-window rows, and set the FK columns (`scoring_events.tournament_id`, `season_participants.participant_id`). No runtime behavior changes; canonical tables become populated but are not yet read by any code path. **Architecture:** One-off script with `--dry-run` and `--sport` flags. For each `sports_seasons` row with `scoring_pattern='qualifying_points'`, upsert canonical rows by `(sport_id, name, year)` for tournaments and `(sport_id, name)` for participants; copy completed `event_results` up to `tournament_results`; merge existing `season_participant_surface_elos` rows into canonical `participant_surface_elos`. Real run is preceded by an exact-match dry-run review. **Tech Stack:** TypeScript, Drizzle ORM, PostgreSQL, Vitest + test DB. **Prerequisite:** Phase 1b complete. Tag `phase-1b-complete` exists. Baseline fixtures in `test-fixtures/baselines/` captured in Phase 1a. **Reference:** `docs/superpowers/specs/2026-05-01-canonical-tournament-layer-design.md` — Phase 2 section. **Important constraints:** - Do not copy `qualifying_points_awarded` from `event_results` to `tournament_results`. QP stays per-window. - Do not write `participant_qualifying_totals`-equivalent data to canonical. QP totals stay per-window. - Abort if two windows disagree on a canonical participant's surface Elo values (not expected today; fail loud). --- ### Task 1: Write the scoring-event → tournament matcher (pure function, TDD) **Why:** The matcher has zero DB dependency; extract and test it in isolation first. **Files:** - Create: `scripts/backfill/match-tournament.ts` - Create: `scripts/backfill/__tests__/match-tournament.test.ts` - [ ] **Step 1: Write failing tests** ```typescript // scripts/backfill/__tests__/match-tournament.test.ts import { describe, it, expect } from "vitest"; import { extractTournamentIdentity } from "../match-tournament"; describe("extractTournamentIdentity", () => { it("extracts (name, year) from a scoring event for golf", () => { const got = extractTournamentIdentity({ name: "Masters Tournament", eventDate: "2026-04-09", eventType: "major_tournament", }); expect(got).toEqual({ name: "Masters Tournament", year: 2026 }); }); it("uses eventDate year when event name doesn't embed year", () => { const got = extractTournamentIdentity({ name: "Wimbledon", eventDate: "2026-07-01", eventType: "major_tournament", }); expect(got).toEqual({ name: "Wimbledon", year: 2026 }); }); it("strips embedded year from name if present", () => { const got = extractTournamentIdentity({ name: "Wimbledon 2026", eventDate: "2026-07-01", eventType: "major_tournament", }); expect(got).toEqual({ name: "Wimbledon", year: 2026 }); }); it("throws if eventDate is null and name has no year", () => { expect(() => extractTournamentIdentity({ name: "Wimbledon", eventDate: null, eventType: "major_tournament", }), ).toThrow(/cannot determine year/i); }); }); ``` - [ ] **Step 2: Run failing test** Run: `npm run test:run -- scripts/backfill/__tests__/match-tournament.test.ts` Expected: FAIL — module missing. - [ ] **Step 3: Implement the pure function** ```typescript // scripts/backfill/match-tournament.ts interface ScoringEventInput { name: string; eventDate: string | null; eventType: string; } export interface TournamentIdentity { name: string; year: number; } const YEAR_SUFFIX = /\s+(\d{4})\s*$/; export function extractTournamentIdentity(ev: ScoringEventInput): TournamentIdentity { const trimmed = ev.name.trim(); const yearMatch = trimmed.match(YEAR_SUFFIX); let cleanName = trimmed; let year: number | null = null; if (yearMatch) { year = parseInt(yearMatch[1], 10); cleanName = trimmed.replace(YEAR_SUFFIX, "").trim(); } if (year === null && ev.eventDate) { year = parseInt(ev.eventDate.slice(0, 4), 10); } if (year === null) { throw new Error( `cannot determine year for event "${ev.name}" — no year in name and eventDate is null`, ); } return { name: cleanName, year }; } ``` - [ ] **Step 4: Verify tests pass** Run: `npm run test:run -- scripts/backfill/__tests__/match-tournament.test.ts` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add scripts/backfill/ git commit -m "feat(backfill): tournament identity extraction from scoring events" ``` --- ### Task 2: Write the backfill orchestrator with dry-run (TDD against test DB) **Files:** - Create: `scripts/backfill-canonical-layer.ts` - Create: `scripts/__tests__/backfill-canonical-layer.test.ts` - [ ] **Step 1: Write the test — empty DB no-op** ```typescript // scripts/__tests__/backfill-canonical-layer.test.ts import { describe, it, expect, beforeEach } from "vitest"; import { db } from "~/db/context"; import { tournaments, participants, tournamentResults, participantSurfaceElos, scoringEvents, seasonParticipants, sportsSeasons, sports, eventResults, seasonParticipantSurfaceElos, } from "database/schema"; import { runBackfill } from "../backfill-canonical-layer"; async function truncateAll() { await db.delete(tournamentResults); await db.delete(participantSurfaceElos); await db.delete(participants); await db.delete(tournaments); await db.delete(eventResults); await db.delete(seasonParticipantSurfaceElos); await db.delete(seasonParticipants); await db.delete(scoringEvents); await db.delete(sportsSeasons); await db.delete(sports); } describe("backfill-canonical-layer", () => { beforeEach(async () => { await truncateAll(); }); it("no-ops on empty DB", async () => { const result = await runBackfill({ dryRun: false }); expect(result.tournamentsCreated).toBe(0); expect(result.participantsCreated).toBe(0); expect(result.tournamentResultsCreated).toBe(0); expect(result.surfaceElosCreated).toBe(0); }); }); ``` - [ ] **Step 2: Run failing test** Expected: FAIL (module missing). - [ ] **Step 3: Implement skeleton** ```typescript // scripts/backfill-canonical-layer.ts import { db } from "~/db/context"; import { tournaments, participants, tournamentResults, participantSurfaceElos, scoringEvents, seasonParticipants, sportsSeasons, eventResults, seasonParticipantSurfaceElos, } from "database/schema"; import { eq, and, isNull } from "drizzle-orm"; import { extractTournamentIdentity } from "./backfill/match-tournament"; export interface BackfillOptions { dryRun: boolean; sportId?: string; } export interface BackfillReport { tournamentsCreated: number; tournamentsLinked: number; // scoringEvents with tournament_id now set participantsCreated: number; participantsLinked: number; // seasonParticipants with participant_id now set tournamentResultsCreated: number; surfaceElosCreated: number; warnings: string[]; errors: string[]; } export async function runBackfill(opts: BackfillOptions): Promise { const report: BackfillReport = { tournamentsCreated: 0, tournamentsLinked: 0, participantsCreated: 0, participantsLinked: 0, tournamentResultsCreated: 0, surfaceElosCreated: 0, warnings: [], errors: [], }; // Eligible sports seasons const seasons = await db .select() .from(sportsSeasons) .where(eq(sportsSeasons.scoringPattern, "qualifying_points")); for (const season of seasons) { if (opts.sportId && season.sportId !== opts.sportId) continue; await backfillSeason(season, opts, report); } return report; } async function backfillSeason( season: typeof sportsSeasons.$inferSelect, opts: BackfillOptions, report: BackfillReport, ): Promise { // Implemented in later tasks. } ``` - [ ] **Step 4: Verify empty-DB test passes** Run: `npm run test:run -- scripts/__tests__/backfill-canonical-layer.test.ts` Expected: PASS (empty DB no-op). - [ ] **Step 5: Commit** ```bash git add scripts/backfill-canonical-layer.ts scripts/__tests__/backfill-canonical-layer.test.ts git commit -m "feat(backfill): orchestrator skeleton with empty-DB test" ``` --- ### Task 3: Tournament + scoring-event linking **Files:** - Modify: `scripts/backfill-canonical-layer.ts` - Modify: `scripts/__tests__/backfill-canonical-layer.test.ts` - [ ] **Step 1: Add failing test — golf window with 4 events** Append to the test file: ```typescript async function seedGolfWindowWith4Events() { const [sport] = await db .insert(sports) .values({ name: "Golf", type: "individual", simulatorType: "golf_qualifying_points" }) .returning(); const [ss] = await db .insert(sportsSeasons) .values({ sportId: sport.id, year: 2026, scoringPattern: "qualifying_points", totalMajors: 4, majorsCompleted: 1, }) .returning(); const events = await db .insert(scoringEvents) .values([ { sportsSeasonId: ss.id, name: "Masters Tournament", eventDate: "2026-04-09", eventType: "major_tournament", isQualifyingEvent: true }, { sportsSeasonId: ss.id, name: "PGA Championship", eventDate: "2026-05-14", eventType: "major_tournament", isQualifyingEvent: true }, { sportsSeasonId: ss.id, name: "US Open", eventDate: "2026-06-18", eventType: "major_tournament", isQualifyingEvent: true }, { sportsSeasonId: ss.id, name: "The Open Championship", eventDate: "2026-07-16", eventType: "major_tournament", isQualifyingEvent: true }, ]) .returning(); return { sport, ss, events }; } it("creates tournaments and links scoringEvents for a golf window", async () => { const { sport, events } = await seedGolfWindowWith4Events(); const report = await runBackfill({ dryRun: false }); expect(report.tournamentsCreated).toBe(4); expect(report.tournamentsLinked).toBe(4); const ts = await db.select().from(tournaments).where(eq(tournaments.sportId, sport.id)); expect(ts.map((t) => t.name).sort()).toEqual( ["Masters Tournament", "PGA Championship", "The Open Championship", "US Open"], ); expect(ts.every((t) => t.year === 2026)).toBe(true); const linkedEvents = await db.select().from(scoringEvents).where(eq(scoringEvents.sportsSeasonId, events[0].sportsSeasonId)); expect(linkedEvents.every((e) => e.tournamentId !== null)).toBe(true); }); ``` - [ ] **Step 2: Run failing test** Expected: FAIL (nothing is created yet). - [ ] **Step 3: Implement tournament backfill in `backfillSeason`** ```typescript async function backfillSeason(season, opts, report) { const events = await db .select() .from(scoringEvents) .where(eq(scoringEvents.sportsSeasonId, season.id)); for (const ev of events) { if (ev.tournamentId) continue; // already linked let identity; try { identity = extractTournamentIdentity({ name: ev.name, eventDate: ev.eventDate, eventType: ev.eventType, }); } catch (e: any) { report.errors.push(`event ${ev.id}: ${e.message}`); continue; } const existing = await db .select() .from(tournaments) .where( and( eq(tournaments.sportId, season.sportId), eq(tournaments.name, identity.name), eq(tournaments.year, identity.year), ), ); let tournamentId: string; if (existing.length > 0) { tournamentId = existing[0].id; } else { if (!opts.dryRun) { const [row] = await db .insert(tournaments) .values({ sportId: season.sportId, name: identity.name, year: identity.year, startsAt: ev.eventDate ? new Date(ev.eventDate) : null, status: ev.eventDate && new Date(ev.eventDate) < new Date() ? "completed" : "scheduled", }) .returning(); tournamentId = row.id; } else { tournamentId = "00000000-0000-0000-0000-000000000000"; // placeholder } report.tournamentsCreated += 1; } if (!opts.dryRun) { await db .update(scoringEvents) .set({ tournamentId }) .where(eq(scoringEvents.id, ev.id)); } report.tournamentsLinked += 1; } } ``` - [ ] **Step 4: Run tests** Run: `npm run test:run -- scripts/__tests__/backfill-canonical-layer.test.ts` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add scripts/ git commit -m "feat(backfill): link scoring events to canonical tournaments" ``` --- ### Task 4: Participant backfill + linking **Files:** - Modify: `scripts/backfill-canonical-layer.ts` - Modify: `scripts/__tests__/backfill-canonical-layer.test.ts` - [ ] **Step 1: Add failing test** ```typescript it("creates canonical participants and links season_participants", async () => { const { sport, ss } = await seedGolfWindowWith4Events(); const [sp1] = await db .insert(seasonParticipants) .values({ sportsSeasonId: ss.id, name: "Rory McIlroy" }) .returning(); const [sp2] = await db .insert(seasonParticipants) .values({ sportsSeasonId: ss.id, name: "Scottie Scheffler" }) .returning(); const report = await runBackfill({ dryRun: false }); expect(report.participantsCreated).toBe(2); expect(report.participantsLinked).toBe(2); const canonical = await db.select().from(participants).where(eq(participants.sportId, sport.id)); expect(canonical.map((p) => p.name).sort()).toEqual(["Rory McIlroy", "Scottie Scheffler"]); const [relinkedSP] = await db.select().from(seasonParticipants).where(eq(seasonParticipants.id, sp1.id)); expect(relinkedSP.participantId).toBe(canonical.find((p) => p.name === "Rory McIlroy")!.id); }); it("re-running the backfill does not create duplicate canonical participants", async () => { const { ss } = await seedGolfWindowWith4Events(); await db.insert(seasonParticipants).values({ sportsSeasonId: ss.id, name: "Jon Rahm" }); await runBackfill({ dryRun: false }); await runBackfill({ dryRun: false }); const rows = await db.select().from(participants); expect(rows.filter((r) => r.name === "Jon Rahm")).toHaveLength(1); }); ``` - [ ] **Step 2: Run failing tests** Expected: FAIL. - [ ] **Step 3: Extend `backfillSeason` with participant logic** Append inside `backfillSeason` (after the event loop): ```typescript const seasonPs = await db .select() .from(seasonParticipants) .where(eq(seasonParticipants.sportsSeasonId, season.id)); for (const sp of seasonPs) { if (sp.participantId) continue; const existing = await db .select() .from(participants) .where(and(eq(participants.sportId, season.sportId), eq(participants.name, sp.name))); let canonicalId: string; if (existing.length > 0) { canonicalId = existing[0].id; } else { if (!opts.dryRun) { const [row] = await db .insert(participants) .values({ sportId: season.sportId, name: sp.name }) .returning(); canonicalId = row.id; } else { canonicalId = "00000000-0000-0000-0000-000000000000"; } report.participantsCreated += 1; } if (!opts.dryRun) { await db .update(seasonParticipants) .set({ participantId: canonicalId }) .where(eq(seasonParticipants.id, sp.id)); } report.participantsLinked += 1; } ``` - [ ] **Step 4: Run tests** Expected: PASS. - [ ] **Step 5: Commit** ```bash git add scripts/ git commit -m "feat(backfill): canonical participants + season-participant linking" ``` --- ### Task 5: Copy completed event results to tournament_results (Masters 2026 case) **Files:** - Modify: `scripts/backfill-canonical-layer.ts` - Modify: `scripts/__tests__/backfill-canonical-layer.test.ts` - [ ] **Step 1: Add failing test — Masters 2026 round-trip** ```typescript it("copies completed event_results to tournament_results without touching QP", async () => { const { ss, events } = await seedGolfWindowWith4Events(); const masters = events.find((e) => e.name === "Masters Tournament")!; const [sp] = await db .insert(seasonParticipants) .values({ sportsSeasonId: ss.id, name: "Scottie Scheffler" }) .returning(); await db.insert(eventResults).values({ scoringEventId: masters.id, seasonParticipantId: sp.id, placement: 1, rawScore: "-10", qualifyingPointsAwarded: "100.00", }); const report = await runBackfill({ dryRun: false }); expect(report.tournamentResultsCreated).toBe(1); const trs = await db.select().from(tournamentResults); expect(trs).toHaveLength(1); expect(trs[0].placement).toBe(1); expect(trs[0].rawScore).toBe("-10"); // QP on event_results must be untouched const [er] = await db.select().from(eventResults).where(eq(eventResults.scoringEventId, masters.id)); expect(er.qualifyingPointsAwarded).toBe("100.00"); }); ``` - [ ] **Step 2: Run failing test** Expected: FAIL. - [ ] **Step 3: Extend `backfillSeason`** Append inside `backfillSeason` after participant linking: ```typescript // Refresh events to get tournament_ids set earlier in this loop const refreshedEvents = await db .select() .from(scoringEvents) .where(eq(scoringEvents.sportsSeasonId, season.id)); for (const ev of refreshedEvents) { if (!ev.tournamentId) continue; const ers = await db .select() .from(eventResults) .where(eq(eventResults.scoringEventId, ev.id)); for (const er of ers) { // Find canonical participant via the season participant const [sp] = await db .select() .from(seasonParticipants) .where(eq(seasonParticipants.id, er.seasonParticipantId)); if (!sp || !sp.participantId) continue; // Skip the zero-QP filler rows (they carry no new information) if (er.placement === null && er.rawScore === null) continue; const existing = await db .select() .from(tournamentResults) .where( and( eq(tournamentResults.tournamentId, ev.tournamentId), eq(tournamentResults.participantId, sp.participantId), ), ); if (existing.length > 0) continue; if (!opts.dryRun) { await db.insert(tournamentResults).values({ tournamentId: ev.tournamentId, participantId: sp.participantId, placement: er.placement, rawScore: er.rawScore, }); } report.tournamentResultsCreated += 1; } } ``` - [ ] **Step 4: Run tests** Expected: PASS. - [ ] **Step 5: Commit** ```bash git add scripts/ git commit -m "feat(backfill): copy completed event_results into tournament_results" ``` --- ### Task 6: Surface Elo backfill (with conflict detection) **Files:** - Modify: `scripts/backfill-canonical-layer.ts` - Modify: `scripts/__tests__/backfill-canonical-layer.test.ts` - [ ] **Step 1: Add failing tests** ```typescript async function seedTennisWithSurfaceElo() { const [sport] = await db .insert(sports) .values({ name: "Tennis", type: "individual", simulatorType: "tennis_qualifying_points" }) .returning(); const [ss] = await db .insert(sportsSeasons) .values({ sportId: sport.id, year: 2026, scoringPattern: "qualifying_points", totalMajors: 4 }) .returning(); const [sp] = await db .insert(seasonParticipants) .values({ sportsSeasonId: ss.id, name: "Novak Djokovic" }) .returning(); await db .insert(seasonParticipantSurfaceElos) .values({ participantId: sp.id, sportsSeasonId: ss.id, eloHard: 2100, eloClay: 2000, eloGrass: 2050, worldRanking: 3, }); return { sport, ss, sp }; } it("backfills canonical surface elo from the single per-window row", async () => { const { sport } = await seedTennisWithSurfaceElo(); const report = await runBackfill({ dryRun: false }); expect(report.surfaceElosCreated).toBe(1); const canonicalPs = await db.select().from(participants).where(eq(participants.sportId, sport.id)); const [canonicalElo] = await db .select() .from(participantSurfaceElos) .where(eq(participantSurfaceElos.participantId, canonicalPs[0].id)); expect(canonicalElo.eloHard).toBe(2100); expect(canonicalElo.eloClay).toBe(2000); expect(canonicalElo.eloGrass).toBe(2050); expect(canonicalElo.worldRanking).toBe(3); }); it("aborts if two windows disagree on a canonical participant's surface elo", async () => { const { sport, ss, sp } = await seedTennisWithSurfaceElo(); // Second window with same canonical participant name but different elo const [ss2] = await db .insert(sportsSeasons) .values({ sportId: sport.id, year: 2027, scoringPattern: "qualifying_points", totalMajors: 4 }) .returning(); const [sp2] = await db .insert(seasonParticipants) .values({ sportsSeasonId: ss2.id, name: "Novak Djokovic" }) .returning(); await db.insert(seasonParticipantSurfaceElos).values({ participantId: sp2.id, sportsSeasonId: ss2.id, eloHard: 2099, // different eloClay: 2000, eloGrass: 2050, }); const report = await runBackfill({ dryRun: false }); expect(report.errors.some((e) => /conflict/i.test(e))).toBe(true); }); ``` - [ ] **Step 2: Run failing tests** Expected: FAIL. - [ ] **Step 3: Extend `backfillSeason`** Append to `backfillSeason`: ```typescript const elos = await db .select() .from(seasonParticipantSurfaceElos) .where(eq(seasonParticipantSurfaceElos.sportsSeasonId, season.id)); for (const row of elos) { const [sp] = await db .select() .from(seasonParticipants) .where(eq(seasonParticipants.id, row.participantId)); if (!sp || !sp.participantId) continue; const [existing] = await db .select() .from(participantSurfaceElos) .where(eq(participantSurfaceElos.participantId, sp.participantId)); if (existing) { const mismatched = existing.eloHard !== row.eloHard || existing.eloClay !== row.eloClay || existing.eloGrass !== row.eloGrass || existing.worldRanking !== row.worldRanking; if (mismatched) { report.errors.push( `surface-elo conflict for canonical participant ${sp.participantId} (name=${sp.name})`, ); } continue; } if (!opts.dryRun) { await db.insert(participantSurfaceElos).values({ participantId: sp.participantId, eloHard: row.eloHard, eloClay: row.eloClay, eloGrass: row.eloGrass, worldRanking: row.worldRanking, }); } report.surfaceElosCreated += 1; } ``` - [ ] **Step 4: Run tests** Expected: PASS. - [ ] **Step 5: Commit** ```bash git add scripts/ git commit -m "feat(backfill): canonical surface-elo with conflict detection" ``` --- ### Task 7: CLI wrapper + dry-run output **Files:** - Create: `scripts/backfill-cli.ts` (CLI entry) - Modify: `scripts/backfill-canonical-layer.ts` (optional — if the main file exported a report shape, we format it here) - [ ] **Step 1: Write the CLI** ```typescript // scripts/backfill-cli.ts import { runBackfill, BackfillOptions } from "./backfill-canonical-layer"; function parseArgs(): BackfillOptions { const args = process.argv.slice(2); const opts: BackfillOptions = { dryRun: true }; for (const a of args) { if (a === "--apply") opts.dryRun = false; else if (a === "--dry-run") opts.dryRun = true; else if (a.startsWith("--sport=")) opts.sportId = a.slice("--sport=".length); else if (a === "--help") { console.log("Usage: tsx scripts/backfill-cli.ts [--dry-run|--apply] [--sport=]"); process.exit(0); } else { console.error(`unknown arg: ${a}`); process.exit(1); } } return opts; } async function main() { const opts = parseArgs(); console.log(`Running backfill (dryRun=${opts.dryRun}, sportId=${opts.sportId ?? "all"})`); const report = await runBackfill(opts); console.log("---"); console.log(`tournamentsCreated: ${report.tournamentsCreated}`); console.log(`tournamentsLinked: ${report.tournamentsLinked}`); console.log(`participantsCreated: ${report.participantsCreated}`); console.log(`participantsLinked: ${report.participantsLinked}`); console.log(`tournamentResultsCreated: ${report.tournamentResultsCreated}`); console.log(`surfaceElosCreated: ${report.surfaceElosCreated}`); if (report.warnings.length) { console.log("\nWARNINGS:"); for (const w of report.warnings) console.log(` ${w}`); } if (report.errors.length) { console.log("\nERRORS:"); for (const e of report.errors) console.log(` ${e}`); process.exit(2); } } main().catch((e) => { console.error(e); process.exit(1); }); ``` - [ ] **Step 2: Add npm script** In `package.json` scripts block, add: ```json "backfill:canonical": "dotenv -- tsx scripts/backfill-cli.ts" ``` - [ ] **Step 3: Run dry-run locally** Against the dev DB (loaded with `npm run db:sync-prod`): Run: `npm run backfill:canonical -- --dry-run` Expected: Non-zero `tournamentsCreated` etc., zero `errors`. Save stdout as `/tmp/backfill-dryrun.txt`. - [ ] **Step 4: Inspect the dry-run output** Review `/tmp/backfill-dryrun.txt`. For each category, sanity-check the count. For example, if we have 2 in-flight golf windows × 4 events each = 8 golf events, `tournamentsCreated` might be 4–8 depending on overlap. - [ ] **Step 5: Commit** ```bash git add scripts/backfill-cli.ts package.json git commit -m "feat(backfill): CLI wrapper with dry-run default" ``` --- ### Task 8: Go/no-go verification (real run on staging) **Human in the loop** — this task is NOT automatable. - [ ] **Step 1: Deploy all preceding commits to staging** Via the project's normal deploy flow. - [ ] **Step 2: Capture a pre-backfill snapshot of the in-flight windows** On staging, run: ```bash npx tsx scripts/capture-baseline.ts --out=/tmp/pre-backfill-staging ``` - [ ] **Step 3: Run dry-run on staging** ```bash npm run backfill:canonical -- --dry-run ``` Expected: zero errors; counts match expectations. - [ ] **Step 4: Human review of dry-run report** Requires a human. Verify: - Tournament names match real-world identity (no "Masters Tournament 2026" with year=null). - Participant counts per sport are sensible. - No conflict errors. If anything looks wrong, fix in code (not in DB), redeploy, re-run dry-run. - [ ] **Step 5: Run real backfill on staging** ```bash npm run backfill:canonical -- --apply ``` Expected: Same counts as dry-run, zero errors. - [ ] **Step 6: Verify no drift on QP totals** ```bash npx tsx scripts/capture-baseline.ts --out=/tmp/post-backfill-staging diff /tmp/pre-backfill-staging/ /tmp/post-backfill-staging/ ``` Expected: **Zero differences in `qp-totals-*.json` and `event-results-*.json`**. Backfill must not touch these. The `surface-elos-*.json` files may differ because this captures per-window Elo before Phase 2; after Phase 2 it still captures the same per-window table (`season_participant_surface_elos`), which is untouched by backfill. So diff should still be empty. If not, stop and investigate. - [ ] **Step 7: Spot-check Masters 2026 round-trip on staging** ```bash psql $STAGING_DB -c " SELECT er.placement AS ev_placement, er.raw_score AS ev_score, tr.placement AS tr_placement, tr.raw_score AS tr_score, sp.name FROM event_results er JOIN scoring_events se ON se.id = er.scoring_event_id JOIN tournaments t ON t.id = se.tournament_id JOIN tournament_results tr ON tr.tournament_id = t.id AND tr.participant_id = (SELECT participant_id FROM season_participants WHERE id = er.season_participant_id) JOIN season_participants sp ON sp.id = er.season_participant_id WHERE t.name = 'Masters Tournament' AND t.year = 2026; " ``` Expected: Every row has `ev_placement = tr_placement` and `ev_score = tr_score`. - [ ] **Step 8: Proceed to production only if all checks pass** Same flow on prod: capture baseline → dry-run → human review → apply → diff → Masters spot-check. - [ ] **Step 9: Tag** ```bash git tag phase-2-complete ``` --- ## Self-Review Checklist - [ ] `runBackfill` with `dryRun: true` writes nothing (verify via row-count diff). - [ ] All tests in `scripts/__tests__/backfill-canonical-layer.test.ts` pass. - [ ] After real run on prod: every `scoringEvents` row in a `qualifying_points` season has non-null `tournament_id`. - [ ] Every `seasonParticipants` row in a `qualifying_points` season has non-null `participant_id`. - [ ] Every completed `eventResults` row (non-null `placement` OR non-null `rawScore`) has a matching `tournamentResults` row. - [ ] No drift in `participant_qualifying_totals` vs. baseline. - [ ] Tag `phase-2-complete` on main. ## Rollback Phase 2 is reversible because no production code reads canonical tables yet: ```sql BEGIN; UPDATE season_participants SET participant_id = NULL WHERE participant_id IS NOT NULL; UPDATE scoring_events SET tournament_id = NULL WHERE tournament_id IS NOT NULL; DELETE FROM tournament_results; DELETE FROM participant_surface_elos; DELETE FROM participants; DELETE FROM tournaments; COMMIT; ``` Then re-run backfill after fixing root cause. Phase 3 begins only after `phase-2-complete` has been green in production for 24 hours.