Local Group Governance

Signed gossip operations, causal DAG, capability-based authorization

Ed25519
signed ops
DAG
causal ordering
6
capability bits
20+
op variants

Architecture

Node A GroupStore Members, capabilities Contexts, metadata DagStore Causal ordering Pending resolution OpLog Persistent sequence of applied ops + DAG heads SignedGroupOp (v3) group_id + parent_op_hashes state_hash + nonce + signer op: GroupOp + Ed25519 signature borsh · content-addressed Node B GroupStore Members, capabilities Contexts, metadata DagStore Causal ordering Pending resolution OpLog Persistent sequence of applied ops + DAG heads gossipsub ns/<hex> topic
GroupStore (persistence)
DagStore (causal ordering)
OpLog (replay + catch-up)
SignedGroupOp (wire format)

Core Concept: Groups Own Contexts

Group admin · members · policies Context A app + state + identities Context B app + state + identities Context C app + state + identities Admin Member 1 Member 2 Group membership → access to contexts MemberRemoved → cascade removal from all contexts + subgroups

Member Roles

Admin

Full control. Can perform all governance operations, manage members, configure the group, and write state in all contexts.

Member

Standard participant. Can read and write state in joined contexts. Specific governance operations require capability bits (e.g. CAN_INVITE_MEMBERS, MANAGE_MEMBERS).

ReadOnly

Observer. Can join contexts and read state, but state mutations are rejected by the platform. Enforced at both the local node (execute handler discards changes) and remote nodes (state deltas from ReadOnly authors are rejected on ingestion).

At-Cut Membership Path (MemberPathAtCut)

MemberPathAtCut is the at-cut analogue of the live MembershipPath enum, used for deriving a member's enumeration role at a given governance snapshot. It has three variants:

None

Identity is not a member at the cut — neither directly nor via the open-subgroup inheritance chain.

Direct { role }

Identity holds a direct stored membership row. Carries the folded role. The direct row is checked before the admin carve-out, so an identity that is both a stored member and the genesis admin gets the stored row's role (matching live list behaviour).

Inherited { anchor, via_admin }

Identity is present via the open-subgroup chain. Carries the anchor group (deepest ancestor with a direct row) and whether that ancestor granted admin authority. Sufficient information to derive the enumeration role without a second lookup.

Membership authorization is now handled entirely by the unified projection. The previous live governance-cut resolver (acl_view_at, MembershipStatus, prefix_walk_membership, MembershipTransition, heads_equal, MAX_PREFIX_WALK_NODES) has been deleted from governance-store/src/membership/status.rs. The admin predicate used in the projection is narrowed to group admin OR genesis root admin of this group only — the global is_root_admin predicate is intentionally excluded so that root admins are not silently granted access to Restricted subgroups.

Subgroups

Groups can form a tree hierarchy. A child group links to a parent via GroupParentRef. Membership and admin authority inherit downward:

Membership Inheritance

Each subgroup carries a subgroup_visibility flag — Open or Restricted (absent = Restricted, the safe default). The membership check (check_group_membership_path) walks up the ancestor chain (max depth 16) only through unbroken Open chains, anchored at the deepest ancestor where the identity holds a direct membership row. Direct membership in the target subgroup short-circuits to Direct; otherwise the walk returns Inherited{anchor, via_admin}. A Restricted ancestor is a wall — the walk stops with None.

At the anchor, admins inherit unconditionally; non-admins need the CAN_JOIN_OPEN_SUBGROUPS capability bit at that anchor.

Inherited role preservation: enumerate_inherited now returns the member's real role at the anchor group (retrieved via role_of(&anchor, &candidate)) rather than defaulting every inherited non-admin to GroupMemberRole::Member. Admin paths still resolve to Admin. Membership authorization is driven entirely by the unified projection; the former live governance-cut resolver (acl_view_at, MembershipStatus, prefix_walk_membership, MembershipTransition) has been removed.

Admin Authority

Parent admins inherit structural governance over descendant groups: add/remove members, detach contexts, delete subgroups, set target application.

Open vs Restricted Subgroups

Restricted subgroups are sealed: only direct members can read their governance ops or context state. Their per-subgroup encryption key is delivered via KeyDelivery to direct joiners only.

