# Identity Account-Lifecycle Contract

Status: DRAFT — awaiting ratification (webui-audit S2, contract 4 of 9).
Authority: PRD D10 (better-auth is the account system of record), Q1 ruled O1
by Jason 2026-08-26 (webui-audit T10). This document turns that ruling into
enforceable policy. It also carries the bootstrap/first-admin invariant from
issue #1430, folded in here after PR #1431's independent review showed the
quick-fix approach was insufficient.

Revision 2: addresses the 9 findings of the independent review
(`fleet/lanes/webui-audit/findings/pr1433-review.md`) — epoch enforcement
tightened (§3), canonical email split from provider claims (§5), external
principal keyed by issuer+subject with DB uniqueness and link step-up (§6),
JIT default precedence and first-admin SSO path defined (§2, §4),
deactivation made measurable (§7.1), deletion kept in scope and the existing
hard-delete endpoint required to fail closed (§7.3), workspace identity
reconciled with the native-kanban SOT (§1.4), verification matrix expanded
(§8), factual labels corrected (§7.3, §8.1).

Revision 3: addresses the residuals and new findings of the revision-2
re-review (`fleet/lanes/webui-audit/findings/pr1433-review-r2.md`) —
`users.emailVerified` added to the canonical set with a defined reset rule on
email change (§5.1–5.2), external-principal uniqueness moved to
(issuer, subject) (§6.1), "can actually use" defined (§6.5), the shipped
delete affordances (web admin page, `mosaic auth users delete`) required to
be removed or disabled with a defined user-visible state (§7.3), the
admin-creation switch removed in favor of plain admin authorization (§2.3),
and §8 extended with observables for IdP removal, forward-auth non-use,
first-admin SSO, wizard-recorded JIT choice, the admin-guide statement, and
positive/expiry-bound step-up cases.

Scope: account creation, bootstrap, federated login, account linking, claim
mapping, deactivation, and (minimally) deletion gating. Out of scope: RBAC
grant semantics (contract 2), wizard UX flow (contract 3), hierarchy schema
(contract 1), sensitive-data custody (contract 7 / D14).

## 1. System of record

1. better-auth's tables (`users`, `accounts`, `sessions`, `verifications`) are
   the only account system of record. All foreign keys reference `users.id`.
2. External IdPs (Authentik or any OIDC provider) are login methods, attached
   through better-auth's generic-OAuth plugin (`packages/auth/src/sso.ts`).
   They never own accounts. Removing an IdP removes a login method, not users.
3. The forward-auth perimeter shim is a deployment measure. Once in-app OIDC
   is configured for a deployment, the shim is demoted: it may stay as network
   perimeter, but no application code may read identity from its headers.
4. **Account ≠ workspace membership.** Creating an account (by any path:
   bootstrap, sign-up, invite, JIT, admin creation) creates no workspace, no
   hierarchy grant, and no workspace-scoped authority (native-kanban SOT
   REQ-TEN-001 / REQ-ID-001). The better-auth `role` field is a platform/auth
   role (`member` | `admin`), not workspace membership. Workspace grants are
   defined by contract 2; until then a fresh account can authenticate and
   holds no workspace authority.

## 2. Registration gating

Measured current state on `next`: `emailAndPassword.enabled: true` with no
gating — anyone who can reach the Gateway can create an account via
`POST /api/auth/sign-up/email` and receives role `member`.

Contract:

1. A single server-side setting `registration_mode` with values
   `open | invite | closed`. It lives in the database (admin-mutable at
   runtime), not in env config.
2. Default after bootstrap: `closed`. The wizard (contract 3) may set a
   different mode during setup, recorded as an explicit operator choice.
   While the bootstrap epoch is open (§3), the effective mode is `closed`
   regardless of any stored value: the setting takes effect only after the
   epoch completes.
3. `closed` blocks self-service email/password sign-up. It does not block
   admin-created users or OIDC JIT (§4). Post-bootstrap admin creation is
   gated by admin authorization alone — there is no separate switch for it.
   JIT is gated by its per-provider flag (§4.1). All user-creating paths are
   closed while the bootstrap epoch is open (§3).
4. `invite` requires a single-use, expiring invite token bound to an email
   address. Invite issuance is an admin operation and is audit-logged.
5. Enforcement point: a better-auth hook (or equivalent middleware executed
   inside the auth handler path), not a Gateway route guard in front of it —
   the raw `/api/auth/*` handler must be incapable of bypassing the gate.

