The audit trail
This page answers the auditor's question about a compliance.tf organization: who changed what an environment enforces, when, and under which exceptions. It is written for the auditor or assessor reading the evidence and for the admin who retrieves it. By the end you will know what a record shows, how to get the records out, and where the trail stops. A second question, which module versions your pipelines were actually handed and when, is answered by download records, a separate per-download record available to organizations that have it enabled.
- A fixed set of acts produce a record: project changes, binding, promoting, rolling back, waiver decisions, publishing, approving, and the coverage-blocking setting.
- A record carries five things: who, what act, what was acted on, a small detail payload, and when. There is no request id, no source address, no client identifier.
- Each record is written in the same atomic operation as the change it describes.
- The trail records successful changes only. A refused promotion, a denied permission, and a read of any kind leave no trace.
- Records are kept for 90 days, then removed. The trail cannot be exported from the app today, so if your framework needs a longer record, keep your own copy and treat it as the system of record.
What the evidence shows
An auditor's question is almost always about a past date: what was live on the day of the incident, who put it there, and what exceptions were open at the time. Compliance.tf answers the what with immutable snapshots, described on Configs and snapshots. The audit trail answers the who, the when and the which. Between them, a past state can be reconstructed: the trail tells you which snapshot an environment was pointed at on a given date, and the snapshot still holds exactly what that meant.
The trail records decisions and pointers, not content. A publish record names the snapshot that was created, not the control set inside it. A promotion record names the snapshot that was promoted, not what changed as a result. The content is not duplicated into the trail because the object it points at cannot change.
What produces a record
Every act that changes what an environment serves, or changes the governance around it, writes a record:
- Structural changes to a project: creating one, archiving one, replacing its module aliases.
- Changes to what an environment serves: binding a snapshot, promoting one, rolling back to the previous one.
- Waiver decisions: proposing an exception, approving it, revoking it.
- Authoring milestones: publishing a draft into a snapshot, approving a snapshot.
- One organization-level setting: whether uncovered framework coverage blocks a promotion or only warns.
Editing a draft writes no record, and neither does creating a config: the publish is the moment that gets recorded. Membership, roles, registry tokens and operational rule edits are not in this trail either; see What it does not cover.
What a record contains
| Field | What it holds |
|---|---|
| Actor | The stable subject identifier of the authenticated caller. Never a token, a service, or an inferred identity: every recorded act is carried out by a signed-in person. |
| Action | The name of the act, from a fixed set. |
| Target | The thing acted on. The project and environment for a bind, promote, rollback or waiver decision; the project alone for project-structural changes; the snapshot for a publish or approval; the organization for the coverage setting. |
| Detail | A small payload specific to the action: identifiers and flags, not content. Sometimes empty. |
| Time | When the act happened, to microsecond precision, in UTC. |
When the trail is read back, the actor's email address is joined in where it can be resolved from current organization membership. That join is a display convenience; the identifier is what is stored.
How it is produced
The record and the change it describes are written as one operation that either fully succeeds or fully fails, for every action in the catalogue. There is no window in which an environment is repointed without a corresponding record, and no record describing a change that did not land. If a promotion is refused because the binding moved underneath it, the promotion and its record are both discarded together.
No product action edits or deletes a record. There is no amend, no correction, no redaction and no delete, for an owner or anyone else.
Removal happens only through retention: records are kept for 90 days, then the datastore expires them. Nothing in the product can shorten retention or extend it for an individual record.
Retrieve or export it
Reading the trail requires the audit:read permission. owner and admin hold it. member does not. The approver capability does not grant it. See Access control.
- Read it back by window. The trail is read in whole calendar months counting back from the current month, newest first. The whole window comes back in one response.
- Recent activity. The governance summary shows the most recent records alongside pending approvals and expiring waivers. A member without
audit:readsees that summary with the recent-activity section empty. - There is no export from the app today, and no scheduled delivery. To keep records beyond the 90 days, have someone with
audit:readread the trail on a regular cadence and store the result in the system you already use for long-lived evidence. Capture the actor-to-person mapping while those people are still members: the email join is resolved from current membership, so a departed member later appears as a bare identifier.
A member can carry out acts that write records without being able to read them. If your control narrative depends on an engineer reviewing their own history, plan for someone with the permission to pull it for them.
What it does not cover
Each of these is something a reader may reasonably assume is captured:
- Failed and refused acts. A promotion the gate blocked, a request rejected for lack of permission, a bind that lost a concurrency race: none of these write anything.
- Warnings. A promotion that passed with a
warnwrites an ordinary record with no trace of the warning. Capture it from the promotion preview. - Reads. Nobody's viewing of a snapshot, an environment, or the trail itself is recorded. Module downloads are not in this trail either: each download gets its own record in a separate store, described in Download records.
- Draft edits and config creation. What was in a draft before a publish is not recoverable from the trail.
- Membership, roles and invitations. Adding a member, changing a role, granting or removing the approver capability, and removing someone are not in this trail.
- Registry tokens. Minting or revoking a token writes nothing here. A download made with a token appears in download records. Tokens carry their own creator attribution; see Registry tokens.
- Operational rule edits. Changes to the Baseline and named rulesets, and to the rules state (Disabled, Preview, Enforced), write no record. Those rules are live-edited with no version or approval step; what a given build received is recorded in the module's manifest instead, by ruleset name and content hash. See Rulesets in the console. Rules inside a config reach an environment only through a snapshot, whose publish, approval and promotion are recorded.
- Plan and billing events. Trials, grace, purchases and cancellations are not in the trail.
- Request context. No source address, user agent, request id or session identifier is stored. You cannot tell from a record where an act came from.
- Most organization settings. Exactly one setting change is recorded: coverage blocking.
- The frozen waiver set. A promotion record names the snapshot, not the waivers it froze into the binding. The binding holds only its current set, so the set in force on a past date is recoverable only while the environment has not been re-bound since.
Download records
The change trail above says what an environment was set to serve. Download records say what the registry actually handed out: one record each time it answers a terraform init for your organization with a module download. The record names the caller, the module and version, the host and binding addressed, and the posture the module was built to. They live in their own store, with their own retention and their own export.
The export of download records is enabled per organization by arrangement with us, not by plan tier. Where it is not enabled, the export answers 403 and the console does not show the panel. Recording started on 7 November 2026; earlier downloads have no record.
What a record proves
A record proves that the registry issued a download URL for that module variant to that caller. It does not prove the bytes were fetched. Terraform fetches the archive from storage in a second request that runs none of our code. Read a record as "download served (URL issued)", not as "module installed".
A record is written for every download URL issued:
- on your organization's own host (
bobthecorp.compliance.tf), whatever the credential; - on a framework host such as
soc2.compliance.tf, or the main registry host, when the credential belongs to your organization: an organization token, or a member's own credential whose access comes through your organization.
No record is written when:
- the request is refused or fails, so no URL is issued. That includes a
503while a first build is in progress; the retry that succeeds writes the record; - the credential resolves to no organization;
- Terraform reuses a module already in its local
.terraform/modulesdirectory and never asks the registry.
A terraform init writes one record for each module download it asks the registry for. Every init that reaches the registry writes its own records.
Fields
A record holds the values below. A field marked always is present on every record, as null when there is no value. The others are left out when empty.
| Field | What it holds |
|---|---|
ts | Always. When the registry answered, in UTC, as YYYY-MM-DDTHH:MM:SS.ffffffZ. Fixed width, so it sorts correctly as text. |
request_id | Always. The registry's id for the request. One record per id: use it to drop duplicates when export windows overlap. |
org_id | Always. Your organization's id. |
host_kind, host | Always. org (your organization's host), framework (a framework host) or canonical (the main registry host), and the host name. host is null on the main host. |
project, env | Present only when the request addressed a project and environment on your organization's host. |
module | Always. namespace/name/provider, for example terraform-aws-modules/s3-bucket/aws. |
requested_version, resolved_version | Always. The version in the request, and the version the registry resolved it to. |
module_segment, alias_ids_digest, alias_map_digest | Present when the request went through a project module alias: the alias segment, and digests of the alias set and map in force. |
snapshot_id, snapshot_config_id | Always. The snapshot and its config the module was built to, when a binding supplied the posture; null otherwise. |
posture_source | Always. Whether your organization's rules were read: found, found_no_rules, missing, or degraded (they could not be read, and the module was served without them). |
degraded_reasons | Why the posture was degraded, when it was. |
framework, enforced_frameworks | The framework the request named, and the frameworks the posture enforced. |
variant_version, variant_hash, variant_module_id, base_module_id | Always. The identity of the built variant you were served, and of the upstream module it was built from. |
pofix_corpus | Always. The build corpus the variant was produced with. |
served | Always. cache (an existing build was served) or build (built for this request). |
ruleset_name, ruleset_hash | The operational ruleset applied, by name and content hash, as in the module's manifest. |
query_overrides | The rule and control selections made in the source URL query, stored under rules, add_rules, enable and disable. |
principal_type | Always. user (a person), org (an organization token) or admin (compliance.tf operator access). |
token_type | Always. custom (a registry token) or jwt (a short-lived sign-in token). |
sub | Always. The subject identifier the registry authenticated. |
actor_sub, actor_source | Always. The person behind the call: the owner of a personal token (owner_sub) or the signed-in user (jwt_sub). null with none for an organization token, or a personal token with no recorded owner. |
token_id | Always. The first 16 hex characters of the stored hash of the registry token. It changes when the token is replaced. null for a sign-in token. The token itself is never recorded. |
token_name | Always. The name the registry token was given when it was minted. null for a sign-in token or a token with no name. |
token_created_by_sub | For an organization token, the member who minted it. |
tier | Always. The plan entitlement the download was served under. |
tool_kind, tool_version | Read from the client's User-Agent: terraform, opentofu or other, and the version. Reported by the client, not verified. |
client_ip_prefix | The network the request came from: the IPv4 /24 or IPv6 /48, for example 203.0.113.0/24. Present on downloads through the registry API (/v1/modules/...) from 8 November 2026. Part of the hash. |
schema_version | Always. 3 for records written from 8 November 2026, 2 before. |
record_sha256 | Always. The SHA-256 of the record; see Verify a record. |
ExpiresAt | Always. The end of the record's retention, as Unix epoch seconds. Part of the pre-image only in schema version 2. |
Three names appear in the export's column list without being stored fields. actor_email is filled in at export time from actor_sub, for current members only; it is blank for anyone who has left. client_ip is the full IP address of a download through the registry API. It is kept apart from the record, outside its hash, for 90 days from the download, so the column is blank for older downloads and in verification exports. served_sha256 is not written today, so its column is empty.
An export that includes client_ip or client_ip_prefix contains your members' IP addresses. You are responsible for how you use and share it.
A record holds no plan, state, source code or credentials.
Retention and immutability
Records are kept for 90 days from the download, or for the period set in your organization's agreement, up to 6 years, then removed. The period is fixed when the record is written. The full client IP address is kept for 90 days from the download whatever the record's period; the record keeps only client_ip_prefix. No product action edits or deletes a record. Like the change trail, removal at the end of retention is asynchronous, so a record slightly past its age can still appear in an export.
How records reach the store
The registry builds the record and puts it on a queue before it answers Terraform. Then a separate writer stores it. A record normally appears in an export within a minute of the download, later while the queue is catching up after a burst.
If the queue cannot be reached, the registry writes the full record to its log instead and still serves the download. Those log lines are replayed into the queue automatically. A record is stored at most once per request_id, so a replay never creates a duplicate. A record that fails the writer's checks is set aside for our review, not stored and not dropped.
Recording never blocks a download, so a record can be missing. In the rare case that one cannot be built, or that both the queue and the log replay fail, the download is served without one. We are alerted in most of these cases, but a record lost on both paths can go unnoticed. Treat download records as the registry's account of what it served, not as a guarantee that every download left a row.
Export from the console
The organization's Activity log page has a Download records panel below the change log. It is shown to members holding audit:read (owner and admin) in organizations with download records enabled.
- Pick From and To dates. They are UTC days. A range can span up to 13 months. The default is the last 30 days.
- Pick a format: CSV, JSONL, or Verification (JSONL), which writes every record field so you can recompute hashes.
- Optionally open Columns and choose which fields to export. Save as organization default stores the choice for everyone in the organization. Standard columns goes back to the standard set.
- Select Download. The browser fetches every page and saves one file. A file over 200 MB is refused; pick a shorter range.
The standard set is ts, request_id, module, requested_version, resolved_version, project, env, host, principal_type, token_type, actor_sub, token_name, client_ip, client_ip_prefix, served and record_sha256.
Export through the API
GET https://app.compliance.tf/api/orgs/{org_id}/downloadsThe API takes a signed-in session, the same as the console, and needs audit:read. Registry tokens are not accepted; see Registry tokens. GET https://app.compliance.tf/api/orgs lists your organizations with their ids.
| Parameter | Meaning |
|---|---|
from, to | YYYY-MM-DD or an ISO 8601 timestamp; UTC when no offset is given. A date-only to covers that whole day. Both inclusive. Up to 13 months apart. Defaults: to is now and from is 30 days earlier. |
format | json (default), jsonl or csv. |
fields | Comma-separated column names. Without it, the organization default applies, else the standard set. An unknown name returns 400. |
profile | verification: every record field, columns ignored. Requires format=jsonl. |
project, env, module | Exact-match filters, applied on the server. module is namespace/name/provider. |
cursor | The cursor from the previous page. Requires explicit from and to, unchanged from the first request. |
Rows come back oldest first. A page holds up to 1,000 rows, and fewer when it reaches a size or time limit. With json the body is {"rows": [...], "cursor": ...}. With jsonl and csv the body is the page, and the next cursor is in the X-Next-Cursor header. Repeat the request with the cursor until it comes back null or the header is absent. A page can be empty and still carry a cursor. CSV carries its header row only on the first page, so the pages concatenate into one file.
In CSV, nested values are JSON-encoded in one cell, and a value starting with =, +, -, @, a tab or a line break gets a leading ' so a spreadsheet does not run it as a formula. CSV is for reading; recompute hashes from the verification profile.
| Status | Meaning |
|---|---|
400 | A parameter is invalid: a bad date, a range over 13 months, an unknown field, or a cursor that does not match the request. |
401 | No signed-in session. |
403 | The caller lacks audit:read, or download records are not enabled for the organization. |
500 | A verification export met a record it cannot write as a pre-image. Contact us with the message. |
503 | The store is temporarily unavailable. Retry the same request. |
Verify a record
record_sha256 is the SHA-256, in lowercase hex, of the record's pre-image: every field of the record except record_sha256 itself (the store's own keys are not part of the record and are never exported; from schema version 3, ExpiresAt is left out too, so the retention period is not sealed into the hash), as JSON with sorted keys, , and : separators, ASCII escaping, encoded as UTF-8. Fields marked always are in the pre-image even when null; the others are left out when empty. All numbers are integers. actor_email is not part of the record and never part of the pre-image.
An export with profile=verification&format=jsonl, or Verification (JSONL) in the console, writes each record as exactly that pre-image plus record_sha256, so a schema version 3 line carries no ExpiresAt. This script recomputes every hash in such a file and exits 1 on a mismatch:
import hashlib, json, sys
n = bad = 0
for n, line in enumerate(open(sys.argv[1], encoding="utf-8"), 1):
record = json.loads(line)
claimed = record.pop("record_sha256")
canonical = json.dumps(record, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
if hashlib.sha256(canonical.encode("utf-8")).hexdigest() != claimed:
print(f"line {n}: hash mismatch for request_id {record.get('request_id')}")
bad += 1
print(f"{n - bad} ok, {bad} mismatched")
sys.exit(1 if bad else 0)python3 verify.py downloads-bobthecorp-2026-11-01-2026-11-30-verification.jsonlAttribute downloads to a pipeline or a person
Give each pipeline its own organization token, named for that pipeline. Its records then carry that token_name and token_id, and you can tell one pipeline's downloads from another's. Pipelines that share a token are indistinguishable in the records. This is the same advice as on Registry tokens: one token per consumer, named for its consumer.
Replacing a token changes token_id. A token's name cannot be changed after it is minted, so give a replacement the same name if you want its records to line up with the old ones.
For people, count by actor_sub. It stays the same when someone replaces their personal token. token_created_by_sub answers "downloads made with tokens this admin minted". If your evidence has to name people, capture the actor_sub-to-person mapping while they are still members, because actor_email is resolved from current membership only.
Example: what production pulled before a promotion
The question: snapshot X was promoted to the prod environment of the payments project. Before that moment, which version of each module did each pipeline last download, and built to which snapshot?
-
Take the promotion time from the
promoterecord forpayments/prodin the change trail, for example2026-11-20T14:03:11.482913+00:00. -
Export the downloads up to that moment, with the columns the question needs:
GET https://app.compliance.tf/api/orgs/{org_id}/downloads?project=payments&env=prod&from=2026-10-01&to=2026-11-20T14:03:11.482913%2B00:00&format=jsonl&fields=ts,module,resolved_version,snapshot_id,token_name,request_idWalk the cursor until the last page and save the pages as
before.jsonl. -
Keep the newest row per module and pipeline:
jq -s 'group_by([.module, .token_name]) | map(max_by(.ts)) | sort_by(.ts) | .[]' before.jsonl
Each row printed is the last download of that module by that pipeline before the promotion, with snapshot_id naming the posture it was built to. Rows with a null token_name came from signed-in people or from tokens with no name. To see which pipelines have not run terraform init since the promotion, export from the promotion time to now and compare the token_name values in the two files.
What download records do not do
- They are not signed or chained.
record_sha256lets you check that a record has not changed since the registry produced it, for example in your own copy. It is not a signature: anyone able to rewrite a record could recompute its hash. Records are not linked to each other, so a missing record leaves no gap you can detect from the others. - They do not show what was installed or deployed. A record shows that a URL was issued, not that the archive was fetched, used in a plan, or applied.
- They cover module downloads only. Version listings, the console and the platform API leave no download record.
- They do not replace the change trail. Who bound or promoted a snapshot is in the change trail, kept for 90 days; download records only name the snapshot a download was built to.
What this is, and is not, as evidence
What the records demonstrate. For any act in the catalogue, the trail shows that a specific identified person carried out a specific act against a specific object at a specific time, and that the record was created inseparably from the change itself. Combined with the immutability of snapshots, that supports a reconstruction of what a given environment enforced on a given date and who put it there.
What they do not do. These records do not, on their own, satisfy any named control or clause in any framework, and compliance.tf does not claim they do. Whether a given control's requirements are met by this trail, with your retention arrangements, your access model and your process around it, is your auditor's determination.
Where the boundary sits. This trail covers changes to compliance posture inside compliance.tf. It is not a change-management system for your infrastructure, not an access log, and not a security monitoring feed. It is also distinct from what the modules and the provider produce about your AWS resources, covered in Audit evidence: the modules evidence what your infrastructure enforced, this trail evidences who decided it should.
Separation of duties. If the records show the same person publishing and approving a snapshot, or publishing and promoting it, separation of duties was not enforced for that act. It is off by default and enabled with our help; see Access control.
Per-framework notes
Frameworks that ask for change records or approval evidence, such as SOC 2's change-management criteria or the audit-logging requirements in PCI DSS, are where auditors most often ask for this trail. The trail is not mapped to any framework clause. The generated framework pages, for example SOC 2, PCI DSS v4.0 and HIPAA Security Rule, list which controls compliance.tf modules enforce for each clause; read them for the infrastructure side, and use this trail for the governance side.
Operating details
The rest of this page is the operator's half: the complete action catalogue with the target and detail each record carries, how to query a window, the precise limits on what the records can tell you, and a recipe for reconstructing a past state from them.
Related
- Audit evidence: what the modules and the provider show about your AWS resources
- Promotion and gates: bindings, the gate, waiver freezing, and rollback
- Configs and snapshots: what a snapshot is and why it is immutable
- Access control: roles, permissions, and who holds
audit:read - Rulesets in the console: live rules and what their manifest records