Managed memory
Every install replaces the entire actor. Managed memory is the machinery that guarantees your app's persistent state survives that.
The mental model
An app declares one or more memory roots. Each root has:
- a current version;
- a schema module per version, which is immutable once released;
- migration edges between versions, which are forward-only.
The compiler generates a persistent variant wrapper per root and a transient runtime binding into it:
type NeutronMemoryType_<scope> = {
#v<version> : NeutronMemorySchema_<scope>_v<version>.Mem;
};
let NeutronMemoryStore_<scope> : NeutronMemoryType_<scope> =
#v<version>(NeutronMemorySchema_<scope>_v<version>.init());
transient let #v<version>(NeutronMemory_<scope>) = NeutronMemoryStore_<scope>;
<scope> is the collision-proof a<app-id-length>_<app-id>_r<memory-id-length>_<memory-id>
stem, so two apps using the same local memory id are independent. The actor is
persistent, so the store is stable by default; the destructured binding and
the module initializer are transient because they are runtime views over
persistent state.
Schema modules
A schema exports only the data contract and its clean-install constructor:
// backend/memory/my_app/v1.mo
// Persistent schema: keep this file immutable after release.
module {
public type Mem = { var name : Text };
public func init() : Mem { { var name = "Neutron" } };
}
Two rules make schemas trustworthy as historical contracts:
Package imports are allowed; relative imports are not. A schema may import
mo:core/Map, but it cannot import an app-local Types.mo or a runtime
service. Otherwise editing shared app types would silently alter a historical
stable contract. Define every app-owned record and variant in the version file.
Runtime code imports the schema, never the reverse.
Migration edges
import V1 "./v1";
import V2 "./v2";
module {
public func migrate(old : V1.Mem) : V2.Mem {
{ var name = old.name; var description = "" }
};
}
Edges should import both their source and target schemas so the compiler checks both sides. They must be bounded and synchronous — large online data transformations need app-specific compatible schemas instead.
Declared in the manifest:
"memory": {
"my_app": {
"version": 2,
"schemas": {
"1": { "src": "memory/my_app/v1.mo" },
"2": { "src": "memory/my_app/v2.mo" }
},
"migrations": [
{ "from": 1, "to": 2, "src": "memory/my_app/v1_to_v2.mo" }
]
}
}
A v3 package that supports installation over v1 must carry v1, v2, v3, and one unique path from each supported start version to v3. Packaging includes every declared root even when the current app module no longer imports it.
What the planner enforces
Before assembly, the planner compares installed and target manifests. It keys
roots by (owner app id, local memory id) and rejects:
| Rejected | Why |
|---|---|
| Restoring or reusing a retired root in the same app lineage | Retirement is permanent for that lineage |
| A changed active schema source hash | Historical contracts are immutable |
| A downgrade | Migration is forward-only |
| Implicit deletion of a live root | Retirement must be explicit |
| Ambiguous or missing migration path | Exactly one path is selected |
A consume target that is not another root retired by the same app in this upgrade, or a root consumed twice | Consolidation is same-app and single-consumer only |
Selected edges are composed into one native Motoko (with migration = …)
expression, and the compiler asks Motoko to type-check the composed migration
and compares the active and candidate stable signatures before any active
metadata write.
A clean install calls only the target schema's init(). It never replays
historical migrations.
The lineage lock
neutron.lock.json is an append-only record of schema source hashes and
migration closures. Existing entries are immutable; removing an active edge
leaves its old entry as history.
It is created by the first managed-memory package build. Do not write it by hand, and do commit it with the app's source. It is the artifact that makes "this v2 schema is the same v2 schema you released last year" checkable.
The manifest is format 3 while the lock remains format 2 — separate lanes.
Retirement and uninstall
Deleting a memory root is not omission. A destructive plan never omits a live root during actor activation, because a Motoko upgrade that drops a stable field discards the data immediately, before anything can verify the new actor.
Instead:
- The native migration renames the active root to an inaccessible optional
NeutronRetiredMemoryStore_<scope>field. The old value survives activation. - A compiler-generated callback clears that optional synchronously inside the verified commit. A trap rolls back both the clear and journal promotion.
- The next successful upgrade omits the field only after it is proven null.
The compiler records the exact bounded retirement descriptors in one canonical owner-scoped stable-signature marker. A former flat V1 marker is rejected rather than migrated. Committed and target descriptor sets are part of the deployment fingerprint, and install preflight checks that the migration plan, the returned descriptors, and the stable marker all agree.
Because retired schemas are recorded, the next compile imports each under a distinct retired alias — which is what allows an uninstalled app to be reinstalled immediately with the same memory id and version but a different active schema.
Consolidating roots
A migration edge may list additional consume roots. The planner permits only
roots owned by the same app and retired in the same target package. The
native migration receives those values after its primary old argument, in
manifest order; the assembler stages the consumed roots through activation, and
the checked commit clears them with every other newly retired root.
Testing obligation
Because a user may install over any version you ever released, test every advertised start version. The compiler will verify types and stable signatures, but only your tests can verify that the data comes out meaning what it meant before.