Marketplaces and snapshots¶
The core API: registration, ingestion, the approval gate and provenance. All
paths are relative to /api.
Registration, removal and sync-mode changes require admin; ingest, approve, reject,
re-vet, and waiver create/delete require approver of the marketplace (or
admin) — resolved server-side from the addressed snapshot or waiver where the
route carries an id; every GET on this page stays open to any session, except
the snapshot preview reads and the
blast-radius report, which take the same
approver-or-admin standing as a decision on that snapshot, and the vetter
settings, which are admin. Two administrative escape hatches require
admin specifically: overriding a blocked vetting outcome on approve, and
enabling or disabling a vetter. See
Delegated administration.
Machine reach. marketplaces:read covers GET /marketplaces, GET
/catalog and a snapshot's /content, /content-diff, /licenses,
/provenance and /release-age; snapshots:read covers /diff, /file,
/files, /vetting, /fetchers, /four-eyes and /name-collisions; marketplaces:register,
marketplaces:ingest, vetting:run, sync:write and waivers:read cover
the corresponding mutations and the waiver listing. Approve, reject, waiver
create, waiver delete, snapshot delete, snapshot restore and marketplace removal
are reachable by no scope at all — they publish, refuse or retract content. A scope grants reach,
not standing: a credential reaching one of the privileged reads above still needs
its principal's approver or admin role. See
Machine API credentials.
Representations¶
Marketplace
{"id":1,"name":"acme","url":"https://github.com/acme/skills.git",
"createdAt":"2026-08-15T09:00:00Z","registeredBy":"dana",
"forge":"github","forgeProject":"acme/skills",
"description":"Acme internal skills","upstreamUpdatedAt":"2026-08-14T18:20:00Z",
"servedSha":"3f9c2ab...","lastIngestAt":"2026-08-15T09:01:00Z",
"lastIngestOutcome":"succeeded","lastIngestReason":null,"snapshots":[]}
lastIngestAt, lastIngestOutcome and lastIngestReason describe how the last
ingest attempt ended, whatever triggered it. lastIngestOutcome is succeeded
or failed. lastIngestReason is set only for a failure: the reason, the root
cause and a next step, in one line. All three are null before the first
attempt. A manifest rejected by policy is a succeeded ingest: the snapshot it
captured is what is rejected.
servedSha is the commit the facade currently serves for this marketplace, read
from the published repository, or null when it serves nothing. It is not the
newest approved snapshot: a withdrawal that serves nothing afterwards leaves an
earlier snapshot recorded approved while servedSha is null.
Snapshot
{"id":42,"marketplaceId":1,"sha":"3f9c2ab...","upstreamSha":"3f9c2ab...",
"state":"held","violation":null,"createdAt":"2026-08-15T09:01:00Z",
"ingestedBy":"ingrid","decidedBy":null,"decidedAt":null}
sha is the commit the gateway serves; upstreamSha is the commit ingested
from upstream. They differ only for a snapshot whose external plugin sources
were resolved into a composite, where upstreamSha is the composite's parent.
registeredBy and ingestedBy are the supply-side identities the
four-eyes rule
compares a reviewer against. ingestedBy is a principal for an on-demand
ingest or a push, scheduler or webhook for an automated trigger, and null
for a snapshot ingested before the actor was recorded; registeredBy is null
for a marketplace registered before it was.
state is one of held, approved, rejected, revoked. A revoked snapshot
also carries revokedBy and revokedAt, and its violation says what
re-vetting found. See
Re-vetting approved content.
POST /marketplaces¶
Register a marketplace. For an upstream marketplace, the gateway lists the upstream's references and resolves its default branch before it creates anything. It fetches no content. A hosted marketplace contacts nothing.
Body — {name, url?, ref?, origin?, pushPolicy?}
$ curl -X POST localhost:8080/api/v1/marketplaces \
-H 'Content-Type: application/json' \
-d '{"name":"acme","url":"https://github.com/acme/skills.git"}'
origin decides where the content comes from:
origin |
url |
Content arrives by |
|---|---|---|
upstream (default) |
required | the gateway fetching the upstream default branch |
hosted |
must be absent | a publisher pushing to /publish/{name} |
pushPolicy applies to a hosted marketplace only: append-only (the default)
refuses a non-fast-forward push, allow-rewrite permits one and records both
tips on the ledger. See
Publishing first-party skills.
| Status | Cause |
|---|---|
| 201 | Registered; returns the marketplace plus warnings (see below). |
| 400 | URL scheme not allowlisted, a url with userinfo (a credential in the URL), ref present and not main, a hosted registration supplying a url, an upstream one omitting it, or a pushPolicy on an upstream marketplace. |
| 409 | A live marketplace has that name. A removed marketplace's name is free. |
| 422 | Name fails ^[a-z0-9][a-z0-9_-]{0,62}$ (at most 63 characters; the reason says the name is the /git/<name> path clients clone), or an unknown origin/pushPolicy. |
| 502 | The upstream could not be read, or has no default branch. Nothing was registered. The problem carries reason, rootCause and nextStep; see Registering a marketplace. |
The upstream is read only after every other check has passed, so a request
refused with 400, 409 or 422 never contacts it.
The 400 cases are trust-boundary rejections — see Compatibility and allowlists.
warnings is a non-blocking, always-present array — empty when there is
nothing to say. Today it carries at most one kind of entry: an upstream
marketplace's clone URL matching another registered marketplace's, once both
are normalized (lowercased scheme and host, no trailing slash, no .git
suffix), reads "url already registered as <name>" for each match. Tracking
one upstream under two names is a legitimate way to test a marketplace before
promoting it, so this never refuses the registration — see Registering a
marketplace.
DELETE /marketplaces/{name}¶
Remove a marketplace. Admin, and reachable by no machine scope.
Body — {reason}, required and non-empty.
$ curl -X DELETE localhost:8080/api/v1/marketplaces/acme \
-H 'Content-Type: application/json' \
-d '{"reason":"upstream moved to https://git.example.com/acme/skills.git"}'
{"id":1,"name":"acme","removedAt":"2026-09-23T10:00:00Z","removedBy":"dana",
"withdrawnSnapshotIds":[42],"pushScopeRemovedFromTokenIds":[7]}
Removal is a retirement, not a delete. Every approved snapshot is withdrawn by
administrative revocation with the reason
marketplace removed: <reason>, serving nothing afterwards, so each withdrawal
is on the ledger, announced as marketplace.snapshot.revoked, and answered
revoked by POST /status/v1/snapshots.
From then on the marketplace is not served, synced, pushed to, listed, approved
or reachable on any /marketplaces/{name}/… route. Its record, its snapshots,
their provenance and content reads by snapshot id, and the ledger are kept.
Every access token granted to publish to the name loses that grant, and only
that one: pushScopeRemovedFromTokenIds lists them, and the ledger records it as
marketplace-push-scopes-removed. Grants to fetch the name are kept. The
marketplace's vetter toggles and chain settings stay in the database but leave
GET /vetting/vetter-toggles and GET /vetting/chain-settings.
| Status | Cause |
|---|---|
| 200 | Removed; returns the marketplace's id, when and by whom, the snapshots withdrawn, and the tokens that lost a publication grant. |
| 403 | Not an administrator. |
| 404 | No live marketplace has that name — including one already removed. |
| 422 | No reason, or an empty one. |
The name is free again. Registering it creates a new marketplace with a new
id that serves nothing until one of its own snapshots is approved. It inherits
no approval, no approver grant, no publication grant and, for a hosted
marketplace, no pushed lineage;
the same commit ingested again comes back held, and the removal's withdrawals
do not block approving it. See
Removing a marketplace.
GET /marketplaces¶
All live marketplaces — a removed one is not listed — each with its forge metadata and full snapshot list. This is the portal's primary query; there is no per-marketplace endpoint.
200 — array of marketplaces.
POST /marketplaces/{name}/ingest¶
Clone the source's default branch into quarantine and pin the tip commit as
refs/snapshots/{sha}. Creates a snapshot in state held. For a hosted
marketplace the source is its own origin repository, and a push already does
this — the endpoint stays available to re-ingest.
| Status | Cause |
|---|---|
| 201 | Snapshot captured; returns it. A manifest that breaks policy captures a rejected snapshot. |
| 404 | Unknown marketplace. |
| 502 | Ingestion failed. The problem carries reason, rootCause and nextStep. |
Ingesting a commit already captured does not create a second snapshot. Every
attempt, from any trigger, updates the marketplace's lastIngest* fields, and a
failed one is recorded on the ledger as ingest-failed.
Upstream sync¶
Automated ingestion triggers (GW_INGEST_0010–GW_INGEST_0014). Modes and the secret lifecycle are described in Syncing from upstream automatically.
PUT /marketplaces/{name}/sync¶
Set how upstream content reaches quarantine: on-demand (default),
scheduled, or webhook. No mode bypasses approval.
$ curl -X PUT localhost:8080/api/v1/marketplaces/acme/sync \
-H 'Content-Type: application/json' -d '{"mode":"webhook"}'
webhookSecret is present only when the new mode is webhook, and only in
this response — setting webhook mode again rotates it, and leaving the mode
discards it. The change is audit-logged as sync-mode-changed.
| Status | Cause |
|---|---|
| 200 | Mode changed; returns the marketplace, plus the secret when entering webhook mode. |
| 404 | Unknown marketplace. |
| 422 | Not one of the three modes. |
POST /hooks/{marketplace}¶
The inbound forge webhook — not under /api, and the only endpoint
reachable without an OIDC session or a PAT. Authenticated solely by an
HMAC-SHA256 signature of the exact raw request body in the GitHub-compatible
X-Hub-Signature-256: sha256=<hex> header, verified in constant time against
the marketplace's secret.
The payload is ignored: a valid signature only triggers an asynchronous
ingestion of the registered upstream URL's default branch — nothing in the
body is read. The ingestion is recorded on the ledger with the actor webhook
and lands held like any other.
| Status | Cause |
|---|---|
| 202 | Signature valid; ingestion queued. |
| 404 | Unknown marketplace, not in webhook mode, or the signature did not verify. One answer for all three, so the status does not reveal which names exist. Nothing was ingested. |
| 413 | Body exceeds skills-gateway.sync.max-webhook-body-bytes; rejected before the marketplace is looked up. |
Virtual catalog¶
The synthesized one-URL catalog (GW_FACADE_0003–GW_FACADE_0005); see The virtual catalog.
GET /catalog¶
The catalog revision the facade is serving at /git/{catalog-name}.
{"sha":"a91b...","generatedAt":"...","constituents":[
{"marketplace":"acme","sha":"3f9c..."}],
"collisions":[{"name":"acme-tools-deploy",
"marketplaces":["acme","acme-tools"]}]}
collisions are the plugin names claimed more than once across the served
estate — by two marketplaces, or twice within one — and therefore published for
no claimant; marketplaces lists the claimants in name order. See
Contested names. Empty in the
ordinary case. Like constituents it is recorded in the catalog commit itself,
so the served revision reports its own.
200 · 404 catalog disabled or not generated yet.
POST /catalog/rebuild¶
Regenerate now from what every marketplace is serving. Approvals and
revocations already do this on their own; this is the on-demand repair path.
Audit-logged as catalog-rebuilt with the acting identity.
200 — the new revision · 404 catalog disabled.
GET /snapshots/{id}/content¶
What the snapshot declares — the review surface. Works on held snapshots,
because reviewing must not require serving.
{"snapshotId":42,"sha":"3f9c2ab...","state":"held",
"plugins":[{"name":"acme-tools","description":"Deployment helpers",
"source":"./plugins/acme-tools",
"skills":[{"name":"deploy","path":"skills/deploy/SKILL.md"}],
"commands":[{"name":"release","path":"plugins/acme-tools/commands/release.md"}],
"agents":[{"name":"reviewer","path":"plugins/acme-tools/agents/reviewer.md"}],
"hooks":[{"event":"PostToolUse","matcher":"Edit|Write","type":"command",
"runs":"${CLAUDE_PLUGIN_ROOT}/scripts/format.sh",
"location":"plugins/acme-tools/hooks/hooks.json:9","declaredBy":"plugin"}],
"mcpServers":[{"name":"tickets","path":"plugins/acme-tools/.mcp.json:3"}],
"lspServers":[{"name":"go","path":"plugins/acme-tools/.lsp.json:2"}]}]}
Every list is present and empty when the plugin has none.
- Commands and agents come from
commands/andagents/, or from the pathsplugin.jsondeclares instead. - Hooks merge
hooks/hooks.jsonwith the hook files and inline hooks declared inplugin.jsonand the marketplace entry, plus hooks in skill and agent frontmatter. For those,declaredByisskill <name>oragent <name>. - MCP servers merge
.mcp.jsonwithplugin.json'smcpServers. - LSP servers merge
.lsp.jsonwith thelspServersofplugin.jsonand of the marketplace entry. A name declared later replaces an earlier one.
A path that escapes the plugin is ignored. A declaration that cannot be parsed
leaves that component out; it does not fail the call. The
executable-surface vetter
reports it.
200 · 404 unknown snapshot.
GET /snapshots/{id}/content-diff¶
The same inventory, against the marketplace's newest live approved snapshot
other than this one: what approving this snapshot would add to what the
organisation already accepted. Works on held snapshots for the same reason
the inventory does.
Every plugin and skill on either side is returned with a status, so a client can render either the whole inventory or only the changes.
| Field | Meaning |
|---|---|
baselineSnapshotId, baselineSha |
The approved snapshot compared against, or null when the marketplace has none |
plugins[].status |
added, removed, changed or unchanged — changed when any skill under it differs or when its manifest entry does |
plugins[].skills[].status |
added, removed, changed, moved or unchanged |
plugins[].skills[].movedFromPlugin |
The plugin the skill was declared under before it moved, else null |
summary |
Skill counts per status |
A skill is changed when anything under its directory differs — not only its
SKILL.md — because the git tree object of the directory is what is compared.
A skill one plugin gave up and another took over is reported once, on its new
plugin, as moved; if its content changed too, the status is changed and
movedFromPlugin is still set. With no approved snapshot, the baseline fields
are null and everything is added.
{"snapshotId":43,"sha":"7c1d4ef...","state":"held",
"baselineSnapshotId":42,"baselineSha":"3f9c2ab...",
"plugins":[{"name":"acme-tools","description":"...","source":"./plugins/acme-tools",
"status":"changed",
"skills":[{"name":"deploy","path":"plugins/acme-tools/skills/deploy/SKILL.md",
"status":"unchanged","movedFromPlugin":null},
{"name":"rollback","path":"plugins/acme-tools/skills/rollback/SKILL.md",
"status":"moved","movedFromPlugin":"acme-legacy"}]}],
"summary":{"added":0,"removed":0,"changed":0,"moved":1,"unchanged":1}}
Note
This is not the preview diff. That one is a file-level unified diff against the commit the facade is currently serving; this one is an inventory diff against the last commit that was approved, and returns no file text.
200 · 404 unknown snapshot.
GET /snapshots/{id}/licenses¶
The licenses the snapshot declares, each with its standing under the
configured allow/ban policy. Detection is
deterministic — SPDX ids resolved from license/copying files anywhere in the
tree, SPDX-License-Identifier tags inside them, and the marketplace
manifest's license metadata fields — and runs over the content pinned to the
snapshot's commit SHA, so the report exists for every snapshot, held
included.
{"snapshotId":42,"sha":"3f9c2ab...",
"licenses":[
{"spdxId":"MIT","source":"file","location":"LICENSE",
"declared":null,"evaluation":"OK"},
{"spdxId":null,"source":"file","location":"plugins/acme-tools/COPYING",
"declared":null,"evaluation":"UNKNOWN"},
{"spdxId":"ISC","source":"manifest",
"location":".claude-plugin/marketplace.json#plugins[acme-tools].license",
"declared":"ISC","evaluation":"OK"}],
"allowed":["MIT","Apache-2.0"],"banned":["AGPL-3.0"]}
A spdxId of null is the unknown license state — the source identified
no known license. An empty licenses array means the snapshot carries no
license information at all. evaluation is one of OK, BANNED,
NOT_ALLOWED, UNKNOWN.
This read reports current policy truth: it is recomputed under the
configuration in force, complementing the gateway's own
SBOM endpoint (/actuator/sbom) and the content
inventory above as the supply-chain read surface. The evidence the approval gate acted on is the
recorded vetting run, not this report.
200 · 404 unknown snapshot.
Snapshot preview¶
Read-only inspection of a snapshot's pinned content: the file tree, individual
files, and the diff against what the marketplace currently serves. Everything
resolves through the quarantine repository's object store at the pinned commit
— paths are matched against tree entries only, so a path the commit does not
contain (traversal shapes included) is simply not found. Works on held
snapshots: inspecting content must not require serving it.
These reads return raw held content, so unlike the metadata reads above they are privileged: an admin, or an approver of the snapshot's marketplace. Everyone else gets 403.
Each listing is paged: a response holds at most one page, total counts
the whole set it pages over, nextOffset is the offset of the next page
(absent on the last one), and "truncated": true means more pages follow. The
page size bounds a response. It does not limit the snapshot: every path of any
snapshot can be reached. The pinned commit never changes, so an offset names
the same entries on every call. The diff's baseline can move between two calls,
when an approval changes what is served. Every diff page names its
baselineSha, so a caller can tell. A negative offset is 400, and an
offset past the end is an empty last page.
GET /snapshots/{id}/tree¶
Query: dir (optional), offset (default 0).
One directory of the pinned commit, the root when dir is absent: its direct
children, directories first and then files, each by name, 500 per page. Each
child carries its status against the served commit (added, modified,
removed, or absent when unchanged). Paths the snapshot removes are listed
too, with no size. A directory child carries files, the files beneath it at
any depth, and changed, the paths beneath it that differ from what is served,
removed ones included. The response carries the same two counts for dir
itself, so the root's files and changed are the snapshot's totals. With
nothing served, baselineSha is absent and every path is added.
{"snapshotId":43,"sha":"9d41f00...","baselineSha":"3f9c2ab...","dir":"plugins",
"files":3261,"changed":12,"total":2,"truncated":false,
"entries":[{"name":"acme-tools","path":"plugins/acme-tools","kind":"directory",
"files":3260,"changed":12},
{"name":"README.md","path":"plugins/README.md","kind":"file",
"size":412,"status":"modified"}]}
200 · 400 negative offset · 403 no applicable role · 404
unknown snapshot, or dir is not a directory of the pinned or the served tree.
Traversal shapes are included in that 404.
GET /snapshots/{id}/files¶
Query: q (optional), offset (default 0).
Every path in the pinned commit's tree, in tree order, with blob sizes, 2000 per
page. With q, only the paths whose full path contains it, compared without
regard to case. This is the path search, and total counts every match in the
snapshot.
{"snapshotId":42,"sha":"3f9c2ab...","total":2,"truncated":false,
"entries":[{"path":".claude-plugin/marketplace.json","size":180},
{"path":"plugins/acme-tools/skills/deploy/SKILL.md","size":841}]}
200 · 400 negative offset · 403 no applicable role · 404 unknown snapshot.
GET /snapshots/{id}/file?path={path}¶
One blob, as text for rendering only. Content is cut at 128 KiB with
"truncated": true and the full size still reported; a blob detected as
binary returns metadata with no text at all.
{"snapshotId":42,"path":"plugins/acme-tools/skills/deploy/SKILL.md",
"size":841,"binary":false,"truncated":false,"text":"# Deploy\n..."}
200 · 403 no applicable role · 404 unknown snapshot, or the path is not in the pinned tree.
GET /snapshots/{id}/diff¶
Query: path (optional), offset (default 0).
The delta a reviewer decides: added, modified and removed paths between the
pinned commit and the marketplace's currently served commit (the published
repository's served tip — the same commit a git fetch returns), with a
unified text diff per non-binary entry under the same 128 KiB cap, 500 entries
per page.
total and summary count the whole diff, not the page. summary holds the
paths added, modified and removed, the binary entries, and linesAdded
and linesRemoved, which are counted from the full content even where an
entry's text is cut. path narrows the diff the way a git pathspec does:
exactly one file, or everything beneath one directory. The counts then cover
the narrowed diff. A path in neither tree, traversal shapes included, narrows
it to nothing.
When the marketplace serves nothing — never approved, or its content was
revoked or unpublished — baselineSha is null and every path is reported as
added, without diff text or line counts: approving the snapshot would serve
all of it.
{"snapshotId":43,"sha":"9d41f00...","baselineSha":"3f9c2ab...",
"total":603,"truncated":true,"nextOffset":500,
"summary":{"added":2,"modified":600,"removed":1,"binary":1,
"linesAdded":601,"linesRemoved":1},
"entries":[{"path":"plugins/acme-tools/skills/deploy/SKILL.md","type":"modified",
"binary":false,"truncated":false,
"diff":"--- a/...\n+++ b/...\n@@ -1 +1 @@\n-old\n+new\n"}]}
200 · 400 negative offset · 403 no applicable role · 404 unknown snapshot.
GET /snapshots/{id}/vetting¶
The snapshot's latest vetting chain run: each vetter's verdict in chain
order, the findings behind it, the waivers currently suppressing any of them,
and the fail-closed effective aggregate that gates approval. A snapshot the
chain has never run against reports "outcome":"BLOCKED" and "run":null.
{"snapshotId":12,"outcome":"CLEAR_WITH_WAIVERS","recordedOutcome":"BLOCKED",
"run":{"runId":5,"snapshotId":12,"trigger":"ingestion","outcome":"BLOCKED",
"startedAt":"...","finishedAt":"...",
"verdicts":[
{"verdictId":9,"vetter":"secret-scan","position":0,"state":"FAIL",
"detail":"1 finding(s); worst critical","reportUrl":null,
"findings":[{"id":"aws-access-key-id","severity":"CRITICAL",
"location":"plugins/hello/DEPLOY.md:5",
"message":"an AWS access key id is committed in this file",
"content":"3b18e512dba79e4c8300dd08aeb37f8e728b8dad"}],
"groups":[{"ruleId":"aws-access-key-id","severity":"CRITICAL",
"message":"an AWS access key id is committed in this file",
"content":"3b18e512dba79e4c8300dd08aeb37f8e728b8dad","line":5,
"locations":["plugins/hello/DEPLOY.md:5"]}]},
{"verdictId":10,"vetter":"prompt-injection","position":1,
"state":"PASS","detail":null,"reportUrl":null,"findings":[]}]},
"suppressed":[{"vetter":"secret-scan","ruleId":"aws-access-key-id",
"location":"plugins/hello/DEPLOY.md:5","waiverId":3,
"approvedBy":"alice","expiresAt":"2026-09-30T23:59:59Z"}],
"uncovered":[],
"waivers":[{"id":3,"marketplace":"corp-marketplace","ruleId":"aws-access-key-id",
"scope":"SNAPSHOT","scopeValue":"a1b2c3…","justification":"documented dummy key",
"approvedBy":"alice","createdAt":"...","expiresAt":"2026-09-30T23:59:59Z",
"revokedAt":null,"revokedBy":null,"active":true}],
"vetters":[{"name":"secret-scan","order":100,"description":"...","version":"3","external":false},
{"name":"prompt-injection","order":200,"description":"...","version":"1","external":false}]}
| Field | Meaning |
|---|---|
outcome |
The effective outcome — the one that gates approval: CLEAR, CLEAR_WITH_WAIVERS, or BLOCKED. Recomputed on every request from the run and the waivers active at that instant. |
recordedOutcome |
What the vetters themselves concluded: CLEAR or BLOCKED. Never rewritten by a waiver. |
suppressed |
The findings an active waiver is currently removing from the computation. |
uncovered |
The blocking finding groups no active waiver covers — the waivers approval still needs. One entry per group: location is its first location, locations all of them, and content and line name the group for a group waiver. |
waivers |
The marketplace's waivers whose rule appears in this run, active and lapsed alike. |
vetters |
The configured chain, in the order it runs: name, order, description, version (the rule set the vetter currently carries) and external (the verdict is delegated to an operator-configured service rather than reached by a built-in vetter). |
override |
Present when an administrator approved this snapshot over a blocked outcome (reason, blockingVetters, uncoveredFindings, overriddenBy, overriddenAt); null otherwise. Its presence is what surfaces the override so it is never indistinguishable from a clean approval. See The vetting override. |
Each finding carries content, the git blob id of its file in the pinned tree.
The gateway sets it; a vetter cannot. It is null when the location names no
file. Each verdict's groups are its findings with identical content collapsed:
the findings that share rule, severity, message, content and line become one
entry listing every location. groups is derived on read and never stored.
state is one of PASS, WARN, FAIL, ERROR, PENDING, DISABLED;
severity is one of INFO, LOW, MEDIUM, HIGH, CRITICAL. A DISABLED
verdict records that an administrator switched that vetter off for the
snapshot's marketplace (vetter settings); it is
neither clearing nor blocking, but a run still needs one clearing verdict to
clear, so disabling every vetter leaves a run BLOCKED. What each state means
is described in Vetting — the vetter chain.
| Status | Cause |
|---|---|
| 200 | The latest chain run, its waivers, and the configured chain. |
| 404 | Unknown snapshot. |
Vetter enable/disable¶
An administrator can switch any vetter in the chain off or on, globally or for one marketplace — a built-in (secret-scan, prompt-injection, license-scan, skill-conformance) or one of the operator's own external connectors. A name no vetter in the chain carries is refused with a 422 naming the ones it does. Both endpoints are admin-only — the switch that governs the vetting chain, and even the visibility of its settings, are not shown to marketplace-scoped approvers.
A disabled vetter is not run at ingestion or re-vetting; the chain records
a DISABLED verdict in its place, so the disablement is part of the run's
evidence rather than a silently shorter chain. Disabling every vetter leaves a
run BLOCKED, never cleared — the switch is not a blanket approval.
GET /vetting/vetter-toggles¶
Lists every enable/disable setting — the global settings and the per-marketplace overrides.
| Status | Cause |
|---|---|
| 200 | The vetter settings. |
| 403 | Caller does not hold the administrative role. |
GET /marketplaces/{name}/vetting-chain¶
The marketplace's effective chain: every configured vetter in the order it runs, the state its enablement resolves to for this marketplace, and which setting decided that. The resolution is the chain's own — not a recombination of the settings list — so it cannot disagree with what actually runs.
The array order is the order the chain runs in, including any administrator's
arrangement. Each entry's order field stays the
vetter's own configured position, which is the tie-break the resolved order falls
back on — it is not the step number.
[{"name":"secret-scan","order":100,"description":"...","version":"3","external":false,
"enabled":false,"source":"MARKETPLACE","reason":"vendor keys, expected",
"updatedBy":"alice","updatedAt":"2026-08-20T09:00:00Z"},
{"name":"prompt-injection","order":200,"description":"...","version":"1","external":false,
"enabled":true,"source":"GLOBAL","reason":null,"updatedBy":"root",
"updatedAt":"2026-08-01T09:00:00Z"},
{"name":"license-scan","order":300,"description":"...","version":"1","external":false,
"enabled":true,"source":"DEFAULT","reason":null,"updatedBy":null,"updatedAt":null}]
| Field | Meaning |
|---|---|
enabled |
Whether the vetter runs for this marketplace. |
source |
MARKETPLACE (a setting scoped to this marketplace), GLOBAL (the global setting), or DEFAULT (no setting at all, so it runs). The absence of a setting is its own source, never a missing value. |
reason, updatedBy, updatedAt |
The note, the acting administrator and the time of whichever setting decided the state; null for DEFAULT. |
external |
The verdict is delegated to an operator-configured external service. |
| Status | Cause |
|---|---|
| 200 | The effective chain, in chain order. |
| 403 | Caller does not hold the administrative role. |
| 404 | Named marketplace not found. |
GET /vetting/global-chain¶
The chain a marketplace with no override of its own runs: every configured
vetter in the order it runs there, the state its enablement resolves to, and which
setting decided it. The same shape as
GET /marketplaces/{name}/vetting-chain,
one resolution level up — the gateway resolves it, so an estate-wide surface
cannot disagree with the per-marketplace one.
source is GLOBAL or DEFAULT here; MARKETPLACE cannot occur.
| Status | Cause |
|---|---|
| 200 | The default chain, in chain order. |
| 403 | Caller does not hold the administrative role. |
PUT /vetting/vetters/{name}/toggle¶
| Field | Required | Meaning |
|---|---|---|
enabled |
yes | true to run the vetter, false to switch it off. |
marketplace |
no | Scope the setting to one marketplace; omit for the global setting. A per-marketplace setting overrides the global one. |
reason |
no | A note recorded with the change and on the audit ledger. |
Each toggle is audited (vetter-disabled / vetter-enabled) naming the
administrator, the vetter, the scope and the new state.
| Status | Cause |
|---|---|
| 200 | The setting after the change. |
| 403 | Caller does not hold the administrative role. |
| 404 | Named marketplace not found. |
| 422 | Unknown vetter, or enabled omitted. |
Chain mode and vetter order¶
Two settings shape a chain run beyond which vetters are switched on: how far the chain goes, and the order it goes in. Both resolve exactly as the on/off switch does — the setting scoped to the marketplace, else the global setting, else the default — and both are admin-only to set and to read, for the same reason: they decide how much evidence stands behind every approval in a marketplace.
| Chain mode | What it does |
|---|---|
run-all |
Every enabled vetter runs. The default. |
stop-after-fail |
The chain stops after the first vetter whose verdict still objects once active waivers are applied; the vetters after it are recorded NOT_REACHED rather than run. |
A run carrying a NOT_REACHED verdict is blocked whatever waivers exist.
Accepting the finding that stopped the chain lets the next run get further; it
does not clear the run whose later vetters never looked. See
Stopping the chain after a failure.
GET /vetting/chain-settings¶
Every chain-mode and vetter-order setting — the global settings and the per-marketplace overrides.
{"modes":[{"id":1,"marketplaceId":null,"mode":"run-all","reason":null,
"updatedBy":"root","updatedAt":"2026-09-10T09:00:00Z"}],
"orders":[{"id":1,"marketplaceId":7,"vetters":["prompt-injection","secret-scan"],
"reason":"cheapest first","updatedBy":"alice",
"updatedAt":"2026-09-10T09:01:00Z"}]}
| Status | Cause |
|---|---|
| 200 | The chain settings. |
| 403 | Caller does not hold the administrative role. |
GET /vetting/global-chain-settings¶
The mode and the order a marketplace with no override of its own runs, and
which setting decided each. The same shape as
GET /marketplaces/{name}/vetting-chain-settings
one resolution level up; modeSource and orderSource are GLOBAL or DEFAULT
here, never MARKETPLACE.
| Status | Cause |
|---|---|
| 200 | The default chain settings. |
| 403 | Caller does not hold the administrative role. |
GET /marketplaces/{name}/vetting-chain-settings¶
The marketplace's effective mode and order, and which setting decided each. A sibling of the chain read rather than part of it: these are properties of the chain, not of any one vetter.
{"mode":"stop-after-fail","modeSource":"MARKETPLACE",
"modeReason":"the external reviewer is billed per call",
"modeUpdatedBy":"alice","modeUpdatedAt":"2026-09-10T09:00:00Z",
"order":["prompt-injection","secret-scan","license-scan"],
"orderOverride":["prompt-injection","secret-scan"],
"orderSource":"MARKETPLACE","orderReason":"cheapest first",
"orderUpdatedBy":"alice","orderUpdatedAt":"2026-09-10T09:01:00Z"}
| Field | Meaning |
|---|---|
mode |
run-all or stop-after-fail, as it resolves for this marketplace. |
order |
Every configured vetter, in the order this marketplace runs them. |
orderOverride |
The arrangement an administrator set, which need not name every vetter; empty when none is set. |
modeSource, orderSource |
MARKETPLACE, GLOBAL or DEFAULT — the absence of a setting is its own source, never a missing value. |
modeReason/orderReason, …UpdatedBy, …UpdatedAt |
The note, the acting administrator and the time of whichever setting decided it; null for DEFAULT. |
| Status | Cause |
|---|---|
| 200 | The effective chain settings. |
| 403 | Caller does not hold the administrative role. |
| 404 | Named marketplace not found. |
PUT /vetting/chain-mode¶
| Field | Required | Meaning |
|---|---|---|
mode |
yes | run-all or stop-after-fail. |
marketplace |
no | Scope the setting to one marketplace; omit for the global setting. |
reason |
no | A note recorded with the change and on the audit ledger. |
Audited as vetting-chain-mode-set, naming the administrator, the scope, the
new mode and the note. A refused change writes nothing.
| Status | Cause |
|---|---|
| 200 | The setting after the change. |
| 403 | Caller does not hold the administrative role. |
| 404 | Named marketplace not found. |
| 422 | Unknown mode, or mode omitted. |
PUT /vetting/chain-order¶
| Field | Required | Meaning |
|---|---|---|
vetters |
yes | Vetter names, in the order they should run. It need not name every vetter. |
marketplace |
no | Scope the setting to one marketplace; omit for the global setting. |
reason |
no | A note recorded with the change and on the audit ledger. |
The vetters the arrangement does not name run after those it does, in their
configured positions with ties broken by name, so an arrangement can never drop a
vetter by omission. Audited as vetting-chain-order-set.
| Status | Cause |
|---|---|
| 200 | The setting after the change. |
| 403 | Caller does not hold the administrative role. |
| 404 | Named marketplace not found. |
| 422 | Empty order, unknown vetter, or a vetter named twice. |
POST /vetting/chain-settings/bulk¶
One chain change addressed to several marketplaces as a single audited act, and the only way to remove a per-marketplace override. Admin-only.
| Field | Required | Meaning |
|---|---|---|
marketplaces |
yes | The marketplaces the change applies to. Blanks are refused; duplicates are folded. |
action |
yes | set-mode, set-order, set-vetter or clear. |
mode |
for set-mode |
run-all or stop-after-fail. |
vetters |
for set-order |
Vetter names, in the order they should run. |
vetter, enabled |
for set-vetter |
Which vetter to switch, and to what. |
clear |
for clear |
Any of mode, order, vetters — which kinds of override to remove. |
reason |
no | A note recorded with every change and on every ledger entry the request causes. |
Clearing an override is a removal, not a write. An override equal to the
default still pins the marketplace — its source stays MARKETPLACE, so a later
change to the global setting does not reach it. Only clear puts the source back
to GLOBAL or DEFAULT. Clearing is idempotent: a marketplace with no override
of that kind is reported UNCHANGED and writes nothing, to the store or to the
ledger. vetters clears every vetter override that marketplace holds, auditing
each by name.
Validated whole, applied per marketplace. Everything knowable without touching a marketplace — an empty selection, an unknown action, mode, vetter or override kind, an empty order, a vetter named twice — is refused with 422 before anything is stored or recorded. Each named marketplace is then attempted independently: one that no longer resolves fails alone.
{"correlationId":"9f1c8e2a-…","applied":1,"unchanged":0,"failed":1,
"results":[{"marketplace":"corp","status":"APPLIED","detail":"mode=stop-after-fail"},
{"marketplace":"gone","status":"FAILED","detail":"marketplace 'gone' not found"}]}
| Field | Meaning |
|---|---|
correlationId |
Carried on every ledger entry this request caused, as bulk=<id> in the entry's detail. An auditor reads the entries as one act rather than a loop. |
applied / unchanged / failed |
A summary of results, never a substitute for it. |
results[].status |
APPLIED, UNCHANGED (nothing to clear) or FAILED. |
results[].detail |
What changed, or the server's reason for refusing. |
Each affected marketplace receives its own ledger entry, through the same
service a single-scope request goes through — vetting-chain-mode-set,
vetting-chain-order-set, vetter-enabled / vetter-disabled, or the clears
vetting-chain-mode-cleared, vetting-chain-order-cleared and
vetter-toggle-cleared. There is no summary entry: the correlation id is the
thread, and a summary that could disagree with the entries it summarises would be
a liability.
| Status | Cause |
|---|---|
| 200 | Every named marketplace was applied or already in the requested state. |
| 207 | At least one named marketplace was refused. Read results. |
| 403 | Caller does not hold the administrative role. |
| 422 | Unknown action, mode, vetter or override kind; an empty selection; an empty or invalid order. |
207 is not a success
A client that reads only the status code learns that something did not happen;
one that reads applied alone would report a partial failure as a success.
Read results.
Vetting waivers¶
A waiver accepts one finding rule, on one marketplace, within one scope, until one expiry. See Waiving a vetting finding for the task and the concept page for the matching rules.
POST /snapshots/{id}/waivers¶
{"ruleId":"aws-access-key-id","scope":"SNAPSHOT",
"justification":"documented dummy key in the fixtures directory",
"expiresAt":"2026-09-30T23:59:59Z"}
| Field | Required | Notes |
|---|---|---|
ruleId |
yes | The finding's stable rule id. |
scope |
yes | SNAPSHOT or PATH. |
path |
for PATH |
Repository-relative; must not contain ... Ignored for SNAPSHOT. |
justification |
yes | Free text; blank is refused. |
expiresAt |
yes | Must be in the future and at most 90 days after the request; a later instant is refused, not shortened. There is no unlimited waiver. |
content |
no | A finding group's git blob id (40 or 64 lower-case hex characters), as groups or uncovered give it. It limits the waiver to that group: the rule on that blob and line, in this snapshot, and nothing else. Only with SNAPSHOT. |
line |
no | The group's line; omit it for a group without one. Needs content. |
The marketplace — and, for SNAPSHOT scope, the commit SHA — are taken from the
snapshot, so a waiver cannot be scoped to content it does not belong to. The
approver is the acting session.
| Status | Cause |
|---|---|
| 201 | Waiver recorded; returns it with active. |
| 400 | Missing justification or expiry, an expiry in the past or more than 90 days ahead, an unusable scope, or a group qualifier that is malformed or not on SNAPSHOT scope. |
| 404 | Unknown snapshot. |
GET /marketplaces/{name}/waivers¶
Every waiver of the marketplace, newest first, active and lapsed alike. A lapsed
or revoked waiver is returned with "active":false — the record of what was
once accepted is part of the audit trail.
| Status | Cause |
|---|---|
| 200 | The marketplace's waivers. |
| 404 | Unknown marketplace. |
DELETE /waivers/{id}¶
Revokes the waiver. It stops suppressing its finding on the next read, so a snapshot cleared only by it becomes blocked again immediately. The row is kept, with its revoker and time.
| Status | Cause |
|---|---|
| 200 | Revoked; returns the waiver with active:false. |
| 404 | Unknown waiver, or already revoked. |
Continuous re-vetting¶
Re-running the chain over content that is already approved. What a fresh
violation does is a deployment setting, not a request parameter — see
Re-vetting approved content and the
revet configuration block.
POST /snapshots/{id}/revet¶
Runs the chain again over the snapshot's pinned content, recording a new run
with trigger revet-manual.
Accepts a snapshot in state approved or held, and the two mean different
things:
approved— a re-vetting, described below: the run is classified, a violation is written to the ledger and announced, and inenforcemode the snapshot is revoked and unpublished.held— a refresh. The chain runs and the run is recorded, and that is all. The snapshot stays held,revokedis alwaysfalse,affectedis always empty, and nothing is announced: there is nothing published to retract. This is what makes a chain staleness marking actionable — the next approval reads the new run.
A rejected or revoked snapshot is refused with 409.
{"snapshotId":12,"marketplace":"corp-marketplace","sha":"3f9c2ab…","runId":31,
"classification":"VIOLATION","outcome":"BLOCKED","revoked":true,"mode":"ENFORCE",
"uncovered":[{"vetter":"secret-scan","ruleId":"aws-access-key-id",
"location":"plugins/hello/DEPLOY.md:5","severity":"CRITICAL",
"message":"an AWS access key id is committed in this file"}],
"affected":[{"principal":"team-payments","fetches":12,
"lastFetch":"2026-08-14T22:10:00Z"}]}
| Field | Meaning |
|---|---|
classification |
CLEAR, VIOLATION (the chain objects to the content), or INCONCLUSIVE (it blocks only because a vetter errored, timed out, or has not answered). |
outcome |
The effective vetting outcome after the waivers active at that instant. |
revoked |
Whether this run revoked and unpublished the snapshot. Always false in warn mode and for INCONCLUSIVE. |
mode |
The re-vetting mode in force. |
uncovered |
The blocking findings no active waiver covers — the reason for a violation. |
affected |
Identities that had already fetched this snapshot's content. |
| Status | Cause |
|---|---|
| 200 | The run and what it concluded. |
| 404 | Unknown snapshot. |
| 409 | The snapshot is not approved. Only served content is re-vetted. |
POST /marketplaces/{name}/revet¶
The same, over every live approved snapshot of the marketplace. This is the operational answer to a scanner rule set or advisory feed that has just moved: the built-in vetters have no feed to subscribe to, so an operator calling this after updating one is how a feed update becomes fresh evidence.
Returns a pass summary — revetted, violations, revoked, inconclusive,
and the individual results.
| Status | Cause |
|---|---|
| 200 | What the pass re-vetted and concluded. |
| 404 | Unknown marketplace. |
GET /snapshots/{id}/fetchers¶
Every authenticated identity this snapshot's content was sent to through the git facade, with how often and when it last was — the blast radius of a retroactive violation, read from the fetch ledger.
Only pack transfers count. A ref advertisement means a client asked, not that it received anything, so counting it would name teams that never got the content.
A transfer is counted when it starts, so a client that abandoned the fetch still appears. The report over-reports rather than missing a holder, which is the direction a recall wants — but it is not a record of receipt.
This read is privileged: an admin, or an approver of the snapshot's marketplace. It names identities, so seeing it is the same judgement as acting on the revocation it informs.
| Status | Cause |
|---|---|
| 200 | The identities that fetched the snapshot's content. |
| 403 | The caller may not approve that snapshot. |
| 404 | Unknown snapshot. |
POST /snapshots/{id}/approve¶
The only endpoint that publishes. Fetches the pinned quarantine ref into the
published repository and force-updates refs/heads/main to that SHA.
The request body is optional. A snapshot whose effective vetting outcome is
blocked is refused, and the problem document carries both blockingVetters
and uncoveredFindings:
{"status":409,"title":"Vetting chain blocked this snapshot",
"detail":"snapshot 12 cannot be approved: …",
"blockingVetters":["secret-scan"],
"uncoveredFindings":[{"vetter":"secret-scan","ruleId":"aws-access-key-id",
"location":"plugins/hello/DEPLOY.md:5",
"locations":["plugins/hello/DEPLOY.md:5"],
"content":"3b18e512dba79e4c8300dd08aeb37f8e728b8dad","line":5,
"severity":"CRITICAL",
"message":"an AWS access key id is committed in this file"}]}
uncoveredFindings is the complete worklist, one entry per finding group (one
rule on one line of identical content, with every path:line it occurs at).
detail names each group at its locations, the first ten spelled out. Record a
waiver for each entry (a group waiver covers every
location of one) and the approval succeeds. Every waiver that was in force is appended to the audit
ledger as waiver-applied.
A snapshot inside the configured minimum release age is refused by the same status with a different problem document — one nothing in the API can unblock, because it clears itself:
{"status":409,"title":"Snapshot has not reached the minimum release age",
"detail":"snapshot 12 cannot be approved yet: …",
"configKey":"skills-gateway.vetting.minimum-release-age",
"minimumReleaseAge":"PT72H",
"eligibility":{"snapshotId":12,"eligible":false,
"firstIngestedAt":"2026-08-16T09:00:00Z",
"eligibleAt":"2026-08-19T09:00:00Z",
"ageSeconds":57600,"remainingSeconds":187200,
"minimumReleaseAgeSeconds":259200}}
Under an enforcing four-eyes rule an approval by an identity on the snapshot's supply side is refused by the same status, with the conflicting acts named:
{"status":409,"title":"Four-eyes rule refused this approval",
"detail":"four-eyes rule refused approval of snapshot 12: …",
"configKey":"skills-gateway.approval.four-eyes.mode",
"conflicts":[{"role":"registered-by","principal":"dana","waiverId":null},
{"role":"ingested-by","principal":"dana","waiverId":null},
{"role":"waiver-author","principal":"dana","waiverId":7}]}
Nothing in the API unblocks this one either: a different identity has to
approve. Under the default warn mode the same conflicts are detected, the
approval succeeds, and a four-eyes-conflict entry is appended to the audit
ledger beside snapshot-approved.
A snapshot introducing a plugin name that looks like one another marketplace
already serves is refused by the same status, until a
waiver on rule
plugin-name-collision covers it. Each colliding name is listed with the
manifest line that declares it and every approved snapshot it collides with:
{"status":409,"title":"A plugin name collides with the approved estate",
"detail":"snapshot 12 cannot be approved: plugin 'c0de-review' at .claude-plugin/marketplace.json:6 collides with …",
"ruleId":"plugin-name-collision",
"collisions":[{"pluginName":"c0de-review",
"location":".claude-plugin/marketplace.json:6",
"incumbents":[{"marketplace":"acme-tools","snapshotId":4,"pluginName":"code-review"}],
"finding":{"id":"plugin-name-collision","severity":"HIGH",
"location":".claude-plugin/marketplace.json:6","message":"…"},
"waiver":null,"covered":false}]}
The administrative override of a blocked vetting outcome does not lift it. A
snapshot whose plugin names cannot be read from its pinned manifest is refused
too, with the title The snapshot's plugin names could not be read and no
collisions: it cannot be shown not to collide. Both refusals are appended to
the audit ledger as snapshot-approval-refused, the first with the detail
plugin-name-collision: … naming the incumbents.
Ahead of every gate above, a snapshot whose recorded closure does not describe the commit it pins is refused, and every discrepancy is named:
{"status":409,"title":"Snapshot closure is incomplete",
"detail":"snapshot 12 cannot be approved: its recorded closure does not describe the commit it pins — …",
"discrepancies":["the served manifest declares plugin 'tools' at _plugins/tools, which the recorded closure does not contain"]}
Nothing the gateway does produces this state, so there is no override for it:
it means something other than the gateway has altered the snapshot's rows or
its pinned commit, and re-ingesting the marketplace is the remedy. The refusal
is appended to the audit ledger as snapshot-approval-refused.
| Status | Cause |
|---|---|
| 200 | Approved; returns the snapshot with decidedBy and decidedAt. |
| 404 | Unknown snapshot. |
| 409 | The snapshot is neither held nor revoked, its recorded closure does not describe the commit it pins, its effective vetting outcome is blocked and no override was supplied, a policy rule denied it, a plugin name it introduces collides with the approved estate and no waiver covers it, its plugin names could not be read, it has not reached the minimum release age, or an enforcing four-eyes rule refused it. |
| 422 | An override was requested (overrideVetting: true) without a reason. |
A revoked snapshot is approved through this same endpoint and no other — there
is no un-revoke. The gate is unchanged, so the finding that revoked it must be
waived or fixed first; the transition records a fresh reviewer and clears the
revocation marks.
Administrative override of a blocked outcome¶
The airline-cockpit escape hatch: an administrator — and only an administrator — may approve a snapshot whose effective outcome is blocked by sending a body:
The override lifts only the vetting gate — the policy, minimum-release-age
and four-eyes gates still run. A reason is required (a reasonless override is
422). The override writes a distinct audit event,
snapshot-approved-over-vetting-failure, naming the administrator, the reason
and the blocking verdicts, and it marks the snapshot so GET
/snapshots/{id}/vetting reports an override — an override is never
indistinguishable from an approval the chain cleared. A marketplace-scoped
approver, who may approve a clean snapshot, cannot override a blocked one.
A blocked snapshot can still be published
There are two deliberate ways past a block, and each leaves its own trail. A waiver is a scoped, expiring acceptance of one finding that any reviewer may record; the ledger says which risk was accepted, by whom, and until when. An override is a one-off, whole-outcome act reserved to an administrator, who states a reason and is named on a distinct ledger event. Neither is a silent bypass.
Reversing an administrative withdrawal¶
A snapshot an administrator
withdrew cannot be approved by an ordinary approval:
the request is refused 409 with a body naming revokedBy, revokedAt and
reason. Lifting it takes a different administrator:
| Refusal | Status | Cause |
|---|---|---|
| An ordinary approval of a withdrawn commit | 409 | Names the withdrawal; use the body above |
| The administrator who withdrew it | 409 | Reversing takes a second identity, unconditionally |
| No reason, or an empty one | 422 | The reason is the record |
| Nothing was withdrawn | 409 | A reversal that did nothing would make the marker's absence ambiguous |
Every other approval gate still runs. A waiver does not lift a withdrawal — waivers clear vetting findings, and a withdrawal has none to clear. The snapshot keeps a standing marker afterwards, so content served over a reversed withdrawal is never indistinguishable from content nobody withdrew.
POST /snapshots/{id}/revoke¶
Withdraw an approved snapshot from the facade. Admin-only, and unreachable by a machine credential — the reason is the whole of the accountability for an act that takes one identity, and a credential in a pipeline cannot supply one that means anything.
$ 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"}'
{"snapshot":{"id":42,"state":"revoked","revokedBy":"bob","revokedKind":"administrative",
"violation":"CVE-2026-0001 in a vendored dependency"},
"nowServing":{"id":41,"state":"approved","sha":"a1b2c3…"}}
| Field | Required | Meaning |
|---|---|---|
reason |
Yes | Why it is being withdrawn. Non-empty. Carried into the refusal any later approval of the same commit receives. |
serveAfter |
Yes | PREVIOUS_APPROVED or NOTHING. No default — see below. |
It withdraws whatever the vetting chain currently concludes and whatever mode re-vetting is in: the point is that the reason is knowledge the chain does not hold. There is no four-eyes rule — approval has one because publishing can do harm, and withdrawing only ever takes content off the wire.
serveAfter has no default and a request without it is refused 422.
Neither answer is safe to assume: PREVIOUS_APPROVED republishes older content
that may carry the same compromise, and NOTHING takes a marketplace down that
may have a clean predecessor. A PREVIOUS_APPROVED with no earlier approved
snapshot is refused 409 before anything is withdrawn, so nothing changes.
| Status | Cause |
|---|---|
| 200 | Withdrawn; nowServing is the snapshot now served, or null |
| 404 | Snapshot not found |
| 409 | Not approved, or no previous approved snapshot to return to |
| 422 | No reason, or no serveAfter |
Afterwards: the withdrawal is on the ledger as snapshot-revoked with
snapshot-unpublished beside it (and snapshot-rolled-back on a return),
marketplace.snapshot.revoked fires, and the forge mirror reconciles.
Quarantine is untouched. Retention may reclaim the content but
never the record.
This stops the gateway serving it; it does not delete what clients hold
Anyone who already cloned the content still has it on disk, and the gateway
does not reach into client machines.
GET /snapshots/{id}/fetchers names the blast
radius, and a client can discover the withdrawal for itself by asking
POST /status/v1/snapshots
about the commits it holds. Acting on the answer is the client's, so pair a
withdrawal with whatever
fleet check
your estate runs.
GET /snapshots/{id}/release-age¶
Whether the snapshot has cleared the minimum release age, and when it will if it has not. This is the same computation the approve endpoint gates on, so the two can never disagree.
{"snapshotId":12,"eligible":false,
"firstIngestedAt":"2026-08-16T09:00:00Z","eligibleAt":"2026-08-19T09:00:00Z",
"ageSeconds":57600,"remainingSeconds":187200,"minimumReleaseAgeSeconds":259200}
| Field | Meaning |
|---|---|
eligible |
Whether the snapshot may be approved now. Always true when the gate is off. |
firstIngestedAt |
When this gateway first ingested the commit. The age is counted from here — never from the commit's own timestamp. |
eligibleAt |
The instant it becomes approvable; equal to firstIngestedAt when the gate is off. |
ageSeconds |
How long ago the gateway first ingested the commit. |
remainingSeconds |
How much of the window is left; 0 when eligible. |
minimumReleaseAgeSeconds |
The configured window; 0 when the gate is off. |
| Status | Cause |
|---|---|
| 200 | The eligibility record above. |
| 404 | Unknown snapshot. |
GET /snapshots/{id}/four-eyes¶
Whether the four-eyes rule would object to the calling identity approving this snapshot, and what the configured mode would do about it. Decides nothing; the approve endpoint enforces the rule independently.
{"mode":"ENFORCE","refused":true,
"conflicts":[{"role":"registered-by","principal":"dana","waiverId":null},
{"role":"ingested-by","principal":"dana","waiverId":null}]}
| Field | Meaning |
|---|---|
mode |
WARN or ENFORCE, as configured. There is no mode that disables detection. |
conflicts |
Each supply-side act the caller performed on this snapshot: registered-by, ingested-by, or waiver-author with the waiverId they wrote. Empty for an independent reviewer. |
refused |
Whether an approval by this caller would be refused — true only when the mode is ENFORCE and conflicts is non-empty. |
The waiver clause is evaluated exactly as an approval would evaluate it, over the waivers that are actually suppressing findings on this snapshot right now — which is why this is answered by the server rather than derived by a client.
| Status | Cause |
|---|---|
| 200 | The record above. |
| 404 | Unknown snapshot. |
GET /snapshots/{id}/name-collisions¶
The plugin names this snapshot introduces to its marketplace that collide with a plugin of an approved snapshot of another marketplace, and whether each is covered by a waiver. Decides nothing; the approve endpoint runs the same evaluation and refuses while any collision is uncovered.
{"enabled":true,"inventoryAvailable":true,"refused":true,
"collisions":[{"pluginName":"c0de-review",
"location":".claude-plugin/marketplace.json:6",
"incumbents":[{"marketplace":"acme-tools","snapshotId":4,"pluginName":"code-review"}],
"finding":{"id":"plugin-name-collision","severity":"HIGH",
"location":".claude-plugin/marketplace.json:6","message":"…"},
"waiver":null,"covered":false}]}
| Field | Meaning |
|---|---|
enabled |
Whether approval.name-collision.enabled is on. Off, nothing is listed and nothing is refused. |
inventoryAvailable |
False when the snapshot's plugin names could not be read; an approval is then refused. |
collisions |
Each colliding name, where the manifest declares it, its incumbents, the finding a waiver accepts, and the covering waiver or null. |
refused |
Whether an approval requested now would be refused by this rule. |
What collides, and what is never compared, is in Vetting.
| Status | Cause |
|---|---|
| 200 | The record above. |
| 404 | Unknown snapshot. |
POST /snapshots/{id}/reject¶
Mark the snapshot rejected. No repository is touched; whatever was already
approved keeps serving. This is also the terminal answer to a revoked
snapshot nobody intends to waive.
Same status codes as approve, without its two refusals: neither the vetting outcome nor the minimum release age gates a rejection. Saying no to suspicious content is never something to wait for.
An approved snapshot cannot be re-decided
Both endpoints return 409 for a snapshot that is approved or rejected.
The only way out of approved is an enforced re-vetting violation, which
the gateway makes, not a caller.
GET /snapshots/{id}/provenance¶
Where the snapshot came from and who decided on it.
{"snapshotId":42,"marketplace":"acme","origin":"upstream",
"upstreamUrl":"https://github.com/acme/skills.git",
"upstreamSha":"3f9c2ab...","sha":"9e1d77c...",
"closure":{"id":7,"snapshotId":42,"digest":"4c2a…","upstreamSha":"3f9c2ab...",
"transformerVersion":"1","createdAt":"...",
"members":[{"pluginName":"tools","sourceType":"github",
"declaredSource":"acme/tools","declaredRef":null,"declaredSha":null,
"cloneUrl":"https://github.com/acme/tools",
"resolvedSha":"b7e0c1d...","treeSha":"d41a9f2...",
"graftPath":"_plugins/tools","objectCount":12,"inflatedBytes":4096}]},
"state":"approved","violation":null,"ingestedAt":"...",
"decidedBy":"alice@example.com","decidedAt":"..."}
upstreamSha is the commit ingested from upstream and sha the commit served;
they differ only for a composite snapshot. closure is the
resolved closure
of external plugin sources, and null for a snapshot that resolved none — which
is every snapshot of a gateway that has not enabled external sources.
200 · 404 unknown snapshot.
Every action on this page also appends an entry to the audit ledger carrying the acting principal.