Skip to content

Delegated Execution Client Contract

A client that holds an account root and a device key, and runs no node of its own, reaches a context through somebody else’s node. This page is the contract for that: the exact bytes it signs, the exact shapes it sends, and which parts are live today.

It exists so a client can be built against a fixed target rather than against a guess. Every field here is quoted from the code that implements it, and anything still due to change says so where it appears.

Contract Status Tracking
Discovery — where to write and as whom Live —
Warrant and delegated write Live; app_version and cited heads signed but not enforced #3933
Session — challenge and LoginStatement Live #3930
Read — an authenticated account reads a context Live #3931

Two conventions run through everything below.

Every signature in Calimero is over a 32-byte digest produced by one function, so a signature minted for one purpose can never be replayed as another. Length-prefixing each segment is what stops ("ab", "c") and ("a", "bc") hashing alike.

crates/primitives/src/identity.rs
pub fn domain_hash(domain: &[u8], parts: &[&[u8]]) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update((domain.len() as u64).to_le_bytes());
hasher.update(domain);
for part in parts {
hasher.update((part.len() as u64).to_le_bytes());
hasher.update(part);
}
hasher.finalize().into()
}

In other words: SHA256( u64le(len(domain)) ‖ domain ‖ ( u64le(len(part)) ‖ part )* ), with every length a little-endian u64.

A client implementing this in another language must reproduce it byte for byte. It is the single most likely place for a cross-language client to go wrong, because a missing length prefix still produces plausible-looking digests that never verify.

Signed structures cross the HTTP boundary as hex-encoded borsh, never as JSON. The canonical form of a signed statement is its borsh encoding; re-describing its fields as JSON would create a second spelling that could disagree with the bytes the signature covers.

Everything unsigned — request envelopes, arguments, responses — is JSON, camelCase.

Before minting a warrant a client needs two facts it cannot derive: which node to talk to, and which account that node will name as executor.

Which relay, from the cloud:

GET /api/cloud/me/namespaces/{namespace_id}/relays
{
"namespace_id": "…",
"relays": [
{
"peer_id": "12D3KooW…",
"relay_url": "https://node-7.example.net", // where to send the intent
"executor_account": "…", // what the warrant must name
"authorship_ready": true, // may it author here yet
"status": "active",
"assigned_at": "…", "confirmed_at": "…", "last_seen_at": "…"
}
]
}

Whether this context is open to delegation, from the node itself:

GET /admin-api/contexts/{context_id}/intents
{
"executorAccount": "…",
"canAuthorOnBehalf": false,
"groupId": "…",
"grantedOnGroupId": null
}

canAuthorOnBehalf: false is the default state of every context — authorship is implied by neither membership nor admin, and is never granted implicitly. A client should read this before signing anything and, when it is false, tell the user to ask an admin of groupId for the grant rather than minting a warrant that will be refused after the nonce is already spent.

A TEE relay (RelayTee) answers true by its role, with no grant. A TEE replica (ReadOnlyTee) always answers false and no grant changes that: the namespace has to admit its TEEs in relay mode. See what the relay must be granted.

// crates/account/src/warrant.rs — live today
pub struct Warrant {
pub context: ContextId,
pub author_account: AccountId,
pub author_device_key: PublicKey,
pub executor: AccountId,
pub app_version: ApplicationId, // the exact build, not a version string
pub method: String, // in the clear
pub intent_hash: [u8; 32], // H(method ‖ args)
pub account_heads: Vec<[u8; 32]>, // at most 64
pub governance_floor: Vec<[u8; 32]>, // at most 64
pub nonce: u64,
pub not_after: u64,
pub signature: [u8; 64],
}

The arguments are committed to as a hash rather than carried; the method itself travels openly, so a peer can select a per-method write-set without reversing a hash against the app’s ABI:

intent_hash = domain_hash(b"calimero.warrant.intent.v1", &[method.as_bytes(), args])

And the signature covers, in this order:

signature = sign(domain_hash(b"calimero.warrant.v2", &[
context.digest(),
author_account.as_bytes(),
author_device_key, // 32 bytes
executor.as_bytes(),
app_version, // 32 bytes
method.as_bytes(), // variable length
intent_hash, // 32 bytes
&(account_heads.len() as u64).to_le_bytes(),
..account_heads, // 32 bytes each, in order
&(governance_floor.len() as u64).to_le_bytes(),
..governance_floor, // 32 bytes each, in order
&nonce.to_le_bytes(),
&not_after.to_le_bytes(),
]))

