Files
Ekklesia/docs/module-development
ekklesiaandClaude Opus 5 3f9b5e3cb0
CI / verify (pull_request) Successful in 31s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 42s
fix: require a compiled artifact for entrypoints.jobs
Module discovery gated the compiled-ESM rule on an allowlist,
`["schema", "server", "ui"]`, so `entrypoints.jobs` accepted
`./src/jobs/index.ts`. Every other executable entrypoint required a built
`.js` or `.mjs`; `jobs` did not, purely because it was never added to
that list. It is the second entrypoint to slip through the same way —
`ui` did too, until Phase 6A added it explicitly — which is the argument
against the allowlist rather than against `jobs`.

The rule is now expressed as "every entrypoint except a directory
entrypoint", with `migrations` the only member of that set. A new
manifest entrypoint therefore defaults to the stricter validation instead
of silently escaping it. The same constant replaces the two existing
`=== "migrations"` special cases for the file/directory kind check and
its message, so the one thing that distinguishes `migrations` is stated
once. The directory message is now templated and produces byte-identical
text.

Every other entrypoint guarantee is untouched: package-relative paths,
no traversal, inside-package before and after symlink resolution, the
artifact must exist, and it must be a file.

No runtime was added. `entrypoints.jobs` still has no consumer — it is
declared, validated, and never executed — exactly as it has been since
ADR 0006, and as `ui` was before its host loader existed. The manifest
schema is unchanged and this stays within v4: the field, its type, and
its optionality are all the same, and only discovery's validation of the
declared path tightened.

Compatibility impact is nil in first-party code: no module, template, or
fixture declares a `jobs` entrypoint at all. A third-party module
declaring a source-TypeScript path would now fail discovery, which is the
intended correction.

Tests cover accepted `.js` and `.mjs`, rejected `.ts`/`.tsx`/`.cjs`, a
missing artifact, a directory, a symlink escaping the package, and that
discovery still never evaluates the artifact. The three rejection cases
were confirmed to fail against the previous implementation.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-21 22:07:54 -05:00
..

Module development

An Ekklesia module is an independently versioned, first-class product. It owns its domain behavior, data declarations, permissions, capabilities, HTTP routes, migrations — and, as of ADR 0021, its own UI.

It does not own the application that contains it. Authentication, authorization enforcement, platform context, the application shell, top-level routing, navigation, the global theme and design-token contract, error boundaries, and trusted-origin/CORS policy are Core's and the host application's. A module consumes those; it never redefines or bypasses them.

What exists today: a module may declare a UI entrypoint in its manifest, and Core validates it. Nothing loads, mounts, or renders it yet — the host shell, the UI runtime, and the standalone host are all later Phase 6 work. Writing a module UI is not yet possible end to end; see Declaring a UI entrypoint for exactly what is and is not supported.

Before you publish or install a module: modules are trusted compiled backend code. Your module runs arbitrary JavaScript inside the API process and shares its PostgreSQL role, and nothing in this guide — manifests, permission declarations, typed facades, route validation, frozen contexts — isolates it. An operator installing your module is deploying trusted server code. See Security model and deployment trust boundaries.

Required package shape

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:

modules/example/
├── drizzle/                  # when the module has migration history
│   ├── 0000_initial.sql
│   └── meta/
│       ├── 0000_snapshot.json
│       ├── _ekklesia.json
│       └── _journal.json
├── dist/schema/index.js  # when the module owns tables
├── dist/server/index.js
├── drizzle.config.ts     # when generating migrations
├── ekklesia.module.json
├── package.json
├── src/schema/index.ts   # when the module owns tables
├── src/schema/tables.ts  # pure Drizzle declarations used by Drizzle Kit
└── src/server/index.ts

The schema and migration paths are optional. A tableless module omits entrypoints.schema; a module with no migration history omits entrypoints.migrations and needs no placeholder files.

{
  "type": "module",
  "ekklesia": {
    "layer": "module"
  }
}

The package version must match the manifest and runtime-definition versions. Internal package dependencies use workspace: ranges and supported public package entrypoints.

Canonical manifest

ekklesia.module.json is inert and authoritative, so Core can validate every installed module without executing module code:

