Skip to content

Audit

The product only ever appends to the ledger: no code path issues an UPDATE or DELETE against it. That is the code's discipline and not a constraint the schema enforces — see Snapshots and the audit ledger for the conceptual treatment and for what does provide tamper-evidence.


The ledger and export reads and the sink listing require auditor (or admin); sink create, delete, and cursor reset require admin. See Delegated administration.

Machine reach. audit:read covers the ledger read and the export; audit-sinks:read the sink listing; audit-sinks:write sink creation, deletion and cursor reset. A machine read of the ledger writes no ledger entry, deliberately: logging it would make a polling exporter append an entry that is itself new content to export. See Machine API credentials.


GET /api/v1/audit

One page of the ledger, newest entry first. Requires an authenticated session with the auditor role.

$ curl localhost:8080/api/v1/audit
$ curl 'localhost:8080/api/v1/audit?before=41&limit=100'
$ curl 'localhost:8080/api/v1/audit?marketplace=acme'
{"entries":[
  {"id":43,"ts":"2026-08-15T11:22:07Z","source":"10.0.0.9","principal":"team-payments",
   "marketplace":"acme","event":"upload-pack","ref":"refs/snapshots/9d01c44...","sha":"9d01c44..."},
  {"id":42,"ts":"2026-08-15T09:04:11Z","source":"10.0.0.4","principal":"alice@example.com",
   "marketplace":"acme","event":"upload-pack","ref":"refs/heads/main","sha":"3f9c2ab..."}],
 "nextBefore":42,"total":43,"totalIsEstimate":false}

200. before (default 0, meaning start at the newest entry) and limit (clamped to the same bounds as the export). Page backwards by passing the previous response's nextBefore as before; it is absent once the page reaches the oldest entry the ledger still holds.

marketplace (optional) narrows the page to the entries recorded against that marketplace name; nextBefore then pages within the narrowing. An entry written under an earlier marketplace that held the same name is included — marketplaceId tells the two apart.

Every page carries total, how many entries the whole ledger holds (whatever marketplace narrowed the page to), and totalIsEstimate. Below 100,000 entries the total is an exact count and totalIsEstimate is false. From 100,000 up it is the database's row estimate, totalIsEstimate is true, and it is as current as the table's last automatic analyse. An exact count at that size would be the full scan of the ledger that the page bound exists to avoid.

Newest first, the opposite of the export

Deliberate. An export consumer resumes forward from where it stopped; a person opening the audit page wants what happened most recently. Same rows and the same typed shape — opposite ends of the ledger.

Paged by ledger sequence rather than by offset, so a page stays stable while the ledger is being appended to underneath a reader. An offset page over an append-only table cannot promise that.


Export endpoints

Endpoint Purpose
GET /api/v1/audit/export Stream entries as application/x-ndjson, one per line in ledger order. ?after= (default 0) and ?limit= (default 1000, capped at 10000). The resume sequence comes back in X-Skills-Gateway-Audit-Cursor.
POST /api/v1/audit/sinks Register a push sink. 201 with the show-once signing secret; 400 disallowed scheme, 409 name taken, 422 bad name.
GET /api/v1/audit/sinks List sinks with cursorPosition, ledgerHead and behind. Secrets are never returned.
PUT /api/v1/audit/sinks/{id}/cursor Set the position — replay. 200 with the sink, 404 unknown.
DELETE /api/v1/audit/sinks/{id} Remove the sink and its delivery channel. 204, or 404.

Both paths withhold entries younger than the commit-settling lag, and every entry carries its ledger id as the de-duplication key. Task-shaped coverage — polling, batch payload, signature, replay — is in Exporting the audit ledger.

Entry fields

