ADR-101: Host inventory and roles — every host is a manifest, roles are additive
Status: Accepted
Date: 2026-04-25
Related: ADR-074 (CI/CD plane separation — names Dev/CI/Ops planes; this ADR's role enum makes the plane assignment per-host explicit), ADR-092 (host/project separation — established that a host carries the shared runtime; this ADR adds the host's identity + capability surface alongside the existing project manifest), ADR-097 (PM in the factory — same pattern: catalog data lives in gyrum-catalog, the factory renders it), ADR-100 (factory growth boundaries — /infra/hosts is a future operator audience this ADR's data model feeds). Forward-ref [ADR-102] (provisioning role composition — composes role playbooks against the inventory introduced here).
Context
The fleet today has one production host (warp-01, the Hetzner CX22 carrying warp + distill + hello-world) and a queue of host work (Buzzy box, dark-factory-db, future CI runner per ADR-074, future preview-env hosts per ADR-073). Three structural facts make the absence of a host catalog a near-term blocker:
- The project manifest in
gyrum-catalog/manifests/<project>.deploy.yamlalready names ahost(inventory alias) field. ADR-092 made every project state which host it lands on. But the inventory itself — what hosts exist, what each carries, what each can carry — lives indark-factory/ansible/inventory.example.ini, a starter file with onelocalhostentry. There is no source of truth for "what is warp-01 today". When SWARM-AR proposed adding distill alongside warp, the answer "what else lives on warp-01?" requiredssh warp-01 && docker ps, not a query. That asymmetry — projects are catalogued, hosts are not — is the gap. - The provisioning playbook (
dark-factory/ansible/install-host.ymlper ADR-092) treats every host as identical. It applies thehostrole uniformly: Docker, Caddy, ufw, observability, GHCR (GitHub Container Registry) auth. That worked when the only host was warp-01 carrying every concern. As the fleet adds a CI runner (which must NOT have the Caddy edge per ADR-074), a future shared DB host (which must NOT pull project images), and an agent-worker host (which carries different concerns again), the uniform host role breaks. The host needs a way to declare its capabilities so the playbook can compose only the relevant role pieces. - The operator has no inventory view. The user's ask, captured tonight: a
/infra/hostsmatrix in the factory showing every host × every role. That UI (separate ticket, not this ADR) needs structured data — a JSON-shaped, schema-validated, version-controlled host record. Today it would have to scrapeansible.cfgfiles and grep for hostnames.
The pattern for the fix is already in the repo. ADR-092 + ADR-097 ship gyrum-catalog/pkg/deploy/ (Go schema, validator, JSON Schema, CLI) for the project axis. The host axis gets the mirror treatment: gyrum-catalog/pkg/hosts/, cmd/validate-host-manifest/, gyrum-catalog/hosts/<name>.yaml. Same shape, same validator pattern, same operator ergonomics.
Decision
Every host the fleet owns carries a manifest at gyrum-catalog/hosts/<name>.yaml, validated by cmd/validate-host-manifest, parsed by pkg/hosts. The manifest names the host's identity (name, fqdn, provider, region), its provisioning context (capacity, status), and an additive list of roles chosen from a closed enum. Provisioning playbooks read the role list and compose the appropriate role-specific playbooks; a host without a role is provisioned with the base host role only.
The shape of the cut:
The host manifest is the source of truth for the host's identity and capabilities. Anything ansible/cloud-init/observability needs to know about the host that is not project-specific lives here. The ansible inventory file becomes a generated artifact (or a thin pointer file) rather than the source.
Roles are an additive closed enum. Today:
edge | web | db | api | ci-runner | e2e-runner | integ | cron | agent-worker. Each role names a capability the host is declared willing to carry, not a service it currently runs. A host withroles: [edge, web, api, db]carries all four — exactly warp-01's shape today. A host withroles: [ci-runner]carries only the CI runner concern (and explicitly NOT edge — the ADR-074 plane boundary is enforced at role grant). Roles compose; a host can hold any subset of the enum.Role enum semantics. Each value is a what is allowed to land here, not what currently runs here:
edge— Caddy + auto-LE, owns :80/:443 on the box. ADR-092 host-piece. Mutually exclusive withci-runner(the plane boundary from ADR-074 forbids the CI plane from terminating user traffic).web— frontend container workloads (the<project>-uiimages per ADR-092 rule 1). Requiresedgeon the same host (the UI needs the vhost).db— postgres / data services. Today this is per-project local pg (ADR-092 default). Whendark-factory-dblands as a fleet shared DB, it carriesdbas its sole project-touching role.api— backend container workloads (the<project>images per ADR-092 rule 1). Theweb/apisplit exists so a future ADR can place a backend on one host and its UI on another (today they always co-locate; the role distinction is forward-looking).ci-runner— GitHub Actions self-hosted runner per ADR-074 §6. Mutually exclusive withedge,web,api,db(the CI plane never sees Ops credentials, never terminates Ops traffic).e2e-runner— fleet E2E orchestration (thedistill-gyrum-ai/e2e-integration/compose stack). Plane-wise sits in CI; in practice today still runs on GitHub-hosted runners. The role exists so when we move it (per ADR-074 trigger #1 or #4) we have the surface to declare.integ— long-running integration test environments (preview environments per ADR-073, contract test rigs). Lives on the Ops plane by construction (per ADR-073) but is bounded — tagged so observability and security pipelines can apply preview-specific budgets.cron— scheduled jobs (backup pipeline per ADR-095, GHCR retention sweep per ADR-074, weekly status digest). Today co-located on warp-01; a future ADR may peel them onto a dedicated cron host.agent-worker— the headless AI-worker box that runs sub-agents for the fleet. Distinct fromci-runnerbecause the agent-worker is a long-lived stateful workload, not an ephemeral-job runner.
Role grant is additive only. Adding a role to a host's manifest is a PR; the next provision applies the role's additive playbook on top of the existing host. Removing a role goes through a drain workflow: mark the role
draining: truein the manifest, the provisioning pipeline refuses to schedule new work to that role on that host, an operator (or a future scheduled task) verifies nothing of that role is running, then a follow-up PR removes the role and the next provision tears the role-specific stack down. The drain step exists so that a role removal cannot silently break a project that thought the host still carried that role.Provisioning composes role playbooks. The
install-host.ymlplaybook stays the orchestrator; it now reads the host manifest, applies the basehostrole unconditionally (Docker, ufw, observability, GHCR auth — every host needs them), then conditionally applies one role-playbook per role in the manifest (role-edge.yml,role-ci-runner.yml, etc.). Mutually-exclusive role combinations fail at validation time, before the playbook fires. The role playbooks themselves are introduced incrementally — today onlyhostexists;role-edge.ymlandrole-ci-runner.ymlare named in ADR-102 (forward-ref)./infra/hostsUI matrix is a separate ticket. This ADR delivers the data model + validator + CLI; the factory's operator-facing matrix view (gyrum-labs/ai-frontend/src/routes/infra/hosts/) is a follow-up that consumes the validated manifests via agy-catalogquery (the same pattern/projectsuses today againstpkg/deploy).
Consequences
What becomes easier:
- "What does warp-01 carry today?" is a
cat hosts/warp-01.yaml, not anssh + docker ps. The manifest lists the roles; the role list tells the operator (and the next sub-agent, and the next pipeline) what concerns the host is committed to. No more spelunking SSH sessions to discover what's on a box. - Adding a new host is a PR, not a runbook. Drop
gyrum-catalog/hosts/<new>.yamlwith the right roles, the provisioning playbook reads it, the right role playbooks compose. The operator decides what goes on the box once, in version control, with a review. - Role-based plane enforcement at validation time.
roles: [edge, ci-runner]failsvalidate-host-manifestbecause the ADR-074 plane boundary forbids the combination. The mistake of "let's just put the CI runner on warp-01, it already has Docker" — the canonical post-mortem shape ADR-074 §10 names — is caught at the PR that adds the role, not after a security incident. - The
/infra/hostsmatrix becomes a thin renderer. The matrix UI iterateshosts/*.yaml, builds rows per host, columns per role, fills cells from the role list. No business logic in the UI; the catalog is the truth. Same pattern ADR-097 used for/pm. - Dark-factory's ansible inventory shrinks toward a generated file. The hand-maintained
inventory.inibecomes redundant once agy-catalog hosts inventorycommand (follow-up) emits a valid ansible inventory from the manifests. The two sources stop drifting because there is only one source.
What becomes harder:
- Two catalog axes to maintain instead of one.
manifests/<project>.deploy.yaml(project axis, ADR-092) andhosts/<host>.yaml(host axis, this ADR). The two reference each other via the host alias — projects state their host, hosts list their roles, the cross-product (which projects land on which host) is computable but lives in neither file. We accept the implicit coupling and add agy-catalog placementquery (follow-up) to materialise it on demand. - Role enum is a closed set; adding a role requires an ADR. A new role (e.g.
gpu-workerfor ML inference) cannot land via a one-line catalog edit; it needs an ADR amendment + a correspondingrole-<name>.ymlplaybook. We accept the friction because uncontrolled role expansion is exactly the failure mode this ADR exists to prevent (the ansible role today is "everything goes on warp-01"; that was a one-line addition each time, and it's why we're here). - Drain workflow is a real artifact to design. Removing a role gracefully — without dropping a project that depended on it — needs a multi-step protocol (mark
draining, verify nothing schedules new work, verify nothing currently runs, remove role, re-provision). This ADR specifies the contract; the implementation lands in ADR-102. Until then, role removal is operator-judgment + manual verification. - The first ten host manifests have to be backfilled. Today's living infrastructure (warp-01 plus whatever else is in the inventory) needs a manifest written by hand. The PR that ships this ADR also ships the backfill for warp-01; subsequent hosts get manifests as they're discovered.
What we have signed up to operate:
gyrum-catalog/pkg/hosts/— Go schema, validator, JSON Schema, mirror ofpkg/deploy. Owned by gyrum-catalog maintainers, same review cadence aspkg/deploy.gyrum-catalog/cmd/validate-host-manifest/— CLI invoked bygyrum-validate-playbookand by CI on every host manifest change. Same exit-code contract asvalidate-deploy-manifest.gyrum-catalog/hosts/<name>.yaml— one file per host the fleet owns. Source of truth for host identity + role grants.- The role enum. Adding, removing, or changing the semantics of a role is an ADR-touch operation, not a code-touch operation. The closed enum is the boundary.
- The drain contract (interface today, implementation in ADR-102). When a role leaves a host, it does so gracefully or it does not do so at all.
What we revisit:
- When a third role-style axis appears (today: project, host. Plausibly: tenant, region, environment) the pattern this ADR establishes —
gyrum-catalog/<axis>/<name>.yaml+pkg/<axis>/+cmd/validate-<axis>-manifest/— is the template. Re-examine the template at the third use; until then, the pair this ADR introduces is not yet a generalised library. - When the role enum grows past ~15 entries the closed-set discipline starts costing more than it saves; revisit whether a tag-based system (open set, conventional names) would scale better. The current 9 entries are well below that threshold.
Alternatives considered
- Keep the ansible inventory file as the source of truth; add comments naming each host's role. What we have today, lightly extended. Loses on every reason this ADR exists: comments are not validated, comments are not queryable, comments are not consumed by the provisioning playbook. The operator's
/infra/hostsUI would have to parseinventory.inicomments, which is a parser we should never write. Rejected. - Put the host data in the project manifest (each project's
host:field grows to ahost: { name, roles, ... }object). Loses on the "host is shared across projects" property — every project on warp-01 would have to declare warp-01's roles, and the duplicates would drift. Rejected — host is a first-class noun, not a sub-field of project. - Make roles open strings, not a closed enum. Less ceremony to add a new role. Loses on the validation story: the
[edge, ci-runner]plane breach we want validate-host-manifest to catch can only be caught against a known role set. Open strings would let typos (ci_runner,edge-server) through silently. Rejected. - Host manifests live in
dark-factory/, notgyrum-catalog/. Co-locates with the ansible playbooks that consume them. Loses on the catalog principle (ADR-064:gyrum-catalogis the canonical home for cross-cutting catalog data;dark-factoryis the home for runtime/ops). The factory UI's/infra/hostspage has to read from a single canonical location, and that location isgyrum-catalog. Rejected. - Use Hetzner labels / cloud provider tags instead of a manifest file. Less duplication with what the cloud already knows. Loses on multi-provider portability (a future Linode or DigitalOcean host couldn't carry the same data shape natively), loses on offline-readability (you can't
cata Hetzner label from your laptop without an API call), and loses on review-via-PR (label edits are not version-controlled). Rejected — provider tags are derived from the manifest, not the other way around. - Skip the validator; let the playbook fail at apply time. Cheaper to ship. Loses on the entire pre-flight gate that ADR-072 + ADR-092 established for the project manifest — every other catalog axis runs through
gyrum-validate-playbookbefore reaching ansible, and the host axis should not be the exception. Rejected.
Supersedes: none. Extends ADR-092 (host/project separation) by giving the host axis a first-class catalog presence alongside the project axis. Composes with ADR-074 (CI/CD plane separation) by making the plane assignment per-host explicit and mechanically validatable. Superseded by: leave blank until a later ADR reverses this one