{
  "manifestVersion": 4,
  "id": "calendar",
  "name": "Calendar",
  "version": "0.1.0",
  "compatibility": {
    "platform": "^0.1.0",
    "moduleSdk": "^0.1.0"
  },
  "dependencies": {
    "modules": {
      "required": {
        "people": "^0.1.0"
      }
    },
    "capabilities": {
      "required": {
        "people.records": "^2.0.0"
      },
      "optional": {
        "media.items": ">=1.1.0 <2.0.0"
      }
    }
  },
  "capabilities": [
    {
      "id": "calendar.events",
      "version": "1.0.0"
    }
  ],
  "permissions": [
    {
      "id": "calendar.events.read",
      "baseline": "restricted",
      "description": "Read calendar events."
    }
  ],
  "storage": {
    "buckets": [
      {
        "id": "assets",
        "description": "Private event assets owned by Calendar."
      }
    ]
  },
  "entrypoints": {
    "server": "./dist/server/index.js",
    "schema": "./dist/schema/index.js",
    "migrations": "./drizzle"
  }
}

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.

manifestVersion: 4 (packages/contracts/src/module-manifest.ts) is a required, exact literal — a version 3 manifest, and any other version, fails validation outright rather than being silently reinterpreted; there is no compatibility adapter or automatic migration between manifest versions. Version 4 adds one optional field, entrypoints.ui, so upgrading a backend-only version 3 manifest is a one-character edit and changes nothing else (ADR 0021). Each permissions entry is a complete declaration, not a bare identifier string: id (must begin with <module-id>., exactly like a capability id), baseline (public, authenticated, or restricted — see Authorization for what each baseline means to the evaluator), and a required, non-blank description up to 240 characters for operators and audit review only — Core's authorization layer never reads or interprets it. Declaring a permission here is necessary but not sufficient for it to be enforceable: the manifest only proves the declaration is well-formed and namespaced correctly, and does not itself register anything with Core's authorization registry (see Authorization for how Core composes every discovered module's declarations, alongside its own, into the one platform permission registry). A duplicate permission id within one manifest, or a permission id outside the module's own namespace, fails validation the same way a duplicate or misnamed capability id does.

apps/api composes this registry once, at startup, from every module installed at that time (see Authorization); installing, removing, or editing a module's manifest takes effect only on the next API restart, not while it is running. Once installed and discovered, a module's declared permission is enforceable and grantable through exactly the same mechanism as any Core-owned permission — the same requirePermission middleware and the same /api/v1/grants explicit-grant HTTP API a platform owner (or anyone they delegate core.authorization.grants.manage to) already uses. Mounting a module's own HTTP routes, and creating relationship-derived grants automatically from a module's own business logic, are both still later work; this only makes a declared permission usable through Core's existing, generic surface.

Every entrypoint except migrations must be a compiled ESM .js or .mjs file — server, schema, jobs, and ui alike. migrations is the one directory entrypoint. All entrypoints begin with ./, remain inside the package, and exist. Production never imports source TypeScript directly. Modules with no tables omit entrypoints.schema; modules with no migration history omit entrypoints.migrations; modules with no UI omit entrypoints.ui.

Unknown manifest properties are still rejected. ui is the only entrypoint version 4 adds — a dashboard, surface, client, web, or renderers key fails validation — and the manifest deliberately has no group, folder, parent, menu, navigation, icon, or ordering field. Dashboard organization is presentation configuration owned by the Dashboard, never module architecture.

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 another S3 client directly.

Declaring a UI entrypoint

A module may declare one optional presentation entrypoint alongside its server, schema, and migration entrypoints:

{
  "entrypoints": {
    "server": "./dist/server/index.js",
    "schema": "./dist/schema/index.js",
    "migrations": "./drizzle",
    "ui": "./dist/ui/index.js"
  }
}

ui obeys exactly the rules server and schema already obey. It must be package-relative, stay inside the module package (checked before and after symlink resolution), resolve to a real file, and be compiled ESM JavaScript ending in .js or .mjs. A ./src/ui/index.ts path is rejected at discovery — the platform never imports source TypeScript, and a UI artifact is a build output like any other.

What the artifact must export

The artifact default-exports one definition, built with defineModuleUi from @ekklesia/module-ui-sdk (ADR 0022):

import { defineModuleUi } from "@ekklesia/module-ui-sdk";
import { createRoot } from "react-dom/client";
import { Editor } from "./editor.js";

export default defineModuleUi({
  id: "announcements",
  version: "0.1.0",
  mount(container, context) {
    const root = createRoot(container);
    root.render(<Editor context={context} />);
    return () => root.unmount();
  },
});

id and version are build-artifact identity markers and must match your manifest, exactly like the ones in defineModuleRuntime. A host verifies both against the manifest it already validated, so a stale UI bundle is caught rather than mounted.

mount receives a container element and the host context, and returns an unmount function or nothing. If your mount acquires anything — a React root, a subscription, a timer, an event listener — return an unmount and release it there, the same discipline your stop hook follows.

React is the supported way to write module UI. It is deliberately not part of the contract: the platform hands you an element and a context, so your framework choice and the host's stay independent.

What the host gives you

mount receives exactly four members, and nothing else is available:

Member What it is
moduleId Your module's id, so you never re-derive it from a URL
api request(path, init) against your own module API — see below
principal Who is viewing: ModuleUiPrincipal, either { kind: "anonymous" } or { kind: "authenticated", userId }
permissions has(permissionId) — a presentation hint, never a security check

Navigation, host theme and design tokens, module configuration, organization context, and diagnostics reporting are not in the contract yet. They need designs that do not exist, and a speculative field frozen into a public contract is harder to remove than to add.

Never build your own API URL

Ask the host for a path relative to your own module's API root:

const response = await context.api.request("posts");

Do not write /api/v1/modules/announcements/posts. That prefix is Ekklesia's private deployment detail. A standalone host serving only your module may expose the same routes at /api, at a different origin, or behind a proxy — and a module that builds the URL itself silently stops being hostable anywhere but Ekklesia. The host resolves the path and owns credentials, base URL, and platform headers; your UI never sees a session token.

request returns a Response, the same Fetch vocabulary your backend routes already speak.

Client-side permission checks are UX, not security

context.permissions.has("announcements.posts.publish") tells you what the host believes, so you can hide an action or disable a control. It is not a security boundary. Server-side authorization is the only authority: your routes are enforced by Core's requirePermission middleware regardless of what any browser decided. Checking has() and then calling your API is still fully authorized server-side; skipping the check is still denied. The point is that a user does not see a button that would fail.

Your UI is trusted code in the host's page

Module UI runs in the same browser JavaScript realm as the host shell. There is no iframe, no sandbox, and no capability confinement — your UI can reach the host page and any global the browser exposes. This matches the backend model: installing a module already means running its code in the API process. Write module UI as trusted first-party code, because that is what it is. See Security model and deployment trust boundaries.

Building your UI

Put your UI source at src/ui/index.tsx, declare the artifact in your manifest, and build:

src/ui/index.tsx        # the compiled entrypoint's source
src/ui/ExampleApp.tsx   # your components
        ↓  pnpm build
dist/ui/index.js        # the artifact your manifest declares

You write no bundler configuration. @ekklesia/module-ui-build owns all of it (ADR 0023), and your package's build script runs both halves of the module:

{
  "scripts": {
    "build": "tsc --project tsconfig.build.json && ekklesia-build-module-ui"
  },
  "dependencies": {
    "@ekklesia/module-ui-sdk": "workspace:*",
    "react": "19.2.8",
    "react-dom": "19.2.8"
  },
  "devDependencies": {
    "@ekklesia/module-ui-build": "workspace:*",
    "@types/react": "19.2.18",
    "@types/react-dom": "19.2.4"
  }
}

The output path is read from your manifest, not from a build convention, so the artifact you declare and the artifact you produce cannot drift. Declare it exactly as you would any other entrypoint:

{ "entrypoints": { "ui": "./dist/ui/index.js" } }

A module with no entrypoints.ui is not an error — the build reports that there is nothing to build and exits successfully, so the same script works for a backend-only module.

The build fails, loudly and with esbuild's own file-and-line diagnostics, when your TSX does not compile, an import cannot be resolved, the declared path is unsafe or is not a .js/.mjs artifact, or src/ui/index.tsx is missing.

TypeScript projects

Keep UI and backend TypeScript separate. Your Node project already includes src/**/*.ts, which does not match .tsx, and a UI project covers the browser half:

{
  "extends": "@ekklesia/typescript-config/browser-react.json",
  "compilerOptions": { "noEmit": true, "types": [] },
  "include": ["src/ui/**/*.tsx", "src/ui/**/*.ts"]
}

"types": [] is deliberate, and as of ADR 0024 it actually holds: process.env in a UI file fails to compile even though the file imports @ekklesia/module-ui-sdk. It did not hold previously — the SDK imported @ekklesia/contracts, whose barrel pulled Drizzle's types and with them Node's globals into every module UI program. The SDK now has no dependencies at all, so importing it cannot drag a backend type graph into your browser code, and a compile test in that package proves both directions.

noEmit is also deliberate — TypeScript only checks types here; esbuild produces the artifact.

What is in your artifact

React is bundled into it. Your artifact is self-contained: it resolves nothing at run time and needs no React from its host, which is what lets the same file run under Ekklesia and under a future standalone host that may not use React at all. The cost is that a page hosting several module UIs loads several React copies, and artifacts are large (the template's is roughly 570 KB, unminified so an operator can review it). That is accepted for now and can be optimized later without changing the contract, because the contract is a mount function rather than a component.

Styling: inline style objects only, for now

Style with React's style prop:

<section style={{ display: "grid", gap: "0.5rem" }}>…</section>

Stylesheet imports are not supported, and the build fails on one. That is deliberate rather than an oversight: esbuild would emit a dist/ui/index.css beside your artifact, but no manifest field declares it and no host loads it — so it would work in your tests and silently do nothing in a real host. A stylesheet contract needs a manifest field, a host loading mechanism, and a style-isolation decision, none of which exist yet. Inline styles are the whole supported story for this phase.

What you may and may not use today

pnpm boundaries enforces modules-do-not-own-ui, narrowed precisely (ADR 0022) and unchanged by the build pipeline — adding the pipeline required no boundary relaxation at all:

Allowed in a module Still rejected
.tsx source .jsx (strict TypeScript only), .css, .vue, .svelte, .astro, .mdx, .svg, .html
react, react-dom Every other framework, plus react-router-dom (routing is the host's), react-native, @vitejs/plugin-react
@ekklesia/module-ui-sdk, @ekklesia/module-ui-build @ekklesia/ui and other shared UI-role packages

You do not need a bundler plugin — @vitejs/plugin-react and friends stay rejected because @ekklesia/module-ui-build owns the bundler entirely.

Layer rules are unchanged: module UI source may not import Core, an app, a surface, a builder, or another module. An unsupported framework aliased behind an allowed name — "react": "npm:vue@3" — is resolved and rejected by its real target.

Still no host. You can author, build, and test a module UI end to end, and @ekklesia/module-ui-host can load and mount a built artifact from a browser URL. Nothing serves your artifact at such a URL yet — Ekklesia has no module-asset publication route, and the Dashboard shell and standalone host do not exist. Your artifact is proven by tests, not mounted in production. See How a host loads your UI.

What this does and does not do today

Discovery parses the manifest as inert JSON, resolves the ui path, validates it, and records it on the discovered module. That is all. Nothing in Core imports, evaluates, mounts, or renders a UI entrypoint — a UI artifact that throws on evaluation still discovers successfully, because it is never evaluated. Hosts perform their own dynamic import and then validate the result with parseModuleUiExport; Core never loads browser code.

Core also never builds anything. Building is an authoring and packaging step you run; Core discovers the compiled artifact you shipped and validates it. Installing a module does not trigger a build, and neither does starting the API.

How a host loads your UI

You do not write this — a host does — but knowing the shape explains what your artifact must satisfy. @ekklesia/module-ui-host is the generic loader both Ekklesia and a future standalone host use (ADR 0024):

compiled artifact (dist/ui/index.js)
        ↓  asset publication — host-specific, NOT YET IMPLEMENTED for Ekklesia
browser-loadable module URL
        ↓  loadModuleUi({ source, container, context })
dynamic import → parseModuleUiExport → identity checked against the manifest
        ↓
mount(container, context)
        ↓
MountedModuleUi.unmount()   // idempotent
const mounted = await loadModuleUi({
  source: { moduleId: "example", version: "0.1.0", url: publishedUrl },
  container,
  context,
});
await mounted.unmount();

What that means for you as a module author:

  • Your id and version must match your manifest. They are checked before mount runs, so a stale artifact is rejected rather than rendered.
  • Return an unmount function if you acquire anything. The host calls it once; calling it again is a no-op. Returning nothing is fine if you acquired nothing.
  • If your mount throws, nothing is registered as mounted and the host is told the mount failed. The loader does not clear your container — the host owns it and decides what a failure looks like.
  • Your UI may be mounted more than once, into different containers, if your implementation supports it. Each mount gets its own cleanup.

Asset publication is the missing piece. Core resolves entrypoints.ui to a path on the server's filesystem; a browser cannot import that. Something must serve your compiled artifact at a URL first, and Ekklesia has no such route yet. Until it does, no host can mount an installed module's UI — the loader is ready, the plumbing to reach it is not.

What a module UI will never own

A module UI consumes platform policy and never redefines it. Authentication and session establishment, authorization enforcement, organization/platform context, the containing application shell, top-level routing and mount boundaries, the platform navigation container, the global theme and design-token contract, platform error boundaries, and trusted-origin/CORS policy belong to Core and the host application. This is the same boundary module HTTP routes already live inside: a module mounts routes, but the platform still owns the method vocabulary, CORS preflight, body ceilings, and permission enforcement.

A module UI also grants no isolation. A module that ships UI is exactly as trusted as one that does not — see Security model and deployment trust boundaries.

Modules stay flat

Shipping UI creates no parent/child relationship between modules. video, livestream, announcements, calendar, roster, and signage are peers. The only relationships a module may express are a genuine module dependency and a genuine capability dependency — both version-ranged, both meaning requirement, never ownership.

A Dashboard may later group modules into folders (Media holding Video and Livestream, or Sunday Production holding Livestream, Signage, and Video). Those groups are Dashboard configuration an administrator can reorganize freely. They are not dependencies, they are not declared in your manifest, and your module must never require one to work correctly.

Runtime server definition

The source entrypoint exports one typed runtime definition and does not repeat the static manifest:

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, services }) {
    const people = capabilities.resolve("people.records");
    await services.database.insert(events).values({ id: "seed", title: "First event" });
  },

  async stop() {
    // Release runtime resources here.
  }
});

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 register context is deliberately limited to moduleId, capability access, and the HTTP route registrar (below) — never services. start and stop share one context shape and both additionally receive services — the same frozen ModuleRuntimeServices object for the lifetime of one running API, currently exposing only database — see Database access.

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.

