feat: establish core identity foundation
CI / verify (pull_request) Successful in 25s
CI / infrastructure-smoke (pull_request) Successful in 12s
CI / migration-smoke (pull_request) Successful in 22s

Adds Core-owned identity contracts, a Core-owned Better Auth database schema
(ekklesia_core_user/session/account/verification), a first-class Core-owned
migration source that coexists with module-owned migrations under the exact
existing migration system, and an explicit Better Auth construction boundary.
No authentication endpoint or sign-in UI is exposed yet (ADR 0010).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
ekklesia
2026-07-21 16:39:15 -05:00
co-authored by Claude Sonnet 5
parent df0f20d8af
commit 679a5d225c
43 changed files with 4259 additions and 64 deletions
+144
View File
@@ -0,0 +1,144 @@
# Identity
Phase 4A establishes Core's identity foundation: a Core-owned Better Auth database schema, a
first-class Core-owned migration source, minimal opaque-identity contracts, and an explicit Better
Auth construction boundary. It does not expose a sign-in endpoint, mount an HTTP handler, or make
any provider or enrollment decision. See
[ADR 0010](../architecture/decisions/0010-core-identity-and-better-auth-adoption.md) for the full
architectural decision; this page is the practical developer guide.
## Ownership
Authentication and identity are a Core platform concern (ADR 0001), not a feature module and not a
surface concern. Better Auth is an implementation detail contained entirely behind
`core/src/identity/`:
- **Modules never import `better-auth`, `@better-auth/*`, or `auth`, and never receive a Better
Auth instance, session token, or raw database client.** This holds structurally today: modules
cannot import `core` at all under `scripts/check-boundaries.ts`'s boundary matrix, regardless of
what Core exposes.
- **Surfaces will eventually authenticate through Core-exposed HTTP/API and client contracts**,
never by querying Better Auth's tables directly. No such Core API exists yet.
- Better Auth's `user` row is an authentication identity, not the future church-person/profile
model. Church membership, ministry roles, contact history, attendance, permissions, and other
module-specific fields must never be added to the identity tables; a future, separately designed
model owns that data, linked only through the opaque user identifier.
## The four Core identity tables
| Better Auth model | Physical table |
| --- | --- |
| `user` | `ekklesia_core_user` |
| `session` | `ekklesia_core_session` |
| `account` | `ekklesia_core_account` |
| `verification` | `ekklesia_core_verification` |
Declared once, by hand, at `core/src/identity/schema.ts`, and passed explicitly to Better Auth's
Drizzle adapter as `identityAdapterSchema`. Columns, defaults, uniqueness, foreign keys (cascading
delete from `session`/`account` to `user`), and indexes mirror the pinned
`[email protected]` generator output exactly; only the table names differ, so ownership is
unambiguous inside the shared `public` PostgreSQL schema. No custom fields are added in this
slice.
## Ekklesia identity contracts
`@ekklesia/contracts` exports the only identity-shaped values available today:
- `EkklesiaUserId` — an opaque, validated string. Parse untrusted input with
`parseEkklesiaUserId`/`safeParseEkklesiaUserId`.
- `EkklesiaPrincipal` — a frozen `{ kind: "anonymous" }` (the `ANONYMOUS_PRINCIPAL` singleton) or
`{ kind: "authenticated", userId }` (built with `createAuthenticatedPrincipal`).
No roles, permissions, organization/campus IDs, group memberships, profile fields, email, session
tokens, or password data are exposed through this contract. Extending it requires a deliberate,
separately reviewed change.
## Core-owned migrations coexist with module migrations
Identity's schema ships as a first-class Core-owned migration source at `core/drizzle/`, in the
exact Drizzle Kit / Ekklesia migration format ADR 0008 and ADR 0009 define — the same journal,
per-file SHA-256 checksum, and `_ekklesia.json` destructive-policy sidecar every module migration
uses. Core is not a module: it has no `ekklesia.module.json` manifest and is never passed through
module discovery. Instead, `core/src/migrations/core-source.ts` discovers `core/drizzle` directly
under the reserved source identifier `"core"`, and
`core/src/migrations/aggregate.ts`'s `discoverAllMigrations` merges it with the existing module
aggregate, **Core first**. An installed module manifest that ever declares `id: "core"` is rejected
at discovery time so Core and module migration identities can never collide.
The transactional runner, advisory lock, breakpoint splitter, and the fixed
`public.ekklesia_core_migrations` history table (ADR 0009) are unchanged: Core's migration honestly
populates the existing `module_id` column with the literal value `"core"`, exactly like a module
populates it with its own module ID. There is exactly one migration system and one executor.
## Validating and applying migrations
```bash
pnpm migrations:validate # offline; Core's migration plus every module's, Core first
pnpm migrations:plan # read-only comparison against PostgreSQL applied history
pnpm migrations:apply # locked, transactional application of the pending batch
pnpm doctor # includes Core's migration in its discovery/plan checks
```
No new commands are required to include identity: it flows through the existing migration
commands automatically. A fresh database applies the Core identity migration through
`pnpm migrations:apply` like any other pending migration; a second application is a genuine no-op.
Doctor, planning, and importing any identity module never create the identity tables — only an
explicit `pnpm migrations:apply` mutates the database.
## Upgrading the Better Auth schema
Better Auth schema changes must never be applied to production directly. Follow this workflow
whenever the pinned Better Auth version changes:
1. Deliberately update the pinned `better-auth`, `auth`, and `drizzle-kit` versions in
`core/package.json`.
2. Regenerate the canonical schema with the pinned local CLI:
`pnpm --filter @ekklesia/core identity:schema:generate` (writes a local, gitignored
`core/.identity-schema-reference.ts` — never committed).
3. Review the diff between that reference file and the checked-in
`core/src/identity/schema.ts`, deliberately updating the latter (table names, relations, and
any structural change) while preserving Core's explicit `ekklesia_core_*` naming.
4. Generate a new forward-only Core migration:
`pnpm --filter @ekklesia/core identity:migration:generate` (drizzle-kit, writes into
`core/drizzle/`). Add the matching entry to `core/drizzle/meta/_ekklesia.json`, declaring
`{"mode":"none"}` or `{"mode":"approval-required","reason":"..."}` for any destructive change.
5. Validate and test it through Ekklesia's existing migration system:
`pnpm migrations:validate`, `pnpm identity:schema:validate`, and
`pnpm test:migrations:integration`.
6. Never edit an already-applied migration. Editing changes its checksum, and Core's runner
refuses an applied migration whose packaged bytes no longer match its recorded history.
`pnpm identity:schema:validate` automates steps 2–3 as a read-only check: it regenerates Better
Auth's canonical schema into a temporary, package-local directory with the pinned local CLI,
structurally compares every core model's columns, types, nullability, defaults, foreign keys, and
indexes against the checked-in schema, and always removes the temporary output — no `DATABASE_URL`
required. It is part of `pnpm verify`.
## Required identity configuration
`createIdentityConfig` (`core/src/identity/config.ts`) validates three explicit values before
`core/src/identity/factory.ts` constructs Better Auth. This document intentionally contains no
real values — configure them per environment, never commit them:
| Value | Requirement |
| --- | --- |
| Secret | At least 32 characters, high entropy (for example `openssl rand -base64 32`). No built-in default is ever provided; a missing or too-short secret is rejected explicitly. |
| Base URL | An absolute `http`/`https` URL. In production, `https` is required and loopback hosts are rejected. |
| Trusted origins | One or more absolute `http`/`https` origins with no path, query, or fragment. Wildcards are rejected in this slice. In production, `https` is required and loopback hosts are rejected. |
Local development origins (for example `http://localhost:3000`) are only accepted when the caller
explicitly passes `environment: "development"` — never silently. Every configuration failure
raises a sanitized `IdentityConfigError` carrying only a stable code, never the offending value.
Secret rotation (Better Auth's plural `secrets` list) and cross-subdomain cookies are deferred; see
ADR 0010.
## What is not operational yet
No HTTP server or API route exists, Better Auth's handler is not mounted, and there is no sign-in
or sign-up endpoint, public registration, invitation flow, email delivery, password reset, email
verification, social or OAuth provider, passkey, magic link, two-factor authentication, or roles
and permissions model. **No user can authenticate through this repository yet.** The next expected
slice is identity HTTP transport and an explicit enrollment policy (for example, how the first
administrator account is created) — not a Dashboard Surface, which depends on that transport slice
existing first.
+27 -8
View File
@@ -19,7 +19,8 @@ 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 migrations:validate # offline Core + module migration inventory and exact checksums
pnpm identity:schema:validate # detects drift against the pinned Better Auth schema generator
pnpm migrations:plan # read-only comparison with PostgreSQL applied history
pnpm migrations:apply # locked, transactional application of the pending batch
pnpm doctor # build-first, read-only human readiness dashboard
@@ -41,9 +42,16 @@ schema entrypoints are loaded and validated for table ownership, namespacing, du
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.
checks Core's own `core/drizzle` migration directory together with every declared module Drizzle
migration directory. It reports Core's migration first, followed by dependency-first module
inventories, with exact SHA-256 checksums, without importing module code, connecting to
PostgreSQL, or executing SQL. Modules with no migration history are valid.
`pnpm identity:schema:validate` regenerates Better Auth's canonical schema with the pinned local
`auth` CLI into a temporary, package-local directory, structurally compares every core model
(columns, types, nullability, defaults, foreign keys, and indexes) against the checked-in
`core/src/identity/schema.ts`, and always removes the temporary output. It requires no
`DATABASE_URL`. See [Identity](identity.md) for the full upgrade workflow.
## Developer Doctor
@@ -66,8 +74,12 @@ Doctor reports a fixed, deterministic set of checks:
- Schema discovery, including validated module and table counts. This imports compiled schema
definitions, which the module contract requires to perform no configuration reads, I/O, or
database connection.
- Offline migration discovery, with safe identities and consistently truncated checksums.
- Offline migration discovery for Core's own identity migration and every module, with safe
identities and consistently truncated checksums, Core listed first.
- PostgreSQL connectivity and read-only applied-history planning when `DATABASE_URL` is configured.
- Identity runtime configuration: skipped (not a failure) when no secret, base URL, or trusted
origins are set; otherwise validated with `createIdentityConfig` and reported pass or failure.
This check never constructs Better Auth or opens a database connection.
- Object storage as skipped while Core has no production storage client or configuration contract.
Every check has a stable ID and one of four states: pass, warning, failure, or skipped. Independent
@@ -94,7 +106,8 @@ Data definitions
✓ Schemas validated
0 modules · 0 tables
✓ Migrations validated
0 migrations · checksums valid
1 migration · checksums valid
core:0000_identity_core_tables · f8f2086ef06f
PostgreSQL
○ Connection check skipped
@@ -102,6 +115,11 @@ PostgreSQL
○ Migration plan skipped
A validated migration inventory and configured PostgreSQL connection are required.
Identity
○ Identity configuration skipped
Core identity schema defines 4 tables (ekklesia_core_account, ekklesia_core_session, ekklesia_core_user, ekklesia_core_verification).
Identity runtime configuration (signing key, base URL, trusted origins) is not set.
Object storage
○ Check unavailable
Core storage integration has not been implemented yet.
@@ -112,8 +130,9 @@ Result: READY WITH WARNINGS
Doctor is a local, human-readable summary. `pnpm verify`, `pnpm ci:verify`, infrastructure smoke,
and migration smoke remain the authoritative automated evidence because they exercise formatting,
boundaries, types, tests, builds, containers, and live mutation/rollback behavior that Doctor does
not repeat. Phase 4 identity with Better Auth is the next architectural phase; Doctor does not
diagnose identity, authorization, HTTP routing, runtime module health, or production storage yet.
not repeat. Identity HTTP transport and an explicit enrollment policy are the next architectural
phase; Doctor does not diagnose authorization, HTTP routing, runtime module health, or production
storage yet, and it never constructs an operational Better Auth instance or claims sign-in works.
Migration planning and application require `DATABASE_URL`. Keep it in the uncommitted `.env`
created from `.env.example`:
+17 -4
View File
@@ -9,10 +9,15 @@ pnpm test
pnpm verify
```
These commands exercise package tests, repository boundaries, type checking, offline module and
migration discovery, formatting, linting, and builds. Migration-runner unit tests use a narrow
database/session port to prove deterministic planning, approval, transaction orchestration,
cleanup, and sanitized errors. They do not claim to prove PostgreSQL behavior.
These commands exercise package tests, repository boundaries, type checking, offline Core and
module migration discovery, identity schema drift detection, formatting, linting, and builds.
Migration-runner unit tests use a narrow database/session port to prove deterministic planning,
approval, transaction orchestration, cleanup, and sanitized errors. They do not claim to prove
PostgreSQL behavior. Identity unit tests cover principal/opaque-ID contracts, configuration
validation (secret, base URL, and trusted-origin rules), the Better Auth construction boundary
(explicit construction, exact schema mapping, no unexpected provider or plugin, sanitized
failures), and Core migration discovery (deterministic ordering, the reserved `core` identity, and
collision rejection) — all without a live database.
`pnpm verify` is intentionally Docker-free. It never plans or applies migrations against a live
database.
@@ -39,6 +44,14 @@ whole-batch DDL rollback, Drizzle breakpoint execution, real advisory-lock concu
timeouts, lock release after failure, exact destructive approvals, and public-error sanitization.
It does not make migrations automatic during Core startup.
A dedicated set of scenarios exercises the committed Core identity migration against real
PostgreSQL: read-only planning on a fresh database, applying it and confirming exactly the four
expected `ekklesia_core_*` tables and their indexes exist, a genuine no-op on a second apply, Core
executing before a fixture module migration in one batch, previously applied module-only history
staying byte-for-byte unchanged once Core is introduced, and a later module migration's failure
rolling back cleanly without damaging the already-committed Core identity migration or leaking the
advisory lock.
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: