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:
| Check | Applied to | Permits |
|---|---|---|
no_inject | Identifiers 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 ids | Booleans, numbers, and strings of a-z, A-Z, 0-9, _, . |
no_module_path | Import paths, which legitimately contain characters no_inject rejects | Safe 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
| Config | Emitted method name |
|---|---|
| Kernel | The logical name, unmangled |
| Ordinary app | app_<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.
Blobmay 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;
};
};
| Group | Derived from |
|---|---|
installation | Compiler-owned trusted identity |
stable_memory | The app's own active managed roots |
app_calls | Its exact declared dependencies |
capabilities | Only 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:
| Resource | Ceiling |
|---|---|
| Installed configs | 256, including Kernel |
| HTTPS outcall endpoints | 2,048 across all apps |
| Chain-key signing slots | 2,048 |
| vetKeys slots | 128 |
| Stable stores | 2,048 stores, 65,536 entries, and 256 MiB declared data |
| Scheduled tasks | 64 per assembled actor (2 per app) |
| Non-authorized HTTP POST handlers | 1,024 declared calls per hour and 64 MiB of declared maximum replay-response bytes per hour |
| Public ingress | 2,048 routes and 16,384 update calls per hour |
| Backend-call install reservations | 2,048 |
| Runtime capability registrations | 8,192 |
| Certified Assets | Aggregate 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.