# Canonical Tournament & Participant Layer

**Date:** 2026-05-01
**Status:** Proposed

## Overview

Qualifying-points sports (golf, tennis, CS2) use a four-event "season" that can roll across calendar boundaries (e.g., a June 2026 tennis window needs Wimbledon 2026, US Open 2026, Australian Open 2027, French Open 2027). Today the data model ties events and participants to a single `sportsSeasons` row, which means two overlapping windows must each have their own copy of Wimbledon 2026, their own Djokovic, their own surface Elo. Any change — results, corrections, Elo adjustments — must be applied to every copy.

This design introduces a **canonical layer** above the existing per-window layer:

- **Canonical** tables own real-world identity (tournaments, participants, final results, surface Elo).
- **Per-window** tables keep their role: which tournaments a window uses, which participants its draft pool contains, and window-specific derived data (EV, QP totals, QP config).

Results entered once on the canonical tournament fan out to every window that contains it. Surface Elo edited on a canonical participant propagates automatically. Creating a new rolling window is a point-and-click composition over the canonical data.

Per-window data that is inherently window-specific (expected value under a particular 4-event set, QP running totals, QP point configuration) stays per-window — because the math depends on which 4 events you're summing over.

## Scope

- **In scope:** All `scoringPattern='qualifying_points'` sports — golf, tennis, CS2. The model is sport-agnostic, but migration covers these three.
- **Out of scope:** Team sports (NHL, MLB, NBA, WNBA, UCL, NCAAM/W), single-event sports (darts and snooker use only the World Championship — no rolling window problem). These keep their existing model.
- **Out of scope:** Historical fully-completed windows. No league currently has a completed qualifying-points sportsSeason, so there's nothing to backfill for reporting.

## Architecture

### Canonical tables (new)

```
tournaments
  id            uuid pk
  sport_id      fk → sports
  name          varchar           -- "Wimbledon"
  year          integer
  starts_at     timestamp
  ends_at       timestamp
  surface       varchar nullable  -- tennis only: hard | clay | grass
  location      varchar nullable
  status        enum              -- scheduled | in_progress | completed
  external_key  varchar nullable  -- reserved for future ATP/PGA/HLTV IDs
  created_at, updated_at
  UNIQUE (sport_id, name, year)

participants            -- RENAMED; see migration section
  id            uuid pk
  sport_id      fk → sports
  name          varchar
  external_key  varchar nullable
  metadata      jsonb
  created_at, updated_at
  UNIQUE (sport_id, name)

tournament_results
  id            uuid pk
  tournament_id fk → tournaments
  participant_id fk → participants
  placement     integer
  raw_score     decimal nullable
  created_at, updated_at
  UNIQUE (tournament_id, participant_id)

participant_surface_elos       -- tennis-specific; replaces the existing per-window table of the same name
  id            uuid pk
  participant_id fk → participants  (canonical)
  world_ranking integer nullable
  elo_hard      integer nullable
  elo_clay      integer nullable
  elo_grass     integer nullable
  updated_at
  UNIQUE (participant_id)
```

**Shape note:** Kept wide format (`elo_hard`/`elo_clay`/`elo_grass`) to match the existing per-window `participant_surface_elos` table, minimizing simulator read-path churn. `world_ranking` moves here because it's a real-world attribute of the participant, not a per-window value.

**Name collision handling:** The existing per-window `participant_surface_elos` table is renamed to `season_participant_surface_elos` in Phase 1 before the new canonical table of the same name is created.

### Per-window tables (renamed / extended)

The existing `participants` table is renamed to `season_participants`. Related tables follow the same renaming pattern for clarity:

| Old name | New name |
|---|---|
| `participants` | `season_participants` |
| `participant_expected_values` | `season_participant_expected_values` |
| `participant_qualifying_totals` | `season_participant_qualifying_totals` |
| `participant_results` | `season_participant_results` |
| `event_results.participant_id` | `event_results.season_participant_id` |
| `participant_surface_elos` (existing per-window) | `season_participant_surface_elos` (renamed in Phase 1); removed entirely in Phase 4 after canonical values verified |

FK additions (nullable at Phase 1, NOT NULL after Phase 2 backfill):

- `scoring_events.tournament_id` → `tournaments.id`
- `season_participants.participant_id` → `participants.id`

Unchanged: `sports_seasons`, `season_sports`, `qualifying_point_config`, `draft_picks`, `teams`, `draft_slots`, `watchlist`, scoring calculation code in `app/models/scoring-calculator.ts`, simulators' Monte Carlo logic.