Each head list is preceded by its own count, and that is not decoration. domain_hash length-prefixes every part it is given, so the heads are individually unambiguous — but the two lists are adjacent, so without the counts account_heads = [a, b], governance_floor = [] would hash identically to [a], [b], and a relay could relabel which plane a head was cited from.

POST /admin-api/contexts/{context_id}/intents
{
"method": "set",
"argsJson": { "key": "a", "value": "b" },
"warrant": "<hex borsh of Warrant>",
"authorProof": "<hex borsh of AccountProof<DeviceCert>>"
}

The client supplies only its own half of the proof. The executor’s proof and signing key are attached by the node from its own credentials, because the warrant authorizes an operator account and the author has no business knowing which of that operator’s processes will run it.

// 200
{
"data": {
"rootHash": "…", // the context's scope root after the run
"returns": { /* the method's own return value, or null */ }
}
}

AccountProof<T> is the self-contained credential that ties a device key to an account:

crates/account/src/signed.rs
pub struct AccountProof<T> {
pub genesis: AccountGenesis, // hashes to the AccountId
pub chain: Vec<RootKeyHandoff>, // root rollovers, epoch 0 upward
pub statement: T, // here: DeviceCert
}

A warrant writes into a context that exists. To create one, sign a ContextCreationWarrant — the group, a 32-byte seed the node derives the context id from, the application, optional service and name, and a hash of the init arguments — and present it to a relay:

GET /admin-api/groups/{group_id}/context-intents?author={account}
POST /admin-api/groups/{group_id}/context-intents
// GET 200 — ask first; it signs nothing and spends nothing
{ "data": { "executorAccount": "…", "groupId": "…",
"canCreateOnBehalf": true, "authorMayCreate": true } }
// POST body
{ "warrant": "<hex borsh of ContextCreationWarrant>",
"authorProof": "<hex borsh of AccountProof<DeviceCert>>",
"initArgs": { /* the JSON init() receives */ } }
// POST 200
{ "data": { "contextId": "…", "groupId": "…", "memberPublicKey": "…" } }

init_hash = domain_hash("calimero.context-creation.init.v1", [init_args]), where init_args is the JSON the node re-encodes from initArgs (compact, keys in the order sent). The signing preimage is domain_hash("calimero.context-creation-warrant.v1", parts) over, in order: group, seed, author account, author device key, executor, application id, then for each of service_name and name a one-byte presence tag (00/01) followed by its UTF-8 bytes (empty when absent), then init_hash, the u64le-counted account heads and governance floor, u64le(nonce) and u64le(not_after).

The author needs admin or CAN_CREATE_CONTEXT in the group; the relay needs only standing to act for members (a RelayTee, or CAN_AUTHOR_ON_BEHALF). The nonce is spent in the new context’s ledger, so the device’s ordinary nonce source is the right one. Refusals: 400 malformed, 403 not authorized (author, relay, expiry, arguments, replay), 404 unknown group or application, 409 the group targets a different application than the one signed. Vectors are under Test vectors.

The same pattern covers governance: adding the other person to a DM, creating a channel’s subgroup, renaming it. Sign a GovernanceWarrant over one op in its delegable form and present it with the op’s bytes:

GET /admin-api/groups/{group_id}/governance-intents
POST /admin-api/groups/{group_id}/governance-intents
// GET 200 — signs nothing, spends nothing
{ "data": { "executorAccount": "…", "groupId": "…", "canActOnBehalf": true } }
// POST body — every field hex borsh
{ "warrant": "<GovernanceWarrant>",
"authorProof": "<AccountProof<DeviceCert>>",
"op": "<GroupOp, or RootOp, in its delegable form>" }
// POST 200 — the group the op acted on; the new subgroup for a creation
{ "data": { "groupId": "…" } }

The warrant’s kind names the plane (0 group op, published on {group_id}; 1 root op, published on the namespace, so {group_id} is the namespace id) and scope is that group. It commits to the op, not a description of it: op_hash = domain_hash("calimero.governance-warrant.op.v1", [[kind], op_bytes]), where op_bytes is exactly the op field. The signing preimage is domain_hash("calimero.governance-warrant.v1", parts) over, in order: scope, [kind], author account, author device key, executor, op_hash, the u64le-counted account heads and governance floor, u64le(nonce) and u64le(not_after).

The delegable form is the op as you mean it, with the fields only the relay can compute cleared: a MemberRemoved/MemberLeft’s two expected hashes are zero or empty, and a GroupDeleted’s cascade lists are empty. The relay fills them in. For GroupCreated, pick the new subgroup’s id at random and name yourself as admin; the id is inside what you sign, so the relay keeps it.

