Membership & Leave Operations

Voluntary leave and Owner role across namespaces, groups, and contexts

3 leaves
context · group · namespace
1 op
TransferOwnership
Direct
leave deletes a row
Owner≠Admin
exclusive role

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 ContextGroupId with no parent. The trust anchor for everything beneath it. There is no separate Namespace type — a namespace is a root group.
  • Group / subgroup. A ContextGroup with a parent_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 by add_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_SUBGROUPS capability 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_SUBGROUPS inherit 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 Owner as 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): MemberRemoved rejects with LastAdmin if it would leave a group with zero admins. Also enforced on demotion.
  • Key rotation on removal (group_governance_publisher.rs:216-220): publishing MemberRemoved automatically generates a fresh group key, wraps it for remaining members, and emits RootOp::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 MemberRemoved first, 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_at that 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 pass delta.governance_position verbatim.
  • 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 MemberRemoved ops. The forward-only contract is documented on the public membership_status_at function; the internal BFS in prefix_walk_membership implements it, and the prefix_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:

pub struct ContextLeftMarker(Key<(ContextId, PublicKeyComponent)>);

// 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

  1. Client calls leave_context(context_id).
  2. Handler resolves the local ContextIdentity via find_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.
  3. In a single batched store handle (so they land in one commit), the handler:
    1. Writes the ContextLeftMarker row in Column::ContextLocal.
    2. Deletes the corresponding ContextIdentity row, which stops sync (the sync layer iterates these rows).
  4. Calls node_client.unsubscribe(&context_id) to leave the gossipsub topic, mirroring join_context's subscribe.
  5. Auto-follow handler (auto_follow.rs:255) checks for the marker via has_left_context on every ContextRegistered event and skips the auto-rejoin if it finds one.

Properties

ScopeLocal only
DAG entryNone
Key rotationNone — leaver still holds the group key
CoordinationNone — cannot fail
ReversibilityTrivial — JoinContextRequest deletes the marker as a side-effect of joining and writes a fresh ContextIdentity row
Other members observeNo
Forward secrecyNot 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

enum GroupOp {
    // 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 with NotADirectMember if the signer is only an inherited member — they must leave the anchor instead).
  • Signer is not the Owner (OwnerCannotLeave; must TransferOwnership first).
  • If signer is admin, removing them does not leave the group with zero admins (LastAdmin via existing ensure_not_last_admin_removal).

Flow

  1. Leaver's client calls leave_group(group_id).
  2. Server-side validation runs the preconditions above.
  3. Leaver publishes MemberLeft { member: signer } via the governance DAG. Signed by the leaver. Targets this group.
  4. 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.
  5. The first remaining admin to ack the op enters phase two:
    1. Generates a fresh group key via build_key_rotation (group_keys.rs:266).
    2. Wraps it for each remaining member (leaver excluded).
    3. Publishes RootOp::KeyDelivery carrying the wrapped bundles.
  6. 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 groupUntouched. Leaver keeps those memberships. (You can be invited to a child while explicitly leaving the parent.)
Inherited memberships in Open subgroupsResolve 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. MemberLeft lands but KeyDelivery is 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. MemberLeft lands; 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_members from 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

  1. Client calls leave_namespace(namespace_id).
  2. 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.
  3. Signer publishes a single MemberLeft { member: signer } at the namespace root.
  4. The cascade handler (cascade_remove_member_from_group_tree, mod.rs:811) fans out: locally and on each receiver, fire the MemberLeft-equivalent for each scope in direct_groups.
  5. Per-scope key rotation runs the same phase-two pattern as leave_group, in parallel. Total network ops: one root-level MemberLeft + N KeyDelivery ops where N = number of direct memberships in the subtree.
  6. 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

enum GroupOp {
    // existing variants + MemberLeft...
    TransferOwnership { new_owner: PublicKey },
}
  • Signer must equal current owner_identity.
  • new_owner must already be a member of the group.
  • Apply: update owner_identity to new_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

ActionMemberAdminOwner
Invite members (with CAN_INVITE_MEMBERS)
Create context (with CAN_CREATE_CONTEXT)
Remove non-owner members (MANAGE_MEMBERS)
Promote member → admin
Demote admin → member1
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 to TransferOwnership.
  • 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.

SectionStatusTracking
Membership Model RecapDocuments existing code
leave_contextImplemented (PR #2280)crates/context/src/handlers/leave_context.rs, auto_follow.rs gate, POST /admin-api/contexts/:id/leave
leave_groupImplemented (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_namespaceImplemented (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 + TransferOwnershipImplemented (PR #2280)GroupMetaValue.owner_identity, GroupOp::TransferOwnership, CannotRemoveOwner gate in MemberRemoved
DeleteGroup Owner-gatingImplemented (PR #2280)Group-scope only — root GroupDeleted + DeleteNamespace Owner-gating planned with leave_namespace in PR #3

Open Questions

  1. 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.
  2. 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.
  3. 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.
  4. 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?
  5. Cascade visibility in leave_namespace. Remaining peers see one user-facing event (MemberLeft) or N events (one per cascading scope's KeyDelivery)? Recommend collapsing to one user-facing event with internal fan-out.
  6. Owner-leaves-with-no-successor edge case. Dedicated DeleteNamespace op (this proposal) or refuse outright?