Skip to content

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)

  1. Railway sync (app only): sync-app-bws-from-railway.py → {PRODUCT}_APP_PROD_*. Skips DATABASE_URL, SUPABASE_*, JWT_SECRET, POSTGRES_PASSWORD.
  2. Stack push (authoritative for DB): push-db-stack-bws.py from live {product}-db → {PRODUCT}_DB_PROD_* (+ build-time VITE_* / NEXT_PUBLIC_* to APP).
  3. 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

  1. Render: render-{product}-env-app.sh (reads {PRODUCT}_APP_PROD_* + {PRODUCT}_DB_PROD_*).
  2. 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_id for practitioner-owned rows, or
  • clinic_id for 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_id from 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_KEY query 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)

  1. Repo/scripts/docs update.
  2. migrate-bws-keys.py --product {name} (copy old → new prefixes).
  3. push-db-stack-bws.py on every sovereign product (re-validate DB strings).
  4. Re-render + recreate app containers.
  5. Observability metric/hostname refresh.
  6. Guest hostname + Tailscale rename.
  7. migrate-bws-keys.py --delete-old after smoke pass.
  8. 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) — intentional
  • LAST1ADMIN_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