R940 Migration Playbook¶
Status: Canonical operational checklist (2026-08-29).
Cross-reference: R940_NAMING_CONVENTION_v1.md §9 · REALOUTCOMES_DIRECTORY_500_DIAGNOSTIC.md
Use this playbook for product migrations (Railway/Supabase → R940), BWS prefix renames, and post-cutover env refreshes.
1. Product classes¶
| Class | VMs | Runtime DB secrets | Notes |
|---|---|---|---|
| Sovereign stack | {product}-app + {product}-db |
{PRODUCT}_DB_PROD_* → LAN |
RealOutcomes, Onset |
| App-only BFF | {product}-app only |
{PRODUCT}_APP_PROD_* → hosted until Tier 6 |
last1-admin |
| Platform | {product}-app |
N8N_APP_PROD_* in last1-platform |
n8n |
Gate B / dump-only keys (e.g. ONSET_HOSTED_DATABASE_URL) are for pg_dump from hosted prod. They must not appear in app runtime .env as DATABASE_URL.
2. BWS setup (T-2 pattern)¶
- Railway sync (app only):
sync-app-bws-from-railway.py→{PRODUCT}_APP_PROD_*. SkipsDATABASE_URL,SUPABASE_*,JWT_SECRET,POSTGRES_PASSWORD. - Stack push (authoritative for DB):
push-db-stack-bws.pyfrom live{product}-db→{PRODUCT}_DB_PROD_*(+ build-timeVITE_*/NEXT_PUBLIC_*to APP). - Validate hosts (see §3) before any app deploy.
3. DB connection validation (mandatory)¶
Run after BWS prefix rename, key copy, or before first app deploy.
# Example: onset
python3 scripts/onset/push-db-stack-bws.py
# Inspect hosts (no secrets)
bws secret list <project-id> -o json | jq -r '.[] | select(.key|test("DB_PROD_(DATABASE|SUPABASE)")) | "\(.key) \(.value)"'
| Key | Sovereign product — expect | Wrong (fix immediately) |
|---|---|---|
{PRODUCT}_DB_PROD_DATABASE_URL |
{product}-db LAN :5432 or :6543 |
*.supabase.co, aws-*-pooler.supabase.com |
{PRODUCT}_DB_PROD_SUPABASE_URL |
http://192.168.1.xxx:8000 |
https://*.supabase.co |
{PRODUCT}_DB_PROD_SUPABASE_POOLER_URL |
LAN :6543 |
hosted pooler |
Rule: Copied BWS values during rename are untrusted until push-db-stack-bws.py refreshes from live stack.
4. App env render + deploy¶
- Render:
render-{product}-env-app.sh(reads{PRODUCT}_APP_PROD_*+{PRODUCT}_DB_PROD_*). - Deploy:
deploy-{product}-app.sh(recreates container with--env-file).
Never use docker restart alone after .env changes. Env is bound at container create.
# Wrong
docker restart my-app
# Right
docker rm -f my-app && docker run -d --name my-app --env-file /opt/{product}/.env ...
# Or: deploy-{product}-app.sh
5. Post-deploy smoke (sovereign app)¶
| Check | Expect |
|---|---|
GET / |
200 |
GET /api/auth/user (no session) |
401 |
GET /api/directory?limit=1 (or product equivalent) |
200 with data |
| App logs | No tenant/user postgres.* not found |
RealOutcomes regression: directory 500 when DATABASE_URL pointed at deleted hosted pooler (diagnostic).
5c. Provider sends in migration smoke (SMS, push, email)¶
Never fire live outbound provider sends from a migration probe unless Ryan explicitly opts in for a controlled test number.
| Provider | Risk if smoke hits live credentials | Required pattern |
|---|---|---|
| Twilio | Real SMS to users (SPRINT-7 T2-1: sms-daily-check sent 2 live texts during anon smoke) |
Stub send in smoke, dry_run flag, or call a read-only health path; keep SMS pg_cron jobs inactive on sovereign until hosted scheduler is retired (T-5) |
| APNs / FCM | Push notifications to production devices | Omit push functions from anon smoke or require missing-credentials 500 as pass-only for reachability |
| SendGrid | Real email | Use /health-style paths or mocked transport in smoke |
Duplicate scheduler rule (shared DB migrations): When hosted and sovereign both exist, only one environment may have active SMS/notification crons. R940 sms-checkin-cron + org-sms-cron stay active=false until T-5 cutover; hosted keeps firing until teardown.
5b. Service-role tenant filters (replaces RLS for app runtime)¶
When a sovereign app uses service-role Supabase server-side (clinic portal, RealOutcomes-style), RLS is bypassed. Authorization moves to explicit app-level filters on every query.
Rule: Every service-role query MUST include a tenant scope filter:
practitioner_idfor practitioner-owned rows, orclinic_idfor clinic-scoped rows (team, settings, shared resources).
Never run unscoped select * / update / delete with the service role.
Review checklist (Gate B+ PRs touching DB access)¶
- [ ] Handler uses
requireClinicPractitioner()/ equivalent before any service-role client - [ ] Every
.from(...)chain includes.eq("practitioner_id", ...)or.eq("clinic_id", ...) - [ ] No service-role client exported to client components or edge without session gate
- [ ] Inserts set
practitioner_id/clinic_idfrom session context, not request body alone
Lint / automation (consider)¶
- ESLint custom rule or CI grep: flag
createAdminSupabase()/ service-role usage without.eq("practitioner_id"or.eq("clinic_id"in the same function - Code review bot note on any new
SUPABASE_SERVICE_ROLE_KEYquery path
Onset Gate B established this pattern for clinic portal Last1 ID auth (TIER_4_ONSET_T3_GATE_B_REPORT.md).
6. BWS prefix rename (NAMING-2 pattern)¶
- Repo/scripts/docs update.
migrate-bws-keys.py --product {name}(copy old → new prefixes).push-db-stack-bws.pyon every sovereign product (re-validate DB strings).- Re-render + recreate app containers.
- Observability metric/hostname refresh.
- Guest hostname + Tailscale rename.
migrate-bws-keys.py --delete-oldafter smoke pass.- Delete old keys only after verification.
7. Admin (paused) — intentional hosted refs¶
last1-admin has no {product}-db VM. Until Tier 6:
LAST1ADMIN_APP_PROD_SUPABASE_URL→ shared admin hosted project (tcthzakppndkykatoefx) — intentionalLAST1ADMIN_APP_PROD_ONSET_SUPABASE_URL→ Onset hosted (emxrmjysvxeboavhatry) — intentional (proxy until Onset cutover)LAST1ADMIN_APP_PROD_RADIOCHECK_SUPABASE_URL→ RadioCheck hosted — intentional
Do not point admin at sovereign LAN URLs until upstream products cut over and Tier 6 reconnect is authorized.
8. SPRINT-7 pre-teardown checklist (last1.app / T-5–T-6)¶
Hard deadline: Complete before hosted tcthzakppndkykatoefx teardown at T-6.
Canonical checklist: SPRINT7_PRE_TEARDOWN_CHECKLIST.md
D-live secret follow-ups (from T2-3 partial)¶
| Secret | GG | Before T-6 |
|---|---|---|
Stripe sk_live_ |
29615733 | Dashboard roll → t2-3-rotate-secrets.py + redeploy |
| SendGrid | 29615727 | No rotation — key dead (401); close GG; Resend migration later |
| Anthropic | 29615732 | Revoke old key in Console + push new key to BWS via rotate script |
Twilio (29160801) and Stripe webhook (29615730) rotated at T2-3.
References¶
- ONSET_BWS_DB_VALIDATION.md (2026-08-29 preventive check)
- TIER_4_ONSET_T-3_SUBAUDITS.md (storage + cron)
- ONSET_MIGRATION_PLAN.md
- REALOUTCOMES_MIGRATION_PLAN.md