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:

  1. Rollback is bricked. sqlx’s Migrator::run hard-errors with VersionMissing when 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.

  2. 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.

  3. 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_migrationsvalidate_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)

Exact

no-op, exit 0

boot

Ahead

no-op, exit 0 — this is what restores rollback

boot + warn!, IF the D3 floor passes

Behind

apply (safe by construction: Behind ⇒ applied ⊊ embedded ⇒ sqlx cannot VersionMissing)

refuse

Diverged / Dirty / ChecksumMismatch

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 (Migrator::run in serving boot)

Rejected

Rollback bricked by VersionMissing; replica convoy on the advisory lock before any health surface; the same coupling storms the test plane (#1275).

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 (<binary> migrate gate + verify-only boot)

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.

Edit this page · latest