Open subgroups align the cryptographic boundary with the access boundary: their governance ops and context state deltas are encrypted with the parent namespace's key rather than a per-subgroup key. Every namespace member already holds the namespace key, so no separate key-delivery path is needed for inheritance-eligible members. The trade-off is explicit: the read-confidentiality boundary for an Open subgroup is namespace-wide, while the join/write boundary remains capability-gated by CAN_JOIN_OPEN_SUBGROUPS.

Receivers don't need a wire flag for the encryption choice — the key_id resolves uniquely to either the subgroup or the namespace keyring, with the namespace keyring tried as a fallback after the subgroup keyring miss.

Born-Open subgroups: RootOp::GroupCreated carries a restricted boolean field (wire-breaking change; nodes must reset). When restricted: false, the group_created apply handler writes the subgroup's visibility (set_subgroup_visibility) during apply — before the OpEvent::SubgroupCreated is queued — so the subgroup is already Open when any TEE subscriber reacts, preventing transient direct ReadOnlyTee rows. restricted: true is the default and preserves legacy behavior. CreateGroupRequest gains a restricted field; the REST endpoint accepts an optional visibility field ("open"|"restricted", default "restricted").

Buffered-Op Retry on GroupCreated

After applying a RootOp::GroupCreated, the governance layer immediately checks whether the node holds any key for the new subgroup (or the namespace key, for Open subgroups) and, if so, retries all buffered encrypted ops for that group via redrive_buffered_ops_for_group. Previously the only retry trigger was KeyDelivery; if KeyDelivery arrived before GroupCreated, the retry failed because subgroup meta was absent and no subsequent trigger existed.

The state_hash staleness check is now bypassed entirely when the subgroup's GroupMeta row does not yet exist, instead of calling compute_state_hash and failing with GroupNotFoundForHash. This mirrors the existing zero-hash fast-path.

A one-shot startup sweep (redrive_stranded_ops_sweep) runs after subscribing to op events and before the event loop. It iterates every namespace the node has an identity for, finds groups with buffered ops whose key is already held, and re-drives each one via redrive_encrypted_ops_for_group_counted. It loops until a full pass applies nothing new (bounded by MAX_PASSES=64), narrowing to only groups that made progress in the previous pass. Errors are logged and swallowed; the sweep never blocks startup.

Sync-Stream Authorization

The responder-side stream-auth gate (DagHeadsRequest, DeltaRequest, snapshot stream) accepts both direct context membership and inheritance-eligible parent membership. Without this, an inheritance joiner's snapshot probe would be silently closed, leaving them stuck on the all-zero initial root hash. The auth gate uses the same parent-walk that powers join-time authorization, so the contract is consistent end-to-end: namespace member + CAN_JOIN_OPEN_SUBGROUPS = read + write + sync, with no extra round-trips.

Restricting Access via Subgroups

To restrict access to specific contexts, create a subgroup and either leave its visibility unset (Restricted by default) or register those contexts under it. Only members of that subgroup (direct or inherited via the Open-chain walk above) can join its contexts.

Create a subgroup with --parent-group-id on meroctl group create or via the parentGroupId field in the API. The SubgroupCreated governance op is published on the parent group's gossip topic. Flip visibility with PUT /groups/:id/settings/subgroup-visibility or meroctl group set-subgroup-visibility.

When joining a parent group with auto_join (default: true), the node subscribes to gossip topics and contexts in all descendant subgroups. MemberRemoved cascades to descendant subgroup contexts (skipping child groups where the member has direct membership).

Governance DAG Structure

Each group has a DAG of SignedGroupOp operations. Operations reference their parents by content hash, forming a causal chain that supports offline ops, concurrent admins, and merge.

G genesis (group create) A1 MemberAdded A2 Admin A A3 B1 Admin B B2 M Merge (Noop with 2 parents) delta_id = SHA-256(signable_bytes) parents = current DAG heads state_hash = optimistic lock Ordering guarantees: • topological (parents first) • pending queue for OOO
Genesis
Admin A ops
Admin B ops (concurrent)
Merge op

SignedGroupOp Schema (v3)

Signable Payload

version
Schema version (currently 3)
group_id
Target group identifier
parent_op_hashes
DAG ancestry (current heads at sign time)
state_hash
Optimistic lock — SHA-256 of group state
signer
Ed25519 public key of the admin
nonce
Per-signer monotonic (dedup + replay)
op: GroupOp
The actual operation (see right column)

GroupOp Variants

