docs(adr-0026): person + user portal-side data model (proposed)
CI / commits (pull_request) Successful in 2m36s
CI / check (pull_request) Successful in 2m46s
CI / scan (pull_request) Successful in 2m56s
CI / a11y (pull_request) Successful in 3m23s
Docs site / build (pull_request) Successful in 3m27s
CI / perf (pull_request) Successful in 6m17s
CI / commits (pull_request) Successful in 2m36s
CI / check (pull_request) Successful in 2m46s
CI / scan (pull_request) Successful in 2m56s
CI / a11y (pull_request) Successful in 3m23s
Docs site / build (pull_request) Successful in 3m27s
CI / perf (pull_request) Successful in 6m17s
ADR-0025 left several anchors as proposed-follow-up: - Principal.user.id / personId carry the Entra oid as a placeholder. - StubScopeResolver returns unrestricted for everyone. - @RequireScope's ScopableResource shape references an Etablissement / Delegation / Region chain that has no table. ADR-0026 specifies the portal-side data model that closes those stubs without dragging the upstream Pléiades / Acteurs+ syncs into scope: - Person golden record (stable identity; lazy-created on first sign-in in v1, source-stamped 'self-signin' for traceability). - User overlay (one-to-zero-or-one with Person; portal-only state like lastSignInAt rides here, not on the shared Person row). - UserScope migration confirming the ADR-0025 §"Sources of truth" shape. - Region / Delegation / Etablissement hierarchy with externally-meaningful codes (INSEE / dept / FINESS) as primary keys, matching scope literals' on-the-wire shape. Facet schemas (Salarié / Élu / Adhérent / Bénéficiaire) are deferred to ADR-0027 alongside the Pléiades and Acteurs+ sync that populates them - keeping the v1 migration small enough to land + roll back cleanly. Status: proposed. Implementation lands in two PRs once accepted: the schema + lazy provisioner, then the PrismaScopeResolver + admin-UI scope-seeding screen + test-tenant seed for the 19 personas. Index updated.
This commit is contained in:
@@ -0,0 +1,316 @@
|
||||
---
|
||||
status: proposed
|
||||
date: 2026-05-24
|
||||
decision-makers: R&D Lead
|
||||
tags: [data, backend, security]
|
||||
---
|
||||
|
||||
# `Person` golden record + `User` portal-account + organisational hierarchy — portal-side data model
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
[ADR-0025](0025-authorization-model-privileges-roles-scopes.md) shipped the authorization model — three orthogonal axes (privileges × functional roles × scopes) — but left several anchors as proposed-follow-up. In v1 those anchors are stubbed:
|
||||
|
||||
- `Principal.user.id` and `Principal.user.personId` are populated with the Entra `oid` as a placeholder, because no portal-side identity record exists yet (see [ADR-0025 §"Principal shape"](0025-authorization-model-privileges-roles-scopes.md) and the BFF builder at `apps/portal-bff/src/auth/principal-builder.ts`).
|
||||
- `ScopeResolver` returns `[{ kind: 'unrestricted' }]` for every signed-in user (see `StubScopeResolver` and [ADR-0025 §"Sources of truth — apf_portal-side `user_scopes` table"](0025-authorization-model-privileges-roles-scopes.md)). The intended per-persona scopes documented in `notes/test-tenant-role-assignments.md` have nowhere to live.
|
||||
- `@RequireScope`'s `ScopableResource` shape — `etablissementFiness`, `delegationCode`, `regionCode`, `isSiege` — describes a parentage chain the BFF cannot actually fetch because there is no `Etablissement` / `Delegation` / `Region` table.
|
||||
|
||||
The portal also needs a representation of **people who are not yet portal users**. APF France handicap touches three populations with very different relationships to the portal:
|
||||
|
||||
1. **Workforce** (salariés on payroll) — almost certainly portal users; their identity, employment status, and current assignment come from Pléiades (the HR system).
|
||||
2. **Governance** (élus du CA national + élus des CD départementaux) — many will be portal users; their identity, mandate type, and mandate scope come from Acteurs+ (the governance system).
|
||||
3. **Bénévoles + adhérents + bénéficiaires** — most will not be portal users; their identity lands in the portal via the membership / dossier flows.
|
||||
|
||||
A naive "User table = anyone who ever signed in" model collapses these three populations into one bucket and loses two important capabilities the portal needs from day one:
|
||||
|
||||
- **Pré-provisioning** — an établissement 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 data model that resolves both the ADR-0025 stubs and these business shapes. It deliberately does **not** specify the upstream sync (Pléiades, Acteurs+, membership / dossier feeds) — that is [ADR-0027](#)'s territory. Likewise, the **facet shapes** (Salarié, Élu, Adhérent, Bénéficiaire) attach to a Person but their fields ride alongside their producing sync, so they land with ADR-0027.
|
||||
|
||||
ADR-0026's scope: the three core tables (`Person`, `User`, `UserScope`) plus the geographic / organisational hierarchy (`Region`, `Delegation`, `Etablissement`) that scope checks dereference today.
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
- **`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.
|
||||
- **`Etablissement.finess`, `.delegationCode`, `.regionCode`** must resolve from a real row, not a hardcoded map — the [ADR-0016](0016-accessibility-baseline-wcag-aa-targeted-aaa.md) panel-tested admin UI will list établissements; the BFF guards traverse the chain on scope checks.
|
||||
- **No premature normalisation of facets.** Salarié / Élu / Adhérent / Bénéficiaire schemas track their upstream system; specifying them before the sync ADR ([ADR-0027](#)) 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-0027 and updates the lookup path, not the schema.
|
||||
- **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)).
|
||||
|
||||
## Considered Options
|
||||
|
||||
- **Option A — `User`-only, no `Person`.** A single table for "anyone who ever signed in". Dossiers reference `userId` directly. Bénéficiaires who never sign in have no row at all.
|
||||
- **Option B — `Person` golden record + `User` portal-account overlay (chosen).** Two tables, one-to-zero-or-one relationship. `Person` is the stable identity; `User` is the portal-access overlay that exists when (and only when) someone has portal access.
|
||||
- **Option C — `Person` lives in Entra (no portal-side row).** Use Entra's user object as the golden record; portal stores only deltas (scopes, etc.).
|
||||
- **Option D — `Person` + per-relationship junction (Salarié-of-établissement-X, Élu-of-CD-Y, …) embedded directly in `Person`.** Single table with discriminator columns for every facet.
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
Chosen option: **B — `Person` golden record + `User` portal-account overlay**, because it is the only option that:
|
||||
|
||||
1. lets a Person exist before they ever sign in (workforce pre-provisioning, dossier beneficiaries);
|
||||
2. lets a User row carry portal-only state (`lastSignInAt`, future audit-flagging, future user-preferences in [ADR-0016](0016-accessibility-baseline-wcag-aa-targeted-aaa.md)) without polluting the Person record that Pléiades / Acteurs+ owns;
|
||||
3. matches the existing intuition in ADR-0025's `Principal.user.{id, personId}` shape: the two ids exist because the two concerns are distinct.
|
||||
|
||||
### Schema — core tables
|
||||
|
||||
```prisma
|
||||
model Person {
|
||||
id String @id @default(uuid())
|
||||
// Identity. firstName + lastName are PII; treat per ADR-0013's redact rules.
|
||||
firstName String
|
||||
lastName String
|
||||
// Primary contact email. Unique because it serves as the fallback
|
||||
// dedup key for Pléiades / Acteurs+ syncs (ADR-0027) — a future
|
||||
// sync receiving a Pléiades row with email X looks up Person by
|
||||
// email before deciding insert vs update.
|
||||
email String? @unique
|
||||
// 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-0027 will add 'pleiades' and 'acteurs-plus'. The field is
|
||||
// a free-form string in v1; promoting to an enum is an ADR-0027
|
||||
// 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.
|
||||
externalId String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User?
|
||||
|
||||
@@index([source])
|
||||
@@index([externalId])
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
personId String @unique
|
||||
person Person @relation(fields: [personId], references: [id])
|
||||
// Entra object id. Stable per-tenant; unique because two
|
||||
// separate Person + User pairs cannot share an Entra identity.
|
||||
entraOid String @unique
|
||||
// Entra tenant id. Stored alongside the oid so a future
|
||||
// dual-audience activation (ADR-0008) can disambiguate.
|
||||
tenantId String
|
||||
// Surfaced in the /admin/users list per ADR-0020. Refreshed by
|
||||
// the SessionEstablisher (apps/portal-bff/src/auth/session-establisher.service.ts).
|
||||
lastSignInAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
scopes UserScope[]
|
||||
|
||||
@@index([entraOid])
|
||||
}
|
||||
|
||||
model UserScope {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
// Discriminator — one of the six ADR-0025 scope kinds.
|
||||
kind String
|
||||
// Per-kind payload. FINESS for etablissement, dept code for
|
||||
// delegation, INSEE region code for region, empty string for
|
||||
// self / siege / unrestricted (the unique constraint below
|
||||
// tolerates empty strings).
|
||||
value String @default("")
|
||||
// Provenance same posture as Person.source. v1: 'admin-ui' or
|
||||
// 'seed'. ADR-0027 adds 'pleiades' / 'acteurs-plus' and the
|
||||
// reconciliation rules between admin overrides and upstream
|
||||
// data.
|
||||
source String
|
||||
createdAt DateTime @default(now())
|
||||
// When non-null and past, the row is ignored at sign-in.
|
||||
// Supports interim-director-for-two-months patterns and admin
|
||||
// UI scope revocations.
|
||||
expiresAt DateTime?
|
||||
|
||||
@@unique([userId, kind, value])
|
||||
@@index([userId])
|
||||
}
|
||||
```
|
||||
|
||||
### Schema — organisational hierarchy
|
||||
|
||||
```prisma
|
||||
model Region {
|
||||
// INSEE region code (2 digits — '11' for Île-de-France, '75' for
|
||||
// Nouvelle-Aquitaine). Stable across reorgs; doubles as the
|
||||
// primary key because the codes are short and stable.
|
||||
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])
|
||||
etablissements Etablissement[]
|
||||
}
|
||||
|
||||
model Etablissement {
|
||||
// FINESS code (9 digits — '0330800013'). The legal identifier
|
||||
// for medico-social establishments in France; stable across
|
||||
// reorgs.
|
||||
finess String @id
|
||||
name String
|
||||
// What this row is: a real établissement vs the siège vs a
|
||||
// future placeholder. Drives scope matching: the matcher
|
||||
// resolves `principal.scopes` against this column.
|
||||
kind String // 'etablissement' | 'siege'
|
||||
delegationCode String
|
||||
delegation Delegation @relation(fields: [delegationCode], references: [code])
|
||||
|
||||
@@index([delegationCode])
|
||||
}
|
||||
```
|
||||
|
||||
`Region.code` / `Delegation.code` / `Etablissement.finess` doubling as primary keys is a deliberate choice: they are externally-meaningful identifiers that already exist in every upstream system, they appear in URLs (`/api/etablissements/0330800013`), and they round-trip cleanly through scope literals (`etablissement:0330800013`). Adding a separate UUID would force every consumer to indirect through it for no gain.
|
||||
|
||||
### Lifecycle — Person + User creation
|
||||
|
||||
**v1 (this ADR's implementation PR):**
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ First sign-in for Entra oid X │
|
||||
│ │
|
||||
│ 1. SessionEstablisher.establish() callback │
|
||||
│ 2. PersonAndUserProvisioner.ensureUser({ oid: X, … }) │
|
||||
│ a. Look up User by entraOid │
|
||||
│ → if found: update lastSignInAt, return │
|
||||
│ → if not found: continue │
|
||||
│ b. Look up Person by email (case-insensitive) │
|
||||
│ → if found: link User to existing Person │
|
||||
│ → if not found: create Person with │
|
||||
│ source='self-signin', then link User │
|
||||
│ 3. PrincipalBuilder.build() now sees a real Person │
|
||||
│ and a real User; Principal.user.personId carries │
|
||||
│ Person.id rather than the entraOid placeholder │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**ADR-0027 (later):** Pléiades / Acteurs+ syncs create Person rows ahead of any sign-in. The lookup path becomes:
|
||||
|
||||
1. by `Person.externalId` if the sign-in carries one (rare — only when the federated identity exposes the upstream id),
|
||||
2. by `Person.email` (the existing v1 lookup),
|
||||
3. fallback to creating a `source='self-signin'` Person.
|
||||
|
||||
The schema does not change; only the provisioner's lookup order extends.
|
||||
|
||||
### Scope resolution — `PrismaScopeResolver`
|
||||
|
||||
`StubScopeResolver` in v1 returns `[{ kind: 'unrestricted' }]`. After ADR-0026 lands:
|
||||
|
||||
```ts
|
||||
@Injectable()
|
||||
export class PrismaScopeResolver extends ScopeResolver {
|
||||
constructor(private readonly prisma: PrismaService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async resolve({ userId }: { userId: string }): Promise<ReadonlyArray<Scope>> {
|
||||
const rows = await this.prisma.userScope.findMany({
|
||||
where: {
|
||||
userId,
|
||||
OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }],
|
||||
},
|
||||
});
|
||||
return rows.map(toScope);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The signature `resolve({ userId })` differs from the current `resolve({ entraOid })` — the User UUID is the natural key once the schema exists. The PrincipalBuilder evolves to pass `userId` instead, which is fine because the provisioner above has just created / fetched it.
|
||||
|
||||
### `Principal.user` post-implementation
|
||||
|
||||
The two placeholders called out at the top of this ADR resolve:
|
||||
|
||||
| Field | Pre-ADR-0026 | Post-ADR-0026 |
|
||||
| ------------------ | ------------------- | -------------------------------------------------------- |
|
||||
| `user.id` | Entra `oid` | `User.id` (portal UUID) |
|
||||
| `user.personId` | Entra `oid` | `Person.id` (portal UUID) |
|
||||
| `user.entraOid` | Entra `oid` | unchanged |
|
||||
| `user.tenantId` | Entra `tid` | unchanged |
|
||||
| `user.displayName` | Entra `displayName` | unchanged (cached at sign-in; admins can override later) |
|
||||
|
||||
### What ADR-0026 ships vs what stays for ADR-0027
|
||||
|
||||
| Concern | ADR-0026 (this) | ADR-0027 (next) |
|
||||
| --------------------------------------------------------------------------- | ---------------------------- | ------------------------ |
|
||||
| `Person`, `User`, `UserScope` schema | ✅ | — |
|
||||
| `Region`, `Delegation`, `Etablissement` schema | ✅ (manually-seeded in v1) | Pléiades-sync population |
|
||||
| `Person.source = 'self-signin'` lazy creation | ✅ | — |
|
||||
| `Person.source = 'pleiades'` / `'acteurs-plus'` | as a documented future value | Sync implementation |
|
||||
| Salarié / Élu / Adhérent / Bénéficiaire facets | deferred | ✅ |
|
||||
| Reconciliation between admin-UI overrides and Pléiades-driven `user_scopes` | deferred | ✅ |
|
||||
|
||||
The facet split was the main "tempting to ship now" item; deferring it keeps the schema migration small enough to land + roll back cleanly if the lazy-provisioner exposes a corner case.
|
||||
|
||||
### 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-0027) can drop into a known shape; their lookup logic (email-based dedup, externalId carryover) is already part of v1.
|
||||
- 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 `Person.email` as the v1 dedup key is fragile — two Pléiades records with the same email crash the unique constraint. Mitigation: ADR-0027 adds `externalId` precedence and surfaces conflicts to the operator.
|
||||
- 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.
|
||||
- Neutral, because `Region.code` / `Delegation.code` / `Etablissement.finess` as primary keys preclude renames. APF's INSEE codes are stable; if a délégation merges, the row is deleted + a new one is inserted with the new code. The cost is well-bounded.
|
||||
|
||||
### Confirmation
|
||||
|
||||
- **Migration tests.** Prisma migration emits a SQL file; the `infra/local` dev stack runs it on fresh boot. Manual + a small `e2e` spec exercising the lazy-create path with a stubbed Entra response.
|
||||
- **PrismaScopeResolver integration test.** Two personas (one with `etablissement:0330800013`, one with `unrestricted`) signed in, scopes round-trip from DB → Principal → `principalCoversResource`.
|
||||
- **`Person.source` enum-as-string is single-sourced.** A small constant array in `apps/portal-bff/src/users/person-source.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 `Person.source` is in the catalogue. Same posture as the `Privilege` / `FunctionalRole` catalogues.
|
||||
- **`Etablissement.kind` enum-as-string.** Same posture as `Person.source`. Legal values: `'etablissement'`, `'siege'`. Extending to `'preprod-placeholder'` etc. is an ADR amendment.
|
||||
|
||||
## Pros and Cons of the Options
|
||||
|
||||
### Option B — `Person` golden record + `User` overlay (chosen)
|
||||
|
||||
- Good, because Person-without-User is a valid state — bénéficiaires + alumni + Pléiades pre-provisioning.
|
||||
- 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-0027's territory and is easier to design with this shape than with Option A.
|
||||
|
||||
### Option A — `User`-only
|
||||
|
||||
- Good, because one table is the smallest possible thing.
|
||||
- Bad, because dossiers held by non-portal-user beneficiaries have nowhere to anchor — either a `Dossier.beneficiaryUserId` that violates the "User = signed-in user" invariant, or a denormalised string copy of the beneficiary's name (no golden record).
|
||||
- Bad, because Pléiades pre-provisioning requires a User row to exist before any sign-in; that User would be a non-signed-in account, contradicting the table's name.
|
||||
- Bad, because portal-only state (`lastSignInAt`) coexists with HR-owned data in a single table — Pléiades sync would overwrite portal-only fields unless the sync logic carefully avoids them.
|
||||
|
||||
### Option C — `Person` lives in Entra
|
||||
|
||||
- Good, because there is no portal-side identity drift to manage.
|
||||
- Bad, because non-employees + non-elus do not exist in Entra (bénéficiaires, alumni, dossier-holders). They are by far the larger population.
|
||||
- Bad, because Entra's storage of attributes is per-tenant and not designed for queryable golden records — listing every salarié in Bordeaux means walking the API, not SELECT.
|
||||
- Bad, because the [ADR-0008](0008-identity-model-entra-workforce-dual-audience.md) dual-audience design anticipated External ID activation for non-workforce — that flips the audience but does not give bénéficiaires Entra identities by default.
|
||||
|
||||
### Option D — `Person` + embedded facet columns
|
||||
|
||||
- Good, because one less join when reading "the salarié record for Person X".
|
||||
- Bad, because `Person.salarie_etablissement_id`, `Person.elu_mandat_type`, `Person.adherent_cotisation_status`, … each introduce a NULL column that 95% of rows leave unused — schema bloat without normalization gains.
|
||||
- Bad, because Pléiades data shape evolves independently from Acteurs+ data shape; embedding both in `Person` means every Pléiades schema change touches the table Acteurs+ rows also live in. Separate facet tables ride their own sync's lifecycle.
|
||||
|
||||
## More Information
|
||||
|
||||
**Phasing.** This ADR is decision-only. Implementation lands in two PRs once the ADR is accepted:
|
||||
|
||||
1. **PR — Prisma schema + lazy provisioner.** `Person`, `User`, `Region`, `Delegation`, `Etablissement`, `UserScope` models; `PersonAndUserProvisioner` called from `SessionEstablisher`; `Person.source` + `Etablissement.kind` constants + drift-gate extension; updated `PrincipalBuilder` to populate `Principal.user.{id, personId}` from the real rows.
|
||||
2. **PR — `PrismaScopeResolver` + admin UI scope-seeding screen + test-tenant seed.** Replaces `StubScopeResolver` in the AuthModule. Seeds the 19 test personas' `user_scopes` rows per `notes/test-tenant-role-assignments.md`. Adds an `/admin/users/:id/scopes` admin-app screen for manual scope management.
|
||||
|
||||
**Follow-up ADRs.**
|
||||
|
||||
- **[ADR-0027](#) — 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