Advanced SDK
This page collects the load-bearing details behind the SDK’s convenience
macros: what #[derive(Mergeable)] actually generates, why a value stored in a
map needs deterministic ids, how root-state merges round-trip through your WASM
module, and the rules that keep migrations and event handlers convergent. If you
have only written apps with the derive macros and the built-in collections, you
do not need any of this — reach for it when you hand-roll a CRDT value, debug a
sync divergence, or write a non-trivial migration.
Prerequisites: collections and
sdk-macros.
Custom Mergeable for a struct CRDT value
Section titled “Custom Mergeable for a struct CRDT value”A struct stored as a collection value (e.g. UnorderedMap<String, TeamStats>)
must be Mergeable so replicas converge. The trait is small — note there is no
context argument; timestamp/identity suppression is handled out-of-band by the
runtime’s merge mode, not threaded through merge:
pub trait Mergeable { fn merge(&mut self, other: &Self) -> Result<(), MergeError>;}The simplest path is #[derive(Mergeable)], which calls merge on every field.
You hand-write the impl only when you need custom behavior (validation, logging,
business rules, skipping a field). The hand-rolled version from
apps/team-metrics-custom delegates field-by-field to each Counter:
#[derive(Debug, Default, BorshSerialize, BorshDeserialize)]#[borsh(crate = "calimero_sdk::borsh")]pub struct TeamStats { pub wins: Counter, pub losses: Counter, pub draws: Counter,}
impl Mergeable for TeamStats { fn merge(&mut self, other: &Self) -> Result<(), MergeError> { // Custom hooks go here (validation, logging, business rules); // then delegate to each field's CRDT merge. self.wins.merge(&other.wins)?; self.losses.merge(&other.losses)?; self.draws.merge(&other.draws)?; Ok(()) }}#[derive(Mergeable)] works on structs only (every field must itself be
Mergeable). For an enum, wrap it in LwwRegister<MyEnum> instead of
deriving.
A hand-rolled Mergeable also needs RekeyTarget
Section titled “A hand-rolled Mergeable also needs RekeyTarget”This is the part that bites. merge alone is not enough: a struct stored as a
map value has nested collection fields (Counter, LwwRegister, …) that
each carry their own entity id. For the runtime to merge those fields as child
entities — rather than treating the whole struct as one opaque last-writer-wins
blob — every replica must derive identical ids for them. That is what
RekeyTarget does: it re-keys each field under a deterministic, field-namespaced
child of the parent entry’s id.
#[derive(Mergeable)] generates both the Mergeable impl and the
RekeyTarget impl. When you write Mergeable by hand you must also write
RekeyTarget by hand:
impl calimero_storage::collections::rekey::RekeyTarget for TeamStats { fn rekey_relative_to(&mut self, parent_id: calimero_storage::address::Id) { use calimero_storage::collections::rekey::field_child_id; calimero_storage::rekey_field_if_supported!( &mut self.wins, field_child_id(parent_id, "wins") ); calimero_storage::rekey_field_if_supported!( &mut self.losses, field_child_id(parent_id, "losses") ); calimero_storage::rekey_field_if_supported!( &mut self.draws, field_child_id(parent_id, "draws") ); }}Example: a high-water-mark gauge (merge = max)
Section titled “Example: a high-water-mark gauge (merge = max)”Delegation (one field.merge() per field) is the common case, but a hand-written
merge can also be the CRDT — encoding the convergence rule directly. A
high-water-mark gauge tracks the largest value ever observed; its merge is simply
max:
use calimero_sdk::borsh::{BorshDeserialize, BorshSerialize};use calimero_storage::collections::crdt_meta::MergeError;use calimero_storage::collections::Mergeable;
/// A monotonic high-water mark: the merged value is the larger of the two.#[derive(Debug, Default, Clone, BorshSerialize, BorshDeserialize)]#[borsh(crate = "calimero_sdk::borsh")]pub struct HighWaterMark { peak: u64,}
impl HighWaterMark { pub fn observe(&mut self, sample: u64) { self.peak = self.peak.max(sample); } pub fn get(&self) -> u64 { self.peak }}
impl Mergeable for HighWaterMark { fn merge(&mut self, other: &Self) -> Result<(), MergeError> { self.peak = self.peak.max(other.peak); Ok(()) }}The determinism rule: a merge must be commutative, associative, and
idempotent so every replica converges to the same value regardless of the order
deltas arrive in. max satisfies all three —
max(a, max(b, c)) == max(max(a, b), c), max(a, b) == max(b, a), and
max(a, a) == a — so two nodes that each observe() different samples always
agree on the peak. Contrast a non-convergent rule like last writer wins on a raw
u64 (which needs a timestamp to be deterministic, i.e. LwwRegister) or
sum (which double-counts when the same delta is delivered twice). Because this
struct has no nested collection fields, RekeyTarget is a no-op — the gauge is a
leaf value, merged whole.
Storage strategy: Blob vs Structured
Section titled “Storage strategy: Blob vs Structured”Every CRDT type declares a StorageStrategy, and this is the lever that decides
whether nested CRDTs converge field-by-field:
pub enum StorageStrategy { /// Store as one opaque blob (leaf CRDTs). Blob, /// Store fields separately with composite keys (containers). Structured,}| Strategy | Types | Merge unit |
|---|---|---|
Blob | Counter, LwwRegister | the whole entry, merged as one value |
Structured | UnorderedMap, Vector, UnorderedSet | each child entity, merged independently |
A Blob type is a leaf: it is merged whole. A Structured type is a container:
its children are stored under composite keys and merged per-child. That per-child
boundary — combined with the deterministic ids from the section above — is what
makes a nested CRDT (a counter inside a map value) converge field-by-field
instead of getting clobbered wholesale.
The merge registry: root merges route through your WASM
Section titled “The merge registry: root merges route through your WASM”The host cannot deserialize your app’s state type — only your compiled module
knows its shape. So #[app::state] emits a __calimero_register_merge WASM
export that the runtime calls at module-load time; it registers a typed merge
closure for your root state. When a root-entity action applies, the merge is
dispatched through WASM rather than guessed at by the host.
The invariant that protects you: there is exactly one registrant per module
(your root state type). If a root type were somehow never registered, a cold sync
fails loudly with MergeError::NoMergeFunctionRegistered — there is no silent
last-writer-wins drop of your entire state. (Routing host root-state merges
through an empty host-side registry was the original cause of a real
state-loss bug; the registry now lives WASM-side only.)
You never call this directly — it is wired up by #[app::state]. It matters when
you are reading a divergence trace and see NoMergeFunctionRegistered: that
points at a module that did not register its root type, not at corrupt data. See
crdt-internals for the full dispatch path.
Concretely, when a sync delta touches the root entity the host can’t deserialize
your state type — so it hands both byte blobs to the module’s
__calimero_merge_root_state export, which runs your typed Mergeable::merge
and returns merged bytes:
Nested collection ids and the re-key pass
Section titled “Nested collection ids and the re-key pass”A collection constructed fresh — Counter::new(), UnorderedMap::new() — first
mints an Id::random(). That is fine for a root, but for a value placed inside
a map the id must instead be derived from the map key, or two nodes that
insert “the same” logical value would mint two different random ids and fork.
Two mechanisms cooperate:
compute_collection_idderives the deterministic, key-based id for a collection placed inside a container.- the re-key pass (
__assign_deterministic_ids()) walks freshly-constructed values, moves each onto its deterministic key-derived id, and tombstones the random one it was born with.
This is why #[app::init] does not just return your state — the macro wraps it so
the re-key pass runs before the first commit:
// Generated by #[app::init] — your constructor is wrapped:::calimero_storage::collections::Root::new(|| { let mut state = #call; // your `init()` body state.__assign_deterministic_ids(); state})The same wrapping runs for migrations (see below), which is how a migrated state ends up with the same ids on every node.
Private storage: node-local, never synced
Section titled “Private storage: node-local, never synced”#[app::private] marks a struct as node-local state that is not
synchronized across the mesh — secrets, per-node config, local caches. You load
it with T::private_load_or_default() and mutate through an EntryMut guard
(as_mut()); the guard auto-persists on Drop, including on early
?-returns, so there is no explicit save call for the borsh-blob fields.
The apps/private_data app keeps plaintext secrets private and publishes only
their hashes to synced state:
#[app::state(emits = for<'a> Event<'a>)]pub struct SecretGame { games: UnorderedMap<String, LwwRegister<String>>, // synced: only hashes}
#[derive(BorshSerialize, BorshDeserialize, Debug)]#[borsh(crate = "calimero_sdk::borsh")]#[app::private]pub struct Secrets { secrets: UnorderedMap<String, String>, // node-local plaintext secrets_added: u64, // ...primitives, std collections, and tree-backed collections only}The macro rejects at compile time any field whose semantics assume the synced
tree: CRDT types (LwwRegister, Counter, GCounter, PNCounter,
ReplicatedGrowableArray), access-control collections (SharedStorage,
UserStorage, FrozenStorage), and authored collections (AuthoredMap,
AuthoredVector). Private storage has exactly one writer — this node — so CRDT
machinery is overhead with no semantic gain. Use a plain u64 / String / Vec
instead.
xcall: fire-and-forget, node-gated, same-node only
Section titled “xcall: fire-and-forget, node-gated, same-node only”A cross-context call is fire-and-forget. env::xcall(...) does not invoke
anything inline — the host fn only queues the call (pushed onto the logic’s
xcalls list). The queued calls are dispatched after the originating
execution commits. The outcome never returns to the caller; it surfaces as a
separate XCall event.
pub fn ping(&mut self, target_context: ContextId) -> app::Result<()> { let params = serde_json::to_vec(&Params { from_context: ContextId::from(env::context_id()), })?; // Queues the call; dispatched after THIS execution commits. env::xcall(target_context.as_ref(), "pong", ¶ms); Ok(())}
#[app::xcall] // node gates this: an xcall to a non-#[app::xcall] method is deniedpub fn pong(&mut self, from_context: ContextId) -> app::Result<()> { // env::xcall_origin() is set by the NODE and cannot be forged. let Some(origin) = env::xcall_origin().map(ContextId::from) else { app::bail!("pong is xcall-only: a direct call carries no origin"); }; if origin != from_context { app::bail!("provenance mismatch: origin {} != claimed {}", origin, from_context); } self.counter.increment()?; Ok(())}Key properties:
- Same-node, namespace-bounded. xcall reaches other contexts on the same node within the namespace — it is not a network RPC.
#[app::xcall]gating is enforced by the node. An xcall targeting a method without the attribute is denied withXCallNotPermittedbefore the method runs; a direct call to that method still works.env::xcall_origin()is unforgeable. The node sets it; the guest cannot. Use it to reject direct calls (no origin) and to verify a self-reportedfrom_contextagainst the real origin.
See xcall for the dispatch and namespace model.
Events with handlers
Section titled “Events with handlers”app::emit!((event, "handler")) attaches a handler name to an event. The handler
is a method on your state, and it runs on the receiving nodes only, after
the originating write commits:
pub fn set(&mut self, key: String, value: String) -> app::Result<()> { app::emit!((Event::Inserted { key: &key, value: &value }, "insert_handler")); self.items.insert(key, value.into())?; Ok(())}
// Runs on receivers, after commit, possibly more than once.pub fn insert_handler(&mut self, key: &str, value: &str) -> app::Result<()> { self.handler_counter.increment()?; // CRDT mutation: safe to replay Ok(())}Delivery is at-least-once with restart replay: the handler is persisted until every receiver has run it successfully, and a node restart mid-delivery replays it. Therefore a handler must be commutative, idempotent, and side-effect-free — restrict it to CRDT mutations. Do not send a notification, charge a fee, or touch an external system from a handler; it may run twice.
Migrations
Section titled “Migrations”A schema change is expressed with #[app::migrate] (a free function returning the
new state) or #[derive(Migrate)] (per-field rules), optionally guarded by
#[app::migration_check]. The cardinal rule: a migrate must be a pure function
of the old state, because every node runs it independently and the results must
match byte-for-byte.
#[app::migrate]pub fn migrate_v1_to_v2() -> ScenarioCrdtNativeV2 { // read_raw() returns the raw committed bytes of the old root, // with the trailing 32-byte Element id already stripped. let old_bytes = read_raw().expect("no existing state to migrate"); let old: ScenarioCrdtNativeV1 = BorshDeserialize::deserialize(&mut &old_bytes[..]).expect("V1 decode");
ScenarioCrdtNativeV2 { items: old.items, // carry collections across unchanged title: old.title, tags: Vector::new(), // seeded purely from `old`, never from node state }}Determinism is provided by two things the SDK runs around your migrate:
#[app::migrate] executes under storage merge mode (which zeroes the per-node
identity/timestamp stamps), and the generated wrapper calls
__assign_deterministic_ids() on the produced state — the same re-key pass as
#[app::init] — so freshly-seeded collections get position/key-derived ids
instead of Id::random().
#[derive(Migrate)] expresses the same thing declaratively, with a per-field
strategy:
| Strategy | Syntax | Meaning |
|---|---|---|
| carry | (default) | take the field from the old state unchanged |
| new | #[migrate(new = EXPR)] | field is new in this version; initialize with EXPR |
| from | #[migrate(from = old_field)] | renamed; pull from a differently-named old field |
| with | #[migrate(from = f, with = func)] | transform the old field through func |
#[app::state(version = 5, emits = for<'a> Event<'a>)]#[derive(app::Migrate)]#[migrate(from = MigrationSuiteV4, emit = Event::Migrated { /* ... */ })]pub struct MigrationSuiteV5ChangeType { items: UnorderedMap<String, LwwRegister<String>>, // carry (default) details: LwwRegister<String>, // carry #[migrate(from = counter, with = counter_u64_to_string)] counter: LwwRegister<String>, // type change via fn}#[app::migration_check] is a fn(old, new) -> bool predicate the runtime runs
before committing the migration; returning false aborts. The check receives
the still-committed old root and the produced-but-uncommitted new root, so you can
assert invariants like entity-count parity. The witness it relies on is transient
— it rides the Outcome side-channel and is not part of committed state.
The ABI travels inside the bytecode
Section titled “The ABI travels inside the bytecode”The app’s state schema (the wasm-ABI manifest) is embedded as a wasm custom
section named calimero_abi_v1, so it ships inside the module bytes and is
covered by the same blob_id hash as the code. The schema therefore cannot
change independently of the code: any edit to the ABI is an edit to the wasm
blob’s identity.
The two sides of that boundary fail in opposite directions on purpose:
- Reads are validation-gated and fail-open. Loading the embedded schema
returns
Noneif the section is absent or malformed or well-formed JSON that doesn’t pass manifest validation (dangling refs, bad state root). A missing schema doesn’t brick a module — pre-schema modules simply get the default (write-lock, no downgrade check) behaviour. - Writes fail-closed. Embedding the schema (build-time tooling) refuses a malformed or truncated module rather than emitting a corrupt one.
The identity-downgrade rail
Section titled “The identity-downgrade rail”Calimero’s upgrade gate (and the calimero-abi diff CI lint) compare the old and
new manifests to catch a class of silent ACL-stripping upgrades. The
access-controlled wrappers — PermissionedStorage / Ownable / AccessControl
(and SharedStorage) — all normalize in the ABI to a collection carrying
crdt_type = SharedStorage plus the inner type, so their writer-set ACL is
visible in the schema. Swapping such a field to a plain, ungated type (say
UnorderedMap) is a network-wide ACL strip, and surfacing the wrapper as a
distinct shape is what makes the downgrade diff able to see it.
Multi-service bundles
Section titled “Multi-service bundles”A bundle is usually one WASM module — the manifest’s top-level wasm (+ optional
abi). It can instead carry several named modules in a services array, each a
{name, wasm, abi?} record. When services is present and non-empty it
overrides the single-service fields: the node ignores top-level wasm / abi
and installs one blob per service
(crates/node/primitives/src/bundle/mod.rs).
A multi-service application stores those blobs in a services map keyed by name, and
a context runs exactly one of them. Select it with the --service flag on context
create:
meroctl context create <app_id> --service <name>At resolve time a None selection is only unambiguous for a single-service app or a
bundle with exactly one service; a true multi-service bundle (more than one entry)
requires the service name, otherwise the blob cannot be resolved
(Application::resolve_service_blob, crates/primitives/src/application.rs).
Miscellaneous power facts
Section titled “Miscellaneous power facts”A grab-bag of things that are easy to get wrong:
| Concept | What to know |
|---|---|
executor_id vs context_id vs xcall_origin | env::executor_id() is who is calling; env::context_id() is the current context; env::xcall_origin() is the node-set, unforgeable origin of an xcall (None for a direct call). |
app::Result / app::Error | Error wraps a serde_json::Value, has a blanket From for any std::error::Error, and the app::bail! / app::err! macros build/return it. ? on any std error just works. |
| view vs mutate | Only a &mut self method (or #[app::init]) commits state. A &self method never commits, even if it is not marked #[app::view] — so a missing view attribute is a missed optimization, not a correctness bug. |
TestHost limits | Networked operations panic: xcall, ed25519_verify, and networked blob ops are unsupported. Event handlers are captured but not auto-dispatched (call them directly in tests). The default identity passes owner/access checks. |
For the runtime mechanics behind these, see xcall and
crdt-internals.