Skip to content

Approving and rejecting snapshots

Approval is the gate. It is the only way content reaches a client, and today it is a human pressing a button.

That is a smaller claim than it sounds. The security property does not come from the sophistication of the vetting; it comes from the fact that nothing is served until someone decides, and that the decision is recorded against an immutable SHA.

Reviewing

A held snapshot gives you four things. In the portal they are the tabs of its card on the marketplace's Review — reached from the Review queue, which lists what awaits a decision across every marketplace — where the newest snapshot awaiting a decision is already open; its one-line delta — 1 skill added, 1 modified · 3 files · +48 −7 · vs 65f64622 — tells you the size of the review before you open any of them.

The content itself

The card's Contents tab is the file explorer: the pinned commit as a real file tree, each file rendered inertly (Markdown without any HTML interpretation, binary files described rather than shown). The tree loads a folder at a time and states the snapshot's true totals, and a search over every path finds a file anywhere in a snapshot of any size. The part that usually decides a successor snapshot is the vs served view of the file you are reading: exactly what it adds, changes or removes against the commit consumers are currently receiving. Paths the snapshot removes are in the tree too, marked. A snapshot of a marketplace serving nothing shows every path as new: approving it serves all of it.

Send the second approver a link, not directions. The address carries the file you are looking at:

/marketplaces/acme?snapshot=41&tab=contents&path=plugins/hello/skills/hello/SKILL.md

Approval is four-eyes, and this is what makes "we both looked at the same thing" checkable rather than assumed. The link needs the same roles the reads do, so it resolves for a co-approver and refuses anyone else.

The card's Diff tab lists every file the snapshot changes against what is served, a page at a time, each with its diff. Nothing is out of reach because of its size. The one bound a reviewer meets is per file: text beyond 128 KiB is marked as truncated, with the file's full size stated. Read such a file from a clone of the upstream at the pinned SHA.

The same reads exist on the API (GET /api/v1/snapshots/{id}/tree, .../files, .../file, .../diff — see the API reference). They require admin or an approver grant for the marketplace (see Delegated administration), because they return held quarantine content.

Contents

GET /api/v1/snapshots/{id}/content enumerates what the snapshot declares — each plugin with its name, source and description, and the skills found under each. In the portal this is the card's Inventory tab.

$ curl localhost:8080/api/v1/snapshots/1/content
{"snapshotId":1,"sha":"3f9c2ab...","state":"held",
 "plugins":[{"name":"acme-tools","description":"...","source":"./plugins/acme-tools",
             "skills":[{"name":"deploy","path":"skills/deploy/SKILL.md"}]}]}

This works on held snapshots by design — reviewing must not require serving.

What it changes

The inventory says what the snapshot ships; GET /api/v1/snapshots/{id}/content-diff says what approving it would add to what you already approved. Every plugin and skill is marked added, removed, changed, moved or unchanged against the marketplace's last approved snapshot, and a skill counts as changed when anything under its directory differs — not only its SKILL.md.

$ curl localhost:8080/api/v1/snapshots/1/content-diff

On the tenth snapshot of a large marketplace this is the read worth starting from: it is the difference between reviewing two new skills and re-reading forty. In the portal it is the card's Diff tab. See the API reference.

Provenance

Where it came from and who has touched it:

$ curl localhost:8080/api/v1/snapshots/1/provenance
{"snapshotId":1,"marketplace":"acme","upstreamUrl":"https://github.com/acme/skills.git",
 "upstreamSha":"3f9c2ab...","sha":"3f9c2ab...","closure":null,"state":"held",
 "violation":null,"ingestedAt":"...","decidedBy":null,"decidedAt":null}

For a snapshot with resolved external plugin sources, sha is the composite the gateway serves, upstreamSha its parent, and closure names every external source with the commit it resolved to — see the closure record.

Violations

If ingestion flagged the snapshot — an external plugin source, for instance — the reason is on the snapshot row and rendered in the portal as destructive text.

An external source produces one of three violations, and they mean different things.