Delegable: MemberAdded, MemberRemoved, MemberLeft, MemberRoleSet, MemberCapabilitySet, DefaultCapabilitiesSet, SubgroupVisibilitySet, group, member and context metadata, ContextDetached, context capabilities; and, as root ops, GroupCreated, GroupReparented, GroupDeleted, and MemberJoinedOpen for yourself. A capability change may not grant or withdraw CAN_AUTHOR_ON_BEHALF.

Every peer applies the op as you, so your own role and capabilities decide: MANAGE_MEMBERS to add or remove, admin to add an admin, CAN_CREATE_SUBGROUP to create a subgroup. The relay needs only standing to act for members. A relay that creates a subgroup for you is seated in it with that standing, and holds its key, so it can serve it straight away. Nonces are spent per group and author device. Refusals: 400 malformed, 403 not authorized (the reason is in the body), 404 unknown group, 409 a subgroup id that already exists. Vectors are under Test vectors.

A device key obtains a session in two calls. Implemented by #3930; enable the account_proof provider on the node to use it.

GET /auth/challenge
{
"data": {
"challenge": "<64 hex chars — 32 bytes>",
"expires_at": 1789470000 // unix seconds; 60s from issue by default
},
"error": null
}

The 32 bytes are expiry(8) ‖ nonce(8) ‖ tag(16), the tag an HMAC over the first two under a key only the node holds. So the node stores nothing at issue time — which matters, because this endpoint is unauthenticated and a per-request row would hand an anonymous caller a write amplifier.

It is single-use, and spent only after a signature over it verifies. Burning it on presentation would let anyone who can see a challenge in flight invalidate it before its holder finishes signing.

404 means the provider is not enabled on this node.

crates/account/src/login.rs
// domain calimero.auth.login.v1 · borsh · signed by the DEVICE key
pub struct LoginStatement {
pub node: PublicKey, // this node's identity, as the client pinned it
pub audience: Audience, // WebOrigin(String) | CodeSigningId(String) | Cli
pub challenge: [u8; 32],
pub session_key: PublicKey, // the ephemeral key the session speaks with
pub device_key: PublicKey, // what the signature is checked against
pub issued_at: u64,
pub expires_at: u64,
pub signature: [u8; 64],
}
signature = sign(domain_hash(b"calimero.auth.login.v1", &[
node, // 32 bytes
&audience_tag_and_bytes, // 1-byte variant tag, then the payload
&challenge, // 32 bytes
session_key, // 32 bytes
device_key, // 32 bytes
&issued_at.to_le_bytes(),
&expires_at.to_le_bytes(),
]))

The audience tag is 0 for WebOrigin, 1 for CodeSigningId, 2 for Cli, followed by the variant’s string bytes (empty for Cli). The tag is load-bearing: without it a statement minted for the web origin "x" would verify as one minted for the code-signing id "x".

The client learns the node’s identity from a pinned certificate, never from the challenge — otherwise an attacker who can answer on the node’s behalf chooses what the device signs about. That is the field’s whole purpose: a hostile relay can fetch a real challenge from the target node and serve it to a user as its own, and only node refuses the replay.

The login goes through the node’s existing token endpoint, with auth_method: "account_proof" — not a bespoke route. That is what gives a device-key session the same refresh, revocation and rate limiting every other session gets.

POST /auth/token
{
"auth_method": "account_proof",
"public_key": "<the session public key — must match the statement's session_key>",
"client_name": "<the node URL, for node-binding the token>",
"timestamp": 1789470000,
"provider_data": {
"challenge": "<64 hex chars>",
"login_statement": "<hex borsh of LoginStatement>",
"account_proof": "<hex borsh of AccountProof<DeviceCert>>"
}
}
// 200
{
"data": {
"access_token": "<JWT; subject is the ACCOUNT, not the device>",
"refresh_token": "<JWT>"
},
"error": null
}

The session is granted to the account: the JWT’s sub is the account id, which /auth/validate forwards to handlers as X-Auth-User.

Membership is not established by logging in. It is evaluated per request against the target context’s group — a relay serves several tenants, so a session must not carry a standing right to read.

Implemented by #3931.

POST /admin-api/contexts/{context_id}/query
Authorization: Bearer <session token>
{
"method": "get",
"argsJson": { "key": "a" }
}
// 200
{ "data": { "returns": { /* the method's return value */ } } }

Three refusals a client must handle distinctly, because they mean different things to a user:

