Node Crate

calimero-node + calimero-node-primitives

Purpose

NodeManager is an Actix actor that orchestrates the entire node. It receives network events via a dedicated channel bridge, manages blob caching, periodic heartbeats, delta handling, and routes streams to the sync manager.

It does not run libp2p directly — NetworkManager does that. NodeManager communicates with the network through NodeClient, which wraps a LazyRecipient<NodeMessage> and a NetworkClient handle. The crate also contains the SyncManager (a long-lived async task, not an Actix actor) and a GarbageCollector actor for storage tombstone cleanup.

2
crates
30+
source files
3
actors / tasks
4
sync protocols

Module Structure

File layout across the two crates. Each handler lives in its own focused file following the Single Responsibility Principle.

calimero-node

NodeManager actor definition, Actix lifecycle hooks
NodeManager struct with SRP decomposition: NodeClients, NodeManagers, NodeState
Startup hooks: setup_startup_subscriptions(), setup_maintenance_intervals(), setup_hash_heartbeat_interval()
NodeState: blob cache (multi-phase LRU eviction), delta stores (DashMap), sync sessions, pending invites
start() entry point, NodeConfig, wires Store / BlobManager / actors / server / GC
Handler<NodeMessage> dispatch — routes GetBlobBytes, specialized node invites
Handler<NetworkEvent> — gossip, stream, subscription, broadcast message routing
Namespace event handlers: handle_namespace_governance_delta(), handle_namespace_state_heartbeat(), backfill protocol
Specialized node: discovery, TEE attestation, join confirmation
handle_state_delta — DeltaStore buffering, context routing, sync session awareness, bounded KeyDelivery wait; gossip write-auth now delegated entirely to authorize_delta_at_edge_projected (projection-only, no live resolver called); refresh_projection_for_cut (DAG-walk/backfill for projection reads; calls ops_for_namespace — the stable indirection layer that currently delegates to collect_namespace_ops (governance-DAG walk) and will be flipped to load_scope_ops once late-decrypted membership is resolved; after ops_for_namespace returns the gov-DAG fold, check_op_store_completeness is called with those ops as a purely diagnostic gate — one op-store read on cold/refresh cuts (warm cuts skip via namespace_to_refresh returning None) — whose result is ignored by the caller and never affects whether or how apply_backfill proceeds; the observe-only shadow_compare_op_store cross-check and its companion scope_root_from_governance_dag have been removed — the comparison was flawed because the maintained projection holds live ingest_op-fed ops that may not be in the op-store); resolve_cut_membership (pub(crate) — single shared projection verdict renderer: performs a scoped projection refresh, then reads both member_at_cut and role_at_cut_for_group under a single read guard for epoch consistency, returns a CutMembership value; used by the gossip apply path, parent-fetch in request_missing_deltas, snapshot-replay in replay_buffered_delta, and DAG-catchup in SyncManager); projection_member_at_cut (pub(crate) — delegates to resolve_cut_membership and discards the role, ensuring the two paths cannot drift; used by SyncManager::peer_is_group_member); drain_governance_pending — re-applies, drops, or re-buffers buffered deltas based on three projection outcomes (Some(true)→apply, Some(false)→drop, None→re-buffer); the live authorize_delta_at_edge function is deleted — no acl_view_at calls remain on any delta authorization path
StateDeltaActor on dedicated Arbiter, bounded mailbox, drop-on-overflow with rebroadcast recovery
SyncSessionActor on dedicated Arbiter — both initiator and responder sync sessions; bounded mailbox + semaphore (max_concurrent), per-session timeout
Group-key delivery (pull-based): on a join request the responder validates the invitation and serves the ECDH-wrapped key (handle_namespace_join_requestbuild_group_key_delivery); joiners recover missing keys via recover_missing_group_keys. After a successful direct key delivery and the existing drain/reconcile steps, ScopeProjections::repersist_namespace_ops is called for the affected namespace_id so that any previously frozen Noop ops (encrypted MemberAdded entries that could not be decrypted at ingest time) are re-decrypted and written into the op-store. Because recover_missing_group_keys is the single runtime key-delivery chokepoint, all late-arriving keys trigger this op-store refresh automatically. RootOp::KeyDelivery is published only by admin-initiated adds (add-members / TEE), not on join. After sign_and_publish_without_apply successfully publishes the MemberJoinedAt op during await_namespace_ready (Stage 2 of namespace join), the op is written into the unified op-store via ScopeProjections::repersist_namespace_ops; previously this locally-authored publish wrote to the gov-DAG but skipped the op-store because the receive-handler dual-write does not cover locally-authored publishes. Note: the thin alias persist_namespace_head_ops has been deleted and all post-author mirror calls at create_group, delete_group, join_context, join_subgroup_inheritance, remove_group_members, reparent_group, create_group_in_namespace, and join_namespace have been removed; repersist_namespace_ops is the canonical method and is called only at the key-delivery and join-completion chokepoints described above.
sync/manager/mod.rs (peer_is_group_member)
SyncManager::peer_is_group_member — encapsulates inbound-peer membership authorization for all sync call sites. Checks MembershipRepository::is_member (live) first, then resolves current governance heads via ScopeProjections::namespace_current_heads and calls projection_member_at_cut (now pub(crate)) for the projection verdict. The projection's answer is authoritative; if it returns None (cold or partially folded ancestry) the method falls back to the live result via projected.unwrap_or(live) so no peer is spuriously rejected — the existing catch-up retry will re-resolve once governance advances. No divergence warning is emitted when projection and live disagree; the live read is retained solely as the None fallback. SyncManager::verify_inbound_member and the materialization-wait verification now both route through peer_is_group_member instead of calling MembershipRepository::is_member directly.
Blob transfer handler — serves blob data to requesting peers
Per-context delta buffering for out-of-order delta arrival
GarbageCollector actor — periodic tombstone cleanup across all contexts

