feat: add the minimal operational module lifecycle
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:
co-authored by
Claude Opus 5
parent
db67cf7700
commit
7729c99e24
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user