docs(adr): split ADR-0026 + propose ADR-0027 (Structure hierarchy) #217
@@ -60,7 +60,7 @@ The structural, security, observability, and quality choices are recorded as ADR
|
||||
|
||||
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 → 0025 are accepted and cover the structural, security, observability, quality, i18n, admin-app, docs-site, charts, AI-relay, and authorization choices. ADR-0026 (portal-side identity model — `Person` + `User` + `UserScope`) and ADR-0027 (portal-side organisational hierarchy — `Structure` with kind discriminator + nullable FINESS / SIRET, cascade-aligned) are proposed; together they unlock the ADR-0025 stubs (`Principal.user.{id, personId}` placeholders, `StubScopeResolver`'s `unrestricted` blanket return). **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 +74,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. Real implementation lands across [ADR-0026](docs/decisions/0026-person-user-portal-data-model.md) (proposed: `Person` + `User` + `UserScope`) and [ADR-0027](docs/decisions/0027-portal-side-organisational-hierarchy.md) (proposed: `Region` / `Delegation` / `Structure` with `kind` discriminator + nullable FINESS / SIRET). Sequencing: ADR-0026 PR 1 + ADR-0027 PR 1 ship schema in parallel; ADR-0026 PR 2 then lands the `PrismaScopeResolver` + admin scope-seeding UI + test-tenant seed (which references ADR-0027's `Structure.code` values). The follow-up [ADR-0028](#) covers Pléiades / Acteurs+ / cascade syncs + facet schemas.
|
||||
- **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.
|
||||
|
||||
@@ -5,15 +5,14 @@ decision-makers: R&D Lead
|
||||
tags: [data, backend, security]
|
||||
---
|
||||
|
||||
# `Person` golden record + `User` portal-account + organisational hierarchy — portal-side data model
|
||||
# `Person` golden record + `User` portal-account — portal-side identity model
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
[ADR-0025](0025-authorization-model-privileges-roles-scopes.md) shipped the authorization model — three orthogonal axes (privileges × functional roles × scopes) — but left several anchors as proposed-follow-up. In v1 those anchors are stubbed:
|
||||
[ADR-0025](0025-authorization-model-privileges-roles-scopes.md) shipped the authorization model — three orthogonal axes (privileges × functional roles × scopes) — but left two identity anchors as proposed-follow-up. In v1 those anchors are stubbed:
|
||||
|
||||
- `Principal.user.id` and `Principal.user.personId` are populated with the Entra `oid` as a placeholder, because no portal-side identity record exists yet (see [ADR-0025 §"Principal shape"](0025-authorization-model-privileges-roles-scopes.md) and the BFF builder at `apps/portal-bff/src/auth/principal-builder.ts`).
|
||||
- `ScopeResolver` returns `[{ kind: 'unrestricted' }]` for every signed-in user (see `StubScopeResolver` and [ADR-0025 §"Sources of truth — apf_portal-side `user_scopes` table"](0025-authorization-model-privileges-roles-scopes.md)). The intended per-persona scopes documented in `notes/test-tenant-role-assignments.md` have nowhere to live.
|
||||
- `@RequireScope`'s `ScopableResource` shape — `etablissementFiness`, `delegationCode`, `regionCode`, `isSiege` — describes a parentage chain the BFF cannot actually fetch because there is no `Etablissement` / `Delegation` / `Region` table.
|
||||
|
||||
The portal also needs a representation of **people who are not yet portal users**. APF France handicap touches three populations with very different relationships to the portal:
|
||||
|
||||
@@ -21,22 +20,22 @@ The portal also needs a representation of **people who are not yet portal users*
|
||||
2. **Governance** (élus du CA national + élus des CD départementaux) — many will be portal users; their identity, mandate type, and mandate scope come from Acteurs+ (the governance system).
|
||||
3. **Bénévoles + adhérents + bénéficiaires** — most will not be portal users; their identity lands in the portal via the membership / dossier flows.
|
||||
|
||||
A naive "User table = anyone who ever signed in" model collapses these three populations into one bucket and loses two important capabilities the portal needs from day one:
|
||||
A naive "User table = anyone who ever signed in" model collapses these three populations into one bucket and loses two capabilities the portal needs from day one:
|
||||
|
||||
- **Pré-provisioning** — an établissement director must be able to assign a scope to a salarié before the salarié first signs in. The salarié needs a portal-side identity that pre-exists their first OIDC callback.
|
||||
- **Pré-provisioning** — a structure director must be able to assign a scope to a salarié before the salarié first signs in. The salarié needs a portal-side identity that pre-exists their first OIDC callback.
|
||||
- **Person-without-User** — a dossier is held by a bénéficiaire who may never sign in. The dossier still needs to reference a stable Person identifier; later, if the bénéficiaire signs up to portal-facing services, an account is overlaid on top of the existing Person record.
|
||||
|
||||
This ADR specifies the portal-side data model that resolves both the ADR-0025 stubs and these business shapes. It deliberately does **not** specify the upstream sync (Pléiades, Acteurs+, membership / dossier feeds) — that is [ADR-0027](#)'s territory. Likewise, the **facet shapes** (Salarié, Élu, Adhérent, Bénéficiaire) attach to a Person but their fields ride alongside their producing sync, so they land with ADR-0027.
|
||||
This ADR specifies the portal-side **identity** model. The organisational hierarchy that `@RequireScope` dereferences (`Region`, `Delegation`, `Structure`) is the sibling concern of [ADR-0027](#), kept on a separate timeline because the source-of-truth audit (cascade + acteurs_plus) reshaped that model after ADR-0026 was first drafted. The upstream **sync** of Person rows from Pléiades / Acteurs+ — together with the **facet shapes** (Salarié, Élu, Adhérent, Bénéficiaire) that attach to a Person — is [ADR-0028](#)'s territory.
|
||||
|
||||
ADR-0026's scope: the three core tables (`Person`, `User`, `UserScope`) plus the geographic / organisational hierarchy (`Region`, `Delegation`, `Etablissement`) that scope checks dereference today.
|
||||
ADR-0026's scope: the three core tables `Person`, `User`, `UserScope`.
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
- **`Principal.user.personId` must point at a real, stable identifier** — guards consuming `self`-scoped resources compare against it, and the AI service uses it (hashed) for audit-log joining per [ADR-0024](0024-ai-service-relay-grpc-sse-bridge.md).
|
||||
- **Person-without-User** must be a valid state — Pléiades pre-provisioning, dossier beneficiaries, alumni.
|
||||
- **`Etablissement.finess`, `.delegationCode`, `.regionCode`** must resolve from a real row, not a hardcoded map — the [ADR-0016](0016-accessibility-baseline-wcag-aa-targeted-aaa.md) panel-tested admin UI will list établissements; the BFF guards traverse the chain on scope checks.
|
||||
- **No premature normalisation of facets.** Salarié / Élu / Adhérent / Bénéficiaire schemas track their upstream system; specifying them before the sync ADR ([ADR-0027](#)) lands invites churn. Mark them as deferred and keep ADR-0026 narrow.
|
||||
- **Lazy User creation on first sign-in** — the OIDC callback creates the `User` row the first time it sees a new Entra `oid`. This avoids a Pléiades-side dependency before any sign-in works; Pléiades-driven pre-provisioning lands with ADR-0027 and updates the lookup path, not the schema.
|
||||
- **No premature normalisation of facets.** Salarié / Élu / Adhérent / Bénéficiaire schemas track their upstream system; specifying them before the sync ADR ([ADR-0028](#)) lands invites churn. Mark them as deferred and keep ADR-0026 narrow.
|
||||
- **Lazy User creation on first sign-in** — the OIDC callback creates the `User` row the first time it sees a new Entra `oid`. This avoids a Pléiades-side dependency before any sign-in works; Pléiades-driven pre-provisioning lands with ADR-0028 and updates the lookup path, not the schema.
|
||||
- **Safe-by-default dedup.** `entraOid` is the only natural key the v1 provisioner trusts. Email is an attribute, not an identifier — see "Lifecycle" below for why.
|
||||
- **No PII outside the boundary it belongs in.** `Person.firstName` / `lastName` are PII — they need the same redaction / hashing posture as the audit-log salt ([ADR-0013](0013-audit-trail-separated-postgres-append-only.md)).
|
||||
|
||||
## Considered Options
|
||||
@@ -44,7 +43,7 @@ ADR-0026's scope: the three core tables (`Person`, `User`, `UserScope`) plus the
|
||||
- **Option A — `User`-only, no `Person`.** A single table for "anyone who ever signed in". Dossiers reference `userId` directly. Bénéficiaires who never sign in have no row at all.
|
||||
- **Option B — `Person` golden record + `User` portal-account overlay (chosen).** Two tables, one-to-zero-or-one relationship. `Person` is the stable identity; `User` is the portal-access overlay that exists when (and only when) someone has portal access.
|
||||
- **Option C — `Person` lives in Entra (no portal-side row).** Use Entra's user object as the golden record; portal stores only deltas (scopes, etc.).
|
||||
- **Option D — `Person` + per-relationship junction (Salarié-of-établissement-X, Élu-of-CD-Y, …) embedded directly in `Person`.** Single table with discriminator columns for every facet.
|
||||
- **Option D — `Person` + per-relationship junction (Salarié-of-structure-X, Élu-of-CD-Y, …) embedded directly in `Person`.** Single table with discriminator columns for every facet.
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
@@ -54,7 +53,7 @@ Chosen option: **B — `Person` golden record + `User` portal-account overlay**,
|
||||
2. lets a User row carry portal-only state (`lastSignInAt`, future audit-flagging, future user-preferences in [ADR-0016](0016-accessibility-baseline-wcag-aa-targeted-aaa.md)) without polluting the Person record that Pléiades / Acteurs+ owns;
|
||||
3. matches the existing intuition in ADR-0025's `Principal.user.{id, personId}` shape: the two ids exist because the two concerns are distinct.
|
||||
|
||||
### Schema — core tables
|
||||
### Schema
|
||||
|
||||
```prisma
|
||||
model Person {
|
||||
@@ -62,18 +61,19 @@ model Person {
|
||||
// Identity. firstName + lastName are PII; treat per ADR-0013's redact rules.
|
||||
firstName String
|
||||
lastName String
|
||||
// Primary contact email. Unique because it serves as the fallback
|
||||
// dedup key for Pléiades / Acteurs+ syncs (ADR-0027) — a future
|
||||
// sync receiving a Pléiades row with email X looks up Person by
|
||||
// email before deciding insert vs update.
|
||||
email String? @unique
|
||||
// Primary contact email. Indexed for operator-driven lookup (admin UI
|
||||
// search, ADR-0028 reconciliation flow), NOT unique — two distinct
|
||||
// humans genuinely can share an email (shared family alias, generic
|
||||
// info@ mailbox at a small partner organisation, error in an upstream
|
||||
// feed). A unique constraint would crash the first Pléiades import
|
||||
// that surfaces such a pair.
|
||||
email String?
|
||||
// Provenance: which upstream owns the row. v1: 'self-signin'
|
||||
// (lazy-created at OIDC callback), 'admin-ui' (manually entered
|
||||
// by an admin before first sign-in), 'seed' (test-tenant
|
||||
// provisioning).
|
||||
// ADR-0027 will add 'pleiades' and 'acteurs-plus'. The field is
|
||||
// a free-form string in v1; promoting to an enum is an ADR-0027
|
||||
// decision once the sync sources are concrete.
|
||||
// provisioning). ADR-0028 will add 'pleiades' and 'acteurs-plus'.
|
||||
// The field is a free-form string in v1; promoting to an enum is
|
||||
// an ADR-0028 decision once the sync sources are concrete.
|
||||
source String
|
||||
// Upstream-system identifier (Pléiades matricule, Acteurs+ id).
|
||||
// Nullable because v1 sources do not provide one.
|
||||
@@ -85,6 +85,7 @@ model Person {
|
||||
|
||||
@@index([source])
|
||||
@@index([externalId])
|
||||
@@index([email])
|
||||
}
|
||||
|
||||
model User {
|
||||
@@ -114,15 +115,15 @@ model UserScope {
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
// Discriminator — one of the six ADR-0025 scope kinds.
|
||||
kind String
|
||||
// Per-kind payload. FINESS for etablissement, dept code for
|
||||
// delegation, INSEE region code for region, empty string for
|
||||
// self / siege / unrestricted (the unique constraint below
|
||||
// Per-kind payload — semantics owned by ADR-0027. For 'etablissement'
|
||||
// the value is a Structure.code (string); for 'delegation' it is a
|
||||
// Delegation.code; for 'region' it is a Region.code. Empty string for
|
||||
// 'self' / 'siege' / 'unrestricted' (the unique constraint below
|
||||
// tolerates empty strings).
|
||||
value String @default("")
|
||||
// Provenance same posture as Person.source. v1: 'admin-ui' or
|
||||
// 'seed'. ADR-0027 adds 'pleiades' / 'acteurs-plus' and the
|
||||
// reconciliation rules between admin overrides and upstream
|
||||
// data.
|
||||
// Provenance, same posture as Person.source. v1: 'admin-ui' or
|
||||
// 'seed'. ADR-0028 adds 'pleiades' / 'acteurs-plus' and the
|
||||
// reconciliation rules between admin overrides and upstream data.
|
||||
source String
|
||||
createdAt DateTime @default(now())
|
||||
// When non-null and past, the row is ignored at sign-in.
|
||||
@@ -135,46 +136,7 @@ model UserScope {
|
||||
}
|
||||
```
|
||||
|
||||
### Schema — organisational hierarchy
|
||||
|
||||
```prisma
|
||||
model Region {
|
||||
// INSEE region code (2 digits — '11' for Île-de-France, '75' for
|
||||
// Nouvelle-Aquitaine). Stable across reorgs; doubles as the
|
||||
// primary key because the codes are short and stable.
|
||||
code String @id
|
||||
name String
|
||||
delegations Delegation[]
|
||||
}
|
||||
|
||||
model Delegation {
|
||||
// French department code (2-3 chars — '33', '2A', '971'). Same
|
||||
// rationale as Region.code.
|
||||
code String @id
|
||||
name String
|
||||
regionCode String
|
||||
region Region @relation(fields: [regionCode], references: [code])
|
||||
etablissements Etablissement[]
|
||||
}
|
||||
|
||||
model Etablissement {
|
||||
// FINESS code (9 digits — '0330800013'). The legal identifier
|
||||
// for medico-social establishments in France; stable across
|
||||
// reorgs.
|
||||
finess String @id
|
||||
name String
|
||||
// What this row is: a real établissement vs the siège vs a
|
||||
// future placeholder. Drives scope matching: the matcher
|
||||
// resolves `principal.scopes` against this column.
|
||||
kind String // 'etablissement' | 'siege'
|
||||
delegationCode String
|
||||
delegation Delegation @relation(fields: [delegationCode], references: [code])
|
||||
|
||||
@@index([delegationCode])
|
||||
}
|
||||
```
|
||||
|
||||
`Region.code` / `Delegation.code` / `Etablissement.finess` doubling as primary keys is a deliberate choice: they are externally-meaningful identifiers that already exist in every upstream system, they appear in URLs (`/api/etablissements/0330800013`), and they round-trip cleanly through scope literals (`etablissement:0330800013`). Adding a separate UUID would force every consumer to indirect through it for no gain.
|
||||
`UserScope.value` references the codes defined in [ADR-0027](#) (`Structure.code`, `Delegation.code`, `Region.code`) but stores them as opaque strings — no foreign-key from `UserScope` to those tables. Rationale: a scope can outlive its target (a structure decommissioned mid-quarter still has historical UserScope rows referencing its code, useful for the audit log). The admin UI surface that creates UserScope rows validates the code against ADR-0027's tables at write time; the runtime guard does the same dereference at sign-in.
|
||||
|
||||
### Lifecycle — Person + User creation
|
||||
|
||||
@@ -188,24 +150,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-0028.
|
||||
|
||||
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-0028 (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`
|
||||
|
||||
@@ -232,6 +197,8 @@ export class PrismaScopeResolver extends ScopeResolver {
|
||||
|
||||
The signature `resolve({ userId })` differs from the current `resolve({ entraOid })` — the User UUID is the natural key once the schema exists. The PrincipalBuilder evolves to pass `userId` instead, which is fine because the provisioner above has just created / fetched it.
|
||||
|
||||
The full guard-side dereference path (`principalCoversResource` walking `Structure.delegationCode` → `Delegation.regionCode` → `Region`) requires ADR-0027's hierarchy to be live. PR sequencing reflects this — see "More Information" below.
|
||||
|
||||
### `Principal.user` post-implementation
|
||||
|
||||
The two placeholders called out at the top of this ADR resolve:
|
||||
@@ -244,34 +211,33 @@ 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) |
|
||||
|
||||
### What ADR-0026 ships vs what stays for ADR-0027
|
||||
### What ADR-0026 ships vs adjacent ADRs
|
||||
|
||||
| Concern | ADR-0026 (this) | ADR-0027 (next) |
|
||||
| --------------------------------------------------------------------------- | ---------------------------- | ------------------------ |
|
||||
| `Person`, `User`, `UserScope` schema | ✅ | — |
|
||||
| `Region`, `Delegation`, `Etablissement` schema | ✅ (manually-seeded in v1) | Pléiades-sync population |
|
||||
| `Person.source = 'self-signin'` lazy creation | ✅ | — |
|
||||
| `Person.source = 'pleiades'` / `'acteurs-plus'` | as a documented future value | Sync implementation |
|
||||
| Salarié / Élu / Adhérent / Bénéficiaire facets | deferred | ✅ |
|
||||
| Reconciliation between admin-UI overrides and Pléiades-driven `user_scopes` | deferred | ✅ |
|
||||
| Concern | ADR-0026 (this) | ADR-0027 (org hierarchy) | ADR-0028 (sync + facets) |
|
||||
| ------------------------------------------------------------------------- | ---------------------------- | ------------------------ | ------------------------ |
|
||||
| `Person`, `User`, `UserScope` schema | ✅ | — | — |
|
||||
| `Region`, `Delegation`, `Structure` schema | — | ✅ | — |
|
||||
| `Person.source = 'self-signin'` lazy creation | ✅ | — | — |
|
||||
| `Person.source = 'pleiades'` / `'acteurs-plus'` | as a documented future value | — | Sync implementation |
|
||||
| Salarié / Élu / Adhérent / Bénéficiaire facets | deferred | — | ✅ |
|
||||
| Reconciliation between admin-UI overrides and upstream-driven user_scopes | deferred | — | ✅ |
|
||||
|
||||
The facet split was the main "tempting to ship now" item; deferring it keeps the schema migration small enough to land + roll back cleanly if the lazy-provisioner exposes a corner case.
|
||||
|
||||
### 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-0028) 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.
|
||||
- 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.
|
||||
- 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-0028 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 `UserScope.value` is an opaque string with no FK to ADR-0027's hierarchy tables. The write path validates against those tables; runtime read tolerates stale codes. Stale entries do not crash sign-in — they fail the `principalCoversResource` check on the resource that no longer exists, which is the correct behaviour.
|
||||
|
||||
### Confirmation
|
||||
|
||||
- **Migration tests.** Prisma migration emits a SQL file; the `infra/local` dev stack runs it on fresh boot. Manual + a small `e2e` spec exercising the lazy-create path with a stubbed Entra response.
|
||||
- **PrismaScopeResolver integration test.** Two personas (one with `etablissement:0330800013`, one with `unrestricted`) signed in, scopes round-trip from DB → Principal → `principalCoversResource`.
|
||||
- **PrismaScopeResolver integration test.** Two personas signed in (one with a `kind='etablissement'` scope, one with `kind='unrestricted'`), scopes round-trip from DB → Principal → `principalCoversResource`. Requires ADR-0027's seeded hierarchy to give the etablissement-scoped persona a real `Structure.code` to point at.
|
||||
- **`Person.source` enum-as-string is single-sourced.** A small constant array in `apps/portal-bff/src/users/person-source.ts` lists the legal values; the catalogue-drift gate ([ADR-0025 §"Confirmation"](0025-authorization-model-privileges-roles-scopes.md)) extends to assert every string written to `Person.source` is in the catalogue. Same posture as the `Privilege` / `FunctionalRole` catalogues.
|
||||
- **`Etablissement.kind` enum-as-string.** Same posture as `Person.source`. Legal values: `'etablissement'`, `'siege'`. Extending to `'preprod-placeholder'` etc. is an ADR amendment.
|
||||
|
||||
## Pros and Cons of the Options
|
||||
|
||||
@@ -281,7 +247,7 @@ The facet split was the main "tempting to ship now" item; deferring it keeps the
|
||||
- Good, because portal-only fields (`lastSignInAt`, future a11y preferences, future audit flags) ride on `User`, not on the shared Person record that Pléiades may overwrite.
|
||||
- Good, because the ADR-0025 stubs resolve naturally — `personId` ≠ `userId` reflects the two-concern split that was already designed in.
|
||||
- Bad, because two tables means two queries on the OIDC callback hot path. Mitigation: a single Prisma `include` keeps it to one round trip.
|
||||
- Neutral, because reconciling lazy-created Person rows with later Pléiades data needs a documented merge policy — that policy is ADR-0027's territory and is easier to design with this shape than with Option A.
|
||||
- Neutral, because reconciling lazy-created Person rows with later Pléiades data needs a documented merge policy — that policy is ADR-0028's territory and is easier to design with this shape than with Option A.
|
||||
|
||||
### Option A — `User`-only
|
||||
|
||||
@@ -300,17 +266,18 @@ The facet split was the main "tempting to ship now" item; deferring it keeps the
|
||||
### Option D — `Person` + embedded facet columns
|
||||
|
||||
- Good, because one less join when reading "the salarié record for Person X".
|
||||
- Bad, because `Person.salarie_etablissement_id`, `Person.elu_mandat_type`, `Person.adherent_cotisation_status`, … each introduce a NULL column that 95% of rows leave unused — schema bloat without normalization gains.
|
||||
- Bad, because `Person.salarie_structure_id`, `Person.elu_mandat_type`, `Person.adherent_cotisation_status`, … each introduce a NULL column that 95% of rows leave unused — schema bloat without normalization gains.
|
||||
- Bad, because Pléiades data shape evolves independently from Acteurs+ data shape; embedding both in `Person` means every Pléiades schema change touches the table Acteurs+ rows also live in. Separate facet tables ride their own sync's lifecycle.
|
||||
|
||||
## More Information
|
||||
|
||||
**Phasing.** This ADR is decision-only. Implementation lands in two PRs once the ADR is accepted:
|
||||
**Phasing.** This ADR is decision-only. Implementation is sequenced against [ADR-0027](#) because the PrismaScopeResolver test matrix needs real `Structure.code` / `Delegation.code` / `Region.code` rows:
|
||||
|
||||
1. **PR — Prisma schema + lazy provisioner.** `Person`, `User`, `Region`, `Delegation`, `Etablissement`, `UserScope` models; `PersonAndUserProvisioner` called from `SessionEstablisher`; `Person.source` + `Etablissement.kind` constants + drift-gate extension; updated `PrincipalBuilder` to populate `Principal.user.{id, personId}` from the real rows.
|
||||
2. **PR — `PrismaScopeResolver` + admin UI scope-seeding screen + test-tenant seed.** Replaces `StubScopeResolver` in the AuthModule. Seeds the 19 test personas' `user_scopes` rows per `notes/test-tenant-role-assignments.md`. Adds an `/admin/users/:id/scopes` admin-app screen for manual scope management.
|
||||
1. **PR — Prisma schema + lazy provisioner.** `Person`, `User`, `UserScope` models; `PersonAndUserProvisioner` called from `SessionEstablisher`; `Person.source` constants + drift-gate extension; updated `PrincipalBuilder` to populate `Principal.user.{id, personId}` from the real rows. Independent of ADR-0027 — can ship in parallel.
|
||||
2. **PR — `PrismaScopeResolver` + admin UI scope-seeding screen + test-tenant seed.** Replaces `StubScopeResolver` in the AuthModule. Seeds the 19 test personas' `user_scopes` rows per `notes/test-tenant-role-assignments.md`. Adds an `/admin/users/:id/scopes` admin-app screen for manual scope management. **Depends on ADR-0027 PR 1** — the seed references `Structure.code` values that must already exist in the database.
|
||||
|
||||
**Follow-up ADRs.**
|
||||
|
||||
- **[ADR-0027](#) — Pléiades + Acteurs+ syncs + facet schemas.** Specifies how `Person` rows are populated from upstream, how facets attach (`Salarie`, `Adherent`, `Benevole`, `Elu`, `Beneficiaire`, `PartenaireExterne`), the reconciliation policy between sync-owned and admin-UI-owned fields, and how `user_scopes` is computed from facet relationships.
|
||||
- **[ADR-0027](#) — Portal-side organisational hierarchy.** `Region` / `Delegation` / `Structure` schema (Structure with kind discriminator + nullable FINESS / SIRET keys, cascade-aligned). Sibling to ADR-0026, shipped at the same cadence.
|
||||
- **[ADR-0028](#) — Pléiades + Acteurs+ syncs + facet schemas.** Specifies how `Person` rows are populated from upstream, how facets attach (`Salarie`, `Adherent`, `Benevole`, `Elu`, `Beneficiaire`, `PartenaireExterne`), the reconciliation policy between sync-owned and admin-UI-owned fields, and how `user_scopes` is computed from facet relationships.
|
||||
- **A future ADR — time-bound roles.** `UserScope.expiresAt` already exists; the corresponding facet-level mechanism (interim director for two months, treasurer mandate from 2026 to 2029) lands when the use case is concrete.
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
---
|
||||
status: proposed
|
||||
date: 2026-05-24
|
||||
decision-makers: R&D Lead
|
||||
tags: [data, backend]
|
||||
---
|
||||
|
||||
# Portal-side organisational hierarchy — `Structure` with kind discriminator and nullable FINESS / SIRET
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
[ADR-0025](0025-authorization-model-privileges-roles-scopes.md)'s scope axis dereferences three real-world hierarchy levels: `etablissement:<code>`, `delegation:<dept>`, `region:<insee>`. The BFF guard `principalCoversResource` walks `etablissement → delegation → region` to decide whether a scope covers a resource — that walk needs persisted rows.
|
||||
|
||||
[ADR-0026](0026-person-user-portal-data-model.md)'s first draft included those three tables (`Region`, `Delegation`, `Etablissement`) with `Etablissement.finess` as the primary key, on the assumption that every APF structure has a FINESS code and the FINESS doubles as the natural identifier on the wire (URL paths, scope literals).
|
||||
|
||||
That assumption is wrong, and was caught before merge. The two APF systems that already model the organisational hierarchy at scale — **`cascade`** (medico-social structure registry + Pléiades/Talentia HR integration) and **`acteurs_plus`** (member + governance registry) — were audited for their source-of-truth shapes. The findings:
|
||||
|
||||
- **Cascade explicitly carries seven types of `Structure`** via a discriminator column: `medico_social`, `antenne`, `dispositif`, `entreprise_adaptee`, `mouvement`, `administratif`, `sanitaire`. Three of those (`antenne`, `dispositif`, parts of `entreprise_adaptee`) **do not have a FINESS** by construction — they are branches, programmatic services, or businesses without medico-social registration. Cascade's `Structure` PK is an internal auto-increment; FINESS / SIRET / SIREN / Pléiades `codePaie` / Talentia `codeCompta` are all carried on **separate per-source enrichment rows** (`StructureSourceFiness`, `StructureSourceSirene`, `StructureSourcePleiades`, `StructureSourceTalentia`), nullable and many-to-one against `Structure`.
|
||||
- **Acteurs_plus does not store FINESS / SIREN / SIRET at all** on its org-hierarchy entities. It uses a portal-internal `code` (unique string) plus an `externalId` integer pointing at upstream registries.
|
||||
- **Neither system uses INSEE region codes as primary keys** — both have auto-PK rows with `code` carried as a unique attribute. (The portal's choice to make INSEE / dept / FINESS doubly-meaningful as PKs is defensible — only one Region table exists, externally-keyed is cleaner here — but it must not assume the same identifier strategy works for every structure.)
|
||||
|
||||
A portal model that pins `Etablissement.finess` as PK therefore **excludes ≥ 30 % of APF's real structure inventory** by construction. The fix is not "make FINESS nullable on Etablissement" — that just smuggles the discriminator into the absence-of-value semantics. The fix is to adopt cascade's discriminator-based model directly.
|
||||
|
||||
This ADR specifies the `Region` / `Delegation` / `Structure` schema that the portal persists, and the seeding posture for v1. The portal-side **sync** of these rows from cascade / Pléiades / Acteurs+ is [ADR-0028](#)'s territory.
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
- **All APF structures must be representable**, not just the medico-social ones. Antennes, dispositifs, entreprises adaptées, mouvement, administratif, siège — every category needs a row.
|
||||
- **Externally-meaningful identifiers must round-trip** through URLs (`/api/structures/<code>`) and scope literals (`etablissement:<code>`) without server-side translation. For medico-social structures the FINESS already plays this role; for the rest we adopt the cascade/acteurs_plus posture of a portal-internal `code` string.
|
||||
- **No FK from `UserScope.value` to `Structure.code`.** ADR-0026 already commits to this — the value is an opaque string at the type level so historical scope rows survive structure decommissioning. Validation happens at the admin-UI write path, not at the database level.
|
||||
- **Cascade is the long-term source of truth**, not the portal. v1 ships a small inline seed (the structures the test tenant exercises) precisely because ADR-0028's cascade sync will overwrite it.
|
||||
- **Do not replicate cascade's full model in v1.** Cascade carries `Pole` (lateral org grouping), `Service` (sub-units inside a structure), arbitrary self-referencing `structureParent`, and four per-source enrichment tables. None of those are required by the v1 scope axis. Defer to ADR-0028 (which will need them for the sync) or beyond, and accept that the portal's v1 schema is a subset.
|
||||
|
||||
## Considered Options
|
||||
|
||||
- **Option A — `Etablissement` keyed on FINESS only** (the original ADR-0026 first draft). Discarded — see Context above.
|
||||
- **Option B — `Structure` with internal `code` PK + `kind` discriminator + nullable FINESS/SIRET (chosen).** Cascade-aligned but simplified: no Pole, no Service, no self-reference, no per-source enrichment tables. One row per structure, with the upstream identifiers as nullable attributes carrying unique indexes when present.
|
||||
- **Option C — Full cascade replication.** Structure + Pole + Service + arbitrary parent chain + four per-source enrichment tables. Faithful to cascade but the join cost on every scope check is non-zero, and v1 has no consumer for Pole / Service / arbitrary nesting.
|
||||
- **Option D — No portal-side Structure table; resolve scope literals via a remote call into cascade.** Defers all schema work but adds a remote call to every guarded request — latency, cascade-availability coupling, and audit-log enrichment all degrade.
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
Chosen option: **B — `Structure` with internal `code` PK + `kind` discriminator + nullable FINESS / SIRET**, because:
|
||||
|
||||
1. it covers 100 % of APF's structure categories without smuggling a discriminator into nullable FINESS;
|
||||
2. it keeps the scope-check hot path local (no remote call to cascade);
|
||||
3. it leaves a clean extension surface for ADR-0028 — adding `Pole`, `Service`, or per-source enrichment tables is additive, not destructive;
|
||||
4. it preserves the externally-meaningful round-trip property: for medico-social structures we set `Structure.code = FINESS` at seed time, so `etablissement:0330800013` stays a readable scope literal where it can.
|
||||
|
||||
### Schema
|
||||
|
||||
```prisma
|
||||
model Region {
|
||||
// INSEE region code (2 digits — '11' for Île-de-France,
|
||||
// '75' for Nouvelle-Aquitaine). Doubles as primary key:
|
||||
// INSEE codes are short, stable, and externally meaningful.
|
||||
code String @id
|
||||
name String
|
||||
delegations Delegation[]
|
||||
}
|
||||
|
||||
model Delegation {
|
||||
// French department code (2-3 chars — '33', '2A', '971').
|
||||
// Same rationale as Region.code.
|
||||
code String @id
|
||||
name String
|
||||
regionCode String
|
||||
region Region @relation(fields: [regionCode], references: [code])
|
||||
structures Structure[]
|
||||
}
|
||||
|
||||
model Structure {
|
||||
// Portal-internal stable code, externally meaningful.
|
||||
// - For medico-social structures: code = FINESS (9 digits).
|
||||
// The round-trip from URL (/api/structures/0330800013) and
|
||||
// scope literal (etablissement:0330800013) stays readable.
|
||||
// - For non-medico-social structures: code is an APF-internal
|
||||
// string assigned at seed / admin-UI time. Examples:
|
||||
// 'siege', 'apf-bdx-merignac', 'ea-toulouse', 'mvt-national'.
|
||||
// - The code is opaque at the type level; matching against it
|
||||
// is string equality, not parsing.
|
||||
code String @id
|
||||
name String
|
||||
// Discriminator aligned with cascade's Structure.type. Closed
|
||||
// set, drift-gated alongside the ADR-0025 catalogues. Legal
|
||||
// values:
|
||||
// 'medico_social' — has FINESS, governed by the FINESS registry
|
||||
// 'antenne' — APF branch office without FINESS
|
||||
// 'dispositif' — programmatic service without establishment status
|
||||
// 'entreprise_adaptee' — EA — usually has SIREN/SIRET, no FINESS
|
||||
// 'mouvement' — APF mouvement (national-level org)
|
||||
// 'administratif' — administrative entity (e.g. siège regional)
|
||||
// 'siege' — APF national headquarters
|
||||
// Extensions ('sanitaire', 'autre', …) are an ADR amendment.
|
||||
kind String
|
||||
// FINESS code (9 digits). NULL for non-medico-social structures.
|
||||
// Unique when present. Cascade's StructureSourceFiness is the
|
||||
// long-term authoritative carrier; the portal keeps it inline
|
||||
// on Structure as a denormalised attribute for the v1 scope-axis
|
||||
// checks. ADR-0028's sync owns the write path.
|
||||
finess String? @unique
|
||||
// SIRET (14 chars: 9 SIREN + 5 NIC). NULL when the structure is
|
||||
// not registered with SIRENE (most antennes, dispositifs).
|
||||
// Unique when present.
|
||||
siret String? @unique
|
||||
// Pléiades payroll code (6 chars). NULL in v1 — populated by
|
||||
// ADR-0028's Pléiades sync once it ships.
|
||||
codePaie String? @unique
|
||||
// Parent delegation. NULL for structures not attached to a
|
||||
// delegation (siège, mouvement national, …).
|
||||
delegationCode String?
|
||||
delegation Delegation? @relation(fields: [delegationCode], references: [code])
|
||||
|
||||
@@index([kind])
|
||||
@@index([delegationCode])
|
||||
}
|
||||
```
|
||||
|
||||
### Scope-literal interpretation
|
||||
|
||||
The ADR-0025 catalogue defines the scope kind name `etablissement`. With ADR-0027's schema, `etablissement:<value>` carries a **`Structure.code`** as `<value>` — not a FINESS specifically. For medico-social structures the two are the same digit-string; for non-FINESS structures the value is the portal-internal code.
|
||||
|
||||
The vocabulary mismatch ("scope name `etablissement` referencing any kind of `Structure`") is a known wart inherited from ADR-0025's catalogue, which shipped before the cascade audit reshaped the model. Renaming the scope kind to `structure` is plausible but invasive (catalogue, drift gate, decorator names, all references). The cost is not paid today; the rename is an ADR-0025 amendment if and when a maintainer trips over it. Until then, the matcher contract is documented in code (`apps/portal-bff/src/auth/scope-matcher.ts`).
|
||||
|
||||
The `principalCoversResource` matcher receives a `ScopableResource` that has been pre-resolved against the Structure / Delegation / Region tables — it does not consult the database itself. Resolution happens once at request-handler entry: the handler reads `Structure` by code, joins `Delegation` and `Region`, and passes the populated `ScopableResource` to the guard.
|
||||
|
||||
### Seeding posture
|
||||
|
||||
`Region`, `Delegation`, `Structure` are **reference data**: slow-moving, every environment needs them, none of the rows are environment-specific (until the test-tenant scope-seeding step in ADR-0026 PR 2, which seeds `UserScope` rows pointing at these structures).
|
||||
|
||||
**Chosen: inline in the Prisma migration.** The first migration carries the schema + an INSERT block populating the codes APF actually operates on. Every environment that runs `prisma migrate deploy` gets the same starting set; nothing to forget at deploy time; the schema and the rows that make the schema usable land in one atomic step.
|
||||
|
||||
The v1 seed is intentionally small — only what the ADR-0026 test-tenant flow needs:
|
||||
|
||||
- **Region** Nouvelle-Aquitaine (INSEE `75`) — sole region for v1.
|
||||
- **Delegation** Gironde (`33`) — sole delegation for v1.
|
||||
- **Structure** rows: a handful of medico-social établissements (Bordeaux + complexe-merignac with `kind='medico_social'`, FINESS = code), one `siege` (no delegation, no FINESS), one `entreprise_adaptee` placeholder if the test matrix needs it.
|
||||
|
||||
The full APF inventory ships once ADR-0028's cascade sync is live. The v1 inline seed is **superseded**, not extended, by the sync — the migration that adds the sync is also responsible for the cleanup truncation if needed.
|
||||
|
||||
### What ADR-0027 ships vs adjacent ADRs
|
||||
|
||||
| Concern | ADR-0026 (identity) | ADR-0027 (this) | ADR-0028 (sync + facets) |
|
||||
| ----------------------------------------------------------- | ------------------- | ---------------------- | ------------------------ |
|
||||
| `Region`, `Delegation`, `Structure` schema | — | ✅ | — |
|
||||
| Inline reference-data seed for the test tenant | — | ✅ | — |
|
||||
| Full APF inventory (~ ten thousand structures) | — | — | Cascade-sync population |
|
||||
| `Pole`, `Service`, arbitrary nesting, per-source enrichment | — | deferred | as ADR-0028's sync needs |
|
||||
| `Structure.codePaie` populated | — | nullable (column only) | ✅ |
|
||||
|
||||
### Consequences
|
||||
|
||||
- Good, because every APF structure category fits the schema — antennes and dispositifs are first-class, not absence-of-FINESS edge cases.
|
||||
- Good, because the scope-axis check stays local (no remote call to cascade on the guard hot path).
|
||||
- Good, because cascade's eventual sync (ADR-0028) writes into the existing column set — no schema churn at sync time, just a write strategy.
|
||||
- Good, because the medico-social FINESS round-trip (`/api/structures/0330800013`) and scope-literal readability (`etablissement:0330800013`) are preserved for the structures that have a FINESS.
|
||||
- Bad, because `Structure.code` semantics differ across kinds — sometimes it's a FINESS, sometimes a portal-internal slug. Operators reading the table need to consult `kind` to interpret `code`. Mitigation: the admin-UI scope-seeding screen ([ADR-0026](0026-person-user-portal-data-model.md) PR 2) shows `(code, name, kind)` together, not just the code.
|
||||
- Bad, because the schema is a strict subset of cascade's — `Pole` (lateral org grouping) and `Service` (sub-units) are missing. If a v1 consumer asks "show me everyone in the Pôle Santé" the portal cannot answer. Mitigation: documented as deferred; ADR-0028 picks it up alongside the sync.
|
||||
- Bad, because the scope kind name `etablissement` no longer matches its value semantics (a `Structure.code` of any kind, not just `medico_social`). The matcher contract is documented; rename deferred to a possible ADR-0025 amendment.
|
||||
- Neutral, because `UserScope.value` has no FK to `Structure.code`. ADR-0026 already commits to this; stale codes are tolerated at runtime and surface as a failed resource match, not a crash.
|
||||
|
||||
### Confirmation
|
||||
|
||||
- **Migration tests.** Prisma migration emits a SQL file; the `infra/local` dev stack runs it on fresh boot. Verified by seeing the seed rows under `prisma studio` after `prisma migrate dev`.
|
||||
- **`Structure.kind` enum-as-string is single-sourced.** A constant array in `apps/portal-bff/src/structures/structure-kind.ts` lists the legal values; the catalogue-drift gate ([ADR-0025 §"Confirmation"](0025-authorization-model-privileges-roles-scopes.md)) extends to assert every string written to `Structure.kind` is in the catalogue. Same posture as the `Privilege` / `FunctionalRole` / `Person.source` catalogues.
|
||||
- **PrismaScopeResolver integration test (jointly with ADR-0026 PR 2).** A persona with `kind='etablissement', value='0330800013'` (a medico-social Structure) and a persona with `value='apf-bdx-merignac'` (a non-FINESS Structure) both successfully cover their respective `ScopableResource` after a single Structure read + Delegation/Region join. Same matcher, both `code` values.
|
||||
|
||||
## Pros and Cons of the Options
|
||||
|
||||
### Option B — `Structure` with kind discriminator + nullable FINESS / SIRET (chosen)
|
||||
|
||||
- Good, because every APF structure category is representable.
|
||||
- Good, because the round-trip property is preserved for medico-social structures.
|
||||
- Good, because cascade's eventual sync writes additively, not destructively.
|
||||
- Bad, because `code` semantics depend on `kind` — operators need to read both.
|
||||
- Neutral, because the schema is a subset of cascade's. Acceptable for v1; revisit when a consumer needs Pole / Service.
|
||||
|
||||
### Option A — `Etablissement` keyed on FINESS only
|
||||
|
||||
- Good, because the schema is the smallest possible thing.
|
||||
- Bad, because it excludes ≥ 30 % of APF's structure inventory (antennes, dispositifs, entreprises adaptées, …) by construction.
|
||||
- Bad, because making FINESS nullable to fix this smuggles the discriminator into absence-of-value semantics, which is harder to reason about than an explicit `kind` column.
|
||||
- Bad, because cascade's seven-type discriminator model demonstrably exists upstream — the portal not reflecting it just means the sync (ADR-0028) has to compress / discard information.
|
||||
|
||||
### Option C — Full cascade replication
|
||||
|
||||
- Good, because the schema is a perfect mirror — no information is lost at sync time, no consumer is ever blocked by an absent table.
|
||||
- Bad, because the v1 scope-axis check does not need Pole, Service, or self-referencing nesting — the join cost is paid every request for unused capability.
|
||||
- Bad, because the four per-source enrichment tables (`StructureSourceFiness`, `StructureSourceSirene`, …) reflect cascade's role as the multi-source aggregator; the portal is downstream of cascade, not parallel to it. Owning four sync feeds the portal does not consume is misallocation.
|
||||
- Neutral, because additive extension from Option B's schema toward Option C's is straightforward — ADR-0028 picks up what its consumers need.
|
||||
|
||||
### Option D — Remote read against cascade
|
||||
|
||||
- Good, because the portal owns zero structure schema — single source of truth, no drift.
|
||||
- Bad, because every guarded request becomes a remote call. Latency adds at the 95p; cascade availability is now in the portal's hot path.
|
||||
- Bad, because audit-log enrichment (which structure was the resource scoped to?) requires the remote call to succeed, on the audit-write hot path that ADR-0013 keeps blocking.
|
||||
- Bad, because the AI-relay's `PrincipalProjector` ([ADR-0024](0024-ai-service-relay-grpc-sse-bridge.md)) needs to emit structure codes in the AI-side `roles[]` payload — a remote call there means cascade is on the AI-chat hot path too.
|
||||
|
||||
## More Information
|
||||
|
||||
**Phasing.** This ADR is decision-only. Implementation is sibling to [ADR-0026](0026-person-user-portal-data-model.md) PR 1 — both schema migrations can ship in one PR or split, but the test-tenant scope-seed (ADR-0026 PR 2) needs both to be live.
|
||||
|
||||
1. **PR — Prisma schema + inline reference-data seed.** `Region` / `Delegation` / `Structure` models + the v1 inline migration carrying the codes APF's test tenant operates on + `Structure.kind` constants + drift-gate extension for `Structure.kind`. Can ship alongside or after [ADR-0026](0026-person-user-portal-data-model.md) PR 1; the two PRs do not share schema, so order is flexible.
|
||||
2. ADR-0026 PR 2 then lands on top — the `PrismaScopeResolver` + admin UI scope-seeding screen seeds `UserScope` rows pointing at this ADR's `Structure.code` values.
|
||||
|
||||
**Follow-up ADRs.**
|
||||
|
||||
- **[ADR-0028](#) — Pléiades + Acteurs+ + cascade syncs + facet schemas.** Specifies the structure-catalogue sync (cascade → portal), the person-catalogue sync (Pléiades + Acteurs+ → portal), the facet shapes (Salarié, Élu, Adhérent, Bénévole, Bénéficiaire, PartenaireExterne), the reconciliation policy between sync-owned and admin-UI-owned fields, and the schema extensions (Pole, Service, per-source enrichment) the sync may need.
|
||||
- **Possible future ADR-0025 amendment — rename scope kind `etablissement` to `structure`.** Triggered only if the vocabulary mismatch causes confusion in code review or operator-facing UIs; carries a closed-set catalogue rename + drift-gate update + all decorator literals.
|
||||
|
||||
**Source-of-truth investigations.** The cascade and acteurs_plus audits that drove this ADR live alongside the project lead's working notes (gitignored). The audit findings summarised in §"Context" are the durable artefacts — the raw notes are not part of the repo.
|
||||
@@ -69,4 +69,5 @@ 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 — portal-side identity model | proposed | `data`, `backend`, `security` | 2026-05-24 |
|
||||
| [0027](0027-portal-side-organisational-hierarchy.md) | Portal-side organisational hierarchy — `Structure` with kind discriminator and nullable FINESS / SIRET | proposed | `data`, `backend` | 2026-05-24 |
|
||||
|
||||
Reference in New Issue
Block a user