stop() may run after a partially completed start()

If your start hook throws, your own stop hook runs first, ahead of the previously started modules being rolled back in reverse order. That is deliberate: a hook that opened a connection, registered a listener, or recorded partial initialization before failing is the only code that knows how to release it. Write stop so it is safe and idempotent about resources it may or may not have acquired — guard on what you actually took, and never assume start completed.

Stop and rollback hooks have finite time: the platform waits at most 10 seconds per module hook, then records a timeout and continues with the next module in reverse order. Settle promptly. A hook that exceeds its deadline is not killed — the platform simply stops waiting for it — but the shared PostgreSQL pool closes after the bounded stop sequence, so a timed-out hook cannot rely on database availability afterwards, and any late query will fail. The platform cannot undo side effects your module performed outside its stop hook.

HTTP routes

A module registers plain Fetch (Request in, Response out) route handlers during register, through the http registrar ModuleRegistrationContext provides alongside capabilities:

import { defineModuleRuntime } from "@ekklesia/module-sdk";

export default defineModuleRuntime({
  id: "calendar",
  version: "0.1.0",

  register({ capabilities, http }) {
    capabilities.provide("calendar.events", calendarEvents);

    http.route({
      method: "GET",
      path: "/events/:eventId",
      permission: "calendar.events.read",
      // This route accepts no request body. See the body-size contract below.
      maxBodyBytes: 0,
      async handler(request, context) {
        return Response.json({
          eventId: context.params.eventId,
          principal: context.principal.kind,
        });
      },
    });
  },
});

