docs(adr-0026): promote to accepted + resolve open questions
CI / commits (pull_request) Successful in 2m49s
CI / check (pull_request) Successful in 3m25s
CI / scan (pull_request) Successful in 3m37s
CI / a11y (pull_request) Successful in 3m25s
Docs site / build (pull_request) Successful in 3m32s
CI / perf (pull_request) Successful in 5m33s

ADR-0026 was reviewed; status flips proposed -> accepted with the
two open questions closed:

1. Person dedup at first sign-in. v1 looks up by entraOid only;
   no email-based merging. Two distinct people genuinely can
   share an email, and silent auto-merge would corrupt the
   golden record. ADR-0027 introduces operator-confirmed
   reconciliation; v1 takes the safe-by-default posture.

2. Org-hierarchy seed placement. Region / Delegation /
   Etablissement codes ship inline in the Prisma migration as
   reference data — slow-moving, environment-agnostic, single
   atomic deploy. ADR-0027's Pléiades sync supersedes the
   inline seed when it ships. user_scopes for the 19 test
   personas stay in prisma/seed.ts (implementation PR 2) since
   those are test-tenant-specific data.

Consequences section reworked to reflect the new dedup choice:
the email-fragility Bad-because is removed; a new Bad-because
documents the v1 cost (a self-signed-in Person + a later
Pléiades-imported Person look like duplicates until ADR-0027's
operator merge flow resolves them — observable, not silent).

CLAUDE.md updated:
- Roll-up bumped 0001 -> 0025 -> 0001 -> 0026.
- New Architecture bullet for the portal-side data model.
- Roadmap entry on @RequireScope Prisma resolver updated to
  reflect ADR-0026 (accepted) and the two-PR implementation
  phasing.