calimero-node / sync

SyncManager, SyncConfig, protocol re-exports, metrics collector traits
Periodic sync loop, protocol selection, stream handling, peer tracking. pull_namespace_governance(context_id, peer) is now an inherent async method on SyncManager — resolves the namespace for the context via namespace_sync::resolve_namespace_id and calls sync_namespace_from_peer targeting the specific diverging peer, reusing the existing NamespaceBackfillRequest machinery edge-triggered on the verdict; best-effort (no-op if the context has no resolvable namespace). The ProtocolDispatch trait method pull_namespace_governance is now a one-line delegate to SyncManager::pull_namespace_governance. Pre-sync governance divergence check (P6.S3): when select_protocol returns SyncProtocol::None (entity roots agree, no entity walk needed) and the peer advertises a non-None scope_root, the manager now computes a scope_verdict. If the verdict is ScopeVerdict::GovDiverged (local and peer scope roots differ despite identical entity roots), it calls pull_namespace_governance and returns early, bypassing the entity walk entirely. This fills the gap where a governance rotation or ACL change — invisible to the entity Merkle — would otherwise persist uncorrected because the post-sync P6.S2 hook only fires after HC/LevelWise actually runs. The datastore read is gated on peer_scope_root being Some; when a peer sends scope_root: None (cold projection or non-group context) the store is never touched and scope_verdict is never computed. After a successful pre-sync governance pull the manager emits a debug-level gov_divergence_pull_complete marker, pairing with the existing gov_divergence_pull_triggered info log. After a successful HashComparison or LevelWise session, if gov_divergence_detected is true, the ProtocolSelector likewise calls pull_namespace_governance and logs gov_divergence_pull_triggered; previously only a passive warning was emitted with no corrective action. The pull is self-limiting: once governance converges the verdict is no longer GovDiverged and no further pull is issued. Implementors of ProtocolDispatch must implement pull_namespace_governance (delegating to the inherent method).
Merkle DFS traversal — cheapest sync protocol; run_initiator_impl calls query_peer_current_root (pub(crate) — also reused by the LevelWise initiator; returns (root_hash, Option<scope_root>)); query_peer_dag_state return type extended from Option<(Hash, Vec<[u8; DIGEST_SIZE]>)> to Option<(Hash, Vec<[u8; DIGEST_SIZE]>, Option<Hash>)> — the scope_root field from DagHeadsResponse (previously discarded as scope_root: _) is now captured and threaded back to all callers; on the re-query convergence path, stats.root_hash_verified is now set via helpers::scope_verdict(...).converged() — the inline match (local_scope_root, peer_scope_root) block is replaced by a call to the shared scope_verdict helper; the scope_root_governance_divergence WARN is emitted by pattern-matching ScopeVerdict::GovDiverged(local, peer) directly, eliminating the prior if let (true, Some(...), Some(...)) guard; HashComparisonStats gains a gov_divergence_detected: bool field set to true when the verdict is ScopeVerdict::GovDiverged, exposing the signal to the ProtocolSelector; the WARN text changes from "awaiting governance sync to propagate the rotation" to "pulling governance from the peer to propagate the rotation"; the deterministic sync simulator sends scope_root: None
BFS level-wise sync for wide trees. The initiator calls query_peer_current_root (pub(crate)) at end-of-session for a fresh (root_hash, Option<scope_root>) rather than comparing against the stale handshake root; convergence is now decided via helpers::scope_verdict(...).converged() — the inline match (local_scope_root, peer_scope_root) block is replaced by the shared helper, mirroring HashComparison. Transport faults fall back to the handshake root; an older peer returning Ok(None) falls back silently. The responder loop has an explicit InitPayload::DagHeadsRequest arm: after all leaf/tombstone merges it re-reads the local entity root, folds the governance projection to obtain scope_root (None for non-group or cold projection), and replies with a DagHeadsResponse carrying both fields. LevelWiseStats gains a gov_divergence_detected: bool field set to true when the verdict is ScopeVerdict::GovDiverged, mirroring HashComparisonStats. When root_hash_verified is false, the scope_root_governance_divergence WARN is emitted by pattern-matching ScopeVerdict::GovDiverged(local, peer); the WARN text changes from "awaiting governance sync to propagate the rotation" to "pulling governance from the peer to propagate the rotation"; all other mismatches emit the general non-convergence warning using the freshly re-queried peer root.
Full snapshot transfer — heaviest fallback protocol
Blob sharing protocol
Sync helper utilities; includes local_scope_root — looks up the group owning a context, calls ScopeProjections::group_scope_root_ephemeral, returns None for non-group contexts or store faults (TODO(perf, C1+): replace full DAG walk with maintained projection reads). Also defines ScopeVerdict { Converged, GovDiverged([u8;32],[u8;32]), DataDiverged } and the free function scope_verdict(local_scope_root, peer_scope_root, local_entity_root, peer_entity_root) -> ScopeVerdict — the single convergence authority used by both HashComparison and LevelWise protocols, and by the pre-sync SyncProtocol::None divergence check in SyncManager. GovDiverged carries both resolved scope roots directly so callers can pattern-match and log them without re-destructuring Options. When either scope root is None (cold projection or non-group context), scope_verdict falls back to bare entity-root comparison (treating asymmetric None as None/None would cause false GovDiverged alarms on partially-warmed nodes). The pre-sync path gates the store read on peer_scope_root being Some, so the common already-in-sync path for cold/non-group peers incurs no I/O.
Per-peer sync history tracking
SyncMetricsCollector trait, PhaseTimer, NoOpMetrics
Production Prometheus metric implementation

