204b0e35fa
CI / commits (pull_request) Successful in 3m1s
CI / check (pull_request) Successful in 3m3s
CI / scan (pull_request) Successful in 3m12s
CI / a11y (pull_request) Successful in 3m1s
Docs site / build (pull_request) Successful in 3m16s
CI / perf (pull_request) Successful in 5m59s
Cascade + acteurs_plus source-of-truth audit caught the original ADR-0026 draft pinning Etablissement.finess as PK while >=30% of APF's real structure inventory has no FINESS (antennes, dispositifs, entreprises adaptees, mouvement, administratif, siege). Narrow ADR-0026 to identity only (Person + User + UserScope). Drop Person.email unique (two distinct humans can share an email) and drop email-based dedup from the v1 provisioner lifecycle - entraOid is the only natural key trusted in v1. New ADR-0027 owns the organisational hierarchy: cascade-aligned Structure with kind discriminator + nullable FINESS / SIRET / codePaie + internal code PK that doubles as FINESS for medico-social structures. Pole / Service / arbitrary nesting / per-source enrichment deferred to ADR-0028 (sync), renumbered from the prior ADR-0027 placeholder. Both ADRs stay proposed pending review.
212 lines
20 KiB
Markdown
212 lines
20 KiB
Markdown
---
|
|
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.
|