Index row flipped to accepted.
This commit is contained in:
Julien Gautier
2026-05-24 05:46:48 +02:00
parent abd1f91809
commit 07cb466d51
3 changed files with 33 additions and 16 deletions
@@ -1,5 +1,5 @@
---
status: proposed
status: accepted
date: 2026-05-24
decision-makers: R&D Lead
tags: [data, backend, security]
@@ -188,24 +188,27 @@ model Etablissement {
│ 2. PersonAndUserProvisioner.ensureUser({ oid: X, … }) │
│ a. Look up User by entraOid │
│ → if found: update lastSignInAt, return │
│ → if not found: continue
b. Look up Person by email (case-insensitive)
→ if found: link User to existing Person
→ if not found: create Person with
source='self-signin', then link User
│ → if not found: create a fresh Person + linked
User in one transaction.
Person.source = 'self-signin'
Person.firstName / lastName = split(Entra
displayName) — best effort
│ Person.email = Entra preferred_username │
│ 3. PrincipalBuilder.build() now sees a real Person │
│ and a real User; Principal.user.personId carries │
│ Person.id rather than the entraOid placeholder │
└──────────────────────────────────────────────────────────┘
```
**ADR-0027 (later):** Pléiades / Acteurs+ syncs create Person rows ahead of any sign-in. The lookup path becomes:
**Why no email-based merging in v1.** Two distinct people genuinely can share an email (shared family alias, generic `info@` mailbox at a small partner organisation, error in an upstream feed). Auto-merging a fresh sign-in into an existing Person by email match would silently corrupt the golden record without surfacing a conflict. v1 takes the safe-by-default posture: `entraOid` is the only natural key the provisioner trusts; every other dedup happens on the explicit reconciliation flow that ships with ADR-0027.
1. by `Person.externalId` if the sign-in carries one (rare — only when the federated identity exposes the upstream id),
2. by `Person.email` (the existing v1 lookup),
3. fallback to creating a `source='self-signin'` Person.
**ADR-0027 (later).** Pléiades / Acteurs+ syncs create Person rows ahead of any sign-in. The provisioner's lookup path extends, in order:
The schema does not change; only the provisioner's lookup order extends.
1. by `Person.externalId` when the sign-in carries one (only when the federated identity exposes the upstream id, or when an admin has linked the two via the admin UI),
2. by an explicit operator-driven match flow on `Person.email` — surfacing the candidate match for confirmation rather than auto-merging,
3. fallback to creating a `source='self-signin'` Person — same as v1.
The schema does not change between the two regimes; only the provisioner's logic and the conflict-resolution UI extend.
### Scope resolution — `PrismaScopeResolver`
@@ -244,6 +247,19 @@ The two placeholders called out at the top of this ADR resolve:
| `user.tenantId` | Entra `tid` | unchanged |
| `user.displayName` | Entra `displayName` | unchanged (cached at sign-in; admins can override later) |
### Seeding the org hierarchy
Two viable shapes for landing `Region` / `Delegation` / `Etablissement` data:
- **Reference data inline in the Prisma migration.** The first migration carries the schema + an INSERT block populating the codes APF actually operates in. Every environment that runs the migration gets the same starting set; nothing to forget at deploy time.
- **Separate `prisma/seed.ts`.** Schema migration ships empty tables; a seed script populates them. The seed is rerun on demand (`prisma db seed`) and skipped when the tables are non-empty.
**Chosen: reference data inline in the migration.** The geographic / organisational hierarchy is _reference data_ — slow-moving, every environment needs it, none of the rows are environment-specific. Inline-in-migration is the conservative posture: one commit ships the schema and the rows that make the schema usable, and `prisma migrate deploy` in a fresh environment produces a working portal in a single step.
The v1 seed is intentionally small — only the codes the test tenant uses (a handful of établissements + a Délégation + Région Nouvelle-Aquitaine + `siege`). ADR-0027's Pléiades sync becomes the authoritative source once it ships and supersedes the v1 inline seed.
User-scope rows for the 19 test personas are NOT in this seed — they are test-tenant-specific data and ride in `prisma/seed.ts` (implementation PR 2), which is `prisma db seed`'d separately on environments where the test personas should exist.
### What ADR-0026 ships vs what stays for ADR-0027
| Concern | ADR-0026 (this) | ADR-0027 (next) |
@@ -260,10 +276,10 @@ The facet split was the main "tempting to ship now" item; deferring it keeps the
### Consequences
- Good, because `Principal.user.personId` is finally a real, stable identifier — `@RequireScope({ kind: 'self' })` can compare against `Dossier.personId` end-to-end without "the entraOid happens to be the personId" gymnastics.
- Good, because Pléiades and Acteurs+ syncs (ADR-0027) can drop into a known shape; their lookup logic (email-based dedup, externalId carryover) is already part of v1.
- Good, because Pléiades and Acteurs+ syncs (ADR-0027) drop into a known shape; their reconciliation logic (`externalId` precedence, operator-surfaced email-based candidates) extends the v1 provisioner rather than rewriting it.
- Good, because the admin UI scope-seeding flow (v1 manual entry per ADR-0025 §"Sources of truth — apf_portal-side `user_scopes` table") now has a target row to write against (`UserScope`).
- Bad, because `Person.email` as the v1 dedup key is fragile — two Pléiades records with the same email crash the unique constraint. Mitigation: ADR-0027 adds `externalId` precedence and surfaces conflicts to the operator.
- Bad, because lazy-create-at-sign-in produces a Person row with sparse fields (`firstName` / `lastName` from the Entra `displayName` split, no postal address, no phone). Acceptable for v1 since dossiers / RH consumers do not exist yet; reconciliation with Pléiades data at sync time fills the gaps.
- Bad, because a user who signs in once with an Entra account and later gets imported from Pléiades produces two Person rows (one `self-signin`, one `pleiades`) that look like the same human. ADR-0027 handles the merge as an operator-confirmed flow; v1 simply accepts the duplicate as the cost of safe-by-default dedup. The duplicate is observable (same email) and easy to surface, not hidden.
- Neutral, because `Region.code` / `Delegation.code` / `Etablissement.finess` as primary keys preclude renames. APF's INSEE codes are stable; if a délégation merges, the row is deleted + a new one is inserted with the new code. The cost is well-bounded.
### Confirmation