# Identity Phase 4A established 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. Phase 4B built the HTTP transport on top of it — see [API](api.md) and [ADR 0011](../architecture/decisions/0011-identity-http-transport-and-public-enrollment.md) for public registration, sign-in, and principal resolution over HTTP. This page covers the identity foundation itself; see [ADR 0010](../architecture/decisions/0010-core-identity-and-better-auth-adoption.md) for that architectural decision. ## 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. `apps/api` (Phase 4B, see [API](api.md)) is that transport for server-side/API consumers; no Dashboard, Website, or Mobile Surface 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 `better-auth@1.6.23` 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/runtime.ts`'s `createIdentityRuntime` 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. ## The identity runtime (Core's Better Auth transport boundary) `@ekklesia/core/identity` publicly exports `createIdentityRuntime({config, db})` (`core/src/identity/runtime.ts`), which constructs Better Auth explicitly and returns a frozen `EkklesiaIdentityRuntime` exposing exactly two Fetch-standard capabilities: `handleAuthRequest(request): Promise` (delegates directly to Better Auth's handler; its normal responses, including its own error responses, pass through unmodified) and `resolvePrincipal(headers): Promise` (never silently downgrades a database outage to an anonymous result — see ADR 0011 for exactly how). Neither method, nor anything else exported from `@ekklesia/core/identity`, exposes `auth.api`, Better Auth's session/user types, the Drizzle adapter, the raw database, a session token, or Better Auth configuration. `apps/api` is the only current consumer of this boundary; see [API](api.md). Construction also explicitly configures four things the pinned release would otherwise leave on an environment-sensitive or implicit default, all covered by [ADR 0012](../architecture/decisions/0012-identity-origin-csrf-protection-under-test.md) and [API](api.md#logging): `advanced.disableCSRFCheck`/`disableOriginCheck` are both pinned to `false` (the default silently skips both under a test runner), Better Auth's `logger` is replaced with one that drops its message and arguments and emits only a fixed, generic string for error-level events, `onAPIError: { throw: true }` routes a genuinely unexpected failure out to `handleAuthRequest`'s own sanitized boundary instead of letting Better Auth's `better-call` transport dependency log it raw through a path no `logger` configuration can reach, and `emailAndPassword.minPasswordLength`/`maxPasswordLength` are pinned to `8`/`128` instead of left on Better Auth's fallback default. `createIdentityDrizzleDatabase` (`core/src/identity/database.ts`) also accepts an optional `operationTimeoutMs`, applied as both `pg`'s `query_timeout` and `statement_timeout` — bounding a hung or slow query, which `connectionTimeoutMs` alone cannot do since it only bounds establishing the connection. `apps/api` sets both timeouts explicitly (see [API](api.md)); an invalid value is 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 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.