feat(platform): migration gate + verify-only boot — schema application as a deploy phase, never in-band at service startup
What
Make schema application an explicit, singly-owned deploy phase that completes before any consumer of the schema starts, and make service boot verify-only — services refuse startup with a named remedy when the applied migration set is behind their embedded one, and NEVER apply DDL from the serving process.
Why — the defect class (found via #1275 (closed)'s test-plane instance)
Today every service converges schema at boot: main.rs -> DbPool::run_migrations(&sqlx::migrate!()) (crates/craig-db/src/lib.rs:281). Schema application is therefore lazy, demand-driven, and executes inside the consumer's own lifecycle. The failure class this creates: an unbounded rebuild/apply cost lands inside a bounded consumer deadline, and concurrent consumers convoy on the migrator advisory lock.
- Test-plane instance (observed 2026-07-31, #1275 (closed)): a migration-set change rotated every template fingerprint; the next battery's consumers rebuilt lazily under 8-way parallelism; waiters serialized on sqlx's migrator lock and were executed by the 120 s per-test kill.
- Production twin (latent, masked today by single-replica + stop-then-start): with 2+ replicas or a rolling deploy, replicas race the same sqlx migrator advisory lock at boot; losers block inside startup while readiness/liveness deadlines tick; a long migration (e.g. an index build on a grown table) becomes a kill-loop — kubelet kills the blocked pod, it restarts, re-races. Even single-replica, a long boot migration is an unobservable outage extension with no distinct "migrating" phase.
The architecturally correct shape
- Migration gate: one identity applies migrations to completion per deploy — compose: a one-shot migrate service the app gates on via
depends_on: service_completed_successfully(the exact declarative fail-loud pattern the devstack already uses for craig-seed / garage-init, ADR-048 C4-gate); k8s: a migration Job / init phase. - Verify-only boot: services compare their embedded migration set against
_sqlx_migrations(versions + checksums) and REFUSE startup on drift with the remedy in the message — the established ADR-048 §D3 posture (boot_verifyrefuses on key/data mismatch), extended from encryption state to schema state. No DDL from serving binaries; no lock to convoy on; the failure mode is a typed refusal, not a blocked deadline. - Expand/contract stays the safety envelope (already CONTRIBUTING.adoc policy): additive step-1 migrations make gate-before-binary-roll zero-downtime-safe post-1.0; contraction ships in a later release.
- The test plane then follows the SAME principle (#1275 (closed)'s pre-warm = the migration gate instantiated for templates), rather than being a workaround.
Precedents this aligns with: ADR-048 §D3 verify-only boot; the C4-gate declarative ordering; ADR-057's substrate-deploys-first runbook; #1153 (closed)'s dedicated-connection migration hygiene (which the gate inherits).
Acceptance criteria
- ADR recording the decision (migration ownership, gate identity/credentials, verify-only boot contract, rollback posture with
cargo xtask migrate snapshot/rollback). - A migrate one-shot per service (or one fleet migrator) in devstack compose; services gain
depends_on: service_completed_successfullyon it;dev startordering stays fail-loud. -
run_migrationscalls removed from all service boot paths; replaced by a schema verify that refuses with the remedy named (exit non-zero, message includes the gate command). - The verify is DIRECTION-AWARE (stock
Migrator::run()is strict both ways — sqlxVersionMissing, migrator.rs:40): refuse when BEHIND (unapplied embedded migrations); BOOT when AHEAD with matching intersection checksums (applied is a superset — the expand/contract rollback/mixed-version window must work); refuse on intersection checksum mismatch. - Readiness/health surfaces distinguish "schema behind" refusal from other boot failures.
- Deployment guide: the gate becomes the documented deploy order (interacts with the ADR-062 stop-then-start note for &77 (closed) conversions).
- k8s note: Job/init-container shape documented for multi-replica deployments.
Scope / priority
Latent pre-1.0 (single-replica, stop-then-start deploys mask the convoy); load-bearing BEFORE any multi-replica or rolling deployment. Platform-wide (8 services + craig-db + devstack + docs) — its own plan, not an epic &77 (closed) child.