A route definition has exactly five fields — method, path, permission, maxBodyBytes, handler — and nothing else; an unknown field (a baseline, a mount prefix, CORS/origin policy, middleware, audit metadata, authentication behavior, or another module's namespace) fails registration outright rather than being ignored. method is one of GET, POST, PUT, PATCH, DELETE, always uppercase; there is no OPTIONS (the platform owns CORS preflight) and no wildcard/all-method registration.

Two routes may not both match one request path

Within one module and one HTTP method, two route patterns must never be able to match the same request path. /events/:id and /events/new can — /events/new matches both — and that pair is rejected during registration with MODULE_ROUTE_OVERLAP. So are /events/:id/edit against /events/new/:action, and /a/:left/c against /a/b/:right. Two identical shapes, including paths differing only in parameter name (/events/:id and /events/:eventId), keep the existing MODULE_ROUTE_DUPLICATE result.

This is rejected rather than resolved by a "static beats parameter" rule because the routes may carry different permissions, and letting declaration order decide which one guards /events/new would make a permission policy depend on the order lines appear in your register hook. A rejected registration publishes none of that module's routes or capabilities and runs no start hook, so the failure is loud at startup rather than subtle at request time.

Still perfectly legal: two different static literals (/events/new and /events/archive), different segment counts (/events/:id and /events/:id/edit), the same path under different methods, and the same local path in two different modules — each module has its own platform-owned mount prefix. Declaration order among disjoint routes is preserved exactly.

Route parameters are decoded, untrusted input — validate them yourself

context.params holds the router's decoded path values. Decoding is not validation, and the fact that your declared path passed the platform's path grammar says nothing about what a caller may send. A single encoded segment routinely carries characters no declared path could:

Request segment context.params value
%2F..%2Fadmin /../admin
a%5Cb a\b
a%00b a, NUL, b
a%0D%0Ab a, CR, LF, b
1%3B%20drop 1; drop
%C3%A9v%C3%A9nement événement

Validate every parameter you use against your own exact domain grammar — a UUID, slug, date, numeric identifier, or whatever your module owns — before any database, filesystem, command, identifier, or other side-effecting use, and reject a missing, repeated, malformed, or semantically invalid value with your own sanitized response:

const eventId = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u;

async handler(request, context) {
  const id = context.params.eventId;
  if (id === undefined || !eventId.test(id)) {
    return Response.json({ code: "INVALID_REQUEST" }, { status: 400 });
  }
  // ...only now is `id` safe to use as a domain identifier.
}

Re-encoding the value, or a decode/re-encode round trip, is not a substitute for that check. The platform deliberately supplies no generic parameter validator: your parameters are your domain types, and one platform-wide pattern would either reject legitimate values or approve meaningless ones. The platform continues to own authentication, authorization, Origin, CORS, body limits, cache behavior, and uncaught-handler sanitization, and permission enforcement always completes before your handler runs — an unauthorized caller can never reach your validation at all.

maxBodyBytes is a conscious, required request-body contract, not an optional or defaulted field: 0 means the route accepts no body at all, and a non-empty declared (Content-Length) or streamed body is rejected before the handler ever runs; any other value is the exact maximum number of bytes accepted. It must be a non-negative integer no greater than the platform ceiling, MODULE_ROUTE_MAX_BODY_BYTES_CEILING (@ekklesia/contracts, currently 1 MiB) — a negative, non-integer, non-finite, or over-ceiling value fails registration with MODULE_ROUTE_MAX_BODY_BYTES_INVALID. The platform enforces this limit itself, ahead of permission evaluation and the handler, using the identical sanitized 413 {"code":"REQUEST_BODY_TOO_LARGE"} contract every other body-consuming route in the platform uses — see API.

path is local to the module, never the path a caller actually sends. The platform alone owns the production mount prefix, /api/v1/modules/<module-id>: "/" is the module's own root, "/events" means /api/v1/modules/calendar/events, and "/events/:eventId" is a parameterized route using the same :name syntax a caller's request is matched against. A canonical path begins with /, a non-root path never ends with /, and every segment is either a lowercase kebab-case static segment or one :parameterName segment — no empty or repeated slashes, no query string or fragment, no backslash or percent-encoding, no dot segments, and no wildcard/catch-all segment. A malformed path is rejected outright, never silently normalized.

Every route — including a route meant to be publicly reachable — names exactly one permission the owning module's own manifest declares; there is no way to register an unprotected, no-permission route. A module cannot supply a baseline directly: the permission's trusted public, authenticated, or restricted baseline always comes from the composed platform registry, exactly like any other enforcement decision. Public access is therefore expressed by referencing a permission whose manifest declaration has baseline: "public", never by omitting a permission. A module may only reference a permission its own manifest declares — never a Core-owned permission, and never another module's — so it can never gate a route behind authority it does not itself own. The same local path may be registered under more than one method, but registering the same module twice under the identical method and path (or two paths that are identical once every parameter is treated as an anonymous placeholder, such as /events/:eventId and /events/:id under GET) fails as a duplicate route.

The handler receives the standard Request plus a context of exactly moduleId, the caller's already-authorized EkklesiaPrincipal, the immutable decoded path parameters, and services — the same frozen ModuleRuntimeServices object the module's own start/stop hooks receive, currently exposing only database (see Database access) — never an authorization store, grant/platform-owner state, storage, jobs, configuration, secrets, a framework-specific request context, or a raw Better Auth session. The full query string remains available through Request.url; there is no separate query or body abstraction.

Core defines, validates, and collects routes into StartedModuleRuntime's immutable route snapshot — it never invokes a handler itself. apps/api mounts that exact snapshot into production at startup, under the /api/v1/modules/<module-id> prefix described above: every route is enforced through the identical requirePermission middleware Core's own routes use, a state-changing method (POST/PUT/PATCH/DELETE) additionally requires a trusted Origin before authorization ever runs, and a handler that throws, rejects, or returns something other than a Response becomes a fixed, sanitized 500 {"code":"MODULE_HANDLER_FAILED"} — never the caught value, a stack, the route's path, its permission id, or the module id. The platform remains the sole owner of CORS and caching for every module route: a handler-supplied CORS-control header is stripped, and every module response is forced to Cache-Control: no-store with Vary: Origin, Cookie (in addition to any of the handler's own legitimate Vary values) until a dedicated cache-policy contract exists — deliberately conservative, since even a public-baseline handler still receives an authorized principal and may personalize its response. See API for the full production mounting, enforcement, and error-response contract.

