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 (warp → distill) 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_smokestep 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_runpropagation 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.databasekey formalised before the wizard ships; today the manifest carries it in some service entries and not others. - The
dry_runpropagation contract has to hold across sub-playbook boundaries. Today's dry_run guard is per-step-shell-body. Whentype: subplaybooklands the runtime needs to propagatedry_runautomatically 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, thenmigrate-distill-to-fresh-host.yaml, thenmigrate-buzzy-to-fresh-host.yaml, etc. Lost because the third repeat is the heuristic threshold for graduation perfeedback_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 driftfeedback_warp_is_design_reference.mdnames. 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.yamland 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_idandsource_hostanddestination_host_name, and resolveservice_repo/dns_hostname/db_namefrom 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: subplaybookto 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-wrappedgyrum-fire-playbookcalls 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)