calimero-node-primitives

NodeClient façade — subscribe, broadcast, heartbeat, group ops, sync trigger
Application module root — get/has app, module declarations
All installation paths (path, URL, blob, bundle), uninstall, artifact extraction
Bundle manifest verification, signature validation, path safety checks
List applications, packages, versions; update compiled app
Blob add / get / delete / list / auth operations
Generic alias create / delete / lookup / resolve / list
NodeMessage enum — GetBlobBytes, RegisterPendingSpecializedNodeInvite, etc.
BroadcastMessage, sync wire types, protocol traits, state machine; MessagePayload::DagHeadsResponse now carries an additive scope_root: Option<Hash> field — None means the responder could not resolve/fold the scope; initiators must skip the shadow comparison in that case
DeltaBuffer — bounded buffer for deltas received during snapshot sync
TopicManager — deduplication-aware gossipsub subscription management with RwLock
JoinBundle — data package for namespace join (group key envelope, governance snapshot, context IDs)

Key Types

Central structs in the node crate. NodeManager is the actor; its fields are split into three injected concerns: clients, managers, and mutable state.

NodeManager

pub struct NodeManager {
    clients: NodeClients,     // context + node façades
    managers: NodeManagers,   // blobstore + sync
    state: NodeState,       // mutable runtime state
}