## 3. Bootstrap / first-admin invariant (from #1430)

Invariant: **the system transitions from zero users to one admin user exactly
once per bootstrap epoch, atomically, regardless of concurrency or which code
path writes users.**

Constraints any implementation MUST satisfy (each traces to a verified defect
in PR #1431's review, `fleet/lanes/webui-audit/findings/pr1431-review.md`):

1. **Durable fail-closed epoch state, obeyed by every writer.** The epoch
   lives in a constraint-backed one-row `bootstrap_state` table. While the
   epoch is open, every non-bootstrap user-creating writer — better-auth
   sign-up, OIDC JIT, admin creation — refuses, fail-closed, enforced inside
   the writer's own path (better-auth hook for the raw handler; guard for
   admin routes). A partial unique index or a winning epoch-transition row is
   necessary but not sufficient on its own: neither stops an untagged insert
   from a writer that never consulted the epoch. Both layers are required:
   database-level transition safety (the epoch-completing write races safely
   and at most one wins) and writer-level refusal (no path can create a user
   without reading epoch state).
2. **Atomic first-admin transition.** The admin user, its credential account,
   the initial admin token, and the epoch-completed transition commit in one
   database transaction or not at all. A better-auth call through
   `drizzleAdapter(db)` runs on the root pool and is NOT part of any caller
   transaction; it may be used inside the bootstrap transition only if the
   adapter is explicitly bound to the transaction handle. Otherwise the
   bootstrap writer must create the user rows itself within the transaction.
3. **Pool safety.** No design may hold a pooled connection inside a
   transaction while awaiting a write that acquires a second connection from
   the same pool (`DB_POOL_MAX=1` is a supported configuration).
4. **Re-runnability (D4).** Bootstrap is not a one-shot: after the first-admin
   epoch completes, re-running the wizard reconfigures the system but never
   re-opens the zero-user transition. "Setup already completed" is a stable,
   testable state, and factory-reset (a future, explicitly destructive
   operation) is the only way to open a new epoch.
5. **No stranded partial outcome.** A failure at any point in the transition
   leaves nothing observable (no admin user without its token, no completed
   epoch without an admin) and setup remains retryable — this follows from
   §3.2 and is stated separately because it is the pre-existing failure mode
   the #1431 review verified.
6. **First-admin via SSO (D4).** When the operator chooses SSO for the
   initial user, the wizard executes the OIDC login as part of the bootstrap
   transition itself: the bootstrap writer creates the account from the
   asserted identity inside the §3.2 transaction. This path is the bootstrap
   writer, not JIT — §4's JIT gate stays closed during the epoch and is not
   an obstacle to D4.

## 4. JIT provisioning (OIDC first login)

