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
@@ -0,0 +1,279 @@
# 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.
+1
View File
@@ -18,3 +18,4 @@ ADRs capture durable choices that affect more than one package or phase.
- [0007: Module-owned Drizzle schemas and table isolation](0007-module-owned-drizzle-schemas.md) — Accepted
- [0008: Module-owned migration contracts and offline validation](0008-module-owned-migration-contracts.md) — Accepted
- [0009: Transactional module migration execution](0009-transactional-module-migrations.md) — Accepted
- [0010: Core identity architecture and Better Auth adoption](0010-core-identity-and-better-auth-adoption.md) — Accepted
+15 -4
View File
@@ -78,7 +78,18 @@ definition entrypoints, which the module contract requires to be side-effect-fre
imports or starts module server entrypoints. It does not apply migrations or modify PostgreSQL, and
it honestly skips object storage because no production Core storage service exists yet.
The next architectural phase is Phase 4 identity with Better Auth. Other target architecture not
yet implemented includes production approval-provider composition, permission enforcement,
physical storage-bucket provisioning, Surface SDK, richer public resource and action contracts,
API server, dashboard, Builder, and reference feature modules.
Phase 4A adds the Core identity foundation. Core owns a Better Auth-backed identity schema
(`ekklesia_core_user`, `ekklesia_core_session`, `ekklesia_core_account`,
`ekklesia_core_verification`) reachable only through Ekklesia-owned contracts and an explicit
Core-owned Better Auth construction boundary; modules never import Better Auth or receive a raw
database client. That schema ships as a first-class Core-owned migration source that reuses the
exact migration format, checksum rule, planner, and transactional runner Phase 3 already
established, ordered before module migrations in the same aggregate. See
[ADR 0010](decisions/0010-core-identity-and-better-auth-adoption.md) and
[the identity development guide](../development/identity.md). Phase 4A does not mount an HTTP
handler or expose a sign-in endpoint; no user can authenticate yet.
The next architectural phase is identity HTTP transport and an explicit enrollment policy. Other
target architecture not yet implemented includes production approval-provider composition,
permission enforcement, physical storage-bucket provisioning, Surface SDK, richer public resource
and action contracts, API server, dashboard, Builder, and reference feature modules.
+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: