docs(adr): split ADR-0026 + propose ADR-0027 (Structure hierarchy) #217

Merged
julien merged 1 commits from docs/adr-0026-narrow-adr-0027-structure into main 2026-05-24 06:55:45 +02:00
Owner

Summary

Splits ADR-0026 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 — narrowed proposed Person + User + UserScope only (identity model)
ADR-0027 — 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

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

  • pnpm exec prettier --check — clean on the four touched files.
  • 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 1Region / Delegation / Structure Prisma schema + inline reference-data migration + Structure.kind catalogue + drift-gate extension.
  2. ADR-0026 Implementation PR 1Person / 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 2PrismaScopeResolver 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.
## 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.
julien added 1 commit 2026-05-24 06:54:13 +02:00
docs(adr): split ADR-0026 + propose ADR-0027 (Structure hierarchy)
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
204b0e35fa
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.
julien merged commit 30cefc4488 into main 2026-05-24 06:55:45 +02:00
julien deleted branch docs/adr-0026-narrow-adr-0027-structure 2026-05-24 06:55:46 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: julien/apf_portal#217