Skip to content

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.

How this fits alongside a client’s own node, a TEE relay sealed end to end, and the TEE authority is laid out in how a client reaches a node.

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, unless it is a TEE relay (RelayTee)
whose role must allow the write the author’s the author’s — and the executor’s decides whether it may relay

The last two rows are the ones to keep hold of. Being a member does not let you author for others, and neither does being an admin. And a relay is not a way round a role: a member who is read-only in the context cannot write through one.

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.

A delegated delta carries two independent signatures at different layers, and keeping them apart is what makes the arrangement checkable.

  1. 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.

  2. The envelope is signed by the executor’s device key under SignatureDomain::Delegated, 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 SignatureDomain::Delta instead. The domain is the first byte of every signed payload, so a self-authored signature can never verify as a delegated one, which would drop the warrant in flight.

Who may relay is decided by the executor’s role first, and by a capability only for a node that is not a TEE. The executor’s role is read where it is effective — its own row in the context’s group, or the row at the ancestor it inherits membership through, which is where a fleet node admitted once at the namespace root holds it:

Executor’s role May relay a member’s write
RelayTee — a TEE relay yes, by its role. Attestation under a namespace whose admission mode is relay is the grant; no capability bit is needed.
ReadOnlyTee — a TEE replica never, even holding CAN_AUTHOR_ON_BEHALF from a default mask or an explicit grant.
Admin, Member, ReadOnly — a self-hosted --delegated-access node only with CAN_AUTHOR_ON_BEHALF, as below.

The same answer is given in three places, from one function (warrant_gate::executor_standing): POST .../intents before it executes, the relay descriptor GET .../intents (canAuthorOnBehalf), and every peer applying the delta at the cut. So a patched replica that skipped its own check still cannot get a relayed delta applied.

Refused up front, never silently. POST .../intents refuses before anything executes, with a 403:

Refusal Message
this node is a TEE replica this node is a TEE replica (ReadOnlyTee) and does not relay writes; the namespace must admit relays with mode=relay
this node is a ReadOnly member (directly or inherited) this node's role in this context is read-only (ReadOnly), so it does not relay writes
the author is read-only in the context (ReadOnly, or a TEE role — directly or inherited) the author's role in this context is read-only
an Admin or Member node without the grant this node holds no authorship grant … an admin must grant CAN_AUTHOR_ON_BEHALF to <account>

Neither message mentions a nonce: a relay client treats that word as a retryable replay, and no retry changes a role. The execute path checks the same thing under the context lock, and a role refusal from there reaches the caller as the same 403 (ExecuteError::DelegatedWriteRefused). A delegated run’s writes are the author’s, so the executor’s own read-only rule never discards them: a write that may not happen is refused, and one that may is kept — never a 200 for a write that was dropped.

For an Admin or Member executor, 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.

A namespace is created carrying it in its default-capability mask, so a non-TEE member admitted to one afterwards lands able to relay with no further op; the mask is copied into a member’s capability row at admission, so seeding it at creation is the one point that needs no backfill. TEE nodes no longer depend on it. A TEE replica admitted under the mask holds the bit and is still refused, which is what the mask used to get wrong — every replica admitted after a namespace’s creation became a relay by accident. A TEE relay relays without it, so a namespace that strips the bit from its default mask still has working TEE relays.

Three things that follows from, none of which the default can paper over:

  • Namespaces only. A subgroup is created closed. A grant resolves through the membership anchor, so one at the root already reaches every Open subgroup beneath it — where contexts live — and a Restricted subgroup is a deliberate membership boundary whose admin decides its own posture.
  • Not retroactive, and never for admins. A member admitted before the mask was set keeps the row it got, and admins never receive the default at all — add_member seeds it for non-admin roles only, and the capability is not implied by admin. So a namespace created before this, and the creator’s own node in any namespace, still need an explicit grant.
  • It reaches every non-admin member, not only attested nodes. What that does not confer is the ability to forge: a delegated write is authorized by the author’s own warrant, signed by their device key and committed to this context, this method and these exact arguments, with its nonce checked unspent and every peer re-verifying both signature layers at the cut. A holder can only spend a warrant it was deliberately handed. What it gains is the arguments in cleartext and the choice of when, or whether, to publish.

It is an initial value, not a rule: GroupOp::DefaultCapabilitiesSet changes it afterwards, and nothing re-asserts it. An admin who wants a closed namespace clears the bit:

Terminal window
meroctl --node node1 group settings set-default-capabilities <NAMESPACE_ID> \
--can-join-open-subgroups

Note that command replaces the mask rather than adding to it, so pass every flag you mean to keep — dropping --can-join-open-subgroups would stop every later member inheriting into Open subgroups.

To grant one relay directly — a node admitted before the default was set, or the admin’s own:

Terminal window
meroctl --node node1 group members set-capabilities <GROUP_ID> <RELAY_ACCOUNT> \
--can-author-on-behalf

