Modules had no way to be told anything. Announcements — the first real product Ekklesia will absorb — has zero environment variables and compiles a LAN IP address into its source, so this is the contract standing between it and the module system. - A module declares what it needs in its manifest, using six bounded types (string, integer, boolean, enum, url, secret) with no nesting and no regular expressions. Static declaration is what lets the platform validate configuration *before* deciding whether a module may start: an enabled module missing a required value is reported `blocked · configuration_missing · <key>` rather than taking startup down from inside its own `start` hook, and Doctor reports the same while still importing no module code. That forces `manifestVersion: 5`, a hard cutover. - Ordinary values persist in one Core table (migration 0005), one row per (module_id, key), stored as text until validated against the declaring module's current manifest. Nothing is coerced; an invalid stored value blocks the module rather than falling back to the default. A key the manifest no longer declares is retained and ignored. - Secrets are never persisted. There is no encryption at rest in this repository, so a `secret` key is deployment-supplied and mapped in by the host under a name the module never sees. A secret declaration has no `default` field, the write API refuses secret material, and the HTTP view has no `value` field for one — absent by construction, not filtered. - Modules receive a frozen `context.config` on the registration, runtime, and HTTP handler contexts: the same object identity, per module, beside `services` rather than inside it (services is deliberately one shared instance). No environment access, no database reference, no way to reach another module's values. - Configuration decides eligibility through ADR 0028's existing resolver: three new blocked reasons, each with a distinct operator fix, propagating to required dependents through the same mechanism as a disabled module. - Changes take effect at the next restart; both resources report restartRequired. New restricted permission `core.modules.configuration.manage`, separate from lifecycle, at the sibling prefix `/api/v1/module-configuration`. - Migration participation stays independent of configuration validity. Endpoints and credentials for shared infrastructure stay platform concerns; a module declares which bucket it needs, not an S3 endpoint. See ADR 0029. Co-Authored-By: Claude Opus 5 <[email protected]>
26 KiB
0028: Module operational lifecycle — enablement, eligibility, and what "running" means
Context
Everything upstream of this decision exists. Core discovers modules, validates manifests and dependencies, orders them topologically, composes permissions, starts a runtime, mounts module HTTP routes, publishes module-owned UI, and resolves a Dashboard arrangement over it.
What did not exist was any notion of a module being on or off. The accepted module set was exactly "the modules found on disk at startup", and three separate places in the repository said so in almost the same words:
ModuleLifecycleStage(@ekklesia/contracts) names nine stages, but nothing implements a state machine over them: there is no installer, no enable/disable, no trust store, and no per-module status record. — ADR 0025
That vocabulary — discovered, validated, configured, registered, migrated, initialized,
started, stopped, failed — was aspiration written down as a contract. Nothing produced any of
those values, and nothing consumed them.
Before migrating a real product into the module system, the platform needs an answer to a small set of operational questions that a church running this software will genuinely ask: is this module on? should it start? what happens to the things that depend on it? why is it not available? This ADR records the smallest model that answers them honestly.
Two constraints came from the repository rather than from principle.
Startup is all-or-nothing, deliberately. ADR 0015 and ADR 0020 built that guarantee carefully:
a module joins the rollback set the moment its start hook is invoked, capabilities and routes are
published only after every module registers and starts, and a failed start unwinds in reverse order
under bounded deadlines. ADR 0025 then depends on it — "the published set is also the running
set" — as does ADR 0026's inventory and ADR 0027's navigation.
There is exactly one persistence precedent, and it is recent. ADR 0027 added two Core-owned, platform-global tables for Dashboard configuration, with the explicit rule that a module with no stored row is still visible. That precedent shaped both the schema conventions and the default policy below.
Decision
Two axes, never one status word
configuration enabled / disabled persistent, administrator-owned
runtime running / disabled / blocked ephemeral, observed per boot
MODULE_LIFECYCLE_STAGES is replaced by these two, because a contract should name states that
something actually produces. Both are in @ekklesia/contracts; both are observable through
GET /api/v1/module-lifecycle.
They are kept apart because they answer different questions and change at different times. Collapsing them into one word would force a lie in one direction or the other the moment an administrator changes a setting on a process that is still running.
installed means "a valid module package on disk that discovery accepted" — and nothing else
There is no installer, no installation registry, no package database, and no install source. This pass deliberately did not create one. The honest chain today is:
present on disk
↓ discovery validates manifest, package, entrypoints, dependencies, cycles
discovered
↓ platform-owned enablement configuration
enabled
↓ required-dependency resolution
eligible
↓ module runtime import, register, start
running
A future installer's job is to put a validated package on disk and record an enablement decision, then reconcile on restart. Nothing in this contract assumes a source-control checkout, an image, or a registry, so that installer remains possible without changing anything here.
Enablement is host configuration, and a module can never see it
One Core table, ekklesia_core_module_state (migration 0004_module_lifecycle_state), with
exactly module_id, enabled, and two audit timestamps. No install source, download URL, update
channel, signature, marketplace metadata, or JSON blob — those are the columns an installer would
need, and their absence is what stops one being built here by accident.
module_id carries no foreign key, for the same reason ekklesia_core_dashboard_placement
does not: modules are packages, not rows. A record for a module that is no longer deployed is
retained and ignored, so redeploying restores the decision rather than resetting it.
A module never declares, reads, or influences its enablement. Nothing in a manifest, a capability, a permission, or a lifecycle hook can reach it, and a module is never told it is enabled — its host either loads and starts it, or does not. That is what keeps the same module implementation reusable under a future standalone host, whose loading decisions are its own. A module asking "am I enabled in Ekklesia?" would be a module that only runs inside Ekklesia.
A newly discovered module is enabled, and the default is the absence of a row
enabled has no column default. The platform's default is expressed by there being no record at
all, and resolved in resolveModuleEligibility.
This preserves the behavior every earlier phase had — deploying a module is sufficient to run it — requires no initialization write when a module first appears, and mirrors ADR 0027's rule that a module with no placement is still visible. Default-disabled was rejected: with no management UI, it would mean every deployment ships a platform where nothing works until someone runs a command, and it would make "install a module" a two-step operation for no safety gain, since the code is already trusted the moment it is on disk (see Trust is unchanged).
An explicit enabled: true record is still meaningful and is retained: it records that somebody
looked and left it on. The read model reports explicit alongside enabled for exactly that
reason.
Required dependencies block; optional dependencies never do
Disabling storage does not silently re-enable it because something needs it. It makes every
module that requires it ineligible, transitively:
storage disabled
↓
media blocked · required_dependency_disabled · storage
↓
announcements blocked · required_dependency_blocked · media
Following the chain always terminates at a ..._disabled entry, so an operator can walk from any
blocked module to the one decision that caused it.
A required capability dependency blocks identically, because it is one: a capability's only provider is a module, and if that module does not run, nothing registers the capability. The reason names the providing module — the thing an administrator can act on — with the capability id alongside it.
An optional dependency never blocks. A module declaring one has already accepted that it may be
absent (discovery permits an optional dependency to not be installed at all), and "disabled" is the
same situation arriving a different way: context.capabilities.resolve(...) returns undefined,
exactly as it does when the provider was never installed. Required and optional behavior are tested
separately and deliberately.
A dependency that is missing, version-incompatible, duplicated, or cyclic has no eligibility state at all. Discovery rejects the whole inventory before enablement is ever consulted, so the dependency graph is validated in exactly one place.
Eligibility is a filter over discovery's order, never a re-sort. Blocking propagates along the same edges discovery used to produce a topological order, so removing an ineligible module can never leave a module ahead of a required dependency it still has.
Startup atomicity is preserved, deliberately
Startup remains all-or-nothing across the eligible set. A module that fails to load, register, or start still aborts API startup and unwinds through Core's existing reverse-order rollback.
This was the sharpest question in the pass, and changing it would have been a much larger decision than this ADR should make by accident. ADR 0015 and ADR 0020 built that guarantee on purpose, and ADR 0025, ADR 0026, and ADR 0027 all rest on "the published set is the running set". Moving to per-module failure isolation means the permission registry, the route snapshot, the publication registry, and the navigation view each have to be derived from a set that is only known after partial failure — a real design, and a separate one.
The cost is stated plainly rather than hidden: one broken module still takes down the whole
platform. Enablement is what makes that recoverable, and this pass closes the one path that would
otherwise be a deadlock — the HTTP lifecycle API is served by the process a bad module takes down,
so pnpm modules:disable -- --module <id> exists as an out-of-band operator command, in the same
shape as pnpm authorization:bootstrap-owner: explicit, never HTTP-exposed, never inferred.
Cross-reference — see ADR 0029. The blocked-reason vocabulary this ADR introduced has since gained three configuration reasons (
configuration_missing,configuration_invalid,secret_missing), andblockedbecame a discriminated union so a configuration reason names a key where a dependency reason names a dependency. Everything else here is unchanged: startup atomicity, the absence of afailedstate, the default-enabled policy, and the dependency propagation those new reasons flow through.
There is consequently no failed runtime state, because no live process could ever report one.
Naming a state nothing can produce is exactly the mistake MODULE_LIFECYCLE_STAGES made. It is the
first thing a future isolated-startup pass adds.
Runtime failure is never persisted as configuration
enabled configuration → stored
failed to start during this boot runtime → not stored
A restart naturally retries every enabled module. There is no failure counter, no quarantine, no backoff, and no "disabled because it crashed" write — a platform that disabled a module on its behalf would be making an administrative decision nobody asked for, and the operator would have no way to tell it apart from one they made.
running means its start hook completed, and nothing more
It does not mean every external resource the module uses is reachable, now or ever. A module that starts successfully and then cannot reach its object storage is a module with a health problem, not a module with a lifecycle problem.
This pass adds no runtime health-probe contract, no readiness hook, and no per-module health endpoint. A module that wants to report its own health does so through its own API and its own UI, like any other domain concern. The limitation is recorded here rather than left to be discovered.
Configuration changes take effect at the next start, and the API says so
There is no hot module installation, removal, or reloading (ADR 0015, unchanged), so PUT writes a
decision and changes nothing about the running process. The response reports all three facts:
{ "id": "announcements", "version": "1.0.0",
"configured": { "enabled": false, "explicit": true },
"runtime": { "state": "running" },
"restartRequired": true }
Reporting runtime: disabled there would be a lie about a process still serving that module's
routes. restartRequired is computed by re-resolving eligibility against live configuration and
comparing with what the runtime reports it actually started, not with what eligibility
predicted — so it is symmetric (enabling a stopped module marks it too) and transitive (disabling a
dependency marks its dependents, because they really will stop).
Hot-unloading to make a button feel immediate was rejected: it would trade a real guarantee — that a trusted module's ESM evaluation happens exactly once and can never be rolled back — for a cosmetic one.
Exposure follows eligibility, structurally rather than by a downstream check
apps/api/src/server.ts passes the same eligible list to startDiscoveredModuleRuntime and to
createModuleUiPublicationRegistry. There is one authoritative eligible set, and everything that
exposes a module consumes it:
- HTTP routes. A disabled module is never imported, so its
registerhook never runs and it publishes no routes. There is no per-request "is this module enabled?" check anywhere, because there is nothing mounted to check — an unmounted path gets the platform's ordinary404. - UI publication. Its artifact URL returns the same
404an uninstalled module does, even to a caller who knows the exact id and version. ADR 0025's rule — the existence of an artifact on disk must not be what makes it servable — now covers "discovered but disabled" as well. - Inventory and navigation. Absent from
GET /api/v1/module-uiand fromGET /api/v1/dashboard/navigation, by construction, since both derive from the publication registry. Its Dashboard placement row is retained and simply renders nothing, exactly like an uninstalled module's (ADR 0027). - Lifecycle hooks. Never invoked, in either direction: shutdown runs no
stophook for a module that never started.
A module whose enablement changed but whose process has not restarted stays fully exposed. It really
is still mounted and still serving; hiding it now would break a viewer mid-session for a change that
has not taken effect, and restartRequired is where that pending change is reported instead.
Permission composition still spans the whole discovered inventory
Unchanged, deliberately. ADR 0015 fixed the startup order so that discovery and permission composition complete before any pool, Better Auth instance, or module entrypoint exists — a composition failure therefore leaves nothing to close. Filtering permissions by enablement would require reading the database first and would invert that order for no safety gain.
A disabled module's permissions therefore stay registered and grantable. Nothing enforces them, because it mounts no routes, and grants survive an enable/disable cycle intact — which is what an administrator would expect, and lets a permission be granted ahead of enabling a module.
Migration participation is deployment, not runtime state
deployed schema/migration ownership ≠ runtime enabled state
Migration discovery, planning, and application continue to read the deployed inventory, regardless of enablement. They are offline, Core-owned, explicitly operator-invoked commands and must not depend on runtime configuration. A disabled module's tables must still be migrated, or enabling it later would require a schema change in the middle of a restart. Nothing about the planner, destructive-change approval, locking, checksums, or transactional guarantees changed.
The URL is a sibling of the module namespace, not a child
GET /api/v1/module-lifecycle
PUT /api/v1/module-lifecycle/<module-id>/enabled { "enabled": boolean }
The obvious /api/v1/modules and /api/v1/modules/:id/enabled are wrong, for the exact reason
ADR 0025 gave for /api/v1/module-ui. Core's route grammar accepts enabled as an ordinary static
segment, so a module named announcements may legitimately register PUT /enabled — mounted at
exactly /api/v1/modules/announcements/enabled. Publishing the platform's own lifecycle mutation
there would put Core and an installed module in one URL namespace with registration order deciding
which one answers, for a route that changes whether modules run. A sibling prefix cannot collide.
PUT against a module id not in the boot inventory is a 404. Unlike an inert Dashboard placement,
an enablement record is operational configuration; accepting one for an arbitrary identifier would
make this an unbounded write surface for a delegable permission. The CLI enforces the identical rule
by running the same manifest-only discovery. Existing records for undeployed modules are still
retained and ignored.
The body is closed and structured — any key beyond enabled invalidates the whole request rather
than being dropped — matching ADR 0027's mutation contract exactly. There is no configuration blob.
One restricted permission, gating read as well as write
core.modules.lifecycle.manage, baseline restricted. The read is not a milder version of the
write: this resource is the only place a disabled module, a blocked module, and the
required-dependency edges between them are visible at all. core.modules.ui.read deliberately
exposes the mountable set to every signed-in user; this exposes the parts of the installation that
are deliberately not running, which is not the same audience. A second, weaker read permission over
the same resource would be ceremony.
It is a platform permission, never a module's, and holding it confers no authority inside any
module. Mutations additionally require an exact trusted Origin and a bounded body before
authorization work begins, like every other Core mutation.
Neither response carries a filesystem path, package name, manifest path, entrypoint, caught error, or stack. The only identifiers are module ids, capability ids, and fixed reason codes from closed sets — all values the platform itself produced and validated.
Startup fails closed when the configuration cannot be read
startApi reads enablement before importing any module entrypoint. If that read fails — an
unmigrated or unreachable database — startup refuses, with Core's sanitized
MODULE_LIFECYCLE_READ_FAILED. It cannot know what an administrator decided, and starting a module
they disabled is worse than not starting at all. This is a behavior change: previously the API would
bind a listener against an unmigrated database and fail every request instead. Nothing is created or
repaired on that path.
Diagnostics answer "why is this module unavailable?" without the logs
pnpm doctor gains one check, modules.lifecycle, in its existing Modules section — not a second
diagnostics framework. It is skipped when discovery failed or DATABASE_URL is unset (configuration
lives in PostgreSQL, and reporting a guessed default is worse than skipping). A disabled module
is a pass with a detail line: it is a decision, not a problem. A blocked module is a
warning: an enabled module that cannot run is the anomaly worth surfacing. Every rendered value is
re-validated before interpolation, matching the file's existing safeModuleDetails discipline.
Trust is unchanged
Installed modules remain trusted compiled code running in the API process with its PostgreSQL authority. Enablement is an operational control, not a security boundary: a module's code is on disk and could be started by any operator with filesystem access. Nothing here sandboxes, isolates, or restricts a module that does run, and nothing here should be described as if it did.
Validated against a real product
The Announcements repository (The_River/Announcements, commit 9aab486) was walked against this
model as a reference consumer. It was not modified, and nothing about it entered Core.
It is a small Express + Socket.IO server that polls an S3-compatible bucket listing every 30
seconds, advances a slide index on a 7-second timer, broadcasts state to a full-screen React
client, and toggles a blank-screen mode from an MQTT topic. It has no database and no
authentication.
Three things it does made the two-axis model earn its keep.
Its storage is unreachable regularly, and that is not a lifecycle failure. loadSlides()
already catches its own fetch failure, logs, and retries on the next tick. Under this model the
module stays running, because its start hook completed; "slides could not be loaded right now"
is the module's own health, surfaced through its own UI. Had running been defined as "everything
it needs is reachable", a router reboot would have looked like a lifecycle event.
Its mode: on/off toggle is emphatically not enablement. It blanks the screen, is flipped
frequently by whoever is running a service, and must take effect instantly. Module enablement is an
administrative deployment decision that takes effect at the next restart. Mapping one onto the other
would have pushed a per-service operational toggle into Core's lifecycle and made it restart-based —
which is precisely the kind of conflation the separate configuration and runtime axes exist to
prevent. (Per the platform direction, that control belongs to a future Companion module speaking the
module API; Ekklesia Core will not use MQTT, and this ADR designs nothing around it.)
Its long-lived timers are what stop is for. Neither setInterval is ever cleared today. As a
module it must clear both in a stop hook that is safe after a partially completed start (ADR
0020) — a migration task, not a Core gap, and comfortably inside the platform's 10-second hook
deadline.
The rest of the walkthrough came out as designed: disabled means no routes, no UI, no navigation entry, no timers, and no hooks; its migrations would still be discovered and applied; a future required dependency being disabled would block it with a named reason; and nothing in its feature code would ever ask whether it is enabled, so the same implementation stays deployable standalone.
Alternatives
- Implementing the nine
MODULE_LIFECYCLE_STAGESnames. Rejected: most name a transition rather than a state, several are unobservable, and shipping them would have repeated the original mistake of publishing a vocabulary nothing produces. - A
failedruntime state now. Rejected while startup is all-or-nothing: no live process could ever report it. - Per-module failure isolation in this pass. Deferred deliberately. It is a genuine improvement and a genuinely larger decision; four accepted ADRs depend on the current guarantee.
- Default-disabled for a newly discovered module. Rejected: it breaks every earlier phase's behavior, requires an administrative step with no UI to perform it in, and buys no safety the trusted-code model does not already concede.
- A
disabledcolumn default. Rejected: it would materialize a decision nobody made and destroy the distinction between "at the default" and "explicitly set". - Persisting startup failures. Rejected: it turns a transient runtime fact into administrative configuration an operator did not write and cannot distinguish from their own.
- Filtering permission composition by enablement. Rejected: it inverts ADR 0015's deliberate startup order for no gain, and would make grants vanish across an enable/disable cycle.
- Filtering migration discovery by enablement. Rejected: schema ownership is a deployment fact, and a disabled module's tables must already exist when it is enabled.
- Enable/disable at
/api/v1/modules/.... Rejected: it collides with the module route namespace, where registration order would decide which handler answers. - A separate read permission. Rejected: the read exposes what the write controls.
- Hot enable/disable without a restart. Rejected: ESM evaluation cannot be undone, and the platform must not claim a module stopped when its code is still resident.
- A generalized runtime health-probe SDK. Rejected for this pass: "running" is deliberately a narrow claim, and a health contract is a design of its own.
- A module installer, package registry, or install-source columns. Explicitly out of scope; the schema deliberately has nowhere to put them.
Consequences
Ekklesia has an operational lifecycle: an administrator can turn a module off, that decision survives restarts, its dependents behave deterministically, and every surface that exposes a module — routes, artifacts, inventory, navigation — follows the one eligible set rather than a filter somewhere downstream.
createApiApp takes a required moduleLifecycle option, matching the pattern dashboard,
moduleUi, and moduleRoutes already follow. Core gains a fourth permission, which moved several
existing assertions. startApi now reads the database before starting modules, so it refuses to
start against an unmigrated one — stricter than before, and stated as such.
Two costs worth naming. Enablement is platform-global and will need a scope column when an organization model exists, exactly like the Dashboard tables. And a broken module still takes the whole platform down; the CLI makes that recoverable without a database edit, but it does not make it pleasant, and closing it properly is the isolated-startup pass.
Deferred
Not implemented or promised here: per-module startup failure isolation and a failed runtime state;
a module installer, marketplace, package registry, download/update/signature infrastructure, or
install-source metadata; hot loading, hot unloading, and filesystem watchers; a module-management
Dashboard UI or any administrator-facing affordance beyond the API and the CLI; automatic migration
application, and any coupling between enablement and migrations; a runtime health-probe or readiness
contract; module-owned configuration and secrets; per-module or per-organization scoping of
enablement; a "reset to default" operation that deletes an explicit record; and process isolation or
sandboxing of any kind.
The permission-id typing gap carried since ADR 0022 is unchanged: permissions.has takes a plain
string, and the wire arrays are string[].