feat: add the minimal operational module lifecycle
CI / verify (pull_request) Successful in 35s
CI / infrastructure-smoke (pull_request) Successful in 13s
CI / migration-smoke (pull_request) Successful in 46s

Ekklesia had no notion of a module being on or off: the accepted module set
was exactly "whatever discovery found on disk", and `ModuleLifecycleStage`
named nine states nothing produced or consumed. This replaces that vocabulary
with two axes that real code produces — persistent, administrator-owned
configuration (`enabled`/`disabled`) and per-boot runtime observation
(`running`/`disabled`/`blocked`) — and wires them through startup.

- One Core table (`ekklesia_core_module_state`, migration 0004) holding only
  `module_id` and `enabled`. A module with no record is enabled by default, so
  deploying a module is still sufficient to run it and discovery never needs an
  initialization write. A record for an undeployed module is retained and
  ignored, so redeploying restores the decision.
- `resolveModuleEligibility` is pure: a disabled module transitively blocks
  every module that *requires* it (module and capability edges alike) with a
  structured reason naming the provider; optional dependencies never block; and
  a missing, incompatible, or cyclic dependency stays discovery's concern.
- `startApi` starts only the eligible set and builds the UI publication
  registry from that same list, so routes, artifacts, browser inventory, and
  Dashboard navigation all follow one authoritative set. A disabled module is
  never imported, so there is no per-request enablement check anywhere.
- Startup stays all-or-nothing, deliberately; there is consequently no `failed`
  runtime state. `pnpm modules:enable`/`modules:disable` is the out-of-band
  recovery path for a module that breaks startup.
- Changes take effect at the next start, and the read model reports
  configuration, runtime, and `restartRequired` separately rather than claiming
  a module stopped when it has not.
- Migration participation and permission composition still span the deployed
  and discovered inventories respectively, unchanged.
- New restricted platform permission `core.modules.lifecycle.manage` gates both
  reading and changing enablement, at a sibling prefix (`/api/v1/module-lifecycle`)
  that cannot collide with the module route namespace.
- `pnpm doctor` gains a `modules.lifecycle` check so "why is this module
  unavailable?" is answerable without reading logs.