Drizzle schema definition

A module with tables keeps the generator-facing declarations in src/schema/tables.ts:

import { integer, pgTableCreator, text } from "drizzle-orm/pg-core";

const moduleTable = pgTableCreator((localName) => `calendar_${localName}`);

export const events = moduleTable("events", {
  id: integer("id").primaryKey(),
  title: text("title").notNull()
});

The production schema entrypoint in src/schema/index.ts wraps those same objects with the Module SDK ownership marker:

import { defineModuleSchema } from "@ekklesia/module-sdk";
import { events } from "./tables.js";

export { events } from "./tables.js";

export default defineModuleSchema({
  moduleId: "calendar",
  tables: { events }
});

The prefix callback converts the module ID to a snake-case namespace and prefixes every local table name. Here the physical PostgreSQL name is calendar_events; care-team plus members becomes care_team_members. Local names must be lowercase snake case, and the complete physical identifier must not exceed 63 characters. The SDK's createModuleTable provides the same convenience when a generator-facing pure declaration file is not needed; Core validates built metadata independently of either helper.

Schema definitions use PostgreSQL tables in the platform-managed default/public schema. They must be side-effect-free: do not connect to a database, read configuration, or perform I/O during import. The moduleId is an ownership marker and must match the static manifest. Export physical base tables, never query aliases.