The 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 a context whose group has not been opened to delegated authorship, which since namespaces are created carrying the bit means a Restricted subgroup, a namespace created before that default, or a node admitted before the mask was set. On a TEE replica it is always false; switching the namespace’s admission mode to relay is what changes it there.

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, or this node is a TEE replica a non-TEE node: ask an admin to grant it — on groupId, or once on an ancestor this node inherits membership through. A TEE replica: the namespace must admit its TEEs with mode=relay
== 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 relay 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. For a TEE relay the group reported is the one whose row carries its RelayTee role — the namespace root, for a fleet relay.

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.

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 --delegated-access (or server.admin.delegated_access = true in config.toml). Both were called public-intents / public_intents until recently; the old flag and the old config key are still accepted for one release, but the name described only the write half and the surface now covers caller-scoped reads too. It does two things. First, it moves the two routes above onto the unauthenticated router, 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, the author’s role in the context may write, and the relay may author — a RelayTee by its role, an Admin or Member by CAN_AUTHOR_ON_BEHALF, a ReadOnlyTee or ReadOnly never — 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.

Second, it turns on the request-carried proof path, so a caller holding an account root and a device key can authenticate any admin request by signing it, rather than by holding a session on a node it has no relationship with. That half is what the caller-scoped reads are for — GET /admin-api/contexts, /namespaces, and the per-resource reads each narrow to the caller’s own groups, resolved per request. It is also why the flag is no longer called public-intents: the name described the first half only, and reads execute nothing.

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.

Signing a request instead of holding a session

Section titled “Signing a request instead of holding a session”

A session is a bearer token: obtained once, presented many times, and worth stealing for as long as it lives. The alternative is to prove who you are on each request, which is what the second half of the flag turns on.

The caller sends one header:

X-Calimero-Proof: <hex of a borsh-encoded CallerProof>

and that value is a chain of three links:

link signed by says
account_proof the account root this device key belongs to this account
session the device key this session key may act for this device, on this node, until
request the session key this exact method, path and body, from here until

The middle link is optional. With it — the three-link chain — the device key never signs a request, so a leaked session key cannot be escalated into use of the device itself. Without it, the two-link chain, the device key signs each request directly; that is meroctl’s shape, where a CLI run by the key’s holder has nowhere better to put a session key than the same process.

The node checks the links cheapest-first — the request’s own window before any signature, one signature before the certificate chain’s n — so a stale or misaddressed proof costs almost nothing to refuse.

What a proof is bound to, and what it is not

Section titled “What a proof is bound to, and what it is not”

The signature commits to the method, the path and a hash of the body. It does not cover the query string: a proxy may rewrite a query — a token parameter most of all — and signing over bytes something else is entitled to change means failing for reasons the caller cannot see. Anything that must be bound belongs in the body.

The expiry is what bounds replay, and nothing else does: a captured proof performs the identical request until it lapses. Keep it to minutes.

Only the session link carries a node. A three-link proof minted for one relay is refused by another, because the statement names the first relay’s signing key. A two-link proof has no node field at all, so there is nothing to bind — a deployment that depends on that binding must require the session link. The asymmetry is deliberate, and it is why VerifiedCaller::audience is an Option rather than a default: both shapes say “this chain had no session”, and a caller that cares has to look.

answer meaning
401 + X-Auth-Error: invalid_proof the chain did not verify — bad signature, wrong node, outside its window, or not a CallerProof at all
403 + X-Auth-Error: invalid_proof the chain is sound, and this node was not asked to serve that account
401, no proof header no credential was presented; opening this path did not open the route

The 403 is the one worth keeping distinct. A caller told 401 will reasonably try again with a fresh proof; a caller told 403 knows the door will not open however it knocks, because the node is not running with --delegated-access.

delegated-proof.yml exercises all of it against real nodes — including the node binding, which it makes testable by presenting a proof minted for one relay at a second relay that also serves delegated access, so a refusal can only be the binding.

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.

A warrant names a context, so it can only write into one that already exists. An account with no node also has to be able to make that context — a channel, a DM, a document — and the relay cannot do it as itself: a relay holds no CAN_CREATE_CONTEXT of its own, and a TEE relay’s own writes are read-only by its role. So creation has its own signed statement, the ContextCreationWarrant (crates/account/src/creation.rs), under its own signing domain calimero.context-creation-warrant.v1, so neither statement can be presented as the other.

It pins everything that shapes the new context, so the relay chooses none of it:

field why it is signed
group the group the author’s CAN_CREATE_CONTEXT is checked in
seed the context id is derived from it (ContextId::from_seed), so one warrant names exactly one context
author_account, author_device_key whose context this is, and who runs init
executor the relay account that may carry it out
application_id, service_name the code the context runs — never swapped for the group’s target
name recorded on the context’s metadata as part of the registration
init_hash domain_hash("calimero.context-creation.init.v1", [init_args])
nonce, not_after, cited heads as on a warrant

The relay serves it next to the intents route, on the same (optionally unauthenticated) router:

GET /admin-api/groups/<group>/context-intents[?author=<account>]
→ { executorAccount, groupId, canCreateOnBehalf, authorMayCreate? }
POST /admin-api/groups/<group>/context-intents
{ warrant: <hex>, authorProof: <hex>, initArgs: {...} }
→ { contextId, groupId, memberPublicKey }

