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
+58 -4
View File
@@ -962,6 +962,13 @@ PostgreSQL, including DDL rollback, advisory-lock contention, history behavior,
sanitized failures. It remains outside the Docker-free `pnpm verify` gate; see the
[testing guide](../development/testing.md).
`pnpm modules:enable -- --module <id>` and `pnpm modules:disable -- --module <id>` record the
platform's enablement decision for one installed module. They read `DATABASE_URL`, run the same
manifest-only discovery every other command does (so a module this deployment cannot see is refused),
write one row, and start nothing. The change takes effect at the next `pnpm api:start`. These exist
so a module that breaks startup can be turned off without the API — the HTTP lifecycle API is served
by the process such a module takes down.
`pnpm doctor` builds first and presents the same production discovery results as a concise,
read-only dashboard. It lists discovered module IDs and versions in dependency order, reports
schema and migration counts, and shows safe migration identities with truncated checksums. Schema
@@ -969,13 +976,60 @@ discovery imports the compiled schema-definition entrypoint, so its no-I/O and n
contract still applies. Doctor does not import or start module server entrypoints, apply migrations,
or exercise runtime hooks; lifecycle behavior remains covered by automated tests. PostgreSQL
planning is skipped when `DATABASE_URL` is absent, and object storage is skipped until Core has a
production storage integration. A skip or pending migration is visible as `READY WITH WARNINGS`,
production storage integration. Its `modules.lifecycle` check reports which installed modules are
enabled and which cannot run — also skipped without `DATABASE_URL`, since enablement is stored in
PostgreSQL. A deliberately disabled module is a pass with a detail line; an *enabled* module that
cannot run (blocked by a disabled required dependency) is a warning, because that is the anomaly. A skip or pending migration is visible as `READY WITH WARNINGS`,
not misreported as a successful check.
### Whether your module runs at all is your host's decision
Between discovery and startup, the platform consults its own **enablement configuration** and
resolves which discovered modules are eligible to run
([ADR 0028](../architecture/decisions/0028-module-operational-lifecycle.md)):
```text
present on disk → discovered → enabled → eligible → running
```
Three things matter to you as a module author, and only three.
**You never see it.** Nothing in your manifest declares enablement, nothing in your `register`,
`start`, or `stop` context exposes it, and your module is never told it is enabled. Your host either
loads and starts you, or it does not. **Do not write feature code that asks whether it is enabled in
Ekklesia** — that is what keeps the same implementation runnable under a future standalone host,
whose loading decisions are its own.
**Disabled means nothing of yours runs.** A disabled module's compiled server entrypoint is never
imported, so no hook fires, no route is mounted, no capability is registered, and its UI artifact is
not published or listed. There is no per-request "is this enabled?" check to code against, because
nothing is mounted.
**A module with no stored decision is enabled.** Deploying your module is sufficient to run it; no
initialization step, database edit, or migration is needed to make it appear.
Your **required** dependencies are real: if a module (or the provider of a capability) you declare
under `dependencies.modules.required` / `dependencies.capabilities.required` is disabled, you are
blocked and will not start, with a structured reason naming it. Your **optional** dependencies are
not: if an optional provider is disabled, you start normally and
`context.capabilities.resolve(...)` returns `undefined` — exactly as it does when that provider was
never installed. Declare optional if you can degrade; declare required if you genuinely cannot run.
Your migrations are discovered and applied regardless of enablement — deployed schema ownership is a
deployment fact, not runtime state — so enabling your module later never needs a schema change in the
middle of a restart.
Finally, "running" means your `start` hook completed. It does **not** mean every external resource
you use is reachable. If your object storage or an upstream service is down after you started, that
is your module's own health to surface through your own API and UI; the platform has no health-probe
contract and will keep reporting you as running.
### Discovery and startup, step by step
Core completes discovery before importing any server entrypoint. It then loads and verifies every
runtime definition before running hooks, registers all modules dependency-first into an unpublished
staged capability registry and an unpublished staged HTTP route list side by side, and starts them in
the same order. Neither the capability registry nor the route list is published — visible on
eligible module's runtime definition before running hooks, registers all of them dependency-first
into an unpublished staged capability registry and an unpublished staged HTTP route list side by
side, and starts them in the same order. Neither the capability registry nor the route list is published — visible on
`StartedModuleRuntime` — unless every module registers and starts successfully. Shutdown is
reverse-order. If a later module fails to start, Core stops already-started modules in reverse order
and reports any cleanup failures without leaking caught exceptions or machine paths. A failing