Files
Ekklesia/docs/architecture/decisions/0010-core-identity-and-better-auth-adoption.md
ekklesiaandClaude Sonnet 5 679a5d225c
CI / verify (pull_request) Successful in 25s
CI / infrastructure-smoke (pull_request) Successful in 12s
CI / migration-smoke (pull_request) Successful in 22s
feat: establish core identity foundation
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]>
2026-07-21 16:39:15 -05:00

19 KiB

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 [email protected] 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.