feat: add module migration contracts and validation
CI / verify (pull_request) Successful in 21s
CI / infrastructure-smoke (pull_request) Successful in 20s

This commit is contained in:
ekklesia
2026-07-21 08:09:30 -05:00
parent 37f54edd89
commit 88c28d75c9
30 changed files with 2947 additions and 48 deletions
@@ -0,0 +1,151 @@
# 0008: Module-owned migration contracts and offline validation
- Status: Accepted
- Date: 2026-07-21
## Context
ADR 0007 gives each module exclusive ownership of its namespaced Drizzle tables, but it does not
define how those tables evolve. Ekklesia needs a cloneable module to carry its migration history
without a central registry, and Core needs to inspect every installed history deterministically
before a future runner is allowed to touch PostgreSQL.
Drizzle Kit already generates PostgreSQL SQL files, a journal, and schema snapshots. Replacing that
format would separate module authors from supported Drizzle tooling. At the same time, Drizzle's
journal does not express Ekklesia's destructive-change policy or module ownership boundary.
## Decision
### Ownership and packaging
Each module owns its migration history and may create, alter, or remove only database objects that
it owns. A dependency on another module does not grant permission to migrate that module's tables,
indexes, constraints, sequences, types, or other private database objects. Cross-module foreign
keys and imports of another module's private schema or migrations remain prohibited by ADR 0007.
A module with migrations declares the optional manifest field
`entrypoints.migrations`, normally as `./drizzle`. It points to a packaged directory inside the
module, not executable code. A module with no migrations omits the field. Static module discovery
must validate the directory for every installed module before migration files are inspected. The
directory is included in the module package so cloning a module into `modules/`, installing, and
building remains sufficient; no central migration registry is introduced.
### Supported Drizzle format and workflow
This slice pins Drizzle Kit `0.31.10`, matching the stable pre-v1 format compatible with the
repository's Drizzle ORM `0.45.2`. A module configures PostgreSQL, its source schema, an output
directory, index-prefixed migrations, and statement breakpoints. Authors generate migrations with
the module's `migration:generate` script, review the generated SQL and snapshots, add the required
Ekklesia policy entry, and run `pnpm migrations:validate` from the repository root.
The accepted directory format is:
```text
drizzle/
├── 0000_initial.sql
└── meta/
├── 0000_snapshot.json
├── _ekklesia.json
└── _journal.json
```
`_journal.json` and indexed snapshots are the native Drizzle Kit PostgreSQL version-7 metadata.
Every journal entry has a corresponding `<tag>.sql` file and `<index>_snapshot.json`. Extra SQL or
snapshot files and unsupported file types are invalid. Files and the `meta` directory must be real
entries within the declared directory; symbolic links are rejected rather than followed.
Ekklesia adds strict `meta/_ekklesia.json` policy metadata:
```json
{
"version": 1,
"moduleId": "example",
"entries": [
{
"tag": "0000_initial",
"destructive": { "mode": "none" }
}
]
}
```
The sidecar has exactly one entry for each journal entry in the same order. A destructive migration
uses `{"mode":"approval-required","reason":"..."}`. The source tree never records an
`approved` boolean: a future approval record must be external to the module and bound to the
module ID, migration identity, and checksum so editing the migration invalidates approval.
### Identity, checksums, and ordering
Within a module, `_journal.json` is authoritative for ordering. Indices start at zero, are
contiguous, and match each indexed tag; timestamps strictly increase; snapshot IDs form the native
Drizzle predecessor chain. The validator does not repair or re-sort an invalid journal.
Between modules, Core uses the dependency-first order produced by complete static module
discovery, with its deterministic identifier tie-breaking. It then preserves journal order within
each module.
A migration's stable identity is `<module ID>:<Drizzle tag>`, for example
`example:0000_initial`. Core calculates a lowercase SHA-256 checksum from the exact SQL file bytes.
Line endings, whitespace, breakpoint comments, and final newlines therefore affect the checksum.
SQL must be non-empty valid UTF-8, but it is not normalized. The source metadata does not duplicate
the calculated checksum. When execution exists, Core's applied database history is the authority
for comparing the stored applied checksum with current packaged bytes and rejecting edits to an
already-applied migration.
### Destructive changes and SQL limits
Migrations are forward-only for now. There are no downgrade or uninstall migrations. A migration
declared `approval-required` must never be silently applied; offline validation reports that state,
while the future runner will require an explicit external approval contract before execution.
This slice neither grants approval nor executes SQL.
The ownership and destructive declarations are enforceable author contracts and review inputs,
not claims that the offline validator can prove arbitrary SQL safe. SQL comments, quoted and
schema-qualified identifiers, procedural bodies, dynamic SQL, extension behavior, and
`search_path` changes make simplistic regular-expression classification incomplete and
misleading. The validator deliberately treats SQL as opaque bytes and does not claim to detect
cross-module access or destructive statements. Stronger enforcement, if added, requires a real
PostgreSQL-aware model and must state its limits.
### Atomic validation and future execution
Offline migration discovery begins only after complete static module discovery succeeds. It opens
no PostgreSQL connection, imports no schema or server entrypoint, and executes no SQL. It validates
the full inventory, metadata, ordering, identities, paths, snapshots, policy, and checksums for all
modules, then publishes one frozen aggregate. Any issue prevents publication of every module's
result. Errors contain deterministic codes, module IDs, and logical metadata paths only; caught
exception messages, SQL text, URLs, secrets, machine paths, causes, stacks, and partial results are
not exposed.
Migration execution remains exclusively controlled by Core. A later ADR and slice will define the
database history schema, pending/applied comparison, advisory locking, transaction boundaries,
approval lookup, failure recovery, and execution semantics.
The selected format follows Drizzle Kit's stable
[`_journal.json` and snapshot handling](https://github.com/drizzle-team/drizzle-orm/blob/drizzle-kit%400.31.10/drizzle-kit/src/utils.ts),
its [migration generation flow](https://github.com/drizzle-team/drizzle-orm/blob/drizzle-kit%400.31.10/drizzle-kit/src/cli/commands/migrate.ts),
and Drizzle ORM's [SQL reading and SHA-256 behavior](https://github.com/drizzle-team/drizzle-orm/blob/0.45.2/drizzle-orm/src/migrator.ts).
## Alternatives
- A custom migration language was rejected because it would abandon supported Drizzle generation
and metadata without improving runtime safety.
- Expected checksums in packaged migration policy metadata were rejected because they duplicate the
artifact being checked and do not prove whether an edited migration was already applied. Applied
database history will provide that authority.
- Timestamp-only or filename-only identity was rejected because independently installed modules
can legitimately use the same generated tag and timestamp.
- Lexicographically sorting migration files was rejected because Drizzle's journal is the native
ordered history and must be internally consistent.
- Automatically classifying SQL with regular expressions was rejected because it would imply an
ownership and destructive-change guarantee the implementation cannot provide.
- Module-controlled execution was rejected because ordering, history, locking, approval, and
failure behavior are platform-wide invariants owned by Core.
## Consequences
Module authors commit generated SQL, native Drizzle metadata, and the Ekklesia policy sidecar. They
must review ownership and destructive impact explicitly. Modules with no database history remain
valid and need no placeholder directory. Offline validation gives CI a stable inventory and exact
checksums without requiring PostgreSQL, but intentionally does not prove SQL semantics or predict
whether a migration will succeed against a particular live database.
+1
View File
@@ -16,3 +16,4 @@ ADRs capture durable choices that affect more than one package or phase.
- [0005: Static module manifests and atomic discovery](0005-static-module-manifests.md) — Superseded
- [0006: Versioned capabilities and compiled module runtime](0006-versioned-capabilities-and-compiled-module-runtime.md) — Accepted
- [0007: Module-owned Drizzle schemas and table isolation](0007-module-owned-drizzle-schemas.md) — Accepted
- [0008: Module-owned migration contracts and offline validation](0008-module-owned-migration-contracts.md) — Accepted
+5 -2
View File
@@ -63,8 +63,11 @@ static versioned manifests, independent capability versions and dependency range
filesystem discovery, compiled server loading, runtime-definition verification, staged capability
registration, dependency-ordered startup and reverse shutdown, rollback, and a backend-only module
template. Optional compiled schema entrypoints now contribute validated, namespaced module-owned
Drizzle tables to a deterministic aggregate without connecting to a database.
Drizzle tables to a deterministic aggregate without connecting to a database. Optional module-owned
Drizzle migration directories now produce an atomic dependency-ordered offline inventory with
validated metadata, explicit destructive-change declarations, and exact SHA-256 checksums.
Target architecture, not yet implemented: migration generation or execution, Better Auth,
Target architecture, not yet implemented: transactional migration execution and applied history,
Better Auth,
permission enforcement, physical storage-bucket provisioning, Surface SDK, richer public resource
and action contracts, API server, dashboard, Builder, and reference feature modules.
+3 -1
View File
@@ -42,7 +42,9 @@ common UI-framework dependencies inside modules. This deny list is defense in de
review remains required when adopting a new frontend technology.
The Phase 3 schema validator complements source boundaries by checking table ownership, foreign-key
targets, and physical table namespaces in compiled Drizzle metadata.
targets, and physical table namespaces in compiled Drizzle metadata. Offline migration validation
checks each module's packaged Drizzle inventory and policy contract, while deliberately making no
claim that opaque SQL ownership can be proven without PostgreSQL-aware analysis.
## Generated code
+6
View File
@@ -19,6 +19,7 @@ pnpm format:check # formatting without mutation
pnpm lint # Biome recommended rules
pnpm boundaries # architectural directions, UI ownership, and cycles
pnpm modules:validate # installed module manifests, compatibility, and dependency graph
pnpm migrations:validate # offline module migration inventory and exact checksums
pnpm typecheck # strict TypeScript
pnpm test # Vitest
pnpm build # Turborepo package build graph
@@ -35,6 +36,11 @@ must produce the compiled JavaScript entrypoints declared by their static manife
schema entrypoints are loaded and validated for table ownership, namespacing, duplicates, and
foreign-key isolation; this command does not connect to PostgreSQL or run migrations.
`pnpm migrations:validate` also builds first, completes static module discovery, and atomically
checks every declared stable Drizzle migration directory. It reports dependency-first module
inventories and exact SHA-256 checksums without importing module code, connecting to PostgreSQL, or
executing SQL. Modules with no migration history are valid.
CI runs `pnpm ci:verify`, which adds Compose configuration validation to `pnpm verify`. A separate
CI job runs the live infrastructure smoke test so code-only local verification does not require
Docker. The explicit suffix avoids pnpm's built-in `ci` install command.
+80 -13
View File
@@ -10,15 +10,24 @@ strict semantic version, and the module layer marker:
```text
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 paths are optional. A tableless module omits them and omits `entrypoints.schema`.
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.
```json
{
@@ -79,7 +88,8 @@ without executing module code:
},
"entrypoints": {
"server": "./dist/server/index.js",
"schema": "./dist/schema/index.js"
"schema": "./dist/schema/index.js",
"migrations": "./drizzle"
}
}
```
@@ -89,10 +99,11 @@ has its own exact semantic version because its public contract can evolve indepe
module release. Capability dependencies use semantic-version ranges. Optional means the provider
may be absent; if it is installed, its capability version must be compatible.
Server and optional schema entrypoints must be compiled ESM `.js` or `.mjs` files, begin with `./`,
remain inside the package, and exist. Production never imports source TypeScript directly. Modules
with no tables omit `entrypoints.schema`. Unknown manifest properties are rejected, including
presentation fields such as components or renderers.
Server and optional schema entrypoints must be compiled ESM `.js` or `.mjs` files. The optional
migration entrypoint is a directory. 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`. Unknown
manifest properties are rejected, including presentation fields such as components or renderers.
Storage bucket IDs are logical names scoped to the module. Modules may request multiple buckets.
Core will later map them to deployment-specific physical S3 buckets and provide scoped access;
@@ -135,18 +146,27 @@ close them in `stop`.
## Drizzle schema definition
A module with tables exports a declaration-only schema definition from `src/schema/index.ts`:
A module with tables keeps the generator-facing declarations in `src/schema/tables.ts`:
```ts
import { createModuleTable, defineModuleSchema } from "@ekklesia/module-sdk";
import { integer, text } from "drizzle-orm/pg-core";
import { integer, pgTableCreator, text } from "drizzle-orm/pg-core";
const moduleTable = createModuleTable("calendar");
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:
```ts
import { defineModuleSchema } from "@ekklesia/module-sdk";
import { events } from "./tables.js";
export { events } from "./tables.js";
export default defineModuleSchema({
moduleId: "calendar",
@@ -154,11 +174,12 @@ export default defineModuleSchema({
});
```
`createModuleTable` converts the module ID to a snake-case namespace and prefixes every local table
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. Core validates the built metadata even when a module does not use
the helper.
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.
@@ -171,6 +192,45 @@ and all columns in a compound target must belong to the same exported target tab
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:
```ts
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
an external Core-owned record bound to the migration 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; the future database history will detect checksum changes. 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 remains the only future migration
executor.
## Discovery and lifecycle
From the repository root, run:
@@ -178,6 +238,7 @@ From the repository root, run:
```bash
pnpm install
pnpm modules:validate
pnpm migrations:validate
pnpm verify
```
@@ -193,6 +254,12 @@ dependency-first and rejects invalid definitions, unnamespaced or duplicate tabl
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.
Core completes discovery before importing any server entrypoint. It then loads and verifies every
runtime definition before running hooks, registers all modules dependency-first into an unpublished
staged capability registry, and starts them in the same order. Shutdown is reverse-order. If a later