It checks what only it can (the group, the init arguments against init_hash, and not_after), runs the same gate every peer will run, then runs init as the author — both halves of the principal from the warrant, exactly as a delegated write — and publishes GroupOp::ContextRegisteredOnBehalf, signed with its own key and carrying the bundle.

Every peer applying that op (crates/governance-store/src/creation_gate.rs) re-verifies the bundle and then asks every question of the author that a self-signed registration asks of its signer:

  • the op is signed by the executor key the bundle certifies;
  • the context id is the one the seed derives, and the application, service and name are the signed ones;
  • neither device is revoked in the group;
  • the author is a member (or the genesis admin), not read-only, and holds admin or CAN_CREATE_CONTEXT — at the op’s cut, like every governance gate;
  • the relay may act for members here — a RelayTee by its role, anyone else by CAN_AUTHOR_ON_BEHALF, a ReadOnlyTee never;
  • the nonce is unspent in the new context’s per-author-device ledger, which is then spent. Replaying the warrant — even after an admin has detached the context — is refused.

The relay’s own CAN_CREATE_CONTEXT is never consulted, and the plain ContextRegistered stays closed to it. Adding the op is a coordinated upgrade (SIGNED_NAMESPACE_OP_SCHEMA_VERSION 14; 15 for the governance wrappers): an older node cannot decode it.

The same split carries governance. A member signs a GovernanceWarrant over one governance op in its delegable form: the op exactly as they mean it, with only the fields a publisher computes from its own view cleared (a removal’s post-state hashes, a cascade delete’s subtree). kind (group or root op) and scope (the group it is published on) sit inside the signature, so consent to one op, in one group, on one plane, cannot be spent as another. The relay fills in what it must, wraps the op in GroupOp::OnBehalf / RootOp::OnBehalf (schema 15) and publishes it with its own key.

Every peer verifies the bundle, checks the op is delegable and is the one the warrant commits to, checks the relay’s standing and the author’s membership, and then applies the inner op through its ordinary handler with the author as the acting principal, at the same causal cut. Every gate that would have asked about the signer asks about the author, by account. So the author’s own authority decides (MANAGE_MEMBERS to add, admin to add an admin, CAN_CREATE_SUBGROUP to create a subgroup) and the relay’s never does. The projection folds the inner op, attributed to the author.

Delegable ops are member-level: membership, roles, capabilities, visibility, metadata, context detach and capabilities, and subgroup create, reparent, delete and self-join. Not delegable: account and device credentials (already signed by the account’s own keys), key rotation, TEE policy, ownership, application upgrades, namespace ops, and any wrapper, so nothing nests. A capability change may not grant or withdraw CAN_AUTHOR_ON_BEHALF, which decides which relays may act for members.

As the node that holds the keys, the relay does what the node performing an op always does: it delivers the group key to a member it adds, rotates on the author’s admin authority on a removal (peers accept that rotation), and mints a new subgroup’s key. A relay that creates a subgroup is seated in it as a Member with CAN_AUTHOR_ON_BEHALF by the apply itself, so it can serve the subgroup at once. A creation naming an existing group is refused, since the creation apply would otherwise seat the author as that group’s admin.

Founding a namespace on a member’s behalf

Section titled “Founding a namespace on a member’s behalf”

A member with no node can found a namespace through a relay. They sign a GovernanceWarrant (kind Root, scope the new namespace id) over the exact NamespaceCreatedV2 genesis: the id is founded_namespace_id(author, salt), so the author computes it and the relay cannot choose another. The relay posts it to POST /admin-api/groups/{namespace_id}/governance-intents, takes an identity in the new namespace, mints its key, and publishes the genesis in the clear, as every genesis travels.

Every peer applies it as the author: the author becomes the namespace’s founder, owner and admin, bound by the credential in the genesis, exactly as if they had founded it from a node of their own. The relay is then seated in the namespace as a Member holding CAN_AUTHOR_ON_BEHALF, with its device bound from the executor certificate the warrant carries, and recorded as the namespace’s founding relay. A founding that names an existing namespace is refused (409).

TEEs are on by default. A namespace founded this way has no admin node to admit a TEE and no TEE to vouch for one, so the founding relay, if it is a TEE, admits itself: it publishes GroupOp::FoundingRelayAttested on the namespace root, carrying its quote over its namespace key and the DCAP collateral. Every peer verifies the quote offline, bound to the key that signed the op, and requires an UpToDate TCB status (or a mock quote on a mock build, flagged as such). The op is itself the namespace’s first admission policy: signed releases of the relay’s profile, UpToDate, relay mode. The relay becomes a RelayTee, and so a verifier that admits further fleet TEEs the ordinary way. Only the founding relay may publish it, once, and only while no policy exists.

What peers take on the relay’s word is which profile of its signed release the measurements match. They cannot fetch the release file, so they trust the relay for that mapping, as every peer trusts the admitter under any signed-release policy. The quote itself is never taken on trust. The response reports teeEnabled (and teeError when the attestation failed; a relay that is not a TEE reports false); the namespace is founded either way.

The schema moves to v16 for this: a v15 node refuses a delegated genesis and cannot decode the attestation.

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.

  • 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