ADR-0039 — Auth-identity ↔ domain-User bridge via a declared binding¶
Status: Accepted (2026-06-19) — fully implemented (#778/#1398), shipped v0.83.15–v0.83.18
across four slices: D2 IR+parser (auth_identity: block, v0.83.15) · D6/A1 validate-completeness
(v0.83.16) · D3a/D4 shared provisioning mirror + production AuthStore.create_user hook (v0.83.17) ·
D3b ref User link-resolution + examples/simple_task dogfood (v0.83.18). The two former open
questions were ratified on 2026-06-19:
A1 = a1 (validate-time resolution of every required column; never a swallowed runtime failure)
and A2 = opt-in (declared auth_identity: enables the bridge). Both are folded into the
decisions below.
Context¶
An authenticated principal lives in two places that the framework keeps only loosely coupled:
- The auth store
userstable — the identity that owns sessions, memberships, the RLS fence, and audit.session.user_id == auth user.id, and that id is consumed everywhere as the auth id (get_user_by_id,delete_user_sessions,set_session_active_membership AND user_id = %s, membership resolution, RLS tenant binding, audit user-id). - The DSL-defined
Userdomain entity — what app FKs point at (created_by: ref User,tipper: ref User).
Today these are bridged by two implicit conventions:
ref UserFK auto-injection (#774) injectscurrent_user— i.e.session.user_id, the auth id — directly into the FK. This silently assumesdomain User.id == auth user.idand that the domain row exists. When it doesn't (a user created via the real/auth/registerflow is never mirrored into theUserentity), everyref Usercreate fails with a FK violation. This is #778.- The auth store separately reads domain
Userattributes by email (_load_domain_user_attributes) — read-only, no id assumption — so scope rules likecurrent_user.schoolresolve.
A partial fix exists for the test path only: _mirror_auth_user_to_domain
(test_routes.py, schema-derived since #1398) upserts a domain User row at the auth id on
/__test__/authenticate. It is not invoked by the production auth flow, so the real-flow gap
(#778) remains. Downstream consumers (e.g. the "pennydreadful" lens) paper over it with a manual
_seed_domain_user(...) upsert commented "works around the broken Dazzle #778 mirror."
The framework already has the general, auth-safe bridge mechanism for other persona-backed
entities: backed_by + link_via (cycle 249, EX-049). A persona tester: backed_by: Tester
binding makes the create handler resolve the backing row by link_via at request time and inject
the domain row's id — no id-equality assumption, no session change. User is explicitly
special-cased out of this path (target != "User"); it falls back to the auth-id injection that
assumes id-equality. This ADR brings User into the same declared-binding model.
Decision¶
D1 — Never repoint session.user_id. Bridge at the injection/provisioning layers, not the session layer.¶
session.user_id is the auth-store identity and is load-bearing for sessions, membership, the RLS
fence, and audit. Conflating it with the domain User id (so a ref User FK resolves) would
break every auth-store lookup keyed on it. The auth↔domain bridge is established by (a) which id
lands in a ref User FK, and (b) ensuring the domain row exists — never by changing what the
session stores. This is the invariant the whole ADR protects.
D2 — The bridge is a declared, validated binding on the User entity, not a silent default.¶
The User entity declares that it is the domain projection of the auth principal, with the link
field (provisional surface; exact keyword finalised at implementation):
entity User "User":
id: uuid pk
email: str required
username: str(40) required
display_name: str(80) required
# Declares: this entity IS the authenticated principal's domain row,
# joined to the auth identity by `email`. Field maps/defaults let the
# registration mirror satisfy NOT-NULL columns the auth flow can't supply.
auth_identity:
link_via: email
map: { username: email_localpart, display_name: email_localpart }
The declaration is the contract. It is FK-graph/link validated at dazzle validate time
(link_via must be a column; mapped/defaulted columns must exist), the same posture ADR-0036/0037
take for security-relevant edges: assert the bridge, don't infer it.
D3 — When declared, the framework does two things, both auth-safe:¶
- (a) Provision (mirror) on real registration. On
/auth/register(and any production auth-user creation), upsert a domainUserrow using the declaredlink_via+map/defaults — the same schema-derived logic as the #1398 test-route mirror, promoted out oftest_routes.pyinto a shared helper both paths call. Idempotent (ON CONFLICT … DO UPDATE). - (b) Inject the domain id, resolved by
link_via. Stop special-casingUserin the create handler; resolve the domainUserrow by the link (email) at request time and inject its id intoref UserFKs — exactly the cycle-249 path used forTester/Agent. Removes thedomain User.id == auth idassumption entirely.
With (a) keeping the row present and (b) removing the id-equality assumption, ref User creates
resolve for any principal, through any auth path, with zero changes to session/membership/RLS/audit.
D4 — Mirror at the shared helper, called from both auth paths (no duplication).¶
_mirror_auth_user_to_domain is generalised into a path-neutral
mirror_auth_user_to_domain(deps, identity, binding) and called from both /__test__/authenticate
(unchanged behaviour) and the production registration path. The test-route mirror stops being a
divergent copy of the production rule (which is how #1398 and #778 drifted apart).
D5 — Undeclared User behaves exactly as today (backward compatible).¶
No auth_identity: declaration ⇒ no mirror, and ref User injection keeps using current_user
(the auth id) as it does now. Apps that own their User table / signup flow, or that don't define a
User entity, are untouched. This answers "apps that don't want auto-mirrored domain rows": opt
in by declaring; opt out by omitting.
D6 — (A1, ratified) Hard-schema & tenancy: fail at validate, never swallow at runtime.¶
When the registration mirror can't safely complete a required column — an enum/ref/uuid or any
required-no-default scalar with no map/default, or a tenant_id on an RLS-fenced User (the
principal registers before having an org/membership) — the framework does not best-effort and
swallow (the #1398/#778 failure mode). Instead:
- The
auth_identity:declaration MUST resolve every required-no-default column onUser(viamapordefault), checked atdazzle validateagainst the entity schema. An unresolvable required column is a validate-time error, not a runtime exception. (This converts the silent runtime FK/insert failure into a static, traceable error — satisfies the model-driven failure-mode posture in the Framing section.) - For an RLS-tenanted
User, the declaration MUST state tenant placement — i.e.Useris a global/unfenced principal table (the common case: the principal exists above any one tenant), or the binding is rejected at validate with a clear message directing the author to makeUserunfenced or own provisioning themselves. v1 does not create a fencedUserrow at registration-before-membership (no tenant to place it in); that path is a validate error, not a silent half-write. (Deferred alternative — provision at first org-membership — recorded in Out of scope; not v1.)
D7 — (A2, ratified) The bridge is opt-in (declared), never always-on.¶
Only an auth_identity: declaration enables provisioning + link_via injection (D2/D3). A User
entity with no declaration behaves exactly as today (D5). No always-on "mirror whenever a User
entity exists" — declared contracts over inferred behaviour, per ADR-0027/0036/0037, and the
declaration is also where D6's required-column maps live.
Rejected alternatives¶
- Repoint
session.user_idto the domainUserid (session-time resolution). The auth-model breaker:session.user_idis consumed as the auth id across sessions/membership/RLS/audit; repointing it makes those lookups miss (the domain id isn't inusers) and would need a second id field plus an audit of every consumer. Violates D1. Rejected. - Always inject the auth id and require id-equality (status quo + a global registration mirror,
no declaration). Keeps the brittle
domain User.id == auth idassumption and the swallow-on-failure mirror; surprises apps that ownUser. Inferred, not asserted. Rejected in favour of the declared binding (D2) + link_via injection (D3b). - A new bespoke
principal:/identity_user:construct unrelated tobacked_by. Duplicates the cycle-249backed_by/link_viamachinery that already does exactly this for other entities. Reuse the proven path; don't fork it. Rejected. - Leave it test-only + document the contract as "app owns the upsert." This is the current
downstream workaround (
_seed_domain_user). Pushes a framework-shaped concern (the principal's domain projection) into every consumer's side code — the failure mode this ADR exists to remove. Rejected as the only answer; the declared bridge subsumes it.
Framing — model-driven failure-modes check (per CLAUDE.md)¶
- Which failure mode does this risk increasing? Hidden side-channel semantics — an auth↔domain coupling that lives in runtime code rather than the AppSpec. We reduce it: the coupling becomes a declared, validated binding instead of two implicit conventions (id-equality + email-attr-merge).
- Which detector catches it if we're wrong?
dazzle validate(link/required-column validation of the binding) + the FK graph; at runtime, the existing FK constraint still fails closed. - Live or merely documented? Live — validation runs every
validate/CI; the A1 disposition makes the previously-swallowed runtime failure a static error. - Can an engineer trace runtime behaviour back to the AppSpec? Yes — the
auth_identity:block is the single declared source of "this entity is the principal; mirror + inject via email." - Does it preserve auth/Postgres semantics, or push them into side code? Preserves: D1 keeps the
auth-store identity untouched; the bridge is a declared projection. It removes side code (the
downstream
_seed_domain_userworkaround).
Consequences¶
- New IR: an
AuthIdentitySpec(link_via + field map/defaults) onEntitySpec; acore/validation/rule (binding on at most one entity;link_viais a column; every required-no-default column is resolved per A1). - Runtime:
_mirror_auth_user_to_domaingeneralised to a shared helper (D4) invoked from the production registration path and the test route; the create handler'sref Userspecial-case (route_generator.py ~L566) replaced by thelink_viaresolution already used for backed entities (D3b). No change toSessionRecord,create_session, membership, RLS, or audit (D1). - Closes the #1398/#1413/#778 cluster's parent: the real-auth-flow mirror gap is resolved by a
declared binding, and downstream
_seed_domain_userworkarounds are deletable. - Greenfield-friendly, consistent with the RLS-tenancy / membership posture (ADR-0036/0037).
Out of scope / deferred¶
- Multi-
User-shaped principals (more than one domain entity backing one auth identity). v1 binds exactly one entity (theUser), mirroring ADR-0037's "principal is always the frameworkUserin v1". - Email change / re-link.
link_via: emailassumes a stable link; an email change updating both stores is a follow-up (the idempotent upsert handles re-provision, not key migration). - Non-email link fields beyond the v1
emailjoin — allowed bylink_viabut onlyemailis validated/tested in v1. - Provision a fenced
Userat first org-membership (the A1/a2 alternative). v1 rejects a fenced-Userbinding at validate (D6) rather than create a tenant-fenced principal row at registration-before-membership. Deferring the mirror to membership-activation is a coherent future extension if a tenanted principal table is ever required; out of scope for v1.