Migration → Staging → Cutover Plan (2026-06-18)

Living checklist for the path from “coexistence machinery code-complete” to cutover. Migration and parity are parallel tracks — validating the data migration does not require the parity work. Plan shape agreed 2026-06-18: validate the migration cheaply (local) before the expensive parity investment, re-validate after, then full staging.

Parity item detail lives in 2026-06-15-mvp-parity-audit — this file does not duplicate it.

Sequence: A (local) → confirm → [early staging smoke] → parity → A again → confirm → B (full staging), with three refinements baked in (harness, early smoke, justified re-run).


Execution principle — all data-placing runs are IN-CLUSTER (added 2026-06-22, James)

Local execution is for TESTS ONLY — the dry-run harness (rangeday db migration-dry-run, throwaway embedded PG) + the hermetic suite. A local run never places data into a real environment. Every run that places data — the existing-data ETL and the new-data sync, staging and production — executes inside the k8s cluster (the migration Job #11 + the sync Deployment), reading cluster-managed secrets. Rationale: a cutover is one-shot and must be auditable + reproducible (“the cluster did it, here is the report” — never “I ran it from my laptop”), and an in-cluster run rehearses the actual cutover mechanism (image, secrets, network path to Supabase + DB, observability) that a local run can’t. This supersedes the earlier “validate cheaply local, then staging” framing for any data-placing run — the local harness stays, but strictly as a fidelity test, never as a way to load staging.

New-data path is already designed — dual outbox (NOT an open question)

Correcting an earlier mis-framing of mine: there is one Supabase (production) carrying two outbox tables — _migration.outbox_staging + _migration.outbox_production (docs/migration/01_outbox.sql). A single SECURITY DEFINER trigger on every write-hot table writes both (02_triggers.sql): production capture is must-succeed (its failure fails the legacy write → no silent prod loss); staging capture is best-effort (wrapped in EXCEPTION → logged to _migration.staging_failures, the legacy write proceeds → a staging hiccup never blocks the old app). Two independent sync instances drain them (apps/sync/src/config.rs OUTBOX_TABLEoutbox_staging | outbox_production), independent metrics + retention. So staging gets real new-data flow from production Supabase with zero risk to prod and no separate staging Supabase. Full design: migration-sync.

Operational prerequisites (ops — not code; the real gate to any run)

All creds for in-cluster runs live as k8s Secrets in the staging namespace (#9) — never on a laptop.

  • Creds provisioned (as staging k8s Secrets): LEGACY_DATABASE_URL (legacy Supabase, read), DATABASE_URL (staging target), SUPABASE_URL/SUPABASE_SERVICE_KEY (media byte fetch), STORAGE_BACKEND + SPACES_* (media sink)
  • Outbox installed on the Supabase — run docs/migration/01_outbox.sql + 02_triggers.sql in the Supabase SQL editor (idempotent; fail-open for staging). A write-path change to the live legacy DB → needs James’s explicit go; both outboxes start capturing immediately.
  • Spaces CORS for browser presigned-PUT (staging only)
  • Legacy data source: in-cluster runs read live Supabase (post-outbox-install, per the gapless sequence — the backup predates the outbox and would leave a gap). The local backup/snapshot feeds the dry-run test harness only.

Gating decisions (James — ALL 4 DONE as of 2026-06-19; see audit note §Decisions)

  • #4 no-shoot re-grade — DONE + merged (#103); the ETL now carries no-shoot. In any run today.
  • #2 influencer KILL — DONE (docs); handled by exclusion (never outbox-captured / not in USER_COLUMNS) — nothing to migrate.
  • #3 hit_factors → Scope B (full parity) — DECIDED; the data migration (un-skip → hit_factor_entries) builds with the feature at Phase 2 (#6). ETL still skips it until then.
  • #1 course-scoring → MVP (2026-06-19) — the heaviest item (the only new schema/proto in the set); new course-run grouping + run UI + the 521-run grouping backfill; builds at #6.

Data-touching parity items — gate the FINAL cutover migration (#8)

The cutover ETL is one-shot, so any parity item that changes migrated data must be in the ETL before Phase 4, and re-validated by Phase 3 (#7). Most parity items are UI-only (friend-profile, trend chart, range-bag picker) — they read already-migrated data and do not gate cutover. The data-touching subset that does:

  • no-shoot (#4) — in the ETL now.
  • hit_factors un-skip → hit_factor_entries (#3, Scope B) — builds at #6.
  • course-scoring run-grouping (#1, MVP) — new course-run grouping on scores + backfill the 521 legacy runs’ stage scores into runs + standings (the stage scores already migrate; this adds the grouping). Builds at #6.
  • stage-diagram vectors (custom_stage) — only if stage-diagram-builder ships; migrate the vector data.
  • email/sales consent (opt_in_emails/sales_notifications) — only if the F13 marketing-consent item is built.
  • qualifier category (drill category) — confirm whether it already migrates (likely yes, as a drill column → not a gate); verify.

Rule: Phase 4 (the real cutover migration) does not run until every built item above is in the ETL and Phase 3 (#7) is green. Staging runs before this (Phase 1.5) are deliberately partial — plumbing de-risk, not final data.


Phase 0 — Build Option A as a repeatable harness (refinement #1)

  • Script the local dry-run end-to-end: restore backup → local Postgres source + fresh target → rangeday db migrate-legacy → media pass (STORAGE_BACKEND=local/LocalFs; SUPABASE_* for byte fetch) → rangeday db sync-status + validation queries
  • Make it re-runnable + assertable (row counts, integrity, spot-checks) so the two “confirm” steps below are nearly free

Phase 1 — Run A → confirm migration fidelity (local, this host)

  • Run the harness against the real backup
  • Confirm: relational ETL correct (users/drills/scores/favorites/friends/challenges/achievements), dead-letters clean, sync forwarding works (hermetic), media manifest correct
  • Known limit: media byte transfer needs legacy Supabase read access even locally (bytes aren’t in the SQL backup)

Phase 1.5 — In-cluster staging sighting: sign in + see your data (✅ DONE 2026-06-23)

✅ DONE 2026-06-23 — the in-cluster rehearsal ran end-to-end. The cluster ran the ETL against live production Supabase (72,070 users · 75,388 scores · 16,338 drills · 26,606 hit_factors · content/friendships/challenges; ~20 min over the Session pooler; Job succeeded), James signed into the staging PWA on his legacy account and saw his data, the sync Deployment forwarded a live legacy write (a drill rename) outbox→sync→PWA (processed, 0 dead-lettered, 0 unhandled), and teardown ran clean (sync stopped, 03_teardown.sql, _migration verified gone → prod back to baseline). Carry-forwards: (1) media is still untested — the --entity media pass is the next test (migration-media; the ETL --entity is being parameterized so it runs in-cluster); (2) the result-gate bug found here (the fixed 5-min poll false-failed the ~20-min run) is fixed (#170) — surfacing integrity violations that exit 0 is the remaining gate follow-up.

The milestone: legacy data migrated into staging by the cluster, then James signs into the staging PWA with his legacy email and sees his data. Every step in-cluster; prereqs = #11 (CLI image + ETL Job manifest) · #9 (staging secrets) · outbox installed (above). (Earlier framing deferred this as a “migration-only smoke”; it is now the corrected, in-cluster plan of record.)

  • #11 — build the in-cluster mechanism: a CLI-bearing image + a staging ETL Job (rangeday db migrate-legacy --entity all) + a staging sync Deployment (OUTBOX_TABLE=_migration.outbox_staging, DATABASE_URL=staging). (apps/sync has no staging workload manifest yet — it lands here.)
  • Install the outbox on Supabase (01+02) → both outboxes begin capturing.
  • Run the ETL Job (existing data; reads live Supabase) → its validation report (per-entity counts, FK + reconciliation gates) lands in Loki/Grafana = the auditable “the cluster did it” artifact.
  • Start the staging sync Deployment (new data) → drains outbox_staging → staging DB; watch sync_lag / dead_letters / staging_insert_failures.
  • Sign in: James’s email is the seeded admin (james.allen@trex-arms.com, migration 0010) → the ETL merges his legacy account into it. First login → NULL password → reset; staging EMAIL_PROVIDER defaults to logging so the reset token prints to the server pod logs (or flip to postmark for a real email) → browse → migrated scores/drills/challenges visible.
  • Media optional for the first sighting — without it, data shows + images break; run --entity media (needs SUPABASE_SERVICE_KEY + SPACES_*) for avatars/drill images.
  • Re-run hygiene: each trial starts from a known state — reset the staging DB (re-apply migrations + seeds) before a fresh load; bake into the Job/runbook so the run is reproducible.
  • Caveat: the view reflects the open audit gaps until they land (D1–D10, 2026-06-22-migration-completeness-audit) — e.g. no badges / stage-layouts / loadout; earned achievements under ids 5/41/42 absent.

Interactive UI validation — a SEPARATE staging need (discovered 2026-06-20)

The PWA is fail-closed DRM-gated: on boot it runs a signed GetInstanceInfo handshake and blocks all UI (no login, no session) unless the license verifies against the embedded public trust anchor (apps/pwa/src/gate.rs:1-8,40-46; libs/core/src/license.rs:7,20-22 — only the public key ships, the private half is custodied offline by T.REX; the in-repo test_keypair :272 is #[cfg(test)]-only and does not match the anchor; no cfg(debug) bypass). Consequence: no parity UI chunk can be demoed interactively on a local stack — interactive validation requires a Verified instance.

  • Lighter than the migration smoke (Phase 1.5): needs only a deployed Verified staging PWA + seed data (an org with members + a drill), not the migration Job (#11). Do not conflate them — UI validation does not depend on the migration-Job infra.
  • Local bar for every PWA UI chunk is therefore: diff review + rangeday check (clippy) + release WASM/Trunk build + reviewer code-read. Compile / type / route-wiring are caught locally; runtime / interaction / real-data-shape are not — those batch to the staging pass. Risk: deferring all parity-UI interaction to one post-parity pass concentrates UI-bug discovery far from authorship; this backlog is the mitigation against silent slippage.
  • Interactive-validation backlog (drive each at the staging pass; reviewer maintains):
    • squad-scoring PR-1 — drill → “Squad” header link; org-member multi-select picker; the four states (loading / error / no-org / empty); selection toggle + live count.
    • squad-scoring PR-2 — per-shooter ScoreInputs grid; live per-row preview updates as hits/time are entered; one-shot batch submit records N scores; double-tap/retry submits the same idempotency keys → no duplicate scores (can’t be tested locally; dedup is key-driven, provider.rs:312,1070).
    • squad-scoring PR-3 — partial-failure surfacing (per-row “Failed: ” badge + “Recorded X of N” summary); on success, recorded rows clear while failed rows stay; edit a failed row → resubmit lands the correction (the reset retires recorded keys; failed keys stay live — also confirms the client uuid_to_b64 ↔ server UuidDto round-trip the unit tests can’t reach).

Phase 2 — Parity work (the long pole — ~18–20 conservative MVP items, ≈3× the coexistence arc)

  • Make the 4 gating decisions above → finalize/file the parity items
  • Build the conservative MVP-parity set — see 2026-06-15-mvp-parity-audit + ROADMAP-LEGACY parity subsections (Scoring & Calculator, Authoring, Media Display)
  • Deferred under conservative (revisit as fast-follow): stage-diagram-builder, drill instructional video, borderlines (loadout, global-average, marketing-email)

Phase 3 — Run A again → confirm (refinement #2 — justified, not redundant)

  • Re-run the harness — catches data-path regressions from the ETL-touching parity items (no-shoot, qualifier category, email consent, stage-diagram vectors)

Phase 4 — Full in-cluster staging trial → cutover

  • Re-run the in-cluster ETL Job + sync (same mechanism as 1.5) with all data-touching parity items in the ETL (no-shoot ✓; hit_factors; course-run grouping; any built stage-diagram / email-consent) and media on.
  • Log into the deployed staging apps → validate the full migrated-user experience end-to-end; confirm the sync drains (lag / dead-letters / staging-failures clean).
  • Cutover = the identical in-cluster mechanism against production: outbox already capturing → ETL Job reads live → production sync Deployment drains outbox_production (must-succeed) → drain to flatline → freeze legacy. Same Job/Deployment, production secrets.
  • → cutover readiness

Maintained by the reviewer; updated as phases complete. Last updated 2026-06-22 — revised so all data-placing runs are in-cluster (local = tests only) and the dual-outbox new-data path is recorded as the plan of record.