Files
apf_portal/docs/decisions/0027-portal-side-organisational-hierarchy.md
T
julien 30cefc4488
CI / check (push) Successful in 2m53s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m42s
CI / a11y (push) Successful in 1m51s
CI / perf (push) Successful in 3m22s
Docs site / build (push) Successful in 2m31s
docs(adr): split ADR-0026 + propose ADR-0027 (Structure hierarchy) (#217)
## Summary

Splits [ADR-0026](docs/decisions/0026-person-user-portal-data-model.md) into two sibling ADRs after a cascade / acteurs_plus source-of-truth audit caught a design break: the first draft pinned `Etablissement.finess` as primary key, but ≥ 30 % of APF's real structure inventory has no FINESS (antennes, dispositifs, entreprises adaptées, mouvement, administratif, siège).

| ADR | Status | Scope |
| --- | --- | --- |
| [ADR-0026](docs/decisions/0026-person-user-portal-data-model.md) — narrowed | `proposed` | `Person` + `User` + `UserScope` only (identity model) |
| [ADR-0027](docs/decisions/0027-portal-side-organisational-hierarchy.md) — new | `proposed` | `Region` + `Delegation` + `Structure` (cascade-aligned: `kind` discriminator + nullable FINESS / SIRET / `codePaie`) |
| `ADR-0028` (future) | — | Pléiades + Acteurs+ + cascade syncs + facet schemas (renumbered from the old `ADR-0027` placeholder) |

No code changes — all three artefacts moving in this PR are markdown.

## What lands

| File | Change |
| --- | --- |
| `docs/decisions/0026-person-user-portal-data-model.md` | Title trimmed (drop `+ organisational hierarchy`); `Region` / `Delegation` / `Etablissement` schema removed; `Person.email` unique constraint dropped (two distinct humans can share an email — see Lifecycle); Lifecycle rewritten without email-based dedup; `UserScope.value` documented as opaque string referencing ADR-0027 codes; Confirmation drops `Etablissement.kind` bullet; ADR-0027 sync references renumbered to ADR-0028; "What ADR-0026 ships vs adjacent ADRs" rewritten to three columns. |
| `docs/decisions/0027-portal-side-organisational-hierarchy.md` | **New.** Decision = Option B (`Structure` with `kind` discriminator + nullable FINESS / SIRET / `codePaie`, internal `code` PK that doubles as FINESS for medico-social structures). Considered options A (FINESS-only — original ADR-0026 draft), B (chosen), C (full cascade replication), D (remote read against cascade). Inline-migration seed for the test tenant (Region 75 + Delegation 33 + a handful of medico-social structures + `siege`); full inventory deferred to ADR-0028's cascade sync. `Structure.kind` enum-as-string drift-gated. |
| `docs/decisions/README.md` | ADR-0026 row: title updated, status stays `proposed`. New ADR-0027 row: `proposed`, tags `data, backend`. |
| `CLAUDE.md` | Roll-up clarifies: `0001 → 0025 accepted`; `ADR-0026 + ADR-0027 proposed`. `@RequireScope` Prisma-resolver roadmap entry references both ADRs + the ADR-0028 follow-up. No new Architecture bullet (entries land when their ADRs ship). |

## Why split

The cascade audit was the trigger. Cascade — APF's medico-social structure registry + Pléiades/Talentia HR integration — models `Structure` with a **seven-value type discriminator**: `medico_social`, `antenne`, `dispositif`, `entreprise_adaptee`, `mouvement`, `administratif`, `sanitaire`. Three of those (`antenne`, `dispositif`, part of `entreprise_adaptee`) **do not have a FINESS** by construction. Cascade carries FINESS / SIRET / SIREN / Pléiades `codePaie` / Talentia `codeCompta` on **separate per-source enrichment rows** (`StructureSourceFiness`, `StructureSourceSirene`, `StructureSourcePleiades`, `StructureSourceTalentia`), nullable and many-to-one against `Structure`.

The acteurs_plus audit confirmed: acteurs_plus does not store FINESS / SIRET / SIREN on its hierarchy entities at all — it uses a portal-internal `code` (unique string) + an `externalId` pointer.

The first ADR-0026 draft's `Etablissement.finess` PK excluded all non-medico-social structures by construction. The fix is **not** to make FINESS nullable on `Etablissement` (that smuggles the discriminator into absence-of-value semantics) — it is to adopt cascade's `Structure` + `kind` discriminator directly. Doing that inside ADR-0026 would have ballooned its scope; splitting is the cleaner shape:

- **ADR-0026** keeps a tight focus on identity (`Person` + `User` + `UserScope`). The Person model is unchanged from the first draft except for the email-dedup rewrite (already discussed before the audit landed).
- **ADR-0027** owns the org hierarchy with the cascade-aligned schema, the seeding posture, and the deferred parts (`Pole`, `Service`, arbitrary nesting, per-source enrichment) called out explicitly as ADR-0028's territory.

## ADR-0027 schema highlights

```prisma
model Structure {
  // Portal-internal stable code. For medico-social structures we set
  // code = FINESS (round-trips through scope literals + URLs cleanly).
  // For non-medico-social structures: APF-internal slug ('siege',
  // 'apf-bdx-merignac', 'ea-toulouse', 'mvt-national', …).
  code            String   @id
  name            String
  // Aligned with cascade's Structure.type discriminator. Drift-gated.
  kind            String   // 'medico_social' | 'antenne' | 'dispositif'
                           // | 'entreprise_adaptee' | 'mouvement'
                           // | 'administratif' | 'siege'
  finess          String?  @unique  // 9 digits, NULL for non-medico-social
  siret           String?  @unique  // 14 chars, NULL when not SIRENE-registered
  codePaie        String?  @unique  // Pléiades 6-char, NULL in v1
  delegationCode  String?           // NULL for siège, mouvement national
  delegation      Delegation? @relation(fields: [delegationCode], references: [code])
  @@index([kind])
  @@index([delegationCode])
}
```

The vocabulary mismatch — ADR-0025's scope kind name is `etablissement` but the value is now a `Structure.code` of any kind — is documented as a known wart, with a possible ADR-0025 amendment as the rename path if a maintainer trips over it.

## Notes for the reviewer

- **`Person.email` unique constraint dropped.** Two distinct humans genuinely can share an email (shared family alias, generic `info@` mailbox, error in an upstream feed). The first draft had `email String? @unique` carried over from a "let's use it as a v1 dedup key" line of thinking that the audit reshaped. The lifecycle now treats `entraOid` as the only natural key the v1 provisioner trusts; email is an attribute, indexed for operator-driven lookup (admin UI search, ADR-0028 reconciliation flow), not a constraint.
- **`UserScope.value` has no FK to ADR-0027 tables.** Deliberate: a scope can outlive its target (a structure decommissioned mid-quarter still has historical UserScope rows pointing at its code, which the audit log needs to read). Admin UI write path validates; runtime guard tolerates stale codes (they fail the resource match, not the sign-in).
- **The old open PR `docs/adr-0026-accept-and-tighten-lifecycle` is superseded by this one.** That branch promoted ADR-0026 (full first draft) to `accepted`. The Q1 / Q2 resolutions from that PR are preserved here — Q1 (no email-dedup) is the new ADR-0026 Lifecycle section; Q2 (inline-migration seed) moves to ADR-0027's "Seeding posture" section since it is org-hierarchy-specific. The old PR can be closed without merging.
- **No code changes**, so no `pnpm ci:check` impact. `pnpm exec prettier --check` clean on the four touched files.

## Test plan

- [x] `pnpm exec prettier --check` — clean on the four touched files.
- [x] ADR cross-references resolve (every `ADR-NNNN` link in the two ADRs round-trips; the new ADR-0028 reference is a known dangling marker for the future sync ADR).
- [ ] **Review focus** — cascade / acteurs_plus audit findings as cited in ADR-0027 §"Context"; the schema choices in ADR-0027 (kind enum, nullable FINESS/SIRET, internal `code` PK); the `Person.email` non-unique change in ADR-0026; the scope-kind vocabulary mismatch documented in ADR-0027.

## What's next (post-merge)

Per ADR-0026 §"Phasing" and ADR-0027 §"Phasing" — the two ADR PRs ship in parallel once accepted:

1. **ADR-0027 Implementation PR 1** — `Region` / `Delegation` / `Structure` Prisma schema + inline reference-data migration + `Structure.kind` catalogue + drift-gate extension.
2. **ADR-0026 Implementation PR 1** — `Person` / `User` / `UserScope` Prisma schema + `PersonAndUserProvisioner` called from `SessionEstablisher` + `Person.source` catalogue + drift-gate extension + updated `PrincipalBuilder`. Independent of (1) at the schema level — can ship in parallel.
3. **ADR-0026 Implementation PR 2** — `PrismaScopeResolver` replacing `StubScopeResolver` + `/admin/users/:id/scopes` admin screen + `prisma/seed.ts` populating the 19 test personas' `user_scopes` per `notes/test-tenant-role-assignments.md`. **Depends on both (1) and (2)** — the seed references `Structure.code` values from (1) and writes `UserScope` rows from (2).
4. **ADR-0028 (proposed)** — Pléiades + Acteurs+ + cascade syncs + facet schemas (Salarié / Élu / Adhérent / Bénévole / Bénéficiaire / PartenaireExterne) + operator-confirmed Person-reconciliation flow + schema extensions (`Pole`, `Service`, per-source enrichment) the sync needs.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #217
2026-05-24 06:55:43 +02:00

20 KiB

status, date, decision-makers, tags
status date decision-makers tags
proposed 2026-05-24 R&D Lead
data
backend

Portal-side organisational hierarchy — Structure with kind discriminator and nullable FINESS / SIRET

Context and Problem Statement

ADR-0025'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'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

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 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") 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) 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 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 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.