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_TABLE → outbox_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.sqlin 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,_migrationverified gone → prod back to baseline). Carry-forwards: (1) media is still untested — the--entity mediapass is the next test (migration-media; the ETL--entityis 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/synchas 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; watchsync_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; stagingEMAIL_PROVIDERdefaults tologgingso the reset token prints to the server pod logs (or flip topostmarkfor a real email) → browse → migrated scores/drills/challenges visible. - Media optional for the first sighting — without it, data shows + images break; run
--entity media(needsSUPABASE_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
ScoreInputsgrid; 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↔ serverUuidDtoround-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.