Production apply / rebuild runbook
The authoritative, ordered procedure to stand up the production AWS substrate from the IaC in infra/envs/production — whether that is the first apply, a rebuild from nothing, or a disaster recovery. Production was opened early for the June-10 single-session demo (june-demo.md → Stream A) and has stayed live and serving real patients since 2026-06-05.
This document is maintained continuously until launch. Anything that changes the substrate — a new secret, a new cron, a new service, a changed ordering constraint — lands here in the same change that introduces it. The value of a rebuild runbook is entirely in its completeness; a step that exists only in someone's memory is a step that is already lost. When you discover a constraint by having something fail, record the failure alongside the fix, because the failure is what makes the ordering believable to the next reader.
Verify before you trust. Several claims below were wrong when checked against the live environment — service names, desired counts, and an ordering that only worked by accident. Where a line says something was verified and when, that is why.
Change log
Newest first. Add an entry whenever the procedure itself changes.
| Date | Change |
|---|---|
| 2026-08-26 (2) | 000051 joins the range, and it rewrites RLS POLICIES. Six SECURITY DEFINER set-helpers replace the three patient-facing policies on exercises + session_exercises; the clinic exercise library goes 996ms → 5.9ms. It is additive and reversible (the down restores all six verbatim, round-trip verified), but it is the only entry in this range that changes who-can-see-what mechanics, so it wants the equivalence matrix run on staging against real rows before production. Nothing applied anywhere but local. |
| 2026-08-26 | Step 0 added: jit=off for restartix_app, and it is independent of this promotion. Postgres was JIT-compiling RLS-expanded plans — 1.1s and 13.8s of code generation for queries doing 8ms and 16ms of work, recompiled on every execution because generated code is never cached. Production is 2 vCPU, so this bounded total throughput to well under one program-page load per second. Applied to LOCAL only so far. The trap is that it needs a pgbouncer restart to take effect; without one, warm backends keep jit=on for up to server_lifetime. |
| 2026-08-17 (2) | The range FOLDED back to 000040–000050. 000051–000055 were applied to no environment, so the consents-model correction and F3.5.3 form sessions were folded into 000043/000044 where their tables are defined, and 000054's starter-template seed was deleted outright. Production is unaffected — it has never applied any of 000040+, so it now runs a shorter range that builds the same schema. STAGING MUST BE REBUILT: it is at 000049 and two of the migrations it already applied have changed. The fold also caught two regressions 000055 had introduced by re-creating policies from stale copies — forms_select and form_template_versions_select had each silently lost a deleted_at IS NULL guard. |
| 2026-08-17 | One of the migrations DELETES consent rows. The org_service_disclaimer retirement deletes the purpose, its published versions and every consents row accepting it — deliberate while those rows are demo content, and irreversible once they are not. Added the ⛔ block in step 5 with the count-first query. (It lived in 000053 when this was written; it is in 000044 after the fold.) |
| 2026-08-15 | The promotion now owns FIVE catch-up scripts, and one of them straddles the deploy. Added ⛔ blocks in step 5 for 000023+000034 (history follows the person — additive, with a backfill that is not instant against production's rows, and a silent failure mode) and for 000008 (the consent subject). The 000008 one is deliberately split: Part A creates the new overload before the deploy, Part B drops the old one after it, with the removal repeated at the end of step 6. Combining them, which the first draft did, would have errored every consent gate from the DDL until a reviewer approved — the 2026-08-11 treatment_plans_enabled shape. Nothing was applied to production; this entry is the record, not the run. |
| 2026-08-14 | Caregiver grant codes need NO schema change in either environment — recorded under step 1 rather than step 5. They live in Redis, which is why; the operationally relevant fact is that maxmemory-policy = noeviction already covers them, and would not if it were ever loosened. |
| 2026-08-14 | Both catch-ups applied to STAGING; production deferred deliberately. Recorded in the two ⛔ blocks in step 5 — nothing in production needs either until the 000040–000050 range promotes, so they belong to that run rather than to a standalone DDL session against live patient data. |
| 2026-08-13 | 000012 was edited in place too — organization_invites.patient_profile_id, for the P7 profile claim. Unlike the 000006 one this fails loudly if forgotten (the invite INSERT names a missing column). |
| 2026-08-13 | 000006 was edited in place and production needs the catch-up. Added the ⛔ block before the has-rows warning: patient_caregivers_select_org_staff (the P7 family-account RLS policy) exists only in the corrected migration, which production will never re-run. Forgetting it fails silently — the Family panel renders empty rather than erroring. |
| 2026-08-12 | Video's absence is now written down rather than merely true. Step 2 records that the secrets script's final Daily section fails against production (no daily-bootstrap container — verified live) and why the substrate omits video entirely; step 8 records that the provider webhook is not a production step yet, and what it costs to forget once it is. |
| 2026-08-11 | The ordering rule gained its second direction — schema additions may precede a deploy, removals must follow it. Added after dropping a column ahead of the reviewer-gated deploy 500'd every authenticated request. Step 5 also now points at the catch-up scripts a restore may need. |
| 2026-08-11 | Step 4 (seed ECR) added, and it is load-bearing. Production now owns its own ECR repos, which a from-scratch apply creates EMPTY — so migrations, crons and services all had nothing to pull. Later steps renumbered. Also added the no-running-tasks alarms, the sibling cron task definition, and fixed two errors that would have failed on paste (media-service → media, clinic's desired count). |
Operator = ops custodian (Ovidiu). Claude authored the IaC + scripts + deploy-production.yml; everything below is run by the custodian (or by Claude under direction). terraform apply, the Clerk prod app, secret population, and DNS registration are all operator steps — they are not done during authoring.
Golden rule: never
terraform applywithout reviewing theplanfirst. Production tfstate is shared; a second human reviews the plan.
Account 538636660353, eu-central-1. Platform hostnames are bare restartix.pro subdomains; the demo vanity host is platforma.restartix.ro on the separate restartix.ro Cloudflare zone.
0. Prerequisites (before any apply)
Bootstrap applied. The
infra/envs/bootstrapcomposition must be applied — it owns therestartix-tfstatebucket and therestartix-deploy-productionOIDC role (trusts the GitHubproductionEnvironment). Verify:bashaws iam get-role --role-name restartix-deploy-production --query 'Role.Arn' --output textIf absent:
cd infra/envs/bootstrap && terraform apply(theproductionentry already exists in itsdeploy_iamblock).GitHub
productionEnvironment exists with required reviewers. Repo → Settings → Environments →production→ add required reviewers. This is the approval gate fordeploy-production.yml; the OIDC role trustsenvironment:production, so without the Environment the deploy can't assume the role.Clerk production application stood up. Separate Clerk app (own
pk_live_/sk_live_). Do NOT reuse staging keys with production patients. Configure its allowed origins / redirect URLs forportal.restartix.pro,console.restartix.pro, andplatforma.restartix.ro.Cloudflare Terraform token ready. Create a
restartix-production-terraformAPI token with Zone:DNS:Edit + Zone:SSL:Edit on bothrestartix.proand therestartix.tvvanity zone (the TV companion's own zone — the apexrestartix.tvserves the prod TV app). Held by the custodian; populated into SM in step 3 soproduction-apply.shworks on later runs. Have it in hand for the first apply. (Staging's TF token needs the same restartix.tv scopes — plus Zone:Zone Settings:Edit — since staging owns that zone's settings + ruleset.)restartix.tvzone exists on Cloudflare + zone id set. Registerrestartix.tvand add it as a Cloudflare zone (point the registrar NS at Cloudflare). Put its Zone ID incloudflare_tv_zone_idin bothinfra/envs/production/terraform.tfvarsandinfra/envs/staging/terraform.tfvars(placeholders are committed). The prod apexrestartix.tv+ stagingstaging.restartix.tvboth live here.Legacy handoff secret value ready — the exact
LEGACY_HANDOFF_SECRETset on the legacyrestartix-admin-api(Railway). The demo entry flow (Stream B) 503s until production's copy matches it byte-for-byte.Tooling:
terraform >= 1.10,awsCLI,session-manager-plugin,psql(brew install libpq),jq,docker.No conflicting DNS. Confirm
api/console/clinic/portal/telemetry.restartix.proand the*.clinic/*.portalwildcards are free on the Cloudflarerestartix.prozone (staging uses the-stagingsuffix, so they should be). On therestartix.tvzone, the apex (@) andstagingrecords must be free. A pre-existing record makes the apply error on that record.
1. Terraform apply
The Cloudflare provider needs CLOUDFLARE_API_TOKEN at plan time (the cloudflare_ip_ranges data source + DNS records). On the first apply the SM secret isn't populated yet, so export it manually:
cd infra/envs/production
terraform init # initialises the S3 backend (production/terraform.tfstate)
export CLOUDFLARE_API_TOKEN=<restartix-production-terraform token>
terraform plan -out=prod.plan # REVIEW with a second human
terraform apply prod.plan
unset CLOUDFLARE_API_TOKENSubsequent runs use the wrapper (sources the token from SM — never in shell history):
./infra/scripts/production-apply.sh plan
./infra/scripts/production-apply.sh applyThis creates: VPC + two NAT Gateways (HA, one per AZ — the interface VPC endpoints were removed 2026-07-31) + SSM bastion + SGs (ALB locked to Cloudflare IPs), platform CMK, RDS Multi-AZ, Redis Multi-AZ, all restartix/production/* SM containers (empty, zero versions), the backup bucket + backup CMK, the restartix-production-<svc> ECR repos (also empty — see step 4), ACM cert (+ Cloudflare validation records), ALB, ECS cluster + 7 services (media at desired_count = 0; clinic at 1), the api + telemetry migrations runners, the sibling small cron task definition, the crons + scheduler DLQ, edge DNS, the TV bucket, SES config set, alarms, and the budget.
Nothing can start yet, and that is expected. Two things Terraform creates are empty by design: the secret containers (step 2) and the ECR repos (step 4). ECS secret injection is fail-closed, so a task naming a versionless secret dies at ResourceInitializationError; a task naming an absent image dies at CannotPullContainerError. Expect both until those steps are done, and do not debug them as faults.
Aurora/RDS first-create takes ~10–15 min (Multi-AZ). The ACM cert validates via the Cloudflare records — allow a few minutes.
Redis runs
maxmemory-policy = noeviction, and more now depends on that than when the choice was made. The parameter group was set that way because Redis holds auth SESSIONS, not just cache:volatile-lru(the redis7 default) would evict TTL-bearing keys under memory pressure and silently log patients out.noevictionturns memory exhaustion into a loud write error instead.Since 2026-08-14 it also protects caregiver grant codes — the short-lived, single-use codes a patient reads out to give somebody access to their record (
caregiver_grant:keys, 15-minute TTL). Under an eviction policy a code could disappear before it expired, and the person redeeming it would be told it was invalid while the person who issued it saw no problem at all.Nothing to apply for that feature in any environment: no migration, no catch-up script, no new secret. The whole reason it is in Redis rather than a table is that a row whose only future is deletion does not warrant a migration plus a catch-up on two environments. What it does need is Redis reachable from the api — which is already true — and this policy left alone. Loosening it later would break sessions first and grant codes second.
The cost of
noevictionis that memory exhaustion refuses WRITES, and nothing currently watches for it. Verified 2026-08-14: this substrate has no ElastiCache alarm of any kind — not memory, not evictions, not connections (step 9 covers ALB, RDS, ECS and NAT only). Every load-bearing Redis path is a write — session creation, hold acquisition, rate-limit counters, locks, JTI reservations, cache-aside fills, grant codes — so a full instance is not a slowdown, it is an abrupt platform-wide write failure with no warning ahead of it. Tracked in platform-completion.md.
2. Populate Secrets Manager
./infra/scripts/populate-production-secrets.shPrompts for (production values): Clerk pk_live_/sk_live_; Cloudflare SaaS token + TF token + zone id; production Bunny Stream + Storage Zone creds; SES from-address; the legacy handoff secret (must equal the legacy Railway value); Sentry DSNs (optional). Auto-generates ENCRYPTION_KEYS + BACKUP_ENCRYPTION_KEY.
ENCRYPTION_KEYSis the crown jewel — it decrypts everypii_regulated/auth_secretcolumn and every Cat A provider credential. Once written, do not rotate without the credential-rotation runbook.
⛔ If this is a REBUILD of an existing production, STOP here
Do not let this script generate keys. It creates
ENCRYPTION_KEYSandBACKUP_ENCRYPTION_KEYwithopenssl randwhenever the container is empty, and on a rebuild every container is empty. Two consequences, both permanent:
- A restored database becomes unreadable. Every encrypted column — the CNP on
patient_profiles, everyauth_secret— is ciphertext under the oldENCRYPTION_KEYS. A new key does not fail loudly; it fails as garbage.- The backups themselves become unreadable. Layer 2 artifacts are encrypted under
BACKUP_ENCRYPTION_KEY, deliberately in its own envelope and under its own CMK. Regenerate it and the Object-Locked bucket still holds every dump and none of them can be opened. There is no second copy of that key.So before running this on a rebuild: recover the original values first and write them in, rather than accepting generated ones. Sources, in order — Secrets Manager may still hold them (deleted secrets are recoverable within their
recovery_window:aws secretsmanager restore-secret), then the custodian's offline copy. If neither exists, encrypted data is gone; say so plainly rather than proceeding and discovering it at restore time.The script skips already-populated secrets, so a re-run against an intact Secrets Manager is safe — it is the empty-container case that destroys data. This is why step 2 comes before any restore, and why it is worth confirming
aws secretsmanager get-secret-value --secret-id restartix/production/encryptionreturns the expected key before going further.
Terraform already populated restartix/production/redis, the JWT signing keys, the bearer tokens, and the bunny-webhook secret (random_id). The RDS master password is RDS-managed.
The script's last section fails today, after everything else has succeeded. It prompts for the Daily (video) bootstrap and then writes restartix/production/daily-bootstrap, which does not exist — production's local.bootstrap_secret_names carries only the SES, S3 and Clerk seeds (describe-secret returns ResourceNotFoundException, verified live 2026-08-12), so update-secret errors and set -e aborts. Nothing before it is lost; re-running with --only <name> picks up anything you still need.
That the container is absent is deliberate, not an oversight: production has no video substrate at all — no daily-bootstrap, no video-room-secret, no video-room-sweep / video-usage-reconcile crons — because every one of them would be inert until 000049 promotes, and an api task definition naming a versionless secret takes the crons down with it (video-infrastructure-plan §5). Video goes to production as one ordered change, from a staging config proven first. What is drift is only the script, which prompts for a secret its environment has no container for.
3. Bootstrap DB roles + telemetry DB
Open the SSM tunnel through the bastion (production has no NAT instance):
# terminal A — leave running
./infra/scripts/production-tunnel.sh # localhost:15432 → RDS:5432# terminal B
ENV=production CONNECT_HOST=localhost CONNECT_PORT=15432 \
./infra/scripts/bootstrap-db-roles.shCreates restartix (owner) + restartix_app (RLS-restricted) + telemetry_app, the telemetry database, the platform extensions (pgcrypto, uuid-ossp, pg_trgm, unaccent, pg_stat_statements, vector), the pgbouncer userlist, and writes the canonical DSNs into restartix/production/database + /telemetry-database. The script's aurora_* terraform outputs are deliberately named to match its contract — production is RDS, the values are the RDS writer endpoint / db name / master secret.
4. Seed the ECR repos
On a rebuild, nothing below this line can run until an image exists. Since 2026-08-11 production owns restartix-production-<svc> (previously it shared staging's restartix-<svc> repos, which were already full — so this step was invisible and the runbook did not have it). terraform apply creates the repos empty, and every task definition it registers names :latest, which does not resolve. The migrations runner in step 5 is the api image; the crons are the api image; the services are their own images. All of them fail to launch with CannotPullContainerError: … not found.
Push without deploying, so the services do not start against an unmigrated database:
./infra/scripts/push-image.sh api --env production # NO --deployThat builds and pushes :latest + :<git-sha>. The api is a Go binary with no build-time environment baking, so push-image.sh's staging NEXT_PUBLIC_* defaults do not apply to it — that caveat is real for the Next.js images, which is why the reviewer-gated workflow builds those in step 6.
Recovering an existing production rather than building a new one? Do not rebuild — copy the images that are already running. Registry-side, within the same account, this mounts layers instead of transferring them (about a second per image) and the result is verifiable by digest:
docker buildx imagetools create \
--tag <registry>/restartix-production-api:<sha> \
--tag <registry>/restartix-production-api:latest \
<source>/restartix-api:<sha>Verify with aws ecr describe-images --image-ids imageTag=<sha> --query 'imageDetails[0].imageDigest' on both sides and compare. A rebuild produces a different image from the same source; a copy produces the same image. During a recovery that distinction is the whole point.
5. Migrations + partition roll
Requires step 4 — the runner pulls restartix-production-api:latest at RunTask time.
The promotion sequence, in one place
Production sits at 000039. The pending range is 000040–000051 — F1–F6, video consultations, family plans, and the consents-model correction — and it does not promote by running the migrations alone. Four catch-up scripts and one extension statement travel with it, in this order. Each is detailed in its own ⛔ block below; this table exists so nobody has to reconstruct the order from prose.
| # | Do | Why here |
|---|---|---|
| 0 | jit-off-app-role.sql as master, then restart pgbouncer | independent of this promotion — apply it today. Costs nothing, waits for nothing, and is worth ~1.5s per statement on every RLS-planned read |
| 1 | CREATE EXTENSION "btree_gist" as master | 000045 runs it as restartix, which cannot, and dies mid-migration leaving schema_migrations dirty |
| 2 | 000006-caregiver-staff-visibility.sql | edited in place; forgetting it empties the Family panel, silently |
| 3 | 000012-invite-claims-profile.sql | edited in place; forgetting it errors every patient invitation, loudly |
| 4 | 000006-caregiver-legal-representative.sql | edited in place; the attestation that lets a caregiver act for an ADULT. Without it the clinic surface 500s on a missing column |
| 5 | 000023-000034-history-follows-the-person.sql | edited in place; forgetting it empties every per-person read, silently. Contains a backfill over every existing run and grant |
| 6 | 000008-consent-subject.sql (Part A) | additive; creates the new overload beside the old so the running code keeps working |
| 7 | run the migrations 000040→000051 | the range itself — ⚠️ see the 000044 block below before running it against real acceptances |
| 8 | partition roll -ahead=3 | partitioned tables have no DEFAULT partition |
| 9 | step 6 — deploy | reviewer-gated; code-only |
| 10 | 000008b-drop-old-consent-subject.sql (Part B) | the removal, and only now: run it earlier and every consent gate errors until approval |
⛔ Step 0 — jit-off-app-role.sql. Not part of the promotion; apply it whenever.
This one does not belong to 000040–000050 and should not wait for it. It changes no data, no schema and no code — only how Postgres executes plans for restartix_app.
Postgres decides to JIT-compile a plan from its ESTIMATED cost, and RLS destroys that estimate: a SELECT on session_exercises is planned through four OR'd policies that recursively inline the policies of sessions, programs, protocols, patients, catalog_entries and session_runs, so a query returning 19 rows becomes a ~750-line plan with ~3000 JIT'd functions. Measured locally against the demo clinic's 40-session programme under real patient RLS context:
| statement | jit=on | jit=off |
|---|---|---|
| per-session exercises + catalog | 1112 ms | 8.4 ms |
| program-exercises aggregate | 13810 ms | 16.1 ms |
The compiled code is never reused — the same prepared statement executed six times on one connection paid 1491/1482/1471/1466/1507/1478 ms. Plans are cached (pgx + pgbouncer max_prepared_statements); machine code is not, and Postgres offers no mechanism to make it so.
Production runs db.t4g.medium — 2 vCPU — and JIT compilation is CPU work that cannot be shared between connections. So this is a capacity fix rather than a latency polish.
psql "$PROD_URL_AS_MASTER" -f infra/scripts/jit-off-app-role.sqlThen restart pgbouncer, or nothing changes. ALTER ROLE … SET applies at backend startup, and pgbouncer keeps warm server connections (min_pool_size = 5, server_lifetime = 3600), so existing backends keep jit=on for up to an hour. Verified live on local: SHOW jit returned off on a direct :5432 connection and on through pgbouncer until the container was restarted. Force a new deployment of the pgbouncer service, then confirm:
SHOW jit; -- through pgbouncer: expect offReverting is ALTER ROLE restartix_app RESET jit; plus the same restart. It is deliberately NOT set on the restartix owner role — AdminPool reads bypass RLS, so their cost estimates are honest, and cross-org Console analytics is the one place JIT could genuinely pay off.
bootstrap-db-roles.sh now carries the same statement, so a from-scratch environment gets it at step 3 and does not need this script.
Steps 2–6 are all additive and all idempotent, so they may be run together in one session ahead of the migrations. Step 10 is the only one that must wait, and it is the one most likely to be forgotten, because skipping it breaks nothing today — see its block at the end of step 6 for what it leaves behind.
Nothing above has been applied to production. As of 2026-08-15 every script here is applied to LOCAL only, except 000006/000012 which are also on staging. Staging has not rehearsed the full sequence either — and a rehearsal there is worth doing first, because a from-scratch staging rebuild validated every ADD CONSTRAINT against an empty schema, which proves the migrations are well-formed and nothing about how they meet production's rows.
This step assumes a NEW production with no data to preserve. If you are recovering an existing one, the data comes first and these migrations are the wrong tool: restore per backup-disaster-recovery.md, then run the migrations runner only to carry the restored schema forward to the image's version.
A restore may also need the catch-up scripts, and nothing else will tell you so. infra/scripts/*.sql hold DDL for migrations that were edited in place after production had already passed them — golang-migrate compares version numbers, not contents, so it will not re-run an edited migration and the restored database keeps the pre-edit shape at a version number that looks current. Compare the restored schema_migrations against the scripts present in that directory: any whose migration number the restore already passed is a candidate. Each script states its own applicability in its header, and each is written to be a no-op where it does not apply. Two exceptions to run nothing blindly: HOTFIX-restore-treatment-plans-column.sql is an emergency rollback that re-adds a dropped column, and 000004b-drop-treatment-plans-column.sql is a removal — see the ordering rule below before either. Restoring over a freshly migrated schema is not the same operation and loses whichever side you did second. Confirm the encryption keys are the original ones (step 2) before restoring — a restore under a new key produces a database that looks healthy and returns garbage from every encrypted column.
⛔ Before promoting
000045, createbtree_gistas master
000045_scheduling.up.sqlrunsCREATE EXTENSION IF NOT EXISTS btree_gist(the appointments double-bookingEXCLUDEconstraint needs the GiST opclass for its equality column). The migrations runner connects asrestartix, which has noCREATE EXTENSIONprivilege on RDS/Aurora — so the statement fails, the migration dies part-way, andschema_migrationsis left dirty at 45. Production is at000039and has not met this yet; it will the moment the000040–000050range promotes.Step 3's script now pre-creates it, but step 3 does not re-run on an existing production — the roles are long since bootstrapped. So on the promotion run this is a manual statement, through the tunnel, as master, before the runner starts:
sqlCREATE EXTENSION IF NOT EXISTS "btree_gist";Found on the 2026-08-12 staging rebuild, which was the first run to migrate past
000044on managed Postgres. It cannot be caught in local development: localrestartixis a superuser, so the migration succeeds there every time.
⛔
000012was edited too — run its catch-up as well
000012_invites.up.sqlgainedorganization_invites.patient_profile_idand a CHECK confining it to patient invitations. It is what lets a dependent on a family account CLAIM their own login: accepting the invitation stampspatient_profiles.human_idon the person who already exists, instead of onboarding minting a second profile and leaving every appointment, form and consent behind on the first.bashpsql "$PROD_DIRECT_URL" -f infra/scripts/000012-invite-claims-profile.sqlIdempotent (
ADD COLUMN IF NOT EXISTS, constraint dropped then re-added). Verify:sqlSELECT column_name FROM information_schema.columns WHERE table_name = 'organization_invites' AND column_name = 'patient_profile_id';Forgetting it fails LOUDLY rather than silently, unlike the
000006one below: the invite INSERT names a column that does not exist, so issuing any patient invitation errors.STATE (2026-08-14): applied to STAGING, deliberately NOT to production. Staging was at
000049with neither object present; both are there now and verified. Production was left alone on purpose — see the note under the000006block below, which applies to both scripts.
⛔
000006was edited after production passed it — run the catch-up
000006_patient_identity.up.sqlgainedpatient_caregivers_select_org_staff, the RLS policy that lets clinic staff read a caregiver link when both people are already their patients (P7 family accounts). Production passed version 6 long ago, so golang-migrate will never re-run it — the table keeps its original single policy, which matches only the caregiver themselves or the patient.The symptom is silent and easy to misread: the Family panel on a patient record renders nothing, and the API returns an empty list rather than an error, so it looks like the patient simply has no relatives on file.
Through the tunnel, as
restartix(no master needed — it owns the table):bashpsql "$PROD_DIRECT_URL" -f infra/scripts/000006-caregiver-staff-visibility.sqlIdempotent (
DROP POLICY IF EXISTSthenCREATE), so a re-run is harmless. Verify:sqlSELECT policyname FROM pg_policies WHERE tablename = 'patient_caregivers'; -- expect BOTH patient_caregivers_select and -- patient_caregivers_select_org_staffIt grants SELECT only.
000006's REVOKE on INSERT/UPDATE/DELETE stands, so a clinic can read a family link and still cannot invent one.STATE (2026-08-14): applied to STAGING, deliberately NOT to production.
Both catch-ups were run against staging (at
000049, clean) and verified:patient_caregiversnow carries both policies,organization_inviteshaspatient_profile_id+ its CHECK +org_invites_select_claim_subject.Production was deferred on a deliberate call, not forgotten. Nothing running in production needs either script today: prod is at
000039and the code that reads these objects is in the unpromoted000040–000050range. Applying DDL to a live database serving real patients buys nothing until that code ships, and theADD COLUMN— though a catalog-only change on a nullable column — still takes a brief ACCESS EXCLUSIVE lock that queues behind any in-flight transaction onorganization_invites.So the intended moment is the promotion run, alongside the
btree_giststatement above. Whoever does that promotion must run BOTH scripts; they are not carried by the migrations, and golang-migrate will not re-run an edited migration. The failure modes differ and both are recorded above:000012fails loudly (invitations error),000006fails silently (the Family panel renders empty).
⛔
000044DELETES consent rows — read before promoting
000044_consent_formsretiresorg_service_disclaimerby deleting it outright, and that means:sqlDELETE FROM consents WHERE purpose_code = 'org_service_disclaimer'; DELETE FROM consent_purpose_versions WHERE purpose_code = 'org_service_disclaimer'; DELETE FROM consent_purposes WHERE code = 'org_service_disclaimer'; DELETE FROM organization_legal_documents WHERE document_type = 'service_disclaimer'; DELETE FROM legal_document_templates WHERE document_type = 'service_disclaimer';…followed by narrowing both
document_typeCHECKs to('terms', 'privacy_notice').Every row recording that a patient accepted that document is destroyed, along with every published version of its text. The
consentsledger is otherwise append-only under six-year retention, and this is the one statement in the schema that contradicts that.It was written deliberately, and the migration's own header argues it: at the time, production held a demo tenant and the rows were demo content, so a
retired_atcolumn plus its guard checks would have been machinery carrying a case that did not exist. That argument is sound only while the rows are disposable.THIS IS A REMOVAL, so the ordering rule applies — but its blast radius is small enough that the rule bends. The code on
mastertoday still knows the third document type:legaldocument.DocumentTypeadmitsservice_disclaimerand the clinic's legal-documents page iterates a fixed["terms", "privacy_notice", "service_disclaimer"]. So between running the migrations and completing the deploy, that page still offers a type whose CHECK no longer admits it, and publishing one returns a 500.That is one admin action on one page — not the 2026-08-11 shape, where a dropped column 500'd every authenticated request. And the alternative is worse: the new code cannot run against the old schema at all (it reads
consent_purposes.enforcement, which lands in this same migration), so deploying first is not available. Run the range, then deploy promptly, and do not publish a service disclaimer in between.The re-consent gate moves in the SAFE direction here, which is worth knowing before you worry about it. It switches from
legal_basis <> 'consent'(platform_terms,platform_privacy_notice,org_terms,org_privacy_notice,org_service_disclaimer— five) toenforcement = 'blocks_access'(the same four, minus the deleted one). No patient is newly blocked; one demand is withdrawn.So before running the migrations, check:
sqlSELECT count(*) AS acceptances, min(granted_at) AS oldest, max(granted_at) AS newest FROM consents WHERE purpose_code = 'org_service_disclaimer';If that returns anything belonging to a real patient at a real clinic, stop and take the decision explicitly. The alternative is small and is described in the migration header: add
consent_purposes.retired_at, stop requiring the purpose, and leave the rows alone — a one-column migration and three lines of check. Doing that after the DELETE has run is not possible; the evidence is gone.Nothing else in
000040–000050destroys data.The one-live-enrollment invariant is NOT in this range. It was written as a forward
000051on 2026-08-22 and then folded into000023besideuq_one_active_prescription_per_patient, whose "Enrollments are unconstrained" comment it corrects.000023is applied everywhere, so neither staging nor production gets the index until it is rebuilt from000001— both sit at a version number that looks current with the pre-edit shape, exactly as the 2026-08-21 / 08-22 folds left them. Until then, onlyprotocols.Service's pre-check stands between a patient and a duplicate enrollment, and it cannot see a concurrent double-tap.A rebuilt environment creates the index against an empty
protocols, so there is nothing to reconcile. The forward version carried a backfill that ended duplicate live enrollments; the fold drops it, because at000023the table does not exist yet. If either environment is ever brought forward by hand instead of rebuilt, that backfill has to come back — the index cannot be created while duplicates exist:sqlSELECT patient_id, source_program_id, count(*) FROM protocols WHERE kind = 'enrollment' AND status IN ('active', 'paused') AND source_program_id IS NOT NULL GROUP BY 1, 2 HAVING count(*) > 1;STATE (2026-08-17): applied to LOCAL only — and see the note under the local-database warning below, because at the time of writing the local database had not run them either.
⛔
000006gained the legal-representative attestation Four columns on
patient_caregivers—is_legal_representative, the basis the clinic was shown, and who attested it when — plus a CHECK and a partial index.It is what lets a caregiver accept the clinic's terms for an ADULT. Without it the rule is minors-only, and an 80-year-old with no email has nobody who can answer for them: they hold no login, and a relative on whose account they sit has demonstrated no standing.
bashpsql "$PROD_DIRECT_URL" -f infra/scripts/000006-caregiver-legal-representative.sqlIdempotent, and the CHECK cannot fail on existing rows — every one of them has
is_legal_representative FALSE. Additive, so it belongs before the deploy. Verify:sqlSELECT column_name FROM information_schema.columns WHERE table_name = 'patient_caregivers' AND column_name IN ('is_legal_representative', 'representative_basis', 'representative_attested_by_principal_id', 'representative_attested_at'); -- expect all fourForgetting it fails LOUDLY: the clinic's family panel selects the columns and errors on a missing one.
STATE (2026-08-15): applied to LOCAL only.
⛔
000023+000034were edited — run the history catch-up Both gained
patient_profile_idbeside their existingpatient_id(session_runs,patient_content_grants), plus a derive-on-insert trigger and four replaced RLS SELECT policies. It is what lets a patient who withdrew and came back still reach their own exercise history and the content they bought — apatientsrow is one processing chapter, and both of those follow the PERSON.Production passed 23 and 34 long ago, so golang-migrate will never re-run them. Through the tunnel, as
restartix(no master needed):bashpsql "$PROD_DIRECT_URL" -f infra/scripts/000023-000034-history-follows-the-person.sqlIdempotent (
ADD COLUMN IF NOT EXISTS,CREATE INDEX IF NOT EXISTS,DROP POLICY IF EXISTSthenCREATE). Purely additive, so it belongs before the deploy. Verify:sqlSELECT count(*) FILTER (WHERE patient_profile_id IS NULL AND patient_id IS NOT NULL) FROM session_runs; -- expect 0 SELECT count(*) FILTER (WHERE patient_profile_id IS NULL) FROM patient_content_grants; -- expect 0 SELECT tgname FROM pg_trigger WHERE tgname LIKE '%derive_patient_profile%'; -- expect BOTH session_runs_ and patient_content_grants_ triggersIt contains a backfill, and production is the first environment where that is not instant. The two
UPDATEs touch every existing run and grant. Check the row counts before running and expect a write lock onsession_runsfor the duration.Forgetting it fails silently and twice over: the columns stay NULL, so every per-person read returns nothing — a patient's activity log renders empty and their purchased content stops unlocking — and the new RLS policies are absent, so the patient's own reads still resolve through a live
patientsrow. Neither errors.STATE (2026-08-15): applied to LOCAL only. Staging and production both owe it, and it belongs to the same promotion run as everything else here.
⛔
000008was edited — and this one is TWO scripts, in two places
current_required_consent_versionsgained a third argument naming the subject of the question. Without it the function asked "has ANYONE this login speaks for accepted?", so a parent's ownorg_termssatisfied the consent gate for a child who had consented to nothing.The split is not bookkeeping — it is this runbook's own ordering rule.
CREATE OR REPLACEwith a new signature OVERLOADS rather than replaces, so the addition and the removal are separable, and separating them is what keeps the running code working across a reviewer-gated approval.Part A — here, before the deploy. Creates the three-argument form beside the old one. Both exist afterwards; arity resolves them unambiguously, and the currently-deployed code keeps calling the two-argument one.
bashpsql "$PROD_DIRECT_URL" -f infra/scripts/000008-consent-subject.sqlsqlSELECT pg_get_function_identity_arguments(oid) FROM pg_proc WHERE proname = 'current_required_consent_versions'; -- TWO rows at this point is CORRECT: `uuid, uuid` and `uuid, uuid, uuid`.Part B — after step 6, once the deploy has shipped the three-argument caller:
000008b-drop-old-consent-subject.sql. It is listed again at the end of step 6 so it is not lost here.Run Part B early and it errors every consent gate on the platform until a reviewer approves — the same shape as the 2026-08-11
treatment_plans_enableddrop. Skip it entirely and nothing errors, but the union-based defect stays one un-updated caller away from being live: a rollback to the previous image brings it straight back.STATE (2026-08-15): both parts applied to LOCAL only (local ran an earlier combined form, so it already has only the three-argument function). Staging and production owe both.
Applying migrations to a production that has rows is its own risk, separate from this runbook. A fresh database validates DDL against zero rows, so every ADD CONSTRAINT succeeds by construction; against real rows it may not. Check constraint-adding migrations against the live data before running them, and never deploy application code ahead of the migration it depends on — the deploy workflow runs no migrations, so code expecting a newer schema fails on reads that have nothing to do with the new feature.
And the rule has a second direction, which is the one that has actually broken production. Code-ahead-of-schema is the obvious hazard; schema-ahead-of-code is the quiet one:
Schema ADDITIONS may go before a deploy. Schema REMOVALS must go after it.
A column the new code no longer reads is harmless while the old code still runs. A column the old code does read, dropped before that code is replaced, breaks every request that touches it. On 2026-08-11 organization_entitlements.treatment_plans_enabled was dropped ahead of the deploy: the old binary named it in LoadRegulatedFlags, which runs inside RequireOrganizationScope — so the drop did not break one endpoint, it 500'd every authenticated request on the platform until the column was restored by infra/scripts/HOTFIX-restore-treatment-plans-column.sql.
The advice that produced it — "script first, deploy second" — is true only when the deploy follows immediately. Production's deploy is reviewer-gated by design, so the same ordering is an outage lasting until someone approves. Split any change that both alters data and removes schema into two scripts and run the removal after step 6.
Blast radius is worth checking rather than assuming: before dropping anything, grep for the column and find out whether its reader sits in middleware. A column read once per request is a very different removal from a column read on one admin page.
Run the migration runners as one-shot ECS tasks (private subnets, migrations SG):
SUBNETS=$(terraform -chdir=infra/envs/production output -json private_subnet_ids | jq -c .)
MIGR_SG=$(terraform -chdir=infra/envs/production state show 'module.network.aws_security_group.migrations_runner' | awk -F'"' '/^[[:space:]]+id /{print $2; exit}')
NET="awsvpcConfiguration={subnets=${SUBNETS},securityGroups=[${MIGR_SG}],assignPublicIp=DISABLED}"
aws ecs run-task --cluster restartix-production-cluster \
--task-definition restartix-production-migrations-runner \
--launch-type FARGATE --network-configuration "$NET" --region eu-central-1
aws ecs run-task --cluster restartix-production-cluster \
--task-definition restartix-production-telemetry-migrations-runner \
--launch-type FARGATE --network-configuration "$NET" --region eu-central-1Wait for each to reach STOPPED with exitCode 0 (check /ecs/restartix-production-migrations-runner logs).
Then roll partitions ahead — migrations seed only a baseline month, and the day-25 cron may be weeks away; partitioned tables (audit_log, notifications, usage_records, session_, media_) have no DEFAULT partition, so current-month inserts fail without this:
APP_SG=$(terraform -chdir=infra/envs/production state show 'module.network.aws_security_group.fargate_app' | awk -F'"' '/^[[:space:]]+id /{print $2; exit}')
APP_NET="awsvpcConfiguration={subnets=${SUBNETS},securityGroups=[${APP_SG}],assignPublicIp=DISABLED}"
aws ecs run-task --cluster restartix-production-cluster --task-definition restartix-production-api \
--launch-type FARGATE --network-configuration "$APP_NET" --region eu-central-1 \
--overrides '{"containerOverrides":[{"name":"api","command":["/bin/api-partition-roll","-ahead=3"]}]}'
aws ecs run-task --cluster restartix-production-cluster --task-definition restartix-production-telemetry \
--launch-type FARGATE --network-configuration "$APP_NET" --region eu-central-1 \
--overrides '{"containerOverrides":[{"name":"telemetry","command":["/bin/telemetry-partition-roll","-ahead=3"]}]}'(The api / telemetry task defs exist after step 1, even though their services run at the desired counts set in compute.tf.)
6. Build + deploy service images
Production deploys go through the reviewer-gated workflow — Actions → Deploy to production → Run workflow → "Use workflow from": master → service: all. It rebuilds each image with production NEXT_PUBLIC_* build-args, pushes to restartix-production-<svc>, registers an immutable :<sha> task-def revision, and smoke-tests /health for the live services. The production Environment's required reviewers must approve before the jobs run.
master is correct here, unlike the staging workflow: this role trusts environment:production rather than a branch ref, and a workflow_dispatch workflow only appears in the Actions tab once its file is on the default branch.
The deploy runs no migrations. It is code-only; step 5 is the only thing that moves the schema. That separation is what makes the order in this document matter — deploying code that expects a newer schema breaks reads that have nothing to do with the new feature.
⛔ AFTER this deploy: drop the old consent-gate overload
The other half of the
000008split in step 5. Only now, with the three-argument caller live:bashpsql "$PROD_DIRECT_URL" -f infra/scripts/000008b-drop-old-consent-subject.sqlsqlSELECT pg_get_function_identity_arguments(oid) FROM pg_proc WHERE proname = 'current_required_consent_versions'; -- ONE row now, reading `uuid, uuid, uuid`.This is the step most likely to be skipped, because skipping it breaks nothing today. What it leaves behind is the union-based consent gate, still callable — so a rollback to the previous image silently restores a state where one family member's acceptance answers for another's.
mediastays atdesired_count = 0(no HTTP smoke test). The service isrestartix-production-media— not…-media-service, which is what this runbook said until 2026-08-11 and which fails as an unknown service. Its autoscaler owns desiredCount, so raise the floor rather than scaling by hand, whichproduction-media-toggle.shdoes:bash./infra/scripts/production-media-toggle.sh up # floor → 1, waits healthy # …bake renders, confirm `ready` on Bunny… ./infra/scripts/production-media-toggle.sh down # restores min = 0clinicruns atdesired_count = 1(compute.tf, verified live 2026-08-11). Earlier revisions of this runbook and a comment in the workflow both grouped it with media at 0; the Terraform is authoritative.
Emergency local hotfix path is push-image.sh — but it now refuses non-staging targets without --env production + a typed confirmation, and bakes staging NEXT_PUBLIC_* defaults unless overridden. Prefer the workflow.
7. Cloudflare / DNS / hostnames
Platform records (
api/console/clinic/portal/telemetry/tv+ wildcards) are created by theedge-cloudflaremodule in step 1, proxied. Verify they resolve and serve.Custom domains (Cloudflare for SaaS). Custom domains are a production-only feature (staging's wiring was scaffolding to prove the path). The mechanism is the same for both parts below: Cloudflare terminates the custom hostname's TLS at the edge and forwards to the portal origin; the ALB's custom-domain catch-all rule (
custom_domain_catch_all, priority 50000) routes the unrecognized Host to the portal target group — there is no per-host ALB rule (the hardcodedplatformahost header was removed in8725a71); the portal resolves the org from the Host /X-Forwarded-Host. Onlydomain_type=portalis accepted today — clinic-app custom domains are gated inAddDomain(bb9450a) until the edge dispatcher ships (seeapps/docs/features/platform/custom-domains.md).a. Real clinic custom domains —
restartix.prozone. Enable Cloudflare for SaaS onrestartix.pro; set the zone fallback origin toportal.restartix.pro(the prod portal ALB target); create the proxiedcustomers.restartix.proCNAME (the SaaS CNAME target — must match the api task def'sCLOUDFLARE_SAAS_CNAME_TARGET). Clinics then self-register via the Console/Clinic custom-domain UI →cloudflaresaas.Register(wired intoAddDomain,4ff8be9) → they CNAME their domain atcustomers.restartix.pro;VerifyDomainpolls Cloudflare untilstatus=verifiedandresolveOrganizationByDomainserves it.b. Demo vanity host
platforma.restartix.ro—restartix.rozone (separate fromrestartix.pro). Enable Cloudflare for SaaS onrestartix.ro; set its fallback origin toportal.restartix.pro. Registerplatforma.restartix.ro(DV cert), then add a verifiedorganization_domainsrow for therestartixorg (domain=platforma.restartix.ro,domain_type=portal) soresolveOrganizationByDomainresolves it.Apex landing site (
restartix.pro+www). The bare apex is served by a static splash from S3 (apps/landing/), same model as the TV companion — an apex bucketrestartix.pro(Cloudflare flattens the proxied CNAME at the apex) plus a redirect-onlywww.restartix.probucket → 301 → apex. Both DNS records and buckets are created by step 1 (compute.tf). After apply, deploy the content with thelandingjob indeploy-production.yml(gh workflow run deploy-production.yml -f service=landing), then verifyhttps://restartix.proandhttps://www.restartix.pro. Without this the apex is an NXDOMAIN. (apps/landing/index.htmlis a placeholder splash — swap in real marketing content when ready.)Flexible-SSL for S3-website hosts (cross-env, do once). The S3-website hosts have HTTP-only origins, and Cloudflare allows only ONE
http_config_settingsruleset per zone — both rulesets are owned by staging'scloudflare.tf. Two zones are involved:restartix.prozone: the apex landing (restartix.pro) +www.restartix.pro. Rule lives incloudflare_ruleset.restartix_pro_flexible_ssl.restartix.tvzone: the TV companion apex (restartix.tv, prod) +staging.restartix.tv. Rule lives incloudflare_ruleset.restartix_tv_flexible_ssl(+cloudflare_zone_settings_override.restartix_tvforalways_use_https).
Apply staging so both rulesets land on their zones, otherwise those hosts return Cloudflare 525. The rules no-op until the production DNS resolves, so applying on staging ahead of production is safe. (The
restartix_pro_flexible_sslruleset was renamed fromtv_staging_flexible_ssl—terraform state mvit on staging before that apply; the exact command is in the resource's comment ininfra/envs/staging/cloudflare.tf.)ALB origin lock. The ALB SG accepts 443 only from Cloudflare IP ranges (
data.cloudflare_ip_ranges). Confirm a direct hit to the ALB DNS name from a non-Cloudflare IP is refused, and all real traffic flows through Cloudflare.
8. Bunny + webhooks
Append the production webhook token to the production Bunny Stream library's webhook URL in the Bunny dashboard:
TOKEN=$(aws secretsmanager get-secret-value --secret-id restartix/production/bunny-stream-webhook-secret \
--region eu-central-1 --query SecretString --output text)
# Set the library webhook URL to:
# https://api.restartix.pro/webhooks/bunnystream?token=$TOKENBunny is the only webhook production registers today. The video provider's (cmd/video-webhook-register) is not a step here yet and must not be run against production before the video substrate lands — there is no provider row to verify deliveries against, so it would arm an endpoint whose every delivery fails. When video promotes, this section gains the second registration and it is the easiest item in the whole runbook to forget: rooms open, consultations run, and the event stream, the minutes meter and the per-participant timeline are simply never populated. Procedure and credentials in video-infrastructure-plan §4.1; the staging form of the same step is in staging-rebuild-runbook.
9. Observability + cost
Confirm SNS email. Set
alerts_email_subscribersininfra/envs/production/terraform.tfvarsto an ops distribution address,apply, then click the confirmation link AWS emails (alarms/budgets don't deliver until confirmed).Activate the
Environmentcost-allocation tag in Billing → Cost allocation tags (so the production budget'sTagKeyValuefilter matches; takes ~24h to populate). Until then the budget tracks$0— drop thecost_filtertemporarily for an account-wide budget if you need coverage sooner.Confirm the six
*-no-running-tasksalarms reachOK, notALARM. Added 2026-08-11, one per always-on service (mediais excluded — it idles at zero by design). They readRunningTaskCountfromECS/ContainerInsights, so Container Insights must be enabled on the cluster or the metric is never published; because the alarms treat missing data as breaching, that misconfiguration pages for every service at once. They sit inINSUFFICIENT_DATAfor the first few minutes after creation and settle once datapoints land — verified taking about five minutes on 2026-08-11.bashaws cloudwatch describe-alarms --alarm-name-prefix restartix-production \ --region eu-central-1 --query 'MetricAlarms[?StateValue!=`OK`].[AlarmName,StateValue]' --output textEmpty output is the goal. Two families of alarm are expected not to be
OKand are not incidents: the auto-createdTargetTracking-…-AlarmLow-…pairs sit permanently inALARMon an idle cluster, and media's pair readsINSUFFICIENT_DATA. Only therestartix-production-*named alarms matter.Seed a datapoint for any cron heartbeat alarm immediately after the apply. A fresh heartbeat alarm sits in
ALARMuntil its first success, which on a daily cadence could be a day away.
10. Smoke test
curl -fsS https://api.restartix.pro/healthz | jq # status/postgres/redis ok
curl -fsS https://portal.restartix.pro/api/health | jq
curl -fsS https://telemetry.restartix.pro/healthz | jq
curl -fsS https://console.restartix.pro/api/health| jqThen exercise the demo end-to-end: legacy "Try it" → /enter (handoff verify + Clerk sign-in token) → /demo (consent + claim) → play the session → stats.
What this substrate intentionally does NOT do (tracked follow-ups)
Layer 2 daily pg_dump backup is off.SHIPPED — enabled in production 2026-08-02 (enable_backup_cron = true), covering both the core and telemetry databases, each encrypted under its own envelope key into the Object-Locked bucket. On a rebuild, keep the ordering the flag's own comment records: deploy an api image carrying/bin/backup-runner+pg_dumpfirst, then flip the flag — arming a 02:00 UTC schedule against an image that cannot satisfy it makes the backup-missing alarm page for a self-inflicted reason (treat_missing_data = "breaching"). The restore drill (restore-drill.sh, entirely in-VPC) is what proves the artifacts actually restore; a verified checksum is not a proven restore. See backup-disaster-recovery.md.- Sentry SDKs not wired. The
restartix/production/sentrycontainer + DSN plumbing pattern are ready (seecompute.tf), but the Go + Next.js SDKs + CI source-map upload are an app-owner follow-up.SENTRY_DSNis NOT in the task defs yet (wiring an unpopulated secret would fail task startup). - HA NAT (one Gateway per AZ) is a documented Phase 2 lever — single-AZ NAT Gateway at launch.
- Real (v2) legal text / 412 re-consent, the full programs, DSAR/erasure, the k6 load gate, and cross-region backups are the real-launch gate, not the demo (june-demo.md → "Relationship to the real production launch").
Stress-test levers (Stream H)
- RDS bump:
rds_instance_classtfvar →db.t4g.large/db.r6g.largefor the stress weeks if the DB is the bottleneck; size back down after (Multi-AZ failover ~1 min). - ECS autoscaling absorbs the load delta between the ~5000-patient stress peak and the ~100-concurrent tail (min/max per service in
compute.tf). - Keep the env live continuously — do NOT trade away HA (Multi-AZ RDS, Redis replica, 2× core/portal) to cut the ~$600/mo floor.
Teardown / safety
RDS (deletion_protection=true, final snapshot) and the ALB (enable_deletion_protection=true) refuse terraform destroy until you clear the flags in a deliberate apply. SM secrets have a 30-day recovery window. The backup bucket has Object Lock COMPLIANCE (7-year) — objects can't be deleted inside the window even by root. There is intentionally no production reset script.