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]>
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/*, orauth(the CLI package), and must never receive a Better Auth instance, session token, or raw database client. This is already structurally guaranteed today:core → moduleimports are impossible in either direction underscripts/check-boundaries.ts'score-is-feature-agnosticandmodules-use-public-platform-contractsrules, and modules may only import shared packages whose role iscontracts,module-sdk,testing, ortooling— nevercoreand 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 fromcore/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/coresubpath. 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 viaparseEkklesiaUserId/safeParseEkklesiaUserId.EkklesiaPrincipal— a frozen, discriminated union of exactly two shapes:{ kind: "anonymous" }(the frozenANONYMOUS_PRINCIPALsingleton) and{ kind: "authenticated", userId: EkklesiaUserId }(built only throughcreateAuthenticatedPrincipal, 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, andbetter-auth/adapters/drizzleand assert those constructors are never invoked merely by import. - The Core-owned Drizzle database instance (
core/src/identity/database.ts'screateIdentityDrizzleDatabase) and the validatedIdentityConfig(core/src/identity/config.ts) are both injected explicitly; the factory reads no environment variables itself. - The identity schema mapping (
identityAdapterSchema) is passed todrizzleAdapterexplicitly, 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 exactlydatabase,secret,baseURL, andtrustedOrigins— 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.jsonand passing it throughdiscoverModuleswas 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 violatecore-is-feature-agnosticand 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'smodule_idcolumn 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
migratecommand, or its programmaticgetMigrationsAPI, 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, andverificationdo not signal Core ownership and are far more likely to collide with a future module's own table names in the single sharedpublicschema. - 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
usertable 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.