Membership & Leave Operations

Voluntary leave, owner-initiated removal, and role-scoped TEE self-purge across namespaces, groups, and contexts

3 leaves
context · group · namespace
Key rotation
on removal (publisher pipeline)
Direct
leave deletes a row
TEE purge
role-scoped hard delete

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

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

The GroupMemberRole enum (crates/primitives/src/context.rs) has exactly four variants:

  • Admin, Member, ReadOnly, ReadOnlyTee.
  • There is no Owner role 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 ordinary Admin. 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): 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, sign_apply_and_publish_inner): publishing MemberRemoved automatically generates a fresh group key via GroupKeyring, 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 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.

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:

  1. Resolves the supplied ContextGroupId to its owning namespace by walking the parent chain.
  2. Reads that namespace's DAG head record from the governance store.
  3. Returns the parent hashes of that head — the cut argument passed to membership_status_at (or AclView::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_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 — 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

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 equals the member being removed (SelfLeaveOnly) and has a direct row in the group; reject with MemberNotDirect if the signer is only an inherited member — they must leave the anchor instead.
  • Signer is not the group's owner_identity (OwnerCannotSelfLeave; must TransferOwnership first).
  • Removing the signer does not leave the group with zero admins (LastAdmin via ensure_not_last_admin_removal).

Flow

  1. Leaver's client calls leave_group(group_id).
  2. Apply enforces the preconditions above (self-equality, direct-row, owner, last-admin).
  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: cascade_remove_member_from_group_tree drops the leaver's ContextIdentity rows, remove_member deletes 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.
  5. The apply emits OpEvent::MemberRemoved; if the leaver's role was ReadOnlyTee it additionally emits OpEvent::TeeMemberRemoved, which drives the leaver's own node to self-purge the group's local key material.
  6. No automatic re-key. As noted above, MemberLeft does 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-initiated MemberRemoved path (or the deferred two-phase self-leave rotation).

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

  • No forward secrecy on self-leave (by design, not a failure). Because MemberLeft never rotates, the leaver retains the ability to decrypt writes made under the key they last held. To revoke that, an admin/owner must issue MemberRemoved (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_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 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 (MemberNotDirect otherwise).
  • Signer is not the owner_identity of 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 → LastAdmin reports the offending scope, forcing a successor promotion before retry. All these checks run upfront, before any mutation.

Flow

  1. Client calls leave_namespace(namespace_id).
  2. 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.
  3. Signer publishes a single MemberLeft { member: signer } at the namespace root.
  4. 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 emits OpEvent::MemberRemoved. For each descendant where the leaver's role was ReadOnlyTee it also emits a per-subgroup OpEvent::TeeMemberRemoved. The same removal then runs for the namespace root, emitting a root MemberRemoved (and a root TeeMemberRemoved if the root role was ReadOnlyTee).
  5. No per-scope key rotation. The cascade is row-removal only — there is no KeyDelivery fan-out. Forward secrecy on each scope's future writes is provided by the owner-initiated MemberRemoved rotation pipeline, not by self-leave.
  6. 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 emitted TeeMemberRemoved events 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-initiated MemberRemoved for a cryptographically-complete eviction.
  • Interrupted TEE self-purge. If the leaver was a ReadOnlyTee node and its local cascade-purge is interrupted (crash, or a signing-key delete error), a durable pending-self-purge marker survives and the startup reconcile_sweep completes 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 the apps/scaffolding-e2e/workflows/group-{kick,leave}-* workflows that depend on the retained rows.
  • ReadOnlyTee removals 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::SubgroupRemoved 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::NamespaceRemoved from the namespace root. Cascade the whole subtree, drop namespace-level state, then unsubscribe from the namespace topic.
PurgeAction::NoneEvent 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_removed writes 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_sweep enumerates 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 NamespaceIdentity rows 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

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)✗ — 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 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 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.

SectionStatusCode
Membership Model RecapDocuments existing code
leave_contextImplementedcrates/context/src/handlers/leave_context.rs, auto_follow.rs gate, POST /admin-api/contexts/:id/leave
leave_groupImplementedGroupOp::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_namespaceImplementedSame 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 MemberRemovedImplementedcrates/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-purgeImplemented (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_headsImplementedcrates/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 + TransferOwnershipImplementedGroupMetaValue.owner_identity, GroupOp::TransferOwnership (ops/group/transfer_ownership.rs), OwnerImmuneFromRemoval / OwnerCannotSelfLeave gates in MemberRemoved / MemberLeft.

Open Questions & Follow-ups

  1. Two-phase self-leave rotation. MemberLeft currently 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-initiated MemberRemoved is the only rotating leave.
  2. 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; ReadOnlyTee removals hard-purge. No global default knob.
  3. Admin-mutual demote. Admins can demote each other. Whether to restrict this to Owner-only is unresolved; current behavior is preserved.
  4. Old owner after TransferOwnership. Becomes a regular admin (current behavior) or just a member (alternative)?
  5. 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).
  6. Periodic reconcile. A purely-lagged-drop of TeeMemberRemoved writes no marker and is not recovered until a future eviction; a periodic sweep is an open follow-up.