Decisions

ADR-076: Project-to-host deployment binding (Layer 1)

Every project's YAML grows a **`deployments:`** block that binds each of its repos to a concrete origin: a host alias resolved through `gyrum-catalog`, a TCP port, a role label, and an optional env-override map. The…

#076

ADR-076: Project-to-host deployment binding (Layer 1)

Status: Accepted Date: 2026-04-23 Related: ADR-059 (project-first IA), ADR-060 (catalog-driven infrastructure), ADR-061 (gyrum-projects library), ADR-064 (gyrum-catalog library + hex arch), ADR-067 (playbooks unified primitive), ADR-068 (playbook runtime), ADR-069 (playbook security), ADR-070 (playbook UI contract), ADR-072 (infrastructure as playbooks), ADR-073 (preview environments), ADR-074 (CI/CD plane separation)

Decision (one paragraph)

Every project's YAML grows a deployments: block that binds each of its repos to a concrete origin: a host alias resolved through gyrum-catalog, a TCP port, a role label, and an optional env-override map. The gyrum-projects library validates the binding against the catalog; the /projects/[slug] UI grows a Deployment tab that shows each binding and lets the operator change it by writing back to the YAML; a new release_flow:deploy-project playbook walks the bindings in dependency order and calls deploy-service (ADR-072 §6) per repo. This is Layer 1 of a four-layer deploy story — binding and deploy-project must land before blue/green (Layer 2), canary (Layer 3), or feature-flag A/B (Layer 4) have a substrate to stand on. This ADR ships the substrate only; Layers 2-4 get their own ADRs (077, 078, 079) once we have real bindings to iterate against.

Context

The four-layer deploy vision

Weft's deploy story has been under-specified since ADR-059 made projects first-class and ADR-072 made infra-as-playbooks the primitive. The operator sketch for where deploys are going:

Layer Question Status ADR
1 — binding + deploy-project "Which host runs which repo, and how do I push a version to all of them atomically?" This ADR 076
2 — blue/green "Can I have two live copies of a service and cut over atomically?" Future 077 (sketch only — see §13)
3 — canary + auto-rollback "Can I send 5% of traffic to the new version and auto-revert on error-rate spike?" Future 078
4 — A/B testing "Can I show feature X to cohort A and feature Y to cohort B?" Future, separate product 079 (feature flags, not deploys)

Layers 2-4 each need Layer 1 as their substrate. Blue/green is "two bindings per repo with a fast switch between them"; canary is "a gradient over bindings"; A/B is the odd one out — it is a feature-flag product that rides on top of a single binding rather than modifying the binding at all. The vision is useful for two reasons: it names what Layer 1 is not (it is not blue/green; it is not canary), and it explains why Layer 1 is small — it only has to answer the binding question.

What already exists, and what is missing

The building blocks are all present; the binding layer is the missing piece.

  • Host inventory. gyrum-catalog already ships the Hetzner host list (exp-315 series — web-01, web-02, ops-vps, db-01, etc.) as the canonical source of host aliases, with a typed SSH target port (ADR-064). Any deploy target we care about has an alias in the catalog today.
  • Projects with multiple repos. ADR-059 committed each project to a repos: list of git repositories in its YAML — today Buzzy's YAML lists buzzy-frontend and buzzy-backend. The relation "this project is made of these repos" is recorded. The relation "this repo in this project runs on this host" is not.
  • Ansible roles per service. A growing set of infra/ansible/roles/ (caddy, grafana, distill, ai-research-run, etc.) can be run against any host in the inventory. They are the imperative bootstrap layer ADR-072 §10 carved out — "bootstrapping a host once is imperative by nature".
  • Infrastructure as playbooks. ADR-072 §5 committed the eight-playbook infra catalogue (DNS, tunnel, Access, Caddy, systemd) and §6 committed deploy-service as the service-level orchestrator — one service, one host, DNS + tunnel + Access + Caddy + systemd.
  • deploy-service is shipped (Phase 1 form). The monolithic Phase 1 deploy-service.yaml (ADR-072 §7 table, Phase 1 row) runs end-to-end and produces a deployed, Access-gated, Caddy-fronted service. It takes host: and hostname: as inputs — one service at a time.

What is missing is the project-level orchestrator: the thing that takes a project slug, looks up all of its deployable repos, finds where each one should run, and calls deploy-service per repo in the right order. Today that is a shell history of four to five gyrum-run deploy-service --host web-01 --unit buzzy-backend … commands pasted in order, each with its own inputs that the operator maintains in a scratch file. One of those commands is out of date by the time the third has finished. Two services in ten months; eight in twelve. The manual path does not scale (ADR-072 §Context makes the same argument for infra — the same shape applies one tier up to projects).

What concrete problems this solves

Four, each traceable to a real ten-minute irritation:

  1. "Where does buzzy-backend run?" is currently answered by: (a) asking in Slack, (b) grepping gyrum-catalog for buzzy, (c) ssh-ing to a likely host and systemctl status-ing. There is no authoritative answer. The binding YAML is the authoritative answer — projects/buzzy.yamldeployments.buzzy-backend.host.
  2. "Deploy Buzzy v1.4 to prod." Today this is N shell commands with the host: input copy-pasted. With the binding, the operator types one command — gyrum-run deploy-project buzzy 1.4 — and the playbook walks every binding.
  3. "What changed between Buzzy deploys on Monday and Wednesday?" Today this is a diff over two shell histories. With the binding, it is a git diff on projects/buzzy.yaml plus the two playbook_runs rows.
  4. "buzzy-frontend needs to move from web-01 to web-02 because web-01 is overloaded." Today this is a multi-step migration with no rehearsal. With the binding, the operator edits deployments.buzzy-frontend.host in the YAML, opens a PR, merges, and runs deploy-project buzzy — the next deploy lands on web-02.

Why now

