Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
28 KiB
Cutover runbook
Operational checklist for switching from legacy Descrybe (Next.js + Clerk + MySQL) to Descrybe v2 (Go + SvelteKit + PostgreSQL). See schema-map.md for ID remapping and features.md for phase parity gates.
Production readiness (read first)
Production cutover: NO-GO until all 10 ops blockers in production-readiness.md are cleared (ETL status/counts: migration-readiness.md). Staging live ETL succeeded (2026-08-03); that does not mean production DNS/cutover is ready.
Run the sequenced dry-runs in NO-GO blockers gate before DNS flip. Do not copy that table into production-checklist.md — checklist links here.
Staging may be used for login testing after set-password (preferably after email patch).
Staging auth rehearsal (no SMTP): promote one company admin → re-issue set-password → copy invite URL → one login smoke — see staging-auth-rehearsal.md and scripts/staging-auth-rehearsal.ps1 / .sh.
NO-GO blockers gate (sequenced dry-runs)
Operator gate for the 10 cutover blockers (production-readiness.md). Run in order; each step is dry-run / report-only first. Live writes need -confirm / confirm=true (or equivalent) after review. All go run commands below are from apps/api unless noted. Do not invent DSNs or mutate A1 live emails/roles without an intentional dry-run review.
Summary strip (platform session): GET /api/admin/readiness — must_set_password, companies_without_admin, companies_without_plan, companies_without_api_keys (reissue inventory; keys never ETL'd). Admin companies filter: GET /api/admin/companies?without_api_keys=1. Presence flags (no secrets): GET /api/admin/diagnostics → config.email_dry_run, smtp_enabled, stripe_mock, stripe_secret_set; cutover block includes companies_without_api_keys. ETL gap inventory (read-only COUNTs): migration_inventory.files_metadata_only, processing_jobs_migrated, tasks_total — not an import path.
| # | Blocker | Status | Dry-run / report-only (clear before GO) |
|---|---|---|---|
| 1 | @legacy.local email hygiene (Clerk removed) |
CODE DONE / optional ops | Clerk is removed from the product — not a hard NO-GO. Residual @legacy.local rows: go run ./cmd/migrator -list-legacy-emails → optional -patch-emails -dry-run / -confirm (never overwrites real emails). Detail: portable-mysql-pg-migration.md |
| 2 | Promote company admins (role=member) |
CODE/LOCAL tooling + list/dry-run; OPS OPEN cutover -confirm |
go run ./cmd/migrator -list-member-memberships -postgres "$DATABASE_URL" → promote non-A1 only with -promote-company-admins -email … or -company-id … then -dry-run / -confirm (never a1=true; A1 rows are always skipped). Local Demo: already admin via seed-demo — see demo-user.md. Alt: staging-auth-rehearsal.md |
| 3 | Skipped company_plans (plan_id=6) |
CODE/LOCAL tooling + list/dry-run; OPS OPEN cutover -confirm |
go run ./cmd/migrator -list-companies-without-plans -postgres "$DATABASE_URL" → -assign-missing-plans -plan-name Free -dry-run (live needs -confirm). Alt: GET /api/admin/companies?without_active_plan=1 |
| 4 | Live SMTP + set-password smoke | OPS OPEN | After #1–#3: -issue-set-password-invites (or rehearsal URLs) → go run ./cmd/mailhooks -hooks ../../artifacts/set-password-hooks.json -dry-run → admin POST /api/admin/settings/mail/test under dry-run expects skipped → then live send + one login. See ops-runtime.md |
| 5 | Real Stripe + signed webhooks | OPS OPEN | GET /api/admin/diagnostics — stripe_mock=false, stripe_secret_set=true; confirm webhook secret in /admin/settings (presence only). Live Checkout/webhook smoke is ops (not boot-required) |
| 6 | goose 039–042 + worker + Node host | CODE/LOCAL migrate scripts + deploy-check; OPS OPEN prod host | Apply (one command, prod host): npm run migrate with DATABASE_URL set — goose up through head (039 worker_heartbeats + 040 indexes + 041 password_reset_tokens + 042 user_session_version) + sqlc. Then restart api + worker together (cmd/api + cmd/worker; local: npm run dev:backend / npm run dev). Verify (read-only): HEALTH_BASE_URL=https://YOUR_API_HOST npm run cutover:deploy-check — goose applied, /readyz checks.worker=ok, adapter-node host gates. Web host: npm run build --workspace=web then npm run start:web. Local PASS does not clear prod. See production-checklist.md §§0–1 / §4 |
| 7 | Orphan-processed cleanup | CODE/LOCAL report/dry-run; OPS OPEN cutover confirm | GET /api/admin/jobs/orphan-processed (report). POST …/orphan-processed-cleanup without confirm=true is report-only. Live delete only with ?confirm=true / JSON {"confirm":true}. See production-checklist.md §1b |
| 8 | Multi-replica edge rate limits | CODE DONE env + snippets; OPS OPEN live edge | Confirm edge/API-gateway RPM caps; optional RATE_LIMIT_REPLICAS=N divides HTTP middleware only (not a shared store). Snippets: deploy/examples/edge-rate-limit.md. Dry-run = config review + security-notes.md — no in-app “simulate N replicas” flag. See §8 below / production-checklist.md §1f |
| 9 | Metrics scrape + RED/sync alerts | CODE DONE examples; OPS OPEN live scrape | CODE DONE: gated /metrics + example scrape/alert files in deploy/prometheus/ (see production-checklist.md §1d). OPS OPEN: live scrape targets + Alertmanager only. Dry-run: curl -sS http://127.0.0.1:$HTTP_ADDR/metrics | head (prod Gate: loopback unless METRICS_PUBLIC=1). On-call worker age: /readyz worker_last_seen_age_s + checks.worker (≤60s; not a /metrics series; stale → 503) |
| 10 | Unmigrated api_keys / blobs / jobs |
OPS OPEN (honesty tooling may be CODE) | Accept gap: tenants reissue API keys + reconnect stores; blobs are metadata-only (bytes not copied); jobs/history not backfilled unless ops ran domain jobs (cutover default = empty). Spot-check Settings → API keys, Files, Processing empties, dashboard ETL gaps panel, /stores reconnect honesty; no migrator dry-run invents keys or blobs |
Still unchecked live clears: #4 live SMTP, #5 live Stripe — do not treat CODE/LOCAL rows as production GO. Clerk is removed from the product (#1 is optional hygiene only).
Pass rule: every row above has a reviewed dry-run/report (or documented exception). Only then proceed to DNS switch. Detailed apply steps for #1–#3 live under §2 Run migrator.
8. Multi-replica edge rate limits
CODE DONE: in-process HTTP middleware + optional RATE_LIMIT_REPLICAS division (httpapi/ratelimit.go). No shared Redis store — RATE_LIMIT_BACKEND=redis|postgres is accepted as docs-only and forced to memory (boot warns).
When N=1: leave defaults (RATE_LIMIT_REPLICAS unset/1); edge zones optional.
When N>1 (ops): without edge, effective RPM ≈ N× documented budgets. Clear this blocker with:
- Env contract (every API replica):
RATE_LIMIT_REPLICAS=<N> # N = API OS processes behind the shared edge
RATE_LIMIT_MULTI_REPLICA=true # acknowledge multi-replica without shared store
TRUSTED_PROXIES=<edge CIDRs/IPs> # hop-1 proxy peers only (so RemoteAddr is client IP)
# RATE_LIMIT_BACKEND=memory # redis|postgres not implemented — forced to memory
- Edge: apply one shared nginx/Caddy rate-limit config in front of all replicas — deploy/examples/edge-rate-limit.md. Zones align to auth 10/register 5/public 30/API-key IP 60 RPM.
- Confirm API boot log includes the in-process / edge warning (
ShouldWarnRateLimits). - Checklist boxes: production-checklist.md §1f.
RATE_LIMIT_REPLICAS divides HTTP middleware only (ceil) under even load — not login email lockout, StartLimiter, AIRateLimiter, or email send limiters. Edge hard caps remain required for cluster-wide RPM.
Preconditions
- Feature-parity gate passed for all phases in production use (A–E as applicable)
- Staging migrator dry-run succeeded; row-count and orphan-FK reports clean
MIGRATE_MYSQL_DSNprovided by operator (live dry-run/load blocked until then — do not invent credentials)- Postgres backups / PITR configured
- Clerk removed from product auth — no live Clerk cutover dependency. Optional: patch residual
@legacy.localvia migrator-list-legacy-emails/-patch-emailsbefore invites (portable-mysql-pg-migration.md); never overwrite real A1/live emails - Company admin memberships promoted (import defaults all to
role=member) - Skipped
company_plansresolved (or accepted with documented exception) - Set-password invites re-issued after email repair (staging load used
-skip-post-import) - Set-password email templates and SMTP verified (ops-runtime.md); mailhooks + login smoke proven
- Production secrets live in one root/host
.env(or secret store mapped into process env) — not a duplicateapps/api/.env. Bootstrap only:DATABASE_URL,APP_ENV=production,WEB_ORIGIN(https),SESSION_SECURE=true,APP_ENCRYPTION_KEY,TOKEN_SIGNING_SECRET(plusHTTP_ADDR/PUBLIC_API_URLas needed). See production-checklist.md §5 and root.env.example APP_ENCRYPTION_KEYset before storing Woo/email/AI/Stripe secrets in the UI (CREDENTIALS_ENCRYPTION_KEYalias accepted; derived key is local/dev only)- Platform Stripe / EPREL / feed private-URL allowlist configured in
/admin/settings(env optional fallback; not required at boot) - Tenant OpenAI / marketing email / store connectors configured in the dashboard (
/integrations/ai,/integrations/email,/stores) after first platform admin login — not via duplicated env files or tmp placeholders - Rollback owners and on-call identified
- Maintenance window communicated to customers
Status: production cutover is NO-GO and not executed by automation; checklist items below remain operator-owned. Do not treat a green staging ETL as cutover-ready.
Local stack quick reference
From repo root f:/laragon/www/_MY/descrybe-v2 (or clone root):
| Step | Command |
|---|---|
| Env | Copy root .env.example → .env; fill bootstrap only (see README.md) |
| Postgres 16 (host 5433 → container 5432) | make up / docker compose up -d (Postgres only) |
| Stop Postgres | make down |
| Apply goose schema + sqlc | npm run migrate (or make migrate / .\scripts\migrate.ps1) |
| API + worker (+ web) | npm run dev — api :28471 + web :28472 + worker (/readyz needs worker) |
| API + worker only | npm run dev:backend / make backend |
| API or worker alone | make api / make worker (API-only → /readyz 503 is expected) |
| Unit tests / vet | make test / make vet |
| Local rehearsal (dry-run) | make cutover-rehearsal / node scripts/cutover-local-rehearsal.mjs |
Do not copy secrets into apps/api/.env or temporary placeholder files. One root .env (or exported process env) is enough. Stripe / EPREL / feed allowlist → /admin/settings; tenant OpenAI / marketing mail / stores → their dashboard routes.
Default local DSN (matches docker-compose.yml):
DATABASE_URL=postgres://descrybe:descrybe@localhost:5433/descrybe?sslmode=disable
Goose 039–042 (prod apply + verify)
One-command schema apply on the target host (requires DATABASE_URL; no SMTP/Stripe):
npm run migrate
# → goose up through 042_user_session_version (039 worker_heartbeats + 040 indexes + 041 password_reset_tokens + 042 session_version) + sqlc
Then restart api and worker together so /readyz sees a fresh processing heartbeat. Local rehearsal: npm run dev:backend (or full npm run dev).
Read-only gate (does not run goose up):
HEALTH_BASE_URL=http://127.0.0.1:28471 npm run cutover:deploy-check
# prod: HEALTH_BASE_URL=https://YOUR_API_HOST npm run cutover:deploy-check
# web host: npm run build --workspace=web && npm run start:web
Local rehearsal (dry-run bundle, no SMTP/Stripe): node scripts/cutover-local-rehearsal.mjs or make cutover-rehearsal — deploy-check → -list-legacy-emails (no patch) → -list-member-memberships → -list-companies-without-plans → orphan-processed POST without confirm=true. Assign / promote / orphan deletes stay separate (-confirm / confirm=true). Needs DATABASE_URL; orphan step needs API up + DESCRYBE_SMOKE_PASS (or CUTOVER_REHEARSAL_PASS) for platform-admin login (demo-user.md). Optional: --skip-deploy-check / --skip-lists / --skip-orphan / --skip-goose / --skip-readyz.
Health probes (API default :8080; local npm run dev uses :28471):
GET /healthz— liveness (no DB); returnsmaintenance/read_onlyGET /readyz— readiness: Postgres ping and freshworker_id=processingheartbeat (≤60s) plus queue probe; 503 if DB down or worker missing/stale
Both paths are exempt from the maintenance gate so cutover rehearsal probes stay green.
1. Freeze legacy writes
- Announce maintenance.
- Put legacy app in read-only / maintenance mode (disable uploads, sync cron, processing, and mutating APIs).
- Stop or pause workers: feed sync cron, River/queue consumers, Clerk-driven webhooks that write MySQL.
- Confirm no new writes: check MySQL
updated_at/ binlog idle for critical tables. - Take a final MySQL dump (or snapshot) labeled with freeze timestamp.
Do not start the migrator until writes are frozen (or accept a documented delta re-run).
2. Run migrator
# From repo root — apply PG schema first (requires DATABASE_URL)
export DATABASE_URL="postgres://descrybe:descrybe@localhost:5433/descrybe?sslmode=disable"
make migrate
# Windows PowerShell: $env:DATABASE_URL="..."; .\scripts\migrate.ps1
# Offline fixture dry-run (no MySQL) — tooling smoke only
cd apps/api
go run ./cmd/migrator -dry-run -fixture ./cmd/migrator/testdata/fixture.json -maps-dir ../../artifacts
# Dry-run against real MySQL (report + id-map; no PG writes of migrated rows)
# Flags: -mysql | MIGRATE_MYSQL_DSN, -postgres | DATABASE_URL, -dry-run, -maps-dir, -id-map, -skip-post-import
go run ./cmd/migrator \
-mysql "$MIGRATE_MYSQL_DSN" \
-postgres "$DATABASE_URL" \
-dry-run \
-maps-dir ../../artifacts \
-id-map ../../artifacts/id-map.json
# Live load (reuse / write artifacts/id-map.json)
go run ./cmd/migrator \
-mysql "$MIGRATE_MYSQL_DSN" \
-postgres "$DATABASE_URL" \
-id-map ../../artifacts/id-map.json \
-maps-dir ../../artifacts
Migrator flags
| Flag | Env default | Purpose |
|---|---|---|
-mysql |
MIGRATE_MYSQL_DSN |
Legacy MySQL DSN (required unless -fixture) |
-postgres |
DATABASE_URL |
Target Postgres URL (required for live load; required for MySQL dry-run) |
-dry-run |
— | Remap/count without writing migrated rows |
-fixture |
— | Offline JSON fixture (dry-run only; omit -postgres) |
-maps-dir |
artifacts |
Output dir for id-map / validation / hooks (gitignored) |
-id-map |
<maps-dir>/id-map.json |
Unified ID map path |
-skip-post-import |
— | Skip set-password invite hook generation |
-fallback-plan-name |
— | During ETL: when a legacy company_plans.plan_id is missing from plans (e.g. plan_id=6), assign this Postgres plan name instead of skipping (safe insert only) |
-list-companies-without-plans |
— | Postgres-only: list companies with no active company_plans row |
-assign-missing-plans |
— | Postgres-only: assign -plan-name to companies without an active plan (never overwrites an existing active plan; requires -dry-run or -confirm) |
-plan-name |
Free |
Plan name for -assign-missing-plans (case-insensitive) |
-list-member-memberships |
— | Postgres-only: list active memberships with role=member (optional -email / -user-id / -company-id; prints a1=true/false) |
-promote-company-admins |
— | Postgres-only: promote matching active member → admin (requires -email, -user-id, or -company-id; never promotes A1 a1=true; requires -dry-run or -confirm) |
-email / -user-id / -company-id |
— | Filters/targets for membership role tooling (-company-id alone scopes promote; A1 still skipped) |
-list-legacy-emails |
— | Postgres-only: list users still on @legacy.local (read-only) |
-export-legacy-emails |
— | Postgres-only: write inventory + emails stub map (-emails-out or <maps-dir>/legacy-emails.json) |
-patch-emails |
— | Postgres-only: patch users.email from -emails-file for rows still @legacy.local (never overwrites real emails; requires -dry-run or -confirm) |
-emails-file / -emails-out |
— | Email map input for patch; export output path |
-confirm |
— | Required for live -assign-missing-plans / -promote-company-admins / -patch-emails writes (omit with -dry-run to preview only — no blind live writes) |
Patch residual @legacy.local emails (optional hygiene)
Clerk is removed from the product. Synthetic addresses still break set-password invites (IsSyntheticLegacyEmail) if any remain. Optional: list → export → fill real emails → dry-run patch → confirm:
cd apps/api
# 1) Count/list still-synthetic users (read-only)
go run ./cmd/migrator -list-legacy-emails -postgres "$DATABASE_URL"
# 2) Export inventory + stub emails{} map (fill from operator email inventory: legacy_user_id → primary email)
go run ./cmd/migrator -export-legacy-emails -maps-dir ../../artifacts -postgres "$DATABASE_URL"
# 3) Preview patches (no writes) — never overwrites a real (non-@legacy.local) address
go run ./cmd/migrator -patch-emails -emails-file ../../artifacts/legacy-emails.json -dry-run -postgres "$DATABASE_URL"
# 4) Apply only after reviewing the dry-run (-confirm required)
go run ./cmd/migrator -patch-emails -emails-file ../../artifacts/legacy-emails.json -confirm -postgres "$DATABASE_URL"
Resolve skipped company_plans (plan_id=6)
Staging skipped rows whose legacy plan_id was absent from plans. Safe options (no deletes; never run live assign without a dry-run preview first):
A. During a future load — map missing plan ids to a valid public plan:
cd apps/api
go run ./cmd/migrator \
-mysql "$MIGRATE_MYSQL_DSN" \
-postgres "$DATABASE_URL" \
-fallback-plan-name Free \
-id-map ../../artifacts/id-map.json \
-maps-dir ../../artifacts
B. After load (Postgres only) — list → dry-run → confirm:
cd apps/api
# 1) List companies missing an active plan (read-only)
go run ./cmd/migrator -list-companies-without-plans -postgres "$DATABASE_URL"
# 2) Preview assignments (no writes) — required before live
go run ./cmd/migrator -assign-missing-plans -plan-name Free -dry-run -postgres "$DATABASE_URL"
# 3) Apply only after reviewing the dry-run (-confirm required; refuses without it)
go run ./cmd/migrator -assign-missing-plans -plan-name Free -confirm -postgres "$DATABASE_URL"
Live assign without -confirm exits with: refusing live write … (no blind live writes). Existing active plans are never overwritten.
Promote company admins (role=member → admin)
Import defaults all memberships to role=member when legacy profiles.role is absent. Platform admins (users.is_platform_admin) come from admin_users via applyPlatformAdmins during load — separate from company-admin memberships.
Hard rule: never promote A1 cohort rows (a1=true / billing.IsA1CohortCompany). Dry-run and -confirm both print skip … a1_cohort and leave those memberships as member.
Local Demo path: seed-demo already binds demo@descrybe.local as admin on Platform Demo only (never A1). If -list-member-memberships shows only A1, there is nothing safe to promote — re-run go run ./cmd/seed-demo -postgres "$DATABASE_URL" rather than inventing users. See demo-user.md / safe-test-fixtures.md.
After load (Postgres only) — list → dry-run → confirm (non-A1 only):
cd apps/api
# 1) List active member memberships (read-only; optional filters)
go run ./cmd/migrator -list-member-memberships -postgres "$DATABASE_URL"
# optional: -email user@example.com | -user-id <uuid> | -company-id <uuid>
# 2) Preview promote (no writes) — scope to a non-A1 company or email; A1 rows are skipped
go run ./cmd/migrator -promote-company-admins -company-id <non-a1-uuid> -dry-run -postgres "$DATABASE_URL"
# or: -email user@example.com -dry-run
# 3) Apply only after reviewing the dry-run (-confirm required; refuses without it)
go run ./cmd/migrator -promote-company-admins -company-id <non-a1-uuid> -confirm -postgres "$DATABASE_URL"
Unscoped promote (no -email / -user-id / -company-id) is refused. Live promote without -confirm exits with: refusing live write … (no blind live writes).
SQL / UI alternates remain in staging-auth-rehearsal.md.
C. Platform admin API
GET /api/admin/companies?without_active_plan=1— companies with no active plan (has_active_planis always included on the list response)GET /api/admin/plans— pick a validplan_idPOST /api/admin/plans/assign—{"company_id":"…","plan_id":N}(full assign; prefer migrator-assign-missing-planswhen you must not touch companies that already have a plan)
Validation reports include informational check companies_without_active_plan (count + sample). Fix until count is 0 (or document an accepted exception).
Artifacts (artifacts/, gitignored): id-map.json, validation-report.json, set-password-hooks.json. Never commit production maps or hook tokens.
Load order and remapping rules: schema-map.md.
Post-load:
-- Prefer CONCURRENTLY outside a transaction for large tables
ANALYZE;
3. Verify counts
Compare MySQL vs Postgres for each migrated table (users, companies, memberships, plans, products, feeds, etc.):
| Check | Pass criteria |
|---|---|
| Row counts | Match within expected skips (ephemeral jobs, Clerk-only rows) |
| Orphan FKs | Zero after remapping |
| Spot checks | Sample products / feeds / memberships per company |
| Admin flag | Every legacy admin_users row → users.is_platform_admin |
| User emails | Real addresses (not @legacy.local) before invites — -list-legacy-emails / -patch-emails (-dry-run then -confirm); see migration-readiness.md |
| Membership roles | Company admins promoted via -list-member-memberships / -promote-company-admins (-dry-run then -confirm; import defaults role=member) |
| Company plans | Skipped plans resolved via -fallback-plan-name / -list-companies-without-plans / -assign-missing-plans (-dry-run then -confirm) or admin assign; staging had 2 skipped (plan_id=6) |
| ID map | Every remapped FK resolves via artifacts/id-map.json |
Archive the verification report with the freeze dump and ID map.
4. Set passwords after import
Migrated users have must_set_password = true and no imported password hash. Full operator notes: migration-readiness.md.
Before this step: ensure invite targets are real emails (optional @legacy.local hygiene if any remain); promote company-admin memberships; account for skipped company_plans. Staging load used -skip-post-import, so invites were not issued automatically — re-issue only after emails are real.
- Re-issue invites (Postgres only; required after
-skip-post-importor email repair):
cd apps/api
go run ./cmd/migrator -issue-set-password-invites -postgres "$DATABASE_URL" -maps-dir ../../artifacts
# writes artifacts/password_invites.json + artifacts/set-password-hooks.json and prints URLs
- Staging rehearsal without SMTP (preferred first): copy printed URLs — do not require mailhooks yet. Checklist + scripts: staging-auth-rehearsal.md.
- Send emails (rate-limited) when proving SMTP — still unproven until this succeeds. Smoke under
EMAIL_DRY_RUNfirst (-dry-run; see ops-runtime.md):
go run ./cmd/mailhooks -hooks ../../artifacts/set-password-hooks.json -dry-run
# Live: EMAIL_DRY_RUN=false + SMTP_ENABLED=true
go run ./cmd/mailhooks -hooks ../../artifacts/set-password-hooks.json
- Local one-user bootstrap (dev only):
go run ./cmd/migrator -set-password "email:pass" -postgres "$DATABASE_URL". - Smoke-test:
/accept-invite→ set password → login → session cookie. - Platform admins: confirm
is_platform_adminand/adminroutes; company admins: promotememberships.roletoadmin(see rehearsal doc).
Do not flip DNS until SMTP/mailhooks/login smoke is proven and at least one admin and one normal user can log in on the new stack. See ops-runtime.md.
5. DNS switch
NO-GO until the NO-GO blockers gate (#1–#10) is cleared. Staging ETL success alone is not enough.
- Deploy Go API + worker + SvelteKit (
adapter-node@5.5.7— Node host forbuild/output; see production-readiness.md) behind the reverse proxy. - Health checks green:
/healthz,/readyz(checks.worker=ok), web origin. Prefernode scripts/cutover-deploy-check.mjsafter goose through 042 and worker restart. - Update DNS / reverse proxy to point production traffic at v2.
- Keep legacy app offline or redirected; do not dual-write.
- Monitor errors, login success rate, job queues, export URLs.
6. Clerk status (removed from product)
Descrybe v2 does not use Clerk. Legacy Clerk decommission (webhooks/keys/app) is historical cleanup on the old stack only — not a v2 cutover NO-GO blocker.
7. Rollback and MySQL retention
If cutover fails before or shortly after DNS switch:
- Point DNS / proxy back to the legacy Next.js app.
- Re-enable legacy writes only if the freeze dump is still the source of truth (or restore MySQL from the freeze dump).
- Leave Postgres as non-authoritative until a corrected migrator re-run.
Retention:
- Keep MySQL read-only (dump + live replica or stopped primary) for at least 30 days after successful cutover.
- Retain
artifacts/id-map.json, freeze dump, and verification reports for the same period. - After 30 days and sign-off, decommission MySQL per data-retention policy.
Quick reference order
- Clear NO-GO blockers (hard live: SMTP #4 + Stripe #5; other rows CODE/LOCAL or optional) via sequenced dry-runs
- Freeze legacy writes
- Run migrator
- Verify counts (+ emails, membership roles, skipped plans)
- Optional email hygiene → promote/plans → re-issue set-password → prove SMTP/login (+ Stripe / deploy / orphan / edge / metrics)
- DNS switch (only after hard live clears)
- Legacy Clerk cleanup is historical only (not a v2 blocker)
- Keep MySQL 30 days (rollback safety)