Skip to content

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.

$ curl -X POST localhost:8080/api/v1/marketplaces/acme/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"}'
{"marketplace":{"name":"acme","syncMode":"webhook","...":"..."},
 "webhookSecret":"9f2c...ab41"}

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/ and agents/, or from the paths plugin.json declares instead.
  • Hooks merge hooks/hooks.json with the hook files and inline hooks declared in plugin.json and the marketplace entry, plus hooks in skill and agent frontmatter. For those, declaredBy is skill <name> or agent <name>.
  • MCP servers merge .mcp.json with plugin.json's mcpServers.
  • LSP servers merge .lsp.json with the lspServers of plugin.json and 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 in enforce mode 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, revoked is always false, affected is 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.

[{"principal":"team-payments","fetches":12,"lastFetch":"2026-08-14T22:10:00Z"}]

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:

{"overrideVetting": true, "reason": "vendor-signed key, accepted risk in TICKET-42"}

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:

{"reverseRevocation": true, "reason": "upstream published a fix; re-reviewed"}
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.