The violation says What happened What to do
"has a non-local source" The gateway will not admit it: external sources are not enabled, or the type, host or scheme is outside what is configured, or the type (npm, archive) is never admissible A configuration question — see configuration. npm and archive are never admissible whatever the configuration says
"could not be resolved" The source was admitted and the fetch did not succeed: the repository is unreachable, an address or a redirect was refused, or the transfer failed. The reason follows Read the reason. A refused address or redirect is normally the upstream's problem, not yours; an unreachable repository may be transient, and re-ingesting is safe
"exceeds a resolution budget" The source is larger, deeper or slower than this gateway permits. The message names the bound and the number Decide whether the content is legitimate. If it is, raise that one bound; the others stay where they are

A fourth reads "admits but cannot yet resolve" and means the source passed admission and nothing resolved it — which today happens only if a source type is allowlisted before anything can resolve it.

None of these snapshots can be approved, and that is the point: a snapshot is held only when every source it declares resolves inside the snapshot the gateway serves. Each is recorded against the commit ingested from upstream, so the content is still there to look at and diagnose.

Reviewing a resolved external plugin

When a source did resolve, there is nothing special to review: the snapshot's manifest declares ./_plugins/<plugin name> and the content is in the snapshot, so the content inventory, the diff and the vetting findings all cover it exactly as they cover a plugin the marketplace repository carries itself.

Two things are worth knowing while reviewing one. The snapshot's SHA is a commit the gateway synthesised, not one you will find upstream — and its parent is the upstream commit, so git diff <parent> <sha> in the quarantine repository shows precisely what the gateway added and rewrote. And its provenance carries the closure: every external source with the URL it was fetched through and the commit it resolved to, which is what to check before approving. An external repository that has moved produces a different snapshot, and approving one is a decision about that resolved commit and no other.

Approval also checks the closure against the commit, before any other gate: the plugins the served manifest grafts, the closure's members and the trees under _plugins/ must all agree. Nothing the gateway does can make them disagree, so a refusal titled Snapshot closure is incomplete means the snapshot's rows or its pinned commit were altered by something other than the gateway. There is no override; re-ingest the marketplace and review the new snapshot.

Vetting verdicts

Before anything else, read what the vetting chain concluded. The snapshot card's Vetting tab — the one it opens on — and the approve dialog itself show the chain outcome and, per vetter, its verdict and every finding with the file and line it came from.

A blocked outcome means at least one vetter failed, errored or has not answered, or that the chain never ran for this snapshot at all.

Read each finding where it is. Every location in the Vetting tab is a link that opens the file in Contents at that line. The line is marked, and the finding's severity, rule and message are written beneath it, next to the text it is about. Files with findings are marked in the tree, and "N findings in this file" above a file leads to each of its lines. The link carries the line in the address (&line=), so the second approver can be sent straight to the same evidence. See Snapshot contents.

A clear outcome is not a clean bill of health

The built-in vetters are pattern matchers. They catch known credential shapes and known prompt-injection markers; a paraphrased instruction or an unshaped secret goes straight past them. Treat clear as "nothing known matched", and keep reading the content. The limits of each vetter are listed under What these vetters can and cannot see next to the verdicts, and in Vetting — the vetter chain.

Evidence from a superseded chain

Enabling a vetter re-runs nothing. Neither does disabling one, reordering the chain, or changing its mode. For content that is already approved the re-vetting sweep converges on it; for a snapshot still waiting at the approval gate, nothing does.

So a snapshot ingested before a chain change carries evidence the chain as it stands today never produced — and the vetter you deliberately switched on did not look at this content. When that is the case, the vetting section says so and names both chains: the one that produced the evidence, and the one in force.

This does not block the approval, and no setting makes it. The gateway states the fact; the decision stays yours, exactly as it is for an override or a waiver. If you approve on superseded evidence, the ledger records it — snapshot-approved-on-superseded-chain, naming both chains — beside the approval rather than instead of it, so it is answerable afterwards.

