Files
descrybe/docs/cutover.md
T
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

378 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)