Skip to main content

Actor assembly

The assembler takes a set of app configs — kernel plus every installed app plus whatever is being installed — and generates one persistent Motoko actor class.

shared({ caller = NeutronInstaller }) persistent actor class Class<system>() = NeutronActor {
// imports
// persistent memory wrappers
// transient module initializers
// capability projections
// function wrappers
NeutronKernel.kernel_authorized_add(NeutronInstaller);
}

The generated actor unconditionally references NeutronKernel, so the kernel config must be present in any assembled set. The final statement adds the installer's caller to the authorization set; Motoko supplies anonymous for that binding during post_upgrade, so the kernel's add method scrubs anonymous and persists only a genuine initial installer.

The current assembler identity is neutron_actor_v25.

Import names and injection protection

Each config's module is imported under a compiler-owned, length-delimited alias:

import NeutronModule_a<id-length>_<config.id> "<config.entry>";

The length prefix keeps app ids out of the generated identifier namespace — two ids cannot produce the same alias by concatenation.

Before generating anything, the assembler validates every value that will reach generated Motoko. Two checks do the work:

CheckApplied toPermits
no_injectIdentifiers and literals: id, entry, src, kernel init args, function names, func.type, func.allow, each func.arg, memory ids and versions, scheduled-task id/method, POST mount id/handler, public-ingress protocol/id/handler, dependency alias and provider names, emitted deployment/compiler idsBooleans, numbers, and strings of a-z, A-Z, 0-9, _, .
no_module_pathImport paths, which legitimately contain characters no_inject rejectsSafe module path shapes

The checked fields are enumerated rather than discovered by a recursive walk. Between them they reject quotes, whitespace, slashes, and semicolons — anything that could terminate a generated string or statement. Canonical app ids are also constrained to 4–30 characters.

Method namespacing

ConfigEmitted method name
KernelThe logical name, unmangled
Ordinary appapp_<app-id>__<logical-method>

Because the canonical app-id grammar forbids leading, trailing, and repeated underscores, __ is an unambiguous boundary owned by the compiler. Two apps can therefore use the same logical method name safely.

Internal functions get a private helper named with the same collision-proof stem:

NeutronAppFunction_a<app-id-length>_<app-id>_r<method-length>_<logical-method>

These are not public actor methods and do not appear in Candid. Dependency wiring and scheduled callbacks refer to them by that generated name.

The registry records the logical name and the exact derived candid_name, and strict normalisation rejects any other mapping. Kernel self-call tooling presents logical names and invokes physical ones.

The generated wrapper

In the sketches below, <app-scope> is the app-only stem a<app-id-length>_<app-id>. (Memory bindings use a longer stem that also encodes the memory id — see Managed memory.)

For a query:

public query({ caller = NeutronCaller }) func <physical>(
NeutronRequest : NeutronModule_<app-scope>.<name>_Input
) : async NeutronModule_<app-scope>.<name>_Output { … }

For an update, the same shape with public shared.

The _Input / _Output aliases are the app method's real authored Candid shapes. This is worth being precise about, because it is a common source of confusion:

Binary fields are not a special wrapper ABI. Blob may occur directly or at any finite nested position, and multiple blobs are permitted. The assembler never removes a binary field, appends a final body argument, wraps a result in a transport envelope, or derives a signature from a manifest declaration.

Every ordinary-app entry wrapper, public or internal, first proves that its installation scope is active:

assert(NeutronKernel.scope_active(NeutronAppScope_<app>));

A public wrapper then asserts owner authorization unless the manifest explicitly declares allow: "unauthorized". Private internal helpers have no external caller and are reachable only through compiler-owned wiring. An update wrapper brackets the call with instruction metering — begin, invoke, finish, return the saved result. Queries are not metered by this app-usage instrumentation. Both the success and caught error paths call finish; a trap rolls back the message, including accounting mutations.

Compiler-injected arguments

The wrapped method receives NeutronRequest plus any names listed in the manifest's arg array. Those are the only arguments added outside the request, and application binary data is never injected this way.

The kernel uses this for caller and this. Ordinary apps may request caller, the immutable canister principal scalar, their own active memory, a declared scheduled task's invocation-scoped backend calls, or — for an exact paid public-ingress handler — public_ingress_cycles. They cannot request the actor value itself.

async: "async*" makes the wrapper use await*, executing the local computation inline until it reaches a real external await.

The app's environment

An ordinary app's Init receives one anonymous record and never a manifest-authored positional list.

public type AppBackendEnvironment = {
installation : { network_id : Blob };
stable_memory : { private_data : Memory.Mem };
app_calls : {
contacts : { lookup : ({ principal : Principal }) -> ContactResult };
};
capabilities : {
backend_calls : NeutronCapabilities.BackendCallsV1;
stable_store : NeutronCapabilities.StableStoreV1;
};
};
GroupDerived from
installationCompiler-owned trusted identity
stable_memoryThe app's own active managed roots
app_callsIts exact declared dependencies
capabilitiesOnly interfaces selected under backend.capabilities