Three converging pressures:

  1. Buzzy is the reference product (ADR-059 close). A reference product needs a reference deploy story. A shell history is not a reference story. If Buzzy ships a deploy playbook and social-proof ships its own shell script, we ossify divergence at the product boundary — exactly the pattern ADR-072 was trying to close.
  2. ADR-072's deploy-service is the sub-playbook this ADR composes. The subplaybook executor (ADR-068 Phase 4) lands the same cycle; a deploy-project playbook that composes deploy-service calls is the natural Phase 4 consumer. Naming the composition now means Phase 4 ships with a second orchestrator (not only deploy-service) and we discover Phase 4 bugs at composition depth 3, not at depth 2.
  3. Layers 2-4 have no floor without Layer 1. Every downstream deploy capability — blue/green, canary, feature flags — needs to point at "the current binding" or "the other binding". If there is no first-class notion of a binding, every Layer-2+ ADR has to invent one. Committing the binding now is the single cheapest thing we can do to unblock the rest of the deploy story.

Decision

1. Summary of gating choices (committed, not re-litigated)

# Choice Rejected alternatives Gated by
1 Binding lives on the project YAML Ansible inventory, k8s manifests, per-repo scripts, a separate bindings.yaml §12
2 Host aliases resolve through gyrum-catalog IP literals in YAML, DNS names, host groups §4.2
3 One binding per repo (Layer 1 scope) Multi-binding (reserved for Layer 2 blue/green) §13
4 deploy-project composes deploy-service via subplaybook: A new flat orchestrator, Ansible playbook wrapping, bash driver §6
5 Rollback = re-run previous version from the audit Separate rollback scripts, git revert only §8
6 Repos without a binding are library repos; skipped, not failed Hard-fail, warn-once-then-fail, auto-infer §9

All six are additive to ADR-059's project YAML, ADR-072's playbook catalogue, and ADR-068's runtime. No new StepExecutor kind; no new database table. The binding is a block of YAML and a playbook that reads it.

2. The binding — shape in YAML

