Cloudoak registry protocol (v2)
A registry is a catalogue of signed app definitions. Nodes read it
anonymously over HTTPS. Publishers write to it with signed requests from
the cloudoak CLI. The registry never holds a user, a node identity, a
credential, a price or a usage figure.
The registry is not trusted for content. Every manifest is signed by its publisher, and everything the registry itself says (who owns an app, which versions exist, what is yanked) is signed by a registry key the node already holds. A node refuses what does not verify and keeps running what it has installed.
v2 is a hard cut from v1: the paths, the documents and the key id form all changed, and nothing reads v1.
Rules are numbered R1, R2, … . Each one has at least one case in
spec/registry-vectors/, which names the rules it exercises.
1. Encoding
Section titled “1. Encoding”- R1. Canonical form is RFC 8785 (JCS): object members sorted by UTF-16 code units, no insignificant whitespace, numbers in the shortest ECMAScript form, strings with only the required escapes. Duplicate member names are an error.
- R2. Hashes are SHA-256, written as 64 lowercase hex characters.
manifest_sha256is the hash of the manifest’s canonical bytes. - R3. A key is an Ed25519 public key, written base64url without padding. Its key id is the hash of its 32 raw bytes: 64 lowercase hex characters. Ids are compared only in that full form. A short id is the first 16 characters, for people to read beside the full one; nothing compares a short id, and a 16-character id given as configuration is refused with a message saying why.
- R4. A signature is Ed25519 over the canonical bytes of the signed object, written base64url without padding.
- R5. Times are RFC 3339 in UTC with a
Zand whole seconds (2026-10-20T10:00:00Z). - R6. Versions are semantic versions (
MAJOR.MINOR.PATCH, optional pre-release, no build metadata) compared by semver precedence.
2. Signed envelopes
Section titled “2. Signed envelopes”- R7. A signed document is
{"<name>": {…}, "signatures": [{"key_id": "<64 hex>", "sig": "…"}]}. The signatures cover the canonical bytes of the inner object alone.<name>is fixed per document (section 4). - R8. A verifier checks every listed signature. A document with no
signatures, a signature by a key id it does not hold, or one that
does not verify, is refused. Extra members beside
<name>andsignaturesare refused. - R9. A publisher’s signed manifest is the same envelope with the
name
manifest:{"manifest": {…}, "signatures": […]}.
3. Where documents are
Section titled “3. Where documents are”Documents are named by paths, all under v2/:
| Path | Name inside | Signed by |
|---|---|---|
v2/meta.json |
— (unsigned) | nobody; its moved_to is root-signed |
v2/keys.json |
keys |
the root key |
v2/catalog.json |
catalog |
an online key |
v2/apps/{app_id}/index.json |
app |
an online key |
v2/apps/{app_id}/versions/{version}.json |
— | the publisher, inside |
v2/publishers/{publisher}.json |
publisher |
an online key |
v2/submissions/{submission_id}.json |
— (unsigned) | nobody |
- R10. A registry base is one of two forms. A plain base is a URL
prefix: a document’s URL is the base, a
/if the base does not end with one, and the path. A base template is a URL containing{id}: a document’s URL is the template with{id}replaced by the document id. - R11. A document’s id is the first 32 hex characters of the
SHA-256 of its path, as UTF-8, with no leading
/. For example the id ofv2/catalog.jsonis the first 32 characters ofsha256("v2/catalog.json"). - R12. Bases are
https. Plainhttpis allowed only forlocalhost,127.0.0.1and::1, and a node may read a registry folder on its own machine as a plainfile://base. A node follows a redirect only to the same scheme and host, and at most three times. - R13. Nodes read anonymously: no cookie, no credential, no node identity is ever sent.
- R14. A base may send
ETag. A node sendsIf-None-Matchwhen it has one, and a304means its stored copy stands.
4. Documents
Section titled “4. Documents”meta.json (unsigned)
Section titled “meta.json (unsigned)”{ "protocol": 2, "name": "Cloudoak registry", "submit": "https://…", "ping": "https://…", "terms": {"version": "1", "url": "https://…"}, "catalog_sequence": 12, "moved_to": {"moved_to": {"bases": ["https://…"], "at": "…"}, "signatures": [ … ]}}- R15.
protocolis2; any other value is refused. - R16.
catalog_sequenceis an unsigned hint. A node fetchescatalog.jsononly when the hint is higher than the sequence it holds, or when it holds none; whatever it fetches is still verified. A missing or wrong hint can only delay an update, never cause a rollback. - R17.
moved_tois a root-signed envelope naming new bases with a date. A node that verifies it against a root key it holds adds those bases, tries them first, and stores them. Amoved_tothat is unsigned, or signed by any other key, is ignored and logged.
keys.json (root-signed)
Section titled “keys.json (root-signed)”{"keys": {"issued_at": "…", "keys": [ {"key_id": "<64 hex>", "public_key": "…", "not_before": "…", "not_after": "…"}]}, "signatures": [{"key_id": "<root key id>", "sig": "…"}]}- R18.
keys.jsonmust verify against a root key the node holds: compiled in for the default registry, or listed inCLOUDOAK_REGISTRY_KEYSfor another one. - R19. Only a key listed here, used inside its
not_beforeandnot_after, may sign a catalogue, app index or publisher record. A document signed by an expired or not-yet-valid key is refused. - R20. Each key’s
key_idmust be the id of itspublic_key(R3).
catalog.json (online-signed)
Section titled “catalog.json (online-signed)”{"catalog": {"sequence": 12, "generated_at": "…", "apps": [ {"app_id": "com.example.notes", "name": "Notes", "publisher": "example", "publisher_verified": false, "summary": "…", "categories": ["…"], "homepage": "…", "latest": "1.2.0", "min_node": "0.6.0", "sequence": 12, "updated_at": "…"}]}, "signatures": [ … ]}- R21.
sequencerises by exactly one with every accepted change, andgenerated_atis the time of that change, so the same sequence always renders to the same bytes. A node refuses a catalogue whose sequence is lower than the one it holds (no rollback). An equal sequence with different bytes is refused too. - R22. Only listed apps appear. Each entry’s
sequenceis the catalogue sequence at which that app’s index last changed.latestis the app’s highest version that is not yanked. Apps are sorted byapp_id.
apps/{app_id}/index.json (online-signed)
Section titled “apps/{app_id}/index.json (online-signed)”{"app": {"app_id": "…", "publisher": "…", "status": "listed", "status_reason": "", "sequence": 12, "owner_keys": [ … ownership log … ], "versions": [{"version": "1.2.0", "manifest_sha256": "…", "published_at": "…", "yanked": false, "yank_reason": "", "min_node": ""}]}, "signatures": [ … ]}- R23.
statusislistedorremoved; a removed app has astatus_reason, and a node installs nothing from it. - R24. An index whose
sequenceis lower than the catalogue entry that names it is refused (an old index served beside a new catalogue). - R25. Versions are sorted by version precedence, oldest first.
yankedandyank_reasonlive here and only here.
apps/{app_id}/versions/{version}.json (never changes)
Section titled “apps/{app_id}/versions/{version}.json (never changes)”{"manifest": {…}, "manifest_sha256": "…", "signatures": [{"key_id": "…", "sig": "…"}], "publisher_key": "…"}- R26. A version document never changes once published, and carries
no yank state. Its
manifest_sha256must equal the hash the signed index lists for that version, and the hash of the canonical manifest. - R27. The manifest’s signatures must verify against
publisher_key, whose id must be the current owner key (R30).
publishers/{publisher}.json (online-signed)
Section titled “publishers/{publisher}.json (online-signed)”{"publisher": {"publisher": "example", "keys": [ {"key_id": "…", "public_key": "…", "added_at": "…", "revoked_at": ""}], "domain": "example.com", "verified": true}, "signatures": [ … ]}- R28.
verifiedis true only while the domain’shttps://<domain>/.well-known/cloudoak-publisher.jsonlists one of the publisher’s current key ids (section 6).
submissions/{submission_id}.json (unsigned)
Section titled “submissions/{submission_id}.json (unsigned)”{"id": "<64 hex>", "action": "publish", "app_id": "…", "version": "…", "state": "accepted", "code": "accepted", "reason": "…", "decided_at": "…"}- R29.
stateisaccepted,in_revieworrejected, with acodefrom section 7 and areasona person can act on. Nothing in review is visible anywhere else.
5. Ownership
Section titled “5. Ownership”Each app index carries owner_keys, the ownership log:
{"seq": 2, "kind": "rotate", "key_id": "…", "public_key": "…", "publisher": "example", "at": "…", "by_previous": "…"}- R30. The current owner key of an app is the key of the last entry in its log. Only that key may publish, yank or unyank the app’s versions, rotate its key or start a transfer.
- R31. The first entry has
seq1 andkindclaim. Each later entry hasseqone higher than the one before. - R32.
by_previousis the previous owner key’s signature over the canonical bytes of the entry withoutby_previous. Entries of kindrotateandtransfermust carry it. - R33. An entry of kind
recoverhas noby_previous: the old key is lost. It is added only after review. - R34. A node that has a different key pinned walks the log from
its pinned key to the current one. It follows
rotateandtransfersteps whoseby_previousverifies. A walk that meets arecoverstep is not followed automatically: the upgrade waits for the node’s owner, with the reason shown. A log that does not contain the pinned key at all is refused.
6. Publishing: one endpoint
Section titled “6. Publishing: one endpoint”POST {meta.submit} with:
{ "request": {"action": "publish", "app_id": "com.example.notes", "version": "1.2.0", "publisher": "example", "key_id": "<64 hex>", "terms_version": "1", "issued_at": "2026-10-20T10:00:00Z", "nonce": "…"}, "request_sig": "…", "public_key": "…", "signed_manifest": {"manifest": {…}, "signatures": […]}}Every request has action, publisher, key_id, issued_at and a
random nonce; every action but verify-domain names an app_id;
terms_version is the version of the terms the publisher accepts.
Actions and their extra members:
| Action | In request |
Beside request |
|---|---|---|
publish |
version |
signed_manifest |
yank, unyank |
version, reason (yank) |
— |
rotate-key |
new_key_id, new_public_key |
new_key_sig (the new key’s signature over the canonical request), entry_sig |
transfer |
to_key_id, to_public_key, to_publisher |
entry_sig |
accept-transfer |
— (signed by the receiving key) | — |
verify-domain |
domain |
— |
The registry checks a submission in this order, and stops at the first failure: size and decoding (R35), freshness (R36), the request signature (R38), replay (R37), rate limits (R53), then the rules of the action. The signature comes before replay and rate limits so that nobody can use up a key’s daily allowance with requests they cannot sign.
- R35. The body is at most 256 KB, decoded strictly: unknown members, duplicate members and trailing data are refused.
- R36.
issued_atis within ten minutes of the registry’s clock. - R37. The submission id is the hash of the canonical
requestobject. A submission id already seen is refused as a replay. - R38.
request_sigispublic_key’s signature over the canonicalrequest, andrequest.key_idis the id ofpublic_key. - R39.
entry_sigis the requester’s signature over the new ownership-log entry:seqone above the last,kind, the newkey_idandpublic_key,publisher, andatequal toissued_at. It becomes that entry’sby_previous. - R39a.
rotate-keymoves one app to a new key: the request is signed by the current owner key,new_key_sigproves the new key is held, and the new key joins the publisher’s keys. A key that no longer owns any app is marked revoked in the publisher record. - R40. A publisher’s first request claims the name. It must carry
the current terms version; without it the answer is
terms_requiredwith the terms address. A new terms version is asked for at the publisher’s next request. - R41. The answer is
202with{"submission_id", "state", "code", "reason"}. The same answer is written to the submission document.
Rules for publish:
- R42. The manifest must validate exactly as a node validates it at
install, and its
app.id,app.versionandapp.publishermust equal the request’s. - R43. The app id is unclaimed, or the signing key is its current owner key (R30).
- R44. The publisher name is unclaimed, or the key is one of that publisher’s keys.
- R45. The version is strictly greater than every published version
of the app. The same version with the same
manifest_sha256is answered as already published (accepted, nothing changes); with different contents it is refused. - R46. Upgrading from the latest published version to this one must succeed under the node’s upgrade rules. The registry stores the plan and shows it in review.
- R47. A registry may reserve publisher names and app-id prefixes
for its operators; anyone else’s claim on one is refused. The central
registry reserves the publisher
cloudoakand app ids undercom.example.. A registry run by the developer using it (a folder, the stand-in) reserves none.
7. Decisions
Section titled “7. Decisions”Every submission ends in one state and one code:
| State | Codes |
|---|---|
accepted |
accepted, already_published |
in_review |
review_new_publisher, review_new_app, review_new_address, review_transfer |
rejected |
bad_request, too_large, stale_request, replayed, rate_limited, daily_cap, bad_signature, terms_required, invalid_manifest, not_owner, publisher_taken, reserved_name, version_not_greater, version_conflict, upgrade_refused, not_found, domain_unverified, rejected_in_review |
- R48. Straight through: a new version of a listed app, signed by its current owner key, from a publisher in good standing, that adds no sign-in return address and no web origin.
- R49. Waits for review: a publisher’s first submission, a new app id, a version that adds a return address or an origin, and a transfer.
- R50. The owner key yanks and unyanks its own versions at once. A yanked version stays stored and verifiable; a node never installs it and warns its admin when it runs it, and never removes an app or its data by itself.
- R51. A transfer completes only when the receiving key submits
accept-transferand the transfer has passed review. Until then the log is unchanged. - R52.
verify-domainmarks a publisher verified whenhttps://<domain>/.well-known/cloudoak-publisher.jsonlists one of its key ids. The fetch is https only, never follows a redirect off the host, and never connects to a private, loopback or link-local address. - R53. Rate limits, counted per UTC day over signed submissions
that are not replays: 20 submissions per key; 3 new publisher claims
per client address; across everyone, 500 submissions and 50 new
publisher claims. Past a per-key or per-address limit the answer is
rate_limited, past a global onedaily_cap, and nothing else is touched. Every signed submission counts against its key and the global total, whatever the answer. A submission counts as a new publisher claim only when it is accepted or put in review: a first publish sent back for the terms (terms_required), or refused, claims no name, and the same publish sent again with the terms accepted claims it once. The claim limits are checked before the submission is decided.
8. Review (registry operators)
Section titled “8. Review (registry operators)”Review requests are signed by a review key and sent from the operator’s machine:
{"request": {"action": "approve", "submission_id": "…", "reason": "…", "key_id": "<review key id>", "issued_at": "…", "nonce": "…"}, "request_sig": "…"}Actions: approve, reject (a submission in review); yank, unyank,
remove, relist (any app or version, with a reason); recover
(adds a recover entry naming a new key, after an out-of-band check).
- R54. Review requests follow R35 to R38, signed by a configured review key. Any other key is refused.
9. What a node checks, in order
Section titled “9. What a node checks, in order”A node that refuses a document says why with one of these codes (and a
sentence for people): bad_protocol, bad_base, redirect_off_host,
untrusted_root, bad_key_id, key_dates, untrusted_signer,
rollback, equivocation, stale_index, hash_mismatch,
publisher_signature, not_current_owner, broken_chain,
pin_not_in_log, recover_needs_approval, yanked, removed.
- R55.
keys.jsonverifies against a root key (R18), then every online key is checked for its validity dates (R19). - R56.
catalog.jsonand eachindex.jsonverify against an online key (R19), with no rollback (R21, R24). - R57. For an install or upgrade: the version document’s hash matches the signed index (R26), then the canonical manifest’s hash, then the publisher’s signature (R27), then the ownership walk (R34). A version that is yanked (R50) or an app that is removed (R23) is not installed.
- R58. A registry with no
keys.jsonis an unsigned registry: it works with first-seen pinning of the publisher key, and the node labels it so wherever it is shown. A registry that has akeys.jsonthat does not verify is refused, not treated as unsigned.
10. Liveness
Section titled “10. Liveness”- R59.
POST {meta.ping}with an empty body answers{"ok": true, "at": "…"}. The registry keeps exactly one row for it, updated in place at most once a minute, and no record of who pinged. A node pings at most once per sync, best effort.
11. Private registries
Section titled “11. Private registries”A private registry is the same documents in a folder (cloudoak app publish --out <folder>) or on any static host, read through a plain
base. CLOUDOAK_REGISTRY_KEYS lists the root keys an operator trusts
for such registries; without one, the registry is unsigned (R58).
Built from spec 2.1.0, SDK 2.0.0 and the node's guide at v0.6.0-alpha.1.