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

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

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

See ADR 0028.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
ekklesia
2026-08-22 14:45:34 -05:00
co-authored by Claude Opus 5
parent db67cf7700
commit 7729c99e24
52 changed files with 5311 additions and 116 deletions
+60
View File
@@ -0,0 +1,60 @@
import { sql } from "drizzle-orm";
import { boolean, check, pgTable, text, timestamp } from "drizzle-orm/pg-core";
/**
* Core-owned storage for the one operational decision an administrator makes about an installed
* module: should it run?
*
* This is **configuration, not runtime state**. Nothing about what happened during a boot is stored
* here — not a start failure, not a last-seen timestamp, not a health result. A restart naturally
* retries every enabled module, which is the behavior an operator expects and which persisting a
* transient failure would quietly break (ADR 0028).
*
* It is also **not an installation registry**. A module is "installed" by being a valid module
* package on disk that discovery accepts; this table records nothing about where it came from, how
* it got there, or what version it is. There is deliberately no install source, download URL,
* update channel, signature, marketplace metadata, or free-form JSON column: none of those concepts
* exist in this platform, and a column for one is how an installer gets built by accident.
*
* Platform-global, for the same reason the Dashboard tables are (ADR 0027): Ekklesia has no
* organization or tenant model to scope it to. When one exists, this table gains a scope column.
*
* Table and constraint names are explicit and namespaced exactly like
* `core/src/authorization/schema.ts` and `core/src/dashboard/schema.ts`, so ownership is
* unambiguous inside the shared public PostgreSQL schema.
*/
/**
* One explicit enablement decision.
*
* **A row exists only because somebody decided.** `enabled` has no column default on purpose: the
* platform's default for a newly discovered module is expressed by the *absence* of a row and
* resolved in `resolveModuleEligibility`, not by a default that would silently materialize a
* decision nobody made. Storing `enabled = true` explicitly is still meaningful — it records that
* an administrator looked at this module and left it on.
*
* `module_id` carries **no foreign key to any module**, because modules are packages discovered on
* disk, not rows — the same reasoning `ekklesia_core_dashboard_placement.module_id` already
* follows. A record for a module that is not currently deployed is therefore normal and is
* retained, so redeploying a module restores the decision instead of resetting it.
* `resolveModuleEligibility` ignores such a record entirely.
*/
export const ekklesiaCoreModuleState = pgTable(
"ekklesia_core_module_state",
{
moduleId: text("module_id").primaryKey(),
enabled: boolean("enabled").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
// Defense in depth beneath the application-layer `moduleIdSchema` contract, which never
// produces this: an empty module id would be an unaddressable, permanently inert record.
check("ekklesia_core_module_state_module_check", sql`length(${table.moduleId}) > 0`),
],
);
/** Explicit map passed to Drizzle, mirroring `authorizationSchema` and `dashboardSchema`. */
export const moduleLifecycleSchema = {
moduleState: ekklesiaCoreModuleState,
} as const;