NodeClients

struct NodeClients {
    context: ContextClient,
    node: NodeClient,
}

NodeManagers

struct NodeManagers {
    blobstore: BlobManager,
    sync: SyncManager,
}

NodeState

struct NodeState {
    blob_cache: Arc<DashMap<BlobId, CachedBlob>>,
    delta_stores: Arc<DashMap<ContextId, DeltaStore>>,
    pending_specialized_node_invites: PendingSpecializedNodeInvites,
    accept_mock_tee: bool,
    node_mode: NodeMode,
    sync_sessions: Arc<DashMap<ContextId, SyncSession>>,
}

NodeConfig

pub struct NodeConfig {
    home: Utf8PathBuf,
    identity: Keypair,
    // namespace identity is auto-generated per root group in the datastore
    network: NetworkConfig,
    sync: SyncConfig,
    datastore: StoreConfig,
    blobstore: BlobStoreConfig,
    context: ContextConfig,
    server: ServerConfig,
    gc_interval_secs: Option<u64>,
    mode: NodeMode,
    specialized_node: SpecializedNodeConfig,
    mock_tee: bool,          // dev/test only — refused if TeeConfig::has_real_attestation
}
⚠ --mock-tee (dev/test only)

The --mock-tee flag (or MEROD_MOCK_TEE env var) on merod run makes fleet-join and attest handlers produce and accept mock TDX attestation quotes instead of requiring real hardware. When active, a loud tracing::warn is emitted at startup; a second warn fires if a Phala KMS provider is configured but real attestation is disabled (likely misconfiguration). The flag is refused (bail!) when TeeConfig::has_real_attestation() returns true — i.e. when real KMS attestation is configured. NodeConfig::mock_tee threads the value into AdminState (via AdminState::new); it is never persisted to the config file. Must not be used in production.

NodeClient primitives

Thin async façade in calimero-node-primitives. Used by Server and ContextManager to interact with node-level concerns. Wraps a Store, BlobManager, NetworkClient, and a LazyRecipient<NodeMessage>.

pub struct NodeClient {
    datastore: Store,
    blobstore: BlobManager,
    network_client: NetworkClient,
    node_manager: LazyRecipient<NodeMessage>,
    event_sender: broadcast::Sender<NodeEvent>,
    ctx_sync_tx: mpsc::Sender<(Option<ContextId>, Option<PeerId>)>,
    specialized_node_invite_topic: String,
}

Context & Group Subscriptions

fn

subscribe(context_id)

Subscribe to a context's gossipsub topic via NetworkClient

fn

unsubscribe(context_id)

Unsubscribe from a context topic

fn

subscribe_group(group_id)

Subscribe to group/{hex} topic

fn

unsubscribe_group(group_id)

Unsubscribe from group topic

fn

broadcast(context, sender, artifact, …)

Publish a StateDelta on the context's gossipsub topic

fn

publish_signed_group_op(group_id, op)

Publish a SignedGroupOpV1 on the group topic

fn

publish_group_heartbeat(group_id, …)

Broadcast GroupStateHeartbeat for catch-up detection

fn

get_peers_count(context)

Global peer count or per-topic mesh peers

Application & Blob Management

NodeClient exposes application lifecycle through four focused sub-modules (application/):

application.rs (module root) → install.rs (all install paths + uninstall) → bundle.rs (manifest verification + path safety) → query.rs (list/search)

