Protocol v2 · §§11–11.3
Repositories
Layout
A repository is the representation of collections in a storage location. The layout is identical in a server’s own storage and in a mirror. Keys are relative to the location’s prefix.
nodes/<nodeHash> encoded node, gzip
bodies/<leafHash>.ndjson.gz the body of a record leaf
records/<recordHash>.json.gz an out-of-line record, gzip
schemas/<schemaHash>.json JCS(schema)
roots/<hex>.json JCS(root); <hex> is the version hash without "ulv2:"
private/<commitment>.json JCS(PrivateSetObject), where private sets are held
files/<fileHash> file bytes
collections/<collectionId>/collection.json collection description
collections/<collectionId>/log/<seq>.json version log entry
collections/<collectionId>/head.json log head- Bodies. One line per entry of the leaf, in entry order, each terminated by
\n: the record’s canonical form, or a pointer{"$ref":"<recordHash>"}torecords/. A body is one or more concatenated gzip members; readers MUST accept any number. - Immutability. Objects outside
collections/MUST NOT change once written. A reader that does not trust a location MUST verify each object against its hash, and body lines against the leaf’s entries, before use. - Write order. A writer MUST write every object a version reaches (leaves and bodies, interior nodes, the PrivateSetObject and root) before the log entry, and the log entry before
head.json. - Self-containment. No object refers to another location. Readers MUST ignore keys outside the layout.
Version log
Each collection has one signed log entry per version.
entry = {"actorId","appId","baseSemver","collectionId","createdAt","keyId",
"message","prev","semver","seq","sig","versionHash"}
sig = base64url(Ed25519 signature over JCS(entry without sig)), no padding
keyId = first 16 hex characters of SHA-256(raw public key)
entry hash = SHA-256(JCS(entry)), sig included
prev = entry hash of seq − 1, or null for seq 1
head.json = JCS({"entryHash","seq","versionHash"}) of the latest entrycollectionIdis signed, so that an entry or log cannot be presented as another collection’s.createdAtis ISO 8601 UTC.appId,actorId,baseSemverandmessageMAY benull; writers SHOULD writeactorIdasnull.collection.jsonholds the collection’sid,owner,slug,nameandkeys, an array of{"id", "alg": "Ed25519", "publicKey"}. It is neither hashed nor signed; readers MUST NOT depend on its serialization.- A verifier MUST use a key only under the id derived from it.
A log is valid if and only if every entry from 1 to head.seq is present, each names the collection being read, each prev chains, each signature verifies under a trusted key, and head.entryHash and head.versionHash equal the last entry’s. Which keys are trusted is not yet specified.
Packs
A version is transferred as a pack: the objects it reaches that the receiver’s base version does not, under their repository keys, as an uncompressed POSIX tar (PAX headers for names over 100 bytes). File bytes and collections/ objects are not carried. Order:
- the schemas the base lacks;
- for each set sent, public first: for each record tree, the tree nodes not at the same position in the base’s tree of that type, parents before children, and after each new leaf the out-of-line records its body points to, then its body; then the new tree nodes of the set’s file tree;
- the PrivateSetObject, if the private set is sent;
- the root.
A receiver MUST NOT depend on any other order than leaf before body, out-of-line records before the body that points to them, and the root last.
A receiver MUST verify each object against its key before writing it, MUST re-derive every received tree by merging the entry changes into its base tree and obtain exactly the received root, count and bytes, MUST write the root last, and MUST refuse a pack that fails any check. Packs are pull-only: a server MUST NOT accept them from clients.
Serving over HTTP
A server serves each collection under a collection URL, an absolute URL without a trailing slash whose form is the server’s choice. On underlay.org it is https://www.underlay.org/api/collections/<owner>/<slug>. A server MUST provide these reads:
GET <collection>/log?after=<seq>&limit=<n>
200 {"collection": collection.json | null, "head": head.json | null, "entries": [entry, ...]}
GET <collection>/versions/<v>/pack?base=<v>&sets=public|all
200 application/x-tar; x-underlay-version, x-underlay-base, x-underlay-sets
GET <collection>/versions/<v>/manifest?cursor=<c>&limit=<n>
200 {"semver", "hash", "schemas": {slug: schemaHash},
"records": [{"id", "type", "hash", "private"?}],
"pagination": {"limit", "hasMore", "nextCursor"}}
GET <collection>/files/<fileHash> the bytes, or a redirect to them
HEAD <collection>/files/<fileHash> content-length
<v>: a semver (leading "v" optional), a version hash (ulv2:<hex>), or latest.
A hash that names several versions (a reverted change repeats one) selects the latest.- Log. Entries with
seqgreater thanafter(default 0), ascending. A server MAY return fewer thanlimit; the client repeats from the lastseqreceived untilhead.seq. - Pack. Without
base, every object the version reaches.setsdefaults topublic. - Manifest. Records in order of type, then id. A caller who cannot read the private set receives the public set only. Cursors are opaque; a server MAY cap
limit. - Files.
<fileHash>MAY carry asha256:prefix. A server MUST serve a file only to a caller who may read a set that holds it.
Errors carry {"error": <message>}. 404: the collection, version, base or file does not exist or may not be read; a server MUST NOT distinguish these. 403: sets=all without access to the private set. 400: an invalid sets. 451: a file the caller could otherwise read, withheld for legal reasons (a file the caller may not read is a 404). Authentication is the server’s choice; a server without access control MUST serve public sets only.
A client MUST NOT trust a server’s responses: it verifies the log, receives packs under the rules above, and verifies files against their hash. The manifest is not verifiable on its own.