Governance
Governance is just operations
Section titled “Governance is just operations”There is no separate governance engine, no consensus round, and no quorum. The access-control, membership, and admin planes of the operation model are governance — they are operations in a governance scope, folded by the same last-writer-wins projection, summarized by the same scope root. A membership change converges exactly the way a data write does.
What this chapter adds is meaning: what those governance operations say, the structure they maintain, and how the system decides whether an author was allowed to write one.
The hierarchy
Section titled “The hierarchy”Governance scopes form a tree (the scope tree from Concepts & Scopes):
- A namespace is the root governance scope — a group with no parent. It anchors identity, admin, and policy for everything beneath it.
- Subgroups nest under it, each its own scope with its own key and members.
- Contexts (the running applications) attach to a group; a context’s members are the group’s members.
A subgroup is either Open or Restricted. In an Open subgroup, a parent member who holds the “join open subgroups” capability is a member by inheritance — no explicit operation needed. A Restricted subgroup is closed: membership requires a direct, deliberate admission. Because membership only ever narrows as you descend, pointing an operation “up” the tree never leaks visibility — the property Concepts & Scopes relied on.
The founder
Section titled “The founder”A scope tree has to start somewhere, and that start can’t appeal to a prior admin — there isn’t one yet. So a namespace begins with a single genesis operation that names its founder. It is self-authorizing: its only checks are that it is signed by the founder it names and that it has no parents (it is genuinely the first operation). From it, the founder is recorded as admin and owner, and every node that later replays the DAG derives the same founder — authority is established by a replayable operation, not by out-of-band trust.
The id commits to the founder
Section titled “The id commits to the founder”A self-authorizing genesis has one weakness on its own: nothing in a random namespace id says which founder is the real one, so a peer could sign a genesis naming itself for an id it did not create, and a fresh replica syncing only from that peer would believe it. So every namespace id is derived from its founder: namespace_id = domain_hash("calimero.namespace.id.v1", [founder_account_id, salt]), with a 32-byte salt that lets one account found many namespaces. The genesis carries the salt (NamespaceCreatedV2, the only genesis there is), and every replica refuses a genesis whose founder and salt do not reproduce the id.
That makes a forged genesis impossible rather than merely detectable. A genesis must be signed by the founder it names, and its id must derive from that founder, so a self-consistent forgery can only found ids its own signer’s account hashes to — claiming somebody else’s would mean finding a SHA-256 preimage. This holds on a bare replica syncing from a hostile peer, the one case the “already established” check cannot protect. Nothing founds a namespace any other way: there is no genesis without a salt, and no caller can pick a namespace id — only, at most, the salt it is derived with.
The same pair is proof of founding for anyone outside the namespace. Every replica keeps it, and the admin API returns it as founding { founderAccountId, salt } on the namespace, so a service shown both can confirm which account founded a namespace without holding any of its governance state. The salt cannot be replayed for another account.
An ordinary namespace draws its salt at random. The account namespace — the one every device of an account follows — derives it instead from the account root’s secret, under its own domain separator, and its founder is the account itself. Every device holding the root computes the same id, and nobody holding only the account id can: the salt reveals nothing about the secret, and it is carried only by that namespace’s genesis, which only its own members hold.
Membership: joining
Section titled “Membership: joining”Joining is a two-part dance, because a new member needs two things: a row in the membership state, and the scope key to actually read the scope’s encrypted operations. The key is delivered privately, wrapped to the joiner’s public key (ECDH), by existing members who already hold it.
The membership operation is public on the governance scope; the key delivery is a private, wrapped envelope. Once the joiner unwraps the key, every prior and future operation in the scope becomes readable, and they fold the scope just like anyone else.
A join by an account that is already a member changes no role.
A member promoted to admin who presents another invitation stays an admin, and a member presenting an admin invitation stays a member; only an admin’s role change moves a standing role.
The join is still recorded, and it still links the device it was made from.
The projection folds it the same way at the operation’s causal cut, in any arrival order.
The streaming state behind the scope root is order-independent too, but it orders clockless operations by id, so between two joins of one member it may keep the later one’s role.
Repeating a join changes nothing however often it happens: while no removal or leave follows a member’s first join, that join’s role stands.
The projection keeps a bounded number of joins per member, and resolves one situation with a fixed rule instead of the exact answer.
When a member’s latest membership write in a group is a removal or a leave above its first join there, and more than 64 of its joins are above that write, the member holds the least role any of its joins of that group ever carried (ReadOnly below Member below Admin).
That is the same in every arrival order, it is the exact role whenever all those joins carried one role, and it can lower a member’s authority but never raise it.
This rule is part of SignedNamespaceOp version 23: a node on an older version folds a different role, so nodes of one namespace must be upgraded together.
Which role an invitation admits at
Section titled “Which role an invitation admits at”An invitation names the role its joiner is admitted at, and only an invitation whose inviter is an admin of the group may name Admin: a member who merely holds CAN_INVITE_MEMBERS cannot mint an admin. The node that answers the join applies the same rule as every peer applying MemberJoined (NamespaceMembershipService::admission_role), read from its current state rather than the join’s cut, so it refuses such an invitation before serving a key and records the joiner at the invited role. A responder writes an Admin row only when it is itself an admitter that endorses the join; otherwise the joiner’s row arrives with the join op. A member who already holds a row keeps the role it holds when it presents an invitation again.
Which group an invitation may target
Section titled “Which group an invitation may target”An invitation names a group_id, and that may be the namespace root or a Restricted subgroup — but not a subgroup on an Open chain to its namespace. The reason is the key, not the membership row.
An Open-chain subgroup is encrypted under the namespace key (Which group’s key covers a group), so the only key that would let such a joiner read the group it was invited to is the namespace key — and that key reads all namespace-level governance and every Open-chain subgroup’s application state, retained after leaving unless rotated. A member admitted to one subgroup is not entitled to it. There is therefore no key the join can be answered with, and the responder refuses the invitation rather than admitting a member who could read nothing.
The route in is an invitation to the namespace root. From there, inheritance carries the member into the Open subgroup for free: parent-group members holding CAN_JOIN_OPEN_SUBGROUPS are inherited as members, and the self-join publishes MemberJoinedOpen — which needs no key delivery of its own, because the namespace key the member already holds covers the subgroup.
Membership: leaving and removal
Section titled “Membership: leaving and removal”There are two ways a member stops being a member, and the difference is entirely about the scope key.
| Path | Description |
|---|---|
| Leave (self-initiated) | A member removes themselves. No key rotation happens — the leaver already holds the key and could have copied it, so rotating would be theater. Leaving is just a membership operation that drops their own row. |
| Removal (admin-initiated) | An admin removes someone else. Here the key is rotated: a fresh scope key is generated and delivered, wrapped, to every remaining member — so the removed member can read nothing authored after their removal. This is forward secrecy. |
Two invariants guard both paths: the scope’s owner cannot be removed involuntarily and cannot self-leave without first transferring ownership, and a removal that would leave a group with no admin is rejected — the tree can never be orphaned.
Worked example: promote a member, then remove one
Section titled “Worked example: promote a member, then remove one”Two operations, both signed by an admin, show how a role change and a removal compose. First promote Bob to admin with MemberRoleSet { member, role: Admin } (the upsert-role op); later remove Carol with MemberRemoved. Promotion is a plain membership write — no key changes. Removal is admin-initiated, so it carries a fresh scope key wrapped to every remaining member (Bob now included, Carol excluded), the KeyRotation { new_key_id, envelopes } bundle attached to the op.
Because authority is judged at each op’s causal cut, the ordering is robust: the promotion stands on every node that folds it, and the removal’s rotation reaches exactly the members who held a row at the cut it was signed against — including a member who was just promoted, and excluding the one being removed.
Auto-follow: turning membership into replication
Section titled “Auto-follow: turning membership into replication”Membership is a row in governance state; it is not, by itself, replicated data. Auto-follow is the mechanism that closes that gap — it is the answer to “I joined the group but no data synced.” A member row carries two auto-follow flags, and when they are set the node automatically joins the contexts (and, eventually, subgroups) it is already entitled to, so membership turns into an actual replicating context on disk.
The flags are stored per member, per group:
| Flag | Default | Meaning |
|---|---|---|
contexts |
true |
Auto-join every context registered in this group. |
subgroups |
false |
Auto-join subgroups this node is entitled to. |
contexts defaults to true because joining a group implies wanting the group’s data — the user’s mental model is “I’m in this namespace, I want its contexts.” Opting out is an explicit per-member, per-group set-auto-follow operation flipping contexts to false. subgroups defaults to false and is currently inert for ordinary roles (it requires an admission op that does not yet exist); the TEE fleet-join path is the one place that sets both flags, via the replica’s own self-signed op after admission.
Event-driven join
Section titled “Event-driven join”Auto-follow is purely event-driven — there is no startup re-scan. When a ContextRegistered op applies in a group, the handler asks one question: is this node a member of that group with auto_follow.contexts = true (and has it not opted out — see below)? If so, it emits a JoinContext for the new context. That single decision point is what keeps a member’s replica set in step with the group as new contexts appear.
Backfill when the flag flips on
Section titled “Backfill when the flag flips on”Event-driven join only covers contexts that register after the flag is on. To cover contexts that already exist when auto_follow.contexts flips to true, flipping the flag synthesizes an AutoFollowSet event: the handler enumerates the group’s existing contexts and joins any it has not already joined. This backfill is a single pass capped at BACKFILL_LIMIT = 1000 contexts. If a group holds more than that at flip-time, the remainder are picked up event-driven as their ContextRegistered ops continue to apply, or by re-flipping the flag.
The fresh-joiner cascade
Section titled “The fresh-joiner cascade”The implicit trigger is what makes a brand-new member backfill contexts that existed before it joined. The auto-follow handler does not subscribe to MemberAdded at all — it subscribes to AutoFollowSet. So when a member is added with auto_follow.contexts set (the default), the MemberAdded apply site synthesizes an AutoFollowSet event for that member. This routes a fresh joiner through the exact same backfill cascade as an explicit flag flip, closing the regression where the UI showed group membership but no data synced. The handler short-circuits as “not for self” on every node except the joiner, so the cascade fires exactly once per membership change.
Inherited members: the anchor rule
Section titled “Inherited members: the anchor rule”A node need not hold a direct member row in a group to auto-follow it. In an Open subgroup, membership flows down by inheritance from an ancestor that granted CAN_JOIN_OPEN_SUBGROUPS (the capability inheritance model). When a node has no direct row in the group, auto-follow walks up the Open-visibility chain to the anchor — the nearest ancestor where the node holds a real direct row — and honours that row’s auto_follow.contexts flag. This is depth-independent: the anchor is always a genuine direct-row member, so its flag is authoritative.
This rule is what lets a root-admitted TEE (ReadOnlyTee or RelayTee) replicate Open-subgroup contexts. The TEE replica holds only a root row, so for every Open subgroup beneath the root the anchor resolves to the namespace root, and the root row’s flag drives replication into subgroups where the replica was never explicitly admitted.
Opting out of a single context
Section titled “Opting out of a single context”Auto-follow is a group-level intent, but a node can suppress it for one context. Calling leave_context writes a node-local ContextLeftMarker for (member, context) into a local-only column — it is never synced to peers. While that marker is present, auto-follow skips the context even though auto_follow.contexts is still true at the group level, so a later ContextRegistered (or backfill) will not silently rejoin it. The marker is cleared only by an explicit user-initiated join request for that context.
Startup self-heal
Section titled “Startup self-heal”Governance apply is online and event-driven, but two crash/race windows can leave a node holding work that no future event will re-trigger. Both are swept once, on the way up.
Re-driving stranded buffered ops
Section titled “Re-driving stranded buffered ops”A governance op a node receives before it holds the scope key is buffered awaiting that key. The race: the op (e.g. a ContextRegistered) arrives and buffers, GroupCreated applies, the live one-shot KeyDelivery retry then runs and fails (the key has not landed yet), and nothing remains to re-drive the buffered op. On startup the node sweeps every namespace it holds an identity for, and for every group whose key it already holds that still has buffered ops, it re-drives them (crates/context/src/self_purge.rs).
The sweep is a fixpoint loop, because applying one op can unblock a later op from a different signer (the nonce window is tracked per (group, signer), so one pass orders only within a signer). It re-runs until a full pass applies nothing new — narrowing each pass to only the groups that just made progress — bounded by MAX_PASSES = 64 as a defensive cap against a logic bug. It runs before the event loop and after the op-event subscription, so any ContextRegistered events it emits drive auto-follow rather than being lost. It is best-effort: per-group errors are logged and swallowed, and the next restart re-runs it.
Re-spawning interrupted upgrades
Section titled “Re-spawning interrupted upgrades”A group upgrade is driven by an in-memory propagator; a crash mid-walk leaves the upgrade row InProgress with no task pushing it. On actor startup the ContextManager scans the store for in-progress upgrades and re-spawns a propagator for each (recover_in_progress_upgrades, crates/context/src/lifecycle.rs). Recovery is unconditional: a cascade descendant left InProgress by a crash would otherwise block that group’s next upgrade forever.
Roles, capabilities, and the owner
Section titled “Roles, capabilities, and the owner”Authority is layered. A member has a role for coarse rights, and fine-grained capabilities for specific actions; the owner is a distinguished identity sitting above roles.
| Role | Can do |
|---|---|
Admin |
All governance operations: manage members, set policy and capabilities, manage subgroups. |
Member |
Read and write context state. Governance actions only with the matching capability. |
ReadOnly |
Read state; writes are rejected, locally and on peers. Still a replica: it applies the deltas other members author. |
ReadOnlyTee |
A TEE replica: a read-only fleet node admitted by hardware attestation; evicted with a self-purge signal. Never relays members’ writes. |
RelayTee |
A TEE relay: everything ReadOnlyTee is, and it may author members’ writes under their signed warrants. Admitted when the namespace’s TEE admission mode is relay. |
A member’s role in a group is its effective role: its own row there, or, when it reaches an Open subgroup only by inheritance, the role of the ancestor row it inherits through. A member that is read-only at the namespace root is read-only in every Open-subgroup context it inherits into, for its own writes locally and on every peer; see the role of an inherited member.
A TEE row changes role only between ReadOnlyTee and RelayTee, under the admission policy’s mode; a role change, re-add or admin handoff that would make an attested TEE a Member, ReadOnly or Admin is refused (TeeMemberRoleLocked). Remove the TEE instead. See TEE attestation.
Capabilities are individual grants a non-admin can hold — for example: create contexts, invite members, join open subgroups, manage members, manage the application, or create/delete/configure subgroups. Admins pass every capability check implicitly; defaults can be set per group and overridden per member.
The owner is not a role but a stored identity with exclusive rights — transfer ownership, delete the group — and the removal immunity noted above. Every other admin is a regular admin. Any member, owner or not, may remove a namespace from its own node: that is local, publishes nothing and leaves the account a member.
Authorization: judged at the author’s cut
Section titled “Authorization: judged at the author’s cut”The decision “was this author allowed to write this operation?” reuses the primitive from Projection: the projection resolves membership and capabilities at the operation’s causal cut — the governance state the author had observed — not at the receiver’s present.
The same mechanism covers data writes: an entity’s writer set (the access-control plane) is resolved at the cut, and a write is accepted only if its author was an authorized writer at that point.
A removal voids the signer’s concurrent governance ops
Section titled “A removal voids the signer’s concurrent governance ops”Resolving authority at the cut alone would let a removed admin keep acting: an op it signs against a cut from before its removal is authorized at that cut, on every node, however late it arrives. So a governance op is also void when the log holds a removal of its signer that is neither its ancestor nor its descendant. The removal can be a member removal, a demotion from admin, the revocation of the device that signed the op, or the revocation of a capability the op relied on. Once a node knows of the removal, no op of that signer concurrent with it carries authority, and every node computes the same answer from the same log in any arrival order.
- A device revocation counts as a removal only if its author was an admin of the root group at its own cut. The apply logs a revocation it refused, so one from anyone else removes nothing. A self-service revocation rests on a root-signed proof that the log does not carry, and a subgroup admin’s ejection is scoped to that subgroup, so neither voids the device’s concurrent ops.
- A role change counts as a demotion only if its author was an admin too.
- A capability set counts as a revocation of the bits the member held at its cut and does not keep, only if its author was an admin of the group at that cut. It voids only the member’s concurrent ops in that group that needed one of those bits: an op a non-admin needs the bit for (adding or removing members for
MANAGE_MEMBERS, metadata forCAN_MANAGE_METADATA, an application target, migration or upgrade forMANAGE_APPLICATION, a context registration forCAN_CREATE_CONTEXT, a subgroup’s creation, deletion or visibility for its own bit), made when the member was not an admin at the op’s cut. An op that needed no removed bit stands, as does any op of a member who was an admin at its cut. A grant voids nothing. Capabilities are not inherited, so the revocation reaches only the group it names. - Ops before the removal stand. Ops after it are refused by the cut check, as before.
- A void op is still logged and still part of the causal graph, so descendants keep their parents. It is not applied, its nonce is spent, and a key rotation riding on it is not taken. A node keeps only so many bytes of such ops per signer and per namespace; past that it keeps an op’s place in the log and not its contents, so the DAG still moves on. A void op keeps what the rule reads of it (only the bulk of a policy is dropped), so every node still reads the same void set. For an op the node cannot read the cost is real: the contents cannot be replayed when the key arrives, and authority questions about that group stay undecided on that node.
- Ops by other members stay valid, including ones concurrent with the removal.
- The void cascades. An admin a void op added, a capability it granted and a rotation it made are recomputed without it, so what that admin then did is void too.
- Two admins who remove each other concurrently are both removed. A cycle of three or more such removals voids every one of them, and nobody is removed. The namespace owner’s ops are never void, and the owner cannot be removed. The exemption covers any op of the removed admin that removes or demotes the account that removed it, whichever group the op acts in.
- A removal that is itself void voids nothing.
- Judging whether a signer still held its authority without a void grant costs a fold of its history. The work is bounded, and past the bound the remaining candidates are void, the same on every node.
- When a removal arrives after a node already applied a concurrent op of the same signer, the node rebuilds the affected group’s members, roles, capabilities and current group key from the log without the void ops. A removal that itself becomes void later puts back what it had voided. Other effects such an op had (for example group metadata or an app target) are not undone, only withheld from a node that hears of the removal first.
- A default capability or visibility change made by a void op is left out of every view, but ops that relied on it are not revisited.
- A node judges with the ops it can read. A removal sealed under a key the node lacks is not seen until the key arrives, and the ops parked behind it are judged again then.
Two consequences are worth telling users. An admin removed while offline loses the governance edits it made offline, and a client should say so before an admin works offline. And data sealed under a group key that a removed admin rotated to stays readable by that admin: the key is kept so that what peers sealed under it before they heard of the removal still opens, it is never the current key again, and it cannot be recalled.
What the rule does not reach: a change of the namespace admin or owner that a void op made before the node heard of the removal is not undone (and the owner the rule exempts moves with it), a TEE removal a node applied has already purged it, an invitation signed by an admin before its removal still admits the joiner, because the inviter is not part of the logged join; and an op sent on an admin’s behalf through a relay is attributed to the relay. Both need the inviter or the warrant’s author in the logged op. A join is never void either. One that is concurrent with an admin’s removal of the joiner stands in the projection at any cut where its stamp beats the removal’s, while the node, which applies the two one after the other, ends with the member removed.
The removal and demotion rule is part of SignedNamespaceOp version 21, and the capability-revoke rule of version 22: a node on an older version applies such an op, so nodes of one namespace must be upgraded together.
Shared writer-set rotations
Section titled “Shared writer-set rotations”A SharedStorage cell’s writer set changes by a governance op, GroupOp::SharedWritersRotated { context_id, cell, prior, nonce, new }, published encrypted in the group that owns the context.
Its place in the causal order is the signed op’s own, so every node that folds the same governance cut reaches the same writer set, however the op reached it.
The apply checks only what the op and its signer’s standing at the op’s cut decide, and writes only a record that the context has rotated cells:
cellis a cell id, andnewandpriorare not empty and hold at most 256 accounts each (checked on the apply, since a decrypted group op is not validated on the way in);- the signing key speaks for an account that is a member of the group, not read-only there, and not a TEE of the namespace (a TEE-triggered run signs with the TEE’s key);
- that account holds
ADMINinprior.
An op naming a context that is not registered in the op’s group has no effect, so the order of a concurrent detach cannot make nodes disagree. The op is not delegable, so a relay cannot publish it for a member.
Which steps take effect is a fold over the cell’s steps in the cut’s ancestry (calimero_storage::shared_writers::fold), ordered by the governance DAG, never by clock.
Only steps published in the context’s group count, each only when its signer stood at the step’s own parents as the apply requires: the key speaks for an account there, that account is a member of the group and not read-only, and it is not a TEE of the namespace.
A node that got the group’s key late therefore folds the same steps as every other node.
A step whose op is void, because its signer was removed or demoted concurrently (see above), does not count either. A rotation needs no member capability, so a capability revoke does not void it.
Only steps that rest on the genesis set count, that is a step from it or from a set another such step left, signed by an admin of the set it steps from.
A cell folds at most 256 such steps (MAX_STEPS_PER_CELL), which bounds the cost of a cut: past that it has no answer, so writes to it wait, rather than a truncated history rolling a rotation back. That is a lifetime budget of rotations for a cell until governance history is checkpointed, and an admin of the cell, a removed one included, can spend it by publishing steps.
- It starts from the set the cell id commits to, found among the steps’
priorsets; no step can supply a genesis of its own. - A step counts only when its
prioris the cell’s set in effect in the step’s own causal past, and its author holdsADMINin that set. - A step is void when a concurrent step (neither is in the other’s causal past) by another account removes its author’s
ADMIN, however low its nonce. It is also void when the step that granted its authorADMIN, directly or through earlier grants, is void, so an admin added on a void branch acts for nothing. An admin removed by a rotation therefore cannot override it by forking from any earlier cut, directly or through an admin its fork adds. - Two concurrent steps race when each removes an admin the other’s grants rest on.
In a race, a step that removes an admin of the cell’s genesis set (the set its id commits to) does not apply, so a genesis admin keeps
ADMINthrough any race; removing one outside a race, including by a step that has seen its steps, is still allowed. The other removals of a race all take effect: each removed admin keeps only what its remover’s step left it, nothing else from either step applies, and steps that have seen both build on that set. If the racing admins were the cell’s only ones, it keeps its other writers and can no longer be rotated. Of a cycle of three or more concurrent removals every step is void and nobody is removed. - From genesis, the fold follows the live step built on the set in effect.
Steps built on one set are concurrent, and of those the lowest
(nonce, signer key, op id)wins: the nonce is only that tie-break, so an author’s sequential rotations apply in causal order whatever its clock says.
ScopeProjections::shared_writers_at_cut answers at a governance cut, and only when the cut’s ancestry is complete and every op of the context’s group, its ancestors and the namespace root in it is readable.
A context whose cells have rotated cannot be detached from its group (HasRotatedCells), judged at the op’s own cut, so its rotations stay with the group readers fold them from.
Registering it in another group is refused only from this node’s own record: a rotation published in a group whose key a node lacks cannot be seen at the cut, so the cut alone would split nodes.
Only a step from a cell’s genesis set, signed by a member in standing who is an admin of it, published in the context’s group, marks a context as rotated.
A member can make a cell of their own in a context, so any member can mark a context this way, and an admin then cannot detach or delete it; removing the member does not undo it.
Deleting the group unregisters its contexts, and a context outside any group has no writer set to read.
Two limits follow from the rules above. A genesis admin removed by another can answer with a concurrent step removing the remover, and then both keep ADMIN, so one genesis admin cannot reliably remove another. And a step’s standing check reads the namespace root admin and its default capabilities as this node holds them, as every other at-cut check does.
Pruning governance history must keep every cell’s rotation steps, or a folded checkpoint of each cell’s writer set and the cut it holds at.
A cut that reaches a pruned prefix without one has no answer (None), never the genesis set.
How execution uses it
Section titled “How execution uses it”A run does not write a rotation; it asks the host for one, and reads a cell’s writers from the host (Storage::shared_writers).
The node answers from shared_writers_at_cut at a cut it pins before the run: the heads of the governance position a state op carries, else its current governance heads (an empty position also reads the current heads), with the fold brought up to that cut first.
A cell the fold cannot answer for (an empty, incomplete or unreadable cut) has no writers for the run, so writes to it are refused rather than guessed.
A context in no group has nothing that can have rotated, so its cells stand at genesis.
After the run returns and before its delta is finalised, the node publishes each requested rotation as a SharedWritersRotated signed with its own identity for the context’s group, in call order.
A cell’s first rotation in the run steps from the set the fold gives at the cut, or at genesis from the set the run read when the cell id commits to it; a later rotation of the same cell steps from the one before it.
The delta is then signed at the namespace’s current heads, not at the cut the run read at, so its governance position includes the run’s own rotations (and any governance that arrived during the run) and receivers judge its writes against the new set.
A step is built on the set in effect at the pinned cut, so another admin’s change published after it leaves the step without effect; the node therefore reads each rotated cell back at the new heads and fails the call, keeping nothing of it, unless it holds the set the run’s last rotation of that cell asked for.
A run that writes a cell it also rotates is refused when the new set leaves its author without what the run did there (WRITE, or WRITE_ONCE to create, for a put; DELETE for a delete; and for an owned entry in the cell’s value, its owner’s WRITE), since receivers would refuse the write at a position that includes the rotation.
The delta is signed at the heads the node reads just before building it, which can be past the cut the run read at.
When they differ, the node reads every cell the run wrote at them and fails the call, keeping nothing, if its author no longer holds what it did there; a run that wrote a cell and has no position to sign at fails closed.
A run that is delegated, TEE-triggered, or the merge-apply of a peer’s delta cannot rotate, nor can a context in no group, and a rotation the node cannot admit refuses the whole call with nothing of the run kept.
A rotation published before a later one failed stays published.
A rotation is refused at the call that records it when either set is empty or over 256 accounts, or the cell is not a cell id (SharedRotation::refusal, one rule): the Rust guest’s host function and the rotate_writers the JavaScript host runs both apply it, so publishing cannot fail half way through on those.
A migration or a context’s first run has no cut to publish at, so one that records a rotation fails rather than dropping it.
A migration still reads each cell’s writers from the fold at the heads current when it starts, so its own gates see a rotated cell’s set rather than the genesis set.
How a receiver judges a peer’s delta
Section titled “How a receiver judges a peer’s delta”A receiver judges a peer’s delta at the governance position its author signed, before any of its actions apply. Every path that feeds a delta into the DAG (gossip, buffered replay, parent fetch, catch-up, head pull, and the re-drive of a stored delta) hands the store the position with it, and the store remembers it by delta id, so a delta that waits for its parents is judged at its own position when it finally applies.
For each Shared cell and each SharedMember anchor the delta touches, the node brings the fold up to the delta’s cut and asks shared_writers_at_cut:
- a cell that never rotated at that cut stands at the set its id commits to, which storage holds, so nothing is resolved for it;
- a rotated cell is checked against the set the rotations left at that cut, handed to storage as
effective_writers; - a cut that cannot be read yet (an ancestor missing, or unreadable for lack of a key) defers the delta: it is never applied on a guess and is retried as governance arrives, like a signer that does not yet resolve;
- a cell with more steps than the fold takes refuses the delta for good, since nothing arriving later changes the answer.
A delta that touches no shared cell is not judged and needs no position for that.
Executing it still pins the position’s cut and folds the namespace up to it (the same refresh_for_cut every remote apply in a group runs), so the guest reads cells at the cut its author signed at; the fold is run whether or not the delta reads a cell.
A position is stale for a cell when the cell’s writers at the delta’s cut differ from its writers at the union of that cut and the position of a data-DAG parent this node stores: the parent’s author had already seen governance up to that position, so a child that cites less reaches back to before a rotation its own causal past has seen, which would let a removed writer write from before their removal. The test reads only the delta’s cut and the stored parents’ positions, never this node’s current heads, so a node that has folded a later rotation and one that has not reach the same verdict. A stale position is refused, and a union cut that cannot be read yet defers the delta. A parent this node does not store (compacted away, or covered by the snapshot it joined from) has no position to contradict, so it is skipped, not failed. A delta with no position, or an empty one, is a legacy row being re-driven: it is judged at this node’s current heads and refused once a cell it writes has rotated there. The first position a node is handed for a delta id is the one it keeps, in memory and in the delta’s stored row; a later envelope for the same id does not replace it.
Two limits of that rule are left as they are. A delta id does not commit to its position, so an author who signs two envelopes for one delta id with different positions can make nodes keep different ones, and the children of that delta can then be judged differently by different nodes, and a stored row can pair the kept position with a later envelope’s signature, which a catching-up peer rejects; binding the position into the id would change the wire format and is left to the owner. And a pending delta that has no stored row (it arrived without events and is still waiting for a parent) is remembered in memory only, capped at 10,000 entries: if its position is evicted before it applies, it applies as position-less.
A delta’s cut is chosen by whoever signs its envelope, so the stale rule bounds honest lag only. It does not stop a removed writer who colludes with a member that still vouches for an old cut: the model is forward-only, and a removal holds for what is signed at a cut that includes it.
A repair or a pushed leaf has no cut of its own, so the host answers for its cells with every writer the cell has had by this node’s current governance heads: the set the cell id commits to, plus the new set of each counted step, void or not, since a delta could be signed at any of those cuts.
A since-removed writer’s earlier write therefore still reaches a node that repairs.
The cost is larger than a removed writer pushing their own signed leaf again: a removed cell admin who is still a member can sign a rotation backdated to a cut before their removal whose new set names other accounts, and while the fold voids that step, its new set still counts here, so the leaves of the accounts it names are admitted on repair too.
A cell it cannot read has no writers, so the write is refused and a later repair retries it.
The algorithm: authorization at the cut
Section titled “The algorithm: authorization at the cut”Below is the whole security model in two folds — the governance plane and the data plane — written to match the code, so it is reimplementation-grade. Both resolve authority at the cut the op references, never at the receiver’s present.
The governance plane is a single fold (authorize) over the op’s payload, decided against an AclView the projection resolves at the op’s causal parents (acl_view_at(op.parents)). It is the one causal-auth boundary; it never walks the DAG itself.
// view = ScopeState::acl_view_at(op.parents)// -> membership + capabilities + per-object ACL, as of the op's causal cutfn authorize(op, view) -> Result<(), Rejected>: match op.payload: Put{entity} => require view.may(op.author, entity, WRITE) // else NotPermitted Delete{entity} => require view.may(op.author, entity, DELETE) // else NotPermitted SetWriters{object} => require view.is_owner(op.author, object) // = may(.., ADMIN); else NotOwner SharedWritersRotated{prior} => require prior[op.author].contains(ADMIN) // else NotOwner MemberAdded|MemberRemoved{group} => require view.is_group_admin(op.author, group) // else NotGroupAdmin SubgroupVisibilitySet{scope, restricted: true} => require view.is_group_admin(op.author, scope) SubgroupVisibilitySet{scope, restricted: false} => require view.is_group_creator(op.author, scope) // else NotGroupCreator DefaultCapabilitiesSet|MemberCapabilitySet{group} => require view.is_group_admin(op.author, group) AdminChanged|PolicyUpdated|SubgroupCreated|SubgroupReparented|SubgroupDeleted => require view.is_root_admin(op.author) // else NotRootAdmin Noop => Ok
// The writer-set check (default-write = membership), two-tier:fn may(author, entity, required: OpMask) -> bool: if let Some(writers) = view.acl.get(entity): // restricted object: explicit ACL is authoritative return writers.get(author).is_some_and(|held| held.contains(required)) // non-restricted: any scope member implicitly holds WRITE|DELETE — but NOT ADMIN, // so a plain member can write/delete default data yet cannot rotate its writer set. return view.is_scope_member(author) and (WRITE|DELETE).contains(required)Note what the fold actually keys on: governance ops are gated by role at the cut (group admin, or root admin for namespace-level ops), and the u32 capability bitmask gates membership inheritance (the CAN_JOIN_OPEN_SUBGROUPS bit on the open-subgroup walk), not these admin ops. A capability is read as view.capability(group, member) = explicit per-member override, else the group default, else 0, then bit-tested.
The data plane (apply_action) is the writer-set analogue: it resolves the authoritative writer set per StorageType and verifies a per-action signature against it, then enforces the operation’s required bit. A Shared cell’s writer set is resolved at the delta’s own governance position (the governance heads its author signed, effective_writers = shared_writers_at_cut(cell, delta.governance_position)), the same cut receivers gate membership at, never from the data DAG and never from the set the cell’s copy on this node holds.
fn apply_action(action, ctx): required = required_op_mask(action) // Add|Update => WRITE, DeleteRef => DELETE match action.storage_type: Public => accept // no signature; membership is the only gate Frozen => verify content hash // immutable, content-addressed (no signature) User{owner} => require ed25519_verify(sig, owner, payload_for_signing) // exactly one writer: the owner Shared{writers} | SharedMember{anchor} => set = ctx.effective_writers ?? host_writers(anchor) // rotated set at the delta's position, else the set the cell id commits to signer = set.keys().find(|w| ed25519_verify(sig, w, payload_for_signing)) require signer.is_some() // else InvalidSignature require set[signer].contains(required) // op-granularity bit; else ActionNotAllowedWhy the cut, not “now”: because every input is resolved at the op’s referenced causal position, the verdict is a pure function of state the author had already observed. Two nodes that fold the same DAG reach the same answer regardless of receive order, so authority converges with no coordination. And it is forward-only: a write authored before a revocation stays valid even if a peer applies the revocation first — the revocation is not in the write’s causal past, so it cannot retroactively un-authorize it.
Specification
Section titled “Specification”Capability bits
Section titled “Capability bits”Capabilities are a u32 bitmask. Each grant is a fixed bit position:
| Bit | Value | Capability |
|---|---|---|
| 0 | 0x0001 |
CAN_CREATE_CONTEXT |
| 1 | 0x0002 |
CAN_INVITE_MEMBERS |
| 2 | 0x0004 |
CAN_JOIN_OPEN_SUBGROUPS |
| 3 | 0x0008 |
MANAGE_MEMBERS |
| 4 | 0x0010 |
MANAGE_APPLICATION |
| 5 | 0x0020 |
CAN_CREATE_SUBGROUP |
| 6 | 0x0040 |
CAN_DELETE_SUBGROUP |
| 7 | 0x0080 |
CAN_MANAGE_VISIBILITY |
| 8 | 0x0100 |
CAN_MANAGE_METADATA |
Roles are a distinct enum: Admin, Member, ReadOnly, ReadOnlyTee, RelayTee. Admins pass every capability check implicitly.
The signed governance op
Section titled “The signed governance op”Governance operations travel as a signed envelope. The group-scoped form:
struct SignedGroupOp { version: u8, // current schema version = 16; checked strictly-equal on decode group_id: [u8; 32], parent_op_hashes: Vec<[u8; 32]>, // DAG parents signer: PublicKey, nonce: u64, // per-signer monotonic, starts at 1 op: GroupOp, signature: [u8; 64],}The content hash (and the bytes the signature covers) are domain-separated:
op_id = SHA-256( DOMAIN ‖ borsh(signable) ) // signable = all fields except signaturesignature = Ed25519_sign( signer_secret, DOMAIN ‖ borsh(signable) )
// group ops DOMAIN = b"calimero.group.v1"// namespace ops DOMAIN = b"calimero.namespace.v1" (SignedNamespaceOp, version = 23)Replay protection
Section titled “Replay protection”Each (group, signer) tracks a nonce window: a contiguous applied floor plus a sparse set of out-of-order applied nonces above it. A nonce is a duplicate if nonce ≤ floor or it is already in the set. This is checked before apply, and tolerates concurrent siblings (same author, different parents) arriving out of order without false-flagging them. Nonces start at 1; 0 is never issued.
Key delivery: the wrapped envelope
Section titled “Key delivery: the wrapped envelope”A scope key is delivered wrapped per recipient:
struct KeyEnvelope { recipient: EnvelopeRecipient, // a member identity OR one device, + its ECDH key sender: PublicKey, // authenticated by `signature` nonce: [u8; 12], // AES-GCM nonce ciphertext: Vec<u8>, // AES-256-GCM(scope_key) signature: [u8; 64], // Ed25519 by `sender`, bound to the group}- Addressing —
EnvelopeRecipient::Member { identity, ephemeral_pk }orDevice { device, ephemeral_pk }. The variant decides the agreement, so the two are one field. Member addressing is the bootstrap form and is permanent. A joiner is bound the moment it is a member — its join op carries the credential in the clear — so the device-addressed form covers the ordinary case. What still needs member addressing is every enrolment that is not a join:AccountDeviceLinkedis an encrypted group op, so a node must already hold the scope key to publish one, which is exactly the position a re-admitted member or one whose devices are all revoked is in. - Shared secret — a fresh ephemeral keypair per envelope (that is the forward secrecy), agreed against the recipient’s key: ECDH on the Curve25519 form of the Ed25519 identity for a member, native X25519 for a device. The raw point is HKDF-derived into the AES-256 key, under a per-scheme label so the two agreements can never collide.
- AEAD — AES-256-GCM, 12-byte nonce, empty additional data, GCM tag appended inline.
- Authentication —
sendersigns the canonical envelope bytes bound to thegroup_id, checked before any ECDH, so a forged or cross-group-replayed envelope is rejected first. The addressing variant tag is inside those bytes, so the two modes cannot be swapped. - Rotation — a removal carries a
KeyRotation { new_key_id = SHA-256(new_key), envelopes }with one envelope per remaining recipient; the removed member and every device of theirs get none. One bundle may mix both addressing modes.
A scope key is identified on the wire by key_id = SHA-256(scope_key), so a receiver can look up which delivered key decrypts a given operation.
Owner vs admin
Section titled “Owner vs admin”The owner is stored alongside admin in the group metadata (owner_identity vs admin_identity). A group may have several admins but exactly one owner. Only the owner may sign TransferOwnership or delete the group, and the owner is immune to involuntary removal. Ownership is itself folded into the governance hash, so an op signed before vs after a transfer resolves against different state.
Where this leads
Section titled “Where this leads”We now have the full meaning of all four planes and the rules that govern who may write them. Next, Capability & Membership Inheritance follows membership down the scope tree — how an Open subgroup inherits its parent’s members with no operation written, where the Restricted wall stops that flow, and how capabilities resolve per member.