Files
greeneclipse 8580c996c3 Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
2026-08-09 22:47:43 +02:00

28 KiB
Raw Permalink Blame History

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/readinessmust_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/diagnosticsconfig.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/diagnosticsstripe_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 039042 + 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 §§01 / §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 storeRATE_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:

  1. 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
  1. 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.
  2. Confirm API boot log includes the in-process / edge warning (ShouldWarnRateLimits).
  3. 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 (AE as applicable)
  • Staging migrator dry-run succeeded; row-count and orphan-FK reports clean
  • MIGRATE_MYSQL_DSN provided 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.local via migrator -list-legacy-emails / -patch-emails before invites (portable-mysql-pg-migration.md); never overwrite real A1/live emails
  • Company admin memberships promoted (import defaults all to role=member)
  • Skipped company_plans resolved (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 duplicate apps/api/.env. Bootstrap only: DATABASE_URL, APP_ENV=production, WEB_ORIGIN (https), SESSION_SECURE=true, APP_ENCRYPTION_KEY, TOKEN_SIGNING_SECRET (plus HTTP_ADDR / PUBLIC_API_URL as needed). See production-checklist.md §5 and root .env.example
  • APP_ENCRYPTION_KEY set before storing Woo/email/AI/Stripe secrets in the UI (CREDENTIALS_ENCRYPTION_KEY alias 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 039042 (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); returns maintenance / read_only
  • GET /readyz — readiness: Postgres ping and fresh worker_id=processing heartbeat (≤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

  1. Announce maintenance.
  2. Put legacy app in read-only / maintenance mode (disable uploads, sync cron, processing, and mutating APIs).
  3. Stop or pause workers: feed sync cron, River/queue consumers, Clerk-driven webhooks that write MySQL.
  4. Confirm no new writes: check MySQL updated_at / binlog idle for critical tables.
  5. 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 memberadmin (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=memberadmin)

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_plan is always included on the list response)
  • GET /api/admin/plans — pick a valid plan_id
  • POST /api/admin/plans/assign{"company_id":"…","plan_id":N} (full assign; prefer migrator -assign-missing-plans when 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.

  1. Re-issue invites (Postgres only; required after -skip-post-import or 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
  1. Staging rehearsal without SMTP (preferred first): copy printed URLs — do not require mailhooks yet. Checklist + scripts: staging-auth-rehearsal.md.
  2. Send emails (rate-limited) when proving SMTP — still unproven until this succeeds. Smoke under EMAIL_DRY_RUN first (-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
  1. Local one-user bootstrap (dev only): go run ./cmd/migrator -set-password "email:pass" -postgres "$DATABASE_URL".
  2. Smoke-test: /accept-invite → set password → login → session cookie.
  3. Platform admins: confirm is_platform_admin and /admin routes; company admins: promote memberships.role to admin (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.

  1. Deploy Go API + worker + SvelteKit (adapter-node@5.5.7 — Node host for build/ output; see production-readiness.md) behind the reverse proxy.
  2. Health checks green: /healthz, /readyz (checks.worker=ok), web origin. Prefer node scripts/cutover-deploy-check.mjs after goose through 042 and worker restart.
  3. Update DNS / reverse proxy to point production traffic at v2.
  4. Keep legacy app offline or redirected; do not dual-write.
  5. 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:

  1. Point DNS / proxy back to the legacy Next.js app.
  2. Re-enable legacy writes only if the freeze dump is still the source of truth (or restore MySQL from the freeze dump).
  3. 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

  1. Clear NO-GO blockers (hard live: SMTP #4 + Stripe #5; other rows CODE/LOCAL or optional) via sequenced dry-runs
  2. Freeze legacy writes
  3. Run migrator
  4. Verify counts (+ emails, membership roles, skipped plans)
  5. Optional email hygiene → promote/plans → re-issue set-password → prove SMTP/login (+ Stripe / deploy / orphan / edge / metrics)
  6. DNS switch (only after hard live clears)
  7. Legacy Clerk cleanup is historical only (not a v2 blocker)
  8. Keep MySQL 30 days (rollback safety)