feat: add module runtime lifecycle
CI / verify (push) Successful in 2m14s
CI / infrastructure-smoke (push) Failing after 1m36s

This commit is contained in:
ekklesia
2026-07-12 22:49:51 -05:00
parent 57552a21a4
commit 7b448d8706
28 changed files with 1382 additions and 203 deletions
@@ -1,6 +1,7 @@
# 0005: Static module manifests and atomic discovery
- Status: Accepted
- Status: Superseded
- Superseded by: [0006](0006-versioned-capabilities-and-compiled-module-runtime.md)
- Date: 2026-07-12
## Context
@@ -0,0 +1,81 @@
# 0006: Versioned capabilities and compiled module runtime
- Status: Accepted
- Date: 2026-07-12
- Supersedes: [0005](0005-static-module-manifests.md)
## Context
Static module manifests let Core validate the complete installed module graph without executing
module code. The first manifest shape treated capabilities as unversioned identifiers and the
Module SDK helper validated a second programmatic copy of that manifest. Neither model could
express compatibility between independently evolving capability contracts without duplicating
canonical metadata.
Core also needs an explicit production loading boundary. Importing source TypeScript would couple
production startup to a development loader. Resolving arbitrary package exports from Core would
require each discovered module to become a declared Core dependency or require a central import
registry, which conflicts with install-by-cloning discovery.
## Decision
`ekklesia.module.json` remains the one canonical static manifest and moves to
`manifestVersion: 2`. Provided capabilities are declarations containing an identifier and an exact
semantic version. Required and optional capability dependencies map identifiers to semantic-version
ranges. Capability versions describe their public contracts and evolve independently from the
module package version. A missing optional capability is allowed; an installed but incompatible
optional capability fails discovery.
`defineModuleRuntime` is the Module SDK helper for a module's executable server definition. The
definition repeats only module ID and module version as build-artifact identity markers, plus the
`register`, `start`, and `stop` hooks. It does not duplicate capabilities, dependencies,
permissions, storage requests, or entrypoints from the static manifest. Core validates every
runtime definition against its manifest before running any hook.
Production server entrypoints are direct package-relative paths to compiled ESM JavaScript ending
in `.js` or `.mjs`, normally `./dist/server/index.js`. Core imports their canonical file URLs only
after complete atomic discovery succeeds. A module can therefore be installed without a registry
edit by cloning it into `modules/`, installing workspace dependencies, building the workspace, and
running module validation. `pnpm modules:validate` builds the workspace before discovery so missing
or stale build artifacts are caught at that boundary.
Registration and lifecycle execution use discovery's deterministic dependency-first order. Each
module registers its declared capabilities into a private stage; successful stages become visible
to later dependent registrations but are not published to callers until all registration and
startup hooks succeed. All registration completes before startup. Normal shutdown runs in reverse
order. If startup fails, Core stops modules whose startup completed, in reverse order, and continues
rollback even if cleanup fails.
The injected contexts contain only the module ID and a capability registrar or resolver. Runtime
errors use stable structured issues and do not expose caught exception messages, stacks, import
URLs, absolute paths, or causes.
## Alternatives
- Source TypeScript entrypoints were rejected because production should not depend on an implicit
TypeScript loader or execute unbuilt source.
- Package export subpaths were deferred because pnpm's strict dependency model cannot resolve an
arbitrary discovered package from Core without a declared dependency or generated registry.
- Duplicating the complete static manifest in TypeScript was rejected because the two copies could
drift and make authority ambiguous.
- Publishing capability registrations as each hook runs was rejected because a later registration
failure would expose a partially initialized platform.
## Consequences
Module builds must preserve the runtime definition's ID and version and produce the declared server
artifact. Entrypoint modules are trusted backend plugins, not a sandbox boundary. Their top-level
evaluation must be side-effect-free because ESM evaluation cannot be rolled back or unloaded.
Registration hooks must remain declarative; modules acquire resources in `start` and release them
in `stop`. A failing `start` hook is responsible for cleaning up work it began before rejecting;
Core rollback covers modules whose startup already completed.
Runtime restart, hot replacement, typed capability tokens, schema aggregation, authentication,
HTTP routing, jobs, and physical storage provisioning remain outside this slice.
## Migration implications
Manifest version 1 is rejected rather than interpreted as version 2. Existing development modules
must convert capability arrays into versioned declarations, capability dependency arrays into
range maps, build their server entrypoint to ESM JavaScript, and export a `defineModuleRuntime`
definition from that entrypoint.
+2 -1
View File
@@ -13,4 +13,5 @@ ADRs capture durable choices that affect more than one package or phase.
- [0002: pnpm workspace and strict TypeScript](0002-pnpm-workspace-and-typescript.md) — Accepted
- [0003: Development infrastructure](0003-development-infrastructure.md) — Accepted
- [0004: Replace unmaintained MinIO baseline](0004-replace-unmaintained-minio.md) — Accepted
- [0005: Static module manifests and atomic discovery](0005-static-module-manifests.md) — Accepted
- [0005: Static module manifests and atomic discovery](0005-static-module-manifests.md) — Superseded
- [0006: Versioned capabilities and compiled module runtime](0006-versioned-capabilities-and-compiled-module-runtime.md) — Accepted
+7 -7
View File
@@ -59,11 +59,11 @@ surfaces.
## Current implementation status
Implemented now: the Phase 1 repository and infrastructure foundation plus public module contracts,
the Module SDK manifest API, static versioned module manifests, automatic filesystem discovery,
compatibility/dependency ordering, structured discovery failures, and a backend-only module
template. Core can validate zero or more modules without importing their server behavior.
static versioned manifests, independent capability versions and dependency ranges, automatic
filesystem discovery, compiled server loading, runtime-definition verification, staged capability
registration, dependency-ordered startup and reverse shutdown, rollback, and a backend-only module
template.
Target architecture, not yet implemented: lifecycle execution and runtime registration, Drizzle
schema aggregation/migrations, Better Auth, permissions, physical storage-bucket provisioning,
Surface SDK, capability/resource/action registries, API server, dashboard, Builder, and reference
feature modules.
Target architecture, not yet implemented: Drizzle schema aggregation and migrations, Better Auth,
permission enforcement, physical storage-bucket provisioning, Surface SDK, richer public resource
and action contracts, API server, dashboard, Builder, and reference feature modules.
+3 -2
View File
@@ -29,8 +29,9 @@ The lint, typecheck, and test commands run both repository-root checks and every
package-level Turborepo task. New packages add their own `lint`, `typecheck`, and `test` scripts;
the root verification command discovers them automatically.
`pnpm modules:validate` scans `modules/*` without a central registry. A missing or empty modules
directory is valid and reports zero installed modules.
`pnpm modules:validate` builds the workspace, then scans `modules/*` without a central registry. A
missing or empty modules directory is valid and reports zero installed modules. Installed modules
must produce the compiled JavaScript entrypoints declared by their static manifests.
CI runs `pnpm ci:verify`, which adds Compose configuration validation to `pnpm verify`. A separate
CI job runs the live infrastructure smoke test so code-only local verification does not require
+86 -29
View File
@@ -5,35 +5,37 @@ it never owns components, styles, layouts, renderers, navigation, or other UI.
## Required package shape
Each immediate child of `modules/` must contain:
Each immediate child of `modules/` must be an ESM workspace package with a unique package name, a
strict semantic version, and the module layer marker:
```text
modules/example/
├── dist/server/index.js
├── ekklesia.module.json
├── package.json
└── src/server/index.ts
```
`package.json` must use ESM, a strict semantic version, a unique package name, and:
```json
{
"type": "module",
"ekklesia": {
"layer": "module"
}
}
```
Its version must match the module manifest version. Internal package dependencies use `workspace:`
ranges and supported package entrypoints.
The package version must match the manifest and runtime-definition versions. Internal package
dependencies use `workspace:` ranges and supported public package entrypoints.
## Manifest
## Canonical manifest
The canonical manifest is inert JSON so discovery can validate a module without executing it:
`ekklesia.module.json` is inert and authoritative, so Core can validate every installed module
without executing module code:
```json
{
"manifestVersion": 1,
"manifestVersion": 2,
"id": "calendar",
"name": "Calendar",
"version": "0.1.0",
@@ -48,10 +50,20 @@ The canonical manifest is inert JSON so discovery can validate a module without
}
},
"capabilities": {
"optional": ["media.items"]
"required": {
"people.records": "^2.0.0"
},
"optional": {
"media.items": ">=1.1.0 <2.0.0"
}
}
},
"capabilities": ["calendar.events"],
"capabilities": [
{
"id": "calendar.events",
"version": "1.0.0"
}
],
"permissions": ["calendar.events.read"],
"storage": {
"buckets": [
@@ -62,38 +74,83 @@ The canonical manifest is inert JSON so discovery can validate a module without
]
},
"entrypoints": {
"server": "./src/server/index.ts"
"server": "./dist/server/index.js"
}
}
```
Module, capability, and permission identifiers are lowercase and namespaced. Entrypoints must begin
with `./`, stay inside the package, and exist. Unknown manifest properties are rejected, including
presentation fields such as components or renderers.
Module, capability, and permission identifiers are lowercase and namespaced. A provided capability
has its own exact semantic version because its public contract can evolve independently from the
module release. Capability dependencies use semantic-version ranges. Optional means the provider
may be absent; if it is installed, its capability version must be compatible.
Server entrypoints must be compiled ESM `.js` or `.mjs` files, begin with `./`, remain inside the
package, and exist. Production never imports source TypeScript directly. Unknown manifest
properties are rejected, including presentation fields such as components or renderers.
Storage bucket IDs are logical names scoped to the module. Modules may request multiple buckets.
Core will later map them to deployment-specific physical S3 buckets and provide scoped access;
module code must not configure SeaweedFS or other S3 clients directly.
module code must not configure SeaweedFS or another S3 client directly.
## Discovery behavior
## Runtime server definition
Run:
The source entrypoint exports one typed runtime definition and does not repeat the static manifest:
```bash
pnpm modules:validate
```ts
import { defineModuleRuntime } from "@ekklesia/module-sdk";
export default defineModuleRuntime({
id: "calendar",
version: "0.1.0",
register({ capabilities }) {
capabilities.provide("calendar.events", calendarEvents);
},
async start({ capabilities }) {
const people = capabilities.resolve("people.records");
// Acquire runtime resources here.
},
async stop() {
// Release runtime resources here.
}
});
```
Discovery checks every installed module atomically. It rejects malformed manifests, duplicate IDs
or package names, version mismatches, incompatible platform or SDK ranges, missing required modules
or capabilities, incompatible installed dependencies, cycles, and missing or escaping entrypoints.
Missing optional dependencies are allowed. Valid modules are ordered dependency-first with stable
identifier sorting.
ID and version are intentional build-artifact identity markers and must match the manifest. Core
rejects missing, duplicate, or undeclared capability registrations. A module may resolve only its
own capabilities and the capability dependencies declared in its manifest. The runtime context is
deliberately limited to the module ID and capability access.
Discovery does not load server code. Lifecycle execution, route/job registration, Drizzle schema
aggregation and migrations, and physical bucket provisioning are deliberately deferred.
Entrypoint imports must have no top-level side effects. Modules are trusted backend plugins, and ESM
evaluation cannot be rolled back. Keep registration declarative; open resources in `start` and
close them in `stop`.
## Discovery and lifecycle
From the repository root, run:
```bash
pnpm install
pnpm modules:validate
pnpm verify
```
`pnpm modules:validate` builds the workspace and then atomically validates every installed module.
It rejects malformed manifests, duplicate IDs or package names, version mismatches, incompatible
platform or SDK ranges, missing required modules or capabilities, incompatible installed
dependencies, cycles, and missing, escaping, or non-JavaScript server entrypoints. Missing optional
dependencies are allowed. Valid modules are ordered dependency-first with stable identifier sorting.
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 starts them in the same order. 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 `start` hook must clean up
its own partial work before rejecting.
## Start from the template
Copy `templates/module` to `modules/<your-module-id>` and follow its README. The template is part of
the workspace and is validated by the same contracts and discovery implementation as installed
modules.
Copy `templates/module` to `modules/<your-module-id>` and follow its README. No central registry edit
is needed: the workspace glob, build, discovery, and runtime loading all find the new package.