See ADR 0028.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
ekklesia
2026-08-22 14:45:34 -05:00
co-authored by Claude Opus 5
parent db67cf7700
commit 7729c99e24
52 changed files with 5311 additions and 116 deletions
+65 -16
View File
@@ -9,8 +9,11 @@ import {
import { createDashboardStore } from "@ekklesia/core/dashboard";
import { createIdentityRuntime, type IdentityRateLimitOverride } from "@ekklesia/core/identity";
import {
createModuleLifecycleStore,
createModuleUiPublicationRegistry,
type DiscoveredModule,
type ModuleEligibilityResolution,
resolveModuleEligibility,
type StartedModuleRuntime,
startDiscoveredModuleRuntime,
} from "@ekklesia/core/modules";
@@ -321,12 +324,15 @@ function bindServer(
* facade each get a typed Drizzle view over it — see `./database.js`).
* 3. Construct the frozen `ModuleRuntimeServices` (currently just `database`) from that same pool,
* then the Core identity runtime, authorization store, and evaluator.
* 4. Start the module runtime from that exact same discovered inventory
* (`startDiscoveredModuleRuntime`) — never rediscovering modules a second time — passing it the
* same services object every module's `start`/`stop` hook receives.
* 5. Create the Hono application, mounting the started module runtime's published routes and
* passing the exact same services object to every module HTTP handler.
* 6. Bind the Node HTTP server.
* 4. Read the stored module enablement configuration and resolve, from that exact same discovered
* inventory, which modules are eligible to run (`resolveModuleEligibility`).
* 5. Start the module runtime from the eligible subset (`startDiscoveredModuleRuntime`) — never
* rediscovering modules a second time — passing it the same services object every module's
* `start`/`stop` hook receives.
* 6. Create the Hono application, mounting the started module runtime's published routes, publishing
* UI for that same eligible set, and passing the exact same services object to every module HTTP
* handler.
* 7. Bind the Node HTTP server.
*
* Discovery and permission composition happen before any pool, Better Auth instance, module
* entrypoint import, or HTTP server exists, so a discovery or composition failure — an invalid
@@ -336,10 +342,20 @@ function bindServer(
* module database facade, identity, authorization) exists before any executable module lifecycle
* code runs, and every module route is published only after the entire module runtime has
* registered and started successfully, so no request can ever reach a module before every module
* has started. Returns a handle with an idempotent `shutdown()` so both the executable entry point
* and tests can stop the server, the module runtime, and the shared pool deterministically. Never
* applies migrations, generates schema, or issues a module database query merely because modules
* were discovered — the database must already be migrated through `pnpm migrations:apply`.
* has started. A module that is disabled, or blocked by a disabled required dependency, is never
* imported at all: no entrypoint evaluation, no lifecycle hook, no route, no capability, and no
* published UI artifact — there is no per-request "is this module enabled?" check anywhere, because
* there is nothing mounted to check.
*
* Step 4 makes startup depend on a reachable, migrated database. If the enablement configuration
* cannot be read, startup fails closed with Core's sanitized `MODULE_LIFECYCLE_READ_FAILED` rather
* than starting every discovered module: the platform cannot know what an administrator decided, and
* starting a module they disabled is worse than not starting at all.
*
* Returns a handle with an idempotent `shutdown()` so both the executable entry point and tests can
* stop the server, the module runtime, and the shared pool deterministically. Never applies
* migrations, generates schema, or issues a module database query merely because modules were
* discovered — the database must already be migrated through `pnpm migrations:apply`.
*/
export async function startApi(
config: ApiRuntimeConfig,
@@ -384,12 +400,33 @@ export async function startApi(
platformOwners: authorizationStore,
});
// Read the administrator's enablement decisions and resolve which discovered modules may actually
// start, before a single module entrypoint is imported. A failure here fails startup rather than
// falling back to "start everything": the platform cannot know what it was told, and starting a
// module an administrator disabled is worse than not starting at all.
const moduleLifecycleStore = createModuleLifecycleStore(databaseHandle.moduleLifecycle);
let eligibility: ModuleEligibilityResolution;
try {
eligibility = resolveModuleEligibility(
inventory.modules,
await moduleLifecycleStore.readConfiguration(),
);
} catch (error) {
await safeCloseDatabase(databaseHandle);
throw error;
}
// Module load/register/start failures use Core's own existing rollback (already-started
// modules are stopped, in reverse order, before this rejects) — there is no separate runtime
// handle to stop here, only the pool this failure leaves open.
//
// Only eligible modules are started, and startup remains all-or-nothing across *that* set: an
// ineligible module is never imported, so its `register`/`start` hooks never run, it publishes no
// routes and no capabilities, and it cannot take startup down. Eligibility filtering preserves
// discovery's topological order, so a started module's required dependencies still precede it.
let moduleRuntime: StartedModuleRuntime;
try {
moduleRuntime = await startDiscoveredModuleRuntime(inventory.modules, services, {
moduleRuntime = await startDiscoveredModuleRuntime(eligibility.eligibleModules, services, {
...(options.moduleHookTimeoutMsOverride === undefined
? {}
: { hookTimeoutMs: options.moduleHookTimeoutMsOverride }),
@@ -409,11 +446,23 @@ export async function startApi(
},
moduleRoutes: moduleRuntime.routes,
moduleServices: services,
// Built from the exact same discovered inventory, and only once the module runtime has
// started — module startup is all-or-nothing, so every module in this list is a running
// module, and a module that failed to load, register, or start never reaches this line at
// all. Modules declaring no `entrypoints.ui` are simply absent from the registry.
moduleUi: createModuleUiPublicationRegistry(inventory.modules),
// Built from the exact **eligible** set the runtime just started, never the raw discovered
// inventory, and only once startup has succeeded — so the published set is the running set. A
// disabled or dependency-blocked module therefore publishes no artifact, appears in no browser
// inventory, and appears in no resolved Dashboard navigation, by construction rather than by a
// filter somewhere downstream. Modules declaring no `entrypoints.ui` are simply absent.
moduleUi: createModuleUiPublicationRegistry(eligibility.eligibleModules),
moduleLifecycle: {
// The boot snapshot: what this process discovered, what it resolved, and what the runtime
// reports it actually started — read from the runtime rather than assumed equal to the
// eligible set.
snapshot: Object.freeze({
discoveredModules: inventory.modules,
eligibility: eligibility.entries,
startedModuleIds: Object.freeze(moduleRuntime.modules.map((module) => module.id)),
}),
store: moduleLifecycleStore,
},
// A typed view over the same single pool as identity, authorization, and the module facade.
dashboard: createDashboardStore(databaseHandle.dashboard),
runtime,