Files
apf_portal/docs/decisions
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
..

Architectural Decision Records

This project records architecturally-significant decisions as ADRs in the MADR 4.0.0 format. References: adr.github.io.

Why ADRs

ADRs capture the why behind a decision — context, drivers, options considered, trade-offs accepted — at the moment the decision is made. They make architecture reviewable, onboarding faster, and prevent the same debate from being re-litigated later.

Conventions

  • Format: MADR 4.0.0. Start from template.md.
  • Filename: NNNN-kebab-case-title.md, e.g. 0007-adopt-tailwind-for-design-tokens.md.
  • Numbering: globally sequential 4-digit prefix. Numbers never reset, never get reused — even when an ADR is superseded or deprecated.
  • Layout: flat folder. ADRs are not nested into category subfolders; topical organization happens via tags.
  • Tags: every ADR carries a tags: array in the MADR frontmatter, drawn from the tag vocabulary below. An ADR may carry several tags. Propose new tags (or renames) in the same PR that needs them; never invent ad-hoc tags inline.
  • Status lifecycle: proposedaccepted → optionally deprecated or superseded by [ADR-NNNN](NNNN-other.md). Update the YAML frontmatter; never delete an ADR.
  • Index maintenance: every ADR addition or status change must update the Index below in the same commit.

When to write an ADR

Write one whenever a development decision is non-trivial: tool or library choice, framework pattern, security control, perf budget, a11y target, naming convention, deprecation, breaking change, or any choice that future contributors would benefit from understanding the why of.

Tag vocabulary

The vocabulary below is the source of truth. It is intentionally coarse — propose extensions only when an existing tag genuinely doesn't fit, and avoid overly narrow tags.

Tag Scope
frontend UI, Angular, components, design system, client-side state
backend API, BFF, server-side services
security AuthN, AuthZ, sessions, CSP, dependency scanning, secret management
performance Perf budgets, caching, bundle size, Lighthouse
accessibility WCAG, a11y testing, keyboard, ARIA, contrast
infrastructure CI/CD, hosting, deployment, runtime
observability Logs, metrics, traces, correlation IDs, monitoring
data Persistence, schemas, migrations, data flow
process Team conventions, workflows, repo policy

Status: starter vocabulary, to be refined as ADRs accumulate. Update this table whenever a tag is added, renamed, or retired.

Index

ADRs are listed in numerical order. To slice by topic, filter on the Tags column.

# Title Status Tags Date
0001 Use ADRs to record architectural decisions accepted process 2026-04-29
0002 Adopt Nx monorepo with the apps preset accepted infrastructure, frontend, backend 2026-04-29
0003 Workspace and app naming convention accepted process 2026-04-29
0004 Frontend stack — Angular (latest LTS), standalone, zoneless, Signals, CSR-only, Vitest accepted frontend 2026-04-29
0005 Backend stack — NestJS over Express, Fastify, Hono accepted backend 2026-04-29
0006 Persistence — PostgreSQL with Prisma accepted data, backend 2026-04-29
0007 Pre-commit hooks and Conventional Commits accepted process 2026-04-29
0008 Identity model — multi-tenant Entra ID for workforce, dual-audience design for future External ID accepted security, data 2026-04-29
0009 Authentication flow — OIDC Authorization Code + PKCE via MSAL Node, BFF session pattern accepted security, backend 2026-04-29
0010 Session management — opaque session IDs in cookies, payload in self-hosted Redis with AES-GCM at rest accepted security, backend, infrastructure 2026-04-29
0011 MFA enforcement — Entra ID Conditional Access baseline, BFF claim sanity-check, step-up hooks designed-in accepted security 2026-04-29
0012 Observability — Pino structured logs + OpenTelemetry tracing, W3C Trace Context propagation, stdout + collector accepted observability, backend, frontend 2026-04-29
0013 Audit trail — separated append-only Postgres schema, decoupled from app logs accepted security, observability, data 2026-04-29
0014 Downstream API access — On-Behalf-Of pattern, unified DownstreamApiClient, audience-aware authorization accepted security, backend 2026-04-29
0015 CI/CD pipeline — Gitea Actions, trunk-based + squash-merge, thin YAML over portable scripts accepted infrastructure, process 2026-04-30
0016 Accessibility baseline — WCAG 2.2 AA + targeted AAA, Angular CDK + spartan-ng + Tailwind, APF panel testing accepted accessibility, frontend, process 2026-04-30
0017 Performance budgets — Core Web Vitals + Lighthouse CI gates, bundle budgets, BFF p95/p99 SLOs accepted performance, frontend, backend, process 2026-04-30
0018 Environment configuration — Angular environment.ts, BFF env vars, audit pool split accepted frontend, backend, infrastructure, process 2026-05-10
0019 Internationalisation — @angular/localize, build-time per-locale bundles, /fr + /en path-based routing accepted frontend, accessibility, performance, process 2026-05-11
0020 portal-admin — dedicated SPA for portal administration, sharing the existing BFF accepted frontend, backend, security, infrastructure, process 2026-05-11
0021 Phase-2 security baseline — helmet, CORS allowlist, double-submit CSRF, rate limiting, structured error envelope accepted security, backend 2026-05-13
0022 Documentation site — VitePress + Mermaid plugin, separate static deployment accepted process, infrastructure 2026-05-15
0023 Charts + dashboards — D3 + Observable Plot wrapped in libs/shared/charts accepted frontend, accessibility, performance 2026-05-16
0024 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 Authorization model — three orthogonal axes (privileges × functional roles × scopes), Entra-backed accepted security, backend, data 2026-05-20
0026 Person golden record + User portal-account — portal-side identity model proposed data, backend, security 2026-05-24
0027 Portal-side organisational hierarchy — Structure with kind discriminator and nullable FINESS / SIRET proposed data, backend 2026-05-24