To act on it instead, use Re-run the chain now on the notice, or POST /api/v1/snapshots/{id}/revet. On a held snapshot that refreshes the evidence in place: the chain runs, the new run is recorded, the snapshot stays held, and nothing is published or retracted. Then read the verdicts again — they may have changed, which is the point.

What to look for

The threats that matter here are the ones no scanner catches:

  • Instructions, not just code. SKILL.md, commands and agent definitions are read by an agent as instructions. Look for anything directing the agent toward credentials, network calls, or files outside the skill's stated purpose.
  • Hooks and MCP servers. These execute without the user ever invoking a skill. A plugin that registers them deserves more scrutiny than a markdown-only skill.
  • The diff, on updates. A skill that grows a scripts/ directory now runs something it did not run before, and that transition is itself worth a closer look.

Deciding

Approve and Reject sit at the foot of the snapshot card on the marketplace's Review, below the evidence they rest on, and nowhere else. Reject fires immediately. Approve opens a dialog showing the vetting verdicts. On the card, a snapshot the gateway would refuse — vetting blocked it, it is inside the minimum release age, or the four-eyes rule refuses you — has Approve disabled with the reason beside it; waive each blocking finding in the Vetting tab and it unblocks. A plugin-name collision is shown, and waived, in the dialog itself.

When another snapshot also awaits a decision, the card names it. Approving this one does not retire it: it stays held, and approving an older one afterwards would serve content older than this one.

$ curl -X POST localhost:8080/api/v1/snapshots/1/approve
$ curl -X POST localhost:8080/api/v1/snapshots/1/reject

Neither takes a request body. A snapshot the vetting chain blocked is refused with 409 until each blocking finding is covered by an active waiver; the problem document lists them in uncoveredFindings.

Approve publishes: the pinned refs/snapshots/{sha} is fetched from quarantine into the published repository and refs/heads/main is force-updated to that SHA. From the next client fetch onward, this is what the marketplace serves.

Reject marks the snapshot rejected and touches no repository. Whatever was already approved keeps serving.

Status Cause
200 Decided.
404 Unknown snapshot.
409 The snapshot is neither held nor revoked, its effective vetting outcome is blocked (see uncoveredFindings), a policy rule denied it, a plugin name it introduces looks like one already served (see collisions), it has not yet cleared the minimum release age, or an enforcing four-eyes rule refused it (see conflicts).

An approved snapshot cannot be re-decided

Approving is not reversible by hand. A snapshot that is approved or rejected returns 409 from both endpoints, and there is no un-approve.

To move a marketplace back to earlier content, re-ingest the desired upstream commit and approve that snapshot. Approving an older one is not a rollback mechanism.

Separation of duties

An approval is worth what the independence of the approver is worth. The gateway therefore checks, at approval time, whether the reviewer is on the snapshot's supply side — whether they, personally, are any of:

Conflict You
registered-by registered the marketplace this snapshot came from
ingested-by triggered the ingestion that pinned it
waiver-author wrote a waiver this approval relies on

The third is not a technicality. Waiving a finding and then approving past your own waiver is one decision wearing two hats, and it is the route around the other two: a reviewer refused for having ingested a snapshot could otherwise accept the objection on a fresh copy and approve that instead.

The automated sync triggers never conflict. A snapshot the polling sweep or a forge webhook brought in records scheduler or webhook as its ingestion actor, and neither is a person whose independence is being protected — so an estate on automatic sync is approvable by anyone the role model allows. Nor does an unrecorded actor conflict: marketplaces and snapshots that predate this release carry none, so the rule tightens only from here on.

What a conflict does is a deployment decision, skills-gateway.approval.four-eyes.mode:

  • warn, the default. The approval proceeds and the conflict is written to the audit ledger. This is what keeps a single-administrator deployment — a first evaluation, a small team — working: there is nobody else to ask, and refusing would only mean nothing could ever be published. The record is what makes that visible rather than silent.
  • enforce. The approval is refused with 409, and the snapshot stays held with nothing published. Somebody else has to decide.

