ADR-063: Migration Gate + Verify-Only Boot — One Schema State Machine for Both Modes
On this page
Status
Accepted (2026-08-02), implemented in full by epic &78 (2026-08-03; MRs !1162–!1167 and
!1169, plus the M5 close-out MR — M0 docs → M1 state machine → M2 migrate mode → MF
registry → M3a gate-first → M3b verify-only flip → M4 test-plane pre-warm; !1168 was the
interleaved #1311 AdvisoryLease fix the M4 battery surfaced, not an epic unit). Drafted
FIRST (the draft-first path). As-built deltas from this
document, recorded in the archived plan’s per-unit notes (the D3 scope decision in this
document’s own D3 as-built note): the D1 Diverged rule was WIDENED at M0
J-review to catch doubly-incomparable sets (making Behind ⇒ applied ⊊ embedded hold by
construction — the D2 apply-safety parenthetical was corrected accordingly); the D3 lint’s
"destructive" scope excludes widening drops (DROP NOT NULL/IDENTITY/EXPRESSION/
CONSTRAINT/DEFAULT — see the D3 as-built note); the gate core reuses a 1-connection
DbPool rather than a raw PgConnection (intent kept: no metrics, no probe); the mains'
mode dispatch + the verify-only composition each live in ONE craig-api fn
(run_process_modes_if_requested, bootstrap_verified) instead of 8 copies. Plan:
Migration Gate + Verify-Only Boot (archived).
Context
Every stateful service applies its embedded migrations inline at boot
(craig_db::run_migrations, called from each service’s boot orchestrator immediately after
craig_api::bootstrap() returns — 8 call sites), before the only TcpListener::bind.
Three verified defects:
-
Rollback is bricked. sqlx’s
Migrator::runhard-errors withVersionMissingwhen the database contains versions the binary does not embed — an old binary against a newer database refuses to start. The error is one-directional by design: sqlx tolerates nothing it does not know. -
Boot convoy. Migrations run behind sqlx’s session advisory lock before any health surface exists; N replicas convoy, orchestrator probes kill the waiters, and the fleet kill-loops precisely when a slow migration most needs to finish.
-
Test plane. The same coupling drives #1275: lazy template-DB migration under 8-way nextest contention — waiters die at the 120 s kill.
A naive gate (run Migrator::run in a one-shot before starting the app) fails review: a
rolled-back (older) gate image would itself VersionMissing against the newer database —
the gate re-bricks the rollback it exists to restore. The gate must inspect first and
know when doing nothing is the correct action.
Decision
D1. One schema state machine, owned by craig-db, consumed by both modes
A pure classifier classify(applied, embedded) → SchemaState over the applied records
(version, checksum, success) (plain SELECT from _sqlx_migrations; only SQLSTATE
42P01 maps to "Behind from zero", every other error stays a DB error) and the embedded
set filtered to up-migrations only (matching sqlx’s apply semantics, which skip down-files).
With extras = applied ∖ embedded and missing = embedded ∖ applied, the relation is
total with deterministic precedence, first match wins (the embedded set is nonempty by
construction — all 8 services embed migrations — so max(embedded) is always defined):
Dirty { version } (any success = false) → ChecksumMismatch { version } →
Diverged { unknown } (extras ≠ ∅ AND (missing ≠ ∅ OR any extra ≤ max(embedded))) →
Behind { missing } (missing ≠ ∅ — reachable only when applied ⊊ embedded) →
Ahead { extra } (extras ≠ ∅ — reachable only when embedded ⊊ applied and ALL extras
strictly newer than max(embedded)) →
Exact.
Diverged is deliberately wide. An arbitrary applied superset is NOT Ahead: embedded {1,3}
vs applied {1,2} is Diverged (an unknown version inside the embedded range — the
histories forked), and the mirror embedded {1,2} vs applied {1,3} is likewise Diverged
(doubly-incomparable). The mirror case is why Diverged must catch missing ≠ ∅ ∧
extras ≠ ∅: a naive "missing → Behind" rule would send it to the gate’s apply, and sqlx
validates EVERY applied migration against the embedded set before applying
(list_applied_migrations → validate_applied_migrations, sqlx-core 0.8.6
migrator.rs:160-161) — the apply would VersionMissing. Under this precedence, Behind
implies applied ⊊ embedded, so the gate’s apply is VersionMissing-free by construction.
D2. The two consumers
| State | Gate (migrate mode) |
Serving boot (verify) |
|---|---|---|
|
no-op, exit 0 |
boot |
|
no-op, exit 0 — this is what restores rollback |
boot + |
|
apply (safe by construction: Behind ⇒ applied ⊊ embedded ⇒ sqlx cannot |
refuse |
|
refuse, exit ≠ 0 |
refuse |
Serving binaries, once M3b lands, never apply DDL. Refusals are typed
(SchemaVerifyError / MigrateModeError, thiserror), deployment-neutral base message plus
an optional caller-supplied gate_hint; the typed message IS the operator surface —
refusal precedes bind, so no health endpoint exists to differentiate on.
D3. Compatibility floor — direction does not establish compatibility
Ahead-tolerance alone would boot an M-era binary against a schema whose contract migrations
dropped structures it uses (the repo already contains an authorized drop; CONTRIBUTING
permits removal after deprecation). A schema_compat_floor table (single row,
min_required_version BIGINT, seeded 0) lands in every service schema. Destructive
contract migrations MUST bump the floor to their own version in the same migration file
(lint-enforced by validate-migration-constraints); verify-Ahead refuses when
floor > max(embedded). Rollback support is thereby explicit and machine-checked: to the
newest release whose embedded max ≥ the floor.
As-built scope (M2/#1307): "destructive" = removes a structure an old binary may READ —
DROP TABLE, DROP COLUMN, the ALTER TABLE … DROP <col> shorthand (incl. multi-line).
WIDENING drops (DROP CONSTRAINT / DROP DEFAULT / DROP NOT NULL / DROP IDENTITY /
DROP EXPRESSION; standalone DROP INDEX) are deliberately out of scope — they widen what
the schema accepts and flagging them would push authors toward a wrong floor bump, the
exact harm this decision prevents. Waiver: -- floor-exempt: <reason>, only when no
released binary reads the dropped structure.
D4. Same image, process-mode separation — <binary> migrate
The gate is the service binary itself invoked as <binary> migrate (exact argv shape, no
extras; --print-openapi precedence defined; non-Unicode argv is a typed error): zero
image drift between the embedded set the gate applies and the set the app verifies —
one build, one truth. The mode runs logging-only telemetry, requires exactly one env var
(CRAIG_<SVC>__DATABASE_URL), and uses a minimal single connection rather than the serving
pool helper (pool metrics + background probes are wrong for a one-shot).
Honesty note (superseded by #1310, 2026-08-04): same image + same DB credentials was
process-mode separation, NOT a privilege boundary — a compromised serving process could
still issue DDL. #1310 shipped the real boundary as the flip this note predicted
(provisioning, not code): per stateful service, a migration-owner role
(craig_<svc>_owner on devstack — LOGIN, owns the database, so migrations incl. the
trusted pg_trgm extension apply without superuser; used ONLY by the gate) and a
runtime role (craig_<svc>_app — CONNECT + schema USAGE + table DML via the owner’s
ALTER DEFAULT PRIVILEGES; the serving boot’s verify reads ride the same grants). DDL
under the runtime role refuses with SQLSTATE 42501 (negative-pinned); the app-role verify
is regression-pinned on both the Exact and Ahead+floor paths (craig-db role_split), and
the devstack gate/serving DSN split is drift-pinned structurally (xtask registry).
Residual CLOSED (#1550, 2026-08-23): default privileges cannot exclude the two schema
meta-tables, so the runtime role initially holds DML (not just SELECT) on
_sqlx_migrations + schema_compat_floor — a spoof/denial surface on what verify READS.
The gate now hardens this AUTOMATICALLY on every SUCCESSFUL verdict (apply and no-op
alike; a refused verdict skips straight to close): DbPool::harden_meta_tables revokes
INSERT, UPDATE, DELETE on both tables from every non-owner grantee — PUBLIC included —
read from the table ACL itself (aclexplode(pg_class.relacl), complete regardless of
session visibility: a superuser-issued grant to a third role is caught too; the
information_schema view would have session-filtered it). Convention-free — the runtime
role’s NAME is a provisioning choice; a pre-#1310 shared-credential deployment yields no
non-owner grantees and the pass is a no-op. SELECT is untouched (verify’s reads), the
pass is idempotent, and a failure fails the gate like a schema refusal (and skips the
#1395 post-apply hook — hardening precedes it). Pinned in craig-db role_split:
post-hardening runtime DML on both tables refuses 42501 (app role, superuser-granted
third role, and PUBLIC all revoked) while verify and domain DML keep working. cargo xtask migrate rollback restores as the OWNER role so
restored objects keep both the app grants and future gate alterability — the next gate
run re-hardens the restored ledger.
D5. Orchestration shape
Compose (the reference orchestration): one gate service per stateful service, YAML anchors
sharing image: + build: with the app (an image-only gate could run a stale :local
image and exit 0 silently); restart: "no"; app services gain
service_completed_successfully edges. A structural invariant test pins the graph.
Kubernetes (documented, not shipped): a parallelism-1 Job or deploy hook. A per-pod init
container is NOT equivalent — per-pod means N concurrent appliers, which recreates the
convoy this ADR removes. Partial-application contract: gates report per-service and exit
non-zero if any failed; additive migrations make retry safe.
D6. Staged transition (external deployments cannot brick)
M3a lands the gates while boot keeps applying (verdict-driven apply_by_verdict — this
alone un-bricks rollback fleet-wide via Ahead-tolerance). M3b flips boot to verify-only
after the gates are proven in-graph, with the pre-1.0 breaking CHANGELOG entry. The
release-gating evidence: a rollback-boundary test that runs the OLD (N−1) gate and OLD
verify against the NEWER database via runtime migrators over a truncated migration-dir
copy; an upgrade-in-place test (existing volume through the gates, no reseed); a
Postgres-only proof ×8 (migrate mode with all non-DB settings absent); an automated
poisoned-gate test.
D7. Enforcement
run_migrations becomes callable only from craig-db internals + the migrate-mode helper
(validate lint) — no serving path applies DDL after M3b. The destructive-migration floor
lint (D3) and the structural compose invariant (D5) are validate-time gates. The xtask
service inventory collapses to ONE typed registry (the cmd/migrate.rs duplicate silently
omitted craig_composition — fixed as its own issue, #1308).
Alternatives considered
| Alternative | Verdict | Why |
|---|---|---|
Boot-apply status quo ( |
Rejected |
Rollback bricked by |
Per-pod init container |
Rejected |
Per-pod = N concurrent appliers; recreates the convoy. Init containers are a pod-level construct and cannot be a singleton. |
Leader-elected in-band migration (one replica applies, the rest wait) |
Rejected |
Distributed coordination inside serving boot; still couples DDL to serving startup; failure modes opaque to operators (which replica is the leader mid-kill-loop?). |
Separate migrator binary / image |
Rejected |
Two artifacts whose embedded sets can drift; the checksum contract then fails between the migrator’s set and the app’s; doubles the release surface for zero isolation gain (same credentials either way). |
Same-image process-mode separation ( |
Chosen |
One embedded set (zero drift); orchestration-neutral (compose one-shot / k8s Job / deploy hook); the shared state machine makes the gate itself rollback-safe (Ahead → no-op). |
Consequences
Positive:
-
Rollback works, with an explicit machine-checked bound (the D3 floor).
-
Serving startup does DDL never (post-M3b) — no convoy, no kill-loop, honest probes.
-
Deterministic, typed refusals for every schema mismatch class, same vocabulary in gate and boot.
-
The test plane inherits the fix (#1275 pre-warm rides the same program).
Negative / accepted:
-
Operators MUST run the gate before the new serving image (pre-1.0 breaking; the compose graph carries it automatically, k8s deployments add a Job/hook).
-
Destructive migrations carry a floor-bump discipline (lint-backed, but a new rule to know).
-
~~The gate is not a privilege boundary~~ — #1310 (2026-08-04) shipped the owner/runtime role split behind it (D4 honesty note carries the as-built + the recorded meta-table residual). Devstack requires a reseed on upgrade across #1310 (roles are provisioned by
init.sql, which runs only on a fresh data dir). -
One more compose service per stateful service (×8) — mitigated by YAML anchors; the one-shots are
restart: "no"and exit immediately on Exact/Ahead.