Bundle signature verification: verify_and_extract_manifest (strict, production) vs extract_manifest_allow_unsigned (dev installs — verifies if present, allows unsigned).

Applications

  • install_application()
  • install_from_path()
  • install_from_url()
  • install_from_bundle_blob()
  • uninstall_application()
  • list_applications()
  • list_packages()

Blobs

  • add_blob()
  • get_blob() / get_blob_bytes()
  • delete_blob()
  • list_blobs()
  • find_blob_providers()
  • announce_blob_to_network()
  • create_blob_auth()

Aliases

  • create_alias()
  • delete_alias()
  • lookup_alias()
  • resolve_alias()
  • list_aliases()

Startup Flow

The start() function in run.rs bootstraps the entire node. Each component is initialized in dependency order, with LazyRecipients breaking circular initialization.

merod run CLI entry point start() run.rs entry Store open RocksDB + optional encryption BlobManager filesystem backend NetworkManager actor · libp2p swarm NetworkClient + EventChannel mpsc(1000) · 80% warning NodeClient store + blob + network + events ContextManager actor · context_recipient NodeManager actor · node_recipient NetworkEventBridge tokio::spawn · channel → actor SyncManager.start() async task · periodic loop Server tokio::spawn · Axum GC Actor 12h default interval 1 2 3 4 5 6 tokio::select! { sync, server, bridge } Node Network Context Storage Sync Server GC

Event Handling

⚠ Migration note: Convergence decisions in both HashComparison and LevelWise protocols are now delegated to helpers::scope_verdict(local_scope_root, peer_scope_root, local_entity_root, peer_entity_root) -> ScopeVerdict. Callers set stats.root_hash_verified via verdict.converged() and emit the scope_root_governance_divergence WARN by pattern-matching ScopeVerdict::GovDiverged(local, peer) — the prior inline match (local_scope_root, peer_scope_root) blocks and the if let (true, Some(...), Some(...)) guard are removed from both protocols. HashComparisonStats and LevelWiseStats each gain a gov_divergence_detected: bool field set to true on GovDiverged; the ProtocolSelector reads this flag after a session completes and, if set, calls ProtocolDispatch::pull_namespace_governance(context_id, peer) — an active governance pull replacing the previous passive warning. Additionally, governance divergence is now detected and repaired on the pre-sync SyncProtocol::None path (entity roots agree, no entity walk needed): when a peer advertises a non-None scope_root and scope_verdict returns GovDiverged, SyncManager::pull_namespace_governance is called immediately, bypassing the entity walk. query_peer_dag_state return type is extended to Option<(Hash, Vec<[u8; DIGEST_SIZE]>, Option<Hash>)> so the peer's scope_root is available to this pre-sync check. The datastore read for local_scope_root is skipped entirely when peer_scope_root is None, preserving zero I/O overhead on cold/non-group peers. After a successful pre-sync pull, a debug-level gov_divergence_pull_complete marker is emitted; operators and e2e suites should monitor the gov_divergence_pull_triggered / gov_divergence_pull_complete pair on both pre-sync and post-sync paths to verify governance convergence. pull_namespace_governance is now an inherent method on SyncManager; the ProtocolDispatch trait impl delegates to it in one line. The gov_divergence_pull_triggered info marker is logged when the pull is issued; operators can monitor it to detect per-tick storms. Implementors of ProtocolDispatch must add a pull_namespace_governance implementation. ScopeProjections::persist_namespace_head_ops is removed; all post-author mirror calls across governance op sites are deleted. MessagePayload::DagHeadsResponse carries a scope_root: Option<Hash> field. None means the responder could not fold the scope. The HC initiator's convergence verdict (stats.root_hash_verified) now requires scope_root agreement when both peers resolve one; if either peer returns None it falls back to bare entity-root comparison. The previous C0 observe-only scope_root_shadow_divergence WARN (which logged divergence without acting on it) is removed. A new scope_root_governance_divergence WARN is emitted when both peers resolve a scope_root, bare entity roots agree, but scope roots differ — indicating a pure ACL/governance-plane divergence where the entity tree-walk has nothing to reconcile; operators and log aggregators should monitor this marker as it signals a governance divergence awaiting sync propagation. The governance_drain_outcome metric no longer emits the "lookup_error", "removed", or "never_member" labels; all non-member drops are now reported under the single "not_member" label. Dashboards or alerts filtering on "removed" or "never_member" must be updated to "not_member". The unified_projection_divergence warns previously fired on the grant arm of drain_governance_pending, on the gossip Authorized role-shadow check (plane=data-write-role), and on divergences in peer_is_group_member have all been removed — dashboards or alerts keyed to those events will stop firing. The data_write_decision_shadow debug logs have likewise been removed. Gossip deltas whose governance ancestry is not yet fully folded are now buffered rather than dropped; operators should not expect silent delta loss on incomplete-fold membership cases. Code matching on DeltaAuthOutcome::MembershipReject must no longer expect a group field — that field has been removed from the variant. The live authorize_delta_at_edge function (backed by acl_view_at) is deleted entirely — all delta authorization paths (gossip apply, parent-fetch, snapshot-replay, DAG-catchup) now go through authorize_delta_at_edge_projected with resolve_cut_membership as the resolver closure. The CutMembership::Incomplete variant carries no embedded needed set; the Buffer.needed is populated from the governance position's heads. request_missing_deltas and verify_fetched_parent gain a node_state: &NodeState parameter to carry the projection reference.