Foreign keys may reference self or another exported table from the same module. They may not target another module or an omitted private table. Every source column must belong to the declaring table, and all columns in a compound target must belong to the same exported target table. Modules must never import another module's tables, columns, relations, schema entrypoint, or migration files. Store opaque identifiers and integrate through public capabilities, events, or APIs instead.

Drizzle migrations

Module migrations use the stable Drizzle Kit 0.31.10 PostgreSQL format. Pin that exact version in the module, set migrations.prefix to index, and generate from the module's own source schema:

import { defineConfig } from "drizzle-kit";

export default defineConfig({
  dialect: "postgresql",
  schema: "./src/schema/tables.ts",
  out: "./drizzle",
  migrations: { prefix: "index" },
  breakpoints: true
});

Keep tables.ts limited to Drizzle declarations so the stable generator can load it without executing the ESM Module SDK wrapper. src/schema/index.ts remains the production compiled schema entrypoint and wraps those same exported table objects with defineModuleSchema; tables are not declared twice.

Run the module's migration:generate script with a lowercase snake-case name, review the generated SQL and snapshot, and add the matching entry to drizzle/meta/_ekklesia.json. Every entry must explicitly declare either {"mode":"none"} or {"mode":"approval-required","reason":"..."}. Do not add an approved flag; approval will be supplied by an external Core-owned provider and bound to module ID, identity, and checksum.

