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
@@ -30,6 +30,15 @@ names nine stages, but nothing implements a state machine over them: there is no
|
||||
enable/disable, no trust store, and no per-module status record. What exists is one accepted set —
|
||||
the modules `discoverModules` validated at startup and `startDiscoveredModuleRuntime` then started.
|
||||
|
||||
> **Cross-reference — see [ADR 0028](0028-module-operational-lifecycle.md).**
|
||||
> The paragraph above described the platform accurately at the time. There is now a persistent
|
||||
> enable/disable state and a resolved eligibility model, and `ModuleLifecycleStage` has been replaced
|
||||
> by the two axes ADR 0028 records. "Publishable" consequently derives from the **eligible** module
|
||||
> set rather than the discovered one, so a disabled or dependency-blocked module publishes no
|
||||
> artifact. Everything else in this ADR — lookup by validated identity, the sibling prefix, exact
|
||||
> version matching, the anonymous artifact route, and the caching contract — is unchanged and still
|
||||
> accepted. There is still no installer.
|
||||
|
||||
## Decision
|
||||
|
||||
### Publication is derived from validated module identity, never from a request path
|
||||
|
||||
@@ -79,6 +79,14 @@ configure, and no module that declares no UI. Ekklesia has no enable/disable sta
|
||||
`ModuleLifecycleStage` remains vocabulary with no runtime state machine behind it — and reporting one
|
||||
would mean inventing it.
|
||||
|
||||
> **Cross-reference — see [ADR 0028](0028-module-operational-lifecycle.md).**
|
||||
> Ekklesia now does have enable/disable state, reported by its own resource
|
||||
> (`GET /api/v1/module-lifecycle`) rather than by this one. This endpoint is unchanged and remains
|
||||
> exactly what it was: the host-neutral list of what may be mounted, with no management verbs. What
|
||||
> changed is upstream of it — the inventory now derives from the **eligible** module set, so a
|
||||
> disabled or dependency-blocked module is simply absent, and the lifecycle vocabulary this
|
||||
> paragraph names has been replaced by the two axes ADR 0028 records.
|
||||
|
||||
### Both URLs are server-produced, and that is the point
|
||||
|
||||
`ui.url` comes from `moduleUiArtifactUrl` (ADR 0025) and `api.baseUrl` from `moduleApiBaseUrl`, a new
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
# 0028: Module operational lifecycle — enablement, eligibility, and what "running" means
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-22
|
||||
- Extends: [0006](0006-versioned-capabilities-and-compiled-module-runtime.md),
|
||||
[0015](0015-static-module-http-routing-and-runtime-lifecycle.md),
|
||||
[0025](0025-trusted-module-ui-asset-publication.md),
|
||||
[0026](0026-minimal-ekklesia-module-ui-host.md),
|
||||
[0027](0027-dashboard-navigation-and-flat-modules.md)
|
||||
|
||||
## 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
|
||||
|
||||
```text
|
||||
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:
|
||||
|
||||
```text
|
||||
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:
|
||||
|
||||
```text
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
```text
|
||||
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:
|
||||
|
||||
```json
|
||||
{ "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 `register` hook 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 ordinary `404`.
|
||||
- **UI publication.** Its artifact URL returns the same `404` an 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-ui` and from
|
||||
`GET /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 `stop` hook 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
|
||||
|
||||
```text
|
||||
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
|
||||
|
||||
```text
|
||||
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_STAGES` names.** 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 `failed` runtime 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 `disabled` column 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[]`.
|
||||
@@ -36,3 +36,4 @@ ADRs capture durable choices that affect more than one package or phase.
|
||||
- [0025: Trusted module UI asset publication](0025-trusted-module-ui-asset-publication.md) — Accepted
|
||||
- [0026: The minimal Ekklesia module UI host](0026-minimal-ekklesia-module-ui-host.md) — Accepted
|
||||
- [0027: Dashboard navigation is presentation configuration, and modules stay flat](0027-dashboard-navigation-and-flat-modules.md) — Accepted
|
||||
- [0028: Module operational lifecycle — enablement, eligibility, and what "running" means](0028-module-operational-lifecycle.md) — Accepted
|
||||
|
||||
@@ -308,3 +308,24 @@ Target architecture not yet implemented includes production approval-provider
|
||||
composition, physical storage-bucket provisioning, jobs/workflow automation, richer public resource
|
||||
and action contracts, a Dashboard, a Builder, and reference feature modules; none of these are
|
||||
scheduled by this document.
|
||||
|
||||
Phase 7A pass 1 adds the platform's operational module lifecycle
|
||||
([ADR 0028](decisions/0028-module-operational-lifecycle.md)). Core now has two clearly separated
|
||||
axes: **configuration** (`enabled`/`disabled`, persistent, administrator-owned, stored in one Core
|
||||
table) and **runtime** (`running`/`disabled`/`blocked`, observed per boot). `resolveModuleEligibility`
|
||||
is a pure function of the discovered inventory plus that configuration: a module with no stored
|
||||
record is enabled by default, an explicit decision always wins, a disabled module transitively blocks
|
||||
every module that *requires* it (module and capability edges alike, with a structured reason naming
|
||||
the provider), and an optional dependency never blocks anything. `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 rather than a filter downstream.
|
||||
|
||||
Startup remains all-or-nothing across the eligible set — ADR 0015 and ADR 0020's rollback guarantee
|
||||
is unchanged, and there is deliberately no `failed` runtime state, because no live process could
|
||||
report one. Enablement is what makes a broken module recoverable, with `pnpm modules:disable` as the
|
||||
out-of-band path. 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 discovery still spans the deployed inventory regardless of enablement, and permission
|
||||
composition still spans the whole discovered set. `running` means a module's `start` hook completed
|
||||
and nothing more; there is no runtime health-probe contract. Modules remain trusted compiled code —
|
||||
disabling one is an operational control, never a sandbox. No installer exists.
|
||||
|
||||
+149
-9
@@ -80,6 +80,8 @@ No real values are shown here — configure per environment and never commit the
|
||||
| `GET /api/v1/dashboard/navigation` | Ekklesia | The resolved Dashboard navigation: the same mountable modules, grouped, labelled, and ordered as this installation has configured them. Requires `core.modules.ui.read`. See [Dashboard navigation](#dashboard-navigation). |
|
||||
| `PUT\|DELETE /api/v1/dashboard/navigation/{groups,placements}/*` | Ekklesia | Organize the Dashboard: create/rename/order/delete a group, and place a module. Requires `core.dashboard.navigation.manage` (baseline `restricted`) and a trusted `Origin`. See [Dashboard navigation](#dashboard-navigation). |
|
||||
| `GET /api/v1/module-ui/<module-id>/<version>` | Ekklesia | Publishes an installed module's validated `entrypoints.ui` artifact at a browser-loadable URL. No authentication; only the declared artifact, never another file. See [Module UI asset publication](#module-ui-asset-publication). |
|
||||
| `GET /api/v1/module-lifecycle` | Ekklesia | Which installed modules are enabled, what this process actually did with each, and whether a restart is pending. Requires `core.modules.lifecycle.manage` (baseline `restricted`). See [Module lifecycle](#module-lifecycle). |
|
||||
| `PUT /api/v1/module-lifecycle/<module-id>/enabled` | Ekklesia | Enable or disable one installed module. Takes effect at the next API start. Requires `core.modules.lifecycle.manage` and a trusted `Origin`. See [Module lifecycle](#module-lifecycle). |
|
||||
|
||||
Better Auth's handler is unreachable outside `/api/auth/*`; every other unmatched path returns a
|
||||
deterministic `404 {"code":"NOT_FOUND"}`.
|
||||
@@ -385,12 +387,16 @@ account.
|
||||
3. Construct the module database facade and the frozen `ModuleRuntimeServices` object from that same
|
||||
pool (see [Module database access](#module-database-access)).
|
||||
4. Construct the Core identity runtime, authorization store, and evaluator.
|
||||
5. Start the module runtime from that exact same discovered inventory
|
||||
(`startDiscoveredModuleRuntime`) — modules are never rediscovered for this step — passing it the
|
||||
services object every module's `start`/`stop` hook receives.
|
||||
6. Create the Hono application, mounting the started module runtime's published routes and passing
|
||||
the identical services object to every module HTTP handler.
|
||||
7. Bind the HTTP server.
|
||||
5. Read the stored module enablement configuration and resolve, over that exact same discovered
|
||||
inventory, which modules are **eligible** to run (`resolveModuleEligibility`,
|
||||
`@ekklesia/core/modules` — see [Module lifecycle](#module-lifecycle)).
|
||||
6. Start the module runtime from the eligible subset (`startDiscoveredModuleRuntime`) — modules are
|
||||
never rediscovered for this step — passing it the services object every module's `start`/`stop`
|
||||
hook receives.
|
||||
7. Create the Hono application, mounting the started module runtime's published routes, publishing
|
||||
UI for that same eligible set, and passing the identical services object to every module HTTP
|
||||
handler.
|
||||
8. Bind the 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
|
||||
@@ -404,9 +410,143 @@ for the permission-composition detail, and [Module development](module-developme
|
||||
the manifest shape that produces these declarations.
|
||||
|
||||
Unlike discovery/composition/Doctor/`modules:validate` (which only ever read manifests), a
|
||||
successful production startup does import each module's compiled server entrypoint and run its
|
||||
`register`/`start` hooks — this is the one place in the platform that deliberately executes module
|
||||
code, and only after every other startup step already listed has succeeded.
|
||||
successful production startup does import each **eligible** module's compiled server entrypoint and
|
||||
run its `register`/`start` hooks — this is the one place in the platform that deliberately executes
|
||||
module code, and only after every other startup step already listed has succeeded. A module that is
|
||||
disabled, or blocked by a disabled required dependency, is never imported at all.
|
||||
|
||||
Step 5 makes startup depend on a reachable, migrated database: if the enablement configuration
|
||||
cannot be read, `startApi` **refuses to start** 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. Nothing is created or
|
||||
repaired on that path.
|
||||
|
||||
## Module lifecycle
|
||||
|
||||
```text
|
||||
GET /api/v1/module-lifecycle
|
||||
PUT /api/v1/module-lifecycle/<module-id>/enabled { "enabled": boolean }
|
||||
```
|
||||
|
||||
The platform's operational view of its installed modules
|
||||
([ADR 0028](../architecture/decisions/0028-module-operational-lifecycle.md)): which are enabled, what
|
||||
this process actually did with each of them, and whether the two currently disagree.
|
||||
|
||||
It is a **sibling** of `/api/v1/modules/`, never a child, for the same reason `/api/v1/module-ui` is
|
||||
(ADR 0025): `enabled` is a legal module route segment, so a module named `announcements` could
|
||||
otherwise register `PUT /enabled` and collide with the platform at exactly
|
||||
`/api/v1/modules/announcements/enabled`.
|
||||
|
||||
```json
|
||||
{
|
||||
"modules": [
|
||||
{
|
||||
"id": "storage",
|
||||
"version": "1.0.0",
|
||||
"configured": { "enabled": false, "explicit": true },
|
||||
"runtime": { "state": "disabled" },
|
||||
"restartRequired": false
|
||||
},
|
||||
{
|
||||
"id": "announcements",
|
||||
"version": "1.0.0",
|
||||
"configured": { "enabled": true, "explicit": false },
|
||||
"runtime": {
|
||||
"state": "blocked",
|
||||
"reason": "required_dependency_disabled",
|
||||
"dependency": { "kind": "module", "moduleId": "storage" }
|
||||
},
|
||||
"restartRequired": false
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Two axes, deliberately not one status word
|
||||
|
||||
`configured` is the decision that survives restarts. `explicit: false` means nobody has decided and
|
||||
the platform default (**enabled**) applies. `runtime` is what *this process* did:
|
||||
|
||||
| state | meaning |
|
||||
| ---------- | ----------------------------------------------------------------------------- |
|
||||
| `running` | the module was eligible and its `start` hook completed in this process |
|
||||
| `disabled` | configuration says it must not run, so it was never imported |
|
||||
| `blocked` | it should run, but a **required** dependency cannot — see `reason`/`dependency` |
|
||||
|
||||
`running` says the `start` hook completed. It says **nothing** about whether every external resource
|
||||
that module uses is reachable right now; Ekklesia has no runtime health-probe contract, and this
|
||||
state must not be read as one. There is deliberately no `failed` state: module startup is
|
||||
all-or-nothing, so a module that fails to start aborts API startup and no live process could ever
|
||||
report it.
|
||||
|
||||
`reason` is one of `required_dependency_disabled` (the named dependency was turned off) or
|
||||
`required_dependency_blocked` (the named dependency is itself blocked). Following the chain always
|
||||
terminates at a `..._disabled` entry. An **optional** dependency never blocks anything.
|
||||
|
||||
### Changing enablement requires a restart, and the response says so
|
||||
|
||||
There is no hot module installation or removal, so `PUT` writes a decision and changes nothing about
|
||||
the running process:
|
||||
|
||||
```json
|
||||
{ "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` compares what the current configuration *would* start against what the
|
||||
runtime reports it *did* start, so it is symmetric and transitive: disabling a module also marks its
|
||||
dependents, because they really will stop on the next boot.
|
||||
|
||||
A module whose enablement changed but whose process has not restarted stays fully exposed — routes,
|
||||
artifact, inventory, and navigation. It really is still mounted; hiding it now would break a viewer
|
||||
mid-session for a change that has not taken effect.
|
||||
|
||||
### What a disabled module does not have
|
||||
|
||||
Never imported ⇒ no `register`, `start`, or `stop` hook runs; no HTTP routes are mounted (an
|
||||
unmounted path gets the platform's ordinary `404`, with no per-request enablement check anywhere);
|
||||
no capabilities are registered; `GET /api/v1/module-ui/<id>/<version>` returns the same `404` an
|
||||
uninstalled module does; and it is absent from both `GET /api/v1/module-ui` and
|
||||
`GET /api/v1/dashboard/navigation`.
|
||||
|
||||
Its **migrations are still discovered and applied**. Deployed schema ownership is not runtime
|
||||
enabled state: a disabled module's tables must already exist when it is enabled, or enabling it would
|
||||
require a schema change in the middle of a restart. Its **permissions also stay registered**, so
|
||||
grants survive an enable/disable cycle and can be issued ahead of enabling a module; nothing enforces
|
||||
them, because it mounts no routes.
|
||||
|
||||
### Authority
|
||||
|
||||
Both verbs require `core.modules.lifecycle.manage` (baseline `restricted`). The read is gated too,
|
||||
because 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
|
||||
covers only what is mountable. Mutations additionally require an exact trusted `Origin` and a bounded
|
||||
body (`CORE_MUTATION_MAX_BODY_BYTES`) before authorization work begins.
|
||||
|
||||
The body is closed: any key beyond `enabled` invalidates the whole request rather than being dropped.
|
||||
A `PUT` naming a module this process did not discover is `404 {"code":"MODULE_NOT_INSTALLED"}` —
|
||||
enablement is operational configuration, not an unbounded key-value store. Existing records for
|
||||
modules that are no longer deployed are retained and ignored, so redeploying restores the decision.
|
||||
|
||||
No response carries a filesystem path, package name, manifest path, entrypoint, caught error, or
|
||||
stack.
|
||||
|
||||
### Recovering from a module that breaks startup
|
||||
|
||||
Startup is all-or-nothing, so a module whose `start` hook throws takes the API down — including this
|
||||
API. The out-of-band path is a Core CLI, in the same shape as `pnpm authorization:bootstrap-owner`
|
||||
(explicit, never HTTP-exposed):
|
||||
|
||||
```bash
|
||||
pnpm modules:disable -- --module announcements
|
||||
pnpm modules:enable -- --module announcements
|
||||
```
|
||||
|
||||
It reads `DATABASE_URL`, runs the same manifest-only discovery the API does (so it refuses a module
|
||||
this deployment cannot see), writes the decision, and starts nothing. Restart the API to apply it.
|
||||
|
||||
## Module HTTP routes
|
||||
|
||||
|
||||
@@ -100,14 +100,31 @@ grant lookup — there is no wildcard, prefix, or namespace-inheritance matching
|
||||
required dot-namespacing in a `PermissionId` (`core.authorization.grants.manage`,
|
||||
`calendar.events.read`) is purely a naming convention for readability and collision-avoidance.
|
||||
|
||||
`CORE_PERMISSION_REGISTRY` is Core's own registry, currently declaring exactly one permission, and
|
||||
remains what Core-only code and existing tests use directly. `CORE_PERMISSION_DECLARATIONS` is the
|
||||
same declarations exported separately in deeply immutable form, for the module composition helper
|
||||
below to build on without reconstructing Core's own list:
|
||||
`CORE_PERMISSION_REGISTRY` is Core's own registry and remains what Core-only code and existing tests
|
||||
use directly. `CORE_PERMISSION_DECLARATIONS` is the same declarations exported separately in deeply
|
||||
immutable form, for the module composition helper below to build on without reconstructing Core's own
|
||||
list:
|
||||
|
||||
| Permission | Baseline | Description |
|
||||
| --- | --- | --- |
|
||||
| `core.authorization.grants.manage` | `restricted` | Create, list, and revoke another user's explicit authorization grants. |
|
||||
| `core.modules.ui.read` | `authenticated` | List the installed module UIs a browser host may mount. |
|
||||
| `core.dashboard.navigation.manage` | `restricted` | Organize the Dashboard: create, rename, order, and delete navigation groups. |
|
||||
| `core.modules.lifecycle.manage` | `restricted` | Read and change which installed modules are enabled. |
|
||||
|
||||
Every Core permission is a **platform** permission and can never be declared, gated, or influenced by
|
||||
a module — `core.*` is a reserved namespace (see below). `core.modules.lifecycle.manage` gates both
|
||||
reading and changing module enablement
|
||||
([ADR 0028](../architecture/decisions/0028-module-operational-lifecycle.md)): the read is not a
|
||||
milder version of the write, because the lifecycle view is the only place a *disabled* module, a
|
||||
*blocked* module, and the required-dependency edges between them are visible at all, whereas
|
||||
`core.modules.ui.read` deliberately covers only what is mountable. Holding it confers no authority
|
||||
inside any module.
|
||||
|
||||
Enablement never changes how a permission is enforced. A **disabled** module's permissions stay
|
||||
registered and grantable — composition spans the whole discovered inventory, not the eligible subset
|
||||
— so a grant survives an enable/disable cycle and can be issued ahead of enabling a module. Nothing
|
||||
enforces those permissions while the module is disabled, because it mounts no routes at all.
|
||||
|
||||
## Module permission composition
|
||||
|
||||
@@ -206,8 +223,8 @@ generic explicit-grant HTTP API, enforcement middleware, and now its own mounted
|
||||
`pnpm doctor`'s `modules.permissions` check composes the platform permission registry from the
|
||||
same already-discovered module list `modules.discovery`/`modules.dependencies` already produced —
|
||||
it never re-discovers modules itself. A successful composition reports a pass with a safe
|
||||
declaration count (for example `1 permission registered` with zero installed modules, since Core's
|
||||
own declaration is always present); zero installed modules still composes successfully. If module
|
||||
declaration count (for example `4 permissions registered` with zero installed modules, since Core's
|
||||
own declarations are always present); zero installed modules still composes successfully. If module
|
||||
discovery itself failed, the check is skipped, matching the existing schema/migration checks'
|
||||
pattern; if discovery succeeded but composition failed, schema and migration checks still run
|
||||
normally — they are independent diagnostics that do not depend on the permission registry. A
|
||||
|
||||
@@ -168,6 +168,33 @@ what a request may do. Reading navigation requires the same `core.modules.ui.rea
|
||||
inventory requires, so it discloses the same installed-module enumeration and nothing more; it
|
||||
carries no filesystem path, package root, or manifest internal.
|
||||
|
||||
### Disabling a module is an operational control, not a security boundary
|
||||
|
||||
A module can be turned off, and that decision survives restarts
|
||||
([ADR 0028](../architecture/decisions/0028-module-operational-lifecycle.md)): a disabled module is
|
||||
never imported, so its code never runs, it mounts no routes, registers no capabilities, and publishes
|
||||
no UI. That is a real and useful operational property — it is how an operator recovers from a module
|
||||
that misbehaves or breaks startup.
|
||||
|
||||
**It is not containment.** The module's compiled code is still on disk, still trusted, and still
|
||||
carries every authority listed above the moment anything starts it. Enablement is a row in
|
||||
`ekklesia_core_module_state`; it constrains the platform's own composition, not the module. Anyone
|
||||
who can put a module package on this filesystem, or who holds the database or operating-system access
|
||||
to change that row, decides what runs. Nothing about disabling a module sandboxes, isolates, or
|
||||
reduces the privileges of a module that *is* enabled.
|
||||
|
||||
Changing enablement requires `core.modules.lifecycle.manage` (baseline `restricted`) and — for the
|
||||
mutation — an exact trusted `Origin` and a bounded body. Reading is gated by the same permission
|
||||
because the lifecycle view is the only place a disabled module, a blocked module, and the
|
||||
required-dependency edges between them are visible; `core.modules.ui.read` deliberately exposes only
|
||||
the mountable set. Neither response carries a filesystem path, package name, manifest path,
|
||||
entrypoint, caught error, or stack — only module ids, capability ids, and fixed reason codes.
|
||||
|
||||
Holding the lifecycle permission grants no authority *inside* any module: every module route stays
|
||||
enforced against that module's own declared permission, and a disabled module's permissions remain
|
||||
registered and grantable precisely so that an enable/disable cycle changes nothing about who holds
|
||||
what.
|
||||
|
||||
### Client-side permission checks are not a security boundary
|
||||
|
||||
A host may tell a module UI what permissions it believes the viewer holds
|
||||
|
||||
@@ -152,6 +152,28 @@ key material is ever committed to this repository** — and the disposable conta
|
||||
`afterAll` even when an assertion fails. See
|
||||
[ADR 0018](../architecture/decisions/0018-identity-session-resolution-and-database-transport.md).
|
||||
|
||||
A fifth dedicated suite (`core/tests/module-lifecycle-store.integration.ts`, included in the same
|
||||
`pnpm test:migrations:integration` run) proves the module enablement store against real PostgreSQL:
|
||||
that migration `0004` really creates `ekklesia_core_module_state` with exactly the four columns and
|
||||
the one declared CHECK constraint the schema states — and, load-bearingly, that `enabled` has **no
|
||||
column default**, so the platform's default for a newly discovered module is genuinely the absence of
|
||||
a row rather than a materialized decision nobody made; that a decision survives a completely new
|
||||
connection; that the upsert is idempotent on `module_id` and bumps `updated_at` in place rather than
|
||||
inserting a second row; that a malformed identifier is rejected before it reaches PostgreSQL and the
|
||||
empty-string case is refused by the database too; and that a record for a module which is no longer
|
||||
deployed is retained and simply ignored by resolution, so redeploying restores the decision.
|
||||
|
||||
The API suite additionally drives the whole lifecycle end to end against a real database and real
|
||||
fixture modules: a disabled module's compiled entrypoint is never imported (proven by its
|
||||
`register`/`start`/`stop` markers staying empty), its routes return the platform's ordinary `404`,
|
||||
its UI artifact and inventory and navigation entries are all absent, and its neighbours run normally;
|
||||
a module blocked by a disabled required dependency is explained over HTTP with a structured reason
|
||||
naming that dependency and no filesystem path anywhere in the payload; a change made through
|
||||
`PUT /api/v1/module-lifecycle/<id>/enabled` reports `restartRequired: true` while the module
|
||||
demonstrably keeps serving, and is applied only by the next start; and a disabled module's migrations
|
||||
are still discovered, because deployed schema ownership is not runtime enabled state. See
|
||||
[ADR 0028](../architecture/decisions/0028-module-operational-lifecycle.md).
|
||||
|
||||
### Which kind of timing proof lives where
|
||||
|
||||
Pass 4 introduced several deadlines, and their tests deliberately differ in kind:
|
||||
|
||||
@@ -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