NodeManager implements Handler<NetworkEvent> (via the bridge) and Handler<NodeMessage>. Network events arrive through a dedicated mpsc channel to avoid cross-arbiter message loss. BroadcastMessage::StateDelta dispatch is forwarded to a dedicated StateDeltaActor running on its own Arbiter so delta processing does not compete with sync, heartbeat, blob, or namespace handlers on the NodeManager mailbox. Inbound and outbound HashComparison/LevelWise sync sessions are likewise routed through a dedicated SyncSessionActor on its own Arbiter (responder via StreamOpened, initiator via SyncManager::start), so a slow session can’t starve the swarm-poll task and trigger gossipsub mesh starvation.

BroadcastMessage Dispatch

When a gossipsub message arrives, NodeManager deserializes it as a BroadcastMessage variant and routes accordingly:

1

StateDelta

Routed via StateDeltaSender::try_send to StateDeltaActor on a dedicated Arbiter. The actor's Handler<StateDeltaJob> calls handle_state_delta which checks sync session state — if a snapshot sync is active the delta is buffered, otherwise it's authorized and applied. Mailbox bounded at STATE_DELTA_CHANNEL_CAPACITY (2048); on overflow the dispatch site logs a warn! and drops, relying on heartbeat-driven rebroadcast.

Projection-only authorization across all delta paths (authorize_delta_at_edge_projected + resolve_cut_membership): The live authorize_delta_at_edge function is deleted. authorize_delta_at_edge_projected is now the sole authorizer for all delta authorization call sites: gossip apply, parent-fetch (request_missing_deltas / verify_fetched_parent), snapshot-replay (replay_buffered_delta), and DAG-catchup head-pull in SyncManager. Each call site passes resolve_cut_membership as the resolver closure. resolve_cut_membership performs a scoped projection refresh under write lock, then reads both member_at_cut and role_at_cut_for_group under a single read guard for epoch consistency. The projection refresh calls ops_for_namespace — the stable indirection layer that currently wraps collect_namespace_ops (governance-DAG walk). The flip to load_scope_ops (unified op-store) is deferred because an encrypted MemberAdded op can be applied to the governance DAG before its group key arrives, freezing as Noop in the op-store; collect_namespace_ops re-decrypts at read time once the key lands, so using the op-store as the projection source would silently drop members and break joiner sync. The closure returns a CutMembership value: Member(role)Authorized, NotMemberMembershipReject, IncompleteBuffer (governance ancestry not yet fully folded — delta is held for retry rather than dropped; no embedded needed set on the variant). projection_member_at_cut is refactored to delegate to resolve_cut_membership and discard the role. All previous shadow/co-authorizer logic, divergence warning logs (membership-cut-grant, data-write-role, data-write-decision planes), and the grant-override on MembershipReject are removed. projection_member_at_cut_authoritative has been deleted. DeltaAuthOutcome::MembershipReject no longer carries a group field. The observe-only shadow_compare_op_store method and its companion scope_root_from_governance_dag have been removed from ScopeProjections; the call site in refresh_projection_for_cut that triggered a full governance-DAG fold on every backfill is deleted.