Status Meaning What the client should do
401 no session, or it expired re-run the login exchange
403 the account is not a member of this context’s group tell the user; do not retry
409 the method is not read-only a bug in the client’s method choice — this is a write, mint a warrant

That last one is the one to get right. Reads are gated on the method being declared read-only, and the gate fails closed: a method that declares nothing is refused rather than guessed at. See #3932 for how that declaration is derived from the method’s receiver — which is why the gate is usable at all, and why a client can call an ordinary &self method without the app author having annotated anything.

Three things about this endpoint are worth knowing before building against it.

The account is never in the body. It comes from the session, so there is no field a caller could set to read as somebody else. A session that is not account-anchored — a node-owner or client-key token — gets 401 here, not because it lacks permission but because this route answers “what may this account see” and such a session names no account. A node owner reads through the ordinary execute path.

Membership is re-checked on every call, against the group owning the context, never cached from the session. One relay serves several tenants, so a session that carried a standing right to read would keep serving somebody after the removal op the node has already applied. Inheritance resolves, so a subgroup member reads a context they hold through a parent.

No rootHash in the response, unlike the intent reply. That field answers “did this change anything?” and for a read the answer is structurally no; returning it would invite a client to watch it for changes that can never come.

5. Inventory — what this account can reach

Section titled “5. Inventory — what this account can reach”

A client that runs no node still has to find out which namespaces and contexts it may read or write, or every id has to be handed to it out of band. Three routes answer that, all with the session token and nothing else:

GET /admin-api/namespaces → the namespaces this account is a member of
GET /admin-api/contexts → the contexts inside them (each with its groupId)
GET /admin-api/contexts/{ctx} → one context, if this account is a member

All three resolve the caller’s groups from the store on every request and return only what those admit. Membership is never read off the session: a namespace or context disappears from the listing when the removal op lands, not when the token expires. A context whose owning group cannot be resolved is excluded, not included — the listings fail closed.

GET /admin-api/contexts/{ctx} applies the same rule to a named id, so a context the listing hides cannot be reached by guessing its id: the answer is 403, and 404 means the node genuinely does not hold it.

Node-owner sessions, and nodes running with no auth guard at all, keep the node-wide view of all three — narrowing there would empty the endpoints on every single-tenant node without closing anything.

1. GET /api/cloud/me/namespaces/{ns}/relays → relay_url, executor_account
2. GET /auth/challenge → challenge
3. sign LoginStatement with the DEVICE key
4. POST /auth/token (auth_method=account_proof) → session token
5. GET /admin-api/namespaces → my namespaces
GET /admin-api/contexts → my contexts, with groupId
6. GET /admin-api/contexts/{ctx}/intents → canAuthorOnBehalf?
├── false → stop; ask an admin of groupId for the grant
└── true → continue
7. READ POST /admin-api/contexts/{ctx}/query (token; no warrant)
8. WRITE sign a Warrant with the DEVICE key
POST /admin-api/contexts/{ctx}/intents (token + warrant + authorProof)

Every step works today.

On a TEE relay, steps 6 and 8 can travel sealed to the relay’s attested key, so the warrant and the arguments are readable only inside its TD. Attest with POST /admin-api/tee/attest (bindTransportKey, includeCollateral), verify the quote, then send those requests through /sealed/v2. On a relay that leaves auth to a proxy (hosted nodes), the session steps (2–4) and reads (5, 7) cannot be sealed. See how a client reaches a node.

Check your implementation against these before pointing it at a node. They are asserted by the Rust tests named beside each block, so they cannot drift from the code without CI going red.

crates/account/src/tests/domain.rs::domain_hash_has_known_answers

domain parts digest
"" — af5570f5a1810b7af78caf4bc70a660f0df51e42baf91d4de5b2328de0e83dfc
"ab" ["c"] 43ee655579de01ca739b3f95c1c2d3f46d353b2c0df818064ea594506cdb2617
"a" ["bc"] 9a8acca1b6c6c0befd3fbc756aed625da998c998f7252e738c4ef061906b9b21

Start with the first row. It is SHA-256 of eight zero bytes — the u64le length of an empty domain — not SHA-256 of the empty string. If you get e3b0c442…, you are omitting the length prefixes, and nothing below will match.

The last two rows are the whole reason the prefixes exist: both hash the letters abc, and they must differ.

crates/account/src/tests/warrant_wire_fixture.rs

Inputs: context = 0x11×32, author_account = 0x22×32, executor = 0x33×32, app_version = 0x44×32, method = "set", args = {"key":"k","value":"v"} (exactly those bytes, no whitespace), account_heads = [0x55×32], governance_floor = [0x66×32], nonce = 42, not_after = 1700000000, author device secret = 0x07 seeded.

