Membership & Leave Operations
Voluntary leave, owner-initiated removal, and role-scoped TEE self-purge across namespaces, groups, and contexts
As-built page. This was originally a proposal; it now documents shipped behavior. For the
fleet-specific eviction story (owner kicks a ReadOnlyTee node on HA-disable, the
self-purge listener, and the marker-gated startup reconcile) see
Fleet HA. Term definitions live in the
Glossary. The governing design record is
docs/adr/0002-fleet-tee-leave-protocol.md.
Problem this solved
Originally, the only way out of a Calimero group or namespace was to be removed by an admin —
MemberRemoved was admin-initiated and there was no self-leave op. Users could not
voluntarily exit a workspace they joined; muting a context they were auto-followed into required
client-side filtering rather than a real opt-out, because auto-follow re-adds them on the next
ContextRegistered event.
Separately, every group records a single "creator / fallback admin" identity
(GroupMetaValue.owner_identity) but the role enum did not distinguish that creator
from any other admin. There was no clean way to express "this person owns the group, others
administrate it."
The shipped design adds three leave operations and an owner identity stored on group metadata that
gates self-leave and involuntary removal. The pieces share infrastructure: the stored owner blocks
leave_group/leave_namespace and involuntary MemberRemoved;
owner-initiated MemberRemoved reuses the group key-rotation pipeline;
leave_namespace is the namespace-root MemberLeft plus a cascade through
descendant groups.
A separate, role-scoped mechanism layers on top for fleet TEE nodes: when a
ReadOnlyTee member is removed, the evicted node hard-deletes its local key
material (see TEE self-purge), because a TEE node has no rejoin path
under the same identity. Non-TEE removals keep the soft-leave invariant.
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.
In the governance-DAG projection layer, this distinction is reflected by the fold rule for
RootOp::MemberJoinedOpen: it folds to OpPayload::Noop rather than
OpPayload::MemberAdded. No persistent GroupMember node is written for
an open-subgroup inheritance join; instead, AclView::is_member_at_cut re-derives
membership at query time via the anchor-root membership + subgroup tree + visibility +
CAN_JOIN_OPEN_SUBGROUPS capability walk — exactly mirroring the live store. This
means inherited access is automatically revoked when the member is removed from the root group
and restored if they rejoin, with no over-grant surviving root-anchor removal.
In the governance-DAG projection layer, this distinction is reflected by the fold rule for
RootOp::MemberJoinedOpen: it folds to OpPayload::Noop rather than
OpPayload::MemberAdded. No persistent GroupMember node is written for
an open-subgroup inheritance join; instead, AclView::is_member_at_cut re-derives
membership at query time via the anchor-root membership + subgroup tree + visibility +
CAN_JOIN_OPEN_SUBGROUPS capability walk — exactly mirroring the live store. This
means inherited access is automatically revoked when the member is removed from the root group
and restored if they rejoin, with no over-grant surviving root-anchor removal.
This behavior is pinned by three assertions in
projection_membership_equivalence.rs: (1)
projection_matches_live_across_inherited_join_and_root_removal asserts that after
an inherited join, member_at_cut_authoritative returns Some(true);
(2) the same test asserts that after a root removal that the live store rejects,
member_at_cut_authoritative must not return Some(true) — the
over-grant guard; (3)
projection_matches_live_across_leave_and_rejoin_inheritance asserts that after a
full leave-and-rejoin cycle, member_at_cut_authoritative correctly restores access.
Together these tests ensure the authoritative grant resolver agrees with the live store across
all three scenarios and never over-grants on root-anchor removal.
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
The GroupMemberRole enum (crates/primitives/src/context.rs) has exactly
four variants:
Admin,Member,ReadOnly,ReadOnlyTee.- There is no
Ownerrole variant. "Owner" is not a member role — it is a single identity stored on group metadata (GroupMetaValue.owner_identity). The owner is whichever member's public key equals that field; they are otherwise an ordinaryAdmin. Owner status is enforced by identity comparison in the apply handlers (OwnerImmuneFromRemoval,OwnerCannotSelfLeave), not by a role check. See the Owner section.
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,sign_apply_and_publish_inner): publishingMemberRemovedautomatically generates a fresh group key viaGroupKeyring, wraps it (wrap_for_member) for every remaining member — excluding the removed one — and delivers it. The removed member never receives the new key, so it cannot decrypt anything written after eviction. This is the forward-secrecy mechanism for the namespace's future writes. Owner-initiated removal is the cryptographically-complete leave path.
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.
Resolving the current governance cut — ScopeProjections::namespace_current_heads
Current-state membership reads (as opposed to historical signed-position checks) need a "now"
frontier: the set of parent hashes that represents the namespace's current DAG head. The static
method ScopeProjections::namespace_current_heads provides this:
- Resolves the supplied
ContextGroupIdto its owning namespace by walking the parent chain. - Reads that namespace's DAG head record from the governance store.
- Returns the parent hashes of that head — the cut argument passed to
membership_status_at(orAclView::is_member_at_cut) for a current-state query.
None return cases. The method returns None when the group cannot
be resolved to a namespace (e.g. the group does not exist in the local store) or when the head
record itself is unreadable. Callers must fall back to the live membership store
(check_group_membership_path) in both cases rather than treating None as
an empty cut — an empty cut would falsely deny all access.
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 — self-removal, no cascade
Self-removal from a single group via GroupOp::MemberLeft. The leaver publishes the
leave op; every receiver deletes the leaver's (member, group) row.
As-built caveat — no key rotation on self-leave. Unlike owner-initiated
MemberRemoved, MemberLeft deliberately does not trigger the
key-rotation pipeline. The publisher of a self-leave is the leaver, and the leaver cannot generate
the fresh group key without also retaining it — which would defeat forward secrecy. So
MemberLeft is the governance-level departure (the membership row is removed, peers
observe the leave, the leaver is deny-listed) without a re-key. The path to a
cryptographically-complete leave is an admin/owner-initiated MemberRemoved, which
rotates. A two-phase self-leave rotation (a remaining admin's apply hook publishes the new key) is
a tracked follow-up, not yet shipped. See the inline note in
crates/governance-store/src/ops/group/member_left.rs.
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 equals the member being removed (
SelfLeaveOnly) and has a direct row in the group; reject withMemberNotDirectif the signer is only an inherited member — they must leave the anchor instead. - Signer is not the group's
owner_identity(OwnerCannotSelfLeave; mustTransferOwnershipfirst). - Removing the signer does not leave the group with zero admins
(
LastAdminviaensure_not_last_admin_removal).
Flow
- Leaver's client calls
leave_group(group_id). - Apply enforces the preconditions above (self-equality, direct-row, owner, last-admin).
- 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:
cascade_remove_member_from_group_treedrops the leaver'sContextIdentityrows,remove_memberdeletes the(leaver, group_id)membership row, and the leaver is added to the group deny-list so their state-delta traffic is dropped until they re-join. - The apply emits
OpEvent::MemberRemoved; if the leaver's role wasReadOnlyTeeit additionally emitsOpEvent::TeeMemberRemoved, which drives the leaver's own node to self-purge the group's local key material. - No automatic re-key. As noted above,
MemberLeftdoes not rotate the group key — the leaver still holds the last key it received and can decrypt writes made under it. Forward secrecy on future writes requires the owner-initiatedMemberRemovedpath (or the deferred two-phase self-leave rotation).
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
- No forward secrecy on self-leave (by design, not a failure). Because
MemberLeftnever rotates, the leaver retains the ability to decrypt writes made under the key they last held. To revoke that, an admin/owner must issueMemberRemoved(which rotates) or the deferred two-phase self-leave rotation must land. - Inherited-only membership. A signer whose membership is purely inherited (no direct row)
is rejected with
MemberNotDirect; they must leave the anchoring ancestor instead.
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 is a single MemberLeft at the namespace root
whose apply cascades through every descendant group where the leaver has a direct row. This is the
only leave operation that fans out across multiple scopes. As with leave_group, the
self-leave op does not rotate keys (same publisher-holds-the-key constraint); row removal +
deny-listing only.
Preconditions
- Signer has a direct row at the namespace root (
MemberNotDirectotherwise). - Signer is not the
owner_identityof the namespace root (OwnerCannotSelfLeave) nor of any subgroup beneath it where they have a direct row (OwnerOwnsSubgroup, reporting the offending scope). - Removing the signer doesn't leave any scope (namespace or descendant) admin-less. If it
would →
LastAdminreports the offending scope, forcing a successor promotion before retry. All these checks run upfront, before any mutation.
Flow
- Client calls
leave_namespace(namespace_id). - Apply detects the no-parent (namespace-root) case and walks the subtree:
- Compute the descendant groups where the signer has a direct row.
- Run owner check + last-admin check across all of them upfront. If any fails
(
OwnerOwnsSubgroup,OwnerCannotSelfLeave,LastAdmin) it bails before mutating anything — no half-applied cleanup.
- Signer publishes a single
MemberLeft { member: signer }at the namespace root. - The apply cascades: for each direct-row descendant it calls
cascade_remove_member_from_group_tree, removes the membership row, deny-lists the leaver, and emitsOpEvent::MemberRemoved. For each descendant where the leaver's role wasReadOnlyTeeit also emits a per-subgroupOpEvent::TeeMemberRemoved. The same removal then runs for the namespace root, emitting a rootMemberRemoved(and a rootTeeMemberRemovedif the root role wasReadOnlyTee). - No per-scope key rotation. The cascade is row-removal only — there is no
KeyDeliveryfan-out. Forward secrecy on each scope's future writes is provided by the owner-initiatedMemberRemovedrotation pipeline, not by self-leave. - Local cleanup on the leaver's node is role-scoped, not a soft/hard toggle:
- Non-TEE roles: soft leave. Local rows (identity, signing keys, contexts) are kept so rejoin / keyshare / inheritance-rejoin flows can reuse them.
ReadOnlyTee: the emittedTeeMemberRemovedevents drive the self-purge listener to hard-delete local key material across the whole subtree and unsubscribe from the namespace gossipsub topic.
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
- No forward secrecy on self-leave across any scope (same constraint as
leave_group): the cascade removes rows but does not re-key. Use owner-initiatedMemberRemovedfor a cryptographically-complete eviction. - Interrupted TEE self-purge. If the leaver was a
ReadOnlyTeenode and its local cascade-purge is interrupted (crash, or a signing-key delete error), a durable pending-self-purge marker survives and the startupreconcile_sweepcompletes the purge on next restart. See TEE self-purge.
Role-scoped TEE self-purge
Removal alone (the MemberRemoved rotation) already provides forward secrecy on a
namespace's future writes — the evicted node never receives the rotated key. But a
ReadOnlyTee fleet node still holds, on its own disk, the old key material from before
the rotation: signing keys, the group encryption keys it last received, its
NamespaceIdentity, and the governance op-log. A regular member's local rows are
deliberately kept (the soft-leave invariant lets kick-and-readd /
rejoin-via-keyshare / inheritance-rejoin reuse them). A ReadOnlyTee node has
no rejoin path — re-admission via MemberJoinedViaTeeAttestation derives a fresh
attestation pubkey — so that material buys nothing and is pure hygiene debt. The self-purge handler
hard-deletes it.
Design record: docs/adr/0002-fleet-tee-leave-protocol.md. Implementation:
crates/context/src/self_purge.rs. Fleet-side framing:
Fleet HA.
Role-scoped trigger
The apply path emits a generic OpEvent::MemberRemoved for every removal, and — only when
the removed member's stored role was ReadOnlyTee — an additional
OpEvent::TeeMemberRemoved (see ops/group/member_removed.rs and
member_left.rs). The self-purge listener subscribes to the op-apply event channel and
reacts only to TeeMemberRemoved:
- Non-TEE removals (
Admin/Member/ReadOnly) keep soft-leave semantics — no purge. Hardening to hard-purge every removal would regress theapps/scaffolding-e2e/workflows/group-{kick,leave}-*workflows that depend on the retained rows. ReadOnlyTeeremovals trigger a hard purge. The self-detection ("did this op evict me?") is a handler concern, not part of the node-agnostic apply contract — it reads this node's stored namespace identity (per-node state). This mirrors the auto-follow architectural split.
Subgroup vs namespace-root
decide_purge_action (pure store reads) resolves the namespace owning the evicted group,
confirms the evicted member is this node's own identity, and then picks one of two actions:
PurgeAction::Subgroup | Removed from one subgroup while still a
member of others under the same namespace. Purge only that group's local rows
(delete_group_local_rows) plus its context-index and tree-edge rows. Do not
unsubscribe from the namespace gossipsub topic — other memberships still need it. |
PurgeAction::Namespace | Removed from the namespace root. Cascade the whole subtree, drop namespace-level state, then unsubscribe from the namespace topic. |
PurgeAction::None | Event is for another member, or for a namespace this node has no identity in. No action. |
What gets hard-deleted
The cascade (cascade_namespace_state) iterates the root plus every descendant group and
calls delete_group_local_rows per group. That helper
(crates/governance-store/src/local_state.rs) deletes the membership rows, capabilities,
metadata, upgrade records, the group op-log + head, the deny-list, and — load-bearing —
the private key material:
- Signing keys.
SigningKeysRepository::delete_all_for_group— the 32-byte private signing-key material. Leaking these is the actual forward-secrecy hazard, so this is the step the failure-class gating treats as security-critical. - Group encryption keys.
GroupKeyring::delete_all_for_group— the AES group keys the node received while admitted (added in PR #2776). Without this the evicted node would retain its copy of past group keys on disk even though the rotation already orphaned them for future writes.
Forward secrecy is therefore split cleanly: future writes are protected by key rotation (re-key excluding the removed member, done by the publisher pipeline at removal); the evicted TEE node's own copies of past keys are deleted here by self-purge. The purge does not — and need not — trigger any rotation itself.
Namespace-level teardown (delete_namespace_local_state) then drops the
NamespaceIdentity, the namespace governance head, and the namespace op-log. Dropping the
identity and unsubscribing is gated on the signing-key purge succeeding (a best-effort
dead-pointer cleanup failure does not block the security-critical finalize).
Durable marker + startup reconcile
TeeMemberRemoved fires once per eviction and an already-evicted identity receives no
further removal events, so a missed or partially-failed purge has no event-driven retry. Recovery is
a startup sweep, made role-safe by a durable marker (ADR 0002 / #2721):
- Before the namespace cascade,
handle_member_removedwrites a durable pending-self-purge marker for the namespace. This is the one site that knows — node-aware and role-aware — that this is a confirmed TEE self-eviction of our identity. - On startup,
reconcile_sweepenumerates markers only and completes a purge iff (marker present) AND (still no surviving namespace-root membership). If the identity is already gone, or we have been re-admitted, it clears the stale marker without purging; a read error skips (never purge on uncertainty). - The marker is essential because, post-eviction, the role row is erased: a role-blind scan of
NamespaceIdentityrows could not distinguish evicted-TEE residue from a pending join or non-TEE soft-leave residue, and would false-purge both. The marker is the role/intent gate; the still-evicted check is the safety gate; both must hold.
Owner (stored identity, not a role)
Owner is not a GroupMemberRole variant. It is a single identity stored on group
metadata: GroupMetaValue.owner_identity (crates/store/src/key/group/mod.rs).
Every group has exactly one Owner, and that member is otherwise an ordinary Admin — the
"Owner" status is whatever the apply handlers grant by comparing the signer's public key against
owner_identity. The distinction is a small set of exclusive privileges plus immunity
from involuntary removal; it is enforced by identity comparison, not by a role bit.
Storage
The struct carries both fields: the legacy admin_identity (now a
fallback creator-admin marker for pre-existing groups) and the shipped owner_identity.
Newly-created groups initialize owner_identity == admin_identity. Field shape and
serialization are stable.
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) | ✓ | ✓ | ✗ — OwnerImmuneFromRemoval |
Self-leave via leave_group / leave_namespace | ✓ | ✓ (last-admin permitting) | ✗ — OwnerCannotSelfLeave / OwnerOwnsSubgroup |
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 |
No (self-leave; deferred) | None | Re-invite (Restricted) / re-inherit (Open) |
leave_namespace |
Whole subtree | MemberLeft at root |
No (self-leave; deferred) | All direct-row scopes | Re-invite at root + re-inherit |
MemberRemoved (owner/admin) |
Single group (cascades contexts) | MemberRemoved |
Yes — re-key excludes removed | Context identities | Re-invite (re-key delivered on add) |
ReadOnlyTee removal + self-purge |
Subgroup or whole namespace | MemberRemoved + TeeMemberRemoved event |
Yes (via removal) + local key delete | Subtree on namespace-root | None under same identity (fresh attestation) |
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 documents shipped behavior. Each row points at the code that implements it.
| Section | Status | Code |
|---|---|---|
| Membership Model Recap | Documents existing code | — |
leave_context | Implemented | crates/context/src/handlers/leave_context.rs, auto_follow.rs gate, POST /admin-api/contexts/:id/leave |
leave_group | Implemented | GroupOp::MemberLeft, crates/governance-store/src/ops/group/member_left.rs, POST /admin-api/groups/:id/leave — apply enforces self-leave + direct-row + owner + last-admin. No key rotation on self-leave (deferred two-phase rotation). |
leave_namespace | Implemented | Same member_left.rs apply detects the no-parent case and cascades through descendant groups where the leaver has direct rows; multi-scope owner + last-admin checked upfront. No per-scope key rotation. |
Key rotation on MemberRemoved | Implemented | crates/governance-store/src/group_governance_publisher.rs (sign_apply_and_publish_inner) + GroupKeyring in group_keys.rs — fresh key wrapped for remaining members, excluding the removed one. |
| Role-scoped TEE self-purge | Implemented (ADR 0002; #2680/#2724/#2725) | crates/context/src/self_purge.rs listener + cascade + marker-gated reconcile_sweep; delete_group_local_rows in governance-store/src/local_state.rs deletes signing keys (and, as of #2776, GroupKeyring encryption keys); OpEvent::TeeMemberRemoved emitted from member_removed.rs / member_left.rs. |
ScopeProjections::namespace_current_heads | Implemented | crates/context/src/scope_projections.rs — resolves a ContextGroupId to its namespace, reads the namespace DAG head, and returns parent hashes as the current-state cut argument. Returns None on unresolvable group or unreadable head; callers fall back to check_group_membership_path. |
Owner identity + TransferOwnership | Implemented | GroupMetaValue.owner_identity, GroupOp::TransferOwnership (ops/group/transfer_ownership.rs), OwnerImmuneFromRemoval / OwnerCannotSelfLeave gates in MemberRemoved / MemberLeft. |
Open Questions & Follow-ups
- Two-phase self-leave rotation.
MemberLeftcurrently does not re-key (the leaver can't generate a key without retaining it). A deferred design has a remaining admin's apply hook publish the new key after the leave. Until it lands, owner-initiatedMemberRemovedis the only rotating leave. - Local cleanup is role-scoped, not a toggle. The earlier soft-vs-hard default question is
resolved by role (ADR 0002): non-TEE removals stay soft;
ReadOnlyTeeremovals hard-purge. No global default knob. - Admin-mutual demote. Admins can demote each other. Whether to restrict this to Owner-only is unresolved; current behavior is preserved.
- Old owner after
TransferOwnership. Becomes a regular admin (current behavior) or just a member (alternative)? - Subgroup-level self-purge reconcile. The startup reconcile is namespace-level only; a subgroup-only purge has no reconcile retry surface yet (tracked in #2726).
- Periodic reconcile. A purely-lagged-drop of
TeeMemberRemovedwrites no marker and is not recovered until a future eviction; a periodic sweep is an open follow-up.