### Source of truth

| Concept | Table |
|---|---|
| Tournament existence & final placements | `tournament_results` (canonical) |
| Window → tournament mapping | `scoring_events` via `tournament_id` |
| Canonical participant identity | `participants` |
| Window's draftable roster | `season_participants` |
| Per-window derived scoring (QP awarded, totals, EV) | `season_participant_*`, `event_results` |
| Surface Elo & world ranking | `participant_surface_elos` (canonical) |

## Data Flow

### Creating a new window

Admin flow has two new steps during `sports_seasons` creation for qualifying-points sports:

1. **Pick tournaments** — multi-select from `tournaments` filtered by `sport_id`. Inline create if missing. Writes `scoring_events` rows with `tournament_id` set.
2. **Pick roster** — multi-select from `participants` filtered by `sport_id`. Writes `season_participants` rows with `participant_id` set. Inline-create canonical participants if missing.

Downstream (EV upload, QP config edit, draft) is unchanged.

### Entering results (enter-once flow)

New admin route: `/admin/tournaments/:id`.

1. Admin enters placements for a canonical tournament. UI supports the same paste-and-parse flow as the existing `2026-04-12-batch-qualifying-results` design, but targets the canonical tournament.
2. Save writes `tournament_results` rows (upsert on `(tournament_id, participant_id)`).
3. `syncTournamentResults(tournamentId)` fans out:
   - For each `scoring_events` row with `tournament_id = tournamentId`:
     - For each participant in `tournament_results` whose canonical `participant_id` also appears in that window's `season_participants`: upsert a matching `event_results` row with `placement` and `raw_score`.
     - Apply existing "fill 0-QP for non-scoring season participants" behavior per the batch qualifying results design.
     - Run the existing `processQualifyingEvent` (idempotent — replaces QP, doesn't add) against that window's scoring event.
     - Update `season_participant_qualifying_totals` per the existing path.
   - Each window's fan-out is its own transaction. Failures are logged per-window with a retry action in the admin UI.
4. Tournament status auto-advances to `completed` on first result insert.

### Corrections

Edit `tournament_results` → re-run `syncTournamentResults`. Idempotent updates to every affected window. No stale data unless sync fails (surfaced in UI).

### Surface Elo

Read path: `app/models/surface-elo.ts`'s `getSurfaceEloMap(sportsSeasonId)` joins `season_participants → participants → participant_surface_elos` (canonical). Admin UI for surface Elo edits canonical `participant_surface_elos` directly, keyed on canonical participant. Edit affects all current and future windows containing that participant.

## Migration Plan

Four phases. Each is reversible until the next begins.

### Phase 1 — Schema (data untouched)

Two sequential Drizzle migrations, in order, to avoid name collisions between the old per-window tables and the new canonical tables:

**Phase 1a — Renames of existing tables (no new tables yet):**
- `participants` → `season_participants`
- `participant_expected_values` → `season_participant_expected_values`
- `participant_qualifying_totals` → `season_participant_qualifying_totals`
- `participant_results` → `season_participant_results`
- `participant_surface_elos` → `season_participant_surface_elos`
- `event_results.participant_id` → `event_results.season_participant_id`

Update every code reference, Drizzle relations, model file names, and type imports. Existing `app/models/participant.ts` is renamed to `app/models/season-participant.ts`. `npm run typecheck` and `npm run test:run` must pass before proceeding to Phase 1b.

**Phase 1b — New canonical tables:**
- Create `tournaments`, `participants` (canonical), `tournament_results`, `participant_surface_elos` (canonical).
- Add nullable FKs: `scoring_events.tournament_id`, `season_participants.participant_id`.
- A new `app/models/participant.ts` is created for the canonical table (the old model file was renamed in 1a, freeing the name).

No logic paths read from canonical tables yet.

Reversible: run migrations in reverse (drop canonical tables first, then reverse the renames).

### Phase 2 — Backfill

One-off script `scripts/backfill-canonical-layer.ts` with `--dry-run` and `--sport=<id>` flags.

For each `sports_seasons` where `scoring_pattern='qualifying_points'`:

1. Upsert `tournaments` rows by `(sport_id, name, year)` from the window's `scoring_events`. Set `scoring_events.tournament_id`.
2. Upsert `participants` rows by `(sport_id, name)` from the window's `season_participants`. Set `season_participants.participant_id`.
3. For each `event_results` row with `qualifying_points_awarded IS NOT NULL` (i.e., already-scored events like Masters 2026): upsert a `tournament_results` row with `placement` and `raw_score`. **Do not copy `qualifying_points_awarded`** — QP stays on `event_results` because it's window-specific.
4. For each row in `season_participant_surface_elos` (the renamed per-window table): upsert into canonical `participant_surface_elos` keyed by canonical `participant_id`, copying `elo_hard`, `elo_clay`, `elo_grass`, `world_ranking`. Abort if two windows disagree on a canonical participant's values (not expected today — fail loud if encountered).

Dry-run writes nothing; emits a CSV + summary to stdout.

Go/no-go: dry-run reviewed by maintainer, real run matches dry-run counts exactly, spot-check Masters 2026 `tournament_results` row matches its `event_results` row.

Reversible: truncate canonical tables, null out FK columns. Old code paths still functional.

### Phase 3 — Cutover

- `ALTER` `scoring_events.tournament_id` and `season_participants.participant_id` to NOT NULL.
- Implement `app/models/tournament.ts`, `app/models/participant.ts` (canonical), `app/models/tournament-result.ts`.
- Implement `app/services/sync-tournament-results.ts`.
- Add admin route `app/routes/admin.tournaments.$id.tsx` with paste-and-parse result entry.
- Add admin "create window" flow additions for tournament + roster picking.
- Switch surface Elo admin UI and `getSurfaceEloMap` to canonical read/write.
- Switch tennis simulator's surface Elo source to canonical.
- Existing per-window "Add Result" / batch entry routes become thin wrappers that write canonical + call sync. (They are removed in Phase 4.)

Feature-flag the new admin entry route; cut over during a no-draft window.

Go/no-go: new window created end-to-end in staging via canonical flow; all pre-Phase-1 baselines match post-Phase-3 values.

### Phase 4 — Cleanup

- Remove per-window result entry wrappers.
- Drop `season_participant_surface_elos` table via migration after verifying canonical `participant_surface_elos` is fully populated and all code paths read from canonical.
- Remove any compatibility shims.

## Testing Matrix

Required types: **U** unit, **M** model integration (real DB), **E** end-to-end (Cypress).

### Baselines to capture before Phase 1

1. Monte Carlo simulator output JSON for each in-flight qualifying-points window, committed as a test fixture.
2. Snapshot of `participant_qualifying_totals` (pre-rename; post-Phase-1a this is `season_participant_qualifying_totals`) per window.
3. Snapshot of each window's `event_results` for completed events (Masters 2026, any completed CS2 majors).

These are used to verify no scoring drift across phases.

### Phase 1 — Schema

| What | Where | Type | Scenarios |
|---|---|---|---|
| Drizzle migration applies cleanly | staging run + `npm run db:migrate` | — | Forward run; re-run `db:generate` and confirm no drift |
| Renames propagate in code | `npm run typecheck`, `npm run test:run` | — | Pass |
| Relations load | existing model tests (updated for renames) | M | All pass |

### Phase 2 — Backfill script

| What | Where | Type | Scenarios |
|---|---|---|---|
| Backfill dry-run | `scripts/__tests__/backfill-canonical-layer.test.ts` (new) | M | (a) Empty DB → no-op. (b) One in-flight golf window with 4 events incl. Masters 2026 → 4 tournaments, N canonical participants, N `tournament_results` rows. (c) Two tennis windows with overlapping participants → one canonical participant per (sport, name). (d) Conflicting canonical rows pre-existing → script aborts with clear error. (e) Dry-run writes nothing; real run matches dry-run CSV exactly. |
| Surface Elo backfill | same test file | M | All existing surface Elo rows translate to `participant_surface_elos (canonical)`; counts match; conflicts abort. |
| Masters 2026 round-trip | same test file | M | Post-backfill: `tournament_results` for Masters 2026 matches `event_results` placement/raw_score; `qualifying_points_awarded` untouched on `event_results`; `season_participant_qualifying_totals` untouched. |
| Baseline verification | `scripts/__tests__/verify-baselines.test.ts` (new) | M | `season_participant_qualifying_totals` post-backfill matches pre-Phase-1 baseline snapshot of `participant_qualifying_totals` (they should be identical — backfill doesn't touch QP). |

### Phase 3 — New code paths

| What | Where | Type | Scenarios |
|---|---|---|---|
| `app/models/tournament.ts` | `app/models/__tests__/tournament.test.ts` | M | CRUD, unique `(sport_id, name, year)`, `findBySport`, status auto-advance. |
| `app/models/participant.ts` (canonical) | `app/models/__tests__/participant.test.ts` | M | CRUD, unique `(sport_id, name)`, find-or-create. |
| `app/models/tournament-result.ts` | `app/models/__tests__/tournament-result.test.ts` | M | Upsert semantics, placement updates, idempotency. |
| `app/services/sync-tournament-results.ts` | `app/services/__tests__/sync-tournament-results.test.ts` | M | (a) Single window, roster matches all result participants → `event_results` created, QP processed. (b) Two windows share a tournament with overlapping but different rosters → only matching participants get `event_results` in each window; QP processed independently per window. (c) Participant in roster but not in `tournament_results` → 0-QP row (matches existing batch finalize). (d) Re-run with same data → idempotent, no duplicates. (e) Re-run after placement correction → all windows updated, QP re-processed. (f) One window's sync fails → others still succeed; failure logged and retryable. |
| Admin `/admin/tournaments/:id` route | `app/routes/__tests__/admin.tournaments.$id.test.tsx` (new) | M | Loader returns tournament + results; action upserts + triggers sync; non-admin → 403. |
| Admin create-window flow | `cypress/e2e/admin-create-qualifying-window.cy.ts` (new) | E | Admin picks 4 tournaments + roster → window created with FKs populated; draft room loads. |
| Result entry fan-out | `cypress/e2e/enter-tournament-result.cy.ts` (new) | E | Two windows share Wimbledon 2026; admin enters Wimbledon results once; both windows' QP standings update. |
| Surface Elo canonical | `app/models/__tests__/surface-elo.test.ts` (update) | M | Reads and writes go to canonical `participant_surface_elos`; tennis simulator receives correct values; world_ranking reads correctly. |
| Tennis simulator surface Elo | `app/services/simulations/__tests__/tennis-simulator.test.ts` (update) | U | Fed canonical surface Elo; Monte Carlo output matches baseline fixture within tolerance. |
| Back-compat wrappers for old per-window routes | existing route tests (update) | M | Writes via old routes still succeed and produce identical `event_results`. |
| Masters 2026 regression | `scripts/__tests__/verify-baselines.test.ts` | M | Post-Phase-3 QP standings for existing golf window match pre-Phase-1 baseline exactly. |

### Phase 4 — Cleanup

| What | Where | Type | Scenarios |
|---|---|---|---|
| Old result-entry routes removed | removed test files | — | Routes return 404. |
| Drop `season_participant_surface_elos` | staging verification | — | Canonical `participant_surface_elos` fully populated; `season_participant_surface_elos` no longer read by any code path; drop migration applies cleanly. |
| Full regression | `npm run test:all` | — | Green. |

### Cross-cutting smoke tests (after every phase)

1. Load an in-flight golf draft room → Masters 2026 results render, QP standings match baseline.
2. Run tennis simulator for an in-flight window → Monte Carlo output matches baseline fixture within tolerance.
3. Create a fresh draft for an existing window → draft picks save (no NULL FK errors).

## Risks & Mitigations

**R1. Name collision for canonical participants.** `UNIQUE (sport_id, name)` is correct for today's roster sizes. `external_key` reserved for future disambiguation via ATP/PGA/HLTV IDs.

**R2. CS2 team-name churn.** Rebranded team is a new canonical participant. Intentional — matches current fantasy semantics.

**R3. Masters 2026 double-scoring.** `processQualifyingEvent` is already idempotent (replaces QP). Explicit test in Phase 2 + Phase 3 matrix.

**R4. Sync fan-out partial failure.** Per-window transactions. Each window logs success/failure independently. Admin UI shows "synced to X of N windows" with per-window retry.

**R5. Simulator output drift.** Baseline Monte Carlo output captured before Phase 1 and compared after each subsequent phase.

**R6. Draft-in-progress during cutover.** Phase 3 deployed behind a feature flag, enabled during a no-draft window.

**R7. Admin muscle memory.** Old routes stay as wrappers through Phase 3; removed in Phase 4. Release notes accompany Phase 3 cutover.

## Out of Scope / Flagged for Later

- Backfill of fully-completed historical windows: no such windows exist today.
- Automated import of tournament results from external feeds (ATP/PGA APIs). `external_key` column reserves space for this.
- Cross-sport disambiguation for identical names (extremely rare; punted until real).
- Merge tool for canonical participants if a duplicate is created accidentally (can be added later; not a v1 blocker given admin-only data entry).