Field Meaning
id BIGSERIAL. The ordering key.
ts When the entry was appended.
source Client address for a facade fetch; the literal admin for an administrative action.
principal The PAT principal for a fetch, the OIDC principal for an admin action, the credential's own principal for a machine API action.
actorType / actor_type What kind of actor acted: human, machine or system (GW_AUDIT_0007). See below.
marketplace The marketplace name, or - when not marketplace-scoped.
marketplaceId / marketplace_id Id of the marketplace the entry concerns, or null when it concerns none (GW_AUDIT_0009). A removed marketplace's name can be registered again, so the name alone does not say which marketplace an entry meant; the id does. Not a foreign key: the removed marketplace's record is kept, but the ledger does not depend on it. On GET /api/v1/audit and in the export.
event What happened — see below.
ref The ref involved, when there is one. For a facade fetch this is a ref the facade advertised for that request — see Events.
sha The commit involved, when there is one.
detail Free-text qualifier, when the entry needs one: the vetting chain outcome, a vetter's verdict, or the reason a reviewer gave when overriding a blocked outcome.
tokenId / token_id Id of the credential that authenticated a facade entry (GW_AUTH_0009) or a machine API entry (GW_AUDIT_0007); null on interactive admin entries and on entries older than per-credential attribution. GET /api/v1/tokens gives the owner the id→name mapping.
credential_kind What kind of credential authenticated a facade fetch (GW_AUTH_0041): pat for a gateway-issued access token, idp for an identity-provider bearer token. Null on every entry no credential authenticated — administrative and system entries — and on facade entries written before this column existed. Available on GET /api/v1/audit; the export payload is unchanged, exactly as it is for actor_type.

The actor type

Value principal is Produced by
human the identity-provider subject, or the PAT principal an interactive session, and every facade fetch by a credential holding no API scope
machine the credential's own principal a machine API credential, and a facade fetch by one
system config-reconciler, scheduler, webhook, revet-policy, catalog-builder, publication-reconciler or system the gateway acting on its own

It is denormalised on purpose, and never a join: an entry written years ago must still say what it meant after the credential it names has been revoked and its row deleted — the same reasoning token_id and credential_kind already carry. Query it directly (WHERE actor_type = 'machine') rather than comparing principal against a list of names.

Facade fetches are typed by the credential, imperfectly and knowingly

A facade fetch records machine when the credential that authenticated it holds any API scope, and human otherwise. A fetch-only PAT sitting in a CI variable therefore still records as human. That is the only distinction the data supports, and it is truthful about what this column introduces rather than guessing about what it does not. Existing entries are human, which is what they have always implicitly claimed.

Events

From the facade

Event When
info-refs A client asked what refs exist. ref is refs/heads/main and sha its resolved SHA — an advertisement genuinely is about the tip.
upload-pack One entry per wanted object when the packfile is served. sha is the object; ref is the advertised ref that object resolves to.

Negotiation rounds are not recorded.

Which ref an upload-pack entry names

A want that is not the served tip can only have come from a refs/snapshots/<sha> advertisement, so it records that ref — this is how a fetch of a snapshot a later approval has superseded is distinguishable from a clone of the current tip.

A want equal to the tip records refs/heads/main, even if the client named the snapshot ref. While a snapshot is current the two refs are the same commit, and the smart protocol carries only object ids in a want, so the two requests are not separable; the tip is the recorded answer, deterministically. Nothing is lost evidentially — sha pins the delivered content exactly.

ref is null if a want resolves to no advertised ref. Under the facade's request policy every want is an advertised tip, so this does not arise in practice; the column says it does not know rather than naming a ref the client did not ask for.

Entries written before this behaviour shipped record refs/heads/main for every upload-pack want, including snapshot-ref fetches. The ledger is append-only and is not rewritten.

From the API — registration, ingestion, approve and reject, each carrying the acting OIDC principal.

Event When detail
roles-read Every authorized read of GET /api/v1/roles, by a person or a machine alike (GW_AUDIT_0007). A refused read records nothing. grants={n}.
machine-credential-created A machine API credential was provisioned. The actor is the administrator who provisioned it; the credential's own actions are recorded under its own principal. credential {id} '{name}' scopes=…; expires=….
machine-credential-rotated A machine API credential got a new secret with an identical grant. As above.
machine-credential-revoked A machine API credential was revoked. credential {id} '{name}' principal=….
ingest-failed An ingest attempt failed, whatever triggered it. The principal is whoever or whatever triggered it: a person, scheduler, webhook, or the pushing principal. The reason, root cause and next step, in one line.
marketplace-upstream-unreachable A marketplace declared in the estate was registered although its upstream could not be read. The principal is config-reconciler. As above.
marketplace-removed A marketplace was removed. Its withdrawals follow as snapshot-revoked and snapshot-unpublished. withdrew {n} approved snapshot(s); reason: ….
marketplace-push-scopes-removed The same removal took the name out of tokens' publication grants. Written only when a token held one. tokens=[{id}, …].

Reads of the ledger itself record nothing, deliberately: an exporter polling on a cursor loop would otherwise append one entry per poll, and that entry is itself new content to export.

From the catalog builder — recorded with catalog-builder as the principal and the catalog's own name as the marketplace, because a contested name is the gateway's finding rather than an act of whoever approved the snapshot that triggered the rebuild. See Contested names.

Event When detail
catalog-name-collision One per name a rebuild withheld because more than one plugin in the served estate claimed it. The name is published for no claimant. {name} claimed by {marketplace}, {marketplace}.

From the vetting chain — every run, recorded under the admin source with vetting as the principal:

Event When detail
vetting-verdict One per vetter on the ingestion run. On a re-vet, only for a vetter whose verdict changed since the snapshot's previous run — a different state, finding count or worst severity, or a vetter version that was not in that run's chain; the run's vetting-completed entry says how many in changed=. {vetter}={state}; findings={n}; worst={severity\|none}; run={id}, e.g. secret-scan=fail; findings=2; worst=critical; run=1841.
vetting-completed Once per run. trigger={ingestion\|revet-scheduled\|revet-manual}; outcome={clear\|blocked}; vetters={n}; run={id}; chain={vetter@version,…}. A re-vet adds changed={k} before run=: the number of vetting-verdict entries it wrote, 0 when nothing changed.

From continuous re-vetting — see Re-vetting approved content. The scheduled sweep records revet-policy as the principal; an on-demand run records the operator.

Event When detail
revet-clear A re-vetting run found nothing. trigger=…; outcome=….
revet-inconclusive The chain could not conclude, so the snapshot stays approved. The vetters that could not answer.
revet-violation A retroactive violation on an approved snapshot. trigger=…; mode={WARN\|ENFORCE}; vetters=…; rules=…; fetchedBy={n}.
revet-violation-affected One per identity that had already fetched the snapshot. principal=…; fetches=…; lastFetch=….
snapshot-revoked The state transition out of approved. The violation that caused it.
snapshot-unpublished The published refs were removed. Whether the marketplace still serves anything.
snapshot-unpublish-failed A revoked snapshot's refs could not be removed. The failure. Needs a person: the record and the wire disagree.

From publication reconciliation — at startup, before the web surface serves its first request, recorded with publication-reconciler as the principal and the affected marketplace as the marketplace. See When the repair fails too.

Event When detail
publication-repaired A snapshot the database records as approved was not being served, and was republished. The snapshot's SHA.
publication-served-not-approved A ref is served for a snapshot the database does not record as approved. Nothing is retracted. Needs a person: the record and the wire disagree. The SHA being served.

What this answers

Because entries carry principal, marketplace and SHA, the inventory question is a single query:

SELECT DISTINCT principal
FROM fetch_log
WHERE sha = '3f9c2ab...' AND ts > now() - interval '90 days';

GET /api/v1/snapshots/{id}/fetchers answers exactly this from the API, and it is what the portal shows beneath a revoked snapshot. That endpoint is the approver's view of one snapshot — an admin, or an approver of its marketplace; an auditor asks the same question of the whole ledger through the reads on this page.

That is the "which of our developers received this exact content" question that git distribution otherwise cannot answer.