Decisions

ADR-137 — Project-manifest `db:` block: per-project Postgres as the default, declared via discriminated union

As of 2026-05-05 the gyrum fleet runs 22 production projects against **one** Postgres at `10.0.0.3:5432`, all using the role `darkfactory`, which has `GRANT`s on **every** database on that cluster. The operational…

#137

ADR-137 — Project-manifest db: block: per-project Postgres as the default, declared via discriminated union

Status: Accepted Date: 2026-05-05 Tracker: warp#1349 Cross-refs: ADR-093 (manifest-driven project tools — sister pattern), ADR-114 (capability-based provisioning), ADR-118 (every action proves itself — validator follows from this)

Context

As of 2026-05-05 the gyrum fleet runs 22 production projects against one Postgres at 10.0.0.3:5432, all using the role darkfactory, which has GRANTs on every database on that cluster. The operational reality:

  • A leaked DATABASE_URL for any one project reads the entire fleet.
  • There is no nightly backup yet (lifted at warp#1341), so the blast radius of an accidental drop is "everything".
  • The cluster's notes: field on the registry records this as a meantime exposure surface — it is the state to drain out of, not the steady state to extend.

Today's recon (see docs/sessions/2026-05-05.md) made the structural fix obvious: per-project Postgres should become the factory's default, with each project's manifest declaring where its DB lives. Two things are needed before the migration can start:

  1. A schema for declaring "where this project's DB lives" that reviewers can validate at PR time, not at deploy time.
  2. A single source of truth for cluster identities, so projects are decoupled from infra IPs.

Both belong upstream of the migration code. Locking them now means Phase 2 of warp#1240 (distill factory-shape) and every subsequent migration writes against a stable contract. Locking them after the first migration ships means re-litigating the shape every PR.

The pattern is the same shape as ADR-093 (manifest-driven project tools): the manifest declares a capability the project needs, the substrate names where it lives, and a generic deploy step resolves one to the other. ADR-114 named this generally; this ADR is the specific application to Postgres.

Decision

The project manifest schema gains an optional db: block that declares the project's Postgres dependency as a discriminated union over three branches: shared, local, external. Exactly one branch is permitted; absence of the whole block declares the project stateless. Per-project Postgres (db.local) is the factory's target default; the legacy shared-cluster shape (db.shared.cluster: primary) is the on-ramp every existing project starts on and migrates out of.

The cluster identities referenced by db.shared live in a single cluster registry at clusters/postgres.yaml, keyed by short name (e.g. primary). Manifests reference the cluster by name, never by IP — flipping the registry once moves every consumer. The same shape applies to backup buckets referenced by db.local.backup_to:: bucket identities live at buckets/object-storage.yaml, keyed by short name (e.g. gyrum-fleet-backups).

The full schema, validator rules, DATABASE_URL resolution table, and per-project role convention are in docs/specs/manifest-db-block.md.

The validator that enforces these rules is not part of this decision — it is a sub-ticket of warp#1349 and rides ADR-118's "per-block validation" stack (validator → CI → runtime). Until it ships, the schema is enforced by review.

Why a discriminated union, not a flat shape

The three modes have disjoint required fields: db.shared needs a cluster reference and a role; db.local needs an image version and a backup target; db.external needs a secret name and an annotation about who owns backup. A flat shape (one block, optional fields, mode flag) lets a manifest declare cluster: primary and version: "16" and url_secret: distill-database-url and the validator has to prove which combinations are nonsense. A discriminated union makes "exactly one of three" structural — the validator's job becomes "is the shape one of these three?", not "is this combinatorial state legal?".

Why a separate cluster registry

Three reasons:

  1. Decouples projects from infra. A project manifest says cluster: primary, not host: 10.0.0.3. The day primary moves, the registry flips and every consumer follows. With inline IPs, that's 22 PRs.
  2. Single audit surface for fleet topology. "Which projects ride the primary cluster?" is grep cluster: primary projects/*.yaml, not a topology recon session like 2026-05-05's.
  3. Drain is observable. When the registry's notes: field on a cluster says "to be drained" and grep shows zero references, that's the signal to decommission. No extra coordination doc.

Why per-project Postgres is the default, not just an option

The goal is to make the secure shape the easy shape. If db.shared reads as the default and db.local reads as the exception, the path of least resistance keeps every new project on the shared cluster. By naming db.local the target default in this ADR and in the spec, every new project starts there; db.shared stays available for the on-ramp migration but reads as the legacy mode it is.

Consequences

What becomes easier

  • Phase 2 of warp#1240 has a contract to write against. The distill factory-shape migration (and every subsequent project's) drops db.shared.cluster: primary into its manifest, no per-project plumbing.
  • Audit becomes a grep. "Which projects still ride the legacy shared cluster?" is one shell line.
  • Cluster moves are one PR. Update clusters/postgres.yaml, redeploy. No project-manifest churn.
  • Deploy step becomes generic over the union. One resolver per branch; no per-project conditional.

What becomes harder

  • The validator is now a hard dependency. Until it ships (sub-ticket of warp#1349), schema violations only get caught by review. Risk is bounded — only one project (distill) uses the block at PR-merge time — but it is real.
  • Per-project Postgres costs more. A db.local project pays for its own Postgres container, volume, backup target. The factory shifts from "22 projects sharing one DB" to "22 projects each with a DB" — more infrastructure to operate, even if each individual piece is smaller.
  • The migration is long. 22 projects × N flips each (manifest edit, deploy verify, drop legacy grant) is months of work. The cluster registry's notes: field carries the "still draining" state through the whole thing.
  • Backup decisions splinter. db.shared projects ride the fleet-level backup at warp#1341; db.local projects ride per-project backup_to:; db.external projects ride whatever the external provider offers. Three audit surfaces, one per branch.

What we have signed up to operate, maintain, or revisit

  • The cluster registry. Every cluster the fleet uses gets an entry. Stale entries (zero references) get removed as part of the drain.
  • The role convention. ${project}_app per-project roles, with passwords at secrets:${project}-pg-password, generated ≥ 32 chars, no reuse. The provisioning step that creates them is separate work; this ADR records the convention.
  • The ?sslmode=disable default. True on private networks today, false the moment a project moves to a public-network DB. Revisit when the first such case lands; the escape today is db.external with a TLS-bearing URL in the secret.
  • The major-version pin rule for db.local.version. Minor updates ride image rebuilds; major upgrades are a manifest edit. Revisit if the rebuild cadence stops covering CVEs in time — unlikely but worth flagging.

Alternatives considered

  • Inline DB shape per project, no schema. Each project hand-writes database: with whatever fields it wants. Lost: the validator becomes impossible (no contract to validate against), audits become per-project archaeology, deploy code branches per project. This is exactly the conditional-shape ADR-092 set out to kill, and the same failure mode would re-emerge here.

  • Single flat schema with a mode: discriminator. One db: block with optional fields and mode: shared | local | external. Lost: the validator has to prove which combinations of fields are legal for which mode (combinatorial state). The discriminated union makes "one branch present" the structural property and mode: a derivable property.

  • Inline cluster connection details in each manifest. Each project carries host: 10.0.0.3, port: 5432, .... Lost: cluster moves become a 22-PR migration. The registry pattern is one of ADR-114's worked examples ("manifests declare what they need; the substrate names where it lives") — applying it here is consistent.

  • Skip the spec, ship the validator first. Put the schema in code, let it materialise as the validator lands. Lost: the migration needs the contract before the validator (Phase 2 of warp#1240 starts before the validator can land), and reading the schema out of code is harder than reading it out of a spec doc. Spec-first is cheaper to revise — it has not shipped enforcement yet.

  • db: postgres-shared-primary | postgres-local | postgres-external as a string enum, with named clusters as one of those values. Lost: the per-branch fields have nowhere to go, or get hoisted to top-level keys, which is the flat-schema problem under a different name.


Supersedes: none Superseded by: (none)