Decisions

ADR-114: Capability-based provisioning

`migrate-service-to-fresh-host.yaml` and `provision-host.yaml` hardcode `cx22` as the destination Hetzner server type. The 2026-04-26 dry-run on GAF surfaced "Server Type not found": Hetzner had deprecated `cx22` in the…

#114

ADR-114: Capability-based provisioning

Status: Proposed Date: 2026-04-26 Related: ADR-067 (playbooks-unified-primitive), ADR-092 (host/project separation), ADR-093 (manifest-driven-project-tools), ADR-101 (host-inventory-and-roles), ADR-111 (broker — same shape applied to model calls)

Context

migrate-service-to-fresh-host.yaml and provision-host.yaml hardcode cx22 as the destination Hetzner server type. The 2026-04-26 dry-run on GAF surfaced "Server Type not found": Hetzner had deprecated cx22 in the operator's account. The default had drifted under us with no signal until a real provision tried to fire.

This is the same failure mode the broker (ADR-111) addressed for model calls: hardcoded provider-specific names go stale because the upstream evolves on its own clock. Pinning a name presumes the name is stable; on cloud providers, names are inventory not API. Every playbook that names a server type takes on a maintenance burden it does not advertise.

The decision is tracked end-to-end in warp #340 (sub-tickets r1 through r6). It is also a candidate principle under warp #339: "infrastructure capability-declared, not type-pinned."

Decision

Playbooks that provision compute declare requirementsrequires_min_cpu, requires_min_ram_gb, requires_min_disk_gb, region, cost_policy — and a runtime resolution step queries the provider's current catalog (hcloud server-type list -o json) and picks the matching type. The resolved name flows into the provision step as ${steps.resolve_server_type.outputs.server_type} instead of ${inputs.destination_host_size}.

cost_policy is an enum: cheapest_sufficient (default — minimise monthly cost subject to constraints), balanced (cost vs. headroom), best_perf (most cores per euro). The resolver implements the policy via a deterministic sort over the filtered candidate set.

server_size_override is the operator escape hatch — a string input that, when non-empty, short-circuits resolution and uses the named type directly. This preserves the existing operator workflow ("I want exactly cpx31 right now") and gives the migration path: existing playbook calls keep working unchanged if the operator passes the old name as override.

The catalog is cached per warp #340 r5: gyrum-catalog refreshes the requirements_to_type_map nightly, so steady-state provisioning does not pay the hcloud round-trip every fire.

Consequences

What becomes easier.

  • Provider deprecation stops silently breaking playbooks. When Hetzner retires cx22, the next nightly catalog refresh notices the absence and the resolver picks the next-cheapest sufficient type on the next fire.
  • cost_policy is explicit and reviewable. Today the choice is buried in the value of a default; tomorrow it is a named field with three documented values, visible in the wizard surface (warp #340 r4) and in the playbook diff at PR time.
  • The wizard (warp #331) collects requirements (cores, RAM, region, policy) instead of asking the operator to know which Hetzner type matches their need. That is the more natural input shape for the role: operators reason about workload, not SKUs.
  • Cross-provider portability becomes plausible. The same requirement vector can resolve against AWS, GCP, or DigitalOcean catalogs without rewriting the playbook — only the resolver step changes.

What becomes harder.

  • Each provision pays a resolver step. With the catalog cache that is a sub-100ms jq over a JSON file; without the cache it is a ~500ms hcloud API call. Both are cheap; the cache exists so the cheaper case is the default.
  • A test corpus is required. For every supported cost_policy × requirement vector, there is one expected resolved type; warp #340 r6 commits a golden-test fixture so a Hetzner catalog change that flips a resolution is caught at PR time, not in production.
  • The escape hatch can be abused. Operators who always set server_size_override defeat the abstraction. The mitigation is a periodic audit on the operator surface: "X% of fires this week used override; do the requirements need adjusting?"

What we sign up to maintain.

  • The resolver shell step + its jq query, including the cost_policy sort logic.
  • The gyrum-catalog nightly refresh of requirements_to_type_map and a per-region freshness alert.
  • The golden-test corpus that anchors policy-to-type resolution against the current Hetzner catalog snapshot.

Alternatives considered

  • Keep server-type names but add a per-playbook valid_types allowlist. Rejected: still pins to names, just in more places. A deprecation invalidates every allowlist instead of a single resolver query — strictly worse spread-out maintenance.
  • Resolve once at playbook-author time, store the resolved name in the YAML. Rejected: that is exactly today's design, just with a different "now" for the resolution. The catalog drifts continuously; a snapshot in YAML is stale by definition.
  • Always re-query hcloud per fire (no cache). Rejected: needless dependency on Hetzner API uptime for every provision. The cache is one ADR-093 manifest-driven artifact that refreshes nightly, which is the right cadence for a catalog that changes on roughly that timescale.
  • Skip cost_policy — always cheapest sufficient. Rejected: real workloads sometimes want the upgrade headroom (best_perf) or the balanced split. Naming the policy as enum is cheap; collapsing to one default re-introduces the problem capability declaration was meant to solve (operator knows the workload; default knows nothing).

Supersedes: none Superseded by: none yet