There is deliberately no way to switch detection off. A conflict reaches the ledger in both modes as a four-eyes-conflict entry naming the acting identity, the snapshot, the mode, whether the approval proceeded, and each conflicting act — which is also how you size up enforce before turning it on: run warn, then read how many approvals would have been refused, and by whom.

Neither mode gates Reject. Refusing suspicious content quickly must never wait for a second pair of eyes.

You can see where you stand before deciding. The portal's approve dialog says which acts conflict — as a warning under warn, and with the confirm button shut under enforce — and the API answers the same question without deciding anything:

$ curl localhost:8080/api/v1/snapshots/1/four-eyes
{"mode":"ENFORCE","refused":true,
 "conflicts":[{"role":"registered-by","principal":"dana","waiverId":null},
              {"role":"ingested-by","principal":"dana","waiverId":null}]}

A refused approval answers with the same list in its problem document, under conflicts, alongside the configKey that imposed it.

enforce needs a second approver per marketplace

Under enforce, a marketplace whose only approver also registered it or ingests its content has nobody left who may publish it. Give every marketplace that needs deciding a second identity with approval rights — a second admin, or an approver scoped to it under delegated administration — before switching.

Plugin names already in use

A snapshot that introduces a plugin name looking like one another marketplace already serves — c0de-review arriving beside an approved code-review — is refused until the collision is accepted. It is the typosquat check: case, separators and Unicode lookalikes are folded away before names are compared, so Claude-Skills and cIaude_skills are the same name.

The approve dialog lists each colliding name with the incumbent it resembles; the API answers 409 with collisions, and GET /api/v1/snapshots/{id}/name-collisions answers the same question without deciding anything. What to do:

  • An impersonation, or a name you cannot account for — reject the snapshot.
  • A fork or vendored copy you recognise — record a waiver on plugin-name-collision for this snapshot, and approve.

Four things about it are worth knowing.

  • The name is checked once. Once a marketplace has an approved snapshot carrying a name, its later snapshots are not checked for that name again.
  • The incumbent is never touched. First come wins: nothing here withdraws or re-checks what is already served.
  • Only plugin names are compared. Skill names such as init or review recur across plugins legitimately and never collide.
  • Re-approving a revoked snapshot checks again, against the estate as it is then. If another marketplace acquired a lookalike name meanwhile, the restore needs a waiver.

The refusal is on the audit ledger as snapshot-approval-refused with the detail plugin-name-collision: …, and an accepted collision as the waiver-applied entry of the waiver that covered it.

Waiting out the minimum release age

If your deployment configures skills-gateway.vetting.minimum-release-age, a snapshot cannot be approved until the gateway has been holding its commit for that long. The portal shows the approve control disabled and reading Eligible in 2d 4h; the API answers 409 with the setting, the current age and the time remaining, and GET /api/v1/snapshots/{id}/release-age answers the same question without attempting a decision.

The window exists for a threat no scanner covers: a compromised release is usually noticed by the wider world — often by the project's own community — within hours of being pushed, and it is frequently pulled again just as fast. Adopting a commit the moment it lands forfeits that detection entirely.

Three things about it are worth knowing before it surprises you.

  • Nothing has to happen for the wait to end. The age is compared on each approval request, so the snapshot becomes approvable by itself. There is no queue to re-run and no state to clear.
  • The clock is this gateway's first sighting of the commit, not the commit's date, and re-ingesting the same commit does not restart it. A backdated or re-pushed commit gets no credit.
  • There is no override. The wait applies to a newly registered marketplace's very first snapshot too. Shipping something before the window elapses means changing the configuration, which is a deployment someone reviews — deliberately a heavier act than clicking past a dialog.

Both outcomes are on the audit ledger: an approval that went through records how long the commit had been held (snapshot-approved, detail ingestion-age=…), and one the window turned away is its own entry (snapshot-approval-refused). What the wait was worth is therefore answerable from the ledger alone.

Reviewing is unaffected: the contents, the provenance and the vetting verdicts are all readable during the wait, so the waivers a blocked snapshot needs can be recorded while the window runs down rather than after it.

Withdrawing content you have already approved