Noop
Merge sentinel (no state change, used to merge DAG heads)
MemberAdded
Add member to group (admin or MANAGE_MEMBERS)
MemberRemoved
Remove member + cascade to all contexts in this group and descendant subgroups (admin or MANAGE_MEMBERS)
MemberRoleSet
Change member role (admin only)
MemberCapabilitySet
Set per-member capability bitfield (admin only)
DefaultCapabilitiesSet
Set default capabilities for new members (admin only)
UpgradePolicySet
Set group upgrade policy (admin only)
TargetApplicationSet
Set target application for the group (admin or MANAGE_APPLICATION)
ContextRegistered
Bind a new context to the group (admin or CAN_CREATE_CONTEXT)
ContextDetached
Remove context from group (admin only)
ContextMetadataSet
Set a context's metadata record — name + opaque data (admin or CAN_MANAGE_METADATA)
MemberMetadataSet
Set a member's metadata record (self, or admin / CAN_MANAGE_METADATA for any member)
GroupMetadataSet
Set the group's metadata record (admin or CAN_MANAGE_METADATA)
GroupDelete
Delete the group (admin only, requires no contexts)
GroupMigrationSet
Configure migration parameters (admin or MANAGE_APPLICATION)
JoinWithInvitationClaim
Invitation-based group join (inviter must be admin or CAN_INVITE_MEMBERS)
ContextCapabilityGranted
Grant per-context capability to a member (admin or MANAGE_MEMBERS)
ContextCapabilityRevoked
Revoke per-context capability from a member (admin or MANAGE_MEMBERS)
SubgroupCreated
Link a child group under this group (admin only)
SubgroupRemoved
Unlink a child group from this group (admin only)

Operation Flows

Create Context Flow

1

Admin calls create_context

API receives group_id (or auto-creates group). Uses the node's namespace identity keypair (from the datastore) if no explicit secret provided.

2

Handler signs GroupOps

Emits a sequence: ContextRegistered → optional ContextMetadataSet. Each is a SignedGroupOp applied locally and published via gossip.

3

Local apply + OpLog

group_store::apply_local_signed_group_op verifies signature, checks state_hash, updates KV rows, appends to op log, recomputes DAG heads.

4

Gossip propagation

Published on group/<hex> topic. Remote nodes receive via SignedGroupOpV1 broadcast, verify, and apply the same ops.

Join Group via Invitation

1

Admin creates invitation

create_group_invitation generates a SignedGroupOpenInvitation with optional expiration timestamp. Shared out-of-band to the joiner.

2

Joiner submits invitation

join_group builds GroupRevealPayloadData, signs it, submits a JoinWithInvitationClaim GroupOp.

3

Op applied + member added

Group store validates the claim, adds the new member, updates op log. Gossip ensures all peers converge.

Join Context (within Group)

1

Group membership implies context access

Context membership is implicit from group membership — no separate governance op or explicit join is required. When a member joins a group with auto_join: true (default), they automatically subscribe to all contexts in that group and its descendant subgroups.

2

Context identity resolved

The node uses its namespace identity as the context member identity. No per-context keypair generation needed.

Member Removal Cascade

MemberRemoved Remove from group GroupMember row deleted Context A identity removed Context B identity removed Join tracking cleaned up Deterministic: same op on any node produces identical cascade — verified by convergence tests

Sync & Catch-up Protocol

Node A (online) Node B (rejoining) every 30s StateHeartbeat { group_id, dag_heads: [h1, h2] } Compare heads missing h2? Stream request GroupDeltaRequest { delta_id: h2 } read_op_log scan for hash GroupDeltaResponse { payload: borsh(SignedGroupOp) } apply_signed_ group_op converged ✓

Startup Recovery

1

reload_group_dags + recover_in_progress_upgrades

On node start, for each group in the store, reads the full OpLog and rebuilds the in-memory DagStore via restore_applied_delta. Reconstructs pending parent resolution state. Re-spawns propagators for any in-progress group upgrades (crash recovery).

2

start_namespace_heartbeat

Starts a 30-second periodic timer. Each tick collects unique namespaces from all groups and publishes NamespaceStateHeartbeat with current namespace DAG dag_heads. Also publishes GroupStateHeartbeat per group for backward compat.

3

Peers respond with deltas

Peers compare heads against their own. For any ops the restarted node is missing, they serve them via NamespaceBackfillRequest/Response streams (or legacy GroupDeltaRequest/Response).

Namespace Governance

