feat: add module runtime lifecycle
This commit is contained in:
@@ -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
|
||||
|
||||
+81
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user