Decisions

ADR-079: Dark-factory as the operational-approval substrate

> **Supersession note.** This ADR **supersedes ADR-077 §3** — the > `~/.gyrum/proposals/`, `~/.gyrum/approvals/`, `~/.gyrum/actions/` > journals and the paired `gyrum-propose` / `gyrum-approve` / > `gyrum-action` CLI…

#079

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 paired gyrum-propose / gyrum-approve / gyrum-action CLI verbs. Everything else in ADR-077 (Phase 1 read-surfaces gyrum-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

  1. ~/.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/humans files (already Open question 1 in ADR-077), or a service (overbuilt). Dark-factory, as a git repo cloned everywhere, is already the shared substrate.

  2. gyrum-approve as a shell command is trivially bypassable. ADR-077 §10 acknowledged this openly: "An adversarial agent can unset 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.

  3. 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.

  4. 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.

  5. The audit trail is git, everywhere. "Who approved this?" is git log approvals/approved/<slug>.yml. "What fired?" is the same file's executed_at trailer. 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:

  1. 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.
  2. 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.
  3. 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:

  1. CODEOWNERS on /approvals/pending/* lists the humans allowed to approve. Starting value:

    /approvals/pending/       @jon
    /policies/approval-classes.yml   @jon
    

    An 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).

  2. Required reviews on the main branch 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 target main.

  3. Agent identity in commit trailers. Commits that add files under approvals/pending/ MUST carry the agent ID in a Co-Authored-By trailer (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/ or docs/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 main branch. 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 small gh-new-approval shell helper in Phase 2 makes the proposal one command.
  • Merge-commit rationale is less structured than a dedicated --rationale flag. Mitigation: PR template enforces a rationale section in the body; the watcher copies it to a rationale: field on the YAML at merge time.
  • policies/approval-classes.yml becomes 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.yml as 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

  1. 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.

  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.

  3. Migration path for ADR-077 PR #334. Options: (a) land #334 as-is (its Phase 1 MVP — the in-flight + findings-unread commands — 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).

  4. Pre-approval misuse. How do we prevent pre_approved lists 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.

  5. What about approvals proposed from outside dark-factory? E.g., an agent working in observ-gyrum-io wants 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.

  6. 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 to rejected/ 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-approved trigger 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