Since the namespace identity model, governance has moved to a single DAG per namespace (root group). All groups within a namespace share one governance DAG, stored in NamespaceGovOp / NamespaceGovHead storage keys. Operations are either cleartext RootOps (visible to all namespace members) or encrypted GroupOps (only readable by group members).

RootOps (Cleartext)

Visible to all namespace members. Used for structural changes and key distribution:

GroupCreated
Atomically creates a new group AND nests it under parent_id (strict-tree invariant — no orphan creation path). Carries a restricted boolean field (wire-breaking Borsh change); apply handler writes subgroup visibility immediately when restricted: false, and triggers a buffered-op retry if the node already holds a key for the new group.
GroupDeleted
Cascade-deletes a group plus its entire subtree and all contained contexts in one op. Payload carries cascade_group_ids + cascade_context_ids which every peer re-enumerates and verifies for deterministic application.
GroupReparented
Atomic edge swap: moves a child from its current parent to a new parent within the same namespace. Replaces the previous GroupNested + GroupUnnested pair; orphan state is structurally impossible.
MemberJoined
Cleartext join notification (joiner doesn’t have group key yet)
KeyDelivery
ECDH-wrapped group key delivery to a specific member
AdminChanged
Changes namespace admin
PolicyUpdated
Extensible policy mutations

GroupOps (Encrypted)

Encrypted with the group’s key. Non-members store an opaque skeleton (causal structure preserved, content unknown):

Group { group_id, key_id, encrypted }
Wrapper containing an encrypted inner GroupOp

Inner GroupOp variants (after decryption) are the same as the per-group ops: MemberAdded, MemberRemoved, ContextRegistered, etc.

Key Delivery Mechanism

1. Joiner publishes RootOp::MemberJoined (signed by joiner’s namespace identity)

2. Existing member sees MemberJoined on the namespace DAG

3. Member wraps group key via ECDH: shared = ECDH(sender_sk, joiner_pk)

4. Member publishes RootOp::KeyDelivery { envelope }

5. Joiner unwraps using ECDH(joiner_sk, sender_pk)

6. Joiner can now decrypt GroupOp payloads and participate fully

Signing Key Hierarchy (Ancestor Walk)

Signing keys are stored at the namespace root when a namespace is created. Child groups (created via create_group_in_namespace) do not get their own copy. Instead, governance_preflight resolves signing authority by walking the parent chain:

1. Check current group for a signing key matching the requester identity

2. If not found, walk to parent via get_parent_group()

3. Repeat up to MAX_NAMESPACE_DEPTH (16) levels + final check at root

4. Return the first key found, or error if none exists

Revocation: Revoking a signing key at the namespace root automatically prevents all descendant groups from signing — no stale copies to clean up. Unnesting: breaks the parent link, so the child can no longer walk to the ancestor’s key.

TEE-Replica Bootstrap: Default Capabilities Seed

seed_bootstrap_admin_if_absent (the KeyDelivery-seed path used by TEE replicas) now also seeds the namespace root's default capabilities to CAN_JOIN_OPEN_SUBGROUPS when that key is absent, mirroring the owner-side precedent in store_group_meta. The seed is gated on absence so it is idempotent and never clobbers a later admin-authored override. Pre-stranded replicas need a re-seed or a gossiped DefaultCapabilitiesSet op.

Recursive Member Removal

When a member is removed from a group, recursive_remove_member() cascades the removal to all descendant groups and their contexts. Removal from a child group does not affect the parent (upward isolation).

Namespace Governance Epoch

Context state deltas carry a governance_epoch field. This is now computed from the namespace DAG heads (not per-group heads), ensuring a consistent governance state reference across all groups in a namespace. Computed via compute_namespace_governance_epoch(store, context_id).

Known Limitations

Projection-at-cut is now authoritative

PermissionChecker::is_admin and is_authorized_with_capability use the projection at the op's causal cut as the primary source of truth. The live store is only consulted when the authorizer returns None (e.g. no apply-auth context, empty parents, or incomplete fold). The previous shadow-logging path (shadow_admin, unified_projection_divergence warnings) has been removed. Each group op replayed during retry_encrypted_ops_for_group is authorized at its own parent_op_hashes cut, not the enclosing KeyDelivery op's cut.

Multi-admin convergence

Concurrent ops from multiple admins with the same state_hash: first applied wins per node. Different nodes may pick different winners for conflicting ops. Merge Noop resolves heads but op ordering may differ. Single-admin groups have no ambiguity.

Governance epoch

