ADR-079: Dark-factory as the operational-approval substrate
Status: Proposed Date: 2026-04-24 Related: ADR-067 (playbooks unified primitive), ADR-068 (playbook runtime), ADR-069 (playbook security — destructive approvals), ADR-072 (infrastructure as playbooks), ADR-074 (CI/CD plane separation), ADR-076 (project-deployment binding), ADR-077 (session-independent agent coordination layer — in flight, PR #334), ADR-078 (trigger-driven playbook orchestration)
Supersession note. This ADR supersedes ADR-077 §3 — the
~/.gyrum/proposals/,~/.gyrum/approvals/,~/.gyrum/actions/journals and the pairedgyrum-propose/gyrum-approve/gyrum-actionCLI verbs. Everything else in ADR-077 (Phase 1 read-surfacesgyrum-in-flight+gyrum-findings-unread; Phase 3 agent-preamble contract) is unchanged. See §9 for the precise supersession map.
Decision (one paragraph)
Dark-factory is already the operational book of record; operational
approvals live in dark-factory as files-in-git, not in a new
~/.gyrum/ journal. We add an approvals/ directory with four
lifecycle subdirs (pending/, approved/, rejected/, executed/),
a policies/approval-classes.yml policy file, and a CODEOWNERS
entry that restricts who can merge into approvals/pending/*. The
approval flow is the GitHub PR flow: an agent or human opens a PR
adding a YAML file under approvals/pending/; the existing
three-persona review comments on it; a human listed in both
approvers_whitelist and CODEOWNERS merges the PR; a post-merge
move + commit (watcher in Phase 2, manual in Phase 1) shifts the
file to approved/ or executed/. No new CLI verbs. No new journal
on disk outside the repo. The identity gate is the merge button on
an org-protected branch with CODEOWNERS-reviewed approval directory,
not a shell command reading $USER.
Context
The observation that tips the design
ADR-077 §3 proposed new ~/.gyrum/ journals plus three new CLI verbs
(gyrum-propose, gyrum-approve, gyrum-action) to track a
proposal → approval → action lifecycle. The user's reply was not a
disagreement with the lifecycle — it was a substrate objection:
"Dark-factory already IS the operational book. Approvals belong there. Use what exists."
The substrate has been demonstrating approval-by-merge for architectural decisions all day already. ADRs flow:
docs/decisions/proposals/<id>.md (agent or human opens PR)
│
│ 3-persona review gate + human merge
▼
docs/decisions/<id>.md (landed, immutable record)
A concrete instance is staged uncommitted in this working tree today
at docs/decisions/proposals/cmd-server-split-proposal.md — the
proposal side of a two-tier promotion that has been implicit in the
ADR workflow for months. The factory works. We just haven't named
the pattern and we haven't extended it beyond architectural
decisions to operational ones.
What evaporates today for operational decisions
Architectural decisions (ADRs) are durable because they are git-tracked markdown files in this repo. The tiers of operational decisions that still evaporate into chat transcripts:
| Decision | Current record | Failure mode |
|---|---|---|
| "Rollback buzzy to v0.14.3" | #ops chat or a scrollback |
No audit trail of who approved, why, or when it ran |
| "Deploy observ-sdk to prod" | a terminal session | No persistent "approved" flag an agent can check |
| "Admin-merge past the size gate" | ~/.gyrum/admin-merges.log |
Exists, but one-way — no PR/review record before the merge |
| "Spend EUR 40/mo on a new Hetzner host" | verbal | No audit, no policy |
All four share the shape ADR-077 §3 wanted to capture: a proposal that a human assents to before something executes. The disagreement is only about where the YAML lives.
Why ~/.gyrum/ is wrong for operational approvals
~/.gyrum/is per-host. One operator's laptop. Any agent running on the ai-research VPS, on Hetzner, or on a Claude Code container sees an empty~/.gyrum/approvals/. Shipping the journal across hosts requires either rsync (fragile), mutual trust of~/.gyrum/humansfiles (already Open question 1 in ADR-077), or a service (overbuilt). Dark-factory, as a git repo cloned everywhere, is already the shared substrate.gyrum-approveas a shell command is trivially bypassable. ADR-077 §10 acknowledged this openly: "An adversarial agent canunset GYRUM_AGENT_ID && USER=jon gyrum-approve." The GitHub merge button on a protected branch under CODEOWNERS review is orders of magnitude harder to spoof — the attacker needs a GitHub auth token for a human account and the human must also be on the CODEOWNERS line.Identity is already solved upstream. GitHub PR review + CODEOWNERS + branch protection is an identity gate the team already depends on for code merges. Re-using it for operational approvals inherits that gate at zero build cost.
Review is already a three-persona PR review. ADR-077 §4 had to re-invent a persona-keyed comment thread on proposals. Dark-factory already runs Priya / Marcus / Lin / Dana against every PR via
gyrum-review-pr. Approvals-as-PRs get that review for free.The audit trail is git, everywhere. "Who approved this?" is
git log approvals/approved/<slug>.yml. "What fired?" is the same file'sexecuted_attrailer. No separate audit log to maintain or prune.
Non-negotiable requirements — recap from ADR-077
ADR-077's three user-named non-negotiables still apply, unchanged:
- Wire this into the ecosystem. Substrate must be shared across
agents and hosts — dark-factory (a cloned-everywhere repo) is
strictly better than
~/.gyrum/(one-laptop) on this axis. - Agents involved in deciding. The three-persona review over an approval PR is exactly the agents-involved-in-deciding flow — same mechanism as ADR-077 §4 roles 1 (raising) and 2 (commenting as personas), just hosted on GitHub PR comments.
- Don't forget. Approved-but-not-executed files in
approvals/approved/are a durable, grep-able list the agent-preamble contract (ADR-077 Phase 3) reads.
Design
1. The directory structure
dark-factory/
docs/decisions/proposals/ # architectural proposals (existing)
docs/decisions/ # architectural decisions (existing)
approvals/ # operational approvals (NEW)
pending/ # open PRs writing here
approved/ # merged, awaiting execution
executed/ # approved + action-fired (watcher writes)
rejected/ # PR closed without merge → moved here
policies/
approval-classes.yml # what needs approval; who approves (NEW)
CODEOWNERS # adds /approvals/pending/ rule (NEW)
approvals/pending/ is the PR-target directory. approvals/approved/
receives the merged file via either a move-commit in the same PR
(Phase 1, manual) or a watcher-committed move (Phase 2).
approvals/executed/ is the final state after the action.playbook
runs. approvals/rejected/ catches PRs closed without merging (the
watcher or a gh-pr-close hook moves the file; a closed PR is a
rejected proposal for audit purposes).
2. The canonical approval YAML schema
# approvals/pending/2026-04-25-<slug>.yml
id: 2026-04-25-rollback-buzzy-deploy
title: Rollback buzzy production to v0.14.3
proposed_by: agent:opus-4.7 # or human:jon@example.com
proposed_at: 2026-04-25T10:32:00Z
kind: rollback # rollback | deploy | admin-override | cost-spend | other
urgency: medium # low | medium | high | critical
scope:
repo: buzzy-gyrum-io
hosts: [web-01, web-02]
rationale: |
Free-form explanation. Multi-paragraph OK. State the problem,
the assessed blast radius, and what reverting buys.
action:
playbook: rollback-project
inputs:
project: buzzy
target_version: v0.14.3
approvers_required: 1
approvers_whitelist: [jon] # cross-check against CODEOWNERS
status: pending # pending → approved | rejected
# --- trailer written by the watcher after execution (Phase 2) ---
# executed_at: 2026-04-25T10:45:00Z
# executed_by: ai-research:<run_id>
# result: success
# result_url: https://dark-factory/playbook-runs/<run_id>
status starts pending. The merge commit flips it to approved
(Phase 1: manually in the PR; Phase 2: the watcher writes the flip
commit). The file moves directories to match status.
3. The approval flow
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Propose │──▶│ Review │──▶│ Approve │──▶│ Act │
│ │ │ │ │ │ │ │
│ open PR │ │ 3-persona │ │ human merges │ │ watcher │
│ → pending/ │ │ comments │ │ PR; file → │ │ runs │
│ │ │ (existing │ │ approved/ │ │ playbook; │
│ │ │ gate) │ │ │ │ file → │
│ │ │ │ │ │ │ executed/ │
└──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘
1. Propose. An agent (or human) opens a PR adding a file under
approvals/pending/. The agent authors with a Co-Authored-By trailer
carrying its agent ID (existing convention — ADR-077 §3.4 names it).
The PR description links the approval YAML and summarises the
proposed action.
2. Review. gyrum-review-pr runs the existing three-persona gate
against the PR — same mechanism as any other PR. Personas apply their
usual lenses:
- Priya (security / YAGNI): "does this rollback clobber state? Is the target version actually safer? Is the blast radius pre-computed or assumed?"
- Marcus (architecture / hex): "is this the right scope? Does the rollback include all hosts that serve the affected traffic or just the one where the alert fired?"
- Lin (TDD / idioms): "will a regression test be added post-rollback so we don't ship this bug again?"
- Dana (docs — advisory): "is the rationale readable by a human doing forensics in six months?"
3. Approve. A human whose username appears in both the YAML's
approvers_whitelist and the repo's CODEOWNERS entry for
/approvals/pending/* merges the PR. GitHub's merge button on a
protected branch with required-review-count ≥ 1 is the identity
gate. The file moves — by a trailing move-commit on the PR branch
(Phase 1) or by a watcher-committed move (Phase 2) — from
approvals/pending/ to approvals/approved/, and status: pending
flips to status: approved.
4. Act. A watcher (ai-research in Phase 2) reads newly-created
files under approvals/approved/, runs action.playbook with
action.inputs, and back-commits the execution trailer
(executed_at, executed_by, result, result_url). The file
moves to approvals/executed/. Phase 1 is manual — a human runs
the playbook from the checked-in file and hand-writes the trailer.
4. Identity and gating
Three layers, each adding a defence, none reinventing primitives:
CODEOWNERS on
/approvals/pending/*lists the humans allowed to approve. Starting value:/approvals/pending/ @jon /policies/approval-classes.yml @jonAn agent cannot be a CODEOWNER — GitHub requires an actual user or team. Expanding the approver list is a PR to
CODEOWNERS, which is itself gated on the existing CODEOWNERS entry (self-gating).Required reviews on the
mainbranch protection setting force the three-persona review gate before the merge button unlocks. This is already active in dark-factory for code PRs; the approval PRs inherit the same branch protection because they targetmain.Agent identity in commit trailers. Commits that add files under
approvals/pending/MUST carry the agent ID in aCo-Authored-Bytrailer (ADR-077 §3.4 shape, pre-existing convention). A pre-commit hook rejects commits missing the trailer when the committer resolves to a known agent username; implemented as a small addition to the existing gyrum-hooks bundle (Phase 1 tightening, not blocking the MVP). The agent cannot self-approve — CODEOWNERS demands a human GitHub account, and GitHub refuses merges authored entirely by the PR author on protected branches with review requirements.
Compared to ADR-077's $USER-whitelist-plus-GYRUM_AGENT_ID honesty
tooling, this is a real auth layer — attacking it requires a
compromised human GitHub token and a CODEOWNERS listing, not an
unset of an environment variable.
5. policies/approval-classes.yml
The policy file declares what classes of operational decision exist, who approves each, and which patterns are pre-approved without a PR. Phase 1 skeleton (empty lists where user fills in):
# policies/approval-classes.yml
# The canonical list of operational-approval classes.
# Changes to THIS file require an ADR-level review (see §8).
classes:
rollback:
description: Revert a running project to a prior version.
approvers_required: 1
approvers_whitelist: [] # fill with GitHub usernames
pre_approved: [] # fill with pre-authorised patterns
deploy:
description: Deploy a new build to a production host.
approvers_required: 1
approvers_whitelist: []
pre_approved: []
admin-override:
description: Merge past a structural gate (file-size, cyclomatic,
coverage, etc).
approvers_required: 1
approvers_whitelist: []
pre_approved: []
cost-spend:
description: Provision infra costing >= the threshold.
approvers_required: 1
approvers_whitelist: []
pre_approved: []
threshold_eur_per_month: 10
The pre_approved block (Phase 4, not Phase 1) allows progressive
pre-authorisation as trust grows:
pre_approved:
- project: buzzy
hosts: [web-01]
max_age_hours: 24
reason: "Small-blast-radius rollback on a low-traffic path."
A pre-approved action still writes to approvals/executed/ with
approver: pre-approved:<class> — the audit record is non-optional;
only the PR gate is skipped.
Starting posture: everything requires a PR. Pre-approval is empty. User opens new PRs to relax specific patterns as comfort grows. The ratchet only moves one way (towards more pre-approval), so we want the starting stance to be strict.
6. Latency and the fast lane
- Default latency. PR open + review + merge = minutes to hours. Correct for 95% of approvals (rollbacks, deploys, cost-spends).
- Fast lane via
pre_approved. Listed patterns skip the PR but still audit. Seconds-latency on the matched path. - Emergency break-glass is not in this ADR. If a future incident reveals we need sub-second approval for autonomous incident response, that is a Phase-2 follow-on ADR — most likely warp-backed, because warp is the low-latency control plane. Don't build for a speed target we haven't missed.
7. Relationship to other ADRs
| ADR | Status under ADR-079 | Notes |
|---|---|---|
| ADR-077 §3 (proposals/approvals/actions journals) | superseded | ~/.gyrum/ journals replaced by dark-factory/approvals/ |
| ADR-077 Phase 1 (gyrum-in-flight, gyrum-findings-unread) | unchanged | read-only agent surfaces, orthogonal to approvals |
| ADR-077 Phase 3 (agent-preamble contract) | unchanged | preamble now reads approvals/pending/ + docs/decisions/proposals/ instead of ~/.gyrum/proposals/ |
| ADR-077 §4 roles (raising, commenting, triage) | unchanged shape, new venue | raising = opening a PR; commenting = PR comments; triage = labels on PRs |
| ADR-078 (triggers) | complementary | approval-approved is a trigger kind: triggers: [{on: approval-approved, class: rollback}] fires the action playbook |
| ADR-072 (playbooks) | unchanged | each action.playbook is a standard playbook run |
| ADR-069 (playbook security — destructive approvals) | consumer | destructive playbook steps that park for approval write an approvals/pending/ file; same substrate |
8. Governance of the policy file
policies/approval-classes.yml is itself an operational governance
surface — the thing that decides what is pre-approved. A bug in this
file (or a drift toward over-broad pre-approval) undermines the whole
system.
Governance rule: PRs touching policies/approval-classes.yml
require an ADR-level review, not a regular-class review. Concretely
this means:
- The PR must link a dated entry in
docs/decisions/ordocs/decisions/proposals/that reasons about the change. - Three personas must leave non-stub comments (not just the usual automated pass).
- A second CODEOWNER must approve in addition to the merge author.
This is enforced socially in Phase 1 (reviewers check). Phase 2 can add a CI gate that grep's for the ADR link. The bar is high on purpose — the file is the trust root.
9. Supersession map (precise)
The claim that ADR-079 supersedes ADR-077 needs to be exact so a future reader is not confused about which concepts survive:
| ADR-077 element | Fate under ADR-079 |
|---|---|
~/.gyrum/proposals/<uuid>.yml schema (§3.1) |
Superseded. Replaced by dark-factory/approvals/pending/*.yml with the §2 schema above. |
~/.gyrum/approvals/<uuid>.yml (§3.2) |
Superseded. Approval is the merge of the approval PR; the merge commit + the moved file in approved/ are the approval record. |
~/.gyrum/actions/<id>-<ts>.yml (§3.3) |
Superseded. Action is the executed trailer on the YAML + the approvals/executed/ location. |
gyrum-propose verb |
Removed. Replaced by git + gh pr create. |
gyrum-proposal-comment --as <persona> |
Removed. Replaced by PR comments posted by the existing three-persona review agents. |
gyrum-approve --rationale "..." |
Removed. Replaced by the GitHub merge button + a merge-commit message that captures rationale. |
gyrum-action <id> |
Removed. Replaced by the watcher (Phase 2) or a manual trailer-commit (Phase 1). |
gyrum-in-flight (Phase 1 command) |
Unchanged. |
gyrum-findings-unread (Phase 1 command) |
Unchanged. |
~/.gyrum/findings/findings.jsonl |
Unchanged. Findings are observations, not approvals; the substrate distinction is intentional. |
~/.gyrum/agents/<id>.state (§3.4) |
Unchanged. Agent lineage is local to a host; in-flight reads from it. |
~/.gyrum/admin-merges.log |
Unchanged in the short term; §7 flags that admin-overrides should eventually flow through approvals/ under the admin-override class, at which point the legacy log becomes a projection of the new truth. Migration is not in this ADR. |
| Phase 3 agent-preamble contract (§6) | Unchanged in shape, updated in content: the preamble reads approvals/pending/ + docs/decisions/proposals/ instead of ~/.gyrum/proposals/. |
The net effect: ADR-077 shrinks to its Phase-1 + Phase-3 halves. Phase 2 (the six journals + four new verbs) is replaced by ADR-079's file-in-git substrate.
10. Phased rollout
| Phase | Ships | Depends on |
|---|---|---|
| 1 — Scaffold (this PR) | approvals/{pending,approved,executed,rejected}/ with .gitkeeps; CODEOWNERS entry for /approvals/pending/ + /policies/approval-classes.yml; policies/approval-classes.yml skeleton with empty whitelists. No watcher. Humans manually run playbooks and back-commit trailers. |
— |
| 2 — Watcher | ai-research watches the repo (poll or GitHub webhook to /api/v1/trigger/webhook/approval-approved; see Open question 2). Reads newly-committed approvals/approved/*.yml, runs action.playbook, writes trailer, moves file. |
ADR-078 trigger approval-approved kind |
| 3 — Operate UI card | /operate renders approvals/pending/*.yml with a "View PR" link. No new API — the UI fetches the YAML via git-over-https or the existing dark-factory repo-read endpoint. |
Phase 2 useful; not strictly blocking |
| 4 — Pre-approval fast lane | pre_approved patterns in policies/approval-classes.yml bypass the PR. Still writes to approvals/executed/. Requires the §8 governance gate on policy changes. |
Phase 2 |
Phase 1 is the minimum shippable. It gives us the substrate — directories, CODEOWNERS, policy file skeleton — and the flow (PR → merge → manual playbook → manual trailer). Every later phase is a pure enhancement: remove manual steps by automating them in the same shape.
11. Alternatives considered
Keep ADR-077's
~/.gyrum/journals. Rejected because the substrate is per-host, the identity gate is an$USER-whitelist that an agent can trivially flip, and we'd be duplicating an approval-by-merge pattern that the repo already runs for ADRs. Every argument for the ADR flow's durability is an argument for this ADR's substrate.Use GitHub Issues instead of files-in-git. Rejected. Issues are not cloneable with the repo; a fresh agent clone has no record; searching historical approvals requires network + auth; the issue schema is shaped for bugs, not YAML decisions. Files-in-git are the right shape.
A dedicated approvals service. Rejected as over-engineering for Phase 1. Even when we want sub-second latency (speculative), a service can project from the git state; the canonical record stays in the repo.
A relational DB with a schema migration. Rejected for the same reason we rejected it in ADR-077 §8: files first, indexes later. If the UI card grows expensive, a projection is a later choice.
Merge approvals into the existing
admin-merges.log. Rejected. That log is post-hoc (one line written after the override). The whole point of this ADR is to make approvals ante-hoc — reviewable before they fire. The two logs serve different lifecycle stages and should stay separate (though §9 notes a later merge is possible once the ante-hoc log matures).Seconds-latency break-glass as a first-class feature. Rejected for now. We have zero incidents where seconds-latency approval was the thing that would have changed the outcome. Building for hypothetical future needs is a policy we explicitly push back on (ADR-077 §8 rejected the same pattern for proposals).
12. Consequences
Easier.
- Every operational approval is now searchable:
git log approvals/approved/is the complete audit surface. - The identity gate is GitHub's — the same one that already protects
the
mainbranch. We add nothing to learn. - The three-persona review gate covers operational decisions, not just code changes. The failure modes the gate catches on code (missing security review, muddled architecture) are the same failure modes on a rollback PR.
- Agents can propose but not approve — same blast-radius bound as ADR-077 §4 demanded, enforced by CODEOWNERS rather than a shell check.
- The fast lane is a policy file edit, not a code change. Relaxing approvals as trust grows is a reviewable PR to one file.
Harder.
- Every approval is a PR — more ceremony than typing
gyrum-approve <id>on the command line. Mitigation:gh pr create+ a smallgh-new-approvalshell helper in Phase 2 makes the proposal one command. - Merge-commit rationale is less structured than a dedicated
--rationaleflag. Mitigation: PR template enforces a rationale section in the body; the watcher copies it to arationale:field on the YAML at merge time. policies/approval-classes.ymlbecomes a trust root (§8). Misuse — adding a sloppy pre-approval — erodes the whole system. Mitigation: §8 governance rule; CI gate in Phase 2.- We now have two proposal tiers in-repo (
docs/decisions/proposals/for architectural;approvals/pending/for operational). The boundary needs to stay clear. Rule of thumb: if the decision changes how we build, it is an ADR; if it changes the state of the running fleet, it is an approval.
What we sign up to maintain.
- The
approvals/directory shape and the YAML schema in §2. policies/approval-classes.ymlas the trust-root policy file.- The CODEOWNERS entry for
/approvals/pending/*and/policies/approval-classes.yml. - The watcher (Phase 2 onward).
- The supersession contract with ADR-077 — any future ADR that touches the proposal/approval/action space must read this one.
13. Open questions
Where does the watcher live? Candidates: ai-research (already running, already has the playbook runtime, simplest bootstrap) or a new daemon (cleaner separation of concerns). Preference: ai-research for Phase 2. If the watcher grows complex (multi-repo watching, rate limiting, retry semantics) it extracts. Deferred — Phase 2.
Webhook or poll for detecting approved PRs? Webhook (post-merge GitHub webhook →
/api/v1/trigger/webhook/ approval-approved) is lower-latency and cleaner; polling the repo every N seconds is simpler to bootstrap. Preference: polling for MVP, webhook for production. Phase 2 can ship polling first and add the webhook as a follow-on. Deferred — Phase 2.Migration path for ADR-077 PR #334. Options: (a) land #334 as-is (its Phase 1 MVP — the
in-flight+findings-unreadcommands — is still correct) and have this ADR supersede §3 specifically; (b) close #334 and re-open a trimmed version. Preference: (a). The Phase-1 read-surfaces are independently useful and land under the 800-line review cap without the §3 material. This ADR cross-references #334's §3 as superseded; #334 gains a one-line reference to ADR-079. Resolved — option (a).Pre-approval misuse. How do we prevent
pre_approvedlists from growing to encompass risky actions? Proposal (§8): policy-file PRs require an ADR-level review and a second CODEOWNER signoff; CI enforces the ADR-link requirement in Phase 2. Resolved — §8.What about approvals proposed from outside dark-factory? E.g., an agent working in
observ-gyrum-iowants to roll back a deploy. Today: it opens an approval PR against dark-factory, linking to its own context. The substrate stays singular. Resolved — approvals are always in dark-factory.Rejection workflow. PRs closed without merging — do we move the file to
approvals/rejected/for audit, or is the closed PR record enough? Preference: move torejected/for parity with approved/executed — the directory shape tells the whole story; a closed PR in GitHub is not greppable on a fresh clone. Deferred to Phase 2 (move-on-close is a watcher concern).
References
- ADR-067 — playbooks unified primitive
- ADR-068 — playbook runtime
- ADR-069 — playbook security / destructive-approval pattern
- ADR-072 — infrastructure as playbooks
- ADR-077 — session-independent agent coordination layer (in flight, PR #334); this ADR supersedes §3
- ADR-078 — trigger-driven playbook orchestration
(defines the
approval-approvedtrigger kind this ADR uses) docs/decisions/proposals/cmd-server-split-proposal.md— a live example of the architectural-proposal tier pattern this ADR mirrors for operational decisions
Supersedes: partial — ADR-077 §3 (see §9 map) Superseded by: leave blank until a later ADR reverses this one