Skip to content

Approvals runbook

llms.txtlists every page for an agent
Optional: hand this page to your coding agentThe steps work by hand too.
Show the whole prompt
Read https://intentius.io/terragucci/guides/approvals-runbook/.
Run the "List the waves waiting" command with a read-only clone and tell me
each wave, digest and expiry. Do not revoke an approval, and do not run `terragucci override`.
Never apply, approve (a pull request review or `terragucci approve`), override a policy denial (`terragucci override`), use `--mode apply`, or merge; never touch `.chant/allowed_signers` or `chant/lifecycle`.

The commands an approver or on-call engineer runs. Approve a waiting wave walks through a first sign-off.

The ledger is _gates/tf-apply.jsonl on the chant/lifecycle branch: a "kind":"pending" line is a wave asking, any other line an approval. Each waiting wave’s report is at _gates/tf-apply/wave-<k>/<digest>.json. A wave that applies under an approval first marks it used in _gates/tf-apply/applied.jsonl; a used approval refuses no later plan.

approval An approval counts when Setup
ledger (default) it names the wave’s digest. Anyone who can push to chant/lifecycle can write one in anyone’s name; it records the plans and does not prove who approved. none
pr-review the merged pull request’s head was approved by a writer other than its author and the wave plans what the review saw, or, as under ledger, it names the digest none; optionally require terragucci/approval (GitHub, Forgejo) or an approval rule (GitLab)
sealed it names the digest and its seal verifies against the signers file at base Set up signers

terragucci config check prints the mode in force and where it comes from. A wave reads it at base, so a change to it governs the merges after it.

You have You want Do
identity.gates in chant.workspace.json, no approval key (sealed) to keep signing add approval: sealed to terragucci.yml
the same unsigned approvals add approval: ledger and run terragucci init, which drops the wave gates; or terragucci init --approval ledger
ledger sealed approvals add approval: sealed, run terragucci init, then set up signers

Only under approval: sealed, once per repo, in a reviewed pull request to the default branch.

  1. Add .chant/allowed_signers, one line per approver in ssh-keygen’s allowed_signers format; terragucci init --signer <principal> writes the first from git config user.signingkey. The first field is what the person passes as --actor:

    github:alice ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI...
  2. Set approval: sealed and run terragucci init. It lists each wave’s gate under identity.gates in chant.workspace.json; then an approval counts only when its seal verifies against the signers file at base (why).

  3. List only people in the file; leave out agents’ and CI jobs’ keys.

  4. Add or remove a person by pull request. The change first governs the apply of the next merge.

A waiting wave exits with code 3; its command is in the job log, the terragucci/apply status and the pull request’s plan note. Read the plans in the report, then, from a checkout:

Terminal window
npx terragucci approve wave-2 --plan <digest> --actor github:alice

It records an approval of the planned digest and restarts the waiting wave (--dry-run prints what it would approve instead). If the restart fails, re-run the job. Commenting /terragucci apply on the pull request also works (an open one needs apply.when: pull-request). On GitLab the comment needs comments: set, and the comments job answers it on its next run.

Under approval: pr-review, approve the pull request on its head before it merges; each gated wave then applies when it plans what the review saw (the steps).

Files changed > Review changes > Approve, with write access.

On GitHub and Forgejo, terragucci/approval on the head then turns success. A wave whose plans moved after the review exits 4 with the terragucci approve command for the new digest. A review’s ledger line carries "via": "pr-review" beside the pull request and its head and reviewers.

This needs policy.override at base.

  1. The denied wave exits 1. Its log gives the terragucci override command for each root the policy denied.

  2. Read the root’s plan and denial in the wave’s report.

  3. As a person the key lists, run the command in a checkout:

    Terminal window
    npx terragucci override envs/prod/app --rule main.deny_public_bucket \
    --reason "the incident needs the bucket public until 18:00" --actor github:alice

    --dry-run prints what it would override and records nothing.

  4. Re-run the wave’s job.

The wave then Because
applies the root, and its report names the override the override names this plan and these rules, and its author is listed at base
exits 1 again, naming why the override does not count its author is not listed, it gives no reason, or under sealed its seal does not verify
exits 4 and applies nothing the plan or its rules changed before any run applied the override; the next run records the new denial to override
exits 1 with a new terragucci override command a run applied the override, and the root’s plan changed since: the new plan needs its own override

The ledger is _gates/policy-override.jsonl, one gate per root. Overrides that went out are listed in _gates/policy-override/applied.jsonl, so a changed plan of that root asks for a fresh override. Revoke an override like an approval, by deleting its line before the wave runs.

This prints each gate whose newest pending record has no approval of its digest after it:

Terminal window
git fetch origin chant/lifecycle
git show origin/chant/lifecycle:_gates/tf-apply.jsonl | jq -s -r '
. as $all
| ($all | map(select(.kind == "pending")) | group_by(.gate) | map(max_by(.timestamp))[]) as $p
| select(($all | map(select(.kind != "pending" and .gate == $p.gate and .planDigest == $p.planDigest and .timestamp >= $p.timestamp)) | length) == 0)
| "\($p.gate)\t\($p.planDigest)\texpires \($p.expiresAt)\t\($p.description)"'

A line past its expires time was recorded more than 48 hours ago; the wave’s next run records a fresh one.

Every approver can push to chant/lifecycle. Remove the approval’s line in a normal commit; it works with force pushes blocked (branch protection).

  1. Clone the branch:

    Terminal window
    git clone --branch chant/lifecycle --single-branch <your repo url> lifecycle
    cd lifecycle
  2. Delete the approval’s line from _gates/tf-apply.jsonl.

  3. Push the commit:

    Terminal window
    git commit -am "revoke the approval of wave-2" && git push origin chant/lifecycle

The wave waits again; the old line stays in history.

A wave whose plans moved after an unapplied approval applies nothing and exits 4. The job names the moved roots; terragucci respond wave-refused prints what changed. Approve the new digest or revert. New plans after an applied approval wait for their own instead.

A revert does not revive the old approval: the refused run recorded a newer pending fact, so the wave needs a fresh terragucci approve. See Fix a refused wave.

Record Expires Stops counting when
Pending fact 48 hours after the first run recorded it; the next run after that records a fresh one. It is replaced by a newer pending fact for the gate.
Approval Never. It is older than the gate’s newest pending fact, or names a digest the wave no longer plans (Fix a refused wave); or, under sealed, its approver leaves .chant/allowed_signers, for merges made after that. Once a run applied under it, it refuses nothing.
Policy override Never. It is older than the root’s newest recorded denial, or names another plan or other rules; or its author leaves policy.override, or under sealed the signers file, for merges made after that.

terragucci

These docs count page views and clicks with PostHog. They set no cookies, store nothing in your browser, and send nothing when your browser asks not to be tracked.