Sometimes what was approved turns out to be harmful and the vetting chain has nothing to say about it — a vulnerability disclosed upstream, a maintainer account compromised, a dependency found to be malicious. The chain will keep clearing the content, because none of that is visible in the files.

An administrator withdraws it directly:

$ curl -X POST localhost:8080/api/v1/snapshots/42/revoke \
    -H 'Content-Type: application/json' \
    -d '{"reason": "CVE-2026-0001 in a vendored dependency",
         "serveAfter": "PREVIOUS_APPROVED"}'

The snapshot stops being served, the withdrawal goes on the audit ledger with who made it and why, marketplace.snapshot.revoked fires, and the forge mirror reconciles. Quarantine is untouched, so the content is still there to re-review.

Admin-only, and there is no four-eyes rule on it. Approval needs two identities because publishing is the direction that can do harm; withdrawing only ever takes content off the wire, so a second reviewer buys no safety and costs time during an incident. The stated reason is the accountability, and it is mandatory.

You must say what the marketplace serves afterwards

serveAfter has no default, and a request without it is refused. Neither answer is safe to assume:

serveAfter What happens When it is right
PREVIOUS_APPROVED The marketplace returns to its previous approved snapshot The predecessor is known-good
NOTHING The marketplace serves nothing The compromise may be in the predecessor too — a poisoned transitive dependency usually is

Asking to return when the marketplace has no earlier approved snapshot is refused before anything is withdrawn, so a request that cannot be honoured changes nothing rather than quietly leaving you with the other outcome. A return is recorded on the ledger as a publication in its own right.

This stops the gateway serving it; it does not delete what clients hold

Anyone who already cloned the content still has it on disk. GET /api/v1/snapshots/{id}/fetchers names every identity that fetched it — the blast radius — and a client can find out for itself by asking POST /status/v1/snapshots about the commits it holds. Nothing forces it to ask, so a withdrawal is only as fast as the check your fleet runs.

Putting it back takes a second administrator

A withdrawn commit cannot be approved again by an ordinary approval. The refusal names who withdrew it, when, and why — so a reviewer can judge whether anything has actually changed rather than assuming they have hit a bug.

If it has changed — the withdrawal was a mistake, or the upstream problem is fixed — a different administrator reverses it:

$ curl -X POST localhost:8080/api/v1/snapshots/42/approve \
    -H 'Content-Type: application/json' \
    -d '{"reverseRevocation": true,
         "reason": "upstream published a fix; re-reviewed"}'

The administrator who withdrew it cannot be the one who reverses it. That is unconditional: withdrawing takes one identity because it cannot publish anything, and reversing does publish, so it takes two. Every other approval gate still runs.

A waiver does not lift a withdrawal. Waivers clear vetting findings, and a withdrawal has no finding to clear — that is the whole point of it.

The snapshot keeps a standing marker afterwards, so content served over a reversed withdrawal is never indistinguishable from content nobody ever withdrew.

Retention keeps the record

An administratively withdrawn snapshot can have its content reclaimed by retention, but its record is never removed. The refusal above is derived from that record, so deleting it would expire the withdrawal on a timer — and the same upstream commit would then ingest as an ordinary held snapshot with no trace that anything was ever withdrawn.

Re-approving a snapshot re-vetting revoked

A snapshot that continuous re-vetting revoked is decidable again, and the route back is this same Approve — deliberately, because a retraction the gateway made without a person has to be answerable by one.

Nothing about the gate is relaxed for it. The effective vetting outcome is evaluated exactly as for a held snapshot, so the finding that caused the violation has to be waived (or fixed by re-ingesting a corrected upstream commit) before the approval succeeds. What the decision records is fresh: a new reviewer, a new timestamp, and the revocation marks cleared. What the snapshot was revoked for stays in the audit ledger.

Rejecting it instead is the terminal answer, and the right one when the finding is not something anyone intends to accept.

This is the one kind of revocation a waiver lifts. A chain verdict is an objection to something in the content, so clearing that objection answers it; an administrator's withdrawal is not, and needs the reversal above instead.

