Files
descrybe/docs/cutover.md
T

378 lines
28 KiB
Markdown
Raw Normal View History

# Cutover runbook
Operational checklist for switching from legacy Descrybe (Next.js + Clerk + MySQL) to Descrybe v2 (Go + SvelteKit + PostgreSQL). See [schema-map.md](schema-map.md) for ID remapping and [features.md](features.md) for phase parity gates.
## Production readiness (read first)
**Production cutover: NO-GO** until all **10** ops blockers in [production-readiness.md](production-readiness.md#remaining-blockers-production-cutover--ops--true-open-items) are cleared (ETL status/counts: [migration-readiness.md](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](#no-go-blockers-gate-sequenced-dry-runs) **before** DNS flip. Do not copy that table into [production-checklist.md](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](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](production-readiness.md#remaining-blockers-production-cutover--ops--true-open-items)). 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](portable-mysql-pg-migration.md#clerk--legacylocal-email-repair-cutover-data-hygiene) |
| **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](demo-user.md). Alt: [staging-auth-rehearsal.md](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](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 **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](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](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](../deploy/examples/edge-rate-limit.md). Dry-run = config review + [security-notes.md](security-notes.md) — no in-app “simulate N replicas” flag. See §8 below / [production-checklist.md](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/`](../deploy/prometheus/) (see [production-checklist.md](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](#5-dns-switch). Detailed apply steps for #1#3 live under [§2 Run migrator](#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:
1. **Env contract** (every API replica):
```text
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
```
2. **Edge:** apply **one** shared nginx/Caddy rate-limit config in front of all replicas — [deploy/examples/edge-rate-limit.md](../deploy/examples/edge-rate-limit.md). Zones align to auth **10**/register **5**/public **30**/API-key IP **60** RPM.
3. Confirm API boot log includes the in-process / edge warning (`ShouldWarnRateLimits`).
4. Checklist boxes: [production-checklist.md](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
- [x] **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](portable-mysql-pg-migration.md#clerk--legacylocal-email-repair-cutover-data-hygiene)); 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](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](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](../README.md#environment-one-file)) |
| 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`):
```text
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):
```bash
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`):
```bash
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](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
```bash
# 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:
```bash
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:
```bash
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:
```bash
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](demo-user.md) / [safe-test-fixtures.md](safe-test-fixtures.md).
**After load (Postgres only)** — list → dry-run → confirm (non-A1 only):
```bash
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](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](schema-map.md).
Post-load:
```sql
-- 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](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](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):
```bash
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
```
2. **Staging rehearsal without SMTP (preferred first):** copy printed URLs — do **not** require mailhooks yet. Checklist + scripts: [staging-auth-rehearsal.md](staging-auth-rehearsal.md).
3. Send emails (rate-limited) when proving SMTP — still **unproven** until this succeeds. Smoke under `EMAIL_DRY_RUN` first (`-dry-run`; see [ops-runtime.md](ops-runtime.md)):
```bash
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
```
4. Local one-user bootstrap (dev only): `go run ./cmd/migrator -set-password "email:pass" -postgres "$DATABASE_URL"`.
5. Smoke-test: `/accept-invite` → set password → login → session cookie.
6. 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](ops-runtime.md).
## 5. DNS switch
**NO-GO** until the [NO-GO blockers gate](#no-go-blockers-gate-sequenced-dry-runs) (#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](production-readiness.md#deploy-note-sveltekit-adapter-decided)) 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
0. Clear **NO-GO** blockers (hard live: **SMTP #4** + **Stripe #5**; other rows CODE/LOCAL or optional) via [sequenced dry-runs](#no-go-blockers-gate-sequenced-dry-runs)
1. Freeze legacy writes
2. Run migrator
3. Verify counts (+ emails, membership roles, skipped plans)
4. Optional email hygiene → promote/plans → re-issue set-password → prove SMTP/login (+ Stripe / deploy / orphan / edge / metrics)
5. DNS switch (only after hard live clears)
6. Legacy Clerk cleanup is historical only (not a v2 blocker)
7. Keep MySQL 30 days (rollback safety)