1. A successful OIDC login with no matching account creates a user
   just-in-time only when `jit_provisioning` is enabled for that provider.
   The flag is per-provider and defaults off, always. There is no
   mode-implied default: Enterprise setup enables JIT only when the wizard
   records it as an explicit operator choice for a named provider (this
   replaces revision 1's "Enterprise mode defaults to closed with OIDC JIT
   enabled", which contradicted the per-provider default).
2. JIT users receive platform role `member`, never an elevated role,
   regardless of IdP claims (§5), and no workspace authority (§1.4).
3. An optional per-provider email-domain allowlist constrains JIT. The
   allowlist matches only when the IdP asserts the email with
   `email_verified: true`; an unverified address never satisfies the
   allowlist. Empty allowlist with JIT on means any authenticated subject at
   that IdP gets an account — permitted, but the wizard must present it as an
   explicit choice.
4. JIT is disabled while the bootstrap epoch is open (§3.1). The first-admin
   SSO path is §3.6, not JIT.

## 5. Claim mapping

1. **Two stores, not one.** Provider-observed claims (`email`,
   `email_verified`, display name, avatar) are recorded per external
   principal — keyed by issuer + subject (§6.1) — at first login and
   refreshed at each login. The canonical account fields (`users.email`,
   `users.emailVerified`, `users.name`, `users.image`) are set exactly once
   at account creation and are never silently overwritten by a later login.
   For SSO-created accounts (JIT or first-admin SSO), `users.emailVerified`
   is set from the provider's `email_verified` claim at creation; for
   password-created accounts it is false until the address completes
   verification.
2. **Canonical email changes only through an explicit workflow.** Either the
   user-initiated email change (with verification of the new address) or an
   admin edit. Any canonical email change — user- or admin-initiated — sets
   `users.emailVerified` to false until the new address completes
   verification; an admin may instead explicitly attest the address as
   verified in the same operation, and that attestation is audit-logged. A
   provider-claim refresh never rebinds `users.email` or
   `users.emailVerified`; a divergence between canonical email and the latest
   provider-observed email is surfaced per §6.4.
3. Never mapped from IdP claims: `role` and any future authorization
   attribute. Authorization lives in the system of record and in the RBAC
   layer (contract 2). An IdP group/role claim may at most be recorded for
   audit; it grants nothing.

## 6. Account linking trust

1. **External principal identity is issuer + subject.** A linked identity is
   keyed by the OIDC issuer and subject claims, not by an unqualified
   provider subject id and not by email. The linked-identity row stores the
   issuer, and the database enforces at most one local account per
   **(issuer, subject)** with a unique constraint on those stored columns —
   uniqueness on (provider, subject) is insufficient because provider →
   issuer is not one-to-one: two provider configurations can point at the
   same issuer, and the identity must not alias across them. The current
   non-unique `(provider_id, account_id)` index satisfies neither;
   application-level checks without a uniqueness witness lose
   concurrent-callback races. Each configured provider additionally binds to
   exactly one issuer, immutable after creation (changing the issuer means
   creating a new provider).
2. Linking an OIDC identity to an existing account happens only in one of two
   ways: (a) explicit link initiated by the logged-in user from settings,
   which requires step-up: a fresh reauthentication (password or existing
   linked method) no older than a short bound the implementation defines
   (≤ 10 minutes) — a session cookie alone is insufficient, so a stolen
   session cannot quietly attach a durable login method; or (b) automatic
   link when the IdP asserts a verified email exactly matching an existing
   account **and** the provider is marked `trusted_for_linking`
   (per-provider flag, default off).
3. Untrusted-provider email collision produces a login error naming the
   conflict, not an auto-link and not a duplicate account.
4. A linked identity whose IdP-observed email later diverges from the
   canonical account email keeps working (the link is by issuer + subject,
   §6.1) but the divergence is surfaced in the user's settings and audit log
   (the per-principal claim store in §5.1 is what makes the divergence
   representable).
5. Unlinking a login method is refused when it would leave the account with
   no **usable** login method. Usable means: a set password, or a linked
   identity whose provider is currently configured and enabled on this
   deployment. A linked identity whose provider has been removed or disabled
   (§1.2) is not usable and does not count; setting a password first lifts
   the refusal.

## 7. Deactivation propagation

1. **Deactivation (better-auth admin ban) is authoritative and bounded.**
   Concretely:
   - Ban and session revocation are one operation: the ban commit revokes all
     better-auth sessions for the user. If revocation partially fails, the
     ban itself must already be committed and every guard denies from that
     point (fail closed); the operation is retryable.
   - Every authenticated entry path checks banned state: HTTP session guards,
     the admin bearer-token path (which today does not test `banned` — an
     implementation defect this contract makes non-conformant), MCP, and
     Socket.IO.
   - Active socket connections are terminated or denied within 30 seconds of
     the ban commit, or at the next inbound message on that socket, whichever
     comes first (socket auth at connect-time only, as today, does not
     satisfy this).
   - The current admin ban route updates only the user row; it does not
     conform to this section until revocation and guard coverage land.
   - Admin tokens owned by the banned user are revoked in the same operation.
2. Deactivation at an external IdP does not propagate automatically in this
   contract's scope (no SCIM). Operational rule: removing a user from the IdP
   without banning them in Mosaic leaves any password or other linked login
   method usable — the admin guide must state this. SCIM/webhook-driven
   propagation is future work and out of scope here.
3. **Deletion is not deactivation, and deletion is gated here.** Account
   deletion semantics (FK fan-out across the 21 foreign-key constraints to
   `users.id`, spread over 19 referencing tables) require their own
   deletion-and-retention contract, chartered as an addition to the S2 list —
   contract 7 is the D14 sensitive-data custody contract and does not cover
   account deletion. Until that deletion contract is ratified: the existing
   hard-delete endpoint (`DELETE /api/admin/users/:id`) is disabled and fails
   closed, and deactivation is the only supported removal operation. A
   contract that merely declared deactivation "the only supported removal"
   while the endpoint stayed live would be false on its face.
   Disabling the endpoint alone is insufficient — its shipped callers must
   not be left as advertised operations that now fail generically:
   - The admin web UI delete action (`apps/web/src/app/(dashboard)/admin/page.tsx`
     and any SPA port of it) is removed, or replaced by a disabled control
     whose visible text states that deletion is unavailable pending the
     deletion-and-retention contract and points at deactivation.
   - The CLI command `mosaic auth users delete`
     (`packages/mosaic/src/commands/auth.ts`) is removed, or exits non-zero
     with a message stating the same and naming the deactivation command.
   - Both surfaces expose deactivation as the supported operation.

## 8. Verification requirements

Every MUST above needs a bounded observable. The matrix:

1. **Bootstrap invariant (§3).** Real-PostgreSQL concurrency tests using two
   distinct physical connections (pattern:
   `apps/gateway/src/agent/connector-lease.postgres.integration.test.ts`,
   which runs in the `test` CI step against the `ci-postgres` PostgreSQL
   service — note that pattern multiplexes one pooled handle, so the tests
   here must explicitly open separate connections). Races to cover:
   setup-vs-setup, setup-vs-raw-sign-up, setup-vs-JIT, setup-vs-admin-create.
   Plus: liveness under `DB_POOL_MAX=1`; fault injection after each write in
   the transition (user, credential, token, epoch) proving nothing observable
   leaks and setup retries; wizard re-run after completion proving the
   zero-user transition never re-opens. Mocked-transaction specs are
   supplementary; they cannot prove serialization.
2. **Registration gating (§2).** Spec coverage of all three modes against the
   raw `/api/auth/` handler path, not only Gateway controllers; invite
   lifecycle (single-use, expiry, email binding); effective-`closed` while
   the epoch is open regardless of stored mode.
3. **JIT (§4).** Provider flag off → no account on first OIDC login; on →
   account with platform role `member` and no workspace grant; domain
   allowlist rejects an unverified email claim even when the domain matches;
   JIT refused while the epoch is open.
4. **Claim mapping (§5).** Login refresh updates the per-principal claim
   store and touches none of the canonical fields (`users.email`,
   `users.emailVerified`, name, image); explicit email-change workflow is the
   only path that rebinds canonical email; every canonical email change
   resets `users.emailVerified` to false unless the admin attestation path
   is taken, and that attestation appears in the audit log.
5. **Linking (§6).** Unique-constraint witness: concurrent first-login
   callbacks for the same (issuer, subject) yield exactly one account, and
   two provider configurations sharing one issuer cannot create two accounts
   for the same subject; trusted auto-link; untrusted collision error;
   step-up both ways: an explicit link succeeds immediately after a fresh
   reauthentication and is refused once the implementation's chosen bound
   (≤ 10 minutes) has elapsed, and refused with no reauthentication at all;
   unlink refusal when no remaining method is usable per §6.5, including the
   removed-provider case, and acceptance after a password is set; divergence
   surfaced after IdP email change.
6. **Deactivation (§7).** Ban revokes sessions atomically or fails closed
   (partial-failure injection); guard denial post-ban on each transport:
   HTTP session, admin bearer token, MCP, Socket.IO; active socket terminated
   within the 30-second/next-message bound; banned user's admin tokens
   unusable; hard-delete endpoint returns a fail-closed error while the
   deletion contract is unratified; the admin web UI renders no live delete
   action (absent, or disabled with the §7.3 text) and `mosaic auth users
delete` exits non-zero with the §7.3 message — both asserted by spec.
7. **System of record and bootstrap edges (§1, §3.6, §4.3).** IdP removal:
   deleting a provider configuration leaves every user row intact and every
   other login method working (spec over the provider-config removal path).
   Forward-auth non-use: with in-app OIDC configured, a request carrying
   forward-auth identity headers and no session is treated as anonymous —
   no code path derives identity from those headers (negative spec at the
   Gateway entry). First-admin SSO: the §3.6 transition commits account,
   token, and epoch atomically from the asserted identity, and fault
   injection mid-transition leaves nothing observable (same harness as §8.1).
   Wizard-recorded JIT choice: enabling JIT for a provider writes an
   explicit per-provider operator-choice record, and no mode selection
   enables it implicitly (assert the stored record, not UI behavior).
8. **Documentation observable (§7.2).** The admin guide contains the
   IdP-removal-does-not-deactivate statement; verified by a docs assertion
   (content check in CI or an enumerated review-checklist item on the
   implementing PR) — a MUST about documentation needs a checkable artifact,
   not intent.

## Ruling request

Ratify sections 1–8 as written, with one decision embedded: registration
defaults to `closed` after bootstrap (§2.2) — say "agreed" or name the mode
you want as the default.