intent_hash dc066cc8524c74dc21714174009df536376e3151f5b92f0a676defde599dbae5
signing preimage cd03cfaad8fb5f5aa143c76f35f39363b6608b820ab318a53ed01be901c7c6c7
author_device_key ea4a6c63e29c520abef5507b132ec5f9954776aebebe7b92421eea691446d22c

The full 351-byte encoding, field by field:

offset field bytes value
0 context 32 1111…11
32 author_account 32 2222…22
64 author_device_key 32 ea4a6c63…
96 executor 32 3333…33
128 app_version 32 4444…44
160 method len 4 03000000 (3, little-endian)
164 method 3 736574 ("set")
167 intent_hash 32 dc066cc8…
199 account_heads len 4 01000000 (1, little-endian)
203 account_heads[0] 32 5555…55
235 governance_floor len 4 01000000 (1, little-endian)
239 governance_floor[0] 32 6666…66
271 nonce 8 2a00000000000000 (42, little-endian)
279 not_after 8 00f1536500000000 (1700000000, little-endian)
287 signature 64 4007d416…

v1 was pure fixed-width concatenation — no length prefixes, no tags — and v2 gives that up on purpose: method is a String and the two head lists are Vecs, so three u32 little-endian counts now sit inside the encoding. The offsets above hold only for these inputs; a method of another length moves everything after it.

Note the two widths. The counts inside the borsh encoding are u32; the counts the signing preimage hashes (above) are u64. They are different serializations of the same numbers and a builder that reuses one for the other produces a warrant that is well-formed and verifies nowhere.

Every integer here is little-endian, in the encoding and in domain_hash alike. That is the second most common cross-language mistake after the missing length prefix.

crates/account/src/tests/creation_wire_fixture.rs

Inputs: group = 0x11×32, seed = 0x12×32, author_account = 0x22×32, executor = 0x33×32, application_id = 0x44×32, service_name absent, name = "general", init args = {"name":"general"}, account_heads = [0x55×32], governance_floor = [0x66×32], nonce = 42, not_after = 1700000000, author device secret = 0x07 seeded.

init_hash 074dc4be8c7abe685532a48947430edd0301b42f60222e715a834e723d2a055e
signing preimage f838cd94995573a7fea9768a82b6207632c4a89ce37c860e0d05febf5609644f
author_device_key ea4a6c63e29c520abef5507b132ec5f9954776aebebe7b92421eea691446d22c
encoding 389 bytes; service_name encodes as 00, name as 01 07000000 67656e6572616c
signature 7e47c0655b1b0ef65a420ef301f6888328579abc153b122d9e7f6396c8757aa5
14d8a4e65ff85912dfb20f6858fcdbfc60818214ca2e6adcbfbf4dae3d9fd906

crates/account/src/tests/governance_wire_fixture.rs

Inputs: scope = 0x11×32, kind = 1 (root), author_account = 0x22×32, executor = 0x33×32, op bytes = 010203, account_heads = [0x55×32], governance_floor = [0x66×32], nonce = 42, not_after = 1700000000, author device secret = 0x07 seeded.

op_hash d6bc121f9fcf7b85bea94d356d620c14dd8e1ae5fa2317cbcfc2c486cf04dfb3
signing preimage 23110c9012218d6996c33991173280928db4cc030a77164a31a2b1e47946bf80
author_device_key ea4a6c63e29c520abef5507b132ec5f9954776aebebe7b92421eea691446d22c
encoding 313 bytes
signature 27a7c779c5d7aef80dcf7125c2ef733bb0f4bbe20d84a75d0b132516d3b2a1a0
0e292b674f927df135d6f631dc7768f2e40a0c1c1040f5fc923385d2b5b05e07

Real ops, from delegated_governance_op_vectors in crates/governance-types:

MemberAdded { member: 0x44×32, role: Member }
op 01 44…44 01
op_hash c48dce4ba9da20980a86832b133e7040ec67c293987c0f131140291a71041cd6 (kind 0)
MemberRemoved { member: 0x44×32 }, delegable form
op 02 44…44 00×32 00000000
GroupCreated { group_id: 0x55×32, parent_id: 0x11×32, restricted: true, admin: 0x22×32 }
op 00 55…55 11…11 01 22…22
op_hash 0ae00fae1b87e3b0f285246632ebe31e2e3b687a563a1f5299be997657dfd55f (kind 1)