This document is the normative specification of the Stelae snapshot protocol:
what the stelae crate implements, and what a third-party profile is written
against. It is the protocol half of what was authored as Dolos
ADR-004,
extracted when these crates left the Dolos workspace. That ADR remains the
decision record; the Dolos profile's own normative half — its layer kinds,
record shapes, cut point and pipelines — is
crates/snapshot/PROFILE.md
in the Dolos repository, and this document owes both of them nothing
normative. Where a rule needs an example, the Dolos profile
(io.txpipe.dolos.cardano) supplies it, as illustration and never as
requirement.
A stele (stele: a standing inscribed slab) is one published artifact set at one sequence point: a set of compressed layers plus an inscription — a canonical JSON document listing the uncompressed digest of every layer along with the profile, sequence and position that identify what the snapshot is of. The protocol owns framing, the signable document, transport, attestation and restore planning; a profile owns layer kinds, record shapes, position, parameters, tag rendering and store semantics.
Four properties drive every rule below:
- Delta transfer. Layers are immutable and content-addressed, so a publish uploads only what is new and a restore fetches only what it lacks.
- Implementation-agnostic formats. Layer content is deterministic CBOR sequences of logical records — never database files.
- Determinism. Two independent parties holding the same data produce byte-identical inscriptions, whatever compressed their blobs.
- Multi-party attestation. Identity is the inscription's sha256; independent parties reproduce and sign that one digest.
Registries are content-addressed: pushing skips blobs the registry already holds (HEAD by digest) and pulling fetches only the layers missing locally, so immutable layers make delta transfer a property of the transport rather than a feature of this protocol. The referrers API gives detached signatures a standard, tooling-compatible home. And registry infrastructure — auth, CDN distribution, garbage collection, mirroring — is commodity, so the protocol specifies none of it. Any OCI Distribution v1.1 registry is a valid home for a stele repository.
Envelope types are protocol-owned and shared by every profile; payload types are vendor-owned:
| Role | Media type | Owner |
|---|---|---|
| Artifact type (manifest) | application/vnd.stelae.stele.v1 |
protocol |
| Config blob (the inscription) | application/vnd.stelae.inscription.v1+json |
protocol |
| Signature (referrer artifact) | application/vnd.stelae.signature.v1 |
protocol |
| Layer payloads | application/vnd.{vendor}.stele.{kind}.v{n}+{codec} |
vendor |
Normative rules for coexistence:
- Payload media types must carry a vendor slot the publisher controls.
vnd.stelae.*is reserved for envelope types and is never a payload type — Stelae defines no payload format. - Profile names are reverse-DNS and vendor-owned (the first profile is
io.txpipe.dolos.cardano, version 1). The short token in media types follows IANAvnd.custom. - The protocol never parses layer bodies or a profile's opaque objects. An
unknown profile name, or a profile major version the client does not
implement, is a clean refusal — never a partial or misinterpreted restore.
A layer whose kind the client does not implement is deliberately not
one of those: the client cannot store what it does not model, so it skips
the layer and reports the skip, and only a
required: truein that layer'sscopeturns the skip back into a refusal naming the kind and the scope. The publish side keeps the strict rule in both cases — a publisher that cannot build a kind must not chain onto a stele carrying it, since the alternatives are dropping the layer from the repository silently or attesting bytes it never read.requiredlives in the profile-ownedscopeand not in an OCI annotation, so it is signed planning input rather than unsigned transport metadata; the protocol carries it and never reads it. It is one-way — a kind published as required forever constrains readers older than it — so marking one is a specification-level act for the profile and rare by construction. - One repository per (profile, dataset). Sharing a registry namespace is
safe: discovery filters on the common
artifactType, theprofilefield discriminates, and tags are rendered by the profile. - Signatures are generic and cover the inscription digest, which itself binds the profile — so signing and verification tooling is shared across vendors.
Rule 3 answers for a kind the reader does not know. It says nothing about a
kind the publisher no longer carries, and required: true cannot be
stretched to cover one: required is a property of a layer, and a retired
kind has no layer to put it on. Absence is meaningful in this format — a
profile may define kinds that exist only when they hold content — so a reader
that still models a retired namespace, finds no layer for it, and reports a
clean restore has silently lost a slice of data.
A profile therefore declares the namespaces it defines, and a retirement is
declared rather than inferred. The profile's parameters carry a schema
map with an entry for every namespace the profile version defines; a
namespace it has retired keeps its entry at revision 0, which is not a
schema revision and reads as "this version defines no records here". A
restore compares that map against the namespaces it models, before a store is
opened: an entry that is missing or zero for one it models refuses the
restore and names the namespace. The gate is presence, with revision 0
reading as absence per the sentinel above; a live revision's value is
never compared — a revision the reader has not seen describes bytes it can
still parse, and gating on it would make every additive append breaking.
Like required, the rule binds forward and not backward. Retiring a
namespace is a specification-level act for the profile.
All layers are zstd-compressed CBOR sequences (RFC 8742). The deterministic encoding profile is pinned by this spec: shortest-form integers, definite lengths only, no floats, no tags (RFC 8949 §4.2.1). Every layer starts with a protocol-defined header record that makes the blob self-describing even when detached from its registry:
[format_version = 1, profile: tstr, kind: tstr, scope: any]
scope is opaque to the protocol: the profile decides its shape, and the
protocol carries it, canonicalizes it (in the inscription) and compares it
for equality — never reads it. Content records after the header are entirely
profile-defined.
diffId — a layer's identity — is the sha256 of the uncompressed CBOR
sequence. Determinism is anchored on uncompressed bytes because compressed
output is only stable for a pinned library version and parameters; the
compression declared in the inscription is transport, and correctness never
depends on it.
The inscription is the canonical, signable document of a stele: the OCI
config blob, canonical JSON per RFC 8785 (JCS). Generic keys plus three
profile-owned opaque objects — position, parameters and each layer's
scope:
{ "schema": 1,
"profile": {"name": "io.txpipe.dolos.cardano", "version": 1},
"sequence": 550,
"position": { "…": "profile-owned" },
"parameters": { "…": "profile-owned" },
"compression": {"algo": "zstd", "level": 9},
"history": [
{"sequence": 548, "inscriptionDigest": "sha256:…"},
{"sequence": 549, "inscriptionDigest": "sha256:…"} ],
"layers": [
{"kind": "blocks", "mediaType": "application/vnd.dolos.stele.blocks.v1+zstd",
"diffId": "sha256:…", "records": 21600, "uncompressedSize": 43210000,
"scope": {"epoch": 0, "startSlot": 0, "endSlot": 21599}} ] }schemais the protocol's own version; verifiers fail closed on anything but the versions they implement.sequenceis the protocol's monotonic ordering key. What it counts is the profile's decision (the Dolos profile sets it to the epoch).position,parametersandscopeare canonicalized by JCS like every other key, so determinism holds without the protocol interpreting them. Verifiers reject unknown generic top-level keys, so extension happens only inside those three objects.- Determinism and signing are defined only over this document's sha256.
- Restore verifies registry blob digests (transport integrity) and diffIds (canonical identity).
history embeds the digest of every previously published inscription, so the
latest signed inscription transitively attests the entire publication history
(~80 bytes per sequence — negligible for a config blob). This makes
attestation outlive blob retention: a stele whose blobs have long been
garbage-collected can still be verified by anyone holding a copy, because the
copy carries its own inscription — check that inscription's digest against
the history of the latest signed one, then the layers against its diffIds.
No external trusted storage of attestations is required.
Retention is the publisher's policy, not the protocol's: a publisher keeps a
trailing window of immutable tags, untagged blobs are the registry's garbage
collector's to reclaim, and blobs still referenced by later manifests
survive on their own. history is what makes that policy safe — trust
evidence for a reclaimed stele survives in every later inscription.
History invariant: history contains exactly one entry per published
sequence, contiguous from the dataset's first published sequence (pinned
per dataset alongside the default repository) up to sequence - 1, in
strictly ascending order — no gaps, no duplicates. JCS canonicalizes object
keys but preserves array order, so the ordering is normative. Verifiers
reject inscriptions that violate the invariant. This is a reproducibility
requirement as much as a safety one: independent publishers converge on
byte-identical inscriptions only if the publication schedule and history
encoding are canonical. If the list ever outgrows the inscription, the
designated evolution is a sequence-indexed Merkle Mountain Range commitment
({root, size}) — a schema-versioned change that can be built retroactively
from the flat list.
A side-effect of anchoring identity on uncompressed content digests is that layer content can be mirrored over any content-addressed transport — or re-compressed with a different algorithm — and still be verified against the same signed inscription via diffIds. Consumption is stricter than verification: the restore client expects the canonical blobs referenced by the OCI manifest, so re-encoded mirrors serve archival and verification, not direct restore.
- One repository per (profile, dataset). The protocol requires an immutable
tag per sequence plus a moving
latest; the profile renders the strings. artifactType: application/vnd.stelae.stele.v1; layer media types per the profile; three annotations per layer, named below.
A stele in a registry is one OCI image manifest, and its shape is closed: a conforming publisher writes exactly the fields below, and a conforming client refuses anything else.
schemaVersion: 2;mediaType: application/vnd.oci.image.manifest.v1+json;artifactType: application/vnd.stelae.stele.v1.configis the inscription's descriptor:mediaTypeisapplication/vnd.stelae.inscription.v1+json,digestis the sha256 of the canonical inscription bytes — the same digest independent parties reproduce and sign — andsizeis those bytes' length.layers: one descriptor per inscription layer, in inscription order. Each carries the layer'smediaTypeexactly as the inscription states it, the compressed blob'sdigestandsize, and the three annotations below.- No
subjectand no manifest-levelannotations.
The manifest bytes are canonical JSON per RFC 8785, through the same canonicalizer as the inscription, and are pushed verbatim: the protocol has one answer to "what are the bytes of this JSON document", not two that agree until they do not.
The per-layer annotation keys are reverse-DNS under stelae.store, a domain
TxPipe owns:
| Key | Status | Value |
|---|---|---|
store.stelae.layer.diffId |
normative | the layer's diffId, exactly as the inscription states it |
store.stelae.layer.kind |
informational | the layer's profile-defined kind |
store.stelae.layer.scope |
informational | the layer's scope object as stringified canonical JSON |
store.stelae.layer.diffId is the identity→blob map — the thing a registry
hands over for free. A directory has no manifest to carry it, so a publish
writes the same map to a blobs.json sidecar beside inscription.json; it
is transport and not identity — unsigned, outside the inscription digest, and
fully recoverable from the blobs — so a stele without one still restores, by
decompressing every blob to rebuild it. A client that does not read it cannot
fetch a layer; it is the one annotation a reader must understand. The other
two exist so a human or a generic registry tool can see what a blob covers
without fetching the config blob, and a client may ignore them.
The manifest and the inscription are two views of one stele — the inscription holds identity, the manifest holds transport — and a disagreement between them, in either direction, is a refusal, never a preference.
A publisher refuses to build a manifest — before anything is pushed — when the inscription describes a layer that was never written, or a layer was written that the inscription does not describe: a blob nothing attests must not be published.
A client refuses a manifest — before any blob is fetched — when:
artifactTypeis missing. This fails closed by choice: a registry that strips the OCI 1.1 discovery field has published something a client cannot recognize as a stele, and reading it anyway would make the discovery contract advisory.artifactTypeis present and is notapplication/vnd.stelae.stele.v1.- the config descriptor's media type is not the inscription's.
- the manifest's layer count differs from the inscription's.
- a layer carries no
store.stelae.layer.diffIdannotation, so nothing says which layer it holds. - a layer's
diffIdannotation disagrees with the inscription's layer at that position. Positional correspondence is a check of its own: a manifest carrying the right blobs in the wrong order passes the map and fails the order. - a layer's media type disagrees with the inscription's at that position.
A manifest past 4 MiB (stelae::MANIFEST_SIZE_LIMIT) is refused before
the push. The figure is not a limit the OCI specification imposes; it is the
ceiling registries converge on, and the refusal is measured on the exact
canonical bytes that would have been pushed, so it names the document and its
layer count instead of arriving later as a registry's 413. A descriptor
with its annotations costs ~350 bytes, so the ceiling falls near 12,000
layers; a profile's layer arithmetic must stay inside it (the Dolos profile's
own accounting lives in its half of the spec).
-
A process that opens a registry client must have installed a process-default rustls
CryptoProviderfirst. The transport ships no crypto backend of its own (reqwest/rustls-no-provider): the backend the client library would otherwise pick wantscmakeon every build machine, so it stays out of the tree and the choice of provider moves to the program. Omitting the install is a panic when the registry client opens, not a link error. -
Authentication is the host's decision, in one of three shapes. The client is opened with credentials its caller supplies — anonymous, a bearer token, or an HTTP Basic pair — and never sources them itself. Which identity a program authenticates as is that program's credential policy, and where it keeps its credentials is that program's deployment: a protocol library that read an environment variable would be deciding both on its host's behalf, and naming the variable would freeze that decision into a published API. So this specification names no environment variable and no configuration key, and
stelae::oci::Options::authis the whole of the interface.Anonymous remains legitimate and is what a genuinely public repository wants. It is not the only legitimate deployment: read access to a repository can be free and identity-less and still credentialed.
A client reads a stele in a fixed order, refusing early: resolve the tag to
a manifest and refuse any manifest–inscription disagreement (above, before
any blob is fetched); verify the inscription's digest, schema, profile
name and major version, and signatures; plan; then fetch, verifying each
blob's registry digest (transport integrity) and its diffId (canonical
identity).
The protocol's half of a restore is planning and resume; layer selection is
profile-side by necessity (a layer's scope is opaque, so only the profile
can read a window out of one), and store writes are the host's.
- No layer kind is mandatory for consumers: the inscription declares what the stele contains, and the client selects which layers to fetch and which data to source elsewhere. A kind the client does not implement is skipped and reported, per rule 3 above.
- The progress file (
stelae::plan::RestoreProgress) records the inscription digest and the diffIds of completed layers, at a path the host chooses. Its resume rule is content-based: a layer is done when itsdiffIdis recorded, which is a fact about bytes and not about the stele they were published in. Which layers may be skipped on resume at all is the profile's decision. - Remaining-transfer accounting (
stelae::plan::Remaining) is derived from the compressed sizes of the layers still to fetch — excluding layers already completed per the progress file — so resumed and deduplicated restores report correct totals.
Signatures are Ed25519 over the inscription digest, pushed as OCI referrer
artifacts (application/vnd.stelae.signature.v1, cosign-compatible envelope
where convenient), enforced k-of-n against a client-configured trusted-key
set. Status: specified, not yet implemented — no signing or verification
code exists in this repository yet; nothing else in this document is
aspirational.
Three version axes move independently and must be kept coherent:
- Protocol schema — the inscription's
schemaand the envelope media types'.v1. Verifiers fail closed on versions they do not implement. - Profile version —
profile.version; a major the client does not implement is a clean refusal. - Payload media-type versions — the
.v{n}in each payload type, vendor-owned. Within a version, a profile is expected to treat.v{n}as a contract on record content rather than an exact-byte pin, with the profile's schema-revision map (inparameters) pinning the bytes; the details are each profile's to specify.
The stelae crate is the protocol implementation this document is normative
for; stelae-driver layers profile-generic lifecycle machinery (chained
publishing, restore budget/checkpoint, preflight, retry) on top of it and
adds no wire surface. The boundary proof is stelae/tests/toy_profile.rs: a
second, trivial profile implemented against the protocol surface alone.