The audit trail
Optional: hand this page to your coding agentThe steps work by hand too.Show the whole prompt
Read https://intentius.io/terragucci/reference/audit-trail/.
Download audit.jsonl from the top of my reports prefix with a read-only identity, run the queries
under "Query the record", and tell me, per project, every approval with its approver, plan digest
and time, every policy override with its reason, every refused wave and every failed apply in the
last 30 days, each with its evidence link.
Read only: do not run `terragucci audit` without `--check`, and write nothing to the bucket.
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`.terragucci audit writes the record as JSON lines in your reports bucket. It reads the approval ledger on chant/lifecycle in each repo and the wave reports in the bucket; no terragucci server or account holds a copy.
Write it
Section titled “Write it”-
Run it where the estate page job runs, before
terragucci estate:Terminal window npx --yes @intentius/terragucci auditnpx --yes @intentius/terragucci estateRun from Projects Ledger read from Reports read from Record written to a control repo its projects:each project’s repo: its url, elsehttps://<key>, with the forge token when the job has oneeach project’s reportsdefaults.reportsone repo the checkout’s own originthe repo’s reportsthe same bucket -
Give the job what it reads. On top of the estate job’s identity:
Reads Why chant/lifecycleof each project, with its historyevery approval, override and revocation <prefix>/<project>/**/tf-apply-wave-*/report.jsonevery apply and its result, every refused wave <prefix>/audit.jsonl, and writes it,audit.htmlandaudit.jsonthe record, its page and its summary The forge token is the project’s
token_env, elseGITHUB_TOKEN,GITLAB_TOKENorFORGEJO_TOKEN, with read access to the repo. -
Read the log:
audit: 214 entries, 3 added2026-10-08T09:12:44.000Z github.com/acme/network approval wave-2 by dana sha256:4be1...: unsigned2026-10-08T09:20:03.000Z github.com/acme/network apply wave-2 by dana sha256:4be1...: applied2026-10-08T10:01:17.000Z gitlab.example.com/platform/data refused wave-1 by lee sha256:9c07...: changed-after-approvalwrote terragucci-audit/audit.jsonl, terragucci-audit/audit.html, terragucci-audit/audit.jsoncopied to s3://acme-terragucci/reports/audit.jsonl, reports/audit.html, reports/audit.jsonlink, until 2026-10-09T10:05:00.000Z:https://acme-terragucci.s3.us-east-1.amazonaws.com/reports/audit.html?X-Amz-Algorithm=AWS4-HMAC-SHA256&...A project whose ledger or index cannot be read makes the job exit 1 and is named on the page. The entries it could read still land.
The estate page links audit.html once audit.json is beside it, and its resource history names each apply’s approver from the record. Flags are on CLI commands.
Storage
Section titled “Storage”| Object | What it is |
|---|---|
<prefix>/audit.jsonl |
the record: one entry per line, oldest first within each run’s additions |
<prefix>/audit.html |
the page: counts by kind, each project’s ledger state, the newest 1000 entries with their evidence links |
<prefix>/audit.json |
the summary, terragucci.audit-summary/v1: how many entries, how many the last run added, counts by kind, and each project’s ledger state |
Entry fields
Section titled “Entry fields”Every line is terragucci.audit/v1 (JSON Schema in the package as dist/audit.schema.json), and holds these fields in this order:
| Field | What it holds |
|---|---|
schema |
terragucci.audit/v1; a new field never changes an existing one |
id |
sha256: over where the entry came from: the ledger line, or the report’s path and finish time |
kind |
what happened; see the next table |
project |
<host>/<path> of the repo |
at |
when: the ledger line’s timestamp, the commit that removed a line, or the run’s finish |
who |
the approver, the overrider, whoever removed the line, or for an apply the approver it applied under; null when nobody did |
what |
the gate: wave-<k>, a migration’s name, or for an override or a lock’s release the root |
digest |
the plan digest: the wave’s set digest, or the digest an override binds; null when a root failed to plan |
result |
the outcome; see the next table |
evidence |
source (ledger or report); for the ledger branch, path and commit; for a report bucket, key and job_url; url, the commit’s page or the run’s report.html, when it is known |
detail |
what else the source says, by kind |
Kinds and results
Section titled “Kinds and results”kind |
From | result |
detail |
|---|---|---|---|
approval-requested |
a pending line in _gates/tf-apply.jsonl, _gates/tf-migrate.jsonl for a migration, _gates/tf-unlock.jsonl for a state lock’s release, _gates/tf-state-export.jsonl for a state export, or _gates/tf-ephemeral.jsonl for a pull request’s ephemeral copy |
waiting |
expires, run_id, description, roots |
approval |
an approval line in _gates/tf-apply.jsonl, _gates/tf-migrate.jsonl, _gates/tf-unlock.jsonl, _gates/tf-state-export.jsonl or _gates/tf-ephemeral.jsonl |
unsigned, sealed or review |
signer (sealed), via, pr, head, reviewers (review), relayed_by, committed_by |
approval-revoked |
an approval line a commit removed | revoked |
approved_by, approved_at, signer (sealed) |
override-requested |
a denial a wave recorded in _gates/policy-override.jsonl |
denied |
expires, run_id, description, rules, plan_digest |
override |
an override line in _gates/policy-override.jsonl |
unsigned or sealed |
reason, rules, plan_digest, signer, relayed_by, committed_by |
override-revoked |
an override line a commit removed | revoked |
approved_by, approved_at, signer (sealed) |
apply |
a tf-apply wave report |
applied, failed or waiting |
wave, commit, roots, gate, approval (the id of the approval entry it applied under), changes, failed, overrides, pull_request, state_versions (each root’s state location, version_id and versioning) |
migration |
a line in _gates/tf-migrate/done.jsonl, written when a state migration wrote its states |
applied or failed |
roots (each root’s location and state version before and after, and both digests), file_digest, error, commit, run_id |
unlock |
a line in _gates/tf-unlock/done.jsonl, written when terragucci unlock-state released a state lock; who is who released it and what the root |
released |
location, lock_id, operation, locked_by, locked_at (the lock as the binary wrote it), approved_by, approved_at, commit |
state-export |
a line in _gates/tf-state-export/done.jsonl, written by terragucci state export before it writes the file; who is the person who exported, what the root |
exported |
location, version_id, approved_by, approved_at, content_digest (the digest of the bytes written, never the bytes) |
ephemeral-apply |
a line in _gates/tf-ephemeral/done.jsonl, written when a pull request’s ephemeral copy applied; who is the forge’s actor for the job, what the gate pr-<n>, digest the copy’s plans |
applied or failed |
pull_request, suffix, roots (each root’s state location and result), expires, approved_by, commit, run_id |
ephemeral-destroy |
a line in _gates/tf-ephemeral/done.jsonl, written when a copy was destroyed; digest is the destroy plans’ |
destroyed or failed |
pull_request, suffix, roots, reason (closed or expired), commit (the code the destroy ran), run_id |
refused |
a tf-apply wave report’s waves[].refused |
changed-after-approval, changed-after-review, changed-after-override, denied-by-policy, held-by-another-apply or superseded-by-a-newer-push |
wave, commit, roots, pull_request, approved (the digest approved), moved or denied (the roots), rules (by root, for a denial), holder (the run applying them, for held-by-another-apply) |
result on an approval says how it was signed. Whether it counted is the wave’s decision under the approval: mode at base, and the apply or refused entry of that wave records it.
| The entry says | What it proves, by mode |
|---|---|
who on an approval |
ledger: the name the approver gave; anyone who can push to chant/lifecycle can write any name, and detail.committed_by is the commit’s author |
detail.signer |
sealed: the seal’s signer; the wave checks it against .chant/allowed_signers at base |
detail.via, pr, reviewers |
pr-review: the apply job recorded a review of that head by a writer other than the author |
detail.relayed_by |
the line’s relayedBy: a service, such as a chat bot, wrote it for the person who names. ledger and pr-review: the relayer’s word that who approved; the line counts like any other, and committed_by is the account that pushed it. sealed: the seal still decides; the line counts only when its seal verifies for who, and the seal covers the relayer’s name |
Completeness
Section titled “Completeness”| Property | How |
|---|---|
| Append-only | each run reads audit.jsonl, keeps every line, and appends the entries whose id it lacks; the write is conditional on the copy read, so two runs at once both land |
| Re-derivable | the ledger is read from its git history every run, and the same records give the same ids; a new record built from the same sources holds the same entries |
| Checked | terragucci audit --check builds the entries again, writes nothing, and exits 1 naming each one the record lacks, such as an approval on the ledger after the last run |
| Revocations kept | removing a line from the ledger revokes it before its wave runs; the record keeps the approval and adds an approval-revoked entry with the commit that removed it |
| Replaced reports | a second run of the same wave on the same commit replaces its report in the bucket; the record keeps the entry of the first run when an audit ran between them, so schedule the audit at least as often as waves run |
Query the record
Section titled “Query the record”jq -r 'select(.kind == "approval") | [.at, .project, .what, .digest, .who, .result] | @tsv' audit.jsonljq -r 'select(.kind == "override") | [.at, .project, .what, .who, (.detail.rules | join(",")), .detail.reason] | @tsv' audit.jsonljq -r 'select(.kind == "refused" or .result == "failed") | [.at, .project, .what, .result, .evidence.url // .evidence.key] | @tsv' audit.jsonljq -c 'select(.project == "github.com/acme/network" and .what == "wave-2") | {at, kind, who, digest, result}' audit.jsonljq -r 'select(.kind == "apply" and .result == "applied" and .detail.gate == "approved" and .detail.approval == null) | [.at, .project, .what, .digest] | @tsv' audit.jsonlFetch the record with the store’s own CLI, such as aws s3 cp s3://acme-terragucci/reports/audit.jsonl ., or open the link the job prints for the page.
The records it reads
Section titled “The records it reads”| Record | Where | What it says | Written by |
|---|---|---|---|
| A wave asking for approval | _gates/tf-apply.jsonl on chant/lifecycle, a "kind":"pending" line |
the wave, the plan digest it asks for, when, and when the request expires | the apply job, when the wave waits |
| An approval | _gates/tf-apply.jsonl, any other line, in a commit of its own |
the wave, the plan digest it binds, who (resolvedBy) and when (timestamp) |
terragucci approve; under approval: pr-review, the apply job |
| A policy override | _gates/policy-override.jsonl |
the root, its plan digest, the rules it overrides, who, when and the reason | terragucci override |
| A run’s report | the bucket, under one path per commit and stage | every root’s plan digest and changes, each wave’s set digest, approval state and, when it applied nothing, why (waves[].refused) |
every tf-plan, tf-apply wave and tf-drift run |
| The index | index.json at the project’s path |
one row per run; terragucci audit reads its tf-apply rows to find the reports |
every upload |
A wave’s report names its approval record’s branch and path (waves[].gate) and never copies it. Report JSON schema has every field. Without a bucket, each report is a CI artifact of its job and terragucci audit has nothing to read; Keep reports in a bucket sets one up.
Matching an apply to its approval
Section titled “Matching an apply to its approval”| Step | Read | Match |
|---|---|---|
| 1 | the apply entry: digest, detail.commit |
the digest the wave applied, and the commit it applied from |
| 2 | detail.approval |
the id of the approval entry whose what and digest it applied under |
| 3 | that entry’s evidence.commit |
the commit on chant/lifecycle that added the approval line |
A wave whose plans moved after approval applied nothing and exited 4: its entry is refused, with the digest approved and the roots that moved (Fix a refused wave).
Retention
Section titled “Retention”| Record | Kept |
|---|---|
| The audit record | your bucket’s lifecycle rule; leave audit.jsonl out of any expiry |
| The ledger | as long as the chant/lifecycle branch; every change is a commit. Protect it against force pushes and deletion (per forge) |
| Reports and the index in a bucket | your bucket’s lifecycle rule; an index keeps its newest 500 rows and the newest of each stage and wave |
| Traces and metrics | your telemetry backend’s retention (Traces and metrics) |
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.