Release Catalog Contract
What a Release Catalog holds, how core writes it, and how the client routes serve it.
Responsibilities
Bundle immutable artifact identity and storage metadata
Release rollout, compatibility, Channel and message policy for one Bundle
ReleaseCatalog the compiled desired state of one scope
Core compiles a scope's catalog with every Release change
Database stores rows through a database adapter
Device accepts a catalog and selects its update locallyA database stores rows and never decides which Release an installation runs. Core writes every catalog the same way on every database, so a new backend needs only a database adapter. For migration from an older version, see Move from v0 to v1.
Scopes and catalog rows
A Release Catalog covers one scope: a Channel, a platform, and an update
strategy, APP_VERSION or FINGERPRINT with a fingerprint hash. Every Release
belongs to one scope, named by its scope_key.
A catalog row holds:
| Field | Meaning |
|---|---|
scope_key | The scope |
catalog_id | An opaque identity, allocated on the scope's first write and kept after it |
strategy, channel_id, channel_key, platform, fingerprint_hash | The scope's parts |
generation | Increases by one with every write and never resets |
payload | The canonical catalog JSON, which only the compiler interprets |
catalog_hash, byte_size | The payload's SHA-256 hash and size |
is_tombstone | Whether the scope has no enabled Release |
updated_at_ms | When the row was written |
Release changes
Every Release change (a deploy, a policy update, a promotion or a deletion) runs in one transaction with its scope's catalog:
- Read the catalog row, the scope's enabled Releases, and, for a new Release, the scope's newest Release ID.
- Apply the change. A new Release ID sorts after the scope's newest.
- Compile the enabled Releases and write the catalog's next generation.
The Release reads are guarded through the catalog row, so a concurrent change to the same scope makes the transaction run again instead of publishing a stale catalog. A scope left without enabled Releases keeps its row as a tombstone with a higher generation.
The compiler keeps a frontier of 11 artifacts for update and crash selection, plus every compatible enabled Release, so a rollback can find the predecessor of any active Release. Rollback candidates ignore rollout and target cohorts. The catalog is never truncated: a change whose compiled catalog exceeds 256 KiB fails before anything is written, and the previous Releases and catalog stay.
Rebuilding a catalog recompiles it from the scope's enabled Releases and writes it only when the result changed. Back up catalog rows together with Releases: a scope that has Releases but no catalog row refuses deploys and rebuilds until the row is restored, because its identity and generation history cannot be recovered from the Releases.
Client routes
GET /release-catalogs/app-version/:platform/:channelKey/:appVersion
GET /release-catalogs/fingerprint/:platform/:channelKey/:fingerprintHash
GET /artifacts/v2/:targetBundleId/from/:currentBundleId
POST /artifacts/v2/:targetBundleId/files
GET /artifacts/v1/:targetBundleId/from/:currentBundleIdThe update check is one point read of the scope's catalog row; it scans no Releases or Bundles. Artifact resolution reads both Bundles in one batch and their patch with one unique read; a files request reads the target Bundle with one point read. Catalog URLs carry no installation state: the device selects its update from the catalog.
| Answer | Status and headers |
|---|---|
| The scope's catalog | 200 with cache-control: public, max-age=0, s-maxage=5, cdn-cache-control: public, max-age=5, stale-while-revalidate=5, stale-if-error=0, an etag, and vary: Accept-Encoding plus the header of the plugin that provides client auth, such as x-api-key for apiKeys(). A matching If-None-Match answers 304. |
| No catalog for the scope | 404 with x-hot-updater-catalog: none and the catalog's cache-control and cdn-cache-control, so a shared cache also answers a scope with no catalog yet. Clients read it as no update. |
| An app version in a form other than its canonical one | 400 |
| A channel key that is not the base64url of a channel name, or a malformed fingerprint hash | 400 |
| A missing or invalid client key | 401 |
| Client auth that cannot read its keys, or a database the schema fence refuses | 503 |
| An artifact, or the files a device asked for | 200 |
| A files request with no paths, more than 100, repeated paths, or a body that is not JSON | 400 |
| A files request with a path the target's manifest does not name | 404 |
Every answer other than a catalog, its 304, and a scope's missing catalog is
private, no-store. A shared cache in front of the client routes keys a
catalog by host, path, Accept-Encoding and the client-auth header, never by
installation state. A database can purge those copies: core calls its
onCachedRoutesChange, which createEngineDatabase
takes, after
each committed write that changes a catalog.
A GraphQL, RPC or other transport needs an HTTP adapter or proxy that serves these routes with the same answers.
How long a replaced catalog stays visible
A shared cache keeps a catalog fresh for five seconds. s-maxage in
Cache-Control forbids serving it stale, so a cache that reads only
Cache-Control revalidates an expired catalog while devices wait. A CDN that reads CDN-Cache-Control (RFC 9213), such as
Cloudflare's Workers Cache, uses it instead: for five more seconds it answers
with the expired catalog while it revalidates in the background, and
stale-if-error=0 still passes origin errors through.
Each server also reuses a catalog it read for five seconds. Without a purge,
a device can therefore read a catalog for up to 10 seconds after a deploy or a
Roll back replaced it behind a cache that reads Cache-Control, and up to 15
seconds behind one that reads CDN-Cache-Control. The stale window adds its
five seconds only when a scope's checks are a few seconds apart; a busy scope
refreshes in the background as soon as the catalog expires.
Artifact responses
An /artifacts/v2 response has the target manifest's URL and hash, an
optional archive URL, and the Hermes bundle's patch from the current Bundle,
which the server includes only if it is smaller than the original when both
download sizes are known. It has no URL per file, so the server signs at most
three URLs whatever the Bundle's size.
The device then reuses the files it already has, from the current Bundle or the
built-in one, and chooses between the archive and the files it still needs. Only
for files it downloads one by one does it POST their manifest paths, at most
100 at a time, as { "paths": [...] } to /artifacts/v2/:targetBundleId/files,
and the server signs those paths only:
{ "files": { "index.android.bundle": { "url": "https://…", "compression": "br" } } }A device can still download a file's original after a missing or corrupt base file or a failed patch. See bundle diffing for patches and the manifest's download fields.
/artifacts/v1 serves SDKs that predate /artifacts/v2: its response signs the
original of every file of the target. The SDK also asks /artifacts/v1 when an
older server answers /artifacts/v2 with 404, and when the app's native code
predates /artifacts/v2, so a newer SDK's JavaScript delivered by OTA keeps
installing updates.
Self-hosted servers
standaloneRepository gives the CLI and console a self-hosted server's
database through its admin API. The server runs core, its plugins and every
transaction on its own database. See the
Standalone repository reference.