When an approval fails

An approval publishes by moving the marketplace's served references onto the snapshot. That is a single all-or-nothing transition, and it can be refused — by a competing writer holding the reference, by the storage underneath it, or, on the object-store backend with more than one replica, by an ordinary lost compare-and-swap.

A refused publication fails the approval. Nothing is published, the snapshot stays exactly as it was — held, or revoked, with its revocation intact — the audit ledger records no approval, and no lifecycle event is emitted. Retry the approval; there is nothing to clean up first.

Do not treat a failed approval as a published one

Earlier versions reported such an approval as successful: the snapshot was recorded as approved, the ledger said so, and subscribers were notified, while the facade went on serving the previous tip. If you are looking at an estate where the database and the facade disagree about what is served, that is the cause. An approved snapshot cannot be decided again, so the repair is the startup reconciliation described below, not a second approval.

When the repair fails too

Failing the approval means putting the recorded decision back, and that write can fail in its turn. The double failure is reported — at ERROR, and on the exception the approval raises — but what it leaves behind is a row saying approved while nothing is served.

The next start reconciles that. After the schema is migrated and before the web surface serves its first request, the gateway compares, per marketplace, what the database records as approved against what storage is actually serving, and republishes what is missing; each repair lands on the ledger as publication-repaired. Publication is idempotent by SHA, so the repair only ever adds. A marketplace serving nothing is repaired like any other — that is exactly what a double failure on a first publication leaves — while one whose storage could not be read is skipped, because "I could not look" must never be taken for "there is nothing there". Doing this before the facade accepts a request means no client observes the served references moving.

The mismatch in the other direction — a ref served for a snapshot the database does not call approved — is reported and never retracted. It lands on the ledger as publication-served-not-approved and the references are left exactly as they are. The asymmetry is deliberate: deleting served references on the strength of a database comparison is the direction that fails catastrophically and silently when the database is the thing that is wrong — a migration mid-flight, a restore from a stale dump. Serving unapproved content is the graver condition, and precisely because it is, retracting it is a judgement for a person holding the report.

publication-served-not-approved needs a person

Content is on the wire that the gateway does not consider approved. Take the SHA from the ledger entry and establish which case it is:

  • A decision whose unpublish never completed — the snapshot is revoked or rejected and its references are still there. The record is right and the wire is wrong; this is the same condition snapshot-unpublish-failed reports, and removing the references is the fix.
  • A snapshot the database no longer has — a restore from an older dump, a migration that did not finish. Here the database is what is wrong. Repair it first: nothing should come off storage while the comparison is being made against state that is itself untrustworthy.

The gateway does not choose between those two on your behalf, which is why it only reports. Where content genuinely has to come off the wire, re-vetting is the path that retracts it and records the decision as it goes; stripping references by hand records nothing.

The remedy is a restart

Reconciliation runs at startup and nowhere else — there is no sweep and no endpoint. A recurring pass would be one more scheduled pass on the estate's shared budget, for a divergence rare enough that it does not earn one: a restart is already the proportionate remedy, and it is one an operator can order the moment they see the report.

Rejection is not deletion

The rejected snapshot stays as evidence — it is the record that someone looked at this exact upstream commit and said no.

Who may approve

Two independent questions, answered in this order.

May this principal approve here at all? That is delegated administration: an admin or an approver scoped to the marketplace.

May this principal approve this snapshot? That is the four-eyes rule above, and it is a different question: it is about what the reviewer already did to the content in front of them, not about what they are permitted to do in general. A principal can hold every role there is and still be the wrong person to approve one particular snapshot.

After approval

The content is live. Verify it and point clients at it — Consuming approved skills.

If the chain objected and you accepted the risk, see Waiving a vetting finding for what the acceptance covers and when it lapses.

Reviewing somewhere else

The decision does not have to be made in the portal. The gateway announces every snapshot that is waiting for a person as a marketplace.snapshot.approval_pending webhook, with the vetting summary attached, and the approve and reject endpoints on this page are the ones an external system calls back — see Driving approvals from your own system.