Local Group Governance
Signed gossip operations, causal DAG, capability-based authorization
Architecture
Core Concept: Groups Own Contexts
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.
SignedGroupOp Schema (v3)
Signable Payload
GroupOp Variants
Operation Flows
Create Context Flow
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.
Handler signs GroupOps
Emits a sequence: ContextRegistered → optional ContextMetadataSet. Each is a SignedGroupOp applied locally and published via gossip.
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.
Gossip propagation
Published on group/<hex> topic. Remote nodes receive via SignedGroupOpV1 broadcast, verify, and apply the same ops.
Join Group via Invitation
Admin creates invitation
create_group_invitation generates a SignedGroupOpenInvitation with optional expiration timestamp. Shared out-of-band to the joiner.
Joiner submits invitation
join_group builds GroupRevealPayloadData, signs it, submits a JoinWithInvitationClaim GroupOp.
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)
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.
Context identity resolved
The node uses its namespace identity as the context member identity. No per-context keypair generation needed.
Member Removal Cascade
Sync & Catch-up Protocol
Startup Recovery
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).
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.
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:
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.cascade_group_ids + cascade_context_ids which every peer re-enumerates and verifies for deterministic application.GroupNested + GroupUnnested pair; orphan state is structurally impossible.GroupOps (Encrypted)
Encrypted with the group’s key. Non-members store an opaque skeleton (causal structure preserved, content unknown):
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
Storage Layer
Network Integration
Handlers
Tests
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::Nonemeans "defer to the live path";AtCutMembershipPath::Nonemeans "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 toScopeProjections::is_last_admin_at_cutfor the last-admin gate. Formembership_path_at_cut, delegates toScopeProjections::membership_path_at_cut(which calls the projection'smember_path_at_cutwalk using the auth-cut context) and maps the result toAtCutMembershipPath, projecting away the role/anchor detail not needed by theMemberJoinedOpengate. Guards against empty parents before delegating; returnsNoneif the projection fold is incomplete.LiveFallbackAuthorizer— implements all methods as unconditionalNone, includingmembership_path_at_cut. This keeps all shadow paths inert for anyMembershipPolicyconstructed 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 aMemberRemovedop targets an admin identity.ensure_not_last_admin_demotion— called when aMemberRoleSetwould 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:
- Every op applied via
apply_group_op_mutationsactivates the last-admin shadow. - Ops replayed during
retry_encrypted_ops_for_groupeach carry their ownparent_op_hashes, so the shadow is evaluated at the correct individual causal cut — not at the enclosingKeyDeliveryop'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".