refactor(auth): land Session -> Principal, add MedewerkerAdapter (RB-13)

ADR-0002 SS3 models Zorgverlener/Medewerker as different Principal
variants with different login flows. Actor #2 (apps/behandelportal)
landed in WP-61/67 and the union never followed: grep -rn "Principal"
returned one hit, a comment. Both apps' auth/domain/session.ts stayed
byte-identical (`{ bsn, naam }`), so the backoffice's Behandelaar
carried a BSN and logged into the backoffice as a citizen, by DigiD,
under a fabricated citizen's name (login.page.ts). The divergence
ADR-0002 predicted took an orthogonal side door instead
(medewerker.interceptor.ts's X-Medewerker/X-Rollen stamp, which never
touches SessionStore) -- which is why ssp/auth and bhp/auth still
measured as 100%/84% duplicated after ADR-C-006 shared the route
guards. RB-09 (landed the day before) made the backend's
IIdentityProvider able to say "no identity" and fail closed; this
ticket is its named FE half.

Each app's auth/domain/session.ts becomes principal.ts, holding the
one Principal variant that app actually has an actor for: ssp keeps
`{ kind: 'zorgverlener', bsn, naam }` (G1 still strips the BSN before
persisting); behandelportal gets `{ kind: 'medewerker', medewerkerId,
naam, rollen }` (no BSN to strip -- G2 shape validation only). A new
MedewerkerAdapter replaces DigidAdapter in behandelportal, resolving
the existing MEDEWERKER_ID/currentRollen() dev stand-in into a
Principal; because there is no credential to check, it returns
Principal directly rather than a Result whose error variant could
never occur. login.page.ts stops being a BSN/wachtwoord form -- one
explainer line and an "Inloggen met SSO" button -- and its dead
error-handling branch goes with the Result wrapper that justified it.

Measured with tools/baseline-scan.mjs --dup: auth duplication drops
from 168/168 (ssp) and 168/200 (bhp) to 32/179 and 32/259 -- under the
backlog's <40 target. What remains is the ADR-C-006 route-guard
re-export (deliberately identical), generic test/story-file
boilerplate, and one shared fragment of the root-singleton-store
idiom -- not re-converged identity or login-flow logic. SS3's
prediction that the two actors would authenticate differently enough
to justify not sharing auth has now actually been tested, not just
asserted, and held.

Also: renamed Session.bsn to Principal.bsn in two doc comments
(libs/shared/src/infrastructure/subject.ts, subject.interceptor.ts)
that cited the old type name; regenerated
libs/shared/docs/behaviour-spec.mdx (generated file, per its own
banner); recorded the resolution in ADR-0002 as a new amendment,
replacing its "Known debt" section.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-08-27 16:54:26 +02:00
co-authored by Claude Opus 5
parent 988612cd7e
commit f19185ed81
22 changed files with 588 additions and 319 deletions
@@ -1,6 +1,6 @@
# ADR 0002 — User groups as actors, not bounded contexts
Status: Accepted · Date: 2026-07-01 · Amended 2026-08-01 (WP-67)
Status: Accepted · Date: 2026-07-01 · Amended 2026-08-01 (WP-67), 2026-08-27 (RB-13)
## Problem
@@ -167,28 +167,34 @@ status lifecycle + authorization endpoints/DTOs — **shipped** (WP-61…WP-67):
`AanvraagStatusTag` (`Domain/Applications/AanvraagStatus.cs`), `GET /me` (`Program.cs:578`),
`Domain/Authorization/Authz.cs`.
## Known debt: `Session → Principal` was never built
A third bullet stood here too — `Session → Principal` — from 2026-08-26 until it was paid
off by RB-13 the next day. See the amendment below for the historical record and what
landed.
§3's `Principal` union is the one decision here that has **not** been executed, and it is now
debt rather than a deferral. Actor #2 arrived — `apps/behandelportal` shipped — and the union
did not follow. `grep -rn "Principal" apps libs` returns a single hit: a comment in
`libs/shared/src/infrastructure/role.ts`. There is no such type.
## Amendment (RB-13, 2026-08-27): `Session → Principal` landed
What that omission actually costs, measured 2026-08-26:
§3's `Principal` union was accepted on 2026-07-01 and not executed until now — see the
"Known debt" record this replaces, added 2026-08-26 by the refactor-backlog audit
(`ADR-C-004`) that found it. `apps/ssp/src/app/auth/domain/principal.ts` now exports the
`zorgverlener` variant (`{ kind: 'zorgverlener'; bsn; naam }`);
`apps/behandelportal/src/app/auth/domain/principal.ts` exports the `medewerker` variant
(`{ kind: 'medewerker'; medewerkerId; naam; rollen }`) — each app holds only the one
member of the union it actually has an actor for, per this ADR's own proposed resolution.
`apps/behandelportal`'s `DigidAdapter` is gone; a `MedewerkerAdapter` resolves the
dev-stand-in medewerker identity (`medewerker.ts`'s `MEDEWERKER_ID`/`currentRollen()` —
unchanged, still the mechanism `medewerkerInterceptor` uses for the backend headers) into
a `Principal` instead, and `login.page.ts` is an SSO-stand-in entry (one button, no BSN
field) rather than the citizen DigiD form it used to share with the SSP verbatim.
- `apps/ssp/src/app/auth` and `apps/behandelportal/src/app/auth` are byte-identical —
`diff -rq` reports **zero** content differences across 9 of 11 files, the only delta being
two extra files in behandelportal.
- `behandelportal`'s Behandelaar still carries a `bsn` and logs in through `DigidAdapter`.
A backoffice user authenticates as a citizen, which is precisely what §3 was written to prevent.
- The divergence that _did_ occur took an orthogonal side door — `medewerker.interceptor.ts`,
a dev-only `X-Medewerker` header stamp that never touches `Session`.
The WP-67 amendment above justifies keeping `auth` duplicated on the grounds that it is
"expected to diverge". That reasoning still holds — but it has never been **tested**, because
the change that would test it is this one. Read the two identical copies as evidence that
§3 is unexecuted, not as evidence that §3 was wrong.
ponytail: this ADR draws the boundaries so nothing has to be undone later. The original
"YAGNI until the backoffice work starts" call was right when written and has now expired —
the backoffice started. `Principal` is owed.
The two `auth` contexts, measured 2026-08-27 after the change
(`tools/baseline-scan.mjs --dup`): **32 duplicated lines each** (from 168 at the
2026-08-26 measurement above; from 211 before ADR-C-006 shared the route guards). What
remains is not re-converged identity/login-flow code — it is `auth.guard.ts`'s intentional
verbatim re-export (ADR-C-006: a route guard is actor-agnostic, not in this ADR's scope)
plus ordinary test/story-file boilerplate (`describe`/`it` shape, a `Meta`/`StoryObj`
scaffold) that any two spec or story files share regardless of subject. The prediction in
§3 — that Zorgverlener and Medewerker, modelled as distinct `Principal` variants, would
turn out to authenticate differently enough that sharing `auth` would have been the wrong
call — has now actually been tested, not just asserted, and held: the two contexts diverge
in domain type, adapter, and login UI as soon as the union exists to make that
divergence possible.