alembic.ini and migrations/env.py) and the same privilege boundary: only a one-off task with owner/migrator credentials ever runs DDL. The long-running API and product worker never receive those credentials. This page covers the branch layout, how each environment invokes it, and what happens when a tenant schema needs to catch up on a migration after it was already provisioned.
Branch layout
migrations/versions/ is not one linear history — it’s three independent Alembic branches sharing one alembic.ini / migrations/env.py, distinguished by branch_labels:
The
control and tenant_template branches both start from down_revision = None — they’re independent trees, not descendants of the legacy unlabeled branch. migrations/env.py’s _tenant_schema_argument() reads an -x schema=<tenant_schema> Alembic argument; when it’s set (only ever for the tenant_template branch), run_migrations_online() creates that schema if missing, points search_path at it, and tracks that schema’s own alembic_version row via version_table_schema — so every tenant gets an independently-versioned copy of the same table set, and the control branch always migrates the fixed control schema regardless.
The
tenant_template migrations themselves use unqualified table names (no schema prefix) specifically so the same migration file works correctly regardless of which schema search_path is currently pointed at — see the module docstring in migrations/versions/20260722_t001_tenant_template_schema.py. Every table in that branch also carries a tenant_id column and a FORCE ROW LEVEL SECURITY policy as defense in depth on top of schema-per-tenant isolation — see Tenancy and data model.DSN resolution: MIGRATIONS_DATABASE_URL vs APP_DATABASE_URL
services/api/tenancy/db.py documents three connection-string roles:
APP_DATABASE_URL— theapp_rwrole’s connection string. Used by the API and the normal product worker for all runtime reads/writes. This role cannot run DDL.MIGRATIONS_DATABASE_URL— the owning/migrator role’s direct connection string. Used only by Alembic and the provisioner, for DDL and for advisory locks that don’t tolerate transaction-mode connection pooling.migrations/env.py’s_database_url()reads this first, falling back toCAUSELOOP_POSTGRES_DSNand thenDATABASE_URLfor compatibility with the legacy MVP1 relational runtime’s env-var conventions.DATABASE_URL— a backward-compatible owner/pooler URL, re-targeted toapp_rwonly whenAPP_DATABASE_URLis absent.
MIGRATIONS_DATABASE_URL; any process that should only serve traffic gets APP_DATABASE_URL and nothing else.
The local one-off migrate service
infra/docker-compose.product.yml defines a migrate service (restart: "no") that runs:
scripts/migrate_product.py does three things in order, using MIGRATIONS_DATABASE_URL (the compose file sets it to the causeloop superuser DSN, distinct from the app_rw DSN the API uses):
run_control_migrations()—alembic upgrade control@head, applying everycontrolbranch revision.ensure_app_rw_role()— creates theapp_rwPostgres role if it doesn’t exist (or updates its password/attributes if it does), withNOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS. BecauseCREATE ROLE ... PASSWORDis DDL and can’t take a bind parameter, this usespsycopg.sql.Literalto embed the password safely, on its own autocommitted connection.grant_app_rw_on_control()— grantsapp_rwSELECT, INSERT, UPDATE, DELETEon every table in thecontrolschema, plus a default-privileges rule so future tables in that schema are automatically granted too.
local-admin, product-worker, provisioner-worker, api) declares depends_on: migrate: condition: service_completed_successfully (directly, or transitively through another dependency), so nothing can run against a database migrate hasn’t already finished preparing.
Note that migrate only runs the control@head upgrade — it never touches tenant_template. Tenant schemas are migrated individually, at provisioning time (see below), not as part of this global step.
AWS and Azure: the same script, as a one-off task/job
Both cloud templates run the identical command as a one-off workload, distinct from the long-running services:- AWS (
deploy/aws/main.tf): anaws_ecs_task_definition.migrationFargate task definition withcommand = ["python", "scripts/migrate_product.py"], receiving onlyMIGRATIONS_DATABASE_URL(the owner DSN) andAPP_RW_PASSWORDas Secrets Manager secrets.deploy/aws/run-migration.shruns it withaws ecs run-task --launch-type FARGATE(no public IP), waits withaws ecs wait tasks-stopped, and asserts the container’s exit code is0before returning — it fails loudly underset -eurather than leaving you to check the console. - Azure (
deploy/azure/main.bicep): aMicrosoft.App/jobsContainer Apps Job (migrationJob) whose container also runscommand: ['python', 'scripts/migrate_product.py'], started manually withaz containerapp job start --name causeloop-production-migration --resource-group YOUR_RESOURCE_GROUPand requiring a successful execution before proceeding.
docs/CLOUD_DEPLOYMENT.md: deploy infrastructure with long-running services disabled (activate_services=false / activateServices=false) → run the migration task/job → run the administrator-bootstrap task/job → only then enable the frontend, API, and workers.
Provisioning-time tenant migrations
New tenants never need a separate migration step:provision_tenant_by_slug() (in services/api/tenancy/provisioner.py) calls run_tenant_migrations(schema_name) as part of provisioning, which runs:
-x schema=<tenant_schema> set via cfg.cmd_opts. Because Alembic tracks its own per-schema alembic_version row (via version_table_schema in migrations/env.py), this is naturally idempotent — a schema already at head is a no-op if run again.
The migrate_all_tenants.py caveat for already-provisioned tenants
New tenant_template revisions only reach a tenant automatically at provisioning time. An already-live tenant’s schema does not pick up a new revision on its own — something has to explicitly run run_tenant_migrations() against it again. scripts/migrate_all_tenants.py does exactly that, for every non-offboarded tenant (or a --only <slug> subset):
control.tenants (excluding lifecycle_state = 'offboarded') and calls run_tenant_migrations(schema_name) for each — safe to re-run, since a tenant already at head is a no-op.
Rollback posture
Every migration file inmigrations/versions/ defines a real downgrade() — most execute destructive DROP TABLE IF EXISTS / DROP COLUMN IF EXISTS statements, not a no-op placeholder. That means alembic downgrade is technically available, but running it against a database holding real tenant data means losing whatever columns or tables that revision introduced — there’s no data-preserving downgrade path.
In practice, docs/CLOUD_OPERATIONS.md establishes the actual rollback discipline used in production: readiness, not alembic downgrade, is the deployment and rollback gate. GET /health/ready — not /health/live — is what should decide whether a deployment is trusted; traffic should never be routed on liveness alone. If a migration or deploy is bad, the documented recovery path is restore-oriented, not downgrade-oriented:
- Stop new writes (scale API and workers to zero) if data integrity is uncertain.
- Preserve PostgreSQL job rows, logs, request IDs, and object versions as-is.
- Restore PostgreSQL first — it is the metadata and business authority, ahead of anything in the object store.
- Restore or select the correct object versions next.
- Bring the provisioner, product worker, API, and frontend back up in that order.
- Require readiness and reconcile any queued/running jobs before reopening traffic.
scripts/rehearse_product_backup.sh is the local, cheap-to-run version of the same rehearsal — see Local parity stack.