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
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user