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-catalogalready 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 listsbuzzy-frontendandbuzzy-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-serviceas the service-level orchestrator — one service, one host, DNS + tunnel + Access + Caddy + systemd. deploy-serviceis shipped (Phase 1 form). The monolithic Phase 1deploy-service.yaml(ADR-072 §7 table, Phase 1 row) runs end-to-end and produces a deployed, Access-gated, Caddy-fronted service. It takeshost:andhostname: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:
- "Where does buzzy-backend run?" is currently answered by:
(a) asking in Slack, (b) grepping
gyrum-catalogforbuzzy, (c) ssh-ing to a likely host andsystemctl status-ing. There is no authoritative answer. The binding YAML is the authoritative answer —projects/buzzy.yaml→deployments.buzzy-backend.host. - "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. - "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.yamlplus the twoplaybook_runsrows. - "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.hostin the YAML, opens a PR, merges, and runsdeploy-project buzzy— the next deploy lands on web-02.
Why now
Three converging pressures:
- 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.
- ADR-072's
deploy-serviceis the sub-playbook this ADR composes. Thesubplaybookexecutor (ADR-068 Phase 4) lands the same cycle; adeploy-projectplaybook that composesdeploy-servicecalls is the natural Phase 4 consumer. Naming the composition now means Phase 4 ships with a second orchestrator (not onlydeploy-service) and we discover Phase 4 bugs at composition depth 3, not at depth 2. - 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 fromgyrum-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 throughgyrum-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'scloudflare-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 aprivileged_port: trueflag 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 (twobackendbindings, 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. Arole:string the operator typed is better than arole: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.envor systemd unit sets. The override always wins. This is wherePUBLIC_API_URLlives 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/versionprobe (ADR-072 §6verify_shastep) 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 indeployments: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_URLthat 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-projectrun that targeted this binding. Sourced fromplaybook_run_eventsvia the audit trail (ADR-068 §5). Links to the commit on GitHub. - Age —
time.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-projectruns 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-projectplaybook 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=Yand 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 viagh 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_overridesis plaintext-only. A secret goes through the ADR-069 vault by name (${vault.buzzy_api_key}as a string value inenv_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_flowcarries a gate stack and a rollback section (ADR-067 §3 table). This is a release;deploy-service(kindrelease_flow) is not directly comparable because it is a service-level release — the fundamental unit arelease_flowacts on is "the thing the operator thinks of as a release", and for multi- repo projects that is the project, not the repo. foreach:onsubplaybook:. Each binding invokesdeploy-serviceas 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.aias 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:
- Before deploying, the
pre_verify_consumesstep calls/api/versionon each project inproject.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. - We do not deploy consumed projects transitively. A
deploy-project buzzydoes not trigger adeploy-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. - Within the project, deploy order follows role convention.
Convention:
backendbeforefrontend,workerbefore anything that produces its work. This is encoded as a stable sort inresolve_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.
- Consumed-project reachability is a soft gate. The
pre_verify_consumesstep can be skipped with an operator confirmation prompt (ADR-068approvalstep) 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-servicesub-playbook's ownwait_readyloop (ADR-072 §6) fails after 90 seconds. Thedeploy-projectparent 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'ssystemd-service-reloadsub-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: truemarker 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 withrepos: [buzzy-frontend, buzzy-backend, buzzy-shared]—buzzy-sharedis 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_overridessecret leak. Operators will put secrets inenv_overridesonce, and it will show up in the audit log. The validator rejects anyenv_overridesvalue 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 matchesdeploy-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:
- Phase 1a — schema extension. First PR edits the YAML schema
in
gyrum-projects; second PR adds thedeployments:block toprojects/buzzy.yamlas the reference example. - Phase 1b — playbook. First PR is the monolithic
deploy-project.yaml; second PR isrollback-project.yaml(depends on 1b first PR's input-recording shape). - Phase 1c — UI. First PR is the read-only Deployment tab (no modals); second PR adds the Change-binding and Deploy modals.
- Phase 1d — rollback UI + playbook decomposition (swap the
Ansible-wrapper shell step for
subplaybook:once ADR-068 Phase 4b lands withforeach:).
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 recordssubplaybook_started/subplaybook_completedpairs 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-bindingssub-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 inhost_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 readsprojects/*.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-projectpair replaces N deploy scripts with 1 playbook + N lines of YAML.A separate
bindings.yamlfile per project (rather than extendingprojects/[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 torepos: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].yamlthat 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. Thedeployments:map keepsrepos: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 initialdeployments:block (a Phase 1a sub-task can shipgyrum-projects bindings --inferto 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) andbuzzy-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-promoteplaybook ramps the weight up while watching error-rate SLOs (ADR-025 burn-rate alerts); on a breach, it callsrollback-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-backendis 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.4replaces a shell history of fourdeploy-serviceinvocations. 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-01vshost: 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/*.yamllives indark-factory. Every binding edit touchesdark-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 thesubplaybook:-composed form ofdeploy-projectto work. Phase 1b ships the monolithic form as a shell-wrappedansible-playbookcall — 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-projectsvalidator. 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-projectplaybook pair. These are long-lived; they track ADR-072 evolutions (new sub-playbook indeploy-service→ reflected indeploy-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, impliedversion_tagvia 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-projectruns). Target: deploy frequency up, error rate down. Measure viaplaybook_run_events(ADR-068 §5). - Rollback use — how often did
rollback-projectrun? 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-061 —
gyrum-projectslibrary; owns the schema + validator. - ADR-064 —
gyrum-catalog+ hex arch; the authoritative list of host aliases. - ADR-067 — playbooks unified primitive;
release_flowkind, front-matter conventions. - ADR-068 — playbook runtime;
subplaybookexecutor, audit stream (playbook_run_events). This ADR introduces a Phase 4b extension (§11). - ADR-069 — playbook security; destructive-step approval
(
rollback-projectdestructive flag), vault references inenv_overrides. - ADR-070 — playbook UI contract;
<PlaybookRunner>hosts the Deploy modal. - ADR-072 — infrastructure as playbooks; this ADR's
deploy-projectcomposes ADR-072 §6'sdeploy-servicesub-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-projectruns 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 byhost:.
Supersedes: none Superseded by: leave blank until a later ADR reverses this one