Approvals runbook
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`.Result
Section titled “Result”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.
Choose a mode
Section titled “Choose a mode”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 |
Set up signers
Section titled “Set up signers”Only under approval: sealed, once per repo, in a reviewed pull request to the default branch.
-
Add
.chant/allowed_signers, one line per approver in ssh-keygen’s allowed_signers format;terragucci init --signer <principal>writes the first fromgit config user.signingkey. The first field is what the person passes as--actor:github:alice ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... -
Set
approval: sealedand runterragucci init. It lists each wave’s gate underidentity.gatesinchant.workspace.json; then an approval counts only when its seal verifies against the signers file at base (why). -
List only people in the file; leave out agents’ and CI jobs’ keys.
-
Add or remove a person by pull request. The change first governs the apply of the next merge.
Approve a wave
Section titled “Approve a wave”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:
npx terragucci approve wave-2 --plan <digest> --actor github:alicenpx terragucci approve wave-2 --plan <digest> --actor github:alice --sign ~/.ssh/id_ed25519It 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.
Approve by review
Section titled “Approve by review”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.
As a Developer or higher, approve the merge request after its last push. An approval before a push counts for nothing.
Files changed > Review > Approve, as an official reviewer.
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.
Override a policy denial
Section titled “Override a policy denial”This needs policy.override at base.
-
The denied wave exits 1. Its log gives the
terragucci overridecommand for each root the policy denied. -
Read the root’s plan and denial in the wave’s report.
-
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:aliceTerminal 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 --sign ~/.ssh/id_ed25519--dry-runprints what it would override and records nothing. -
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.
List the waves waiting for an approval
Section titled “List the waves waiting for an approval”This prints each gate whose newest pending record has no approval of its digest after it:
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.
Revoke an approval before its wave runs
Section titled “Revoke an approval before its wave runs”Every approver can push to chant/lifecycle. Remove the approval’s line in a normal commit; it works with force pushes blocked (branch protection).
-
Clone the branch:
Terminal window git clone --branch chant/lifecycle --single-branch <your repo url> lifecyclecd lifecycle -
Delete the approval’s line from
_gates/tf-apply.jsonl. -
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.
Recover from a refusal
Section titled “Recover from a refusal”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.
Expiry
Section titled “Expiry”| 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. |
- Waves and approvals
- Gate policy
- Threat model: what each approval mode stops
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.