Membership & Leave Operations
Voluntary leave and Owner role across namespaces, groups, and contexts
Problem
Today, the only way out of a Calimero group or namespace is to be removed by an admin —
MemberRemoved is admin-initiated and there is no self-leave op. Users cannot
voluntarily exit a workspace they joined; muting a context they're auto-followed into requires
client-side filtering rather than a real opt-out, because auto-follow re-adds them on the next
ContextRegistered event.
Separately, every group already records a single "creator / fallback admin" identity
(GroupMetaValue.admin_identity) but no role distinguishes that creator from any other
admin. There is no way to express "this person owns the group, others administrate it" — admins
can demote each other, kick each other, and leave a group ownerless. There is no
TransferOwnership op to designate a successor.
This page proposes three new leave operations and formalizes Owner as a real, exclusive,
transferable role distinct from Admin. The four pieces share infrastructure: Owner blocks
leave_group/leave_namespace; leave operations reuse the existing
MemberRemoved key-rotation pipeline; leave_namespace is
leave_group plus the cascade handler.
Membership Model Recap
Background on the data shapes the leave operations operate over. Names match the code as it exists today.
Hierarchy
- Namespace. A
ContextGroupIdwith no parent. The trust anchor for everything beneath it. There is no separateNamespacetype — a namespace is a root group. - Group / subgroup. A
ContextGroupwith aparent_group_id. Forms a strict tree under a single namespace. - Context. A unit of replicated state. Belongs to exactly one group via
ContextGroupRef. Members of the group can join, sync, and write to it.
Direct vs inherited membership
Group membership is computed by check_group_membership_path
(crates/context/src/group_store/membership.rs:217-291):
- Direct membership = a stored
(member, group)row exists. Created byadd_group_members(explicit invitation). Required for Restricted groups; possible but redundant for Open subgroups. - Inherited membership = no row exists; the walker concludes membership by walking the
parent chain through Open subgroups when the member holds the
CAN_JOIN_OPEN_SUBGROUPScapability in the parent.
This distinction is critical for leave operations. Leave deletes a direct row. If the only path to membership is inheritance, there is no row to delete — the member must leave whichever ancestor provides the inheritance.
Visibility (Open vs Restricted)
A per-group flag (VisibilityMode::{Open, Restricted} in
crates/context/config/src/lib.rs:134-137) that controls whether the parent-walk
inherits into this subgroup or stops cold:
- Open: parent members with
CAN_JOIN_OPEN_SUBGROUPSinherit membership. - Restricted: walks halt; only direct invitations admit members.
At leave-time, visibility doesn't change behavior — it only affects whether a row exists in the first place. Most members of Open subgroups have no row (inherited); most members of Restricted subgroups have one (direct invite).
Roles & capabilities
Roles defined in crates/primitives/src/context.rs:253-265:
Admin,Member,ReadOnly,ReadOnlyTee.- This proposal adds
Owneras a fifth role — exactly one per group, transferable.
Per-member capability bits (crates/context/config/src/lib.rs, MemberCapabilities):
CAN_CREATE_CONTEXT,CAN_INVITE_MEMBERS,CAN_JOIN_OPEN_SUBGROUPS,MANAGE_MEMBERS,MANAGE_APPLICATION,CAN_CREATE_SUBGROUP,CAN_DELETE_SUBGROUP,CAN_MANAGE_VISIBILITY,CAN_MANAGE_METADATA.
Existing protections
- Last-admin protection (
membership_policy.rs:39-46):MemberRemovedrejects withLastAdminif it would leave a group with zero admins. Also enforced on demotion. - Key rotation on removal (
group_governance_publisher.rs:216-220): publishingMemberRemovedautomatically generates a fresh group key, wraps it for remaining members, and emitsRootOp::KeyDelivery. The leave operations in this proposal hook into the same pipeline.
Forward-only invariant — load-bearing
The apply-time membership check is a function of two things only: the signer's identity
and the governance-DAG position the signer signed against. It is not a function
of the receiver's current local state. This is the forward-only property: a write authored
at governance position P stays valid forever, even if the receiver has since applied
a MemberRemoved for the signer at a later position P' in its local DAG.
Why this matters
Without forward-only, two peers receiving the same set of ops in different orders would end up with different state:
- Peer A receives
MemberRemovedfirst, then the pre-removal delta. If "is the signer currently a member?" gates the apply, the delta is rejected. - Peer B receives the delta first, then
MemberRemoved. The delta applies; the later removal doesn't retroactively undo it.
That divergence cascades: any subsequent delta that reads the (different) state diverges further, context root hashes drift apart, and the network has no honest convergence point. Forward-only removes the receive-order dependence: pre-removal writes always apply on every peer, regardless of when the receiver learns about the removal.
How it's implemented
Every state delta carries a governance_position field that names the governance-DAG
heads the author observed at sign time. On receive, the apply path calls
membership_status_at(signer, delta.governance_position) — which walks the
ancestry of the signed heads and replays the membership state machine. The walk visits
only the prefix that was already final when the delta was signed; anything causally after
the signed cut (including a later MemberRemoved for this signer) is invisible to
the walk and cannot retroactively invalidate the delta.
Where this can break
Two classes of change would silently re-introduce the divergence described above:
- Calling the check with the wrong position. Any caller of
membership_status_atthat passes the receiver's current state (or any post-cut heuristic) instead of the signed delta position breaks forward-only. The three production call sites — gossip-receive, governance-pending drain, and snapshot-sync replay — each carry inline comments naming this contract; reviewers should flag any new caller that doesn't passdelta.governance_positionverbatim. - Changing the prefix walk to visit descendants. A "performance optimization" that
has the BFS catch up to local heads after visiting the signed heads would observe ops the
signer hadn't signed against, including later
MemberRemovedops. The forward-only contract is documented on the publicmembership_status_atfunction; the internal BFS inprefix_walk_membershipimplements it, and theprefix_walk_forward_only_*regression tests pin the boundary.
Scope
Forward-only applies to MemberRemoved and MemberLeft — operations
that revoke authorship. It does not apply to role demotions
(MemberRoleSet transitioning a Member to ReadOnly): those are enforced against
current state at the receive path, separately from the cross-DAG check, because a member who
was demoted retains a different relationship to their pre-demotion writes than a member who
was fully removed. See is_read_only_for_context for the live-state role gate.
leave_context — local-only opt-out
Stop syncing a context on this node. No DAG op, no broadcast, no key rotation. Other members never observe the leave; from their perspective the leaver is still in the membership list (which is implicit anyway, computed from group membership + auto-follow).
Storage
Two separate stores live side by side: the synced ContextIdentity row
(key at crates/store/src/key/context.rs:120, value at
crates/store/src/types/context.rs:117) and a node-local
ContextLeftMarker tombstone in a new column.
The marker lives in Column::ContextLocal (a new column
specifically for node-local context-scoped state — never synchronized).
Keying is identical to ContextIdentity so the
(context_id, member_pk) pair is the unit of opt-out:
// Borsh value:
pub struct ContextLeftMarker {
pub left_at_ms: u64,
}
A separate column instead of a flag on ContextIdentity for two
reasons: (a) ContextIdentity is part of the synced membership
shape, so adding a node-local field to it would conflate replicated and
node-local state; (b) extending the existing Borsh value would break
deserialization of pre-existing on-disk rows, requiring a migration.
The dedicated column makes the node-local-ness explicit at the storage
layer and keeps the synced shape clean.
Flow
- Client calls
leave_context(context_id). - Handler resolves the local
ContextIdentityviafind_local_signing_identity— scans the column for the row where this node holds the private key. Falls back to the namespace identity if no local row exists. - In a single batched store handle (so they land in one commit), the
handler:
- Writes the
ContextLeftMarkerrow inColumn::ContextLocal. - Deletes the corresponding
ContextIdentityrow, which stops sync (the sync layer iterates these rows).
- Writes the
- Calls
node_client.unsubscribe(&context_id)to leave the gossipsub topic, mirroringjoin_context's subscribe. - Auto-follow handler (
auto_follow.rs:255) checks for the marker viahas_left_contexton everyContextRegisteredevent and skips the auto-rejoin if it finds one.
Properties
| Scope | Local only |
| DAG entry | None |
| Key rotation | None — leaver still holds the group key |
| Coordination | None — cannot fail |
| Reversibility | Trivial — JoinContextRequest deletes the marker as a side-effect of joining and writes a fresh ContextIdentity row |
| Other members observe | No |
| Forward secrecy | Not provided — leaver retains the key, can decrypt anything they sync later if they rejoin |
What this is not
- Not a kick. Leaver still appears in any "members of this context" query.
- Not a cryptographic leave. If the user wants forward secrecy on a context, they must leave the group containing it (which rotates the group key).
leave_group — distributed, two-phase, no cascade
Self-removal from a single group. Reuses the existing MemberRemoved key-rotation
pipeline but the publisher/rotation responsibility splits across two parties: the leaver
publishes the leave op, then a remaining admin publishes the KeyDelivery with the
fresh group key. The leaver never holds the new key.
New op
// existing variants...
MemberLeft { member: PublicKey },
}
Distinct from MemberRemoved for audit clarity — "this member chose to leave" vs
"this member was removed" are semantically different events even when they have similar effects.
Preconditions
- Signer has a direct row in the group (computed via
check_group_membership_path; reject withNotADirectMemberif the signer is only an inherited member — they must leave the anchor instead). - Signer is not the Owner (
OwnerCannotLeave; mustTransferOwnershipfirst). - If signer is admin, removing them does not leave the group with zero admins
(
LastAdminvia existingensure_not_last_admin_removal).
Flow
- Leaver's client calls
leave_group(group_id). - Server-side validation runs the preconditions above.
- Leaver publishes
MemberLeft { member: signer }via the governance DAG. Signed by the leaver. Targets this group. - Op broadcasts to all members of the group. Each receiver applies it: deletes the
(leaver, group_id)row from local membership state. Leaver's own node also drops sync state for this group's contexts and (optionally) purges encrypted blobs. - The first remaining admin to ack the op enters phase two:
- Generates a fresh group key via
build_key_rotation(group_keys.rs:266). - Wraps it for each remaining member (leaver excluded).
- Publishes
RootOp::KeyDeliverycarrying the wrapped bundles.
- Generates a fresh group key via
- Each remaining member receives
KeyDelivery, unwraps their bundle, replaces the active group key. New writes use the new key from now on. The leaver's old key still decrypts historical content but not new writes.
Cascade behavior
| Direct rows in subgroups under this group | Untouched. Leaver keeps those memberships. (You can be invited to a child while explicitly leaving the parent.) |
| Inherited memberships in Open subgroups | Resolve themselves. The walker stops finding the leaver once their parent row is gone — no extra work needed. |
Failure modes
- Network partition between phase one and phase two.
MemberLeftlands butKeyDeliveryis delayed. Forward secrecy briefly weak — the leaver is no longer in the membership list but new writes are still under the old key (which they hold). Heals on partition resolve. - All admins offline.
MemberLeftlands; rotation never fires until an admin returns. Operational mitigation: alert when no rotation follows a leave within a configurable window.
Rejoin
- Restricted group: requires fresh
add_group_membersfrom a remaining admin. New keys delivered via the existing membership-add path. - Open group (if leaver had a redundant direct row): same. If leaver's path to the group was via inheritance from a parent, that inheritance is automatically restored as soon as they rejoin the parent.
leave_namespace — cascading, heaviest
A namespace is the root group. Leaving it cascades through every descendant where the leaver has a direct row, rotating each scope's group key independently. Plus the namespace's own root key. This is the only operation in the proposal that fans out across multiple scopes.
Preconditions
- Signer has a direct row at the namespace root.
- Signer does not own the namespace OR any subgroup beneath it. If they do →
MustTransferOwnership { groups: [...] }listing every owned scope. - Removing the signer doesn't leave any scope (namespace or descendant) admin-less. If it
would →
LastAdmin { group: G }reports the offending scope, forcing a successor promotion before retry.
Flow
- Client calls
leave_namespace(namespace_id). - Server-side validation walks the subgroup tree:
- Compute
direct_groups = [namespace, G1, G2, ...]— every group in the namespace where the signer has a direct row. - Run owner check + last-admin check across all of them. If any fails, surface the offending scope and bail before publishing anything.
- Compute
- Signer publishes a single
MemberLeft { member: signer }at the namespace root. - The cascade handler (
cascade_remove_member_from_group_tree,mod.rs:811) fans out: locally and on each receiver, fire theMemberLeft-equivalent for each scope indirect_groups. - Per-scope key rotation runs the same phase-two pattern as
leave_group, in parallel. Total network ops: one root-levelMemberLeft+ NKeyDeliveryops where N = number of direct memberships in the subtree. - Local cleanup on the leaver's node:
- Soft mode (default): archive namespace data as read-only. Leaver can browse their historical view.
- Hard mode (opt-in): purge keys, DAG entries, encrypted blobs.
Edge case — last member of the namespace
If the leaver is the last remaining member, MemberLeft writes locally but has no
peers to broadcast to. The namespace effectively dies on the leaver's node.
If the leaver is also the owner AND last member, they're stuck in the cycle — they must transfer
ownership but there's no one to transfer to. Use a dedicated DeleteNamespace op
(analogous to DeleteGroup but at root) that bypasses the owner-cannot-leave rule.
Failure modes
- Same as
leave_group, multiplied across scopes. Each rotation can fail independently. - Partial cascade: if some sub-rotations succeed and others don't, the leaver's root row is gone everywhere but some peers may have lingering ghost rows in subgroups. Eventually consistent — peers detect the leaver-not-in-root state and reconcile on next sync.
Owner Role
Promotes the existing GroupMetaValue.admin_identity field
(crates/store/src/key/group/mod.rs:1160-1169) from "fallback creator-admin" to a
real, exclusive, transferable role. Every group has exactly one Owner. Owner is also an admin —
all admin permissions cascade. The distinction is exclusive privileges no admin can perform, plus
the property that Owner cannot be involuntarily removed.
Storage change
Semantic rename of admin_identity to owner_identity. Field shape and
serialization unchanged — every existing group's current admin_identity becomes
its Owner. No migration needed at the data layer; the change is at the type and access-path
layer (rename callsites, expose as a real role).
New op
// existing variants + MemberLeft...
TransferOwnership { new_owner: PublicKey },
}
- Signer must equal current
owner_identity. new_ownermust already be a member of the group.- Apply: update
owner_identitytonew_owner. Old owner becomes a regular admin (retains admin powers; doesn't lose them as a side-effect of transfer). - No key rotation (membership unchanged).
Privilege matrix
| Action | Member | Admin | Owner |
|---|---|---|---|
Invite members (with CAN_INVITE_MEMBERS) | ✓ | ✓ | ✓ |
Create context (with CAN_CREATE_CONTEXT) | ✓ | ✓ | ✓ |
Remove non-owner members (MANAGE_MEMBERS) | — | ✓ | ✓ |
| Promote member → admin | — | ✓ | ✓ |
| Demote admin → member | — | ✓1 | ✓ |
TransferOwnership | — | — | ✓ |
DeleteGroup / DeleteContext / DeleteNamespace | — | — | ✓ |
Be involuntarily removed (MemberRemoved) | ✓ | ✓ | ✗ — CannotRemoveOwner |
Self-leave via leave_group / leave_namespace | ✓ | ✓ (last-admin permitting) | ✗ — must TransferOwnership first |
1 Open question. The existing code allows admins to demote each other
(mod.rs:823); restricting to Owner-only would tighten centralization. This proposal
preserves the current behavior (admins can demote each other), but the doc author may want to
revisit.
Owner per context
Same model. CreateContext records its creator as owner_identity on
the context's metadata. The context owner can TransferContextOwnership and
DeleteContext. Other than that, regular context membership is governed by the
parent group as before.
Interactions with leave operations
leave_context: no owner constraint. Local opt-out doesn't touch governance — even an owner can mute a context locally.leave_group: rejected for owner. Error fast-paths toTransferOwnership.leave_namespace: rejected if signer owns the namespace OR any subgroup beneath it. Error lists every owned scope. UI may bundle these into a single multi-transfer flow.
Single-Page Reference
| Op | Scope | DAG? | Key rotation? | Cascade? | Reversible? |
|---|---|---|---|---|---|
leave_context |
Local only | None | None | n/a | Trivial — flip flag |
leave_group |
Single group | MemberLeft |
Yes (1 group) | None | Re-invite (Restricted) / re-inherit (Open) |
leave_namespace |
Whole subtree | MemberLeft at root |
Yes (1 + N) | All direct-row scopes | Re-invite at root + re-inherit |
TransferOwnership |
Single group/context | Op | None (membership unchanged) | None | Transfer back |
DeleteGroup (Owner) |
Single group + descendants | Op | n/a (everyone gets removed) | Yes | None — destructive |
DeleteNamespace (Owner) |
Whole subtree | Op | n/a | Yes | None — destructive |
Implementation Status
This page commits the design before any code lands. Status flips from proposed to implemented as each feature ships, in PRs that update the corresponding section.
| Section | Status | Tracking |
|---|---|---|
| Membership Model Recap | Documents existing code | — |
leave_context | Implemented (PR #2280) | crates/context/src/handlers/leave_context.rs, auto_follow.rs gate, POST /admin-api/contexts/:id/leave |
leave_group | Implemented (PR #2280) | GroupOp::MemberLeft, crates/context/src/handlers/leave_group.rs, POST /admin-api/groups/:id/leave — apply enforces self-leave + direct-row + owner + last-admin. No key rotation; admin follow-up required for forward secrecy (deferred). |
leave_namespace | Implemented (PR #2280) | crates/context/src/handlers/leave_namespace.rs, POST /admin-api/namespaces/:id/leave. Apply detects no-parent and cascades through descendant groups where leaver has direct rows; multi-scope owner + last-admin checked upfront. No per-scope key rotation. |
Owner Role + TransferOwnership | Implemented (PR #2280) | GroupMetaValue.owner_identity, GroupOp::TransferOwnership, CannotRemoveOwner gate in MemberRemoved |
DeleteGroup Owner-gating | Implemented (PR #2280) | Group-scope only — root GroupDeleted + DeleteNamespace Owner-gating planned with leave_namespace in PR #3 |
Open Questions
- Soft vs hard local cleanup default for
leave_namespace. This proposal defaults to soft (archive). Privacy-conscious products may want hard (purge) as the default. - Admin-mutual demote. Today admins can demote each other. Should this be restricted to Owner-only? This proposal preserves current behavior; revisit if a security concern surfaces.
- Old owner after
TransferOwnership. Becomes a regular admin (this proposal) or just a member (alternative)? Admin retains operational continuity; member is a cleaner "step-down" semantic. - Multi-scope transfer ergonomics. If an owner of a namespace + 5 subgroups wants to leave, does the UI surface 6 separate prompts or one bulk-transfer flow?
- Cascade visibility in
leave_namespace. Remaining peers see one user-facing event (MemberLeft) or N events (one per cascading scope'sKeyDelivery)? Recommend collapsing to one user-facing event with internal fan-out. - Owner-leaves-with-no-successor edge case. Dedicated
DeleteNamespaceop (this proposal) or refuse outright?