ADR-007: Shared staging super-database with per-project login roles
This memento supersedes ADR-006 in
full: prod was never consolidated onto a shared instance, and staging now uses
<proj>_staging login roles rather than {project}_app NOLOGIN roles.
Context
ADR-006 decided two shared Supabase instances — one prod, one staging — each
holding a schema per project, with a NOLOGIN {project}_app role per schema
and a connection string carrying ?options=-csearch_path%3D{project}.
The prod half of that was never executed. ops/cron/backup-dbs.sh reads five
separate secrets (ANNIE_DB_URL, RELI_DB_URL, FILMDUEL_DB_URL,
KINDRED_DB_URL, LACHESIS_DB_URL), and ops/cron/README.md states that
every project is dumped --schema=public because “all five keep their tables
there”. Prod is five per-project databases, each with its tables in public.
The decision recorded in issue #66 on 2026-09-10 settles what happens next. Wiring Reli to the shared staging project surfaced two facts about the live infrastructure, both reported from Supabase and Railway configuration this repository cannot reach:
- Reli’s staging and production environments had been pointing at one database.
- The Supavisor pooler silently discards
options=-c search_pathfrom the connection URL. A connection made that way resolves against the default search path, not the project schema — so the recipe written intoops/db-migrations/011,014,015,016,017-*.sqldoes nothing, without erroring.
That is why the role convention below sets search_path on the role instead of
in the URL.
Decision
Staging is one shared super-database; prod is per project
Every project’s staging environment uses the shared Supabase staging project
(xhcfolmrdctmtqgqqxec, eu-west-1). Production databases stay per project.
Schema-level isolation is what makes one shared staging database safe, carried forward from ADR-006: a project’s role holds privileges on its own schema and nothing else, so a leaked or misconfigured staging credential cannot read another project’s tables.
The role and schema convention
For each project on the staging super-database, as superuser:
CREATE ROLE <proj>_staging LOGIN PASSWORD '...';
GRANT <proj>_staging TO postgres;
CREATE SCHEMA <proj>_staging AUTHORIZATION <proj>_staging;
ALTER ROLE <proj>_staging SET search_path = <proj>_staging;
GRANT USAGE ON SCHEMA extensions TO <proj>_staging;
Two properties make this work:
- The schema name equals the role name. Postgres’ default search path is
"$user", public, so a session authenticated as<proj>_stagingresolves into schema<proj>_stagingwith no URL parameter at all. The explicitALTER ROLE ... SET search_pathpins it even if the default changes. - The role has no privileges on
public. Projects cannot touch each other’s tables, and a project cannot accidentally create tables inpublic.
The connection string
Railway’s staging DATABASE_URL authenticates as the role itself on the
session-mode pooler:
postgresql://<proj>_staging.<ref>:<password>@aws-1-eu-west-1.pooler.supabase.com:5432/postgres
Port 5432 is session mode and is required — Alembic runs DDL, which transaction
mode cannot carry. Do not append options=-c search_path=...: the pooler
ignores it, which is the failure this ADR exists to stop repeating. The search
path comes from the role.
The Alembic baseline rule
A migration with down_revision = None — a clean baseline — requires dropping
alembic_version by hand before it runs, and must never be deployed while
staging and prod share a database. A baseline applied against a shared
database rewrites the migration history of both environments at once.
Adopting the convention for a remaining project
- reli — done; the reference implementation. Its staging deploy is probed
at
https://reli-staging.up.railway.app/healthzbypipeline-health-cron.sh. - annie — a known exception. Its staging deploy exists
(
word-coach-annie-staging.up.railway.app/api/health) and its staging tables live inpublicon the super-database, predating this convention. Moving it toannie_stagingis not required by this ADR. - filmduel — the staging deploy exists
(
filmduel-staging.up.railway.app); only the database side is missing. Run the role/schema SQL, then repoint the Railway stagingDATABASE_URL. - kindred — has no staging deploy yet (issue #34 closed with the Railway
staging environment, Supabase staging setup and GitHub secrets outstanding).
Create the Railway staging environment first, then the role and schema, then
the
DATABASE_URL, then add the health URL toSTAGING_DEPLOY_URLSinops/cron/pipeline-health-cron.sh. - lachesis — same state and same sequence as kindred (issue #35 left its Railway, env and DNS steps manual).
Verify each one by connecting with the staging DATABASE_URL and checking that
SHOW search_path returns the project’s schema, and that a SELECT against
another project’s schema is refused.
Consequences
ops/db-migrations/011-staging-init.sqland the014–017schema-isolation runbooks are superseded and must not be run. They now carry a banner saying so. Running one today would create a{project}schema and a{project}_appNOLOGINrole that this convention does not use.- ADR-006 is superseded in full. Every one of its decision bullets is replaced:
prod is per project, staging schemas are
<proj>_staging, the role logs in, and staging deliberately no longer mirrors prod. - Staging no longer mirrors prod’s schema names — staging is
<proj>_staging, prod ispublic. Migration SQL must therefore be schema-agnostic (unqualified table names, resolved by the session’s search path) to be valid in both environments. Hard-coding a schema name breaks one of them. - ADR-005’s credential bullet, which says prod projects point to a shared prod Supabase instance, is corrected by this ADR: prod is per project. ADR-005’s own decision — separate credential sets per environment, automated promotion — still stands.
- Each project gains one new secret: the
<proj>_stagingrole password, held in that project’s Railway staging environment. - Open, and not resolvable from this repository: prod connections are per
project against databases whose tables are in
public, so the pooler’s handling ofoptionsis not load-bearing there today. Whether any prod connection string still carriesoptions=-c search_pathcan only be checked in Railway.
Alternatives considered
options=-c search_pathin the connection URL (what011and014–017document): rejected. The Supavisor pooler discards it, so the connection silently resolves against the default search path. This is the defect that produced this ADR.ALTER ROLE ... SET search_pathon the existing{project}_approle: rejected. ANOLOGINrole cannot be the authenticating user, so itssearch_pathdefault never applies to the app’s session.- Schema named
<proj>, role named<proj>_staging: rejected. With mismatched names the default"$user", publicpath resolves nothing, and the search path has to be carried somewhere again. Matching the names is exactly what removes the URL parameter. - Transaction-mode pooler (port 6543): rejected. Alembic’s DDL needs session mode.
- A per-project staging database, mirroring prod’s isolation: rejected. That is the per-instance cost ADR-006 set out to remove, and one shared staging database with schema isolation already bounds the blast radius.