Decisions

ADR-020: Shared infrastructure vs per-product isolation

The factory runs one Postgres instance (`dark-factory-postgres`) that every product-needing-SQL shares. [platform-gap-audit §5.5](../platform-gap-audit.md#55-single-shared-postgres--h--l-m) names this as a present-day…

#020

ADR-020: Shared infrastructure vs per-product isolation

Status: Proposed Date: 2026-04-21

Context

The factory runs one Postgres instance (dark-factory-postgres) that every product-needing-SQL shares. platform-gap-audit §5.5 names this as a present-day risk: a bug in any product's migration, a privilege-escalation in any product's query path, or a runaway query crashes every product simultaneously.

Two competing forces:

  • Factory thesis favours sharing. Running N Postgres containers on one VPS is wasteful (per-instance fixed overhead, duplicate WAL, duplicate connection pools). Operating N backup stories, N upgrade paths, N monitoring surfaces, N user-and-role policies multiplies operational cost.
  • Factory thesis also demands that a killed experiment leaves no footprint. Per-product isolation makes teardown trivial (drop database; docker rm). Shared infrastructure makes teardown a careful pg_dump -n <schema> + drop schema that has to avoid collateral damage.

The product-concept doc (§8) proposes a spectrum of isolation levels a product can pick from, with a migration path between them. This ADR ratifies that spectrum and — crucially — assigns the default rather than leaving the choice to a product owner at scaffold time.

The gap audit's Tier-3 T3-A already names "per-product database isolation" as a scale/maturity item: separate Postgres users with enforced search_path, or separate instances on the same VPS. That item remains in its tier; this ADR provides the decision framework so products pick the right level without waiting for T3-A.

Decision

Three isolation levels exist:

Level Shape Default for
L1 — Shared schema One database, one public schema or a per-product schema inside it; no dedicated Postgres user. Experiments (tier: 2).
L2 — Per-product database Separate database inside the shared instance; dedicated Postgres user with GRANT scoped to that database. Live products (tier: 1).
L3 — Isolated instance Separate Postgres container (still on the VPS, still on the shared docker network) or separate VPS entirely. Platform-critical (tier: 0) products with data that must survive a shared-instance incident.

The default is the product's tier's row above. A product may pick a level higher than its tier's default (a tier: 2 experiment that handles real payment data picks L2); a product may not pick a level lower than its tier's default without a defence recorded in its own README or ADR.

Declarations:

  • L1 products do not need to say anything in .gyrum/product.yml — L1 is what depends: [shared-postgres] gives you.
  • L2 products declare depends: [shared-postgres] and additionally call out the per-product database in their product README. A future extension of product.yml may add an isolation: db field — flagged here so a successor ADR knows where to extend.
  • L3 products depend on their dedicated instance as a named entry (e.g. depends: [buzzy-postgres]) rather than on shared-postgres. Provisioning the dedicated instance is part of that product's deploy.

Migration path: L1 → L2 is a scheduled window (pg_dump the schema, create new database, restore, rewrite the product's DATABASE_URL, redeploy, drop old schema). L2 → L3 adds "provision new container / VPS" to that sequence. Migration tooling is out of scope for this ADR; documenting the sequence is part of platform-gap-audit T3-A.

Shared Redis and shared bucket, should they appear, follow the same L1/L2/L3 shape: shared namespace / dedicated key prefix / dedicated instance. Each gets its own ADR at the time it ships.

Consequences

What becomes easier. A product owner has a single question to answer at scaffold time ("is this an experiment or a live product?") and the right isolation level follows mechanically. Incident reviews have a known axis ("was this a shared-schema product? a per-product-db product?") to reason about blast radius against. Migration is a visible path rather than an implicit leap.

What becomes harder. Shared-instance backup has to know, per database, what to dump (easy — pg_dumpall suffices today; per-product restore is the real test, covered by T1-E). Role/grant management grows — L2 introduces per-product users, which is more surface than L1's shared-role-on-everything. A product promoted from L1 → L2 needs an operational plan and a window.

What we sign up to operate. The level assignment for every product (part of the migration PR that adds .gyrum/product.yml). Per-product Postgres users for L2 products (small — pkg/database already supports the pattern via its CreateTenantPolicy plumbing, with adaptation). The defence note in a product's README when it picks below its tier's default.

What we accept as risk. L1 products are acknowledged to share blast radius — a bad migration in one experiment can corrupt another experiment's data. The mitigation is (a) migration review rigour per repo, (b) backup frequency such that rollback-to-last-dump is minutes not days, (c) promoting a product to L2 the moment it holds anything it would miss. Near-term, most live products will land at L2; most experiments at L1.

Non-consequence: this ADR does not pick an L3 substrate. "Separate instance on the same VPS" and "separate VPS" are both L3; the choice is made per-L3 product based on the reason it went L3 in the first place. Future ADRs ratify specific L3 deployments.

Alternatives considered

  • Every product starts at L3. Rejected. Operational cost multiplied by N products undoes the factory thesis. Also: many experiments never hold data worth isolating.
  • Every product starts at L1; promote manually when problems surface. Close to today's behaviour. Rejected because "when problems surface" is code for "after the incident" — the Tier-1 products especially are exactly the ones that cannot afford to learn via incident.
  • Isolation by database user + search_path, skip the per-database option. Rejected as the default for live products. User-and-schema isolation is weaker than database isolation (a role with CREATEDB or a privilege-escalation bug in pg_catalog escapes it), and the operational win (one backup story) is preserved by L2 since L2 still lives inside the shared instance.
  • Per-product instance from day one. Rejected. Same reasoning as the first alternative, with the additional problem that our VPS has 4 GB of RAM (infrastructure/README.md) and hosting 30+ Postgres instances is silly.
  • Use Postgres RLS everywhere, no other isolation. Rejected as a substitute for level isolation. RLS is excellent within a multi-tenant product (per-tenant rows in one product's database). It does not protect product A from product B's DDL on a shared schema.
  • Managed Postgres (RDS, Neon). Out of scope by platform-gap-audit §7. Revisit when a product's uptime or residency requirements force the change.

Supersedes: none Superseded by: