docs(adr-0028): propose CI/CD + git hosting migration Gitea -> GitLab
CI / commits (pull_request) Successful in 3m41s
CI / scan (pull_request) Successful in 3m51s
CI / check (pull_request) Successful in 4m2s
CI / a11y (pull_request) Successful in 3m18s
Docs site / build (pull_request) Successful in 3m43s
CI / perf (pull_request) Successful in 7m28s
CI / commits (pull_request) Successful in 3m41s
CI / scan (pull_request) Successful in 3m51s
CI / check (pull_request) Successful in 4m2s
CI / a11y (pull_request) Successful in 3m18s
Docs site / build (pull_request) Successful in 3m43s
CI / perf (pull_request) Successful in 7m28s
ADR-0015 framed Gitea Actions as a level-2 implementation explicitly slated for a GitLab migration within 6-18 months. That window opens now: vm-gitlab is provisioned, the team is about to scale, and the Gitea-specific friction points documented inline in .gitea/workflows/ ci.yml (paywalled gitleaks-action, github.com-bound trivy-action, manual curl+tar install dance) are accumulating maintenance tax. ADR-0028 records the platform shift. Architectural principles from ADR-0015 (trunk-based + squash, all-gates-blocking, thin YAML over portable scripts in pnpm + Nx, on-prem runners, Conventional Commits) carry over unchanged - only host / pipeline-file grammar / runner type / scan tooling change. Decision-only; 4-phase migration (mirror, parallel pipelines, cutover, cleanup) lands in follow-up PRs. Renumbering: ADR-0028 was a placeholder reference for the future Pleiades + Acteurs+ sync ADR. That ADR shifts to 0029; the 25 link references across ADR-0026 and ADR-0027 update in lockstep. CLAUDE.md roll-up adjusted accordingly. Status: proposed. Accept will be a separate small PR per the established pattern (see #219 for ADR-0026/0027 acceptance).
This commit is contained in:
@@ -25,7 +25,7 @@ A naive "User table = anyone who ever signed in" model collapses these three pop
|
||||
- **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 **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.
|
||||
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-0029](#)'s territory.
|
||||
|
||||
ADR-0026's scope: the three core tables `Person`, `User`, `UserScope`.
|
||||
|
||||
@@ -33,8 +33,8 @@ ADR-0026's scope: the three core tables `Person`, `User`, `UserScope`.
|
||||
|
||||
- **`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.
|
||||
- **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.
|
||||
- **No premature normalisation of facets.** Salarié / Élu / Adhérent / Bénéficiaire schemas track their upstream system; specifying them before the sync ADR ([ADR-0029](#)) 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-0029 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)).
|
||||
|
||||
@@ -62,7 +62,7 @@ model Person {
|
||||
firstName String
|
||||
lastName String
|
||||
// Primary contact email. Indexed for operator-driven lookup (admin UI
|
||||
// search, ADR-0028 reconciliation flow), NOT unique — two distinct
|
||||
// search, ADR-0029 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
|
||||
@@ -71,9 +71,9 @@ model Person {
|
||||
// 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-0028 will add 'pleiades' and 'acteurs-plus'.
|
||||
// provisioning). ADR-0029 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.
|
||||
// an ADR-0029 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.
|
||||
@@ -122,7 +122,7 @@ model UserScope {
|
||||
// tolerates empty strings).
|
||||
value String @default("")
|
||||
// Provenance, same posture as Person.source. v1: 'admin-ui' or
|
||||
// 'seed'. ADR-0028 adds 'pleiades' / 'acteurs-plus' and the
|
||||
// 'seed'. ADR-0029 adds 'pleiades' / 'acteurs-plus' and the
|
||||
// reconciliation rules between admin overrides and upstream data.
|
||||
source String
|
||||
createdAt DateTime @default(now())
|
||||
@@ -162,9 +162,9 @@ model UserScope {
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**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.
|
||||
**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-0029.
|
||||
|
||||
**ADR-0028 (later).** Pléiades / Acteurs+ syncs create Person rows ahead of any sign-in. The provisioner's lookup path extends, in order:
|
||||
**ADR-0029 (later).** Pléiades / Acteurs+ syncs create Person rows ahead of any sign-in. The provisioner's lookup path extends, in order:
|
||||
|
||||
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,
|
||||
@@ -213,7 +213,7 @@ The two placeholders called out at the top of this ADR resolve:
|
||||
|
||||
### What ADR-0026 ships vs adjacent ADRs
|
||||
|
||||
| Concern | ADR-0026 (this) | ADR-0027 (org hierarchy) | ADR-0028 (sync + facets) |
|
||||
| Concern | ADR-0026 (this) | ADR-0027 (org hierarchy) | ADR-0029 (sync + facets) |
|
||||
| ------------------------------------------------------------------------- | ---------------------------- | ------------------------ | ------------------------ |
|
||||
| `Person`, `User`, `UserScope` schema | ✅ | — | — |
|
||||
| `Region`, `Delegation`, `Structure` schema | — | ✅ | — |
|
||||
@@ -227,10 +227,10 @@ The facet split was the main "tempting to ship now" item; deferring it keeps the
|
||||
### 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-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 Pléiades and Acteurs+ syncs (ADR-0029) 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 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.
|
||||
- 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.
|
||||
- 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-0029 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
|
||||
@@ -247,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-0028'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-0029's territory and is easier to design with this shape than with Option A.
|
||||
|
||||
### Option A — `User`-only
|
||||
|
||||
@@ -279,5 +279,5 @@ The facet split was the main "tempting to ship now" item; deferring it keeps the
|
||||
**Follow-up ADRs.**
|
||||
|
||||
- **[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.
|
||||
- **[ADR-0029](#) — 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.
|
||||
|
||||
Reference in New Issue
Block a user