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.
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, 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.
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: the subject the relay’s authorship capability is granted to. |
executor_key |
which device may run it | One device of executor. Each relay node spends nonces in its own ledger, so a warrant any device of the operator could present would be spendable once per device. A relay that re-keys voids the warrants it has not spent. |
release_bytecode_id |
on which code | The blob id of the release the context’s group names. The relay refuses a warrant pinning any release but the one it is about to run, so an honest relay never spends a warrant on code an upgrade widened. Peers cannot check which code ran, so a dishonest relay is not stopped by it. release_version carries its semver beside it, signed for readability and never compared. |
intent_hash |
what | H(method ‖ args). The hash, never the plaintext — see below. |
nonce |
once | Monotonic per author device, spent in the ledger of the executor device the warrant names. Per device rather than per account because two devices of one account are independent replicas and cannot coordinate on a shared counter. Per executor device because each ledger then has one producer, so every replica reaches the same verdict: one nonce on warrants for two relays is two warrants, each spendable once. |
not_after |
until when | Checked by the relay at admission against its clock, and by every peer against the delta’s own signed stamp, never a clock; see below. |
governance_floor |
no earlier than | Governance heads the author had seen. The cut the change is authorized at must reach every one of them, or every peer refuses it — see the cut a delegated change is judged at. |
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
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.
What the relay must be granted
Section titled “What the relay must be granted”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
Restrictedsubgroup 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_memberseeds 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:
meroctl --node node1 group settings set-default-capabilities <NAMESPACE_ID> \ --can-join-open-subgroupsNote 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:
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 having to be reissued. Warrants name the key as well, so those not yet spent are minted again.
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 and executor_key name the relay’s account and signing key, which 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": "…", "executorKey": "…", "canAuthorOnBehalf": false, "groupId": "…", "grantedOnGroupId": "…", // optional "releaseBytecodeId": "…", "releaseVersion": "…" } }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.
releaseBytecodeId and releaseVersion are the release a warrant for this context must pin: the one its group names, which the context runs once any pending upgrade has applied.
meroctl context intent makes this read itself, before it signs anything: it takes executor, executor_key and the release 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, the same problem direct admission answers for a join.
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
RelayTeeby its role, anAdminorMemberbyCAN_AUTHOR_ON_BEHALF, aReadOnlyTeeorReadOnlynever — 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 query, 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 query, and a hash of the body,
under the domain calimero.auth.request.v2.
The path and query are the request target exactly as it reaches the node’s listener, byte for byte: a reordered, re-encoded, added or dropped query (an empty ? included) is a different request and is refused.
Behind a reverse proxy, the node verifies the target it receives, after any rewriting the proxy did.
The proxy must forward the path and query unchanged: no prefix stripping, no re-encoding, no reordering.
To serve several nodes under one host, give each a prefix with NODE_PATH_PREFIX, so the node itself sees the prefixed path the client signed.
The expiry is what bounds replay, and nothing else does: a captured proof
performs the identical request until it lapses. A node refuses a request link
valid for more than MAX_REQUEST_LIFETIME_SECS (300 s) and a session link valid
for more than MAX_SESSION_LIFETIME_SECS (one hour), before any signature check.
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.
The three refusals, and why they differ
Section titled “The three refusals, and why they differ”| 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.
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.
Creating a context on a member’s behalf
Section titled “Creating a context on a member’s behalf”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, executor_key |
the relay account, and the one device of it, 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, executorKey, 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, and the init arguments against init_hash), checks not_after against its clock (every peer checks it too, against the op’s signed stamped_at), 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 and the warrant names;
- 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
RelayTeeby its role, anyone else byCAN_AUTHOR_ON_BEHALF, aReadOnlyTeenever; - 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 op’s signed
stamped_at, the relay’s clock when it registered the context, is not past the warrant’snot_after.
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. Adding stamped_at to it and to both wrappers is another (SIGNED_GROUP_OP_SCHEMA_VERSION 17, SIGNED_NAMESPACE_OP_SCHEMA_VERSION 25).
Governing on a member’s behalf
Section titled “Governing on a member’s behalf”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, stamped with its clock (stamped_at; group-op schema 17,
namespace-op schema 25).
Every peer verifies the bundle, refuses an op stamped past the warrant’s
not_after, 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, subgroup create, reparent, delete
and self-join, and a group’s first application choice. Not delegable: account
and device credentials (already signed by the account’s own keys), key rotation,
TEE policy, ownership, application upgrades and migrations, 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.
The first application, and only the first. A namespace founded through a relay
(below) targets no application, and a context can only be created for the
application its group targets, so without this a member with no node could own a
namespace and never put a context in it. The member signs TargetApplicationSet
with target_application_id, package and version as they mean them and
bytecode_id cleared: that field is the blob id of the .mpk as the relay stores
it, which no registry publishes. The relay resolves package@version from its
registry, refuses (403) a bundle whose manifest derives a different application
id, fills bytecode_id and publishes. Every peer then refuses a delegated
TargetApplicationSet unless the group had chosen no application at the op’s
cut: nothing in the op’s ancestry set the group’s target (a TargetApplicationSet,
signed or delegated, or a cascade from the zero bytecode), and a subgroup created
before its parent chose inherited nothing. The ancestry is the same on every peer
that holds it, so every peer reaches the same verdict; one that cannot read the
ancestry parks the op instead of judging it from its live rows. Changing a target a
group already has is an upgrade, and an upgrade is published by a node as itself.
The author still needs admin or MANAGE_APPLICATION, checked by the ordinary
handler. Widening the set this way is SIGNED_NAMESPACE_OP_SCHEMA_VERSION 20: an
older node decodes the wrapper but refuses it, so the two must not share a namespace.
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, and publishes the genesis in the clear, as every genesis travels.
The warrant’s executor_key is the relay’s signing key, which the author reads from GET /admin-api/identity (publicKey) because a namespace that does not exist yet has no discovery route; the relay refuses a warrant naming another key before it enlists.
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.
The cut a delegated change is judged at
Section titled “The cut a delegated change is judged at”A delegated change is authorized at the causal cut it cites, the same as a self-authored one:
- a delta cites the governance heads its envelope was signed under;
- a governance op or registration cites its own parents.
Every peer checks the author’s role and the relay’s standing as they stood at that cut, not in its own current rows. Three things follow.
- A removal the cut doesn’t include doesn’t refuse the write. Say the author wrote before an admin removed them. A peer that has already applied the removal still accepts the write, the same as a peer that hasn’t. When these checks read each peer’s live rows, the two peers disagreed about the same delta forever. That was divergence, not revocation.
- A grant withdrawn after the cut still covers the relay. The relay executed while it held
CAN_AUTHOR_ON_BEHALF, and the change is judged by that standing. - A cut this peer hasn’t folded is never answered from live rows. The delta parks and is retried once the history arrives. Answering from live would make the verdict depend on how far this peer had folded, which is the divergence this rule removes.
The rule for who may relay is written once, over a small set of reads (StandingReads in calimero-governance-store). It has two sources: this node’s rows, for the relay’s own check before it executes, and the projection at a cut, for every peer applying the change. So the relay and its peers can’t disagree about the rule, only about the state it is applied to, and that state is the cut.
Two things are still read live, on purpose. Device revocation and the deny-list are the deny direction, and neither is in the projection’s fold. The author’s own self-authored writes are filtered against both live too. So a delegated write is refused exactly where the author’s own write would be.
The governance floor
Section titled “The governance floor”The relay chooses the cut. Without a lower bound, it could choose one from before a demotion or revocation the author already knew about, and have the change judged there. governance_floor is that lower bound. The author signs the governance heads they had seen, and the change’s cut must reach every one of them:
- Peers walk the cut’s ancestry. If the ancestry is complete and doesn’t include a floor head, the change is refused:
FloorNotCovered, the same on every peer. A gap in the ancestry is undecidable, never a refusal, because the missing op could be the one that connects them. - The relay checks the floor against the governance ops it holds, before it executes. A relay that hasn’t seen the author’s floor refuses rather than executing at a cut its peers would refuse. It should sync and try again.
- A founding can cite no floor. There is no namespace yet whose heads it could name.
An empty floor, which every client has signed so far, is covered by every cut. So existing warrants are unaffected.
What the floor doesn’t fix: where it comes from. A client with no node learns governance heads from a relay. A relay that withholds an op can hand the author a floor from before it, and the floor then bounds nothing the relay didn’t choose. The floor detects an honest relay that is behind. It constrains a dishonest one only when the author gets the floor from somewhere else, such as another node, or the union of the heads several relays report. Any head a withholding relay lacks is one it can’t reach, so it can’t execute that warrant.
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, executor 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 ledger is per node, and that is a known gap. Each node spends nonces in its own store. The ledger is not part of replicated state, so four things escape it:
- A delta delayed past its author’s window is refused only on the peers that moved past it.
- A node that joins by snapshot sync starts with an empty ledger. It would accept a relay’s replay of a warrant that every peer with full history refuses.
- The executor device a warrant names can sign two deltas from one warrant. Each replica keeps whichever arrives first, so replicas fork. Naming one device keeps every other relay out; it does not stop the named one equivocating.
- A device key cloned onto two nodes is one executor device to every verifier, so the two nodes can each spend a warrant naming it once, with the same fork.
Both are divergence. The fix that would close them was studied and not landed. A node could ship its ledger with the snapshot, merged as a join (the higher mark, the union of spent nonces). But the snapshot channel deliberately refuses every record it can’t authenticate per record, and a ledger row can’t be authenticated that way. A malicious snapshot source could then mark nonces spent that never were, and the receiving node would refuse writes its peers accept. The sound fix records the spend in the context’s replicated state. Each delegated delta would write its nonce into a reserved entry, so the ledger is covered by the state root a snapshot is verified against. That is a storage-layer change, and it is left as the next step.
The same reasoning explains not_after. The relay checks it at admission against its own clock, and commits nothing it would stamp past it. Peers never read a clock at apply, which would make acceptance node-dependent: the same delta arriving either side of an expiry would be applied by one peer and refused by another. They compare not_after with the delta’s own HLC stamp, which the relay signs and every receiver reads the same; a delegated governance op or context registration is compared the same way with the stamped_at the relay signs into it. The stamp is the relay’s to choose, so this holds an honest relay to the deadline and does not stop a dishonest one backdating; the nonce ledger is what bounds a warrant on the network.
The release pin is the relay’s alone for the same kind of reason. Peers apply the delta’s actions and never run the method, so only the node that ran it knows which code produced them.
A warrant pinning another release is refused with a 403 before it runs, and its nonce stays unspent.
When GET .../intents names the pinned release, the context has not yet moved to it after an upgrade, and the same warrant can be retried; otherwise the author mints a fresh one.
Peers cannot re-check the pin at the delta’s governance cut either: during an upgrade the group already names the new release while the relay still runs the old one, so the cut would disagree with the code that actually ran.
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