Governance-drain projection authority: drain_governance_pending uses member_at_cut (projection) as the sole decision authority — no live acl_view_at DAG walk. The three projection outcomes map directly to drain outcomes: Some(true) → re-apply the buffered delta; Some(false) → drop it (records a governance_drain_outcome metric with the unconditional label "not_member"; the previous live call that distinguished "removed" / "never_member" / "not_member" has been removed entirely); None → re-buffer (ancestry still being folded). The "lookup_error", "removed", and "never_member" label values no longer exist. The re-buffer debug log no longer includes needed_count.

Inbound-sync authorization (peer_is_group_member): projection_member_at_cut is pub(crate) and is reused by SyncManager::peer_is_group_member for all inbound-sync peer authorization. That helper calls the live MembershipRepository::is_member first, then resolves governance heads and calls projection_member_at_cut. The projection's answer is authoritative; a None result (cold or partially folded) falls back to the live result via projected.unwrap_or(live) so no peer is spuriously rejected. No divergence warning is emitted. Both the materialization-wait verification and verify_inbound_member route through peer_is_group_member instead of calling MembershipRepository::is_member directly.

2

HashHeartbeat / Scope-root authoritative verdict (scope_verdict) + active governance pull

On the re-query convergence path in run_initiator_impl (HashComparison) and at end-of-session in LevelWise, stats.root_hash_verified is now set via helpers::scope_verdict(local_scope_root, peer_scope_root, local_entity_root, peer_entity_root).converged(). The scope_verdict helper returns a ScopeVerdict enum: Converged (roots agree on both planes), GovDiverged([u8;32],[u8;32]) (entity roots agree but scope roots differ — pure ACL/governance-plane divergence), or DataDiverged (entity roots disagree). Both protocols pattern-match ScopeVerdict::GovDiverged(local, peer) to emit the scope_root_governance_divergence WARN (text: "pulling governance from the peer to propagate the rotation"), and both stats structs set gov_divergence_detected = true. After the session returns, the ProtocolSelector checks gov_divergence_detected and, if set, calls ProtocolDispatch::pull_namespace_governance(context_id, peer) (which delegates to the inherent SyncManager::pull_namespace_governance); a gov_divergence_pull_triggered info marker is logged and a debug-level gov_divergence_pull_complete marker is emitted on completion. Previously only a warning was emitted with no corrective action. Pre-sync path (P6.S3): when select_protocol returns SyncProtocol::None and the peer's scope_root (surfaced from the extended query_peer_dag_state return value) is non-None, the manager computes scope_verdict and, on GovDiverged, calls SyncManager::pull_namespace_governance directly without entering the entity walk. When peer_scope_root is None the store read for local_scope_root is skipped entirely. When either scope root is None, scope_verdict falls back to bare entity-root comparison. Both protocols call the shared query_peer_current_root (pub(crate)) helper to obtain a fresh (root_hash, Option<scope_root>). The query_peer_dag_state return type is extended to Option<(Hash, Vec<[u8; DIGEST_SIZE]>, Option<Hash>)> to surface the peer's scope_root. The previous observe-only scope_root_shadow_divergence WARN is removed. Operators and e2e suites should monitor both the scope_root_governance_divergence WARN and the gov_divergence_pull_triggered / gov_divergence_pull_complete pair on all sync paths (pre-sync, HashComparison, and LevelWise).

2

HashHeartbeat

Periodic root hash announcement. Compared with local state; mismatches trigger sync via ctx_sync_tx channel.

3

