Snapshots and the audit ledger¶
Two objects carry the product's guarantees: the snapshot, which makes content immutable and attributable, and the ledger, which makes access answerable.
Marketplace¶
A marketplace is a registered upstream git repository — a name, a clone URL, and best-effort forge metadata (forge, project, description, last upstream update) captured at registration.
The name must match ^[a-z0-9][a-z0-9_-]{0,62}$ — at most 63 characters. It is
the identity of the marketplace everywhere: the portal route, the API path, and
the facade URL /git/{name}.
Registration does not fetch any content. It establishes that this URL is one the gateway is willing to talk to, and that the gateway can actually read it: it lists the upstream's references and resolves the default branch it will pin.
Snapshot¶
A snapshot is one upstream commit, captured at one moment, with a vetting decision attached. It is the unit of review, approval, serving, audit and retention.
| Field | Meaning |
|---|---|
sha |
The 40-hex commit the gateway serves. This is the pin: the upstream commit for a local-only manifest, and the synthesised composite for one with resolved external sources (see below). |
upstreamSha |
The commit ingested from upstream. Equal to sha unless a composite was synthesised, in which case it is the composite's parent. |
state |
held, approved, rejected, or revoked. Set at ingestion and changed only by a decision or by an enforced re-vetting violation. |
violation |
Why ingestion flagged the snapshot, or why re-vetting revoked it. Cleared by a fresh decision. |
decidedBy / decidedAt |
The principal who decided, and when. Survives a revocation. |
revokedBy / revokedAt |
The identity that revoked it, and when. Cleared by a fresh decision. |
createdAt |
When the gateway ingested it. |
stateDiagram-v2
[*] --> held: ingest
held --> approved: approve (publishes)
held --> rejected: reject
approved --> revoked: enforced re-vetting violation<br/>(unpublishes)
revoked --> approved: fresh approve decision<br/>(re-publishes)
revoked --> rejected: reject
approved --> [*]: serving on refs/heads/main
rejected --> [*]: never served
revoked --> [*]: served once, not any more
The state machine stays small, and every edge is deliberate.
approved → revoked is the only transition the gateway makes without a person,
and only when continuous re-vetting is configured to
enforce. It removes the published refs; it does not touch quarantine, so the
content is still there to be re-reviewed.
revoked → approved is the way back, and it is the ordinary approve endpoint —
no un-revoke, no undo. That means the violation must be waived or fixed first,
because the same effective-vetting gate applies, and it means the return is a
recorded decision with a named reviewer.
Everything else is refused with 409: an approved snapshot cannot be re-decided, and a rejected one cannot be approved.
Deletion is not a state
Retention marks a snapshot deleted without touching state — a deleted
snapshot is still whichever of the four it was. See
Snapshot retention.
Why SHA pinning is the point¶
Git refs are mutable; commit SHAs are not. Everything the gateway stores and serves is keyed by the snapshot's SHA:
- Quarantine holds
refs/snapshots/{sha}— one immutable ref per snapshot. - Approval force-updates the published
refs/heads/mainto that exact SHA. The published ref moves only because a human approved a specific commit. - The ledger records the SHA on every fetch.
The consequence: "what exactly ran on that laptop" reduces to a ledger lookup, and an upstream force-push cannot change the answer retroactively.
Composite snapshots¶
For a local-only marketplace — every marketplace, under the shipped configuration — the snapshot's SHA is the upstream commit, and there is nothing more to say.
When external plugin sources are enabled and a manifest declares one, the snapshot is instead a synthesised commit the gateway assembles at ingestion:
- the upstream tree, unchanged;
- each resolved external plugin's content under
_plugins/<plugin name>/; .claude-plugin/marketplace.jsonrewritten so that plugin'ssourceis./_plugins/<plugin name>;- the upstream commit as its parent.
That commit is the snapshot. It is what vetting opens, what a reviewer approves, what the facade serves, what retention anchors on and what the ledger records — so nothing downstream of ingestion learns that external content exists, because the resolved content is not metadata beside the snapshot: it is the snapshot.
The parent is what makes the transformation checkable by someone other than the gateway. The manifest as upstream declared it stays byte-exact and reachable from the served SHA, so
is the whole of what the gateway changed, and the commit message names the upstream commit, every source with the commit it resolved to, and the version of the transformation that produced it.
Identity follows from the inputs: the same upstream commit, the same resolved external commits and the same transformation give the same SHA, which is what makes re-ingesting unchanged content idempotent. An external repository moving on therefore produces a different snapshot, held for its own approval — the same rug-pull protection the upstream commit already gets, extended to the repositories the manifest points at.
The closure record¶
The commit message is one witness to what was resolved. The closure is the other, and the one that can be queried: a record written in the same database transaction as the composite snapshot, naming the upstream commit, the version of the transformation, a SHA-256 digest over the whole, and one member per resolved external plugin:
| Member field | Meaning |
|---|---|
pluginName, sourceType, declaredSource |
What the manifest declared — the plugin, the source type (github), and the source exactly as written (acme/tools). |
declaredRef, declaredSha |
Any pin the manifest declared. Always null today: a declared pin is refused rather than resolved elsewhere. |
cloneUrl, resolvedSha |
The URL the source was fetched through and the commit it resolved to. |
treeSha, graftPath |
The tree grafted into the composite and where — _plugins/<plugin name>. |
objectCount, inflatedBytes |
What the grafted tree cost, measured at resolution. |
Every value is a copy taken at ingestion. Nothing in the closure points at the marketplace row or at configuration, because those change and the closure must not: the marketplace is the mutable source, the closure is the immutable artifact that was vetted and approved. A closure is never updated, and it leaves the database only when its snapshot is purged. A snapshot that resolved nothing — every local-only snapshot, and a rejected resolution — has no closure record; that absence is the empty closure.
The closure is what makes which approved snapshots include repository X, at any commit? one indexed query rather than a walk over every quarantine repository. It appears in the snapshot's provenance and in the policy facts, and the approval gate refuses any snapshot whose closure, served manifest and pinned tree disagree.
What a snapshot contains¶
GET /api/v1/snapshots/{id}/content parses the captured commit and lists what it
declares: each plugin with its name, source and description, and the skills
found under each. A resolved external plugin appears like any other, with its
source inside the snapshot, because by the time anything reads the snapshot
that is what it is.
This is the review surface, and it works on held snapshots — reviewing a
snapshot must not require serving it.
The audit ledger¶
The ledger is one PostgreSQL table, fetch_log, and the product only ever
appends to it: no code path issues an UPDATE or DELETE against it, and
retention does not compact it.
That is a discipline of the code, not a constraint of the schema.
fetch_log carries no trigger, no rule and no revoked grant, so anything
holding write access to the database can still change a row — the gateway
simply never does. Tamper-evidence comes from your own database controls and
from exporting the ledger to a system the gateway cannot
reach, not from the table.
| Column | Meaning |
|---|---|
id |
BIGSERIAL. The ordering key. |
ts |
When the entry was appended. |
source |
The client address for a fetch; the literal admin for an administrative action. |
principal |
The PAT principal for a fetch, the OIDC principal for an admin action. |
marketplace |
The marketplace name, or - when not marketplace-scoped. |
event |
What happened. |
ref |
The ref involved, when there is one. For a fetch, a ref the facade advertised for that request. |
sha |
The commit involved, when there is one. |
What gets recorded¶
Facade fetches — info-refs when a client asks what refs exist, carrying
the resolved main SHA; and one upload-pack entry per wanted object when the
packfile is served, naming the advertised ref that object resolves to. A
superseded snapshot stays fetchable by name, so a fetch of one is distinguishable
in the ledger from a clone of the tip — see
Auditing for the exact rule and its one
unavoidable ambiguity. Negotiation rounds are not recorded.
Administrative actions — registration, ingestion, approve and reject, each carrying the acting OIDC principal.
An upload-pack entry is appended when the gateway begins sending the pack,
not when the client has finished receiving it, so a transfer that aborts midway
still records as a fetch. For the question the ledger exists to answer — who
might be holding this content — over-reporting is the safe direction, but it is
not a record of receipt, and the
blast-radius report
inherits it.
Appending is on the serving path¶
A facade fetch appends its entry synchronously, inside the request: no queue, no background writer, and nothing catching a failure. Serving is therefore no more available than PostgreSQL — an outage fails the fetch outright rather than handing out content that goes unrecorded.
That direction is deliberate. The gateway's claim is that nothing leaves it unaccounted for, and content served while the ledger was unreachable would falsify that claim for exactly the incident the ledger exists to answer. Size the database for the serving path, not for the reporting one.
Reading it¶
GET /api/v1/audit returns the ledger
a bounded page at a time, newest first, optionally narrowed to one marketplace,
with a count of the whole ledger on every page. The portal's
Audit log page renders it and loads older
pages on request, and each marketplace's Activity section reads its own slice.
For a complete pull, use the NDJSON export and its cursor.
Because entries carry the principal, marketplace and SHA, the inventory question — "every identity that fetched this exact content" — is a single query against the ledger.
Append-only, and finite¶
Append-only is a rule about writes: nothing in the gateway updates a ledger row
or deletes one to change what it says. It was never a promise that the table
grows forever, and it could not be — info-refs is appended on every client
poll, so the ledger is the one table whose size is set by how often developers
run git fetch rather than by how much the organisation does.
What bounds it is deliberately narrow. Only the two facade read events are ever removable, and only once every enabled export sink has already taken them; the administrative half — who approved, rejected, revoked, registered — is never removable at any age. Nothing is removed at all unless an operator configures a maximum age, and nothing is removed in a deployment with no export sink, because then the gateway's own copy is the only copy and deleting it would destroy the evidence rather than age it.
So the ledger is append-only in the sense that matters — no entry ever changes its meaning — and finite only behind proof that something else holds what is being dropped. See Bound the audit ledger.
Exporting it¶
The portal view is a recent-activity table; evidence lives in your compliance system. The ledger is therefore also readable as a continuous feed, either pulled as newline-delimited JSON or pushed to a registered sink.
A sink is nothing but a cursor over the ledger — the sequence of the last entry it has been handed. No entry is copied into a per-consumer queue, which is what keeps the append-only table the single copy of the evidence and makes replay a write to one column. Delivery is at-least-once and each entry carries its ledger sequence, so a receiver de-duplicates on that.
The ledger sequence is assigned before its row commits, so both paths withhold the last few seconds of entries rather than risk a cursor stepping over an append still in flight — bounded staleness in exchange for no gaps. See Exporting the audit ledger.
What the ledger is not¶
- Not self-proving — no entry carries a digest over its predecessor, and the append-only property is the code's rather than the schema's (above), so the table cannot attest to its own integrity.
- Not itself retained or archived by the product. How long you keep it is your compliance decision.
- Not a real-time alerting channel. Export is cursor-based and settles for a few seconds before an entry is handed on.
Current scope limits¶
Both limits are enforced, not merely documented:
Local sources by default. Plugins must live inside the marketplace
repository as relative paths unless external plugin sources are enabled, which
they are not by default. An enabled gateway resolves a github source and
rewrites the manifest so every source is local again; git, git-subdir, npm
and archive are still rejected fail-closed. Whatever the setting, a snapshot is
held only when every source it declares resolves inside the snapshot the gateway
serves.
Default branch only. Registration accepts no ref other than main. Serving
additional refs is a future feature, implemented as promotion per
(upstream, ref), with each ref advancing independently through the same gate.