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