The project YAML schema (ADR-059 §4) grows one new top-level field, deployments:, a map keyed by repo name (a value that must appear in the project's repos: list). Each entry describes where that repo runs.

Reference example, verbatim:

slug: buzzy
class: product
repos: [buzzy-frontend, buzzy-backend]
deployments:
  buzzy-backend:
    host: web-01         # must exist in gyrum-catalog/hosts
    port: 8090
    role: backend
    env_overrides: {LOG_LEVEL: info}
  buzzy-frontend:
    host: web-02
    port: 3000
    role: frontend
    env_overrides: {PUBLIC_API_URL: http://web-01:8090}

Every field carries meaning:

  • host: — a host alias from gyrum-catalog. Not an IP, not a FQDN, not a label. The catalog is the typed source-of-truth for what a host is (ADR-064); the binding references a catalog entry by alias. A host that isn't in the catalog can't be bound. This forces new hosts through gyrum-catalog's onboarding path before they can host anything.
  • port: — the TCP port the service listens on at the origin. The value is the port inside the host; the public port is whatever the Cloudflare Tunnel routes to (per ADR-072 §5.3's cloudflare-tunnel-route-upsert). Range: 1024-65535. Values below 1024 are a validator failure (privileged ports require sudo and are the wrong shape for a userspace service; operator can override via a privileged_port: true flag in a later ADR if we discover a real need).
  • role: — a free-text label the operator picks — backend, frontend, worker, cron, api. It has no runtime semantics in Layer 1; it exists so the UI can render the bindings table with a "Role" column and so Layer 2's blue/green ADR can group bindings by role (two backend bindings, one green, one blue). We do not enumerate role values in this ADR — enumerating it prematurely invents a taxonomy we don't have the data to shape yet. A role: string the operator typed is better than a role: enum the architect guessed.
  • env_overrides: — a string→string map the deploy playbook forwards to the service as environment variables, after whatever the service's own .env or systemd unit sets. The override always wins. This is where PUBLIC_API_URL lives in the frontend binding — the frontend has to know where the backend is, and only the binding has both addresses. Forwarded values are strings; no nesting. Types (booleans, ints) are stringified by the operator when they edit.

Explicitly out of scope for the Layer 1 binding:

  • No replica count. A single binding describes a single instance on a single host. Multi-replica is Layer 2 blue/green or a horizontal- scale ADR; we do not pretend to support it here.
  • No resource reservations (cpu, memory). systemd + the host's capacity plan handle this today; formalising it is a separate concern.
  • No healthcheck overrides. deploy-service's /api/version probe (ADR-072 §6 verify_sha step) is the health contract; overriding per-binding adds complexity Layer 1 doesn't need.

These are all deferred, not forbidden — a future ADR that introduces replicas (say, for the worker-pool case) can extend the deployments.* entry schema additively.

3. Validation — four rules, two layers

The gyrum-projects library (ADR-061) owns the binding validator. Rules are checked in two places: CI (on PR to any repo with projects/*.yaml) and the backend-at-read-time (ADR-059 §4's /api/v1/projects endpoint). Four rules:

# Rule Where checked Error shape
1 Every key in deployments: appears in repos: CI + backend ErrDeploymentOrphan: 'buzzy-mobile' in deployments but not in repos[]
2 Every host: resolves in gyrum-catalog CI + backend ErrHostUnknown: 'web-99' not in gyrum-catalog/hosts
3 Every port: is 1024-65535 CI ErrPortOutOfRange: 80 is privileged; use >=1024
4 No two bindings share {host, port} across any project in the fleet CI ErrPortConflict: buzzy-backend(web-01:8090) collides with focus-api(web-01:8090)

Rule 4 is the interesting one — it spans projects. The validator reads every projects/*.yaml at CI time, builds the global {host, port} map, and rejects a PR that introduces a conflict. Two services on the same host on the same port is always wrong; we catch it in review, not in production.

Non-rules (intentional):

  • A repo in repos: with no entry in deployments: is fine — it is a library repo (see §9). We do not require every repo to be bound.
  • A host with no bindings is fine — an idle capacity host. The catalog tracks the host; the bindings are sparse over it.
  • Env-override keys are not validated. A PUBLIC_API_URL that points at a non-existent backend is a runtime concern, not a schema concern.

4. The UI — /projects/[slug] grows a Deployment tab

ADR-059 §3 committed five tabs per project — Overview, Build, Run, Docs, Todos. This ADR adds a sixth tab: Deployment (or, if we want to keep five for tidiness, promotes it into Run as a sub-section). The first pass ships it as a sixth tab, because "Deployment" and "Run" answer different questions (where does it live vs. is it healthy), and because a sixth tab is cheaper than a visual reorganisation of Run.

4.1 The Deployment tab layout

/projects/buzzy/deployment

┌─────────────────────────────────────────────────────────────────┐
│ Deployment — buzzy                            [Deploy version…] │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  Repo             Host        Port   Role      Last SHA   Age   │
│  ──────────────── ──────────  ────── ─────────  ────────  ──── │
│  buzzy-backend    web-01      8090   backend    a1b2c3d   2h    │
│    LOG_LEVEL=info                                               │
│    [Change binding]  [History]  [Rollback to prev]              │
│                                                                 │
│  buzzy-frontend   web-02      3000   frontend   a1b2c3d   2h    │
│    PUBLIC_API_URL=http://web-01:8090                            │
│    [Change binding]  [History]  [Rollback to prev]              │
│                                                                 │
│  (buzzy-docs: no binding — library repo, not deployed)          │
└─────────────────────────────────────────────────────────────────┘

Columns:

  • Repo — the repo name (key of the deployments: map).
  • Host — linked to /infra/hosts/{host} — the host's page in the Infra mode (ADR-058).
  • Port — the port: value. Displayed; not interactive here (changing it is a binding change via the modal).
  • Role — free-text label.
  • Last SHA — the git SHA of the most recent successful deploy-project run that targeted this binding. Sourced from playbook_run_events via the audit trail (ADR-068 §5). Links to the commit on GitHub.
  • Agetime.Since(last_deploy_at) in the usual "2h / 3d / 1w" short form.

Under each row, a one-line env_overrides: summary — truncated with if long, click to expand. And three per-row actions:

  • Change binding — opens a modal (§4.2).
  • History — opens a drawer listing the last N deploy-project runs that touched this binding, each with a SHA, a before/after host-port pair if the binding itself changed, and a link to the <PlaybookRunner> run record.
  • Rollback to prev — links to §8's rollback-project playbook pre-filled with the previous-known-good binding + version.

At the bottom, unbound repos appear as an italicised line ("buzzy-docs: no binding — library repo, not deployed") so an operator scanning the tab can see all the repos and reason about which are deployable. This prevents the "did I forget to bind buzzy-mobile or is it a library?" moment.

At the top-right, a Deploy version… button opens a modal that launches deploy-project for this project (§4.3).

4.2 The Change-binding modal

Clicking Change binding on a row opens a modal:

┌───────────────────────────────────────────────┐
│ Change binding — buzzy-backend               │
├───────────────────────────────────────────────┤
│ Host       [ web-01 ▾ ]   (from catalog)      │
│ Port       [ 8090     ]                       │
│ Role       [ backend  ]                       │
│                                               │
│ Env overrides:                                │
│   LOG_LEVEL           info            [-]     │
│   [+ Add override]                            │
│                                               │
│ ⚠ This will edit projects/buzzy.yaml and      │
│   open a PR against dark-factory.             │
│                                               │
│                [ Cancel ]  [ Propose change ] │
└───────────────────────────────────────────────┘

Key shapes:

  • The host dropdown is sourced from gyrum-catalog's list — an operator cannot type a free-form hostname in this field. This is Rule 2 of §3 enforced at the edit point.
  • Port has inline validation — < 1024 disables the Propose button, with the error text from Rule 3.
  • Cross-project conflict check runs on blur — when the operator tabs out of the port field, the UI calls GET /api/v1/projects/conflicts?host=X&port=Y and shows a red warning if another binding collides (Rule 4).
  • "Propose change" opens a PR against dark-factory (the repo that owns projects/*.yaml). The UI does not write to the file directly — it constructs the YAML diff client-side, sends it to the backend, and the backend commits via gh api repos/gyrum-labs/dark-factory/contents/.... This matches ADR-072 §8's GitOps invariant: "infra changes are PRs, not UI clicks". The Deployment tab is a PR-generator, not a live configuration tool.

4.3 The Deploy-version modal

Clicking Deploy version… opens a modal that invokes release_flow:deploy-project:

┌───────────────────────────────────────────────┐
│ Deploy buzzy                                 │
├───────────────────────────────────────────────┤
│ Version tag   [ v1.4.0 ▾ ]   (or branch)      │
│ Dry run       [x] Preview without deploying   │
│                                               │
│ Will deploy:                                  │
│   buzzy-backend  → web-01:8090                │
│   buzzy-frontend → web-02:3000                │
│   (in dependency order: backend first)        │
│                                               │
│                  [ Cancel ]  [ Deploy ]       │
└───────────────────────────────────────────────┘

The "Will deploy" preview is computed from the bindings and the §7 dependency order. Clicking Deploy hands off to the standard ADR-070 <PlaybookRunner> for deploy-project, with the project slug and version tag as pre-filled inputs. Dry-run behaves per ADR-072 §2.2 — the run executes end-to-end but every step emits changed: null.

Version-tag dropdown is sourced from the project's main repo's tags — we pick the "main repo" by convention as the first entry in repos:, but a later ADR may formalise this.

4.4 What the tab does not do

Kept off the tab, deliberately:

  • No per-deployment metrics. That's Run tab's job. The Deployment tab answers "where does this run?"; Run answers "is it healthy?". Two questions, two tabs.
  • No log viewer. Logs are an Operate-mode concern.
  • No secret editing. env_overrides is plaintext-only. A secret goes through the ADR-069 vault by name (${vault.buzzy_api_key} as a string value in env_overrides); the resolved value never appears in the tab. Operator edits happen in a separate secret-management UI (out of scope).

5. The playbook — release_flow:deploy-project

A new playbook lives alongside deploy-service in dark-factory/playbooks/infra/deploy-project.yaml. Kind is release_flow (ADR-067 §3) — it is a release, with a gate stack and a rollback story (§8). Shape:

---
kind: release_flow
id: deploy-project
title: "Deploy a project — walk the bindings, deploy each service"
persona: sre
owner: "@jon"
status: active
trigger: "Operator invocation from /projects/[slug] Deploy button, or CLI"
success_metric: "Every bound repo is running at the target version; /api/version returns expected SHA"
tags: [infra, deploy, project, orchestrator]

allowed_personas: [sre, owner]

# Computed at parse time from the deploy-service sub-playbook's scopes
# (ADR-072 §2.4 union rule).
required_scopes:
  - {vendor: cloudflare, scope: "Zone:DNS:Edit"}
  - {vendor: cloudflare, scope: "Account:Cloudflare Tunnel:Edit"}
  - {vendor: cloudflare, scope: "Account:Access: Apps and Policies:Edit"}
  - {vendor: ssh, scope: "host:*:sudo:systemctl:*"}
  - {vendor: ssh, scope: "host:ops-vps:sudo:caddy"}

runtime:
  inputs:
    - name: project_slug
      type: string
      required: true
      description: "Project slug, e.g. 'buzzy'"
    - name: version_tag
      type: string
      required: false
      default: "latest"
      description: "Git tag, branch, or SHA to deploy; 'latest' resolves to the newest tag on the main repo"
    - name: dry_run
      type: bool
      required: false
      default: false

  steps:

    - id: load_project
      type: http
      target: local
      method: GET
      url: "http://localhost:8080/api/v1/projects/${inputs.project_slug}"
      expect_status: [200]
      save_as: project

    - id: resolve_deploy_plan
      type: shell
      target: local
      run: |
        # Walk project.deployments, resolve dependency order (§7),
        # emit a JSON array: [{repo, host, port, role, env_overrides,
        # consumes_ready}]. Repos with no binding are skipped with a
        # 'skipped: true' marker (§9).
      save_as: plan

    - id: pre_verify_consumes
      type: shell
      target: local
      run: |
        # For each project in project.consumes, call its /api/version.
        # Fail fast if an upstream is unreachable (§7).
      when: "${project.consumes | length > 0}"

    - id: deploy_each
      type: subplaybook
      playbook: release_flow:deploy-service
      foreach: "${plan}"
      as: binding
      skip_when: "${binding.skipped}"
      with:
        service_name: "${binding.repo}"
        host: "${binding.host}"
        unit_name: "${binding.repo}"
        backend_url: "http://localhost:${binding.port}"
        hostname: "${binding.repo}.gyrum.ai"     # convention; see §5.1
        tunnel_id: "${project.tunnel_id}"
        zone_id: "${project.zone_id}"
        allowed_emails: "${project.allowed_emails}"
        version_tag: "${inputs.version_tag}"
        env_overrides: "${binding.env_overrides}"
        dry_run: "${inputs.dry_run}"
      save_as: deploy_results

    - id: post_verify
      type: shell
      target: local
      run: |
        # For each deployed binding, curl /api/version and confirm
        # the SHA matches version_tag. Collect into a report.
      when: "!inputs.dry_run"
      save_as: post

  outputs:
    version_tag: "${inputs.version_tag}"
    deployed: "${deploy_results | map(r => {repo: r.service_name, host: r.host, changed: r.outputs.dns_changed || r.outputs.caddy_changed || r.outputs.app})}"
    skipped: "${plan | filter(p => p.skipped) | map(p => p.repo)}"
    dry_run: "${inputs.dry_run}"
---

# deploy-project

Project-level deploy orchestrator. Walks every binding in
dependency order, calls deploy-service (ADR-072 §6) per binding,
verifies /api/version post-deploy.

## Gate stack

References `docs/shared/gate-stack.md`:

1. CI green on the project's main repo at the target tag
2. Consumed projects reachable (`pre_verify_consumes`)
3. Dry-run preview rendered and confirmed by operator
4. Deploy in dependency order
5. Per-deploy `/api/version` verification (`post_verify`)

## Rollback

See ADR-076 §8 and `release_flow:rollback-project`.

Key shapes:

  • Kind is release_flow. release_flow carries a gate stack and a rollback section (ADR-067 §3 table). This is a release; deploy-service (kind release_flow) is not directly comparable because it is a service-level release — the fundamental unit a release_flow acts on is "the thing the operator thinks of as a release", and for multi- repo projects that is the project, not the repo.
  • foreach: on subplaybook:. Each binding invokes deploy-service as a sub-playbook. foreach: is a runtime feature not present in ADR-068 Phase 4 as originally specified; Phase 4b adds it (see §11 — we call this out as a named runtime extension gated by this ADR).
  • Dependency-order walk. resolve_deploy_plan (a shell step) is the current implementation of §7. A future ADR can promote it to a first-class runtime feature; for Layer 1 it is a shell step that reads the project YAML and produces an ordered list.
  • hostname: convention. The playbook synthesises ${binding.repo}.gyrum.ai as the public hostname. This is a Layer-1 convention; Layer 2+ will make public hostname an explicit binding field when blue/green needs two hostnames per repo.

6. Composition depth and the deploy-service boundary

deploy-project calls deploy-service (ADR-072 §6). Layers:

deploy-project (kind: release_flow)   ← ADR-076
 └─ deploy-service (kind: release_flow)   ← ADR-072 §6
     ├─ cloudflare-dns-upsert
     ├─ cloudflare-tunnel-route-upsert
     ├─ cloudflare-access-app-upsert
     ├─ caddy-site-upsert
     └─ systemd-service-reload

That's composition depth 3 from deploy-project to a leaf sub-playbook. ADR-068 §4 caps depth at 5. We're fine; we have headroom for two more layers (Layer 2 might add a blue-green-cutover orchestrator that composes two deploy-project calls).

The boundary between deploy-project and deploy-service is: anything that is per-binding — where to deploy, which port, which env — is deploy-project's job. Anything that is per-service — DNS record, tunnel route, Access policy, Caddy site, systemd unit — is deploy-service's. deploy-project does not know about Cloudflare; deploy-service does not know about projects. The project-shaped concerns (bindings, dependency order) never leak into the service-level playbook, and the service-level infra concerns never leak into the project-level playbook. This is the ADR-067 §4 separation-of-concerns pattern at the project/service tier.

7. Dependency resolution

Projects carry consumes: (ADR-059 §6). A project that consumes social-proof must not deploy until social-proof is reachable. deploy-project implements this as a pre-verification, not as a recursive deploy:

  1. Before deploying, the pre_verify_consumes step calls /api/version on each project in project.consumes. If any upstream is unreachable, the run fails fast with a clear error naming the upstream. The operator deploys or repairs the upstream first, then re-runs.
  2. We do not deploy consumed projects transitively. A deploy-project buzzy does not trigger a deploy-project social-proof. Transitive deploys are a Pandora's box (version mismatch between what's deployed and what's available, change windows, etc.). Layer 1 deploys exactly one project per invocation. The operator chooses the deploy order.
  3. Within the project, deploy order follows role convention. Convention: backend before frontend, worker before anything that produces its work. This is encoded as a stable sort in resolve_deploy_plan:
priority(role) =
  'database' → 10
  'backend'  → 20
  'worker'   → 30
  'api'      → 40
  'cron'     → 50
  'frontend' → 90
  (other)    → 60

Within a priority band, repos deploy in the order they appear in the project YAML's repos: list. This is convention, not contract — a repo with role: frontend that actually contains migrations will deploy last; the operator knows. Phase 1b can make this explicit via a depends_on: list per binding, but Layer 1 ships the convention and sees if it's enough in practice.

  1. Consumed-project reachability is a soft gate. The pre_verify_consumes step can be skipped with an operator confirmation prompt (ADR-068 approval step) if the operator knows the upstream is intentionally offline — e.g. a coordinated maintenance window. The log captures the override.

8. Rollback

A second playbook lives alongside: release_flow:rollback-project. Shape (front-matter; body sketch):

---
id: rollback-project
kind: release_flow
title: "Roll back a project to the last-known-good deploy"
persona: sre
owner: "@jon"
allowed_personas: [sre, owner]
destructive: true         # cutting traffic back is a change; approval required
required_scopes: (same union as deploy-project)
---
runtime:
  inputs:
    - project_slug
    - to_run_id?           # specific playbook_run to roll back to; default = previous successful
    - dry_run?
  steps:
    - id: load_history
      # Read playbook_run_events for deploy-project runs on this project;
      # find the previous successful run (or the one named by to_run_id).
    - id: extract_plan
      # The old run's inputs table contains the old version_tag and the
      # bindings as-of that run's load_project snapshot. We captured
      # bindings in the deploy-project outputs for exactly this reason.
    - id: approval
      # "Roll buzzy back from v1.4 to v1.3.2? Affects 2 bindings."
    - id: deploy_each
      # Same as deploy-project, but with the previous-run's bindings
      # and version_tag.

The mechanism is: deploy-project's outputs (§5) record the deployed bindings and version_tag. That record is the rollback plan. Rollback is "re-run that record" — not a separate state machine. This matches ADR-072 §8 — "rollback is a re-run of the previous version" — lifted one tier up to projects.

Rollback is destructive in the ADR-069 sense (it moves production traffic to a different version). The implicit approval step (ADR-069 §6) shows the operator the version and bindings before proceeding.

Edge case: the previous successful run deployed against a binding that has since been edited (host/port changed in the YAML). Rollback deploys against the historical bindings (from the audit), not the current ones. This is correct — the operator is rolling back to a known state, including the binding state at that time. Restoring just the code but leaving the new binding in place is a flavour of rollback we do not support in Layer 1; if we need it, the rollback-project playbook grows a preserve_current_bindings: bool input in a future ADR.

9. Edge cases

Enumerated here because they all bite once the playbook has N real bindings under it.

  • Host unreachable. The deploy-service sub-playbook's own wait_ready loop (ADR-072 §6) fails after 90 seconds. The deploy-project parent receives the failure, halts remaining bindings, and leaves earlier deployed bindings in place — they are live on the new version; the unreachable host still runs the old version. This is the correct tradeoff for Layer 1 (no atomic project-wide deploy); Layer 2 blue/green offers the atomic option. The operator sees a partial-deploy report in the run outputs and decides to re-run, to investigate, or to roll back.
  • Port conflict on target host. Caught at validator time (§3 Rule 4). The PR that introduces the conflicting binding never merges; the operator picks a different port. If the conflict slips through (e.g. a service already holds the port from a prior manual deploy), deploy-service's systemd-service-reload sub-playbook fails on port-in-use at service start; the project deploy halts with a clear error.
  • Repo without a binding. Skipped with a skipped: true marker and a one-line log entry. Not a failure. Rationale: not every repo in a project is deployable — some are libraries (ADR-059 §1 explicitly names platform-class libraries). Example: a Buzzy project with repos: [buzzy-frontend, buzzy-backend, buzzy-shared]buzzy-shared is a Go module, not a service; skipping it is correct. The UI (§4.1) shows the unbound repo as an italicised line so the operator can distinguish "forgot to bind" from "intentionally a library". A warn-if-no-binding hint is reasonable future polish; we do not ship it in Layer 1 because the italicised row already surfaces it.
  • env_overrides secret leak. Operators will put secrets in env_overrides once, and it will show up in the audit log. The validator rejects any env_overrides value that matches the ADR-072 §2.3 high-entropy-string heuristic (40-char base64, ghp_/gho_/sk_ prefixes, etc.) with a clear error: "looks like a secret; use ${vault.X} or ${env.X}". This is best-effort; a low-entropy password still gets through, and that's a followup. The right fix is the vault (ADR-069 §4); the validator's job is to catch the drive-by mistake.
  • Host removed from catalog while bindings still reference it. The catalog validator (ADR-060) rejects the host deletion until all bindings migrate. If a host disappears out-of-band (physical decomm, not a PR), the next validator run on any project referencing it fails loudly; deploys against the host fail at deploy-service's first SSH step.
  • Two projects bind the same repo. This shouldn't happen (a repo belongs to one project per ADR-059 §4), but if it does, the cross-project port-conflict check (§3 Rule 4) will almost certainly catch it because both bindings will share the same host+port for the same unit name. We do not add a separate "repo in two projects" check — it's either already caught or it's actually fine (the repo is a library both projects deploy, which is a weird but legal shape).
  • Binding edit during an in-flight deploy. The running deploy uses the snapshot it loaded in load_project; the edit takes effect on the next deploy. No mid-run re-read. This matches deploy-service's approach to input immutability (inputs fix at run start, ADR-068 §4).

10. Implementation plan — phased

Layer 1 ships in four small phases. Each phase is independently useful and each is 1-2 PRs.

Phase Scope Repos touched PR count ADR prereq
1a — schema + validator Extend gyrum-projects YAML schema to accept deployments: (§2). Add the four validators (§3). Extend scripts/validate-projects.sh (ADR-059 extended-by note). No UI, no playbook. gyrum-projects, dark-factory 1-2 PRs
1b — deploy-project playbook Author deploy-project.yaml (§5) as a monolithic Phase 1 playbook per the ADR-072 §7 Phase 1 pattern. Composes deploy-service via an inlined Ansible-wrapper shell step for this phase only, decomposing to subplaybook: calls when Phase 4b of ADR-068 (foreach: on subplaybook) lands. Ships alongside rollback-project. dark-factory 2 PRs ADR-072 §7 Phase 1 (shipped)
1c — /projects/[slug] UI Deployment tab Add the sixth tab (§4.1), Change-binding modal (§4.2), Deploy modal (§4.3). Backend: extend /api/v1/projects/:slug to return deployments and the playbook_run_events-sourced last-deploy metadata. ai-frontend, ai-research-run 2 PRs 1a
1d — rollback polish Promote the rollback from "shell-implemented" to a first-class playbook with the destructive-approval flow. UI wiring for the "Rollback to prev" button. dark-factory, ai-frontend 1-2 PRs 1b, 1c

Each phase is shipping-ready in isolation. Phase 1a is the prerequisite for every other phase; Phase 1b is usable from the CLI before Phase 1c ships the UI; Phase 1d closes the loop. Total cost: 6-8 PRs over a 2-3 cycle window.

Expected landing order:

  1. Phase 1a — schema extension. First PR edits the YAML schema in gyrum-projects; second PR adds the deployments: block to projects/buzzy.yaml as the reference example.
  2. Phase 1b — playbook. First PR is the monolithic deploy-project.yaml; second PR is rollback-project.yaml (depends on 1b first PR's input-recording shape).
  3. Phase 1c — UI. First PR is the read-only Deployment tab (no modals); second PR adds the Change-binding and Deploy modals.
  4. Phase 1d — rollback UI + playbook decomposition (swap the Ansible-wrapper shell step for subplaybook: once ADR-068 Phase 4b lands with foreach:).

11. Runtime dependency — foreach: on subplaybook: steps

deploy-project needs a runtime feature not explicitly committed by ADR-068 Phase 4: foreach: iteration over a subplaybook: step. The step invokes the same sub-playbook once per item in a list input, each iteration with different with: values.

We name this here, rather than in an ADR-068 amendment, because this ADR is the first consumer and the only consumer we know of today. Shape:

- id: deploy_each
  type: subplaybook
  playbook: release_flow:deploy-service
  foreach: "${plan}"              # a list expression
  as: binding                     # each iteration binds the item here
  skip_when: "${binding.skipped}" # optional skip expression per item
  with:
    service_name: "${binding.repo}"
    …

Semantics:

  • Sequential by default. Iterations run one after another. Layer 2+ blue/green may want parallel fan-out; Layer 1 does not. Sequential is simpler to audit (the event stream interleaves per-iteration events with clear boundaries) and matches the dependency-ordered deploy intent.
  • Failure halts. An iteration that fails halts the remaining ones; already-completed iterations stand. This is §9's "host unreachable → halt, leave earlier live" behaviour.
  • Each iteration is a separate playbook_run. The parent's event stream records subplaybook_started / subplaybook_completed pairs per iteration. The child runs appear in the audit as first-class runs.

This is a Phase 4b extension to the ADR-068 runtime. We track it as a tracking issue on the dark-factory board ("ADR-068 Phase 4b — foreach on subplaybook"); the issue cites this ADR as the driver. An ADR-068 amendment ADR may land alongside Phase 4b if the semantics need further specification.

12. Alternatives considered

  • Kubernetes-native bindings (Deployment + Service manifests). Rejected. Kubernetes solves a strict superset of Layer 1: the binding-to-host problem is what k8s Deployments+Services are, plus replica counts, plus self-healing, plus a scheduler, plus a lot more. For Gyrum's scale (today: two services; in twelve months: ten), the operational overhead of running a control-plane cluster dwarfs the value — we would spend more time keeping the cluster healthy than deploying things to it. ADR-020 already committed to VMs + systemd + Caddy; this ADR operates inside that envelope. Escape hatch: if Gyrum grows past ~50 services, the binding schema here is a natural source for rendering k8s manifests (a render-k8s-from-bindings sub-playbook) and we revisit.

  • Ansible inventory as the source of truth. Rejected as the source; accepted as one consumer. Ansible inventory already knows which hosts exist (we share this with gyrum-catalog), but the binding — "which repo from which project runs here, on which port, with which env" — is a project-shaped concern that does not naturally live in host_vars/. Splitting state between Ansible inventory and project YAML would mean two places to look for "where does buzzy-backend run?", which is the exact pathology we are closing (§Context problem 1). Ansible can consume the bindings (a future ADR may publish a dynamic inventory that reads projects/*.yaml); it should not own them.

  • Hardcoded per-repo deploy scripts. The current ugly reality. Rejected — doesn't scale past two services (ADR-072 §Context made the same argument at the infra tier; the shape repeats at the project tier). Per-repo scripts duplicate the four-to-five infra steps per service, drift out of lock-step with each other, and have no shared audit trail. The binding + deploy-project pair replaces N deploy scripts with 1 playbook + N lines of YAML.

  • A separate bindings.yaml file per project (rather than extending projects/[slug].yaml). Rejected. Two files that must stay in sync is a worse shape than one file with two sections. The binding is a property of the project, not a peer artifact. A PR that adds a repo to repos: and a binding for it is one diff, not two. The only reason to split would be access control — if we wanted different reviewers for "project membership" vs "deploy targets" — and we don't have that requirement.

  • Bindings at the host level — a hosts/[alias].yaml that lists which services it runs. Rejected. Inverts the natural authority: the project owns its deploys; the host owns its capacity. A host-keyed list forces a PR to the host file every time any project edits its binding, which is high contention for cross-cutting infra changes and makes project review fragmentary. Derived views ("what runs on web-01?") can be rendered from the project-keyed bindings without inverting the schema.

  • Bindings as inline fields on each repo entry in repos:. E.g. repos: [{name: buzzy-backend, host: web-01, port: 8090}, …]. Rejected. Mixes two concerns — "which repos belong to this project" (always true) with "where does this repo run" (true only for deployable repos) — and forces library repos into a verbose shape for no reason. The deployments: map keeps repos: a flat list and makes "is this repo deployable?" a single lookup ('X' in deployments).

  • Inference from Ansible roles. A binding could be inferred from the presence of infra/ansible/roles/buzzy-backend/ + a hostname guess. Rejected — inference means discovering where something would run if someone invoked the right Ansible playbook against the right host. The authoritative "it runs there" is what the binding is. Inference is a fine starting point for generating the initial deployments: block (a Phase 1a sub-task can ship gyrum-projects bindings --infer to scaffold), but it cannot be the ongoing source.

  • A richer binding schema that includes replicas, resource reservations, healthcheck overrides. Rejected for Layer 1 scope. Adding fields we don't need yet lets them ossify around Layer 1's single-binding-per-repo assumption. We grow the schema when we grow the capability, not before.

13. Layer 2+ preview

This section is a sketch, not a commitment. It exists so the Layer 1 schema choices make sense in light of where Layers 2-4 go.

  • Layer 2 — blue/green (ADR-077, future). A repo's binding becomes a pair of bindings: buzzy-backend.blue (currently live) and buzzy-backend.green (the staging instance). Both bindings coexist; a fast-switch playbook (release_flow:cutover-blue-green) flips the Cloudflare Tunnel route (ADR-072 §5.3) from pointing at blue to pointing at green. The Layer 1 schema extends additively: deployments: grows from "one host/port per repo" to "a set of slots per repo", with Layer 1's current single-entry shape as the default case. No Layer 1 data is invalidated.

  • Layer 3 — canary + auto-rollback (ADR-078, future). Blue and green both serve traffic; the Tunnel route splits a weighted fraction (5% canary, 95% stable). A release_flow: canary-promote playbook ramps the weight up while watching error-rate SLOs (ADR-025 burn-rate alerts); on a breach, it calls rollback-project (this ADR §8) with the prior version. Auto-rollback is "a scheduled run that inverts the weight" — the primitive we ship in Layer 1 (rollback-project) is exactly what canary needs.

  • Layer 4 — A/B testing (ADR-079, separate product). A/B is not a deploy layer. It is a feature-flag runtime that routes inside a single binding — the same version of buzzy-backend is live; different code paths execute for different cohorts. Feature flags are their own product surface (a web UI for experiment config, a client SDK) and do not require any change to the binding schema. Naming A/B here is about scope-setting: the binding layer does not grow an "A vs B" dimension because that is the wrong tool for A/B testing.

The summary: Layer 1's schema has one entry per repo; Layer 2 generalises it to N slots; Layer 3 adds weights across slots; Layer 4 doesn't touch bindings at all. This sequence makes each layer an additive extension with a clean migration story.

14. Consequences

Easier.

  • "Where does X run?" is a grep on projects/*.yaml. Audit in five seconds. Six-months-later question has a deterministic answer.
  • Project deploys become one command. gyrum-run deploy-project buzzy v1.4 replaces a shell history of four deploy-service invocations. Saves minutes per deploy and eliminates the "forgot one" class of bug.
  • Binding changes become reviewable. Moving buzzy-frontend from web-01 to web-02 is a YAML diff in a PR; the reviewers see it, the audit records it, the deploy playbook picks it up. Currently this is a tribal "hey, I'm moving X to Y, any objections?"
  • Rollback is cheap. The last successful run's outputs are the rollback plan. No separate rollback script to maintain.
  • Layer 2+ has a floor to build on. Blue/green, canary, and feature-flag ADRs each start with a binding schema to extend rather than invent. Cheapest thing we can do to unblock the rest of the deploy story.
  • New operator onboarding drops. "Which host runs buzzy-backend?" becomes cat projects/buzzy.yaml | yq .deployments.buzzy-backend.host. No tribal knowledge.

Harder.

  • Binding YAML is now load-bearing. A typo (host: web-01 vs host: web01) is a validator-caught error at PR time — fine. A logic bug (the operator binds to the wrong host intentionally, because the real target was in their head not the YAML) is caught at run time — less fine. Mitigation: the UI dropdown (§4.2) eliminates the typo class; the role of the PR reviewer covers the logic class.
  • Validator runs on every PR across every project. Rule 4 (cross-project port conflict) is O(N projects × N bindings); at N=50 projects × 5 bindings each, we are at 250 entries. Still fine for a CI validator (<1s); large enough that we should not be cavalier about extending the rule set further.
  • Schema discipline — deferred fields stay deferred. We committed (§2) to not putting replicas, resource limits, or healthcheck overrides in the binding yet. The first person who needs them will want to add them inline. The answer is "write the ADR that extends the schema"; the fear is "the person adds it as an undocumented field because they are in a rush". Mitigation: the validator rejects unknown fields under deployments.* with a pointer to this ADR.
  • PR load on dark-factory. projects/*.yaml lives in dark-factory. Every binding edit touches dark-factory. That's already true for project metadata (ADR-059 §4); this ADR increases the PR rate by a factor of "once per binding-change, roughly one per service-move-per-quarter". Acceptable.
  • The foreach: runtime feature (§11) is new. ADR-068 Phase 4b has to land for the subplaybook:-composed form of deploy-project to work. Phase 1b ships the monolithic form as a shell-wrapped ansible-playbook call — correct under ADR-072 §7 Phase 1 pattern — and decomposes at Phase 4b.

What we sign up to maintain.

  • The deployments: schema as the canonical binding. A field added here — a role enum, a replicas integer, a probe URL — is an ADR. The schema review cadence is 90 days (§15); new fields get proposed there.
  • The gyrum-projects validator. Four rules today (§3), more as we accumulate learned pathologies. The validator is the enforcement point for the schema; it is the first line of defence against invalid bindings.
  • The deploy-project + rollback-project playbook pair. These are long-lived; they track ADR-072 evolutions (new sub-playbook in deploy-service → reflected in deploy-project's composed graph).
  • The Deployment tab. UI drift is expected; the shape (row- per-binding, Change-binding modal, Deploy-version modal) is the committed pattern. Changes to the shape are ADR-reviewable.
  • The Layer 1/2 boundary. Nothing in Layer 1 pre-commits the schema to a single-binding-per-repo forever. When Layer 2 ADR (077) extends the schema to N slots, Layer 1's single-entry shape must migrate cleanly. We commit to maintaining that additive-migration property — no breaking rewrite when Layer 2 lands.

15. Review cadence

Re-review every 90 days (next: 2026-07-22) against:

  • Schema coverage — are the five documented fields (host, port, role, env_overrides, implied version_tag via deploy inputs) sufficient? Which new fields have operators been requesting? Is the deferred-fields discipline holding?
  • Validator catches per month — how often does Rule 1-4 reject a PR? Rising catch rate means the schema is load-bearing; falling rate is fine.
  • Deploy frequency / duration — pre-ADR baseline (shell history) vs post-ADR (deploy-project runs). Target: deploy frequency up, error rate down. Measure via playbook_run_events (ADR-068 §5).
  • Rollback use — how often did rollback-project run? Each rollback is a retro item (what went wrong) and a goodness signal (rollback was cheap enough to use).
  • Layer 2 pressure — has blue/green pressure started? If operators are manually duplicating bindings ("buzzy-backend-v2" as a fake second binding), ADR-077 is overdue.
  • UI ergonomics — is the Change-binding modal being used, or are operators still PR-editing the YAML by hand? The latter is fine (a power-user path); an 80/20 split toward modal use is the target for the Business-Owner persona.
  • GitOps discipline — did anyone manually edit a deploy target on a host without a matching YAML edit? Each is a retro; a rising count is a signal we need the Phase-6-ish drift detector at the project tier.

References

  • ADR-059 — project-first IA; this ADR's deployments: field extends the YAML schema committed there.
  • ADR-060 — catalog-driven infrastructure; the host: field resolves through the catalog.
  • ADR-061gyrum-projects library; owns the schema + validator.
  • ADR-064gyrum-catalog + hex arch; the authoritative list of host aliases.
  • ADR-067 — playbooks unified primitive; release_flow kind, front-matter conventions.
  • ADR-068 — playbook runtime; subplaybook executor, audit stream (playbook_run_events). This ADR introduces a Phase 4b extension (§11).
  • ADR-069 — playbook security; destructive-step approval (rollback-project destructive flag), vault references in env_overrides.
  • ADR-070 — playbook UI contract; <PlaybookRunner> hosts the Deploy modal.
  • ADR-072 — infrastructure as playbooks; this ADR's deploy-project composes ADR-072 §6's deploy-service sub-playbook.
  • ADR-073 — preview environments; a preview-project is a project with its deployments: pointing at ephemeral preview hosts.
  • ADR-074 — CI/CD plane separation; deploy-project runs on the CD plane, triggered by the CI plane's "tag promoted" signal.
  • dark-factory/playbooks/infra/deploy-service.yaml — the sub-playbook this ADR composes.
  • gyrum-catalog — the host inventory referenced by host:.

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