Migration identities are <module-id>:<generated-tag>. Core preserves Drizzle journal order inside each module and dependency-first discovery order between modules. It calculates SHA-256 from the exact SQL bytes, so changing whitespace or line endings changes the checksum. Never edit an already-applied migration: Core compares the current exact checksum and metadata with its fixed PostgreSQL history and refuses changed, missing, unknown, or out-of-prefix entries. Add a new forward migration instead. Downgrade and uninstall migrations are not supported.

A module may migrate only its own objects. SQL is intentionally treated as opaque: offline validation checks the artifact and metadata contract but cannot prove SQL ownership or classify destructive behavior. Review those properties explicitly. Core is the only migration executor. It follows Drizzle's exact --> statement-breakpoint delimiter and never naively splits semicolons or falls back to non-transactional execution.

Database access

context.services.database — available in start, stop, and every authorized route handler, never in register — is a ModuleDatabase: a typed Drizzle query surface over exactly select, selectDistinct, selectDistinctOn, insert, update, delete, execute, and transaction, preserving Drizzle's own table-aware argument and result inference. It is the platform's only supported way for module code to reach PostgreSQL:

import { defineModuleRuntime } from "@ekklesia/module-sdk";
import { events } from "./schema/tables.js";

export default defineModuleRuntime({
  id: "calendar",
  version: "0.1.0",

  register({ http }) {
    http.route({
      method: "GET",
      path: "/events",
      permission: "calendar.events.read",
      maxBodyBytes: 0,
      async handler(_request, context) {
        const rows = await context.services.database
          .select({ id: events.id, title: events.title })
          .from(events);
        return Response.json(rows);
      },
    });
  },
});

