docs(adr): land ADR-C-001, ADR-C-003, ADR-C-007 and ADR-C-009

The architect approved the four ADR-fix tickets. All four change what the
architecture documents claim. No code changes.

ADR-0001, ADR-C-001: the worked example claimed the POC has no real backend.
It rewrites against `backend/src/BigRegister.Api`. Every path it named is
repointed. The out-of-scope list drops two discharged bullets: 33 `parse*`
boundaries exist, and `npm run gen:api` is real.

ADR-0001, ADR-C-003: a new section states that the generated client is the wire
contract. A hand-written `contracts/*.dto.ts` is the exception for two cases
only. The four survivors stay, because NSwag emits every property as optional
and flattens `RegistrationStatusDto` into five optional strings. The `parse*`
trust boundary stays mandatory, because a generated type is a compile-time
claim about the wire and not a runtime guarantee.

ADR-0003, ADR-C-007: four paths moved in WP-67 and are repointed. Point 4 kept
the principle and changed its example to `skeleton` and `spinner`. Two of its
claims were false and the amendment says so: `app-alert` wraps the vendored
`.feedback` classes, and `site-header` composes the vendored `.titlebar`.

ADR-0004, ADR-C-009: the exception section states a four-part test instead of
one named exception. `OrgTemplateStore` and `FeatureFlagStore` both pass it. RB-07
gated this ticket, because clause 4 needs an audited allow path. RB-07 landed
that, so the ADR does not ratify a control that the code lacks.

Three tickets need a matching CLAUDE.md correction in the same diff. CLAUDE.md
section 2 loses the false `alert` example. Section 4 gets the generated-client
rule and the four-part test.

