docs: document module database access
CI / verify (pull_request) Successful in 27s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 29s

Add ADR 0016 recording the accepted module database access design (ModuleDatabase, the frozen
ModuleRuntimeServices boundary, one pool/one facade/one services-object identity, explicit
migrations, and the trust model), with narrow cross-reference corrections to ADR 0006 and ADR 0015
where their original text is now inaccurate. Sweep docs/development/api.md,
docs/development/authorization.md, docs/module-development/README.md, docs/development/testing.md,
and docs/development/identity.md for the new database boundary and remaining Phase 4C staleness;
correct the stale "Phase 4C is next" roadmap paragraph in platform-architecture.md and the matching
gap in README's current-status narrative. Extend the module template with a permissioned
GET /records route reading through context.services.database, using the canonical
createModuleTable helper, with matching template tests.
This commit is contained in:
ekklesia
2026-07-25 21:50:03 -05:00
parent d1b9efd4d2
commit c38b572778
15 changed files with 584 additions and 91 deletions
@@ -28,14 +28,16 @@ registry, which conflicts with install-by-cloning discovery.
> ordering — remains accepted and unchanged. This section is left as originally accepted, as the
> historical record of the manifest's prior shape.
> **Cross-reference — see [ADR 0015](0015-static-module-http-routing-and-runtime-lifecycle.md).**
> **Cross-reference — see [ADR 0015](0015-static-module-http-routing-and-runtime-lifecycle.md) and
> [ADR 0016](0016-module-database-access.md).**
> "HTTP routing... remain[s] outside this slice" (Consequences, below) and "The injected contexts
> contain only the module ID and a capability registrar or resolver" (this section, below) described
> this ADR's own scope accurately at the time. ADR 0015 implements module HTTP routing: it extends
> `ModuleRegistrationContext` (the `register`-only context) with an `http` route registrar, while
> `ModuleRuntimeContext` (the `start`/`stop` context this sentence also describes) remains exactly as
> limited to module ID and capability access as originally decided here. Every other statement in
> this ADR remains accepted and unchanged.
> `ModuleRegistrationContext` (the `register`-only context) with an `http` route registrar. ADR 0016
> further extends `ModuleRuntimeContext` (the `start`/`stop` context this sentence also describes)
> with a `services` field carrying the module database facade — `ModuleRegistrationContext` itself
> gains no such field and remains exactly as database-free as originally decided here. Every other
> statement in this ADR remains accepted and unchanged.
`ekklesia.module.json` remains the one canonical static manifest and moves to
`manifestVersion: 2`. Provided capabilities are declarations containing an identifier and an exact
@@ -162,6 +162,16 @@ configured trusted origin — never a wildcard, and never anything module-influe
### A frozen, minimal handler context and sanitized failure boundary
> **Cross-reference — see [ADR 0016](0016-module-database-access.md).**
> "A frozen context of exactly `moduleId`... and an immutable, Hono-decoded parameter record" and "A
> handler never receives a Hono `Context`, a database connection... or any other middleware-internal
> state," below, described the handler context's complete shape accurately at the time. ADR 0016
> adds a fourth field, `services`, carrying `ModuleRuntimeServices` (currently just a typed
> `ModuleDatabase` facade) — never a raw database connection, `pg.Pool`, `PoolClient`, connection
> string, or other infrastructure object. Every other statement in this section remains accepted and
> unchanged: a handler still never receives a Hono `Context`, the authorization store or evaluator,
> raw PostgreSQL access, configuration, or secrets.
A handler receives the unmodified Fetch `Request` (`c.req.raw`, so `Request.url`, headers, method,
body, and a streaming request all behave normally, and the full query string remains available
without a competing query abstraction) and a frozen context of exactly `moduleId`, the
@@ -276,6 +286,10 @@ runs, since nothing about this design sandboxes it.
## Deferred
> **Cross-reference — see [ADR 0016](0016-module-database-access.md).**
> Module database or Drizzle access, listed immediately below as deferred, is now implemented.
> Every other item in this list remains deferred and unimplemented.
Module database or Drizzle access; module storage access; jobs and workflow/event automation;
module-owned configuration and secrets services; a `ModuleRuntimeContext` authorization service;
relationship-derived grant operations; reinstall/reactivation semantics for a permission or route
@@ -0,0 +1,227 @@
# 0016: Module database access
- Status: Accepted
- Date: 2026-07-25
- Extends: [0006](0006-versioned-capabilities-and-compiled-module-runtime.md) and
[0015](0015-static-module-http-routing-and-runtime-lifecycle.md)
## Context
ADR 0007 and ADR 0008 already let a module own a Drizzle schema and a validated, checksummed
migration; ADR 0009 already applies those migrations transactionally. ADR 0015 let a module mount
permissioned HTTP routes and receive an authorized principal. None of these gave a module's
*executable* code — a `start`/`stop` lifecycle hook or a route handler — any supported way to
actually query or write the tables its own schema declares. A module wanting to persist or read
product data had no platform-sanctioned path to PostgreSQL at all.
The platform already owns exactly one physical PostgreSQL pool per API runtime
(`apps/api/src/database.ts`), wrapped in typed Drizzle views for Core's own identity and
authorization schemas (`createIdentityDrizzleView`, `createAuthorizationDrizzleView`). Handing a
module that raw pool, a connection string, or an independently constructed Drizzle instance would
either leak platform connection infrastructure into module code or require a second, competing
pool — both rejected by the same reasoning ADR 0006 and ADR 0015 already applied to capability
registration and HTTP routing. This ADR records the third typed view over that one pool, and the
runtime-services boundary that delivers it to trusted module code without exposing the pool
itself.
## Decision
### `ModuleDatabase`: a restricted, typed view over `NodePgDatabase`
`packages/contracts/src/module-database.ts` defines `ModuleDatabase` as
`Pick<NodePgDatabase, "select" | "selectDistinct" | "selectDistinctOn" | "insert" | "update" |
"delete" | "execute" | "transaction">` — derived from Drizzle's own `node-postgres` database type
with `Pick`, not hand-declared, so every method keeps Drizzle's own table-aware argument and
result inference exactly as-is. A module calling `database.select().from(table)` or
`database.insert(table).values(...)` gets the identical typed builder a direct Drizzle caller
would; no `any` or `unknown` erasure exists anywhere in the public type. `ModuleDatabase` carries
no schema generic: a module brings its own Drizzle table objects to each call, so there is no
global relational-query object spanning every installed module's tables for this type to expose.
The type excludes everything else `NodePgDatabase` or the `drizzle(...)` factory otherwise
carries: `$client` (the raw `pg` client/pool), `_` (internal session/dialect/schema state),
`query` (a schema-bound relational query builder), `$cache`, `$count`, `$with`, `with`, and
`refreshMaterializedView`. There is no connection string, pool, session, or dialect object
reachable through the type.
### The facade physically closes the same gaps at runtime, not only in the type system
`core/src/modules/database.ts`'s `createModuleDatabaseView(pool)` does not return the
`drizzle(pool)` instance directly. Drizzle's own query methods are plain prototype methods that
read `this.session`/`this.dialect` internally, not arrow-function instance properties, so handing
that instance to a module directly would both break the moment a module destructured a method off
it (`const { select } = database` loses `this`) and — more importantly — would leave every
non-public property physically reachable on the object at runtime, reachable to a module written
in plain JavaScript or one that bypasses the `ModuleDatabase` type with a cast, not merely hidden
by TypeScript. `createModuleDatabaseView` instead builds a fresh, `Object.freeze`d object with
each of the eight operations explicitly bound to the underlying Drizzle instance: only those eight
exist on the object at all, each correctly bound, and nothing else does. Building this view
performs no query and no migration — `drizzle(pool)` only wraps the given client, synchronously
and without I/O, the same construction `createIdentityDrizzleView`/`createAuthorizationDrizzleView`
already rely on. Core never constructs a `pg.Pool` itself; `apps/api/src/database.ts` remains the
one place that does, and `createModuleDatabaseView` wraps the pool it is given.
### `ModuleRuntimeServices` is the one typed boundary carrying it into trusted module code
`ModuleRuntimeServices` (`packages/contracts/src/module-database.ts`) currently contains exactly
one field: `database: ModuleDatabase`. It is frozen, and it is never handed to a module's
`register` hook — `ModuleRegistrationContext` (ADR 0006, extended by ADR 0015 with `http`) gains no
`services` field at all, so registration remains exactly as declarative and database-free as
originally decided. `ModuleRuntimeContext` (the shared `start`/`stop` context) and
`ModuleRouteHandlerContext` (ADR 0015's authorized-handler context, now exactly `moduleId`,
`principal`, `params`, `services`) both gain a `services` field instead. For a given API runtime,
`start`, `stop`, and every authorized HTTP handler receive the *same* `ModuleRuntimeServices`
object — the same object identity, not a fresh one built per hook or per request — so a module
never needs to stash a database reference in module-global state to share it between lifecycle
code and its own route handlers; the platform simply provides it directly, every time, to every
authorized call site.
### One pool, one facade, one services object, per API runtime
`apps/api/src/server.ts`'s `startApi` composes in a fixed order: discover the module inventory once
and compose the permission registry from it; construct the one runtime `pg.Pool`
(`createApiDatabase`, wrapping identity, authorization, and module views over it); construct the
frozen `ModuleRuntimeServices` from that same pool's module view; construct Identity, the
authorization store, and the evaluator; start the already-discovered module runtime, passing it
that services object; create the Hono application and mount the started runtime's routes, passing
the identical services object; bind the HTTP server. `startDiscoveredModuleRuntime` threads the
same services object into `ModuleRuntimeContext` for every module's `start` and `stop`;
`mountModuleHttpRoutes` threads the identical object into every authorized handler's
`ModuleRouteHandlerContext`. No second pool, no second facade, and no second services object is
ever constructed for one running API. A services/Identity/Authorization construction failure
closes the one pool and starts no module; a module-runtime failure closes the pool without binding
a server; an app-construction or bind failure stops the module runtime, then closes the pool —
matching the cleanup-ordering discipline ADR 0015 already established, now additionally covering
the services object's own dependencies.
### Shutdown remains drain → stop → close, now load-bearing for in-flight database use
Normal shutdown is unchanged from ADR 0015: drain and close the HTTP listener first, so no new or
in-flight request can reach a module handler while the module runtime is stopping; stop the module
runtime next — a module's `stop` hook may still use `services.database`, which is safe because the
pool has not yet closed; only then close the shared pool, so no module code can run against it
afterward. This ordering was already correct under ADR 0015; it is now additionally load-bearing,
because a `stop` hook doing real database work depends on the pool still being open when it runs.
Shutdown remains memoized — one shared promise every concurrent or later caller awaits — and a
module-stop failure still does not prevent pool closure, though (unlike a startup-cleanup path) it
is not swallowed: it remains a genuine result a caller observes.
### Module schemas and migrations are unchanged; the facade is not a migration tool
A module continues to own and import its own Drizzle table definitions (ADR 0007) and its own
generated, checksummed migration (ADR 0008), applied transactionally and explicitly (ADR 0009).
`ModuleDatabase` exposes no migration operation, and nothing in the module runtime infers a
schema, creates a table automatically, or runs a migration as a side effect of module discovery,
registration, start, or an HTTP request. A module's schema and migration changes still follow the
existing generation, validation, policy, and deployment flow unchanged; they must be applied,
exactly as before, before starting module code that depends on the new shape.
## Trust and security boundary
Modules remain trusted, compiled backend plugins — the same trust model ADR 0006 established for
capability registration and ADR 0015 extended to HTTP handlers. `ModuleDatabase` is a deliberately
restricted *integration* boundary, not a security sandbox: it exists to keep a module's code
Drizzle-typed and decoupled from how the platform physically connects to PostgreSQL, not to defend
against a malicious module, which already runs arbitrary code in this process and could reach
PostgreSQL through other means regardless of what this facade does or does not expose. It provides
no tenant isolation, no PostgreSQL-role-level separation between modules, and no runtime
enforcement that one module's code only ever queries its own tables — table ownership is an
architectural and migration-policy convention (ADR 0007's naming/prefix rules), not something this
facade inspects or restricts at the SQL level; `execute` in particular lets a trusted module issue
arbitrary SQL, exactly as `select`/`insert`/`update`/`delete`/`transaction` already let it construct
arbitrary queries against any table object it imports. Authorization still fully protects the HTTP
path into that trusted code: `requirePermission` and, for state-changing methods,
`requireTrustedOrigin` (ADR 0013, ADR 0015) complete before a handler — and therefore before
`services.database` is ever reachable — runs. A handler-thrown database error, caught or not,
remains sanitized by the existing module-handler boundary (ADR 0015): an uncaught failure becomes
exactly `500 {"code":"MODULE_HANDLER_FAILED"}` with `Cache-Control: no-store` and no SQL,
PostgreSQL message or code, table/constraint name, stack, connection detail, or filesystem path;
this ADR introduces no second, database-specific public error contract.
## Consequences
### Positive
- A module can persist and query its own product data with full Drizzle type inference, without
ever owning a connection, a pool, or connection configuration.
- The platform still owns exactly one physical pool and one shutdown lifecycle per API runtime;
adding module database access did not require a second one.
- Lifecycle hooks and HTTP handlers share one consistent services boundary
(`ModuleRuntimeServices`), extensible the same deliberate way in the future.
- Registration remains declarative and side-effect-free, preserving every existing discovery,
Doctor, and `modules:validate` guarantee (none of them constructs the facade or executes module
code).
### Tradeoffs
- Adding a new Drizzle operation to the module-facing surface requires deliberately extending
`ModuleDatabase`'s `Pick`, not merely relying on whatever `NodePgDatabase` happens to expose.
- A trusted module can still issue arbitrary SQL through `execute`; this facade does not, and does
not claim to, prevent that.
- Table ownership remains a naming/migration-policy convention, not a runtime-enforced sandbox —
the same tradeoff ADR 0007 already accepted for schema ownership.
- Static installation and API-restart requirements are unchanged: a module's database access, like
its routes and permissions, only takes effect on the next restart.
- A module's migration must be applied before starting code that depends on it; the platform does
not infer or run it automatically, so an operator error here surfaces as a real database error
from the module's own code, sanitized like any other handler failure.
## Rejected alternatives
- **Exposing `pg.Pool` or `$client` directly** was rejected: it would leak platform connection
infrastructure into module code and make every module responsible for connection lifecycle the
platform already owns correctly.
- **Supplying `DATABASE_URL` to modules** was rejected for the same reason, plus the operational
risk of a module opening its own competing connections outside the platform's one validated pool.
- **One pool per module** was rejected: it would multiply connection overhead and configuration
surface for no isolation benefit this facade does not already provide at the type level, and
contradicts the one-pool-per-runtime model ADR 0015 already established.
- **Letting a module construct its own Drizzle instance** was rejected: it would reintroduce
exactly the raw-pool/connection-string exposure this ADR exists to avoid.
- **Database access during `register`** was rejected: registration remains declarative — the same
reasoning ADR 0006 already applied to capability provision — and a database-free registration
hook can never have a partially-open connection to clean up if discovery aborts.
- **Module-global service assignment** (a module stashing `services` in a top-level variable from
`start` for its handlers to read later) was rejected as the *documented* pattern: the platform
provides the identical object directly to every authorized call site, so no module needs to
invent its own storage for it.
- **Running migrations automatically at startup** was rejected: it would violate the existing,
explicit, operator-invoked migration model (ADR 0009) and risk running unreviewed DDL as a
side effect of an API restart.
- **A generic, string-keyed service locator** (`services.get("database")`) was rejected in favor of
a fixed, typed `ModuleRuntimeServices` shape: a locator would erase type information at exactly
the boundary this ADR exists to keep typed, and would let a module probe for services that do
not exist yet.
- **A global schema/query object spanning every installed module** (Drizzle's own schema-bound
`query` relational builder, extended to cover every module) was rejected: it would require the
platform to aggregate every module's schema into one object at runtime, reintroducing exactly the
cross-module coupling ADR 0007's per-module schema ownership already rejected.
- **SQL parsing, rewriting, or query interception as a runtime isolation boundary** was rejected:
modules are trusted compiled code already capable of running arbitrary queries through `execute`;
a parsing layer would be both bypassable and a false promise of a security boundary this facade
does not attempt to provide.
## Testing evidence
- Unit coverage (contracts, Module SDK, Core, `apps/api`) proves: the public `ModuleDatabase`
surface preserves Drizzle's table-aware inference with no `any`; raw pool/client/session/dialect
properties are absent from the type and, separately, from the constructed facade at runtime;
every forwarded method remains correctly bound when destructured; `register` receives no
services/database field; `start`/`stop` receive the same frozen services object; a module HTTP
handler's database is reference-equal to its module's lifecycle database; denied,
trusted-origin-rejected, and `OPTIONS` requests never touch the database fake; a zero-module
runtime never constructs or touches a database; startup/shutdown cleanup ordering holds under
every failure path exercised.
- A real installed-module PostgreSQL integration fixture (`core/tests/fixtures/
module-database-integration/db-facade/`, exercised from `apps/api/tests/
module-database.integration.ts`) proves the same guarantees against a real, disposable
PostgreSQL database: the module's table does not exist before its migration is explicitly
applied; constructing the pool and facade performs no DDL; starting the API never runs or
regenerates a migration; a real `start` hook write is visible to a later authorized HTTP read
through the identical facade object; a real authorized mutation persists and a later read
observes it, gated by permission enforcement and trusted-Origin checking exactly as ADR 0015
requires; a real committed transaction persists and a real rollback (a deliberate unique-
constraint violation) leaves no partial mutation; a module deliberately catching a real
PostgreSQL failure returns its own response untouched, while an uncaught real failure sanitizes
to the existing `MODULE_HANDLER_FAILED` boundary with no leaked SQL, error text, or path; a
`stop` hook's write completes and is independently observable before the runtime's pool closes.
+1
View File
@@ -24,3 +24,4 @@ ADRs capture durable choices that affect more than one package or phase.
- [0013: Core authorization, permission registry, and platform-owner bootstrap](0013-core-authorization-and-owner-bootstrap.md) — Accepted
- [0014: Module permission declarations and platform permission registry](0014-module-permission-declarations-and-platform-registry.md) — Accepted
- [0015: Static module HTTP routing and production runtime lifecycle](0015-static-module-http-routing-and-runtime-lifecycle.md) — Accepted
- [0016: Module database access](0016-module-database-access.md) — Accepted
+25 -8
View File
@@ -95,12 +95,29 @@ identical lowest-privilege baseline status, and signup order never grants elevat
[ADR 0011](decisions/0011-identity-http-transport-and-public-enrollment.md) and
[the API guide](../development/api.md), and
[the identity development guide](../development/identity.md) for the underlying schema and
contracts. It is now possible to publicly register, sign in, resolve a principal, and sign out; it
is not yet possible to verify an email, recover a password, reach a Dashboard, or hold any role or
permission.
contracts. It is now possible to publicly register, sign in, resolve a principal, and sign out.
The next architectural phase is Phase 4C: Core authorization (public / authenticated-baseline /
relationship-derived / grants / platform-owner) and an explicit, auditable platform-owner bootstrap
command. Other target architecture not yet implemented includes production approval-provider
composition, physical storage-bucket provisioning, Surface SDK, richer public resource and action
contracts, dashboard, Builder, and reference feature modules.
Phase 4C is complete: Core's authorization foundation — a schema, evaluator, and store for
platform owners, explicit grants, and relationship-derived grants, plus an immutable permission
registry — and an explicit, single-use, auditable platform-owner bootstrap command, plus reusable
Hono enforcement and an `/api/v1/grants` HTTP API in `apps/api`. See
[ADR 0013](decisions/0013-core-authorization-and-owner-bootstrap.md) and
[the authorization guide](../development/authorization.md).
Phase 5A wired module-declared permissions into that same authorization system — a version-3
manifest, one composed platform permission registry spanning Core's own declarations and every
installed module's, enforced through the identical middleware Core's own routes use. Phase 5B let a
module mount its own framework-neutral HTTP routes in production, under
`/api/v1/modules/<module-id>`, enforced and response-sanitized exactly like Core's own routes. This
branch completes Phase 5C: typed module database access (`context.services.database`) for a
module's lifecycle hooks and authorized HTTP handlers, over the platform's one existing PostgreSQL
pool. See [ADR 0014](decisions/0014-module-permission-declarations-and-platform-registry.md),
[ADR 0015](decisions/0015-static-module-http-routing-and-runtime-lifecycle.md),
[ADR 0016](decisions/0016-module-database-access.md), and the repository root
[README](../../README.md#current-status) for the complete, current status of every phase.
The project continues finishing the module platform's backend foundations before Phase 6 Surface
work begins. Target architecture not yet implemented includes production approval-provider
composition, physical storage-bucket provisioning, jobs/workflow automation, richer public resource
and action contracts, a Dashboard, a Builder, and reference feature modules; none of these are
scheduled by this document.
+75 -27
View File
@@ -215,11 +215,15 @@ abuse-control design.
permission registry (Core's own declarations plus every module's) — `prepareStartupModuleInventory`,
`apps/api/src/platform-permissions.ts`.
2. Construct the one shared PostgreSQL pool.
3. Construct the Core identity runtime, authorization store, and evaluator.
4. Start the module runtime from that exact same discovered inventory
(`startDiscoveredModuleRuntime`) — modules are never rediscovered for this step.
5. Create the Hono application, mounting the started module runtime's published routes.
6. Bind the HTTP server.
3. Construct the module database facade and the frozen `ModuleRuntimeServices` object from that same
pool (see [Module database access](#module-database-access)).
4. Construct the Core identity runtime, authorization store, and evaluator.
5. Start the module runtime from that exact same discovered inventory
(`startDiscoveredModuleRuntime`) — modules are never rediscovered for this step — passing it the
services object every module's `start`/`stop` hook receives.
6. Create the Hono application, mounting the started module runtime's published routes and passing
the identical services object to every module HTTP handler.
7. Bind the HTTP server.
Discovery and permission composition happen before any pool, Better Auth instance, module
entrypoint import, or HTTP server exists, so a discovery or composition failure (an invalid
@@ -257,9 +261,11 @@ are mounted under `/api/v1/modules/<module-id>`, in the module runtime's publish
(same permission check, response body stripped); a module cannot separately author `HEAD`.
- **Handler input.** A handler receives the raw Fetch `Request` (so `Request.url`, headers, and
body all behave normally, and the full query string remains available) plus a frozen context of
exactly `moduleId`, the authorized `EkklesiaPrincipal`, and frozen, Hono-decoded path parameters
(an empty frozen object for a route with none) — never a Hono `Context`, a database connection,
the authorization store, storage, jobs, configuration, or secrets.
exactly `moduleId`, the authorized `EkklesiaPrincipal`, frozen, Hono-decoded path parameters (an
empty frozen object for a route with none), and `services` — the same frozen
`ModuleRuntimeServices` object the module's own `start`/`stop` hooks received (see
[Module database access](#module-database-access)) — never a Hono `Context`, the authorization
store, storage, jobs, configuration, or secrets.
- **Handler failure.** A handler that throws, rejects, or returns anything other than a `Response`
becomes a fixed `500 {"code":"MODULE_HANDLER_FAILED"}` with `Cache-Control: no-store` — never the
caught value, a stack, the route's path or permission id, or the module id. A `Response` a
@@ -276,6 +282,46 @@ are mounted under `/api/v1/modules/<module-id>`, in the module runtime's publish
registry: installing, removing, or editing a module's routes takes effect only on the next API
restart. Removing a module makes its former routes return the ordinary `404 {"code":"NOT_FOUND"}`.
## Module database access
`context.services.database` gives a module's `start`/`stop` lifecycle hooks and every authorized
HTTP handler a typed Drizzle query surface over its own tables — never during `register`, which
stays declarative and database-free. See
[Module development](module-development/README.md#database-access) for the module-authoring
contract; this section covers only how `apps/api` composes and wires it.
- **`ModuleDatabase`.** A restricted, typed view over `NodePgDatabase`
(`packages/contracts/src/module-database.ts`), exposing exactly `select`, `selectDistinct`,
`selectDistinctOn`, `insert`, `update`, `delete`, `execute`, and `transaction`, each preserving
Drizzle's own table-aware argument and result inference. It exposes no `$client`, no internal
session/dialect state, no schema-bound relational `query` builder, and no connection string,
pool, or raw `pg` client — not merely hidden by the TypeScript type, but physically absent from
the constructed object at runtime (`core/src/modules/database.ts`'s `createModuleDatabaseView`
builds a frozen object with each operation explicitly bound, rather than handing out Drizzle's
own instance).
- **One pool, one facade, one services object.** `apps/api/src/database.ts`'s `createApiDatabase`
wraps the one shared PostgreSQL pool in three independent typed Drizzle views — identity,
authorization, and now `module` (a `ModuleDatabase`). `startApi` builds one frozen
`ModuleRuntimeServices` object (currently just `{ database }`) from that pool and passes the
*same* object to every module's `start` and `stop` hook and to every module HTTP handler for the
lifetime of one running API — never a fresh object per hook or per request, and never a second
pool or facade.
- **No database access without authorization.** A handler's context — and therefore
`services.database` — is unreachable until `requirePermission` (and, for a state-changing
method, `requireTrustedOrigin`) has already succeeded; a denied request, a rejected-Origin
request, and an `OPTIONS` preflight never invoke a module handler at all, so they never touch a
module's database.
- **Errors.** A handler-thrown database error is sanitized through the exact same
`MODULE_HANDLER_FAILED` boundary described above — there is no separate, database-specific public
error contract. A module that deliberately catches its own database failure may still return any
valid `Response` of its own choosing.
- **Migrations stay explicit.** Constructing the facade performs no query and no migration.
Module schema and migration changes still follow the existing generation, validation, policy, and
deployment flow (see [Module development](module-development/README.md#database-access)); nothing
in module discovery, registration, start, or an HTTP request infers a schema or creates a table
automatically. See [ADR 0016](../architecture/decisions/0016-module-database-access.md) for the
complete design and its rejected alternatives.
## Database connection limits
`apps/api/src/server.ts` configures the one shared PostgreSQL pool (`createApiDatabase`,
@@ -287,10 +333,10 @@ a query is already running on one. The operation timeout additionally sets `pg`'
and `statement_timeout`, so a hung or unexpectedly slow query is aborted rather than holding a pool
connection (and its caller) indefinitely. An invalid timeout (non-integer, zero, negative, or
larger than PostgreSQL's own maximum) is rejected as configuration error before any pool is
constructed, sanitized the same way an invalid connection string already is. Both the identity and
authorization domains read from this one pool through independent typed Drizzle views — see
[Authorization](authorization.md#shared-database-composition) for why there is exactly one pool,
not two.
constructed, sanitized the same way an invalid connection string already is. The identity,
authorization, and module domains all read from this one pool through independent typed Drizzle
views — see [Authorization](authorization.md#shared-database-composition) for why there is exactly
one pool, not two.
## Graceful shutdown
@@ -300,19 +346,22 @@ shared promise the first time it is called: every caller, concurrent or sequenti
exact same operation, and that operation runs exactly once no matter how many times `shutdown()`
is called. In order: it stops the HTTP listener from accepting new connections and lets in-flight
requests finish; then stops the module runtime (so no module resource stops while a route handler
might still be in flight); then closes the one shared PostgreSQL pool (identity and authorization
both read from it — see [Authorization](authorization.md#shared-database-composition)). Unlike a
startup-failure cleanup path (below), a module-stop failure here is not swallowed — it is a
genuine result a caller should observe — but pool cleanup always still runs first, so a stop
failure never leaves the pool open. The executable entry point's `SIGINT`/`SIGTERM` wiring is
extracted into `installShutdownSignalHandlers(running, exit)` — the same function production
might still be in flight) — a module's `stop` hook may still use `services.database` here, since
the pool has not yet closed; then closes the one shared PostgreSQL pool (identity, authorization,
and the module database facade all read from it — see
[Authorization](authorization.md#shared-database-composition)). Unlike a startup-failure cleanup
path (below), a module-stop failure here is not swallowed — it is a genuine result a caller should
observe — but pool cleanup always still runs first, so a stop failure never leaves the pool open,
and no module code can run against it afterward. The executable entry point's `SIGINT`/`SIGTERM`
wiring is extracted into `installShutdownSignalHandlers(running, exit)` — the same function production
composition uses, but independently testable with a fake `running`/`exit` so a test can trigger "a
signal arrived" without sending a real process signal or invoking the real `process.exit`.
A startup failure after the database pool was already created always closes that pool before the
failure propagates: identity/authorization construction failing (no module runtime was started
yet); the server failing to bind its listen address; or an app-construction failure, both of which
also stop the module runtime first, since it may have already started successfully. A module
failure propagates: module-services/identity/authorization construction failing (no module runtime
was started yet — the frozen `ModuleRuntimeServices` object is built in the same step, from the
same pool); the server failing to bind its listen address; or an app-construction failure, both of
which also stop the module runtime first, since it may have already started successfully. A module
load/register/start failure uses Core's own existing rollback (already-started modules stop, in
reverse order, before the failure propagates) and then closes the pool — there is no separate
runtime handle to stop in that case, since `startDiscoveredModuleRuntime` never returned one.
@@ -354,10 +403,9 @@ Cookie: better-auth.session_token=<redacted, now revoked>
{"kind":"anonymous"}
```
## Next phase
## What's next
Phase 4C, pass 3: module discovery/Module SDK permission registration, roles/groups/teams/tenants,
an owner-management HTTP endpoint, service accounts/API tokens for non-browser automation, and
reversible relationship-derived access itself. See [Authorization](authorization.md) for what this
pass already added (permission registry, platform-owner bootstrap, explicit-grant HTTP API). Not a
Dashboard Surface, which still depends on an authorization model that is only now getting started.
See the repository root [README](../../README.md#current-status) and
[platform architecture](../architecture/platform-architecture.md#current-implementation-status)
for what has landed across every phase and what remains before Surface work begins. This document
tracks `apps/api`'s own HTTP, module-runtime, and database-composition contracts as they evolve.
+16 -12
View File
@@ -396,15 +396,19 @@ Every response sets `Cache-Control: no-store`.
`apps/api/src/database.ts`'s `createApiDatabase(databaseUrl, options)` is the one place `apps/api`
touches PostgreSQL connection setup. It constructs exactly one validated, timeout-bounded
`pg.Pool` (`createValidatedPostgresPool`, `@ekklesia/core/database`) and wraps it with two
independent typed Drizzle views — `createIdentityDrizzleView(pool)` and
`createAuthorizationDrizzleView(pool)` — rather than two separate pools. `close()` on the returned
handle is the only thing that ever calls `pool.end()`; neither domain view owns or closes the pool
itself. `apps/api/src/server.ts` calls `createApiDatabase` once during `startApi()`, builds the
identity runtime from `.identity` and the `AuthorizationStore`/`AuthorizationEvaluator` from
`.authorization` off that same handle, and closes the handle exactly once — on graceful shutdown,
and on any startup failure after the pool was already constructed (mirroring the existing
identity-only failure-cleanup path from [API](api.md#graceful-shutdown)).
`pg.Pool` (`createValidatedPostgresPool`, `@ekklesia/core/database`) and wraps it with three
independent typed Drizzle views — `createIdentityDrizzleView(pool)`,
`createAuthorizationDrizzleView(pool)`, and `createModuleDatabaseView(pool)` (the `ModuleDatabase`
facade module code queries through `context.services.database`; see
[API](api.md#module-database-access) and
[Module development](../module-development/README.md#database-access)) — rather than three
separate pools. `close()` on the returned handle is the only thing that ever calls `pool.end()`;
no domain view owns or closes the pool itself. `apps/api/src/server.ts` calls `createApiDatabase`
once during `startApi()`, builds the identity runtime from `.identity`, the
`AuthorizationStore`/`AuthorizationEvaluator` from `.authorization`, and the frozen
`ModuleRuntimeServices` from `.module` off that same handle, and closes the handle exactly once —
on graceful shutdown, and on any startup failure after the pool was already constructed (mirroring
the existing identity-only failure-cleanup path from [API](api.md#graceful-shutdown)).
The existing per-domain factories (`createIdentityDrizzleDatabase`,
`createAuthorizationDrizzleDatabase`), which each construct and own a private pool, are
@@ -413,9 +417,9 @@ unaffected and remain available for standalone/testable use — the CLI bootstra
the authorization schema and has no reason to share a pool with anything else.
`apps/api/tests/lifecycle.test.ts` proves this composition explicitly: exactly one
`new Pool(...)` call for a full `startApi()` lifecycle (covering both identity and authorization
usage), and exactly one `pool.end()` call regardless of how many concurrent `shutdown()` callers
there are or whether the failure happened during startup.
`new Pool(...)` call for a full `startApi()` lifecycle (covering identity, authorization, and
module database usage), and exactly one `pool.end()` call regardless of how many concurrent
`shutdown()` callers there are or whether the failure happened during startup.
## Tests
+5 -4
View File
@@ -169,9 +169,10 @@ rejected the same sanitized way an invalid connection string already is.
## What is not operational yet
No email verification, password recovery, invitation flow, email delivery, social or OAuth
provider, passkey, magic link, two-factor authentication, or roles/permissions/platform-owner model
provider, passkey, magic link, two-factor authentication, or roles/groups/teams/tenants model
exists. Public registration, sign-in, principal resolution, and sign-out over HTTP **are**
operational as of Phase 4B — see [API](api.md) for the transport and routes, and ADR 0011 for why
public registration is intentional and grants no elevation. The next expected slice is Phase 4C:
Core authorization and an explicit, auditable platform-owner bootstrap command — not a Dashboard
Surface, which still depends on an authorization model that does not exist yet.
public registration is intentional and grants no elevation. Core's authorization foundation
(platform owners, explicit grants, an immutable permission registry) landed in Phase 4C and is
covered separately — see [Authorization](authorization.md) — not a Dashboard Surface, which still
depends on an authorization model that only now exists and has not yet been built on.
+17
View File
@@ -97,6 +97,23 @@ database failure and asserts, against actual `console.error`/`warn`/`log` output
Auth's own internal error logging — configured explicitly in `core/src/identity/runtime.ts` — never
emits a raw exception, SQL, connection detail, or the rejected request's origin.
A third dedicated suite (`apps/api/tests/module-database.integration.ts`, included in the same
`pnpm test:migrations:integration` run) proves `context.services.database` against a real,
installed test module (`core/tests/fixtures/module-database-integration/db-facade/` — never the
repository's live `modules/` directory, and invisible to normal discovery/Doctor/`modules:validate`
unless a test explicitly points discovery at it): the module's own table does not exist until its
migration is explicitly applied, and constructing the pool or facade never creates it; `start`,
`stop`, and an authorized HTTP handler all receive the identical `ModuleRuntimeServices` object,
proven by reference equality; a real authorized mutation persists and a later read observes it,
gated by the same permission/trusted-Origin enforcement every other module route uses; a real
committed transaction persists and a real rollback (a deliberate unique-constraint violation)
leaves no partial mutation; a module deliberately catching a real PostgreSQL failure returns its
own response untouched, while an uncaught one sanitizes to the existing
`MODULE_HANDLER_FAILED` boundary with no leaked SQL, error text, or path; and a `stop` hook's write
completes and is independently verifiable, through the harness's own administrative connection —
never the API's own pool — before that pool closes. See
[ADR 0016](../architecture/decisions/0016-module-database-access.md).
CI runs the same command in the dedicated `migration-smoke` job. After this change is merged, add
the following pull-request status context to `main` branch protection:
+80 -8
View File
@@ -157,9 +157,9 @@ export default defineModuleRuntime({
capabilities.provide("calendar.events", calendarEvents);
},
async start({ capabilities }) {
async start({ capabilities, services }) {
const people = capabilities.resolve("people.records");
// Acquire runtime resources here.
await services.database.insert(events).values({ id: "seed", title: "First event" });
},
async stop() {
@@ -170,8 +170,11 @@ export default defineModuleRuntime({
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.
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](#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
@@ -235,10 +238,12 @@ method and path (or two paths that are identical once every parameter is treated
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`, and the immutable decoded path parameters — never a database
connection, 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.
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](#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
@@ -344,6 +349,73 @@ destructive behavior. Review those properties explicitly. Core is the only migra
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:
```ts
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",
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](#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](#drizzle-migrations) — 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](#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](#drizzle-schema-definition) already establishes
for imports, just extended to runtime queries. See
[API](../development/api.md#module-database-access) for how `apps/api` composes and wires the
facade, and [ADR 0016](../architecture/decisions/0016-module-database-access.md) for the complete
design.
## Discovery and lifecycle
From the repository root, run: