Decisions

ADR-106: Generic migrate-service-to-fresh-host playbook + manifest-driven wizard

The `warp-01 → warp-02` cutover that triggered this work is the third host migration the fleet has run (after `db-shared-01 → db-buzzy-01` via `postgres-migrate-database` and the implicit `runner-01 → runner-02` swap…

#106

ADR-106: Generic migrate-service-to-fresh-host playbook + manifest-driven wizard

Status: Proposed Date: 2026-04-26 Related: ADR-067 (playbooks as the unified primitive — this ADR is one more concrete release_flow under that contract), ADR-068 (playbook runtime architecture — names the executors this playbook composes today and the executors it will collapse to in Phase 2/3/4), ADR-088 (observability lifecycle — silence-before-delete, inherited via destroy-host), ADR-089 (host security lifecycle — snapshot-before-delete, inherited via destroy-host), ADR-092 (host vs project separation — this playbook spans both layers), ADR-093 (manifest-driven project tools — the wizard reads service metadata from the manifest), ADR-104 (dry-run-on-history adaptive gate — this playbook obeys the maturity gate), ADR-107 (gates as playbook primitives — the per-step gate stack model this playbook follows).

Context

The warp-01 → warp-02 cutover that triggered this work is the third host migration the fleet has run (after db-shared-01 → db-buzzy-01 via postgres-migrate-database and the implicit runner-01 → runner-02 swap that happened by hand in March 2026). The first two were one-shot scripts; the third was scoped as a warp-specific cutover playbook (migrate-warp-to-warp-02.yaml per the original warp #330 brief and abandoned PR #675). Mid-session on 2026-04-26 the operator named the pattern: "the playbook could be generic at some point, we just need a wizard to populate the repo, the server, etc". The third-time-is-a-pattern heuristic from the operator memory feedback_pipelines_over_ai.md"AI is scaffolding, pipelines are the target" — applies directly: a service-specific cutover playbook is a one-time scaffold; a generic cutover playbook plus a wizard that populates its inputs from the project manifest is a fleet-permanent pipeline.

The forces pushing toward the generic shape are concrete. Every gyrum service onboarded after warp (distill, buzzy, future services) will need this same migration capability; if every service ships its own per-service migration playbook, the fleet acquires N copies of the same 13 steps with the per-service literals (warpdistill) substituted. That is the exact drift mode feedback_warp_is_design_reference.md warns about — divergent forks of the same logic, hand-maintained, accumulating per-service quirks that mask real bugs. The forces pushing against the generic shape are also real: a generic playbook is harder to debug for any single service because the failure mode names are abstract (service_id not warp), the smoke step contract has to assume a standard /api/version endpoint, and the install-playbook lookup has to assume a standard ansible role layout. The first concrete invocation will reveal which assumptions break — that is the standing risk and the reason the dry-run-then-real-fire cycle (warp #313 / ADR-104) is the maturity gate before any service trusts the playbook to drive its production cutover.

The wizard half of the cut is named separately because a generic playbook with no wizard is a UX regression — operators staring at twelve required inputs they have to remember per-service is worse than a per-service playbook with two inputs. The manifest (ADR-093) already carries repo, public_hostname, and postgres.database per service; the wizard reads those, presents a one-screen "where to where, what version, how risky" form, and assembles the playbook invocation. Without the wizard the playbook is not operator-grade; with the wizard it is. The wizard is its own ticket (warp #331) so this ADR can ship the playbook half independently and the wizard half follows on the existing factory UI infrastructure.

Decision

The fleet ships a single generic migrate-service-to-fresh-host.yaml release_flow at ai-research/playbooks/ that takes any single-instance gyrum service from one host to a freshly-provisioned host, parameterised by service_id, source_host, destination_host_name, destination_host_size, service_repo, dns_hostname, db_name, expected_sha, dns_ttl_seconds, and dry_run. The first concrete invocation is the warp-01 → warp-02 cutover; every subsequent service inherits the migration capability with zero per-service playbook authoring. A separate ticket (warp #331) ships the migration wizard at gyrum.ai/infra/migrate-service that reads the project manifest (ADR-093) to populate service_id, service_repo, dns_hostname, and db_name from the operator's service selection — the operator names "where to where, what version, how risky" and the rest is derived.

The playbook's 13 steps follow the gate-stack model from ADR-107 ("gates as playbook primitives") and the destructive-step ordering from ADR-088 / ADR-089: pre_check_dry_run → provision → deploy_empty → stop_service_container → migrate_database → start_service_smoke → smoke_test_api → pause_for_pre_dns_confirm → cut_dns → wait_drain → pause_for_pre_destroy_confirm → destroy_source → assert_cutover_complete. Two operator-confirmation pause steps fence the cross-fleet blast-radius operations (DNS flip + source destroy); a final hard assert (per warp #323) verifies the live SHA matches expected_sha AND the source host is gone from Hetzner. The expected_sha input is required with no default — operators must name the SHA they expect to see live, otherwise the assert is meaningless and the playbook would silently allow a regression to ship.

The playbook composes existing primitives rather than reimplementing them: provision-host for step 2, postgres-migrate-database for step 5, destroy-host for step 12. This is the ADR-067 unified-primitive model in action — release_flows are compositions of release_flows, not bespoke scripts. Today's runtime lacks type: subplaybook (Phase 4+ per ADR-068), so the composition is shell-wrapped gyrum-fire-playbook calls with TODOs naming the future shape; the single Phase 4 PR collapses every wrapper to a typed step.

The maturity gate is the load-bearing rule: per ADR-104's dry-run-on-history adaptive gate and the broader playbook-maturity epic (cross-link warp #313), this playbook needs 5 clean dry-run-then-real-fire cycles against real targets before the runtime is allowed to drive it autonomously. Until the fifth clean cycle, every fire requires (a) a successful --dry-run invocation immediately prior, encoded as the pre_check_dry_run step, (b) operator presence at both pause steps, and (c) the merged PR's SHA passed as expected_sha. Pipeline-items (ADR-067 kind: pipeline) will not depend on this playbook's health endpoint until the maturity gate clears.

Consequences

What becomes easier:

  • Onboarding a new service inherits the migration capability for free. A service added after this playbook ships gets gyrum-fire-playbook migrate-service-to-fresh-host --input service_id=<new> working as soon as its install-<id>.yml ansible role and project manifest entry exist. No new playbook authoring per service.
  • The wizard at warp #331 has a single backend to call. One playbook id, one input shape, one set of validation rules. The wizard's complexity stays in the manifest-resolver (which it would need anyway) and not in switch-on-service routing.
  • Fleet-level migration patterns stay coherent. When ADR-088's silence-before-delete or ADR-089's snapshot-before-delete evolves, every service's migration inherits the new behaviour automatically — not just the services whose per-service playbook was hand-updated.
  • The dry-run-then-real-fire maturity gate has a single learning surface. Five clean cycles of this playbook teach the runtime how single-instance cutover behaves; that learning transfers to every service rather than being repeated per per-service playbook.

What becomes harder:

  • Per-service quirks have to be debugged through abstraction. When the start_service_smoke step fails for distill because distill's compose stack uses a non-standard service name, the failure mode is "the generic step doesn't fit distill" not "distill's playbook has a bug" — the fix lands in the generic playbook (likely as a new input or a manifest-derived value), and the operator has to reason about the abstraction layer rather than the concrete service.
  • The first invocation is risky. Generic playbook + brand-new code paths (the dry_run propagation through 13 steps, the cut_dns Cloudflare write, the recursive destroy-host call) means the warp-01 → warp-02 cutover is also the first real-world proving ground. The maturity gate's "5 clean cycles before autonomy" is the mitigation; the first cycle being warp's cutover is the cost.
  • The wizard becomes a hard prerequisite for non-warp services. Until warp #331 ships, every non-warp service that needs migration has to invoke the playbook with all 12 inputs by hand. Operator-grade UX comes only with the wizard — naming the wizard explicitly as the missing piece is the cost of the cut.
  • Two layers to coordinate on schema changes. Adding an input to the playbook (e.g. for a future health-check path override) means updating the wizard, the manifest schema (ADR-093), and the playbook in lockstep — a coordination cost that per-service playbooks did not have.

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

  • The maturity gate has to be tracked. The fleet needs a clear count of clean cycles per playbook; warp #313's dry-run gate is the closest existing surface but the broader maturity tracking is operator-named and not yet formalised. Until then, the operator manually tracks "cycle 1 of 5" in the session log.
  • The wizard's manifest-resolver is a critical-path dependency. A wizard that can't read the manifest produces empty form fields and operator typos. The manifest schema (ADR-093) needs the postgres.database key formalised before the wizard ships; today the manifest carries it in some service entries and not others.
  • The dry_run propagation contract has to hold across sub-playbook boundaries. Today's dry_run guard is per-step-shell-body. When type: subplaybook lands the runtime needs to propagate dry_run automatically through the call (the executor's job, not the author's); ADR-068 §subplaybook should name this contract explicitly when Phase 4 is specced.

Alternatives considered

  • Per-service migration playbooks (the original warp #330 shape). Author migrate-warp-to-warp-02.yaml, then migrate-distill-to-fresh-host.yaml, then migrate-buzzy-to-fresh-host.yaml, etc. Lost because the third repeat is the heuristic threshold for graduation per feedback_pipelines_over_ai.md; we are at the third repeat now. Per-service playbooks accumulate the same 13 steps with the per-service literal substituted, which is exactly the divergent-fork drift feedback_warp_is_design_reference.md names. The cost of per-service playbooks compounds linearly with service count; the cost of a generic playbook is one-time at first invocation.

  • Generic playbook with NO wizard. Ship migrate-service-to-fresh-host.yaml and stop. Lost because operator-grade UX matters: 12 inputs at the CLI is hostile to anyone who isn't already in the playbook's head. The wizard is named explicitly as warp #331 so it can ship next, on the existing factory UI infrastructure, against this playbook's stable input contract.

  • Bake the wizard into the playbook (read manifest from inside the playbook itself). Have the playbook take only service_id and source_host and destination_host_name, and resolve service_repo / dns_hostname / db_name from the manifest at step time. Lost because the playbook becomes harder to fire by hand for incident-response cases (where the manifest may be the thing that's broken), and because mixing the orchestration concern (the playbook) with the data-resolution concern (the manifest read) violates the layering ADR-067 names.

  • Wait for type: subplaybook to ship before authoring the generic playbook. Defer the playbook until Phase 4 of ADR-068 makes the composition typed. Lost because warp-01 needs to migrate now (operator-stated 2026-04-26), and the shell-wrapped gyrum-fire-playbook calls are a clean syntactic preimage of the Phase 4 typed shape — the migration from shell-wrapped to typed is mechanical when Phase 4 lands.


Supersedes: none Superseded by: (leave blank until a later ADR reverses this one)