Context & Groups

calimero-context + calimero-context-primitives + calimero-context-config

Purpose

ContextManager is an Actix actor that manages contexts (application instances), groups (governance units), and namespace governance DAGs. Every context belongs to a group within a namespace. The manager holds per-namespace DagStores (via HashMap<namespace_id, Arc<Mutex<DagStore>>>), handles 30+ message types, coordinates with a modular GroupStore for persistence, and runs lifecycle recovery and Prometheus metrics collection.

ContextManager actor contexts cache · namespace_dags HashMap GroupStore (modular) · namespace governance 30+ Handler<ContextMessage> variants lifecycle recovery · metrics · 30s heartbeat crates/context/src/lib.rs Server / RPC ContextClient NodeManager delta routing GroupStore persistence NetworkManager gossip publish

Module Structure

calimero-context

lib.rs
ContextManager actor — startup, fields, Actix lifecycle, namespace DAG management
lifecycle.rs
Startup recovery: recover_in_progress_upgrades(), start_namespace_heartbeat() (30s periodic). ContextManager::started calls auto_follow::spawn before self_purge::spawn — this ordering is load-bearing: it ensures the auto-follow broadcast receiver exists before the curative startup sweep can emit OpEvent::ContextRegistered events, preventing those events from being silently dropped.
metrics.rs
Prometheus metrics: execution count/duration, namespace retry/decode events, membership policy rejections
group_store/mod.rs
Authoritative persistence layer (refactored into modules) — apply_local_signed_group_op
group_store/namespace.rs
Namespace identity resolution: resolve_namespace(), get_or_create_namespace_identity_bundle()
group_store/namespace_governance.rs
Namespace governance DAG application, key unwrapping, skeleton storage
self_purge.rs
Startup curative sweep: redrive_stranded_ops_sweep runs once after subscribing to op_events and before the event loop. Iterates every namespace the node has an identity for, finds groups with buffered ops whose key is already held (inverse of groups_awaiting_key), and re-drives each via redrive_encrypted_ops_for_group_counted. Loops until a full pass applies nothing new (cross-signer convergence), bounded by MAX_PASSES=64, with pass-narrowing so only groups that made progress are revisited. Errors are logged and swallowed; the sweep never blocks startup.
auto_follow.rs
auto_follow::spawn now subscribes to op_events synchronously on the caller thread before spawning the async task, passing the receiver into run(). Must be spawned before self_purge::spawn (see lifecycle.rs).
group_store/namespace_membership.rs
Namespace membership tracking and policy enforcement
group_store/membership.rs
Group membership management with parent chain walking (max depth 16)
group_store/group_keys.rs
Group encryption key management: generate, store, wrap/unwrap via ECDH. CapabilitiesRepository::set_subgroup_visibility is called during group_created::apply to write the subgroup's visibility key atomically on first apply (gated on has_subgroup_visibility absence, so it never clobbers later SubgroupVisibilitySet flips).
group_store/capabilities.rs
Per-member capability bitmask management
group_store/context_tree.rs
Context-group binding: register, detach, list contexts per group
handlers/create_group.rs
Namespace root creation (parent_group_id == None) now signs, applies, and publishes a RootOp::NamespaceCreated { founder: admin_identity } genesis op via sign_apply_and_publish_namespace_op. A NoPeersSubscribed publish error is treated as success (apply succeeded locally; publish is best-effort). Any other error triggers a full rollback of all locally-written root rows (meta, founder Admin member row, default caps, group key, signing key, optional name metadata) via individual idempotent deletes before returning Err. The namespace identity row is deliberately not rolled back so a retry gets the same identity and founder. The DAG head does not need rollback because apply_signed_op is head-atomic (the head advances only after a successful apply).
group_store/tests.rs
Comprehensive group store tests including namespace identity, governance, key delivery. Also contains dag_releases_out_of_order_ops_and_the_projection_converges: a #[tokio::test] that builds a four-op causal chain spanning the admin, membership, ACL, and data planes, feeds it through DagStore<Op> + UnifiedApplier in four delivery orders (in-order, full reverse, and two interleaved), and asserts that every op is applied (none stuck pending) and that scope_root matches the result of direct in-order folding in all cases. Also contains persisted_op_log_reloads_and_replays_to_the_same_projection: a #[tokio::test] that persists a three-op causal chain of mixed-plane ops (AdminChangedMemberAddedSetWriters) via persist_op, reloads them from a fresh store handle via load_scope_ops, replays the loaded ops through DagStore<Op> + UnifiedApplier, and asserts the reconstructed scope_root matches an independent in-memory fold of the same ops; also asserts that loading an empty scope returns zero rows.
unified_op_store.rs
Public module exposing two functions for durable unified-op persistence. persist_op borsh-serializes an Op and writes it under ScopeUnifiedOpKey in Column::UnifiedOp; the write is idempotent because the key is content-addressed by op id. load_scope_ops performs a seek-then-entries range scan over the scope prefix and deserializes all rows back into Vec<Op>. Important: rows are returned in key order, not causal order; callers must replay the loaded ops through DagStore<Op> + UnifiedApplier to recover causal ordering and projections. DAG heads are deliberately not persisted here. No production apply or sync path calls these functions yet. Exported as pub mod unified_op_store from crates/context/src/lib.rs.
unified_applier.rs
UnifiedApplier — implements DeltaApplier<Op> backed by an Arc<std::sync::RwLock<ScopeProjections>>. Its apply method acquires a write lock (held only across the synchronous ingest_op call, never across an .await), calls ScopeProjections::ingest_op with the delta's payload, and returns Ok(()). Lock poison is recovered rather than propagated, matching the stance already documented in NodeState::read_scope_projections. Two constructors: new() for a private/standalone projection, and with_projection(Arc<RwLock<ScopeProjections>>) as the C2.1 dual-write seam that attaches to the node's live shared projection. A projection() accessor exposes the Arc for inspection. Exported as pub mod unified_applier from crates/context/src/lib.rs, making UnifiedApplier part of the crate's public API.
projection_membership_equivalence.rs
Equivalence tests asserting member_at_cut_authoritative agrees with the live resolver: (1) projection_matches_live_across_inherited_join_and_root_removal — after an inherited join, member_at_cut_authoritative returns Some(true); after root removal that live rejects, it must NOT return Some(true) (over-grant guard); (2) projection_matches_live_across_leave_and_rejoin_inheritance — after a leave-and-rejoin cycle, member_at_cut_authoritative restores access
governance_dag.rs
DAG bridge — GroupGovernanceApplier + NamespaceGovernanceApplier. The standalone GroupGovernanceApplier::apply continues to call the plain apply_local_signed_group_op (live-fallback gates); a code comment documents why: a SignedGroupOp's parent_op_hashes belong to the per-group op log, not the namespace governance log the projection is keyed by, so passing them to EphemeralProjectionAuthorizer would resolve against the wrong id-space and silently no-op. Defines the AtCutAuthorizer trait with three required methods: is_admin_at_cut, is_admin_or_capability_at_cut, and the newly added is_last_admin_at_cut. All three must return None when parents is empty — this is the empty-cut contract enforced across every method: violating it would falsely reject genesis ops by evaluating against an empty fold. Both LiveFallbackAuthorizer and EphemeralProjectionAuthorizer implement the trait. LiveFallbackAuthorizer implements all three returning None. EphemeralProjectionAuthorizer now holds a Mutex<Option<(ContextGroupId, Arc<FoldedProjection>)>> cache; its private folded method checks the cache before calling ScopeProjections::ephemeral_projection, reusing the folded DAG for the same group on a cache hit. All three trait methods go through folded. EphemeralProjectionAuthorizer::is_last_admin_at_cut delegates to ScopeProjections::is_last_admin_at_cut, guarding against empty parents before folding. The real group-auth shadow only fires on the namespace-envelope group-op path, not the standalone DAG applier path.
scope_projection.rs
Shadow unified-op projection — op_from_signed_namespace_op() derives projection ops from namespace governance deltas; folded into scope projections on DAG apply (additive, not yet authoritative). ScopeProjections::namespace_current_heads resolves a ContextGroupId to its owning namespace, reads that namespace's DAG head record, and returns the parent hashes — the "now" frontier used as the cut argument for current-state membership reads via the projection. Returns None when the group cannot be resolved or the head record is unreadable; callers should fall back to live membership in that case. Per-query projection helpers (ephemeral path): ScopeProjections::member_now_ephemeral builds a fresh ScopeProjections from the store on each call — resolves the namespace, folds the governance DAG via ops_for_namespace (the stable indirection layer that currently delegates to collect_namespace_ops), then reads member_at_cut at the heads recorded after the fold (so a concurrent governance-op advance cannot name ops the fold hasn't processed). Returns None when the namespace is unresolvable, when ops_for_namespace reports a store fault (surfaced with a warn), or when the cut-completeness guard finds the heads don't cover the full fold. Intended for context-layer handlers that lack a cached projection. ScopeProjections::member_now_checked is the single decision point for all membership gates. It calls member_now_ephemeral first; when the projection can decide it acts on that verdict and cross-checks MembershipRepository::is_member best-effort (a transient live error does not override a projection verdict). When projection and live definitely disagree it emits a tracing::warn with marker=unified_projection_divergence and plane=membership-query. When the projection returns None it falls back to live as authoritative and propagates live's error normally. ScopeProjections::is_last_admin_at_cut answers, at the op's parent cut (pre-mutation), whether removing or demoting the given member would orphan the group's admin set. It mirrors live logic exactly: a member counts as admin if they hold a direct Admin-role row or are the genesis admin; another admin requires a distinct Admin-role row (the genesis admin alone does not satisfy it). Returns None when ancestry is incomplete, deferring to live. The causal-position semantics are intentional: because the read is at the parent cut rather than current state, the result can legitimately differ from a live current-state read under concurrency — this is the causal-honor property, not a bug. ScopeProjections::membership_path_at_cut calls the projection's member_path_at_cut walk using the auth-cut context, returning Option<calimero_authz::MemberPathAtCut>. Returns None when the parents slice is empty (empty-cut contract), when the namespace is unresolvable, or when the fold is incomplete. ScopeProjections::role_at_cut_for_group resolves the effective role of a member in a group at the governance cut. It uses member_path_at_cut (the same derivation already validated by the membership-role plane) to handle three cases: direct membership (folded role row), inherited-via-admin (always Admin), and inherited-via-anchor (anchor's role row). It gates on cut_ancestry_complete via auth_cut_context and returns None rather than guessing when the anchor row is missing, preventing spurious divergence reports. Returns None when ancestry is incomplete, the member is absent from the projection, or the namespace is unresolvable — callers should fall back to live role reads in those cases. ScopeProjections::scope_root_for computes a governance-folded convergence root for a given scope as SHA-256(entities_root ‖ acl_hash ‖ governance_hash). Returns None when the scope has not been folded yet; callers must treat a None return as "cannot compare" rather than a divergence — never as evidence of mismatch. The ACL component is currently empty because SetWriters ops are not yet routed through the governance DAG; this will change at C2. ScopeProjections::group_scope_root_ephemeral is a companion free function that builds an ephemeral ScopeProjections from the raw store (no NodeState required) and adds an ancestry-completeness guard: if the governance DAG cut is not fully folded it returns None to avoid false divergences from mid-backfill nodes. ScopeProjections::ops_for_namespace is the stable indirection point for all projection-rebuild call sites (backfill_namespace, member_now_ephemeral/ephemeral_fold, and the scope_root reconstruction). It currently delegates to collect_namespace_ops (the governance-DAG walk). The C2.2b flip — switching to load_scope_ops (the unified op-store) — is explicitly deferred: an encrypted MemberAdded op can be applied to the governance DAG before its group key arrives; the apply-time dual-write freezes it as Noop in the op-store, while collect_namespace_ops re-decrypts at read time once the key lands. Reading from the op-store directly would therefore silently drop those members and break joiner sync. The C2.2c fix for this is ScopeProjections::repersist_namespace_ops (see below). ScopeProjections::repersist_namespace_ops repairs the op-store after a group key is delivered. It re-walks a namespace through collect_namespace_ops (the canonical read-time decryption path) and calls persist_op for every op found. Because ops are content-addressed by the same op id, a now-decryptable op (e.g. MemberAdded) overwrites the stale Noop entry that was frozen at apply time when the key was absent. The method is best-effort (errors are logged, not propagated), idempotent, and bounded by the same MAX_BACKFILL_OPS walk cap used elsewhere. It should be called after any group key delivery to ensure the op-store remains consistent with the governance-DAG fold. ScopeProjections::persist_namespace_head_ops is a new public method that mirrors locally-authored governance ops into the unified op-store immediately after they are published. Rather than walking only the DAG heads (which would be racy — a concurrent peer op applied during the author's publish round-trip can advance the head, orphaning the just-authored op from a heads-only walk), the method delegates to repersist_namespace_ops, which performs a full gov-DAG fold bounded by MAX_BACKFILL_OPS. The call is idempotent and best-effort; failures are logged and never affect authoring success. It is wired into four handlers: create_group (after GroupCreated is successfully reported), delete_group (after GroupDeleted is applied), join_context (after MemberJoinedOpen is published), and join_subgroup_inheritance (after MemberJoinedOpen is published). In each case the call is a no-op if the preceding apply or publish step errored. ScopeProjections::scope_root_from_op_store provides the op-store's view of a scope's governance root. It loads all persisted ops for a given ScopeId via load_scope_ops, folds them into a fresh ScopeProjections instance, and returns the governance scope_root using a fixed zero entities_root. Returns Ok(None) when no ops exist for the scope (empty scope is not an error), and Err on store faults. Intended for completeness verification before C2.2b — it answers "does the op-store reconstruct to the same governance root as the maintained DAG projection?" ScopeProjections::check_op_store_completeness is a diagnostic-only observe gate that compares an already-computed gov-DAG fold (dag_ops) against the unified op-store. It extracts the op-ids from dag_ops, loads the persisted op-ids for the same scope via load_scope_ops, and emits a warn-level tracing event with marker op_store_incomplete (carrying missing_count, dag_count, op_store_count, and a hex-encoded sample_missing id) for every op present in the gov-DAG but absent from the store. If the op-store cannot be loaded at all it emits a distinct op_store_gate_unavailable marker and returns None — a store fault cannot masquerade as a verified-clean result. Return semantics: None = completeness unverifiable (store unreadable), Some(vec![]) = verified complete, Some(ids) = those op-ids are missing from the store. This gate is observe-only and retires at C3 Stage 4 when the projection read flips onto the op-store.

Note: ScopeProjections::shadow_compare_op_store and scope_root_from_governance_dag have been removed. The observe-only cross-check in refresh_projection_for_cut that triggered a full governance-DAG fold on every backfill has also been deleted. That comparison was flawed: the maintained projection holds live ingest_op-fed ops that may not be in the op-store, so a gap in the op-store could be masked by the live apply path and go undetected. The unified_op_store_divergence log marker is no longer emitted.

Note: The live governance-cut membership resolver (acl_view_at), the MembershipStatus enum (Member/Removed/NeverMember/Unknown), prefix_walk_membership, the MembershipTransition state machine, heads_equal, MAX_PREFIX_WALK_NODES, and resolve_membership_from_transitions have all been deleted from governance-store/src/membership/status.rs. Membership authorization is now handled entirely by the unified projection (ScopeProjections::member_now_checked). Re-exports of these symbols have been removed from governance-store/src/membership/mod.rs, governance-store/src/lib.rs, and context/src/lib.rs.

handlers/
30+ handler files, one per ContextMessage variant (create, join, delete, execute, namespace ops, etc.). Membership gates in get_cascade_status, get_group_upgrade_status, list_group_contexts, and list_all_groups now call ScopeProjections::member_now_checked instead of MembershipRepository::is_member directly — the direct MembershipRepository import has been removed from those four modules. list_all_groups silently skips groups whose membership cannot be determined (a tracing::warn is emitted) rather than aborting the entire listing.

calimero-context-primitives

client/mod.rs
ContextClient typedef — thin async façade wrapping LazyRecipient<ContextMessage>
client/sync.rs
Sync-related client helpers (group delta request/response coordination)
client/context_api.rs
Context-level API methods (create, join, delete, execute, config queries)
client/crypto.rs
Cryptographic helpers (signing, key derivation, namespace identity)
local_governance/mod.rs
Wire types: SignedGroupOp, GroupOp, SignedNamespaceOp, NamespaceOp — Ed25519, borsh, schema v3. ⚠ WIRE BREAK: RootOp::GroupCreated gains a boolean restricted field; nodes must reset. restricted: true is the default and preserves legacy behavior. The op-adapter projection reads restricted from the live op instead of hardcoding true. A born-Open subgroup (restricted: false) is already Open when tee_subgroup_admit reacts, so no transient direct ReadOnlyTee row is written. CreateGroupRequest gains a restricted field (default true); the REST endpoint accepts an optional visibility field ("open"|"restricted", default "restricted").
local_governance/tests.rs
Unit tests for governance wire types and signing
group.rs
Actix messages — group-level and namespace-level message types for actor communication
messages.rs
ContextMessage enum — top-level message envelope with 30+ variants (including namespace ops)

calimero-context-config

client_config.rs
Configuration for context client connections and transport settings
types.rs
Shared configuration types used across context crates

ContextManager Actor

The central actor for all context and group operations. Initialized on node start, it rebuilds in-memory state from persistent storage and starts background coordination tasks.

Key Fields

pub struct ContextManager {
    store: Arc<Store>,
    node_client: NodeClient,
    context_client: ContextClient,
    group_dags: HashMap<GroupId, DagStore>,
    contexts: HashMap<ContextId, ContextState>,
    upgrade_propagators: HashMap<GroupId, UpgradeProp>,
    // ... network, runtime refs
}

Startup Sequence

1

recover_in_progress_upgrades

Resume any application upgrades that were interrupted by a previous shutdown.

!

auto_follow::spawn (before self_purge)

Subscribes to op_events on the caller thread and spawns the auto-follow task. Must run before self_purge::spawn so the receiver is live before the curative sweep emits OpEvent::ContextRegistered.

!

redrive_stranded_ops_sweep (self_purge)

One-shot curative sweep: for every namespace the node has an identity for, finds groups with buffered ops whose key is already held and re-drives them via redrive_encrypted_ops_for_group_counted. Loops up to MAX_PASSES=64 until nothing new is applied.

2

reload_group_dags

For each group in the store, read the full OpLog and rebuild the in-memory DagStore via restore_applied_delta.

3

start_group_heartbeat (30s)

Periodic timer publishes GroupStateHeartbeat with current dag_heads for every group, triggering catch-up on peers.

Actor::started() recover upgrades reload DAGs start heartbeat OpLog (storage) per-group sequence borsh(SignedGroupOp) DagStore (memory) restore_applied_delta rebuild heads + pending Heartbeat (30s) GroupStateHeartbeat broadcast dag_heads

Handler Map

All 20+ ContextMessage variants, grouped by function. Each variant is handled by a dedicated file in handlers/.

Context Lifecycle

CreateContext
Create a new context, bind to group, emit ContextRegistered ops
JoinGroupContext
Join a context within a group (requires group membership)
DeleteContext
Remove context and clean up all associated state
Execute
Run WASM method, produce state delta, broadcast
UpdateApplication
Upgrade WASM binary for a context

Group Governance

AddGroupMembers
Add one or more members to a group
RemoveGroupMembers
Remove members with cascade to all contexts
ApplySignedGroupOp
Ingest a signed governance operation into the DAG
SetMemberCapabilities
Grant or revoke per-member capability flags
UpdateMemberRole
Change member role (admin, member, etc.)
UpgradeGroup
Propagate application upgrade across group contexts

Membership & Access

JoinGroup
Join a group via invitation claim flow
JoinGroupContext
Join a specific context within a group
CreateGroupInvitation
Generate a SignedGroupOpenInvitation
SetDefaultCapabilities
Default capability flags for new group members

Configuration & Sync

SyncGroup
Trigger group state synchronization with peers
SetGroupAlias
Set human-readable alias for a group
SetMemberAlias
Set human-readable alias for a member
DeleteGroup
Delete group and cascade to all owned contexts
DetachContextFromGroup
Unbind a context from its group
ListGroupMembers
Enumerate effective group members. Reads from ScopeProjections::member_entries_with when a folded projection context is available; falls back to the live MembershipRepository::list ∪ enumerate_inherited union when the projection returns None. Both paths are sorted by PublicKey before skip(offset).take(limit) to guarantee stable, path-independent pagination order across a mid-backfill transition.
Caller Server, NodeMgr, SyncManager ContextMessage enum dispatch 20+ variants Context Lifecycle (5) Group Governance (6) Membership & Access (7) Config & Sync (5) handlers/ one file per variant async fn handle(&mut self, ...)

Projection-Backed Member Count & Migration Cohort

Two operations now derive their member sets from the governance projection fold rather than the live store, with a live fallback when the projection is unavailable.

GetGroupInfo — member_count

Handler<GetGroupInfoRequest> computes member_count as the length of the identity set returned by member_identities_with on the already-built projection fold, instead of calling MembershipRepository::count + MembershipRepository::enumerate_inherited directly. The shadow divergence log that previously compared both paths is removed. If member_identities_with returns None (unfed namespace or incomplete ancestry fold), the handler falls back to the old live count + enumerate_inherited path.

collect_migration_cohort

collect_migration_cohort calls member_identities_subtree_ephemeral at the top and returns its result immediately on success, bypassing the live list ∪ enumerate_inherited loop entirely. The previous shadow comparison block is removed. The live path is retained only as a fallback when the projection returns None.

Governance Preflight & Signing Key Resolution

Every governance mutation handler calls governance_preflight() before signing and publishing. The common pattern is encapsulated in sign_and_publish_group_op() which handles the boilerplate.

governance_preflight Flow

1
Resolve requester identity — from explicit parameter, or fall back to node_namespace_identity() which walks the parent chain to the namespace root
2
Verify group existsload_group_meta() must return Some
3
Admin check (optional) — require_group_admin() if require_admin=true
4
Resolve signing key via ancestor walkresolve_group_signing_key() checks self, then walks parent chain up to MAX_NAMESPACE_DEPTH (16) levels. Returns the first signing key found for the requester identity.

Signing Key Hierarchy

When a child group is created via create_group_in_namespace, the signing key is stored only at the namespace root. Child groups resolve signing authority by walking up the parent chain:

// resolve_group_signing_key (signing_keys.rs)
for _ in 0..MAX_NAMESPACE_DEPTH {
    if let Some(sk) = get_group_signing_key(store, &current, public_key)? {
        return Ok(Some(sk));
    }
    current = get_parent_group(store, &current)?; // walk up
}
get_group_signing_key(store, &current, public_key) // final check at root

Key revocation at the namespace level automatically propagates — child groups lose access because the ancestor walk finds nothing. No stale copies to clean up.

sign_and_publish_group_op Helper

Encapsulates the governance handler boilerplate: preflight → clone datastore/node_client → build signer → sign, apply, publish via async actor response. Used by 7 handlers (set_group_alias, set_default_capabilities, set_default_visibility, update_group_settings, set_member_alias, set_member_capabilities, update_member_role).

GroupStore Deep-Dive

The authoritative persistence layer for all group and governance state. Handles signature verification, state-hash optimistic locking, nonce-based idempotency, DAG maintenance, and cascading side effects.

apply_local_signed_group_op Flow

1

Cap parent_op_hashes

Ensure parent hashes reference valid DAG heads (capped at 64 concurrent heads).

2

verify_signature

Ed25519 signature verification on the signable_bytes of the operation.

3

check state_hash

Causal-ancestry guard — the op's state_hash is checked for consistency with the current computed group state hash. Concurrent ops from different admins that were each signed against the same genesis state are both accepted; the nonce window deduplicates replays of already-seen ops without changing member count or state hash. The integration test state_hash_mismatch_does_not_reject_concurrent_ops verifies convergence: two admins independently add different members against the same genesis state, the ops are applied in opposite order on two nodes, and both nodes converge to an identical four-member set with equal compute_state_hash values.

4

check nonce (idempotent on duplicate)

Per-signer monotonic nonce. If the nonce was already seen, the op is silently treated as idempotent (no error, no re-apply).

5

dispatch GroupOp

Match on the GroupOp variant and apply the state mutation (add member, set visibility, cascade removal, etc.).

6

append OpLog

Serialize the SignedGroupOp via borsh and append to the persistent op log with an incrementing sequence number.

7

recompute dag_heads

Update the set of DAG head hashes — the new op becomes a head, its parents are removed from the head set.

8

set nonce

Record the signer's nonce for replay protection.

Key Storage Prefixes

All stored in Column::Group with typed key prefixes:

GroupMeta
Group metadata (name, created_at)
GroupMember
Member records per group
GroupOpLog
Persisted signed operations
GroupOpHead
Current DAG head hashes
GroupLocalGovNonce
Per-signer monotonic nonce
GroupContextIndex
Context → group mapping
ContextGroupRef
Reverse group → context ref
GroupMemberContext
Per-member context tracking
GroupCapability
Member capability grants
GroupAlias
Human-readable group alias
MemberAlias
Human-readable member alias
sign_apply_and_publish helper

Convenience function used by most handlers to emit governance ops. Combines three steps into one call:

async fn sign_apply_and_publish(
    &self,
    group_id: GroupId,
    op: GroupOp,
) -> Result<()> {
    // 1. Sign op with node's Ed25519 key
    // 2. apply_local_signed_group_op (persist + DAG)
    // 3. Publish via gossipsub on group topic
}
GroupOp incoming op verify + check sig · state_hash nonce · parents dispatch match GroupOp variant persist OpLog + heads + nonce DAG updated new heads computed

Membership Gate: Projection-First Decision Flow

Four handlers (GetCascadeStatus, GetGroupUpgradeStatus, ListGroupContexts, ListAllGroups) previously called MembershipRepository::is_member directly. They now route through ScopeProjections::member_now_checked, which implements a projection-first, live-fallback decision with divergence telemetry:

1
member_now_ephemeral — builds a fresh ScopeProjections from the store, folds the namespace governance DAG, and reads member_at_cut at post-fold heads. Returns None on store faults, unresolvable namespace, or incomplete fold coverage.
2
Projection verdict — when member_now_ephemeral returns Some(verdict), that verdict is used as the gate decision. Live membership (MembershipRepository::is_member) is still consulted best-effort for cross-checking; a transient live error does not override the projection verdict.
3
Divergence logging — when the projection and live results definitely disagree, a tracing::warn is emitted with marker=unified_projection_divergence and plane=membership-query.
4
Live fallback — when the projection returns None (undecidable), MembershipRepository::is_member is used as authoritative and its errors propagate normally — except in list_all_groups, where errors cause the group to be skipped with a tracing::warn rather than aborting the whole listing.

ContextClient

Thin async façade wrapping LazyRecipient<ContextMessage>. Used by Server (for RPC execution) and NodeManager (for delta routing) to invoke context-level operations without importing Actix directly.

pub struct ContextClient {
    recipient: LazyRecipient<ContextMessage>,
}

impl ContextClient {
    pub async fn send(&self, msg: ContextMessage) -> Result<ContextResponse>;
}

Context Operations

create_context
Create new context bound to group
delete_context
Remove context and clean up state
execute
Run WASM method, produce delta (auto-resolves executor identity)
get_context
Query context metadata

Group Operations

add_group_members
Add members to a group
remove_group_members
Remove members with cascade
apply_signed_group_op
Ingest signed op into group DAG
apply_signed_namespace_op
Ingest signed op into namespace DAG
set_member_capabilities
Set capability flags for member

Membership

join_group
Join group/namespace via invitation (generates namespace identity, unwraps group key)
create_group_invitation
Generate invitation token
list_namespaces
List namespaces with identity info
get_namespace_identity
Get this node's namespace identity public key

Configuration

sync_group
Trigger group sync with peers

Query

context_config
Get context configuration
get_context_application
Get WASM app metadata
get_context_member_page
Paginated member listing

Operator Notes

Startup curative sweep

On startup the node automatically re-drives any buffered encrypted ops that are already decryptable but were never applied due to a missing GroupCreated event (e.g., arrived out of order before the key was held). The sweep runs inside self_purge::run, iterating all known namespace identities (NamespaceRepository::iter_identities) and finding groups with buffered ops whose key is already held via NamespaceRetryService::groups_with_held_key_buffered_ops. It is bounded to MAX_PASSES=64 and never blocks startup on error.

Spawn ordering: auto_follow::spawn is called before self_purge::spawn in ContextManager::started. This is load-bearing — if reversed, OpEvent::ContextRegistered events emitted by the sweep may be dropped by the broadcast channel before the auto-follow receiver exists.

Namespace root creation — NamespaceCreated genesis op

When a namespace root group is created (parent_group_id == None), the handler now signs, applies, and publishes a RootOp::NamespaceCreated { founder: admin_identity } genesis op via sign_apply_and_publish_namespace_op. A NoPeersSubscribed error from publish is treated as success — the apply succeeded locally and publish is best-effort. Any other error from the genesis apply is fatal: all locally-written root rows (meta, founder Admin member row, default caps, group key, signing key, optional name metadata) are rolled back via individual idempotent deletes (MetaRepository::delete, MembershipRepository::remove_member, CapabilitiesRepository::delete_default, GroupKeyring::delete_key_by_id, SigningKeysRepository::delete_key, MetadataRepository::delete_group) before returning Err. The namespace identity row is deliberately not rolled back so a retry gets the same identity and founder. The DAG head requires no rollback because apply_signed_op is head-atomic — the head advances only after a successful apply.

RootOp::GroupCreated wire change

⚠ WIRE BREAK: RootOp::GroupCreated now carries a boolean restricted field. Existing nodes must reset their op logs. restricted: true is the default and preserves all legacy behavior. A group created with restricted: false is "born-Open": group_created::apply writes the subgroup's visibility key via CapabilitiesRepository::set_subgroup_visibility during apply (before OpEvent::SubgroupCreated is queued, and only on first apply), so tee_subgroup_admit sees the group as already Open and skips writing a transient direct ReadOnlyTee row.

Two log signals are emitted by the list_group_members projection shadow and are relevant for monitoring:

  • unified_projection_divergence / plane=membership-role — a live member's role differs from the projected role. These are pre-flip validation failures: once the divergence rate reaches zero, the handler can be flipped to authoritative projection. Persistent warnings indicate a bug in role derivation or ACL fold logic.
  • When member_roles_for abstains (group unfolded or absent from view.groups) the role shadow is skipped entirely for that group, consistent with the identity shadow's materialized-fallback parity behavior.

UnifiedApplier

UnifiedApplier is the C2.0 projection-backing applier. Use UnifiedApplier::new() for standalone replay or tests (creates a private ScopeProjections), and UnifiedApplier::with_projection(arc) to attach to the node's live shared ScopeProjections during dual-write (C2.1 seam). The std::sync::RwLock is intentional — the lock is never held across an .await. Poison is recovered, not panicked, matching the node's existing projection-reader stance. The projection() accessor returns the underlying Arc for inspection by callers (e.g., tests asserting convergence via scope_root).

ops_for_namespace / collect_namespace_ops / repersist_namespace_ops

ScopeProjections::ops_for_namespace is the single stable dispatch point for loading ops at every projection-rebuild site. All internal callers — backfill_namespace, member_now_ephemeral/ephemeral_fold, and the scope_root reconstruction — call ops_for_namespace rather than collect_namespace_ops directly. Today ops_for_namespace simply delegates to collect_namespace_ops (the governance-DAG walk). The flip to load_scope_ops (unified op-store) is deferred; see the C2.2b deferral note in scope_projection.rs above for the root cause.

ScopeProjections::repersist_namespace_ops is the C2.2c bridge that keeps the op-store consistent after a group key is delivered. After key delivery, collect_namespace_ops can now decrypt ops that were previously frozen as Noop in the op-store. Calling repersist_namespace_ops re-walks the namespace via collect_namespace_ops and calls persist_op for each op, atomically overwriting stale Noop entries with their fully-decrypted forms. The operation is idempotent (content-addressed keys), best-effort (errors logged, not propagated), and bounded by MAX_BACKFILL_OPS. It should be invoked after every successful group key delivery to ensure the op-store remains reconstruction-accurate.

ScopeProjections::persist_namespace_head_ops is the local-author companion to repersist_namespace_ops. It is called immediately after a locally-authored governance op is published to mirror that op into the unified op-store without racing against concurrent peer advances. Because walking only the current DAG heads would be racy (a peer op can advance the head during the author's publish round-trip, orphaning the just-authored op), the method delegates to repersist_namespace_ops for a full gov-DAG fold bounded by MAX_BACKFILL_OPS. The call is idempotent and best-effort — failures are logged and never block the authoring response. Callers: create_group (after GroupCreated is successfully reported), delete_group (after GroupDeleted is applied), join_context (after MemberJoinedOpen is published), and join_subgroup_inheritance (after MemberJoinedOpen is published). The new test persist_namespace_head_ops_lands_locally_authored_op_in_the_op_store simulates the local-author gap (an encrypted MemberAdded present in the gov-DAG but absent from the op-store), calls persist_namespace_head_ops, and asserts the op appears in the op-store and that folding the op-store projection yields member_at_cut returning Some(true) — proving the encrypted payload was decoded correctly, not just that the op ID was stored.

unified_op_store (persist_op / load_scope_ops)

unified_op_store::persist_op and unified_op_store::load_scope_ops are the durable persistence surface for unified ops. persist_op is idempotent: writing the same op twice is safe because the key is content-addressed by op id — this is the property that makes repersist_namespace_ops safe to call multiple times. load_scope_ops returns rows in key order, not causal order — callers must replay the returned Vec<Op> through DagStore<Op> + UnifiedApplier to recover causal ordering and a valid projection. DAG heads are deliberately not persisted by this module. No production apply or sync path uses these functions yet; they are available for future backfill and crash-recovery paths.

scope_root_for / group_scope_root_ephemeral / scope_root_from_op_store

ScopeProjections::scope_root_for and its companion group_scope_root_ephemeral are public API on ScopeProjections. Important caller contract: a None return must always be treated as "skip comparison", never as divergence. The ancestry-completeness guard in group_scope_root_ephemeral means mid-backfill nodes will return None rather than a spurious mismatch. The unit test scope_root_for_moves_on_hash_neutral_membership_change_and_is_none_when_unfolded holds entities_root fixed, ingests an AdminChanged op then a causally-chained MemberAdded op, and asserts the scope root differs before and after; it also asserts None is returned for an unfolded scope.

ScopeProjections::scope_root_from_op_store reconstructs a governance scope root purely from persisted ops (via unified_op_store::load_scope_ops) without relying on any maintained in-memory DAG. Returns Ok(None) for scopes with no persisted ops. Two assertions have been added to the existing persisted_op_log_reloads_and_replays_to_the_same_projection test: (1) scope_root_from_op_store equals the expected in-memory fold result after ops are persisted; (2) scope_root_from_op_store returns None for a scope that has no persisted ops.

Note: ScopeProjections::shadow_compare_op_store has been removed from the hot path. The C2.2c regression gate is the enabled test op_store_reconstruction_recovers_late_decrypted_membership_after_key_delivery in tests/op_store_reconstruction.rs (formerly op_store_reconstruction_matches_governance_dag_for_late_decrypted_membership, previously #[ignore]d). The test is structured as an explicit before/after: it first asserts the op-store holds a frozen Noop (Some(false)) before re-persist, then calls repersist_namespace_ops, then asserts the op-store reconstruction matches the governance-DAG fold (Some(true)). The #[ignore] attribute and its explanatory message have been removed; the test now runs in CI as a live regression gate for the C2.2c fix.

A separate completeness-gate test completeness_gate_flags_governance_ops_missing_from_the_op_store in tests/op_store_reconstruction.rs verifies check_op_store_completeness end-to-end: it builds three governance ops, persists only two to the op-store, calls check_op_store_completeness with all three as the dag-fold, and asserts the return is Some(vec![op3.id]) (one gap). It then persists the third op and asserts the return becomes Some(vec![]) (verified complete), confirming that the None vs empty-vec distinction works correctly and that the gate accurately tracks store coverage.

AtCutAuthorizer trait

Implementors of AtCutAuthorizer must provide three required methods. The empty-cut contract applies to all of them: every method must return None when parents is empty — violating this would falsely reject genesis ops by evaluating against an empty fold.

  • is_admin_at_cut — returns whether a member holds admin authority at the given DAG cut.
  • is_admin_or_capability_at_cut — returns whether a member holds admin authority or a specific capability flag at the given cut. LiveFallbackAuthorizer returns None. EphemeralProjectionAuthorizer delegates to the cached fold via the private folded helper.
  • is_last_admin_at_cut — newly required; returns whether the given member is the last admin at the given DAG cut, i.e., whether removing or demoting them would orphan the group's admin set. A member counts as admin if they hold a direct Admin-role row or are the genesis admin; another admin requires a distinct Admin-role row. LiveFallbackAuthorizer returns None. EphemeralProjectionAuthorizer delegates to ScopeProjections::is_last_admin_at_cut, guarding against empty parents before folding. Returns None when ancestry is incomplete, deferring to live.
  • membership_path_at_cut — newly required; returns the membership path for a given member at the DAG cut, as an AtCutMembershipPath. An empty parents slice returns None (defer to live), consistent with the empty-cut contract. LiveFallbackAuthorizer returns None unconditionally. EphemeralProjectionAuthorizer delegates to ScopeProjections::membership_path_at_cut, which calls the projection's member_path_at_cut walk using the auth-cut context and returns Option<calimero_authz::MemberPathAtCut>; the result is then mapped to AtCutMembershipPath, projecting away the role/anchor detail.
  • ScopeProjections::role_at_cut_for_group — node-side accessor that derives the effective role of a member in a group at the governance cut. Uses member_path_at_cut to handle three cases: direct membership (returns the folded role row), inherited-via-admin (always returns Admin), and inherited-via-anchor (returns the anchor's role row). Gates on cut_ancestry_complete via auth_cut_context. Returns None when ancestry is incomplete, the member is absent from the projection, the anchor row is missing (to prevent spurious divergence reports), or the namespace is unresolvable — callers must fall back to live role reads in those cases. (Note: acl_view_at and MembershipStatus have been removed; this projection-based accessor is the sole membership-authorization path.)

Note: the group-auth shadow that uses EphemeralProjectionAuthorizer only fires on the namespace-envelope group-op path. The standalone GroupGovernanceApplier::apply retains plain live-fallback gates (see governance_dag.rs above).

Dependencies

How the three context crates relate to other Calimero crates. Arrows show compile-time dependencies.

calimero-context ContextManager actor + handlers calimero-context-primitives ContextClient + messages + wire types calimero-context-config client config + types store dag node-primitives network-primitives primitives authz authorization governance-types shared gov types op op types op-adapter op adaptation projection scope projection actix tokio borsh ed25519-dalek serde
Context crates
Storage
Node
Network
Primitives
New dependencies (authz, governance-types, op, op-adapter, projection)
External crates