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
@@ -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[]`.
|
||||
Reference in New Issue
Block a user