Applications, Bundles & Services
A context is a shared, replicated state machine. The code it runs is an application. This page explains that code-side model top-down: what an application is, how a bundle packages and distributes one, how those bytes reach a node, how services let a single application carry several WASM modules, and how a context picks the exact bytecode it executes.
Throughout, the running example is a notes app, notes-app, that ships two
modules: an api service that handles user requests and a worker service that
runs background compaction.
What an application is
Section titled “What an application is”An application is the unit of executable code a context targets: content-addressed
WASM bytecode plus its embedded ABI. It is identified by an application id — a
32-byte hash, shown hex — and stored on a node as an ApplicationMeta record
(crates/store/src/types/application.rs). The in-memory view handed to callers is
Application (crates/primitives/src/application.rs):
pub struct Application { pub id: ApplicationId, // 32-byte hash, hex pub blob: ApplicationBlob, // { bytecode: BlobId, compiled: BlobId } pub size: u64, pub source: ApplicationSource, pub metadata: Vec<u8>, pub signer_id: String, // did:key of the update authority (bundles) pub package: String, // package id from the manifest pub version: String, pub services: BTreeMap<String, ApplicationBlob>, // named modules}ApplicationBlob is a pair of content-addressed blob ids: bytecode (the WASM) and
compiled (an optional ahead-of-time-compiled cache; the all-zero blob id when
absent). Because blobs are content-addressed, the same bytes always resolve to the
same blob — see Blobs.
There is exactly one way an application id is derived, because there is exactly one
kind of application: ApplicationId::for_bundle = hash_borsh(package, signer_id),
read off the bundle’s signed manifest
(crates/primitives/src/application.rs). It is therefore version-stable: the
same package from the same signer keeps one id across every release.
That id is about which application. The exact build a context executes is tracked
separately, as a group’s bytecode_id (a BlobId over the bytecode, changing on
every release; see Upgrades), and a signed bundle’s
ContentHash (a digest of a file’s raw bytes, computed on every blob write; the
only externally supplied one is the expected-hash check input, e.g. a CLI flag).
BlobId and ContentHash are distinct types with no implicit conversion between
them: assigning one to the other does not compile. Converting requires going through
raw bytes explicitly, so a swap has to be written on purpose rather than happening by
accident.
The id deliberately leaves out the bytecode: a new version of the same package from the same signer produces the same application id and overwrites the stored row in place. That version-stability is what lets an upgrade move a context to new code without changing the id its members agree on. It is also what makes the id independently re-derivable: every node that verifies the same bundle computes the same id, so an id named on the wire is a claim any receiver can check against the bytes it fetched, rather than a label it has to trust.
The stored ApplicationMeta carries the same shape with the package fields and a
services: Vec<ServiceMeta> list. The services field was added after the initial
schema, so ApplicationMeta’s Borsh decoder treats an old row that ends right after
signer_id as having no services — older records keep deserializing without a
migration.
A bundle is how an application is distributed
Section titled “A bundle is how an application is distributed”An application is published as an .mpk bundle (Mero Package Kit): a
gzip-compressed tar of a manifest.json, the WASM / ABI / migration artifacts it
references, and a required Ed25519 signature. Installing a bundle is what produces
an application — the manifest’s package and signerId fix the application id, and
the artifacts become the stored bytecode and service blobs.
The manifest format, the signing payload (RFC 8785 canonicalization + SHA-256), and the verification rules on install - a valid signature on every path, plus a SHA-256 check of each wasm artifact against the signed manifest - are documented in full under Application Upgrades & Migration → Bundles, signing & install. This page does not repeat that detail; it picks up at what the bundle contains.
There are two ways to install one, and no third:
| Admin API | meroctl |
What it takes |
|---|---|---|
install-application |
app install PACKAGE@VERSION |
{ "package", "version" } - coordinates the node resolves against its own source |
install-dev-application |
app install --path app.mpk |
a local filesystem path to a signed .mpk |
Neither request names a location on the network.
The caller says which application; the node’s own [registry] config says where from.
Like every admin request body, the coordinate request refuses unknown fields, so a body
carrying a url field is a 400 rather than an install of something the caller did not name.
How bytecode reaches a node
Section titled “How bytecode reaches a node”Installing a bundle is one way an application enters a node. The other is that a group names the bytecode its members must run and each member acquires those bytes for itself - the code never rides along with the governance op that names it.
A node has exactly one application source, chosen by
[registry] mode, and there is nothing behind it:
mode |
Applications come from | This node serves application bytecode to peers |
|---|---|---|
http (default) |
the node’s own configured base_url, addressed by package/version |
no |
dht |
context members, over blob share | yes |
app_source (crates/app-downloader/src/source.rs) picks that source once, from the
mode, and hands back the only thing that can be asked for bytes.
An http node holds no peer handle at all, and a dht node dials no registry, so
neither mode can reach the other’s transport even by accident.
The serving side is symmetric with the fetching side: an http node is not a source of
application bytecode, so it neither announces nor serves it
(NodeClient::may_share_blob).
User-data blobs are shared in both modes - only application artifacts are withheld.
One acquisition, four outcomes
Section titled “One acquisition, four outcomes”Every acquisition goes through one resolver, acquire_bytecode
(crates/node/primitives/src/client/application/acquire.rs), which wraps
ApplicationDownloader::download:
| Step | What happens |
|---|---|
| 1 | The node already holds bytecode_id - nothing is fetched, and it is installed from local bytes if no row names it yet |
| 2 | Otherwise the one configured source is asked for the bytes |
| 3 | What arrives is stored, its blob id compared against bytecode_id, and installed - or released and refused |
| 4 | The source had nothing: the node keeps running the version it has and retries on next access |
Step 4 is Outcome::Unavailable, and it is not a failure.
Nothing escalates it into one, and no second route is tried, because there is no second
route to try.
The join bootstrap (sync_context_config) resolves through this path, reading the
coordinates off the target’s stored application row - the row GroupOp::ContextRegistered
seeds from the pair it carries.
Sync resolves through the same path when a context’s bytecode is missing
(sync/manager/blob_fetch.rs), so a dht node acquires it the way a joiner does rather
than opening a blob-share leg of its own.
An upgrade reads the coordinates off the group’s
upgrade ladder instead,
because a bundle’s ApplicationId is version-stable: the row describes the version this
node installed, while each ladder rung names the release it moves to.
Each node resolves against its own registry
Section titled “Each node resolves against its own registry”The op carries coordinates, not a URL. No remote party names an address this node fetches from; each receiver appends the coordinates it was given to its own configured base:
{base_url}/artifacts/{package}/{version}/{package}-{version}.mpkSo the publisher’s registry is not a dependency of every member.
A mirror, or a registry inside an air-gapped deployment, works by pointing each node’s
[registry] section somewhere else.
Coordinates are mandatory on the wire: an application is a signed bundle, whose manifest
carries both halves, so an application without them cannot exist.
The emitting node refuses rather than signs an op no receiver could resolve - see
behaviour that fails loudly below.
Each coordinate must be a single safe path segment (non-empty, at most 128 bytes,
[A-Za-z0-9._-] only, never . or .. and never containing ..), so a coordinate
cannot walk out of the configured base; anything else yields no URL, and the fetch is
refused rather than attempted.
bytecode_id is the only byte authority
Section titled “bytecode_id is the only byte authority”bytecode_id names the bytecode blob, and it alone decides which bytes are acceptable: the
download is stored, its blob id is compared against bytecode_id, and a mismatch deletes
the stored blob and fails the leg.
It is a BlobId - a hash over chunk ids - never a ContentHash, and the two do not
convert implicitly, so the wrong one cannot be threaded in by mistake.
A wrong, stale, or hostile registry can therefore only cause a failure, never a code
substitution - the node keeps the version it runs and retries on next access.
A bundle is checked twice over: its blob id must equal bytecode_id, and the
ApplicationId re-derived from its verified manifest must equal the one governance named
(bind_application_row).
That is what lets the operator’s own base_url be fetched with no host guard at all, which
is what makes a private or air-gapped registry usable. Nothing remote-chosen ever names a
URL, so there is no remote-chosen fetch left to guard.
The corollary is a requirement on whoever runs the registry: serve the .mpk byte for byte.
bytecode_id covers the whole envelope, so re-gzipping the tar, reordering its entries, or
restamping mtimes yields a different blob id.
Every registry fetch then fails the comparison, leaving every node on the version it
already runs, with a blob id mismatch failure to show for it.
A node that cannot address an application says so
Section titled “A node that cannot address an application says so”Because there is no fallback, a node that cannot address an application fails where the mistake was made rather than degrading quietly later:
| Situation | What happens |
|---|---|
| Creating a context, or emitting an upgrade, against an application row that carries no coordinates | both fail with the same error, from the shared registry_coords check: application <pkg>@<version> has no registry coordinates |
mode = "http" with no base_url, installing by coordinates |
the install fails: [registry] mode = "http" needs a base_url to fetch from |
mode = "http" with no base_url, acquiring a group’s bytecode |
logged (no application source is configured) and reported as Unavailable; the context keeps its current version and retries |
mode = "dht", installing package@version by name |
nothing to ask - the peer route needs a context and a bytecode_id, so the install reports the coordinates as unpublished |
The last row is the shape of dht mode generally: a dht node joins contexts and follows
upgrades normally, but it cannot bare-install an application by name.
It gets applications from governance, or from a local .mpk path.
Services: one application, many modules
Section titled “Services: one application, many modules”A bundle is usually a single WASM module: the manifest’s top-level wasm (plus an
optional abi). It can instead declare a services array of named modules, each a
{name, wasm, abi?} record. The rule is in
crates/bundle/src/lib.rs:
If
servicesis present and non-empty, it takes priority overwasm/abi.
So notes-app’s manifest carries:
services: [ { name: "api", wasm: { path: "api.wasm", hash, size } }, { name: "worker", wasm: { path: "worker.wasm", hash, size } },]On install the node ignores the top-level wasm/abi, adds one blob per service,
and records them in ApplicationMeta.services (as ServiceMeta { name, bytecode, compiled }) — surfaced on Application as the services map keyed by name. A
single-service bundle leaves services empty and uses the top-level blob instead.
Resolving which blob to run
Section titled “Resolving which blob to run”One application, many modules — so something has to pick the module. That is
Application::resolve_service_blob(service_name)
(crates/primitives/src/application.rs); the stored side mirrors it as
ApplicationMeta::resolve_service. The rule:
service_name |
services map |
Resolved blob |
|---|---|---|
None |
empty | the default blob (single-service app) |
None |
exactly one entry | that one service’s blob |
None |
two or more entries | None — ambiguous, a name is required |
Some("api") |
contains api |
the api service’s blob |
Some("api") |
no such entry | None — service not found |
pub fn resolve_service_blob(&self, service_name: Option<&str>) -> Option<ApplicationBlob> { match service_name { None if self.services.is_empty() => Some(self.blob), // single-service default None if self.services.len() == 1 => /* the only service */, None => None, // multi-service needs a name Some(name) => self.services.get(name).copied(), }}The practical consequence: a single-service application is always runnable with no
name, but for notes-app a context must declare whether it is the api or the
worker — None does not resolve.
How a context runs one
Section titled “How a context runs one”A context binds to exactly one application and one service. Both live on its stored
ContextMeta (crates/store/src/types/context.rs):
pub struct ContextMeta { pub application: key::ApplicationMeta, // keyed by application id pub root_hash: Hash, pub dag_heads: Vec<[u8; 32]>, pub service_name: Option<Box<str>>, // which service this context runs}service_name is chosen when the context is created (meroctl context create <app_id> --service <name>) and never changes for the life of the context. At
execution time the node:
- looks up the application row for
ContextMeta.applicationand takes its bytecode blob; - passes that blob together with the context’s
service_nameto module loading (get_module→get_module_for_blob→application_bytes_from_blob(blob, service_name)), which, for a bundle blob, extracts and compiles the selected service’s WASM; - runs the resolved module against the context’s state.
What actually runs once the blob is resolved — the host ABI boundary, the storage layers, the determinism contract — is Application Execution.
An upgrade swaps the executed bytecode while the application id stays stable (that is exactly the version-stable bundle id above). The context keeps targeting the same application id and the same service name; only the blob behind it advances, version by version.
Where this leads
Section titled “Where this leads”Next, the Context Lifecycle — how a context that runs this application is created, joined, left, and deleted.
An application’s bundle id is anchored on its signerId — the did:key that holds
update authority. That signing key is one of the distinct key roles
in Calimero (an app-publisher key, separate from a node’s transport, member, and scope keys).