# 0007: Module-owned Drizzle schemas and table isolation - Status: Accepted - Date: 2026-07-13 ## Context Ekklesia needs to collect database declarations from independently installed modules without giving Core feature-specific knowledge or allowing modules to couple through private database structures. Schema declarations must be inspectable before any future migration or database startup work, and their physical names must remain globally collision-resistant in a shared PostgreSQL deployment. The module manifest already has an optional `entrypoints.schema` field. Production module loading already requires compiled ESM artifacts and complete static discovery before executable code is imported. ## Decision A module that owns tables declares a compiled ESM `.js` or `.mjs` schema entrypoint in `ekklesia.module.json`, normally `./dist/schema/index.js`. The entrypoint default-exports a `defineModuleSchema` definition containing only a module ID ownership marker and a table record. The ID must match the already-discovered manifest. The schema entrypoint contains declarations only, performs no I/O, opens no database connection, and has no top-level side effects beyond constructing Drizzle metadata. All module tables use Drizzle's PostgreSQL dialect in the platform-managed default/public PostgreSQL schema. Modules cannot select custom PostgreSQL schemas. A module owns only the table objects exported by its own schema definition. Schema definitions contain physical base tables, not query aliases that could disguise another table under an owned name. Physical table names use this mandatory namespace: ```text _ ``` For example, module `care-team` creates local table `members` as `care_team_members`. Identifiers must be lowercase ASCII snake case and no longer than PostgreSQL's 63-character identifier limit. `createModuleTable(moduleId)` applies this transformation for authors, while Core validates the resulting physical names independently. Every table object and every physical table name may appear only once in the aggregate. Foreign keys may target self-referential or other exported tables owned by the same module. Every source column must belong to the declaring table, and every target column in a simple or compound key must belong to one exported target table. A foreign key may not target a table owned by another module, even when a module or capability dependency exists. A target with the same namespace that is omitted from the owning module's schema definition is also invalid. Modules may not import another module's schema entrypoint, tables, columns, relations, or other private code. Cross-module data relationships use opaque identifiers and public capabilities, events, or APIs. The repository boundary checker enforces literal source/package imports, and Core schema validation independently rejects cross-module foreign-key metadata. Core schema discovery first completes ordinary module discovery for the entire installed graph. If that fails, no schema entrypoint is imported. It then imports optional schema entrypoints in dependency-first module order, validates every definition and table, and publishes a frozen aggregate only if all modules succeed. Module contributions and table records are explicitly sorted, and failures are structured, deterministic, and stripped of caught exception messages, stacks, URLs, causes, and machine-specific paths. Atomicity here means no schema code runs before complete static discovery and no partial aggregate is published. ESM evaluation itself cannot be undone, which is why schema entrypoints must remain side-effect-free. ## Alternatives - Source TypeScript entrypoints were rejected because production schema discovery must consume the same built artifacts that deployment uses. - PostgreSQL schema-per-module isolation was rejected for this slice because table prefixes already provide explicit ownership without adding schema search-path and migration-filtering complexity. - Cross-module foreign keys were rejected because they couple module installation, upgrades, deletion, and migration ordering through private storage details. - Inferring tables from all named module exports was rejected because an explicit default definition provides one reviewable ownership boundary and deterministic aggregation. - A central table registry was rejected because cloning a module into `modules/` must remain enough to install its declarations. ## Consequences Module authors must use names short enough for the complete namespaced identifier to fit within 63 characters. Modules with no tables omit `entrypoints.schema`. Custom PostgreSQL schemas, views, enums, sequences, and richer relation metadata can receive explicit contracts later rather than being accepted implicitly now. This decision discovers and validates Drizzle table metadata only. It does not create a database, execute migrations, compare live schema state, or define upgrade, downgrade, uninstall, or destructive-change policy. Those migration decisions remain a later Phase 3 slice.