Module lifecycle
The five states are on Managing modules, from the operator’s side. This is the same machine from inside the module — what core calls, when, and what it does with a throw.
The scan
Section titled “The scan”The loader reads modules/*/module.json from the filesystem synchronously, at require
time. The database is not consulted: what is on the volume determines what mounts.
MODULES_DIR defaults to <repo>/modules, and Compose sets it to /app/modules. A
missing modules directory is not an error — “no modules installed” is the normal state of
bare core, and the loader must not make the mount mandatory to boot.
Modules load alphabetically by id, deterministically. There is no dependency
resolution between modules, and alphabetical order is the honest way of saying so: any
other order would imply a precedence nobody is computing. Do not build a module that needs
to load before or after another one.
Validation, in order
Section titled “Validation, in order”Each step runs against your module. A failure at any step is your module’s failure and nobody else’s.
module.jsonparses, has no unknown keys, and itsidmatches the directory name.coreApiis satisfied by core’sMODULE_API_VERSION.- Declared
mountsprefixes are well-formed and collide with nothing. - Declared
extensionsslots all exist. schemaandpurgefiles exist and are readable, and their table names are namespaced or allowlisted.require()of your server entry succeeds and exports a function.register(ctx, api)returns without throwing, and registers exactly whatmodule.jsondeclared.
Step 7 is worth reading twice. The manifest is not documentation of what you register — it is a claim core holds you to. Registering something you did not declare fails, and so does declaring something you do not register.
Then the module runs
Section titled “Then the module runs”For each module that passed:
- Schema replay — your
schema.sqlfragment is applied. It must be idempotent; it runs on every boot. - Routes and registrations mount.
onBoot(ctx)is called, if you export one. This is where long-lived work belongs: opening a stream, starting a poller, connecting to something.
On shutdown, onShutdown() is called. Disabling a module from the panel dispatches it
too, so the module actually stops — releases its sockets, closes its streams — rather than
merely becoming unreachable.
Enabling is deliberately not the mirror image: there is no onBoot re-dispatch, so the
panel offers a restart instead. If your onBoot is expensive or stateful, that asymmetry is
in your favour.
Failure is contained, by construction
Section titled “Failure is contained, by construction”A module that fails to load never takes the site down.
The loader try/catches the module’s entire lifecycle — require, validation, registration,
schema replay, onBoot — not merely failures that surface after a router object was
returned. Any failure at any point marks that module startup_failed, records the reason,
and the site comes up with that module’s routes and navigation absent.
Two consequences to design around:
- A failed module is retried on every restart. There is no backoff and no quarantine. A deterministically broken module re-records its failure each boot, which is the honest thing for it to do.
disabledis the only state a boot leaves alone. Every other non-disabled module is reset toenabledat boot and then recorded asstartedorstartup_failed. Disabling is an operator’s decision rather than an outcome, so it survives restarts untouched.
Where the loader is specified
Section titled “Where the loader is specified”MODULE_API.md
Part 4 is the normative account of everything on this page, including the exact position of
the load() call in app.js and why it is load-bearing in both directions.