# 0010: Core identity architecture and Better Auth adoption - Status: Accepted - Date: 2026-07-21 ## Context Phase 3 delivered module discovery, module-owned Drizzle schemas and migrations, and Core-controlled transactional migration execution (ADRs 0006-0009). Authentication and identity were explicitly out of scope. `docs/architecture/platform-architecture.md` already names identity as the next architectural phase, and ADR 0001 places identity under Core, not under a feature module or a surface: "Core orchestrates generic platform infrastructure: startup, identity, permissions, configuration, database connectivity, storage, events, jobs, module discovery, and lifecycle." Ekklesia adopts Better Auth as the underlying authentication library rather than writing session/credential handling from scratch. Better Auth ships a PostgreSQL/Drizzle adapter, a schema generator, and its own `migrate` command. None of that is a substitute for Ekklesia's existing migration contracts: ADR 0008 and ADR 0009 already define the one migration format, one checksum rule, one history table, one advisory lock, and one transactional executor that every persistent schema change in this repository must go through. This decision defines how identity is owned, how Better Auth is contained, and how its schema enters that existing pipeline without creating a second migration system or weakening any Phase 3 guarantee. This is a bounded Phase 4A slice: it establishes the identity foundation only. It does not mount an HTTP handler, expose a sign-in endpoint, or make any provider or enrollment decision. ## Decision ### Core owns identity; Better Auth is an implementation detail behind it Authentication and identity are a Core platform concern, exactly like database connectivity and migration execution. Better Auth is contained entirely behind Ekklesia-owned contracts and composition code under `core/src/identity/`: - Modules must never import `better-auth`, `@better-auth/*`, or `auth` (the CLI package), and must never receive a Better Auth instance, session token, or raw database client. This is already structurally guaranteed today: `core → module` imports are impossible in either direction under `scripts/check-boundaries.ts`'s `core-is-feature-agnostic` and `modules-use-public-platform-contracts` rules, and modules may only import shared packages whose role is `contracts`, `module-sdk`, `testing`, or `tooling` — never `core` and never a Better Auth package. Phase 4A adds no exception to that matrix. - Surfaces will eventually authenticate through Core-exposed HTTP/API and client contracts, never by querying Better Auth's tables or importing Better Auth types directly. No such Core API exists yet; it is explicitly out of scope for this slice (see "Not yet operational" below). - Core's own public package barrel (`core/src/index.ts`) does not re-export anything from `core/src/identity/` in this slice. The Better Auth factory, the Drizzle adapter wiring, and the constructed instance stay internal to Core's identity module and are consumed only by Core's own code and tests, never by a public `@ekklesia/core` subpath. A later HTTP transport slice decides what (if anything) becomes part of Core's public surface. ### Better Auth's `user` row is an authentication identity, not a person profile The four Better Auth core tables (`user`, `session`, `account`, `verification`) exist to answer "who is this session," nothing more. They are not, and must never become, the future church-person/profile/domain model. Church membership, ministry roles, contact history, attendance, permissions, and any other module-specific field must never be added as columns on these tables. A future person/profile module or Core extension, not this schema, is where that data belongs, linked only through the opaque user identifier below. No custom user fields are added in this slice, and none are anticipated by widening the Better Auth tables later — the correct extension point is a separate, explicitly designed model. ### Ekklesia identity contracts (`@ekklesia/contracts`) `packages/contracts/src/identity.ts` defines the only identity-shaped values a module may eventually see: - `EkklesiaUserId` — an opaque, validated, non-empty string (`ekklesiaUserIdSchema`). It carries no meaning beyond "a stable identifier for one Ekklesia identity." Callers never construct one by hand; they parse it via `parseEkklesiaUserId`/`safeParseEkklesiaUserId`. - `EkklesiaPrincipal` — a frozen, discriminated union of exactly two shapes: `{ kind: "anonymous" }` (the frozen `ANONYMOUS_PRINCIPAL` singleton) and `{ kind: "authenticated", userId: EkklesiaUserId }` (built only through `createAuthenticatedPrincipal`, which validates and freezes the result). Deliberately absent from this contract, and out of scope for this PR: roles, permissions, organization or campus identifiers, group memberships, church profile fields, the user's email or any other personal data, Better Auth session tokens, cookie contents, and password information. Email and other personal data are therefore not automatically exposed to modules through this contract; a future slice that grants modules richer identity data must extend this type deliberately and re-justify each field. Roles and permissions remain intentionally deferred — this PR does not invent a generalized authorization framework. `packages/contracts` has no dependency on `better-auth`, so no Better Auth type can leak through this or any other public contracts export. ### Better Auth schema ownership and table naming Better Auth's four required core models are declared once, by hand, as an explicit Drizzle schema at `core/src/identity/schema.ts`, using Core-owned physical table names that make ownership unambiguous: | Better Auth model | Physical table | | --- | --- | | `user` | `ekklesia_core_user` | | `session` | `ekklesia_core_session` | | `account` | `ekklesia_core_account` | | `verification` | `ekklesia_core_verification` | `core/src/identity/schema.ts` exports `identityAdapterSchema`, an explicit `{ user, session, account, verification }` map passed directly to `drizzleAdapter(db, { provider: "pg", schema: identityAdapterSchema })`. Table naming is therefore never inferred from Better Auth's defaults; it is an explicit, reviewed mapping, matching the Drizzle adapter's documented `schema` option. Columns, defaults, uniqueness, indexes, foreign keys (`ON DELETE CASCADE` from `session`/`account` to `user`), and timestamp types mirror the pinned `better-auth@1.6.23` Drizzle/PostgreSQL generator output exactly; only the physical table names differ. ### Better Auth's own generator and migrate command are development aids only `better-auth`/`auth`'s schema generator (`pnpm --filter @ekklesia/core identity:schema:generate`, which shells out to the pinned local `auth generate` CLI) is a development-time aid used to derive and later cross-check the canonical schema. It is never run against a production database, and it never writes to `core/src/identity/schema.ts` directly — a developer reviews its output and updates the checked-in schema deliberately. Better Auth's own `migrate` command is not used at all: per its documentation it only supports the built-in Kysely adapter, and even if it supported Drizzle it would still be a second, uncoordinated migration executor. Ekklesia has exactly one: the Core migration runner defined by ADR 0009. ### Core-owned migrations: one migration system, one executor Identity's persistent schema is packaged as a first-class **Core-owned** migration source, not a disguised feature module. `core/drizzle/` holds the generated migration for the four identity tables in the exact format ADR 0008 defines (native Drizzle Kit 0.31.10 journal, per-tag SHA-256 checksum of the exact SQL bytes, and an `_ekklesia.json` destructive-policy sidecar declaring `{"mode":"none"}` for this create-only migration). Core has no `ekklesia.module.json` manifest, no compiled server entrypoint, and is never passed through `discoverModules`; giving it one merely to reuse discovery would be exactly the "disguise Core identity as an installed feature module" this decision rejects, and would violate `core-is-feature-agnostic`. Instead, `core/src/migrations/core-source.ts` reuses the exact same per-directory journal, snapshot, checksum, and destructive-policy validation module discovery already used for module migrations — the underlying inspection function was factored out of `core/src/modules/migration-discovery.ts` without changing its behavior for modules — against Core's own `core/drizzle` directory, attributed to the reserved source identifier `"core"`. `core/src/migrations/aggregate.ts`'s `discoverAllMigrations` merges Core's migrations with the existing module aggregate from `discoverModuleMigrations`, placing Core's migrations first, and rejects discovery outright (a `ModuleMigrationDiscoveryError` with code `MODULE_MIGRATION_ID_DUPLICATE`) if any installed module manifest ever declares `id: "core"`, so Core and module migration identities can never collide. Core has no dependency relationship with feature modules — per ADR 0001, Core underlies modules — so it is not part of the module dependency graph; it simply precedes every module in the merged aggregate. Existing dependency-first module ordering, identities, and checksums are untouched by this merge. `core/src/migrations/runner.ts` and `core/src/migrations/postgres-database.ts` — the transactional executor, the advisory lock, the breakpoint splitter, and the applied-history table — require **no changes**. Both already operate purely on the generic `ModuleMigrationAggregate` shape (a flat migration list plus a `{moduleId, migrations}` list) and treat `moduleId` as an opaque string key; a Core-owned entry with `moduleId: "core"` is handled by every existing code path unmodified. The fixed history table `public.ekklesia_core_migrations` and its `module_id` column, established by ADR 0009, are not renamed or restructured for this decision: Core's identity migration honestly populates that same free-text column with the literal value `"core"`, exactly like any module populates it with its own module ID. ADR 0009's own contract already describes that column as "Owning module ID" while its enforcement treats it as an opaque source key, so this is a backward-compatible representation, not a special case. No existing valid history row's meaning changes, and no column is added or removed. Migrations remain forward-only, matching ADR 0008 and ADR 0009; there are no downgrade or uninstall migrations for identity either. `core/src/cli/migration-command-support.ts`'s `discoverRepositoryMigrations` (the single chokepoint feeding `migrations:plan`, `migrations:apply`, and `migrations:validate`) and Doctor's `migrations.discovery`/`postgresql.plan` checks both now call `discoverAllMigrations`, so all three CLI commands and `pnpm doctor` represent Core's migration honestly and deterministically alongside module migrations, with Core listed first. A fresh database applies the Core identity migration through the existing `pnpm migrations:apply`; a second application is a genuine no-op, proven against real PostgreSQL. The identity schema is never created merely because a package is imported, Better Auth is constructed, or Doctor runs — discovery, planning, and Doctor's identity configuration check are read-only; only an explicit `pnpm migrations:apply` mutates the database. ### Better Auth construction boundary `core/src/identity/factory.ts` exports `createIdentityAuth({ config, db })`, Core's explicit Better Auth composition boundary: - Better Auth is constructed only when this function is called. Importing any identity module performs no PostgreSQL connection, no environment read, and no schema generation or migration — proven by unit tests that mock `pg`, `better-auth`, and `better-auth/adapters/drizzle` and assert those constructors are never invoked merely by import. - The Core-owned Drizzle database instance (`core/src/identity/database.ts`'s `createIdentityDrizzleDatabase`) and the validated `IdentityConfig` (`core/src/identity/config.ts`) are both injected explicitly; the factory reads no environment variables itself. - The identity schema mapping (`identityAdapterSchema`) is passed to `drizzleAdapter` explicitly, never left to Better Auth's naming defaults. - No provider or plugin beyond the four core models is enabled: no `emailAndPassword`, no social or OAuth provider, no organization/admin/2FA/passkey/username/magic-link plugin, no experimental feature. `betterAuth()` is called with exactly `database`, `secret`, `baseURL`, and `trustedOrigins` — nothing else — proven by a unit test asserting the exact option key set. - The function returns only a frozen, opaque `{ constructed: true }` marker. The constructed Better Auth instance, its HTTP handler, the injected database, and the Drizzle adapter are never returned, so they cannot leak through any public contract; this is the concrete meaning of "keep the Better Auth handler internal until the HTTP transport slice." ### Secrets, base URL, and trusted origins `createIdentityConfig` requires an explicit secret of at least 32 characters (matching Better Auth's own documented minimum) and rejects a missing secret, a secret under 32 characters, and a trivially degenerate secret (every character identical) as a narrow, deterministic guard — it cannot measure true entropy, and operators remain responsible for generating a high-entropy value (for example `openssl rand -base64 32`). No built-in or default secret is ever provided, and no secret is ever generated or persisted automatically. Secret rotation is deferred: Better Auth supports a plural `secrets` list for non-destructive rotation, but Phase 4A accepts only a single `secret` value. A future slice may adopt the plural form without a schema change. Base URL and trusted origins are explicit, typed configuration, never inferred from an incoming request. Every trusted origin must be an absolute `http`/`https` origin with no path, query, or fragment. Wildcard trusted origins (Better Auth supports `*`/`?`/`**` glob patterns) are rejected outright in this slice: cross-subdomain cookies are not enabled yet, so a wildcard origin would widen the trusted surface without a matching cookie-scope decision. This is deliberately narrower than upstream Better Auth, not an oversight; it may be revisited alongside a future ADR that enables cross-subdomain cookies. `createIdentityConfig` defaults to `"production"` validation when the environment is not specified, so an omitted environment never silently relaxes checks; in production mode, `http://` origins and loopback hosts (`localhost`, `127.0.0.1`, `::1`, `0.0.0.0`) are rejected for both the base URL and every trusted origin. Local development origins (for example `http://localhost:3000`) are allowed only when the caller explicitly passes `environment: "development"` — never silently. ### Cookies Host-only secure cookies remain the default direction. This decision does not enable `advanced.crossSubDomainCookies`; every cookie stays scoped to a single host, matching Better Auth's own default posture (`httpOnly` always, `secure` in production, `SameSite=Lax`). Enabling cross-subdomain cookies requires a future ADR that also addresses the matching trusted-origin widening. ### Deferred: providers, enrollment, and everything past schema construction This slice does not enable any authentication provider (email/password, social, passkey, magic link, username, OAuth/OIDC) and does not implement any enrollment, invitation, verification, or password-reset flow — those are explicit provider and enrollment decisions reserved for later, separately reviewed slices. No `emailAndPassword` or other provider is enabled even internally; the factory constructs only the database adapter and base configuration required to validate schema wiring. Phase 4A does not mount an HTTP handler and exposes no authentication endpoint: it is acceptable, and expected, that no user can sign in after this PR. Two-factor authentication, Better Auth's admin and organization plugins, and any other plugin remain out of scope until a slice that deliberately adopts them. ## Alternatives - Discovering Core's migrations by giving it a synthetic `ekklesia.module.json` and passing it through `discoverModules` was rejected: Core is not an installed, optional, versioned package the way a feature module is, it has no server entrypoint to run, and forcing it through module discovery would violate `core-is-feature-agnostic` and misrepresent Core as a module in every diagnostic and history row. - Writing a second, Core-specific migration runner or history table for identity was rejected: ADR 0009 already defines one transactional executor and one history contract; a second one would be exactly the duplicated migration system this decision must avoid, and would fork the advisory-locking and rollback guarantees the runner already provides for free. - Renaming or restructuring `ekklesia_core_migrations`'s `module_id` column for a "cleaner" Core vocabulary was rejected: it is a breaking change to an already-shipped, ADR 0009-governed table for a purely aesthetic gain, and the column already functions as an opaque source key today. - Using Better Auth's own `migrate` command, or its programmatic `getMigrations` API, was rejected: both are documented as Kysely-only and do not apply to the Drizzle adapter at all; neither would coordinate with Ekklesia's advisory lock, checksum, or approval contract even if they did. - Relying on Better Auth's default (unrenamed) table names was rejected: `user`, `session`, `account`, and `verification` do not signal Core ownership and are far more likely to collide with a future module's own table names in the single shared `public` schema. - Supporting Better Auth's wildcard trusted-origin syntax immediately was rejected for this slice because it has no matching cookie-scope decision yet; narrowing to exact origins keeps the trusted surface auditable until cross-subdomain cookies are deliberately designed. - Putting church-person/profile fields directly on Better Auth's `user` table was rejected: it would conflate an authentication identity with a domain model that modules, not Core, are expected to own, and would make every future profile field a Core-owned, Better-Auth-coupled migration. ## Consequences Ekklesia has exactly one migration format, one checksum rule, one advisory lock, and one transactional executor, and Core's identity schema is now the first non-module consumer of that pipeline, applied and integration-tested against real PostgreSQL. `pnpm doctor`, `pnpm migrations:plan`, and `pnpm migrations:apply` all represent Core's identity migration honestly, ordered before any module migration. No module can import Better Auth or receive a database client or session token; that boundary was already structurally enforced by `scripts/check-boundaries.ts` and required no new code to hold for identity. What remains explicitly not operational after this PR: no HTTP server or API route, no mounted Better Auth handler, no sign-in or sign-up endpoint, no public registration or invitation flow, no email delivery, no password reset or email verification, no social or OAuth provider, no passkeys or magic links, no two-factor authentication, no roles or permissions, and no church member/person/profile modeling. 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.