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

  1. 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.
  2. 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_verify refuses 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.
  3. 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.
  4. 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_successfully on it; dev start ordering stays fail-loud.
  • run_migrations calls 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 — sqlx VersionMissing, 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.

Edited by CRAIG-Bot-CA