Sync RPC specification
The transport, authentication model, conventions and complete method table of the Stem sync API, with every method and message type defined by a Hypermedia Schema, a worked exchange, and the versioning rule.

Part of Stem. This page specifies the remote procedure calls of the sync protocol: the peer-to-peer methods two daemons use with each other, and the local methods a client uses with its own daemon. Every method and every message type is a Hypermedia Schema published as a page of this site, and the union of all methods is rpc/method.

Transport

Peer methods run as gRPC over a libp2p stream with the protocol id /hypermedia/1.0. Two peers speak only when they share the exact id; the id changes whenever a message layout or the scope-set rule changes. The stream is secured by libp2p (the device keys), so the gRPC layer carries no transport credentials, and the server reads the caller's peer id from the connection. Messages are at most 8 MiB in either direction.

Local methods run over the daemon's own gRPC and HTTP endpoints, authenticated by the daemon's local token or a bearer token as described in Daemon. Over HTTP a read is GET /api/<Key> with the input in the query string and a write is POST /api/<Key> with a DAG-CBOR body, as the Seed API already does.

Every method is one call: request(key, input) → output. The schema of a method is a closed struct with exactly three required properties, key (a literal naming the method), input and output. A streaming method, of which Watch is the only one, returns one output value per message for the life of the stream.

Errors are a small, closed set. A method either returns its output or fails with one of:

error

meaning

unauthenticated

the method needs an account binding on the connection and there is none

invalid-argument

the input does not match its schema or violates a stated rule

resource-exhausted

a limit was hit; retry later

unavailable

the server cannot answer now, for example during a reindex

There is deliberately no not-found and no permission-denied: a blob or resource the caller may not read is reported in the output exactly like one the server does not have (uniform denial).

Authentication model

A connection starts with no identity beyond its peer id. Authenticate binds it to an account by a signature over the account, the two peer ids and a timestamp; a connection may be bound to several accounts; bindings end with the connection or with Goodbye. Every peer method is then evaluated against the readers sets that include any bound account, plus everything readable by everyone. The same evaluator answers the local Access method, so a client can see in advance what a connection will be served.

Methods

key

side

input

output

does

Authenticate

peer

account, ts, sig

expires

binds the connection to an account

ListSpaces

peer

page

spaces, nextPageToken

spaces the caller may read

ListPeers

peer

page, listHash

peers, nextPageToken

peer-table exchange

Reconcile

peer

scope, ranges

ranges

one round of set reconciliation

Fetch

peer

scope, cids

blobs, missing

serves readable blobs authority-first

Offer

peer

scope, cids, proof

id, wanted, rejected

offers blobs for a scope

Watch

peer, stream

scope, since

notification per message

live updates on a scope

Goodbye

peer

none

none

ends bindings and watches

Sync

local

scope, version, wait

sync-status

bring a scope up to date now

SyncStatus

local

scope

sync-status

report without starting a run

GetPolicy

local

account

policy

the policy in force

SetPolicy

local

account, rules

policy node-ref

publish a new policy

Access

local

scope, principal

access-report

explain access

Publish

local

blobs, scope

cids, stashed, rejected

ingest signed blobs

ListDisclosures

local

filters, page

disclosures, nextPageToken

read the disclosure ledger

ListTransfers

local

filters, page

transfers, nextPageToken

read the transfer log

Connect

local

addrs

peer

dial a peer explicitly

The peer methods are the whole wire surface. There is no listing of all blobs, no push of arbitrary blobs, no per-space mirror mode and no subscription message; a subscribed peer is simply one that reconciles and watches.

Conventions

    A method schema is a closed struct {key, input, output}, all three required, the convention introduced for the Seed API in the rpc/* library work. key is a literal so the method union is discriminated on it.

    input and output are either inline structs or includes of a shared type under rpc/type/* or data/*.

    A field that is always present but may be empty is spelled anyOf [T, null] and marked required, so the receiver can rely on the key.

    Lists are bounded where the protocol needs a bound; the bound is in the schema (maxItems).

    Pagination uses page in and nextPageToken out; a null token means the last page.

    Timestamps are Unix milliseconds (timestamp). Clocks are advisory everywhere except Authenticate's one-minute window and a grant's expires.

    Messages are DAG-CBOR on the wire and dag-json in examples.

A worked exchange

Peer A follows the space z6MkA… and reconnects to its site, peer B.

{"key": "Authenticate", "input": {"account": {"/": {"bytes": "7QEA…"}}, "ts": 1759910400000, "sig": {"/": {"bytes": "…"}}}} {"expires": 1759914000000}

A opens the first reconciliation round with its whole set as sixteen fingerprints.

{"key": "Reconcile", "input": { "scope": {"space": {"/": {"bytes": "7QEA…"}}, "depth": "subtree"}, "ranges": [ {"mode": "fingerprint", "boundTs": 1759000000000, "boundCid": {"/": "bafy…01"}, "fingerprint": {"/": {"bytes": "…"}}}, {"mode": "fingerprint", "boundTs": 1759500000000, "boundCid": {"/": "bafy…02"}, "fingerprint": {"/": {"bytes": "…"}}}, {"mode": "fingerprint", "boundTs": 1759910400000, "fingerprint": {"/": {"bytes": "…"}}} ]}}

B agrees on the first two ranges and splits the third, which does not match.

{"ranges": [ {"mode": "skip", "boundTs": 1759500000000, "boundCid": {"/": "bafy…02"}}, {"mode": "fingerprint", "boundTs": 1759800000000, "boundCid": {"/": "bafy…07"}, "fingerprint": {"/": {"bytes": "…"}}}, {"mode": "list", "boundTs": 1759910400000, "cids": [{"/": "bafy…11"}, {"/": "bafy…12"}, {"/": "bafy…13"}]} ]}

A answers the list with its own list for that range; after the third round the ranges all agree and A knows it lacks bafy…12 (a Grant) and bafy…13 (a Node). It fetches them.

{"key": "Fetch", "input": {"scope": {"space": {"/": {"bytes": "7QEA…"}}, "depth": "subtree"}, "cids": [{"/": "bafy…13"}, {"/": "bafy…12"}]}} {"blobs": [ {"cid": {"/": "bafy…12"}, "data": {"/": {"bytes": "…Grant…"}}}, {"cid": {"/": "bafy…13"}, "data": {"/": {"bytes": "…Node…"}}} ], "missing": []}

The Grant came first although A asked for the Node first. B wrote two disclosures, both with basis grant naming A's account and the grant that makes it a reader. A then watches.

{"key": "Watch", "input": {"scope": {"space": {"/": {"bytes": "7QEA…"}}, "depth": "subtree", "facets": ["comments"]}}} {"scope": {"space": {"/": {"bytes": "7QEA…"}}, "depth": "subtree", "facets": ["comments"]}, "cids": [{"/": "bafy…21"}, {"/": "bafy…22"}], "ts": 1759910460000}

A new comment's Node and Snapshot arrived at B one minute later; A fetches them and shows the comment.

Versioning

Adding a method is adding a schema page and an arm to rpc/method; a peer that does not know a key fails the call with invalid-argument and nothing else changes. Adding an optional field to an input or output is compatible. Changing or removing a field, changing the scope-set rule, the item order or the fingerprint of Reconcile, or the authority-first order of Fetch, is a protocol change: it takes a new protocol id, and peers on different ids do not sync.

See also

Do you like what you are reading? Subscribe to receive updates.

Unsubscribe anytime