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
+3
View File
@@ -1,5 +1,8 @@
export * from "./database.js";
export * from "./discovery.js";
export * from "./lifecycle.js";
export * from "./lifecycle-schema.js";
export * from "./lifecycle-store.js";
export * from "./migration-discovery.js";
export * from "./permissions.js";
export * from "./runtime.js";
+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;
+150
View File
@@ -0,0 +1,150 @@
import { asc } from "drizzle-orm";
import { type ModuleEnablementRecord, moduleIdSchema } from "@ekklesia/contracts";
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
import type { Pool } from "pg";
import { createValidatedPostgresPool, type PostgresPoolOptions } from "../database/index.js";
import type { ModuleLifecycleConfiguration } from "./lifecycle.js";
import { ekklesiaCoreModuleState, moduleLifecycleSchema } from "./lifecycle-schema.js";
export type ModuleLifecycleDrizzleDatabase = NodePgDatabase<typeof moduleLifecycleSchema>;
/**
* A typed Drizzle view over an externally owned pool, exactly like `createDashboardDrizzleView` and
* `createAuthorizationDrizzleView`: it never constructs, registers an error listener on, or closes
* a pool. The caller owns the pool once, however many typed views it creates over it.
*/
export function createModuleLifecycleView(pool: Pool): ModuleLifecycleDrizzleDatabase {
return drizzle(pool, { schema: moduleLifecycleSchema });
}
export interface ModuleLifecycleDatabaseHandle {
readonly close: () => Promise<void>;
readonly db: ModuleLifecycleDrizzleDatabase;
}
/**
* A self-contained handle for a caller that needs *only* this domain and owns the connection for
* the length of one command — Doctor's read-only lifecycle check and the enable/disable CLI. It
* composes the shared, already-validated pool factory rather than repeating a fourth copy of
* connection-string and timeout validation. `apps/api`, which shares one physical pool across every
* domain, uses `createModuleLifecycleView` instead.
*/
export function createModuleLifecycleDatabase(
connectionString: string,
options: PostgresPoolOptions = {},
): ModuleLifecycleDatabaseHandle {
const { close, pool } = createValidatedPostgresPool(connectionString, options);
return Object.freeze({ close, db: createModuleLifecycleView(pool) });
}
export type ModuleLifecycleStoreErrorCode =
| "MODULE_LIFECYCLE_MODULE_ID_INVALID"
| "MODULE_LIFECYCLE_READ_FAILED"
| "MODULE_LIFECYCLE_WRITE_FAILED";
const storeErrorMessages: Readonly<Record<ModuleLifecycleStoreErrorCode, string>> = Object.freeze({
MODULE_LIFECYCLE_MODULE_ID_INVALID: "A module identifier is invalid.",
MODULE_LIFECYCLE_READ_FAILED: "The module lifecycle configuration could not be read.",
MODULE_LIFECYCLE_WRITE_FAILED: "The module lifecycle configuration could not be saved.",
});
/** Never carries the underlying database error, SQL text, host, connection string, or stack —
* matching `AuthorizationStoreError` and `DashboardStoreError` exactly. */
export class ModuleLifecycleStoreError extends Error {
readonly code: ModuleLifecycleStoreErrorCode;
constructor(code: ModuleLifecycleStoreErrorCode) {
super(storeErrorMessages[code]);
this.name = "ModuleLifecycleStoreError";
this.code = code;
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
export interface SetModuleEnabledInput {
readonly moduleId: string;
readonly enabled: boolean;
}
export interface ModuleLifecycleStore {
/** The whole stored configuration, in deterministic module-id order. Small by nature — at most one
* row per module ever decided about — so it is read whole rather than paginated. */
readConfiguration(): Promise<ModuleLifecycleConfiguration>;
/** Records one explicit decision, creating or replacing it. Idempotent: the same input twice
* leaves the same single row. Writing configuration never starts, stops, loads, or unloads
* anything — the running process is untouched until it is restarted. */
setEnabled(input: SetModuleEnabledInput): Promise<ModuleEnablementRecord>;
}
function assertValidModuleId(value: string): void {
if (!moduleIdSchema.safeParse(value).success) {
throw new ModuleLifecycleStoreError("MODULE_LIFECYCLE_MODULE_ID_INVALID");
}
}
function rethrowAsStoreError(error: unknown, code: ModuleLifecycleStoreErrorCode): never {
if (error instanceof ModuleLifecycleStoreError) {
throw error;
}
throw new ModuleLifecycleStoreError(code);
}
/**
* Core's persistence for which installed modules are enabled.
*
* Validation lives here rather than only at an HTTP boundary, so the invariant holds for every
* caller — the API, the CLI, and any future one: an identifier is checked against
* `moduleIdSchema`, the platform's one module-identifier grammar, rather than a second grammar
* invented here.
*
* Nothing in this file reads a manifest, imports module code, resolves a dependency, or decides
* whether a module may start. It stores a decision; `resolveModuleEligibility` interprets it. That
* separation is what keeps enablement host-owned configuration instead of something a module could
* ever influence.
*/
export function createModuleLifecycleStore(
db: ModuleLifecycleDrizzleDatabase,
): ModuleLifecycleStore {
return Object.freeze({
async readConfiguration(): Promise<ModuleLifecycleConfiguration> {
try {
const rows = await db
.select({
enabled: ekklesiaCoreModuleState.enabled,
moduleId: ekklesiaCoreModuleState.moduleId,
})
.from(ekklesiaCoreModuleState)
.orderBy(asc(ekklesiaCoreModuleState.moduleId));
return Object.freeze({
modules: Object.freeze(rows.map((row) => Object.freeze({ ...row }))),
});
} catch (error) {
rethrowAsStoreError(error, "MODULE_LIFECYCLE_READ_FAILED");
}
},
async setEnabled(input: SetModuleEnabledInput): Promise<ModuleEnablementRecord> {
assertValidModuleId(input.moduleId);
try {
const [row] = await db
.insert(ekklesiaCoreModuleState)
.values({ enabled: input.enabled, moduleId: input.moduleId })
.onConflictDoUpdate({
set: { enabled: input.enabled, updatedAt: new Date() },
target: ekklesiaCoreModuleState.moduleId,
})
.returning({
enabled: ekklesiaCoreModuleState.enabled,
moduleId: ekklesiaCoreModuleState.moduleId,
});
if (!row) {
throw new ModuleLifecycleStoreError("MODULE_LIFECYCLE_WRITE_FAILED");
}
return Object.freeze(row);
} catch (error) {
rethrowAsStoreError(error, "MODULE_LIFECYCLE_WRITE_FAILED");
}
},
});
}
+312
View File
@@ -0,0 +1,312 @@
import type {
ModuleBlockedReason,
ModuleBlockingDependency,
ModuleEnablementRecord,
ModuleRuntimeState,
} from "@ekklesia/contracts";
import type { DiscoveredModule } from "./discovery.js";
/**
* The complete stored enablement configuration: only the modules somebody explicitly decided
* about. A module with no record here is not "missing" — it is at its default, and the default is
* enabled (see `resolveModuleEligibility`).
*/
export interface ModuleLifecycleConfiguration {
readonly modules: readonly ModuleEnablementRecord[];
}
/** How a module's configured state was arrived at. `explicit` distinguishes "an administrator chose
* this" from "nobody has decided, so the platform default applies", which is exactly the difference
* an operator needs when they are wondering whether a module is deliberately off. */
export interface ModuleConfiguredState {
readonly enabled: boolean;
readonly explicit: boolean;
}
/**
* One module's resolved eligibility: what configuration says, and whether the dependency graph
* permits it to start.
*
* `eligible` is the single fact runtime composition consumes. Everything else on this record exists
* to explain it.
*/
export interface ModuleEligibility {
readonly moduleId: string;
readonly version: string;
readonly configured: ModuleConfiguredState;
readonly eligible: boolean;
/** Present exactly when the module is configured enabled but not eligible. A disabled module is
* not "blocked" — it is doing what it was told. */
readonly blocked?: {
readonly reason: ModuleBlockedReason;
readonly dependency: ModuleBlockingDependency;
};
}
export interface ModuleEligibilityResolution {
/**
* The modules the platform may start, **in the exact discovery order they were given**. Filtered,
* never re-sorted: discovery already produced a topological order, and blocking propagates along
* the same edges, so removing an ineligible module can never leave a module ahead of a required
* dependency it still has.
*/
readonly eligibleModules: readonly DiscoveredModule[];
/** Every discovered module, in discovery order, eligible or not. This is the explanation surface;
* `eligibleModules` is the executable one. */
readonly entries: readonly ModuleEligibility[];
}
interface MutableEligibility {
readonly moduleId: string;
readonly version: string;
readonly configured: ModuleConfiguredState;
eligible: boolean;
blocked?: {
readonly reason: ModuleBlockedReason;
readonly dependency: ModuleBlockingDependency;
};
}
function frozenEligibility(entry: MutableEligibility): ModuleEligibility {
return Object.freeze({
configured: Object.freeze({ ...entry.configured }),
eligible: entry.eligible,
moduleId: entry.moduleId,
version: entry.version,
...(entry.blocked
? {
blocked: Object.freeze({
dependency: Object.freeze({ ...entry.blocked.dependency }),
reason: entry.blocked.reason,
}),
}
: {}),
});
}
/**
* Resolves which of the already-discovered modules the platform may actually start.
*
* Pure and synchronous, exactly like `resolveDashboardNavigation`: no database, no filesystem, no
* clock, no module code. It is handed the discovered inventory and the stored configuration and
* returns what to start plus why anything else is not starting, so every rule below is testable
* without PostgreSQL.
*
* ## The rules, exactly
*
* - **A module with no stored record is enabled.** This is the platform's default policy, chosen
* so that deploying a module is sufficient to run it — the behavior every earlier phase already
* had — and so that discovering a module never requires an initialization write. It mirrors
* ADR 0027's rule that a module with no Dashboard placement is still visible.
* - **An explicit record always wins over the default,** in both directions. A stored
* `enabled: true` is indistinguishable in effect from no record at all, and is retained anyway
* so an administrator can see that somebody decided.
* - **A stored record for a module that is not installed is ignored, not an error.** It is
* retained by the store for the same reason a Dashboard placement is: redeploying the module
* restores the decision instead of silently resetting it.
* - **A disabled module blocks every module that *requires* it,** transitively, and the block
* names the dependency rather than silently re-enabling it. Administrator intent is never
* overridden.
* - **An optional dependency never blocks anything.** A module that declares an optional
* dependency 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. The dependent starts, and `context.capabilities.resolve(...)` simply returns
* `undefined`, exactly as it does when the provider was never installed.
* - **A required *capability* dependency blocks exactly like a required module dependency**,
* because it is one: the capability's only provider is a module, and if that module does not
* run, nothing registers the capability.
* - **A missing, incompatible, duplicated, or cyclic dependency is not represented here at all.**
* Discovery rejects all four before this function is ever reached, so there is no eligibility
* state for them and no second place where the dependency graph is validated.
*
* ## What this function structurally cannot do
*
* It never reads a manifest field beyond identity, version, and the declared dependency maps;
* never imports or evaluates module code; never writes; and never enables anything. Disabling a
* dependency can only ever *remove* modules from the eligible set.
*/
export function resolveModuleEligibility(
discoveredModules: readonly DiscoveredModule[],
configuration: ModuleLifecycleConfiguration,
): ModuleEligibilityResolution {
const configuredByModuleId = new Map(
configuration.modules.map((record) => [record.moduleId, record.enabled] as const),
);
const entries = new Map<string, MutableEligibility>();
for (const discovered of discoveredModules) {
const moduleId = discovered.manifest.id;
const stored = configuredByModuleId.get(moduleId);
const enabled = stored ?? true;
entries.set(moduleId, {
configured: { enabled, explicit: stored !== undefined },
eligible: enabled,
moduleId,
version: discovered.manifest.version,
});
}
// The provider of each capability, taken from the same manifests discovery already validated.
// A capability has exactly one provider in a valid inventory (discovery rejects a duplicate
// module id, and a manifest may only declare capabilities inside its own namespace).
const capabilityProviders = new Map<string, string>();
for (const discovered of discoveredModules) {
for (const declaration of discovered.manifest.capabilities) {
capabilityProviders.set(declaration.id, discovered.manifest.id);
}
}
// One pass in discovery order is sufficient and is what makes transitive blocking deterministic:
// discovery hands back a topological order, so every required dependency of a module has already
// been resolved by the time that module is examined.
for (const discovered of discoveredModules) {
const entry = entries.get(discovered.manifest.id);
if (!entry?.eligible) {
continue;
}
const requiredEdges: readonly ModuleBlockingDependency[] = Object.freeze([
...Object.keys(discovered.manifest.dependencies.modules.required).map(
(dependencyId): ModuleBlockingDependency => ({
kind: "module",
moduleId: dependencyId,
}),
),
...Object.keys(discovered.manifest.dependencies.capabilities.required).flatMap(
(capabilityId): readonly ModuleBlockingDependency[] => {
const providerId = capabilityProviders.get(capabilityId);
// Unreachable through real discovery, which rejects a required capability nothing
// provides. Skipping rather than inventing a state keeps this function from having an
// opinion discovery already owns.
return providerId === undefined
? []
: [{ capabilityId, kind: "capability", moduleId: providerId }];
},
),
]);
for (const dependency of requiredEdges) {
const provider = entries.get(dependency.moduleId);
if (!provider || provider.eligible) {
continue;
}
entry.eligible = false;
entry.blocked = {
dependency,
reason: provider.configured.enabled
? "required_dependency_blocked"
: "required_dependency_disabled",
};
break;
}
}
const resolvedEntries = Object.freeze(
discoveredModules.map((discovered) => {
const entry = entries.get(discovered.manifest.id);
if (!entry) {
throw new Error("Internal lifecycle error: a discovered module was not indexed.");
}
return frozenEligibility(entry);
}),
);
const eligibleIds = new Set(
resolvedEntries.filter((entry) => entry.eligible).map((entry) => entry.moduleId),
);
return Object.freeze({
eligibleModules: Object.freeze(
discoveredModules.filter((discovered) => eligibleIds.has(discovered.manifest.id)),
),
entries: resolvedEntries,
});
}
/**
* What one module looks like to an operator: the decision, what this process actually did, and
* whether the two currently disagree.
*/
export interface ModuleLifecycleView {
readonly moduleId: string;
readonly version: string;
readonly configured: ModuleConfiguredState;
readonly runtime: {
readonly state: ModuleRuntimeState;
readonly reason?: ModuleBlockedReason;
readonly dependency?: ModuleBlockingDependency;
};
/**
* `true` when applying the *current* configuration would produce a different running set than
* this process actually has. Computed by re-resolving eligibility against live configuration and
* comparing with what the runtime really started — never by assuming the two agree.
*
* It is deliberately symmetric and transitive: disabling a module that others require marks those
* dependents too, because they really will stop on the next restart.
*/
readonly restartRequired: boolean;
}
export interface ModuleLifecycleSnapshot {
/** The exact discovered inventory this process booted from. Never re-discovered afterwards: this
* process runs the modules it found at startup, and reporting anything else would describe a
* platform that is not running. */
readonly discoveredModules: readonly DiscoveredModule[];
/** The eligibility this process resolved at boot, which is what explains a module that is not
* running right now. */
readonly eligibility: readonly ModuleEligibility[];
/** The module ids the runtime reports it actually started (`StartedModuleRuntime.modules`), not
* the ids this process predicted it would start. Under the preserved all-or-nothing startup model
* these are the same set; reading the runtime's own answer is what keeps that an observation
* rather than an assumption. */
readonly startedModuleIds: readonly string[];
}
/**
* Composes the operator-facing read model from the boot snapshot plus the configuration as it
* stands *now*.
*
* The two inputs are deliberately from different moments, because the honest answer is: "here is
* what this process is doing, here is what you have since asked for, and here is whether those
* differ." Collapsing them would either hide a pending change or claim a change already took
* effect. Ekklesia does not hot-load or hot-unload a module (ADR 0015, unchanged), so a
* configuration change is only ever applied by the next restart.
*/
export function resolveModuleLifecycleView(
snapshot: ModuleLifecycleSnapshot,
configuration: ModuleLifecycleConfiguration,
): readonly ModuleLifecycleView[] {
const started = new Set(snapshot.startedModuleIds);
const bootEligibility = new Map(
snapshot.eligibility.map((entry) => [entry.moduleId, entry] as const),
);
const pending = resolveModuleEligibility(snapshot.discoveredModules, configuration);
const wouldRun = new Set(pending.eligibleModules.map((module) => module.manifest.id));
return Object.freeze(
pending.entries.map((current) => {
const boot = bootEligibility.get(current.moduleId);
const isRunning = started.has(current.moduleId);
const runtime = isRunning
? { state: "running" as const }
: boot === undefined || !boot.configured.enabled
? { state: "disabled" as const }
: boot.blocked
? {
dependency: boot.blocked.dependency,
reason: boot.blocked.reason,
state: "blocked" as const,
}
: // Enabled and unblocked at boot, yet not started: unreachable while startup stays
// all-or-nothing, and reported as blocked-by-nothing rather than as `running`, so a
// future change to startup atomicity can never make this silently claim success.
{ state: "blocked" as const };
return Object.freeze({
configured: current.configured,
moduleId: current.moduleId,
restartRequired: wouldRun.has(current.moduleId) !== isRunning,
runtime: Object.freeze(runtime),
version: current.version,
});
}),
);
}
+14 -12
View File
@@ -65,26 +65,28 @@ export interface ModuleUiPublicationRegistry {
/**
* Builds the platform's module UI publication policy from an already-discovered module list.
*
* The policy is deliberately small, because the repository's module state is deliberately small:
* Ekklesia has one accepted module set — the modules `discoverModules` validated at startup — and
* no installer, no enable/disable state, and no trust store. `ModuleLifecycleStage`
* (`@ekklesia/contracts`) names stages as a vocabulary but no runtime state machine implements
* them. So "publishable" means exactly:
* The policy is deliberately small. "Publishable" means exactly:
*
* 1. the module is in the discovered, fully validated set the platform accepted at startup, and
* 1. the module is in the **eligible** set the platform started — it survived discovery *and*
* the operational lifecycle permits it to run (`resolveModuleEligibility`, `./lifecycle.js`) —
* and
* 2. its manifest declares `entrypoints.ui`, which discovery resolved to a real compiled file
* inside the module package.
*
* A module directory sitting on disk satisfies neither on its own — it has to have survived
* discovery — which is the property `docs/development/security.md` requires: *the existence of an
* artifact on disk must not be what makes it servable*.
* artifact on disk must not be what makes it servable*. Since ADR 0028 the same is true of a module
* an administrator disabled: it is discovered and validated, and it is still not publishable,
* because this registry is built from the eligible list rather than the discovered one. That is why
* the filtering lives at the *call site's choice of input* rather than as a `disabled` check
* somewhere downstream — there is exactly one authoritative eligible set, and everything that
* exposes a module consumes it.
*
* Pure and synchronous: no filesystem, environment, database, or network access, and it never
* imports, evaluates, or otherwise executes a module's UI artifact. The caller supplies a complete
* discovered list; production composition (`apps/api/src/server.ts`) builds this only after the
* module runtime has started, so the published set is also the running set — module startup is
* all-or-nothing (`startDiscoveredModuleRuntime`, `./runtime.js`), so a module that failed to load,
* register, or start takes the whole API startup down with it rather than lingering as a
* imports, evaluates, or otherwise executes a module's UI artifact. Production composition
* (`apps/api/src/server.ts`) passes the exact list it just started, and only after
* `startDiscoveredModuleRuntime` succeeded — startup is all-or-nothing across that set, so a module
* that failed to load, register, or start takes API startup down with it rather than lingering as a
* publishable-but-not-running module.
*/
export function createModuleUiPublicationRegistry(