Empty groups are omitted; an app with none receives Init().

The app declares this type locally and narrowly. The generated value may be structurally wider — record width subtyping lets an app that does not consume installation simply not mention it. Motoko's structural typing verifies that what is generated and what is consumed agree.

The app receives neither the installation scope nor NeutronKernel.

installation.network_id

A 32-byte nonzero value derived from the trusted deployment root key:

SHA256(
u32be(len("neutron.network-id.v1")) || UTF8("neutron.network-id.v1") ||
u32be(len(root_key_spki_der)) || exact_root_key_spki_der
)

Production uses the compiler-pinned IC mainnet root; local provisioning supplies the exact root key of its attached PocketIC instance. A manifest, app, browser, runtime config, or UI cannot supply or replace it.

The actor persists it in immutable stable state, asserts it is 32 nonzero bytes before transient app initialisation, and requires any compatible upgrade's compiled value to equal the persisted one. It is public identity, not a bearer capability — it grants no calls, storage, or keys. Its only job is to stop network-scoped protocols from booting with an app-selected or provisional zero identity.

Capability projection

Selecting a capability interface causes the assembler to inject exactly one attenuated leaf. For example:

capabilities = {
https_outcalls = https_outcalls_capability(NeutronAppScope_<app>);
certified_assets = NeutronKernel.certified_assets_capability(NeutronAppScope_<app>);
};

The returned leaf captures the scope; app source never receives NeutronAppScope_<app>, the kernel factory, a management actor, or a raw primitive.

The assembler also emits two tables from the same canonical plan:

configure_app_capabilities — one complete, installation-scoped declaration table covering backend-call policy, randomness, chain-key slots, stable stores, HTTPS endpoints, vetKeys slots, connection tuples, public-ingress routes, and mode-specific HTTP route configuration.

configure_capability_registry — one entry per broker-enforced runtime resource, each carrying the exact app scope, plan fingerprint, kind, resource id, independently versioned API, authority fingerprint, grant mode, and toggle policy.

Configuration is actor-local target state. During activation a service may create provisional, fail-closed state needed to prove that the target fits, but that state grants no runtime authority. The verified install commit activates the target scopes and reconciles persistent resources: unchanged exact authority keeps its disabled state and bounded usage, changed authority resets, and removed resources disappear.

For a retained installation scope, Certified Assets configuration is monotonic: numeric reservations may widen, but the capability or its collection set cannot be added, removed, or reinterpreted in place. A shape change requires an explicit uninstall/reinstall so the old installation scope can be retired.

Compile-time ceilings

The assembler enforces global and per-app limits before actor construction, so an over-limit install fails at compilation rather than at activation. Selected target-wide ceilings are:

ResourceCeiling
Installed configs256, including Kernel
HTTPS outcall endpoints2,048 across all apps
Chain-key signing slots2,048
vetKeys slots128
Stable stores2,048 stores, 65,536 entries, and 256 MiB declared data
Scheduled tasks64 per assembled actor (2 per app)
Non-authorized HTTP POST handlers1,024 declared calls per hour and 64 MiB of declared maximum replay-response bytes per hour
Public ingress2,048 routes and 16,384 update calls per hour
Backend-call install reservations2,048
Runtime capability registrations8,192
Certified AssetsAggregate worst-case charged bytes, arena bytes, and extents must fit the fixed physical-reservation policy

The kernel service asserts the same limits at runtime as defence in depth.

What is deliberately not in the environment

Two exclusions are worth calling out because they look like omissions:

Scheduled task handles. For each callback invocation the assembler creates a fresh exact task_capabilities record from the canonical plan. Its backend-call leaf has an independent budget and a scheduler-owned lease. Completion, failure, or the owner disabling the task permanently revokes it, and broker calls recheck the lease before dispatch and after every await.

public_ingress_cycles. Resolved only as an explicit function argument inside the compiler-owned dispatcher switch, for a synchronous handler used exclusively by paid canister update routes. Declaring it also removes that function's ordinary owner-authorized wrapper, making it reachable only through the paid dispatcher.

Both are excluded for the same reason: an app-wide field would be ambient authority available to every method, including ones the user never approved for that purpose.

Runtime verification

The generated kernel_runtime_info query reports deployment, assembler, and compiler ids plus active app versions, each app's capability-plan fingerprint, and memory schema hashes. This is how the installer verifies which actor is running and which plan projection it contains after a one-way self-upgrade.

Its generated body applies the same owner-authorization assert as any protected manifest query.