Delegated Authorship
The problem: authorship and execution are not the same act
Section titled “The problem: authorship and execution are not the same act”Everything in the write path assumes one node does both jobs — it runs the application and it signs the result. For a laptop running merod that assumption is free. For a phone, a browser tab, or an agent holding nothing but a key, it is not available at all, for two independent reasons:
- It cannot execute. The runtime is a WASM JIT. Running a method means compiling and running the application, against materialized state, under an execution lock. That is not something a thin client does.
- It cannot decrypt. State deltas are sealed under the scope key. A device that never joined the group never received one, so even the inputs are unintelligible to it.
The naive fix is to let the node write on the member’s behalf and label the result with the member’s name. That is not delegation, it is impersonation: nothing in the operation records that the member ever asked, so any holder of the group key could mint writes in anyone’s name and no peer could tell the difference.
Delegated authorship closes that gap with one idea: the member’s consent is itself a signed object, and it travels with the change. “Somebody else ran this for me” becomes a claim the author made and every peer can check, rather than an assertion the executor makes and no one can.
Before and after
Section titled “Before and after”| Self-authored | Delegated | |
|---|---|---|
| who runs the method | the author’s node | the executor (“relay”) |
| who signs the envelope | the author’s device key | the executor’s device key |
author_id on the delta |
the signer | not the signer |
| what proves the author consented | the signature itself | the warrant inside the envelope |
| what the author must hold | a node, a scope key, the app | one signing key |
| what the executor may do without a grant | — | nothing |
The last row is the one to keep hold of. Being a member does not let you author for others, and neither does being an admin.
The warrant
Section titled “The warrant”A warrant is the author’s signed statement that a specific request may be run by a specific operator on their behalf. It is minted offline — no node is contacted, nothing is published — and it binds exactly the things that would otherwise be substitutable:
| Field | Binds | Why it must be bound |
|---|---|---|
context |
where | Otherwise it authorizes the same request in every context at once. |
author_account |
who it is for | The authorization subject, carried rather than re-derived: re-deriving at apply time asks a different question than the author answered, so a disagreement has to be refusable instead of silently resolved. |
author_device_key |
which replica | The CRDT replica the change is attributed to, and the key whose signature this is. |
executor |
who may run it | An account, not a key — so one of the operator’s processes re-keying does not void warrants already issued to it. |
intent_hash |
what | H(method ‖ args). The hash, never the plaintext — see below. |
nonce |
once | Monotonic per author device. Per device rather than per account because two devices of one account are independent replicas and cannot coordinate on a shared counter. |
not_after |
until when | Checked by the relay at admission. Deliberately not checked by peers at apply — see below. |
The intent travels in the clear to the relay, and that is the whole point of the hop: it has to read the method and arguments to run them. What must not travel in the clear is the intent on the gossip wire, and it does not — the warrant commits to H(method ‖ args), and the detail stays sealed beside the operations like any other delta.
Two signatures, two domains
Section titled “Two signatures, two domains”A delegated delta carries two independent signatures at different layers, and keeping them apart is what makes the arrangement checkable.
-
The warrant is signed by the author’s device key under its own domain, and says what may be done for me, by whom. It is minted before the relay is ever contacted and is valid regardless of how the request reaches it.
-
The envelope is signed by the executor’s device key under
calimero/deleg/1, and says I ran this, and here is the consent that let me. The warrant is embedded whole in the signed bytes rather than hashed, so a relay holding two warrants for the same author cannot swap them between deltas and have each still verify.
A self-authored envelope signs under calimero/delta/2 instead. The two domains are separate for a wire-compatibility reason as much as a cryptographic one: borsh writes a tag byte for an Option, so adding one field to the shared payload would have changed the signed bytes of every self-authored delta ever produced. A separate domain leaves those bytes untouched — and means a self-authored signature can never verify as a delegated one, which would drop the warrant in flight.
What the relay must be granted
Section titled “What the relay must be granted”Authoring for others is a capability of its own, CAN_AUTHOR_ON_BEHALF, and it is granted like any other — see capability inheritance. It is not implied by membership, and not implied by admin. A relay that has not been granted it gets a clean refusal at the API, before anything is executed or published, which matters: a delta peers would reject is worse than a request that never happened.
An admin grants it with:
meroctl --node node1 group members set-capabilities <GROUP_ID> <RELAY_ACCOUNT> \ --can-author-on-behalfThe mask is replaced, not merged, so re-pass any other flag the member already holds. meroctl group members check-access <GROUP_ID> <ACCOUNT> prints the decoded set, CAN_AUTHOR_ON_BEHALF included.
<GROUP_ID> may be the namespace root rather than each context’s own group. The gate honours a grant found at the ancestor the relay inherits its membership through, so a relay fleet needs one grant and not one per channel. A Restricted subgroup is a wall here as it is everywhere: it required its own admission, so it needs its own grant.
Note the subject: an account, not a key. That is what lets the relay rotate the key one of its processes signs with without the grant — or any warrant already issued against it — having to be reissued.
Discovery: what the author has to be told first
Section titled “Discovery: what the author has to be told first”Every input to a warrant is something the author already holds, with one exception. executor names the relay’s account, which is a content address the client cannot derive, and whether that relay may execute a delegated write here is a row in the owning group’s capabilities the client cannot read. So the relay answers both, on the path the intent will be presented to:
GET /admin-api/contexts/:context_id/intents→ { "data": { "executorAccount": "…", "canAuthorOnBehalf": false, "groupId": "…", "grantedOnGroupId": "…" // optional } }canAuthorOnBehalf answers one question: may this node execute a delegated write in the group owning this context? It is a fact about the node, not about admin — an admin is simply who can change it. false is not an error and not permanent: it is the state of every context no admin has opened yet, since the capability is implied by neither membership nor admin and is never granted implicitly.
grantedOnGroupId says where the grant lives. canAuthorOnBehalf is that answer collapsed to a bool, so the two are never in conflict; the group is what tells a caller which group to ask, or later to edit:
grantedOnGroupId |
Means | What to do |
|---|---|---|
| absent | no group reachable from here carries the grant | ask an admin to grant it — on groupId, or once on an ancestor this node inherits membership through |
== groupId |
granted on this context’s own group | nothing — paired with canAuthorOnBehalf: true |
!= groupId |
granted on an ancestor, and honoured here | nothing — but that group, not groupId, is what a later revoke or narrow has to edit |
That third row is the one worth having. Contexts routinely live in subgroups while a TEE fleet node is admitted once at the namespace root, so one grant at the root is what covers the whole fleet — and this field is the only way to see that it is a root grant doing the covering rather than a per-context one.
A grant reaches wherever membership reaches, and no further. An ancestor grant is honoured for a context in an Open subgroup the node inherits membership through, and refused across a boundary membership cannot cross: a Restricted subgroup required its own admission, so it requires its own grant, and a node deny-listed off an Open subgroup is refused there. That resolution is the one documented exception to capabilities being read from the target group alone.
canAuthorOnBehalf remains the single authorization answer. grantedOnGroupId says where it came from — never something to read as permission in its own right.
Asking before signing is not politeness. A nonce is spent from a monotonic per-device sequence to mint a warrant, and one naming the wrong executor is unspendable: the number is gone and the write never happened.
meroctl context intent makes this read itself, before it signs anything: it takes executor from the answer rather than from /admin-api/identity, and refuses outright when canAuthorOnBehalf is false — naming the group and the account so the message is the grant command to ask an admin for. So the nonce is only ever spent on a warrant the relay can actually run.
Reaching a relay you have no account on
Section titled “Reaching a relay you have no account on”POST .../intents sits on the admin API, which is guarded. For a member using their own node that is the right default and costs nothing. For the case delegated authorship exists for it is fatal: a browser tab or an agent holding one signing key has no relationship with the relay and so no credential on it, which is recorded as the known gap in direct admission.
So a node may be run as a relay, with merod init --public-intents (or server.admin.public_intents = true in config.toml). It opens exactly the two routes above and nothing else, because they are the two that carry their own credential:
- the warrant is signed by the author’s device key and commits to this context, this method and these exact arguments;
- it is refused unless unexpired, its nonce unspent, and the relay holds
CAN_AUTHOR_ON_BEHALF— all before anything is executed or published; - the delta is attributed to the author, and every peer re-checks both signature layers and re-authorizes at the cut.
A node token proves none of that, and none of that needs one. What is left is signature verification on an unauthenticated request — cheap, and refused before execution; rate-limiting that hop belongs to the deployment’s reverse proxy, as it does for the other public routes.
Off by default: a self-hosted node’s operator did not ask for an unauthenticated surface, and the routes are useless to them anyway, since a member with a node authors its own writes.
It is the gate only under embedded auth
Section titled “It is the gate only under embedded auth”Worth being exact about, because the two auth modes differ and the difference is where the mistakes live.
Under --auth-mode embedded this setting is the gate: the guard wraps the protected router only, so moving the two routes across genuinely removes the credential requirement.
Under --auth-mode proxy — the default, and what every hosted node runs — the node installs no guard on either router. A reverse proxy enforces auth and is the only thing that does. So setting this alone changes nothing a caller can observe, and leaving it unset does not close the path: whatever the proxy exempts is open regardless.
That asymmetry is the trap. A proxy deployment has to set this and exempt exactly this path at its ingress, from one source of truth, or the two drift — and the drift is silent in the unsafe direction, because the ingress exemption is the half that actually opens the door. The fleet sidecar docs describe how the hosted node image drives both from a single variable for exactly this reason.
Replay, and why the ledger is a window
Section titled “Replay, and why the ledger is a window”A warrant’s signature stays valid forever — that is what a signature is. So replay is not forgery, and the envelope check cannot be what stops it: a relay re-presenting the same authorization would produce a second perfectly-verifiable delta. Every node therefore keeps a per-(context, author device) ledger and spends the nonce as part of admitting the delta.
The ledger is a sliding window (64 wide), not a strictly-increasing counter, and the reason is convergence. A high-water mark that only accepts nonce > seen makes acceptance depend on arrival order: two warrants that cross paths in the network would be accepted by one node and refused by another, and the two would never agree again. A window accepts any unseen nonce within it, so the verdict is a function of the set received, not the order.
The same reasoning explains not_after. It is checked by the relay, at admission, against its own clock — and never by peers at apply. Wall-clock at apply time would make acceptance node-dependent: the same delta arriving either side of an expiry would be applied by one peer and refused by another, which is divergence rather than security. Expiry limits how long a relay will spend a warrant; the nonce ledger is what bounds it on the network.
What this is not
Section titled “What this is not”- Not a key handoff. The author’s signing key never leaves their machine and is never sent. Only signatures are.
- Not a standing grant. A warrant authorizes one intent, once. Nothing accumulates.
- Not a way to bypass authorization. The write is authorized as the author — at the cut, against the folded membership, like any other. A warrant lets someone act as you; it does not let them act beyond you.
- Not the executor’s write. The result is attributed to the author’s account and device: their replica slot, their membership, their name in the app.
A worked example
Section titled “A worked example”The author holds one key and nothing else. Its account is already a member of the group; its device joined nothing.
1. mint — offline, on the author's machine, no node involved: warrant = { context=0x0102…1f, author_account=0x0e2c…5e30, author_device_key=0x4d07…3803, executor=<relay account>, intent_hash=H("set" ‖ {"key":"delegated",…}), nonce=1, not_after=now+300 } signature = Ed25519_sign(author_device_sk, b"calimero.warrant.v1" ‖ …)
2. present — POST /admin-api/contexts/<id>/intents { method: "set", argsJson: {...}, warrant: <hex>, authorProof: <hex> } The author sends only its OWN proof. The relay attaches its own credential — which of its processes runs this is not the author's problem.
3. admit — relay checks: the delegation verifies, the warrant names THIS context, it covers THIS (method, args), it has not expired, and the relay may author on behalf. Any failure → refusal, no delta.
4. execute — WASM runs with the AUTHOR as principal, so every write inside is owned by the author's account and keyed to the author's device.
5. sign — delta_signature = Ed25519_sign(relay_device_sk, payload of (b"calimero/deleg/1", context_id, delta_id, author_id, executor_key, warrant, governance_position, hlc))
6. persist — one atomic write, under the DAG lock: the delta with author_id = the author's device, and the spent nonce. Both or neither — a spent nonce with no delta would silently burn the author's request.
7. broadcast — the delta and the delegation together. A peer re-runs both signature layers, authorizes the write as the AUTHOR at the cut, spends the nonce in its own ledger, and applies.Step 7 is the half a relay cannot fake. Everything before it happens on one machine; only a peer accepting the result proves the network agrees the author consented.
Where this leads
Section titled “Where this leads”The delta is now an ordinary member of the log. The receive & apply path treats it like any other from here — the only difference is that verification had two layers instead of one, and that the author_id it carries was never the key that signed it.