The Registry & Supply Chain
A Gridmason registry is the supply chain for every widget and plugin remote a host loads at runtime. It hosts signed, content-hashed ES-module remotes, serves them immutably by hash, and publishes the distribution state that tells hosts which of those remotes are still safe to load. One rule holds for every instance, and it shapes everything below: a registry is supply chain, never a control plane. Hosts reach it over outbound HTTPS (or signed offline bundles) and keep working when it is unreachable.
The registry is open source and self-hostable. Any organization can run a private
registry for its own widget ecosystem; the project also operates a public
flagship instance at registry.gridmason.dev. A self-hosted instance is a full
registry — its own trust roots, its own publisher records, its own review policy —
running the same released container image as the flagship, with no proprietary
fork. The wire formats that make this interoperable (manifest schema, signature
envelope, transparency-log entry, revocation feed, trust-root document, bundle
format) all live in the public @gridmason/protocol package, which also ships the
verification library hosts use. Any conforming registry interoperates, and any
host can verify any conforming registry.
This page is written for two audiences: the operator standing up and running an instance, and the integrator wiring a host to consume one.
What a registry does
A registry is a pipeline with a hot read path bolted alongside it. An artifact enters through publish intake, passes an automated review lane and a human review lane, gets countersigned and anchored in a transparency log, and only then becomes resolvable and servable. Distribution state (revoked / killed) rides over the top as a signed feed. Each stage is covered in depth in the API reference; this is the shape of the whole.
Publish intake
The gridmason CLI uploads a content-hashed, immutable artifact and its
publisher signature envelope to POST /v1/artifacts. An upload is an authenticated
publisher's submission of one manifest plus one entry module (and optional chunks,
schemas, and docs), each part carried as base64 bytes, plus a signed source archive
that the human reviewer builds against.
Two identity bindings gate every upload:
- Registered publisher — the verified
(issuer, subject)OIDC identity must own a publisher record, or the upload is refused403 not_registered. - Tag under the publisher's prefix — the artifact
tagmust fall under the publisher's registered namespace prefix (<prefix>-…), or it is refused403 tag_not_in_prefix. This is the check that stops one publisher shipping under another's name.
Intake validates the signature envelope structurally — it must be the
@gridmason/protocol SignatureEnvelope publisher half, the same shape the
countersign stage later consumes — but does not verify the signature
cryptographically or run any checks yet. It records the artifact in the submitted
state and returns the source-qualified artifact record. Immutability is enforced by a
database uniqueness constraint, not a read-then-write, so a re-upload of an existing
(publisher, tag, version) is refused 409 version_exists even under concurrency.
The reviewed hash is the runnable artifact; a publisher cannot swap code post-review.
Envelope format note. Intake now accepts only the protocol
SignatureEnvelopeshape. The bare DSSE shape that@gridmason/cli≤ 0.5.x uploaded is refused400 invalid_envelope; publishers upgrade to@gridmason/cli≥ 0.6.0. No registry configuration changes.
Automated review lane
Immediately after intake, the automated stage runs the shared checks module
(@gridmason/cli/checks, pinned ^0.6.0) verbatim — the identical code path
gridmason lint runs locally. This is the central design constraint: there is no
second implementation to drift, so a locally-green artifact passes automated review
by construction. The registry contributes only the glue that builds the check context
from the uploaded parts; it declares no check logic of its own.
The checks, in report order:
| Check id | What it enforces |
|---|---|
manifest.schema |
the manifest validates against the @gridmason/protocol manifest JSON Schema |
manifest.tag |
the tag is lowercase, hyphenated, and publisher-prefixed |
manifest.capabilities |
each capability's scope grammar is well-formed |
sdk.* |
SDK-adherence static analysis (raw network I/O outside the SDK, token reachability, obfuscation heuristics) |
deps.acyclic |
the requires graph is acyclic — a circular requires is rejected |
dom.abuse |
DOM-abuse heuristics over the served source |
A clean run transitions the artifact submitted → reviewing and hands it to the
human lane. Any failed check — or a manifest that will not parse — transitions it to
rejected. The report is persisted on the review case, and the transition emits an
audit event with actor system. The stage is deterministic: identical bytes always
produce the identical report and transition.
Human review lane
A reviewer lists the queue (GET /v1/review/queue, oldest first), reads a case's full
automated report (GET /v1/review/cases/:id), and records a verdict —
approve or reject — through POST /v1/review/cases/:id/verdict. Findings must map
to a check id present in the automated report, or to the literal manual sentinel for
a judgement made by hand; a finding against an unknown check id is refused
422 unknown_check_id.
Two separation rules are enforced structurally:
- reviewer ≠ author — the verdict author's OIDC identity is compared to the
artifact publisher's; a match is refused
403 self_review_forbidden. (The single disclosed exception is the flagship launch-phase waiver, described under Federation & trust below.) - appeal reviewer ≠ original reviewer — an appeal re-opens a rejected artifact as
a new
reviewingcase that records the original rejector as its excluded reviewer; a verdict from that identity is refused403 appeal_reviewer_forbidden.
The verdict write is single-shot and guarded on the case still being undecided, so two
reviewers racing the same case cannot both decide it — the loser gets
409 already_decided. The lane fails closed on integrity faults: if the publisher
record cannot be resolved (so reviewer ≠ author is unprovable) the verdict is refused
409 author_unresolved; if the artifact leaves reviewing concurrently (a
revoke/kill) it returns 409 transition_failed rather than reporting a success the
artifact never reached. An approve hands the artifact to countersign.
The mechanism above is part of the platform and identical on every instance. The policy — what a reviewer accepts, the trust tiers, the SLAs — belongs to each operator and is published on the policy page. There are no secret rules.
Countersigning and transparency anchoring
On the reviewing → approved transition the countersign stage:
- Binds — reproduces the signed release document (the
{ path → hash }map) from the artifact's stored content hashes and checks it hashes to the subject the publisher signed. A drift is refused, never countersigned. - Countersigns — signs the publisher signature with the separately-held registry countersign key, producing the protocol dual-signature envelope.
- Anchors — appends the release to the transparency log. A release approved under the flagship self-review waiver is flagged in the logged leaf.
- Emits — persists the
ReleaseDoc(file map + completed envelope + the full inclusion entry) for the serving surface. - Audits —
release.countersignedfor the signing act, thenrelease.loggedonce the release is both anchored and persisted, both under theregistry:countersignactor — never a reviewer identity.
The countersign key is ECDSA P-256 / SHA-256 (ES256) — the only algorithm the
protocol verify library accepts at format 1.x — and is held separately from review
staff (see custody, below). Anchoring is synchronous with approval and fail-closed:
if the transparency log is unreachable or rate-limits the submission, the stage records
the failure and does not mark the release logged. The approval verdict still stands;
the release is simply not emitted until anchoring succeeds. An un-anchored release is
never published as though it were.
Artifact serving (hash-addressed)
Once countersigned, an artifact's files are served by content hash with immutable cache headers, so a CDN caches them indefinitely and the registry API stays off a page load's critical path. Two anonymous, read-only, content-addressed surfaces:
| Route | Serves |
|---|---|
GET /v1/artifacts/:hash |
the exact immutable bytes of one artifact file, addressed by its content hash (sha2-256:<hex>) |
GET /v1/releases/:hash |
the countersigned release document { releaseDoc, envelope, logEntry } a host caches and verifies offline |
A hash is served only when a countersigned release document lists it. An unknown
hash is 404 unknown_hash; the signed source archive — content-addressed at intake
but listed by no release document — is deliberately not servable (it is review
input, not a served remote) and returns 404 even though its blob exists; a listed hash
whose blob is missing is an origin fault answered 404 blob_missing. Nothing that is not
blessed by a signed release ever leaves this surface. Every served response carries
Cache-Control: public, max-age=31536000, immutable and an ETag of the content hash,
so a matching If-None-Match gets a 304. ES-module remotes serve as
text/javascript; the surface exposes no mutation — there is no route that
overwrites or deletes a published hash.
Resolution (import-map fragments)
POST /v1/resolve turns a host's gate snapshot — the set of remotes it has enabled
— into an import-map fragment the host merges into its native-ESM import map. It is
anonymous and requires no deployment registration: the registry only qualifies
the host's already-decided enablement with verifiable URLs; it never decides enablement
and is never a control plane a deployment must phone.
Each resolved module carries a hash-pinned entry URL (/v1/artifacts/<entry-hash>)
plus the signature bundle — { release, envelope, logEntry } — the host verifies
with @gridmason/protocol before it loads a byte. A module resolves only when it passes
both gates: its (publisher, tag, version) is currently approved and backed by a
countersigned release document, and the signed revocation/kill feed does not list it
as revoked or killed. Any other state excludes it. Unresolvable modules are reported in
an excluded array (each with a reason) so the host can render a fallback card — they
are never placed in imports.
Revocation & kill feed (signed, TTL'd, fail-closed)
The registry owns distribution state — whether an artifact it has already published
is still loadable — and publishes it as a signed, monotonic feed at
GET /v1/revocation/feed (anonymous; distribution state is public). Two states:
| State | Meaning | Effect on a host |
|---|---|---|
revoked |
withdrawn | block new loads; running instances left alone |
killed |
kill switch, strictly more severe | block new loads and force-unload running instances |
Each entry also carries an advisory severity (low/medium/high/critical) and
a human reason; severity is triage metadata for how a host surfaces the event and
does not change the load decision. Valid transitions are approved → revoked,
approved → killed, and the escalation revoked → killed. The feed is signed over its
canonical bytes (JCS / RFC-8785) with the same countersign key that approves
releases, so a host pins one countersign root and verifies both. Freshness semantics are
covered under Integrating a host below — they are the fail-closed core of the whole
model.
Self-hosting
The distributable is a single container image plus a compose stack. Backing services
are an S3-compatible object store (artifacts, release documents, feeds) and
Postgres (records, review queue, audit log). The transparency log is external
(Rekor) or, for evaluation only, in-process.
Quickstart
Prerequisites: Docker with the Compose plugin; Node 20+ and npm (for the
migration, trust-root, and policy-render helper scripts, which run from a repo checkout
and are not part of the running image); and openssl (to generate the countersign
key).
1. Bring up the stack. The compose.yaml defines the whole local stack — Postgres,
MinIO, a bucket-creation one-shot, an idempotent migration one-shot, and the registry
service:
git clone https://github.com/gridmason/registry.git
cd registry
npm install
docker compose up --build -d
This starts the registry on http://localhost:8080. Confirm it is up:
curl -fsS http://localhost:8080/healthz # {"status":"ok",...} — liveness
curl -fsS http://localhost:8080/readyz # {"status":"ready",...} — Postgres AND object store reachable
The compose stack uses development-only credentials and runs the in-process
memorytransparency log with no public anchoring — fine to evaluate the install, never a production configuration.
2. Generate the trust roots. A host bootstraps trust in your registry from a pinned trust-root document. It anchors your registry's approval signature, so you need a countersign key first. Generate a self-signed P-256 key offline:
openssl ecparam -name prime256v1 -genkey -noout -out countersign.key
openssl req -x509 -new -key countersign.key -days 365 \
-subj "/CN=$(hostname) countersign" -out countersign.crt
Then project your identity and the two PEMs into the environment and generate the
document. trust-root:init reads the same env the service reads and reuses the
service's key handling, so the root it emits is exactly the one the running service
countersigns with:
export REGISTRY_ID="registry.example.com"
export OIDC_ISSUER_ALLOWLIST="https://token.actions.githubusercontent.com"
export COUNTERSIGN_PRIVATE_KEY="$(cat countersign.key)"
export COUNTERSIGN_CERTIFICATE="$(cat countersign.crt)"
npm run trust-root:init # writes ./trust-root.json
The document's countersignRoots entry is the SHA-256 fingerprint of your
certificate's public key — the identifier hosts pin. Verify it against the
certificate before publishing; the two must match:
openssl x509 -in countersign.crt -pubkey -noout \
| openssl pkey -pubin -outform DER \
| openssl dgst -sha256 | awk '{print "sha256:"$2}'
Publish trust-root.json alongside your instance and hand the fingerprint to your host
operators to pin. Optional fields flow from the environment:
OIDC_ISSUER_ALLOWLIST → issuerAllowlist; TRUST_ROOT_LOG_PUBLIC_KEYS pins your
transparency log's key(s) (leave empty for the ephemeral memory log);
TRUST_ROOT_PUBLISHER_CA_ROOTS adds publisher-CA roots for the issued-cert path (omit
for the keyless OIDC path this cut uses).
3. Publish your policy page. Every instance publishes a human-readable policy page
stating how it reviews, signs, and serves remotes. Render the neutral self-host
variant, customize every operator: … field first, then publish the HTML:
npm run policy:render -- --variant self-host
Do not copy the flagship's waiver language — those disclosures apply only to an instance actually running single-rostered.
4. Reach a publishable instance. An unauthenticated publish request returning 401
proves the endpoint is mounted (a 404 would mean it is not):
curl -s -o /dev/null -w '%{http_code}\n' \
-X POST http://localhost:8080/v1/artifacts \
-H 'content-type: application/json' -d '{}' # 401
To actually publish: configure an OIDC issuer (OIDC_ISSUER_ALLOWLIST, ideally
OIDC_AUDIENCE set to your registry id), register a publisher and claim a prefix, set
the reviewer roster (REVIEW_REVIEWER_IDENTITIES), then
gridmason publish --registry https://registry.example.com.
Configuration reference — the settings that matter
The registry is configured entirely through environment variables, each parsed and
validated once at boot; an invalid value fails startup with a descriptive ConfigError.
An instance boots with none of the following set, but it is not production-ready
until they are — and the design is fail-closed, so unset security-critical settings
disable the surface rather than opening it:
| Variable | Default | Fail-closed behaviour when unset |
|---|---|---|
REGISTRY_ID |
registry.local |
Your canonical id — becomes the widget source string and qualifies every published identity. Set it. |
OIDC_ISSUER_ALLOWLIST |
(empty) | Trusted OIDC issuer URLs, the authorship trust anchor. Empty = no publisher can register. |
OIDC_AUDIENCE |
(empty) | Required token aud; set to this registry's id so a token minted for another party cannot be replayed here. |
COUNTERSIGN_PRIVATE_KEY |
(empty) | PKCS#8 ECDSA P-256 key the registry approves with. Custody-controlled secret. Empty = no countersign stage (approvals record a verdict but publish no release). |
COUNTERSIGN_CERTIFICATE |
(empty) | X.509 P-256 leaf carried in the countersignature; must match the private key. Its public key is your countersign root. |
REVIEW_REVIEWER_IDENTITIES |
(empty) | OIDC identities allowed to submit a verdict. Empty = no one can review. |
OPS_OPERATOR_IDENTITIES |
(empty) | OIDC identities allowed to revoke/kill and read the audit log. Empty = the kill switch cannot act. |
TRANSPARENCY_LOG_DRIVER |
memory |
Set to rekor for a real instance so releases are publicly anchored. |
NODE_ENV |
development |
Set to production — turns on the transparency-log boot guard and production error verbosity. |
Reviewer, operator, and publisher identities use the composite form
composeOidcIdentity(issuer, subject) produces: each part percent-encoded, joined by a
single space (e.g. https%3A%2F%2Fissuer.example reviewer-1). Percent-encoding leaves no
literal comma, so the comma-separated lists are unambiguous.
Backing stores default to the compose MinIO/Postgres stack. For production, point
DATABASE_URL at managed Postgres and the OBJECT_STORE_* settings at real S3 — set
OBJECT_STORE_FORCE_PATH_STYLE=false for AWS S3, and leave the access-key settings empty
to use the SDK default credential chain (IAM role). Replace every development
credential. Readiness (/readyz) reports a postgres and an objectStore probe and is
ready only once both are reachable.
The transparency-log boot guard is the one gotcha. NODE_ENV=production combined
with TRANSPARENCY_LOG_DRIVER=memory is refused at boot — a production instance that
forgot rekor would silently skip the public anchoring the pipeline promises. Set
rekor (TRANSPARENCY_LOG_REKOR_URL defaults to https://rekor.sigstore.dev), or set
TRANSPARENCY_LOG_ALLOW_MEMORY_IN_PRODUCTION=true to deliberately accept no public log.
The compose stack sets that escape hatch so the quickstart boots; a real instance uses
rekor. The served revocation-feed freshness window is REVOCATION_FEED_TTL_SECONDS
(default 3600, capped at 24 h).
Migrations are applied by a dedicated command, never on boot — npm run migrate locally
or node dist/db/migrate-cli.js in the container — and are idempotent
(schema_migrations-tracked, each IF NOT EXISTS-guarded).
Going-to-production checklist
- Replace every development credential (Postgres, object store).
- Point
DATABASE_URLandOBJECT_STORE_*at managed Postgres and S3 (OBJECT_STORE_FORCE_PATH_STYLE=falsefor real AWS S3). - Set
TRANSPARENCY_LOG_DRIVER=rekorso releases are publicly anchored (or explicitly accept no public log via the escape hatch above). - Set
NODE_ENV=productionand setREGISTRY_IDto your canonical id. - Keep the countersign key in a secret manager, projected into the process — never written from the application UI.
- Set
OIDC_ISSUER_ALLOWLIST,OIDC_AUDIENCE,REVIEW_REVIEWER_IDENTITIES, andOPS_OPERATOR_IDENTITIES; an empty allowlist accepts no registrations. - Publish your customized self-host policy page.
Key custody and rotation (in brief)
The countersign key is the registry's approval authority; its custody is what keeps
approval separate from review. The separation is structural in the codebase: the key
loads only from COUNTERSIGN_PRIVATE_KEY / COUNTERSIGN_CERTIFICATE, which are distinct
from the reviewer roster (REVIEW_REVIEWER_IDENTITIES) — the countersign path never
reads a review-lane credential, so a reviewer identity can never become the signing key.
Hold the private key offline, distinct from any reviewer's publishing identity, and
project it only as a secret. A configured-but-unusable key (bad PEM, wrong curve,
mismatched certificate) fails loudly at boot rather than silently skipping countersign
and shipping unapproved releases.
Planned rotation moves to a new countersign key with no flag day, via an overlap
window. The operator mints a new key offline, runs npm run rotate:root with the
outgoing key in the environment and the incoming certificate as --incoming-cert to
produce an overlap document — a trust-root document listing both roots plus a
crossSig, the outgoing key's signature over the document. Publish it, hand the incoming
fingerprint to host operators, and run the overlap for at least one host release cycle
so every host can ship the incoming pin before the instance starts signing with the
incoming key. Then switch the signing key and publish a single-root document dropping the
outgoing root. A validating npm run rotate:dry-run walks the whole ceremony against
throwaway keys using the host's own @gridmason/protocol decision functions, so what
it proves is exactly what a real host decides.
Key compromise is not an overlap rotation — a compromised key cannot be trusted to cross-sign its successor. The order is: (1) kill every affected artifact through the revocation feed (fleet-wide, force-unloads running instances); (2) use the transparency log's leaves to enumerate exactly what was signed under the bad key; (3) re-key with a forced re-pin — publish a single-root document under a fresh key and distribute its fingerprint out of band, hosts replace the compromised pin (those that have not re-pinned fail closed, the intended outcome while a key is known-bad); (4) re-sign the clean releases under the new key and lift their kill.
Federation & trust
Hosts do not trust registries ambiently. A host pins a trust root per registry — the
sha256: fingerprint of that registry's countersign certificate's public key — through
one of two security-critical channels, both of which pin out of band rather than fetching
roots blind at runtime:
- Build-time — roots shipped inside the host application build (the default for registries known when the app is built, e.g. the flagship).
- Deploy-time — an operator-supplied trust-root config (file/secret), so a deployer can trust a fresh self-hosted registry without rebuilding the app. Whoever writes this config can graft a registry into the deployment's supply chain, so it is protected like any credential and is never writable from the application UI.
Multiple registries coexisting
A host may trust several registries simultaneously (e.g. the flagship plus a private
instance). Publisher prefixes are unique only within a registry — there is no global
prefix authority — so identity is always source-qualified as (registry, publisher, tag). When a host merges import-map fragments from several registries it pins each
prefix to one registry; a merged map in which two registries claim the same prefix is
rejected as a configuration error. Flagship-acme and private-acme can coexist only
under distinct pinned prefixes. Freshness is tracked per registry: a host trusting N
registries keeps N feed cursors and N TTL clocks, and a fail-closed revocation decision is
scoped to the one registry whose feed went stale — remotes from still-fresh registries are
unaffected.
The dual signature
Every published version carries two signatures, and hosts verify both plus the content hash plus transparency-log inclusion before load:
- Publisher signature — authorship. Keyless by default (a Sigstore-style short-lived
signing cert bound to the publisher's OIDC identity at
gridmason publishtime). The OIDC issuer is the real trust anchor: each registry configures an explicit issuer allowlist, and the cert and log entry record the issuer and subject claims so verifiers see exactly which identity vouched for the artifact. - Registry countersignature — approval, applied only after review passes, with the separately-held countersign key.
A stolen publisher key alone therefore cannot ship code to anyone — it cannot produce the registry countersignature. The flagship anchors to the public Sigstore infrastructure (Fulcio for keyless certs, Rekor as the log) rather than operating its own CA/log; self-hosted registries bring their own countersign key and choose their log driver.
What the policy page commits an operator to
Every instance publishes a policy page from the same in-repo template; only the data differs, which is what "same format, different policy" means. It states, publicly and without secret rules, how that operator reviews, signs, and serves remotes — the review roster, the SLAs, the countersign-key custody, and any disclosed waivers.
The flagship variant discloses its launch-phase posture rather than hiding it: while its review roster is below two people it opens invite-only with a single-person review roster, and the reviewer ≠ author separation-of-duties rule is waived for the operator's own published widgets until the roster reaches ≥ 2 reviewers. Every affected release is flagged in its transparency-log entry; the published SLAs are targets, not guarantees, until staffed, shown alongside the current measured latency; and the countersign-key custody procedure (offline key, distinct from the reviewer's publishing identity) is documented even while one person holds both roles.
The self-host variant carries none of that waiver language. Separation of duties is
enforced (reviewer ≠ author), SLAs are operator-defined, and self-hosters inherit none of
the flagship's launch-phase waivers — the page states this explicitly. The waiver is
controlled by REVIEW_SELF_REVIEW_WAIVER, which defaults to false and must be left off
on a self-host instance.
Integrating a host with a registry
A host consumes a registry over three surfaces: resolution (to build its import map), the verification library (to check what it got), and the revocation feed (to know what is still loadable). Nothing the registry returns is trusted for being returned by the registry — the host verifies everything against its own pinned roots.
Resolution from the host side
The host calls POST /v1/resolve with a gate snapshot — the exact (publisher, tag, version) remotes it has enabled, plus, optionally, what shared dependencies it offers:
{
"registry": "registry.gridmason.dev", // must equal this instance's REGISTRY_ID
"modules": [
{ "publisher": "acme", "tag": "acme-chart", "version": "2.3.1" }
],
"shared": { // optional: one URL per major the shell provides
"react": [
{ "major": 18, "url": "/vendor/react@18.js" },
{ "major": 17, "url": "/vendor/react@17.js" }
]
}
}
The 200 response is an import-map fragment: an imports map keyed by the bare specifier
"<registry>/<tag>" pointing at hash-pinned entry URLs; a scopes map (emitted only for a
widget needing a non-default shared major); a modules array where each entry carries its
source-qualified identity plus the bundle — { release, envelope, logEntry } — the host
verifies; and an excluded array for requested modules that did not resolve. The host
composes absolute URLs by prepending that registry's pinned serving origin to the
root-relative /v1/artifacts/:hash paths the API returns.
Loading is native ESM + import maps — there is no Module-Federation runtime. A widget's
manifest sharedScope declares the specifier ranges it expects; the host declares what it
offers per specifier; resolution picks the highest offered major the range permits and
emits a scopes entry only when it differs from the host's default. A widget whose range no
offer satisfies is excluded (unsatisfied_shared_scope). Exclusion reasons a host handles:
reason |
Meaning |
|---|---|
unknown_publisher |
no publisher owns that prefix on this registry |
unknown_module |
no (publisher, tag, version) artifact exists |
not_distributable |
not approved (revoked, killed, or never approved), or the feed lists it revoked/killed |
no_release |
the artifact has no countersigned release document |
unresolvable_release |
the release or its manifest is internally inconsistent |
unsatisfied_shared_scope |
no shell offer satisfies the widget's sharedScope range |
The fragment is not marked immutable — the set a snapshot maps to changes when distribution state flips — so a host re-resolves within the revocation TTL. The hash-pinned URLs it hands to the browser are the immutable, CDN-cacheable ones.
Verifying releases with @gridmason/protocol
The signature bundle in each resolved module is exactly the material the protocol's
verifyRelease consumes as its untrusted, network-delivered inputs — identical to the
serving surface's GET /v1/releases/:hash body:
release— the signed release document ({ path → hash });envelope— the completed dual-signature envelope (publisher + registry countersignature);logEntry— the transparency-log inclusion entry.
The host supplies the pinned trust roots, CA/countersign roots, log key, and clock out of
band — nothing in the fragment is trusted for being in the fragment. verifyRelease checks
both signatures, the content-hash binding, and log inclusion before the host loads a byte.
Because the protocol library is the same code the registry runs server-side, verification
is not a reimplementation that can drift.
Consuming the revocation feed
The host polls GET /v1/revocation/feed, verifies the detached signature against its pinned
countersign root, then evaluates freshness with the protocol's evaluateFreshness. Three
rules govern the decision:
- Monotonic
seq(rollback protection). The feed'sseqonly ever increases. A feed whoseseqis below the host's stored cursor is a replayed older feed and is rejected as a rollback regardless of its TTL. An empty feed isseq: 0(a valid "nothing revoked" feed); a host that has never seen this registry starts its cursor at-1. - TTL freshness window. While
nowis withinissuedAt + ttlSeconds*1000the registry is fresh and its remotes may load, minus the artifacts the feed blocks. A reachable-but-fresh registry that is momentarily down keeps working — fail-open within the TTL. - Fail closed past the TTL. Once the window elapses the feed is stale, and the host MUST re-check this registry's feed before loading its remotes — fail closed for revocation, scoped to this registry only. Remotes from still-fresh registries are unaffected.
Max TTL is 24 h; the default served TTL is 1 h, and the feed is generated live per fetch with
the current seq, so a kill is reflected on the very next poll and propagates within the
online bound (≤ 1 h; air-gapped hosts pick it up on the next bundle sync). A killed entry
puts the artifact in evaluateFreshness's blocked list with state: "killed" so the host
both blocks new loads and force-unloads running instances; a revoked entry blocks new
loads only. This is the host's half of the ownership contract: effective gate = registry
distribution state ∧ host enablement, and every conforming gate service MUST consume this
feed — a killed remote never enters the import map regardless of local enablement.
Operations
Two operator surfaces run the live instance. Both are operator-only: a bearer token must
verify against an allowlisted OIDC issuer and name an identity in
OPS_OPERATOR_IDENTITIES. A verified non-operator gets 403 not_an_operator.
Review-lane endpoints
Reviewers (identities in REVIEW_REVIEWER_IDENTITIES) work three reviewer-only endpoints:
GET /v1/review/queue— artifacts awaiting a human verdict, oldest first, each with a summary of its automated report.GET /v1/review/cases/:id— one case with the full automated report and the recorded verdict (nullwhile pending).POST /v1/review/cases/:id/verdict— recordapproveorrejectwith findings that map to the report's check ids (or themanualsentinel). Returns201with{ decision, artifactState, waiverUsed, findings }.
Publishers track their own submissions over the owner-scoped status surface:
GET /v1/artifacts/:id/status returns the artifact record plus a review object (present
only when there is something to report — automated results and/or human findings), and
POST /v1/artifacts/:id/appeal re-opens a rejected artifact for a second reviewer. A
missing artifact and one owned by a different publisher both answer 404 not_found, so the
status endpoint is not an enumeration oracle.
Kill / revoke flow
Distribution state is driven by two operator-only endpoints:
POST /v1/ops/artifacts/:id/revoke
POST /v1/ops/artifacts/:id/kill
Body: { "severity": "critical", "reason": "actively exploited credential path" } —
severity is low/medium/high/critical, reason a non-empty string. On success the
artifact transitions, a feed entry is appended (bumping seq), and an audit event is emitted.
Responses: 201 on success; 400 invalid_request for a bad severity or empty reason;
403 not_an_operator; 404 not_found; 409 invalid_state if the artifact is not
distributable (never approved, or already killed). A revoke/kill both moves the artifact out
of approved (so it drops from resolution at the source) and appends a feed entry (so
already-cached hosts block it on their next poll) — either gate alone is sufficient to exclude
the remote.
Audit surface
Every registry state transition is an auditable event — publish, review, sign, log, gate
flip, revoke — recorded append-only in the audit_event table. An event carries actor (a
publisher/reviewer/operator identity, or system / registry:countersign / anonymous),
action (the transition verb), subject (usually the artifact id), and at. The durable
write is best-effort and never blocks or fails the transition that produced it — an audit
outage degrades observability, it does not stop the registry. A completeness test walks the
whole pipeline and asserts one event per transition, so a future change that adds a transition
without an audit event fails CI. Hot-path reads (serving, resolution) and the audit query
itself deliberately emit no per-request event — the trail records state changes, not
reads; only auth denials on those surfaces are audited.
Operators read the trail back through:
GET /v1/ops/audit
Operator-only, same auth as the kill/revoke endpoints. Filters (all optional, AND-combined):
subject and action (exact match), since / until (inclusive ISO-8601 bounds), limit
(default 50, max 500), and before (a keyset cursor). Events return newest-first with a
nextBefore cursor (null when the trail is exhausted); page forward by passing it back as
before. A malformed since/until or a negative before/limit is 400 invalid_request.
The countersign stage's event pair is worth knowing when reading the trail:
release.countersigned records the signing act, and release.logged fires only once the
release is both anchored and persisted. A release.countersigned with no following
release.logged is expected and unambiguous — exactly one of release.log_failed (the log
append failed after retries) or release.persist_failed (the append succeeded but the release
doc did not persist) then explains why publication did not complete. Either failure leaves the
artifact approved-but-unpublished; a re-drive picks up any approved artifact lacking a
release doc and completes it, so the trail is never silent about a signed-but-unpublished
artifact.