State deltas carry a governance_epoch field but it's not yet used to reject stale application-level deltas from removed members.

Context-level capability enforcement

Context-level capabilities (ManageApplication, ManageMembers) are stored and can be granted/revoked via governance ops, but are not yet checked during WASM execution. ReadOnly role enforcement is platform-level (execute handler + delta ingestion), not capability-based.

Gossip publish failures

If gossip publish fails, it's currently silent. Should surface errors to callers or retry.

Key File Map

Core Governance

context/primitives/src/local_governance/mod.rs
Wire types: SignedGroupOp, GroupOp, SignableGroupOp, SignedNamespaceOp, NamespaceOp (RootOp/GroupOp). Ed25519 signing/verification, content hashing, schema v3. Also defines MemberPathAtCut — the at-cut analogue of live MembershipPath — and AclView::member_path_at_cut which returns it. Defines the AtCutAuthorizer trait (including is_admin_or_capability_at_cut and the new membership_path_at_cut), the simplified AtCutMembershipPath enum (None/Direct/Inherited) used by that method, LiveFallbackAuthorizer, and EphemeralProjectionAuthorizer. The live governance-cut resolver (acl_view_at, MembershipStatus, prefix_walk_membership, MembershipTransition, heads_equal, MAX_PREFIX_WALK_NODES) has been deleted from governance-store/src/membership/status.rs; that file now only contains role_from_invited_role and its test. Re-exports of acl_view_at and MembershipStatus have been removed from governance-store/src/membership/mod.rs, governance-store/src/lib.rs, and the group_store facade in context/src/lib.rs.
context/src/governance_dag.rs
GroupGovernanceApplier and NamespaceGovernanceApplier bridge calimero_dag: converts ops to CausalDelta, delegates apply to group_store.
context/src/group_store/mod.rs
Authoritative persistence (refactored into modules). apply_local_signed_group_op is now a shim that delegates to apply_local_signed_group_op_at_cut with the inert LIVE_FALLBACK_AUTHORIZER. apply_local_signed_group_op_at_cut is the new public entry point accepting an &dyn AtCutAuthorizer alongside the op. apply_group_op_mutations now threads parents and authorizer through to GroupApplyCtx::new_with_apply_auth. PermissionChecker gains with_apply_auth. PermissionChecker::is_admin and is_authorized_with_capability now query the projection at the op's causal cut via is_admin_at_cut / is_admin_or_capability_at_cut first; if the authorizer returns Some(verdict) that verdict is used directly without touching the live store. Only when the authorizer returns None (no apply-auth context, empty parents, or an incomplete fold) do the gates fall back to live MembershipRepository queries. The private shadow_admin helper and the associated shadow block in is_authorized_with_capability have been removed. apply_group_op_mutations uses signed_group_op.parent_op_hashes (the individual op's own parents) rather than self.parents (the enclosing cut's parents), so that each buffered group op replayed during retry_encrypted_ops_for_group is authorized at its own causal cut instead of the outermost KeyDelivery op's cut.
context/src/group_store/namespace.rs
Namespace identity resolution: resolve_namespace(), get_or_create_namespace_identity_bundle(), parent chain walking.
context/src/group_store/namespace_governance.rs
Namespace governance DAG application: apply_signed_namespace_op(), key unwrapping, skeleton storage for encrypted ops. GroupCreated apply now writes subgroup visibility and triggers redrive_buffered_ops_for_group when the node holds any key for the new group. State-hash staleness check is bypassed when subgroup GroupMeta is absent.
context/src/group_store/namespace_membership.rs
Namespace membership tracking and policy enforcement.
context/src/lib.rs
ContextManager actor. Holds per-namespace DagStores, runs recovery on startup, starts namespace heartbeat timer.
context/src/lifecycle.rs
Startup recovery: recover_in_progress_upgrades(), start_namespace_heartbeat() (30s periodic), redrive_stranded_ops_sweep() (one-shot at startup, re-drives buffered encrypted ops for all groups whose key is already held).
context/src/metrics.rs
Prometheus metrics: execution count/duration, namespace retry/decode events, membership policy rejections.

Storage Layer

store/src/key/group/mod.rs
26+ typed key prefixes for Column::Group: GroupMeta, GroupMember, OpLog, OpHead, Nonce, Context indexes, capabilities, aliases, NamespaceIdentity (0x37), NamespaceGovOp (0x38), NamespaceGovHead (0x39), GroupKeyEntry (0x3A), GroupParentRef (0x36), GroupChildIndex (0x35). Aliases were replaced by MetadataRecord rows (0x2D–0x2F).
store/src/types/group.rs
Value types: GroupOpHeadValue, GroupMetaValue, GroupUpgradeValue, NamespaceIdentityValue, NamespaceGovHeadValue.

Network Integration

node/src/handlers/network_event/namespace.rs
Namespace gossip ingestion: NamespaceGovernanceDelta, NamespaceStateHeartbeat → apply ops, trigger key delivery, or backfill via P2P streams.
node/src/handlers/network_event/specialized.rs
Specialized node discovery, TEE attestation, join confirmation handling.
node/src/key_delivery.rs
Automatic key delivery: on MemberJoined, wraps group key via ECDH and publishes RootOp::KeyDelivery.
node/src/sync/manager/mod.rs
Responds to GroupDeltaRequest and NamespaceBackfillRequest by scanning op log and sending responses.

Handlers

context/src/handlers/create_context.rs
Emits ContextRegistered + optional ContextMetadataSet as signed ops. Uses namespace identity for signing.
context/src/handlers/join_group.rs
Invitation claim flow: generates/retrieves namespace identity, subscribes to namespace topic, stream-requests join, unwraps group key, applies governance ops snapshot, auto-joins contexts.
context/src/handlers/apply_signed_namespace_op.rs
Namespace DagStore integration: feeds namespace ops into the namespace-level DAG.
context/src/handlers/apply_signed_group_op.rs
Group DagStore integration: feeds op into DAG, handles pending/applied/error states.
context/src/handlers/list_namespaces.rs
Namespace listing, querying, and identity resolution for the admin API.
context/src/handlers/upgrade_group.rs
Application upgrade propagation across group contexts.

Tests

context/src/group_store/tests.rs
Comprehensive group store tests including namespace identity, governance, key delivery, and membership policy. Approximately 12 unit tests that covered the removed acl_view_at / MembershipStatus / prefix_walk_membership code have been deleted alongside those symbols.
context/tests/local_group_governance_convergence.rs
Two-node convergence, op log, idempotency, DAG ordering, state_hash conflict prevention, cascade removal, head capping.
node/src/local_governance_node_e2e.rs
E2E: gossip message → node manager → context apply → membership verified.

Last-Admin At-Cut Gate: Trait, Shadow Logging & Policy Wiring

Overview

is_last_admin_at_cut has been added to the AtCutAuthorizer trait, completing the set of at-cut gates used by permission-checking paths. MembershipPolicy now optionally carries the op's parent cut and an authorizer reference, activated via with_apply_auth. GroupApplyCtx::new wires both through automatically, so every group-op apply path participates in last-admin shadow logging.

AtCutAuthorizer Trait

The trait now exposes three at-cut predicates:

  • is_admin_at_cut(&self, identity) → Option<bool>
  • is_admin_or_capability_at_cut(&self, identity, cap) → Option<bool>
  • is_last_admin_at_cut(&self, identity) → Option<bool>
  • membership_path_at_cut(&self, identity) → Option<AtCutMembershipPath> (new) — returns the kind of membership path the identity holds at the op's causal cut. Option::None means "defer to the live path"; AtCutMembershipPath::None means "not a member at the cut".

All four share the same empty-parents contract: every implementation must return None when the op's parent_op_hashes slice is empty. Violating this contract would cause genesis ops to be falsely rejected — the live-store fallback is always authoritative for the genesis case.

Implementation per Authorizer

  • EphemeralProjectionAuthorizer — delegates to ScopeProjections::is_last_admin_at_cut for the last-admin gate. For membership_path_at_cut, delegates to ScopeProjections::membership_path_at_cut (which calls the projection's member_path_at_cut walk using the auth-cut context) and maps the result to AtCutMembershipPath, projecting away the role/anchor detail not needed by the MemberJoinedOpen gate. Guards against empty parents before delegating; returns None if the projection fold is incomplete.
  • LiveFallbackAuthorizer — implements all methods as unconditional None, including membership_path_at_cut. This keeps all shadow paths inert for any MembershipPolicy constructed outside an apply-auth context (e.g. read-only membership queries).

MembershipPolicy Changes

Two new optional fields are added to MembershipPolicy:

  • apply_auth_parents: Option<&[DeltaId]> — the parent cut hashes of the op being applied.
  • apply_auth: Option<&dyn AtCutAuthorizer> — reference to the current authorizer.

Set both together via the builder method:

policy.with_apply_auth(parents: &[DeltaId], authorizer: &dyn AtCutAuthorizer) → Self

When these fields are absent (the default), every shadow call is a no-op, preserving the behaviour of non-apply constructions.

Last-Admin Enforcement: would_orphan_admins

The two last-admin enforcement methods on MembershipPolicy now delegate to a shared would_orphan_admins helper that resolves the blocking decision from the projection at the op's parent cut via AtCutAuthorizer::is_last_admin_at_cut:

  • ensure_not_last_admin_removal — called when a MemberRemoved op targets an admin identity.
  • ensure_not_last_admin_demotion — called when a MemberRoleSet would drop the last admin to a non-admin role.

would_orphan_admins calls authorizer.is_last_admin_at_cut(identity) and uses that verdict directly. It only falls back to the live membership computation (is_admin && !has_another_admin) when no apply-auth context is present (local pre-checks, cascades, tests) or when the projection fold is incomplete. This live fallback is a temporary measure and is noted as retiring in a follow-up.

The shadow_last_admin helper, which previously compared the projection verdict against the live verdict and emitted unified_projection_divergence / last-admin plane warnings on mismatch, has been deleted. Its purpose was to validate parity before the enforcement flip; now that the projection is authoritative it is no longer needed. Similarly, the unified_projection_divergence / membership-path warning path (previously emitted by shadow_membership_path) has also been deleted now that MemberJoinedOpen authorizes directly via the projection.

Wiring through GroupApplyCtx

GroupApplyCtx::new now calls .with_apply_auth(parents, authorizer) on the MembershipPolicy it constructs, using the same parents and authorizer already threaded into PermissionChecker. The call site is unchanged for callers; the wiring is automatic. This means:

  1. Every op applied via apply_group_op_mutations activates the last-admin shadow.
  2. Ops replayed during retry_encrypted_ops_for_group each carry their own parent_op_hashes, so the shadow is evaluated at the correct individual causal cut — not at the enclosing KeyDelivery op's cut.

Empty-Parents Contract (Genesis Safety)

The contract applies uniformly to all three trait methods. A genesis op has no parents (parent_op_hashes = []). Returning anything other than None in that case would mean the projection is evaluated against an empty fold, which is structurally unsound. The EphemeralProjectionAuthorizer guard and the LiveFallbackAuthorizer unconditional-None path both satisfy this contract. Future implementations must do the same.

Relationship to Existing At-Cut Pattern

This addition is intentionally symmetric with the existing is_admin_at_cut and is_admin_or_capability_at_cut gates on PermissionChecker. The last-admin gate now follows the same projection-authoritative model as those gates: the projection result is used directly, and the live store is only consulted as a fallback when no apply-auth context is present or the fold is incomplete. The shadow_last_admin divergence-logging path has been removed now that the flip is complete. Future changes to last-admin enforcement are localized to MembershipPolicy and would_orphan_admins — no trait changes are required.

The new membership_path_at_cut method is now used as the primary authorization source for the MemberJoinedOpen gate. member_joined_open::apply first calls NamespaceApplyCtx::projection_membership_path, which queries the authorizer for the projection's at-cut path and returns Option<AtCutMembershipPath>. If the projection returns a path (i.e. Some(...)), the gate's match branches operate on AtCutMembershipPath variants directly — the live check_path call is skipped entirely. Only when projection_membership_path returns None (no apply-auth context, incomplete fold, or the default live-only authorizer) does the gate fall back to the live MembershipRepository::check_path read. The previous NamespaceApplyCtx::shadow_membership_path helper, which accepted an eagerly-computed live_path argument and logged a tracing::warn! on divergence, has been replaced by projection_membership_path, which takes no live_path argument and returns the resolved projection path instead of logging. The divergence-comparison logic and the unified_projection_divergence / membership-path warning have been removed entirely. The helper membership_path_kind, which collapsed the live MembershipPath to AtCutMembershipPath for that comparison, is no longer needed by this path.

Note on AtCutMembershipPath: This is a simplified three-variant enum (None / Direct / Inherited) distinct from the richer live MemberPathAtCut. It collapses role and anchor detail to only the kind the MemberJoinedOpen gate needs. Option::None returned from membership_path_at_cut (and from projection_membership_path) means "defer to live"; AtCutMembershipPath::None means "not a member at the cut".