SignedGroupOpV1

Forwarded to ContextManager's group operation handler. Signature verified, then applied to the local GroupStore DAG.

4

GroupStateHeartbeat

Group-level hash heartbeat for governance DAG catch-up detection.

5

GroupGovernanceDelta

Governance state delta. Routed to ContextManager for DAG ingestion.

6

GroupMutationNotification

Notification of group membership changes. Triggers re-evaluation of subscriptions and capabilities.

Stream Routing

When a peer opens a stream, stream_opened.rs inspects the protocol prefix to route:

/calimero/stream/…

Sync protocol streams — routed via SyncSessionSender::try_send to the dedicated SyncSessionActor Arbiter (issue #2316). The actor calls SyncManager::handle_opened_stream for hash comparison, level-wise sync, snapshot transfer, or key sharing. Bounded mailbox + a semaphore sized to sync_config.max_concurrent caps concurrent in-flight sessions; on overflow the inbound stream is dropped and peers retry.

/calimero/blob/…

Blob transfer protocol — handled by blob_protocol.rs. Serves blob data from BlobManager to requesting peers.

Subscription Lifecycle

NodeManager manages gossipsub subscriptions for both context topics (one per context) and group topics (group/{hex32}).

ctx

Context Topics

Subscribed when a context is joined/created via NodeClient. Carries StateDelta and HashHeartbeat messages. Unsubscribed on context leave.

grp

Group Topics

Subscribed for each group the node participates in. Carries SignedGroupOpV1, GroupStateHeartbeat, and GroupGovernanceDelta. On peer subscription events, the node auto-triggers group sync and alias re-broadcast.

Periodic Tasks

Heartbeats

SyncManager periodically broadcasts HashHeartbeat per context and GroupStateHeartbeat per group. Interval is configurable via SyncConfig.

Tombstone GC

GarbageCollector actor runs every gc_interval_secs (default 12 hours). Scans all contexts for expired CRDT tombstones past TOMBSTONE_RETENTION_NANOS and deletes them.

Blob Eviction

Cached blobs in NodeState::blob_cache are evicted based on last_accessed timestamps. The CachedBlob::touch() method refreshes access time on read.

Sync Loop

SyncManager runs a continuous async loop: listen for sync triggers from ctx_sync_rx, select the cheapest applicable protocol (hash comparison → level-wise → snapshot), execute, and track results per peer.

Dependencies

What the node crate depends on and what depends on it.

calimero-node depends on

calimero-context
ContextManager actor — started by node during bootstrap
calimero-context-primitives
ContextClient façade for sending messages to ContextManager
calimero-context-config
ContextGroupId, group configuration types
calimero-network
NetworkManager actor — started by node, runs libp2p swarm
calimero-network-primitives
NetworkClient, NetworkEvent, NetworkConfig, specialized invite types
calimero-node-primitives
NodeClient, NodeMessage, BroadcastMessage, sync wire types, DeltaBuffer
calimero-store
Store handle, typed keys, column families
calimero-store-rocksdb
RocksDB backend for Store
calimero-store-encryption
Optional AES encryption layer for the datastore
calimero-storage
CRDT storage engine, tombstone constants, EntityIndex
calimero-blobstore
BlobManager for binary object storage
calimero-server
Axum HTTP server, started by node as tokio task
calimero-primitives
Core types: ContextId, BlobId, PublicKey, events
calimero-crypto
SharedKey for encrypted sync handshakes
calimero-dag
DAG operations used by sync protocols
calimero-tee-attestation
TEE attestation verification for specialized node invites; TeeConfig::has_real_attestation() is the predicate used to deny --mock-tee when real KMS attestation is configured
calimero-utils-actix
LazyRecipient, ActorExt, Actix utilities

Depended on by

merod
Binary crate — calls calimero_node::start() from merod run command
calimero-server
Uses NodeClient (from primitives) for blob and application operations
calimero-context
Uses NodeClient (from primitives) for broadcast, subscribe, and sync triggers
Node
Context
Network
Storage
Server
Shared / Primitives
TEE