gridmason docs / Registry & Supply Chain

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:

  1. Registered publisher — the verified (issuer, subject) OIDC identity must own a publisher record, or the upload is refused 403 not_registered.
  2. Tag under the publisher's prefix — the artifact tag must fall under the publisher's registered namespace prefix (<prefix>-…), or it is refused 403 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 SignatureEnvelope shape. The bare DSSE shape that @gridmason/cli ≤ 0.5.x uploaded is refused 400 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 verdictapprove 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 reviewing case that records the original rejector as its excluded reviewer; a verdict from that identity is refused 403 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:

  1. 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.
  2. Countersigns — signs the publisher signature with the separately-held registry countersign key, producing the protocol dual-signature envelope.
  3. Anchors — appends the release to the transparency log. A release approved under the flagship self-review waiver is flagged in the logged leaf.
  4. Emits — persists the ReleaseDoc (file map + completed envelope + the full inclusion entry) for the serving surface.
  5. Auditsrelease.countersigned for the signing act, then release.logged once the release is both anchored and persisted, both under the registry:countersign actor — 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 memory transparency 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_ALLOWLISTissuerAllowlist; 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_URL and OBJECT_STORE_* at managed Postgres and S3 (OBJECT_STORE_FORCE_PATH_STYLE=false for real AWS S3).
  • Set TRANSPARENCY_LOG_DRIVER=rekor so releases are publicly anchored (or explicitly accept no public log via the escape hatch above).
  • Set NODE_ENV=production and set REGISTRY_ID to 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, and OPS_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:

  1. Build-time — roots shipped inside the host application build (the default for registries known when the app is built, e.g. the flagship).
  2. 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:

  1. Publisher signature — authorship. Keyless by default (a Sigstore-style short-lived signing cert bound to the publisher's OIDC identity at gridmason publish time). 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.
  2. 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's seq only ever increases. A feed whose seq is below the host's stored cursor is a replayed older feed and is rejected as a rollback regardless of its TTL. An empty feed is seq: 0 (a valid "nothing revoked" feed); a host that has never seen this registry starts its cursor at -1.
  • TTL freshness window. While now is within issuedAt + ttlSeconds*1000 the 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 (null while pending).
  • POST /v1/review/cases/:id/verdict — record approve or reject with findings that map to the report's check ids (or the manual sentinel). Returns 201 with { 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.