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:
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.
{"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.
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:
{"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 with409, and the snapshot staysheldwith 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:
{"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-collisionfor 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
initorreviewrecur 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
revokedorrejectedand its references are still there. The record is right and the wire is wrong; this is the same conditionsnapshot-unpublish-failedreports, 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.