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.
Module Structure
File layout across the two crates. Each handler lives in its own focused file following the Single Responsibility Principle.
calimero-node
calimero-node / sync
calimero-node-primitives
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
clients: NodeClients, // context + node façades
managers: NodeManagers, // blobstore + sync
state: NodeState, // mutable runtime state
}
NodeClients
context: ContextClient,
node: NodeClient,
}
NodeManagers
blobstore: BlobManager,
sync: SyncManager,
}
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
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,
}
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>.
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
subscribe(context_id)
Subscribe to a context's gossipsub topic via NetworkClient
unsubscribe(context_id)
Unsubscribe from a context topic
subscribe_group(group_id)
Subscribe to group/{hex} topic
unsubscribe_group(group_id)
Unsubscribe from group topic
broadcast(context, sender, artifact, …)
Publish a StateDelta on the context's gossipsub topic
publish_signed_group_op(group_id, op)
Publish a SignedGroupOpV1 on the group topic
publish_group_heartbeat(group_id, …)
Broadcast GroupStateHeartbeat for catch-up detection
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/):
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.
Event Handling
⚠ Migration note (F5 window): Monitor for unified_projection_divergence log events on the drain path. The governance_drain_outcome metric no longer emits the "lookup_error" label; consumers should add handling for the new "not_member" label. Additionally monitor for unified_projection_divergence events with plane = "membership-sync", which are emitted by the new peer_is_group_member helper when the projection and live membership disagree during inbound-sync authorization; a None projection result (cold or partially folded ancestry) silently falls back to the live result and will self-correct once governance advances.
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:
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 decrypted 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-override on MembershipReject: When live membership returns DeltaAuthOutcome::MembershipReject, handle_state_delta no longer unconditionally drops the delta. It first calls projection_member_at_cut_authoritative (which refreshes the projection fold via refresh_projection_for_cut then reads member_at_cut_authoritative). If the projection returns Some(true), the delta falls through to the apply path and a unified_projection_divergence / membership-cut-grant warning is emitted; observe_peer_identity is also called so projection-only peers still feed sync peer selection. None or Some(false) still reject. The projection is therefore the authoritative membership decider on the primary gossip path; live's result is a cross-check, not the final word. Other MembershipReject sites (sync delta-request, parent-fetch replay) intentionally remain live-only.
Governance-drain projection authority: drain_governance_pending also uses member_at_cut_authoritative (projection) as the sole decision authority — not acl_view_at (live 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); None → re-buffer (ancestry still being folded). After a decisive verdict, acl_view_at is called purely for a cross-check: on Some(true), a live result of Removed or NeverMember emits a unified_projection_divergence warn with plane = "membership-cut-grant"; on Some(false), a live result of Member emits the same marker with plane = "membership-cut". The live resolver is not called on None to avoid false positives. The old Err drop arm is eliminated because the projection never returns an error. The governance_drain_outcome drop metric label is now best-effort: "removed" / "never_member" if the live cross-check agrees, or the new fallback label "not_member" otherwise. The "lookup_error" label no longer exists. The re-buffer debug log no longer includes needed_count (the projection returns a boolean absence, not a structured missing-heads list).
Inbound-sync authorization (peer_is_group_member): projection_member_at_cut is now 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 with the same refreshing backfill logic used on the write path. The projection's answer is the authoritative decision; a None result (cold or partially folded) falls back to the live result so no peer is spuriously rejected. Divergences between the two are logged as unified_projection_divergence with plane = "membership-sync". Both the materialization-wait verification and verify_inbound_member inside SyncManager now route through peer_is_group_member instead of calling MembershipRepository::is_member directly.
HashHeartbeat
Periodic root hash announcement. Compared with local state; mismatches trigger sync via ctx_sync_tx channel.
SignedGroupOpV1
Forwarded to ContextManager's group operation handler. Signature verified, then applied to the local GroupStore DAG.
GroupStateHeartbeat
Group-level hash heartbeat for governance DAG catch-up detection.
GroupGovernanceDelta
Governance state delta. Routed to ContextManager for DAG ingestion.
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}).
Context Topics
Subscribed when a context is joined/created via NodeClient. Carries StateDelta and HashHeartbeat messages. Unsubscribed on context leave.
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.