Two findings were wrong. ADR-C-001 asked to keep an out-of-scope bullet that
reads "SessionStore is in-memory". The session persists to `localStorage` now,
so the bullet covers multi-tab sync only. ADR-C-007 flagged one half of point 4
and missed that the other half is equally false.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-08-27 18:29:05 +02:00
co-authored by Claude Opus 5
parent 7fbac8fca5
commit 25a5d415a5
10 changed files with 458 additions and 68 deletions
@@ -69,44 +69,96 @@ the governance/transparency artifact.
The frontend keeps only **format** validation (postcode shape, integer parsing) for
instant feedback — never as the authority.
### Where the contract lives, after codegen
The paragraph above says "manage it with one source of truth that generates types for
both sides". That target state has arrived, so this section states which artifact is now
the contract.
**The generated client is the wire contract.** `libs/shared/src/infrastructure/api-client.ts`
is regenerated from the backend's OpenAPI document by `npm run gen:api`, and CI fails on
drift (the `api-client-drift` job regenerates it and runs `git diff --exit-code`). It is the
single source of truth for the shape of every endpoint. An adapter consumes its types
directly; 19 of the 20 infrastructure adapters do.
**A hand-written `contracts/*.dto.ts` is the exception, for two cases only:**
1. **Codegen does not reach the endpoint** — a hand-rolled `fetch`/XHR path that the
generator never sees.
2. **The generator types the shape too loosely** — the generated type compiles but is
weaker than the wire really is.
In either case the hand-written file must still import nothing. It describes the wire, not
the domain.
**The `parse*` trust boundary is unchanged and stays mandatory**, whichever way the type
arrived. A generated type is a compile-time claim about the wire, not a runtime guarantee:
the server can send anything. `infrastructure/` validates the untrusted shape and maps it
onto the domain, exactly as before.
**The four surviving hand-written contracts stay.** They are
`apps/ssp/src/app/registratie/contracts/{brp-address,dashboard-view,duo-diplomas}.dto.ts`
and `libs/beheer/src/contracts/stamdata.dto.ts`. All four fall under case 2, and the
dashboard view shows why: the generator emits every property as optional, and it flattens
a discriminated union into a bag of optional fields.
```ts
// generated — every field optional, `tag` a bare string, all variants merged
interface RegistrationStatusDto {
tag?: string | undefined;
herregistratieDatum?: string | undefined;
geschorstTot?: string | undefined;
reden?: string | undefined;
doorgehaaldOp?: string | undefined;
}
// hand-written — a real discriminated union, per-variant fields required
type RegistrationStatusDto =
| { tag: 'Geregistreerd'; herregistratieDatum: string }
| { tag: 'Geschorst'; geschorstTot: string; reden: string }
| { tag: 'Doorgehaald'; doorgehaaldOp: string; reden: string };
```
Adopting the generated shape here would push `undefined` handling into every consumer and
make an illegal state representable, which CLAUDE.md §3 forbids. Retiring these four is
therefore **not** a cleanup to schedule; it becomes correct only if the backend annotates
its DTOs so the generator emits required properties and real unions.
## Worked example in this POC
This POC has no real backend (static mock JSON + fake submit timers), so the
"BFF output" is a static file; the `decisions` block stands in for what the backend
would compute. Two slices were implemented to demonstrate **both** policy shapes:
Implemented against the real backend, `backend/src/BigRegister.Api`. Two slices demonstrate
**both** policy shapes.
**A. Dashboard profile → one aggregated, decision-enriched call (decision-flag).**
- Contract: `src/app/registratie/contracts/dashboard-view.dto.ts`
- Endpoint: `GET /api/v1/dashboard-view` (`Program.cs`), one call replacing three.
- Contract: `apps/ssp/src/app/registratie/contracts/dashboard-view.dto.ts`
(`DashboardViewDto` = registration + person + `decisions`).
- Endpoint: `public/mock/dashboard-view.json` (one call replaces three).
- Boundary parse: `parseDashboardView()` in
`src/app/registratie/infrastructure/dashboard-view.adapter.ts` validates the
untrusted shape and maps DTO → domain (hand-written; no schema lib for one
contract).
- `BigProfileStore` now derives `profile` and `decisions` from the single
validated view (was a 3-resource `map2`). One request → one consistent snapshot.
- `herregistratie.page.ts` reads `decisions.eligibleForHerregistratie` instead of
computing it client-side. That rule is server-owned: it lives only in
`HerregistratieRule.cs`, with no FE mirror to drift from it (WP-75).
- The unused upstream adapters/mocks (`brp.adapter.ts`, `registration.json`,
`brp.json`) were deleted — those calls live behind the BFF now.
`apps/ssp/src/app/registratie/infrastructure/dashboard-view.adapter.ts` validates the
untrusted shape and maps DTO → domain (hand-written; no schema lib).
- `BigProfileStore` derives `profile` and `decisions` from the single validated view (was a
3-resource `map2`). One request → one consistent snapshot.
- `herregistratie.page.ts` reads `decisions.eligibleForHerregistratie` instead of computing
it client-side. That rule is server-owned: it lives only in `HerregistratieRule.cs`, with
no FE mirror to drift from it (WP-75).
**B. Intake scholing threshold → config value.**
- Contract: `src/app/herregistratie/contracts/intake-policy.dto.ts`.
- Endpoint: `public/mock/intake-policy.json` (`{ "scholingThreshold": 1000 }`).
- Endpoint: `GET /api/v1/intake/policy` (`Program.cs`), serving
`IntakePolicy.ScholingThreshold`.
- Contract: the generated `IntakePolicyDto`; the adapter is
`apps/ssp/src/app/herregistratie/infrastructure/intake-policy.adapter.ts`.
- `intake.machine.ts`: the hardcoded `LAGE_UREN_DREMPEL` constant is gone;
`lageUren(a, scholingThreshold)` and validation take the value, which lives in
machine state and is set via a `SetPolicy` message. A `SCHOLING_THRESHOLD_DEFAULT`
remains only as the offline fallback.
`lageUren(a, scholingThreshold)` and validation take the value, which lives in machine
state and is set via a `SetPolicy` message. A `SCHOLING_THRESHOLD_DEFAULT` remains only
as the offline fallback.
- `intake-wizard.component.ts` fetches the policy and dispatches `SetPolicy`.
- WP-69: the backend re-validates the threshold as the authority on submit —
`IntakePolicy.RejectIncompleteScholing` runs before `POST /applications/{id}/submit`
(intake-typed) writes anything, 400ing an incomplete scholing answer instead of
silently accepting a crafted POST that skips it. (WP-72 deleted the legacy
`POST /intakes` endpoint this once also covered — deleting the surface is a stronger
fix than 400ing on it.)
(intake-typed) writes anything, 400ing an incomplete scholing answer instead of silently
accepting a crafted POST that skips it. (WP-72 deleted the legacy `POST /intakes` endpoint
this once also covered — deleting the surface is a stronger fix than 400ing on it.)
## Migration sequence (for the real app)
@@ -119,12 +171,17 @@ would compute. Two slices were implemented to demonstrate **both** policy shapes
## Out of scope here (next steps, not built in the worked example)
- Runtime DTO validation on **every** endpoint (only the dashboard view has it).
- Optimistic-update race fix in `BigProfileStore`
(`beginHerregistratie`/`rollbackHerregistratie` can leave `pending` wrong under
concurrent submits).
- Session persistence / multi-tab sync (`SessionStore` is in-memory).
- Real OpenAPI/TypeSpec codegen toolchain.
- Multi-tab session sync. The session itself now persists (`localStorage`, read back
through `parseStoredPrincipal`), but a change in one tab does not reach another — no
`storage` listener exists.
Two bullets were discharged and removed. Runtime DTO validation is no longer "only the
dashboard view": 33 `parse*` boundary functions exist. The OpenAPI codegen toolchain is
real: `npm run gen:api` generates `libs/shared/src/infrastructure/api-client.ts` and CI
drift-checks it.
ponytail: build the pattern once on one slice; copy it across screens when the real
backend lands, rather than scaffolding all of it up front.