Import your module's own table objects (src/schema/tables.js — the same declarations Drizzle schema definition already covers) exactly as you would with an independently constructed Drizzle instance; the only difference is that services.database, not a client you built yourself, executes the query. Never:

  • Construct a Pool, a Client, or a Drizzle instance in module code.
  • Read DATABASE_URL or any other connection configuration.
  • Access $client, a session, or a dialect object — ModuleDatabase exposes none of them, and not merely at the type level: the constructed facade physically has no such property.
  • Stash services (or services.database) in a module-global variable. The platform provides the identical object directly to start, stop, and every authorized handler; there is nothing to cache.
  • Run or generate a migration from register, start, stop, or a route handler. Schema and migration changes remain explicit deployment work — generate, review, commit, and apply them the same way as any other module migration, before starting code that depends on the new shape. Nothing in discovery, registration, or startup infers a schema or creates a table automatically.

A handler's database error is sanitized through the same 500 {"code":"MODULE_HANDLER_FAILED"} boundary as any other handler failure (see HTTP routes) — there is no separate, database-specific error contract. Catch a failure yourself if your route should return something more specific; an uncaught one is sanitized automatically.

ModuleDatabase is an integration boundary for trusted, compiled backend module code, not a sandbox: it exists so your module never has to construct or configure a PostgreSQL connection itself, not to defend against malicious code, since a module already runs arbitrary code in this process. It provides no tenant isolation and does not stop your own code from querying a table it imports; execute in particular lets trusted code run arbitrary SQL, exactly as every other operation already lets it construct arbitrary queries. Keep querying only your own module's tables — the same convention Drizzle schema definition already establishes for imports, just extended to runtime queries. See API for how apps/api composes and wires the facade, and ADR 0016 for the complete design.

Discovery and lifecycle

From the repository root, run:

pnpm install
pnpm modules:validate
pnpm migrations:validate
pnpm doctor
pnpm migrations:plan
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 or schema entrypoints. Missing optional dependencies are allowed. Valid modules are ordered dependency-first with stable identifier sorting.

After complete static discovery succeeds, validation loads optional schema entrypoints dependency-first and rejects invalid definitions, unnamespaced or duplicate tables, custom PostgreSQL schemas, and foreign keys outside the owning module. The aggregate is published only when every schema is valid. This slice does not connect to PostgreSQL or execute migrations.

pnpm migrations:validate performs complete static discovery first, then reads every declared migration directory without importing module code or connecting to PostgreSQL. It validates native Drizzle journals and snapshot chains, Ekklesia policy metadata, ordering, inventory, paths, and checksums atomically. Its concise report lists every installed module and migration in deterministic order.

With DATABASE_URL set through the uncommitted root .env, pnpm migrations:plan performs a read-only applied-history comparison. The plan can become stale. pnpm migrations:apply is a separate, explicit database mutation: Core acquires its bounded session advisory lock, rereads and validates history, preflights the whole destructive-approval set, and applies all pending modules in dependency-first order in one transaction. If a later migration fails, every schema and history change from that invocation rolls back; migrations committed by an earlier invocation remain.

An approval-required migration needs an approval from an external provider matching its module ID, exact identity, and exact checksum. Module metadata cannot grant approval, and there is no wildcard, force, or approve-all option. The default developer command supplies no approvals and therefore refuses destructive pending migrations.

Runner behavior is unit-tested through a narrow database port. The separate pnpm test:migrations:integration command now exercises the production adapter against disposable PostgreSQL, including DDL rollback, advisory-lock contention, history behavior, approvals, and sanitized failures. It remains outside the Docker-free pnpm verify gate; see the testing guide.

pnpm doctor builds first and presents the same production discovery results as a concise, read-only dashboard. It lists discovered module IDs and versions in dependency order, reports schema and migration counts, and shows safe migration identities with truncated checksums. Schema discovery imports the compiled schema-definition entrypoint, so its no-I/O and no-configuration contract still applies. Doctor does not import or start module server entrypoints, apply migrations, or exercise runtime hooks; lifecycle behavior remains covered by automated tests. PostgreSQL planning is skipped when DATABASE_URL is absent, and object storage is skipped until Core has a production storage integration. A skip or pending migration is visible as READY WITH WARNINGS, not misreported as a successful check.

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 an unpublished staged HTTP route list side by side, and starts them in the same order. Neither the capability registry nor the route list is published — visible on StartedModuleRuntime — unless every module registers and starts successfully. 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. No central registry edit is needed: the workspace glob, build, discovery, and runtime loading all find the new package.