diff --git a/CLAUDE.md b/CLAUDE.md
index f9ead47..8140bb8 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -54,13 +54,14 @@ The structural, security, observability, and quality choices are recorded as ADR
- **Charts + dashboards:** `D3 + Observable Plot` wrapped in `libs/shared/charts/`, one Angular component per chart type (bar, donut, line, stacked-bar, …). A11y baked in by the lib (SVG `
`/``, `` tabular fallback, colour-blind-safe palettes, AA-contrast text, `prefers-reduced-motion` gate). Bundle stays under [ADR-0017](docs/decisions/0017-performance-budgets-lighthouse-ci.md)'s lazy-chunk cap via per-`d3-*` module tree-shaking. Future bespoke visualisations land in raw D3 inside the same lib — see [ADR-0023](docs/decisions/0023-charts-d3-observable-plot.md).
- **AI service relay:** dedicated `apf-ai-service` repo (ASP.NET Core, Microsoft Agent Framework) consumed via native gRPC HTTP/2 only — proto contract vendored under `apps/portal-bff/src/grpc/proto/apf-ai/` with `ts-proto` codegen committed alongside. BFF dials with `@grpc/grpc-js` (h2c in dev, h2 + TLS in prod), bridges `ChatService.Chat` to `text/event-stream` for the SPA, exposes `RagService.Search` and `ModelsService.ListModels` as plain JSON endpoints. Identity travels as an unsigned `Principal` (subject, roles, attributes) in the proto body for the POC, hashed via the audit module's `HashUserIdService` so portal and AI service audit trails join on the same `actor_id_hash`. Production hardening (signed envelope vs mTLS) deferred — see [ADR-0024](docs/decisions/0024-ai-service-relay-grpc-sse-bridge.md).
- **Authorization model:** three orthogonal axes — **privileges** (Entra app roles, `Portal.*`), **functional roles** (Entra security groups → curated `apf-role-*` slug catalogue, 24 entries v1), **scopes** (portal-side `user_scopes` table, future Pléiades feed; kinds = `self / etablissement: / delegation: / region: / siege / unrestricted`). Composed at sign-in into a session-resident `Principal`; portal guards consume the structured shape, a deterministic `PrincipalProjector` flattens it to the AI-service `roles[]` contract. Replaces stargate's linear hierarchy. Catalogues are closed-set, drift gated by CI — see [ADR-0025](docs/decisions/0025-authorization-model-privileges-roles-scopes.md).
+- **Portal-side data model:** `Person` golden record (stable identity, can exist without a portal account — workforce pre-provisioning, dossier bénéficiaires, alumni) + `User` overlay (one-to-zero-or-one with Person, lazy-created on first OIDC callback in v1; carries portal-only state like `lastSignInAt`). `UserScope` table backs the ADR-0025 scope axis. Organisational hierarchy `Region` / `Delegation` / `Etablissement` keyed on INSEE / dept / FINESS codes (externally meaningful, matches scope literals on the wire). Facets (Salarié / Élu / Adhérent / Bénéficiaire) + Pléiades / Acteurs+ sync deferred to a follow-up ADR-0027 — see [ADR-0026](docs/decisions/0026-person-user-portal-data-model.md).
- **Runtime:** Node.js latest LTS major.
## Repository status
The Nx workspace is **scaffolded and operational**. The three apps (`portal-shell`, `portal-admin`, `portal-bff`) and the seven lib roots (`libs/feature/auth`, `libs/shared/auth`, `libs/shared/charts`, `libs/shared/state`, `libs/shared/tokens`, `libs/shared/ui`, `libs/shared/util`) are in place; CI runs `format:check / lint / test / build` plus the ADR-0025 catalogue-drift gate on every PR.
-ADRs 0001 → 0025 are accepted and cover the structural, security, observability, quality, i18n, admin-app, docs-site, charts, AI-relay, and authorization choices. **Shipped on `main`:**
+ADRs 0001 → 0026 are accepted and cover the structural, security, observability, quality, i18n, admin-app, docs-site, charts, AI-relay, authorization, and portal-side data model choices. **Shipped on `main`:**
- **Phase-1 foundation** — Nx workspace, Angular `portal-shell`, NestJS `portal-bff`, Prisma + Postgres, Pino + OpenTelemetry, Husky/lint-staged/commitlint, Gitea Actions CI.
- **Phase-2 auth + audit + security** — OIDC Auth Code + PKCE via MSAL Node, Redis sessions with AES-256-GCM at rest, idle 30 min sliding + absolute 12 h hard ceiling, RP-initiated logout, double-submit CSRF, `audit.events` append-only schema with role-based grants, helmet + env-driven CORS allowlist + rate limiting + structured error envelope (see [ADR-0021](docs/decisions/0021-phase-2-security-baseline.md)).
@@ -74,7 +75,7 @@ ADRs 0001 → 0025 are accepted and cover the structural, security, observabilit
- `DownstreamApiClient` + OBO ([ADR-0014](docs/decisions/0014-downstream-api-access-obo-pattern.md)) — module scaffolded (`obo.strategy`, `signed-assertion.strategy`, JWKS publisher, encrypted token cache); no v1 consumer yet. Wires in when the first business route needs an Entra-protected API.
- `@RequireMfa()` step-up consumer routes ([ADR-0011](docs/decisions/0011-mfa-enforcement-entra-conditional-access.md)) — guard + decorator shipped; awaiting first sensitive route that needs explicit freshness enforcement beyond the Conditional Access baseline.
-- **`@RequireScope` Prisma-backed resolver + first consumer surface** — `StubScopeResolver` returns `unrestricted` for everyone in v1 per [ADR-0025](docs/decisions/0025-authorization-model-privileges-roles-scopes.md) §331. The real implementation lands with ADR-0026 (proposed: `Person` golden record + portal-side `User` + `user_scopes` table); the first scope-aware route surface follows in the same PR family.
+- **`@RequireScope` Prisma-backed resolver + first consumer surface** — `StubScopeResolver` returns `unrestricted` for everyone in v1 per [ADR-0025](docs/decisions/0025-authorization-model-privileges-roles-scopes.md) §331. [ADR-0026](docs/decisions/0026-person-user-portal-data-model.md) (accepted) specifies the `Person` + `User` + `user_scopes` schema; implementation lands in two PRs — the Prisma migration + lazy provisioner, then `PrismaScopeResolver` + admin-UI scope-seeding screen + test-tenant seed.
- **Proto-drift CI gate for the AI relay** ([ADR-0024](docs/decisions/0024-ai-service-relay-grpc-sse-bridge.md)) — asserts the vendored `apf-ai-service` proto files stay in lockstep with the upstream contract.
- **Admin app — CMS & menu management** ([ADR-0020](docs/decisions/0020-portal-admin-app.md)) — multilingual static-page editor + navigation menu builder. The user-list + audit-log-viewer modules already exist; the CMS/menu pair is the remaining v1 module scope.
- **Strategic security baseline ADR** — separate from the implementation-level [ADR-0021](docs/decisions/0021-phase-2-security-baseline.md). Remains **paused** awaiting RSSI input on the OWASP ASVS reference level and adjacent frameworks (HDS, GDPR, possibly NIS 2). When it lands it will either confirm 0021 or supersede pieces of it.
diff --git a/docs/decisions/0026-person-user-portal-data-model.md b/docs/decisions/0026-person-user-portal-data-model.md
index 152df9f..bd6a26e 100644
--- a/docs/decisions/0026-person-user-portal-data-model.md
+++ b/docs/decisions/0026-person-user-portal-data-model.md
@@ -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
diff --git a/docs/decisions/README.md b/docs/decisions/README.md
index 1a9d99e..116fc16 100644
--- a/docs/decisions/README.md
+++ b/docs/decisions/README.md
@@ -69,4 +69,4 @@ ADRs are listed in numerical order. To slice by topic, filter on the `Tags` colu
| [0023](0023-charts-d3-observable-plot.md) | Charts + dashboards — D3 + Observable Plot wrapped in `libs/shared/charts` | accepted | `frontend`, `accessibility`, `performance` | 2026-05-16 |
| [0024](0024-ai-service-relay-grpc-sse-bridge.md) | AI service relay — vendored gRPC protos, NestJS gRPC client, SSE bridge to the SPA, POC unsigned principal | accepted | `backend`, `security`, `observability` | 2026-05-19 |
| [0025](0025-authorization-model-privileges-roles-scopes.md) | Authorization model — three orthogonal axes (privileges × functional roles × scopes), Entra-backed | accepted | `security`, `backend`, `data` | 2026-05-20 |
-| [0026](0026-person-user-portal-data-model.md) | `Person` golden record + `User` portal-account + organisational hierarchy — portal-side data model | proposed | `data`, `backend`, `security` | 2026-05-24 |
+| [0026](0026-person-user-portal-data-model.md) | `Person` golden record + `User` portal-account + organisational hierarchy — portal-side data model | accepted | `data`, `backend`, `security` | 2026-05-24 |