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.
@@ -19,28 +19,47 @@ layer — not a palette swap.
## Decision
1. **Vendor the package** under `public/cibg-huisstijl/` (not an npm dep — it was delivered as files),
loaded via a `<link>` in `src/index.html` so the CSS's relative `url(../fonts|icons|images)`
references resolve at runtime. Storybook serves the same via `staticDirs`.
2. **Token bridge over token rewrite.** `src/styles.scss` redefines the app's ~54 `--rhc-*` tokens
loaded via a `<link>` in each app's `index.html` (`apps/ssp/src/index.html` and
`apps/behandelportal/src/index.html` — two since WP-67) so the CSS's relative
`url(../fonts|icons|images)` references resolve at runtime. `public/` stays at the repo
root and both apps' `angular.json` targets copy it. Both Storybook instances serve the
same via `staticDirs: ['../public']`.
2. **Token bridge over token rewrite.** `libs/shared/styles.scss` — one copy, both apps'
`angular.json` point at it (WP-67) — redefines the app's ~54 `--rhc-*` tokens
onto CIBG values (`--bs-*` where one exists, CIBG palette hex otherwise). The `--rhc-*` names are
now an internal alias set; the _values_ are CIBG. This avoided rewriting 300+ token references and
keeps the "components reference tokens" convention intact. (`styles.scss` is exempt from
`check:tokens`, so palette hex lives in that one file only.)
keeps the "components reference tokens" convention intact. (`libs/shared/styles.scss` is
exempt from `check:tokens`, so palette hex lives in that one file only.)
3. **Re-skin atoms, keep their `input()` APIs.** Each `shared/ui` atom now emits Bootstrap/CIBG classes
(`app-button``btn btn-primary`, `text-input``form-control`, radio/checkbox → `form-check-*`);
domain pages compose the same atoms and barely changed.
4. **Hand-roll what CIBG's build drops.** CIBG omits Bootstrap's `.alert` and `.navbar`, so `app-alert`
is a small token-styled surface and the header/side-nav use `.nav` + a local blue bar. Local class
names that collide with Bootstrap components were renamed (`.card``.app-card`, badge → `.status-badge`).
4. **Hand-roll what CIBG's build drops, and mark it.** Where the vendored build has no class for a
concept, the component is a small token-styled surface carrying a `// CIBG-GAP EXTENSION:` marker.
The clearest live examples are `skeleton` and `spinner`: CIBG documents "Laadindicatie" but the
vendored build ships no loading-skeleton or loading-spinner class, so both are built from the token
bridge. Local class names that collide with Bootstrap components were renamed (`.card``.app-card`,
badge → `.status-badge`).
Two claims this point used to make were wrong and are corrected here. **`.alert` is not a gap:**
`app-alert` is a thin wrapper over the vendored `.feedback feedback-*` classes — the design system
owns surface and icon, and the component adds only the icon's a11y label and a flex fix. It carries
no gap marker, correctly. **The header is not hand-rolled either:** `site-header` composes the
vendored `.titlebar` and `.logo__*` classes and leaves the robijn fill (`--ro-layout`) untouched.
The `shell` template's `.layout`/`.main`/`.content` classes are page scaffolding, not a substitute
for a missing design-system component, so they carry no marker either.
5. **System-font stack; no licensed fonts.** `--bs-font-sans-serif` is overridden to `system-ui`; the
licensed RO/Rijks **text** woffs are removed from the vendored copy (CIBG icon font kept). Logo stays
a text wordmark. Interactivity stays Angular-driven (no Bootstrap JS).
## Consequences
- Wiring the design system touches `styles.scss` (token bridge), `index.html`, `angular.json`
(`public/` already copied), and `.storybook/` — plus the class strings in ~40 `shared/ui` +
`shared/layout` + a few domain components. The `@rijkshuisstijl-community/*` deps are dropped.
- Wiring the design system touches `libs/shared/styles.scss` (token bridge), both apps'
`index.html`, both `angular.json` targets (`public/` already copied), and both Storybook config
dirs (`.storybook-ssp/` and `.storybook-behandelportal/` — separate since WP-67, because a single
merged tsconfig cannot resolve both apps' `@auth/*` at once) — plus the class strings in ~40
`libs/shared/ui` + `libs/shared/layout` + a few domain components. The
`@rijkshuisstijl-community/*` deps are dropped.
- `check:tokens` still guards raw hex in components; the token bridge + hand-rolled surfaces comply.
- Known benign build warning: _"Unable to locate stylesheet: /cibg-huisstijl/css/huisstijl.min.css"_
Angular's index optimizer doesn't process a `public/` stylesheet at build time. The asset is copied
@@ -49,6 +68,6 @@ layer — not a palette swap.
intentionally dropped, so we accept the warning.
- Renaming the internal token names from `--rhc-*` to `--app-*` is possible later but out of scope.
- Hand-rolled components (point 4) are tracked in the **CIBG gap register**
(`src/docs/cibg-gaps.mdx`, Storybook "Foundations/CIBG Gap Register"): every deviation from the
design system carries a `// CIBG-GAP EXTENSION:` marker so it's auditable rather than silently
drifting.
(`libs/shared/docs/cibg-gaps.mdx`, Storybook "Foundations/CIBG Gap Register"): every deviation
from the design system carries a `// CIBG-GAP EXTENSION:` marker so it's auditable rather than
silently drifting.
@@ -20,7 +20,7 @@ was neither isolated nor validated:
- All reference data and thresholds are **compiled-in C# constants**, served through
screen-shaped BFF-lite endpoints; the frontend renders decisions and holds no reference
data (ADR-0001).
- User-facing UI copy is already **`$localize`** (`src/locale/*.xlf`) — git-tracked, and a
- User-facing UI copy is already **`$localize`** (`apps/<app>/src/locale/*.xlf`) — git-tracked, and a
second locale is a translation file, not a code change. That is already the compile-time
model for text.
- The profession↔diploma map lived as a _private_ `Dictionary` inside `DiplomaRules`, mixed
@@ -62,17 +62,49 @@ production database, never runtime-editable.
| Kind | Home | Gate |
| ------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| Reference tables + tunable numbers (professions↔diplomas, thresholds, policy questions, document categories) | `Stamdata/` typed C# **or** typed JSON data-file (`professions.json`), optionally valid-timed | compiler (shape; + values when C#) + `StamdataValidationTests` (values, references, validity windows) |
| User-facing UI copy | `$localize``src/locale/*.xlf` | build (`i18nMissingTranslation: error`) |
| User-facing UI copy | `$localize``apps/<app>/src/locale/*.xlf` | build (`i18nMissingTranslation: error`) |
| Letter / brief passage content | config-as-code in the backend (seed content), **not** the DB | compiler + endpoint tests |
### The deliberate exception: org-templates
### The deliberate exception: operational configuration
Per-organization letterhead (return address, footer, signature, margins) **is**
runtime-editable in SQLite, via the org-template admin editor (WP-23/26). That is
intentional and does not contradict this ADR: it is _operational configuration_ owned by an
admin persona, versioned with publish/rollback inside the app, and specific to one
sub-organization's identity — not the shared business rules a wrong value would break for
everyone. Stamdata (the rules and reference tables the whole register runs on) stays code.
"Never runtime-editable" above is the rule for **stamdata** — the shared reference tables
and business rules the whole register runs on. It is not a ban on all persisted
configuration. Some configuration is operational rather than business-rule, and belongs to
an admin persona at runtime.
This section states the **test** rather than a list, so the next surface can check itself
instead of arguing by analogy. Runtime-editable persistence is permitted only when all four
hold:
1. **The catalog lives in code.** What may be set — the keys, the schema, the defaults,
the descriptions — is compiled in and reviewed through git. The store holds values, never
the definition of what a value means.
2. **An unknown or unlisted key fails closed.** A row the code catalog does not know cannot
invent a setting, enable a feature, or be written. A bad row is inert, not authoritative.
3. **The value is operational.** Per-organisation identity, or an on/off rollout switch —
not a shared business rule whose wrong value breaks the register for everyone. This is the
clause that keeps stamdata out.
4. **Writes are admin-capability-gated and audited.** The write path goes through an `Authz`
capability gate, and the gate records the decision — allow as well as deny — in
`AuthzAuditStore`.
**Two surfaces pass this test today.**
| Surface | (1) catalog in code | (2) fails closed | (3) operational | (4) gated + audited |
| ----------------------------- | ----------------------------------------------- | ------------------------------------------------------------ | --------------------------------- | ------------------------------- |
| `OrgTemplateStore` (WP-23/26) | the `OrgTemplateDto` shape + `OrgTemplateRules` | unknown `subOrgId``null` → the endpoint 404s | one sub-organisation's letterhead | `OrgAdmin``orgtemplate:edit` |
| `FeatureFlagStore` (WP-47) | `Domain/Features/FeatureFlags.Catalog` | unknown key → `Set` returns false (404); `IsEnabled` → false | an on/off rollout switch | `FlagsAdmin``flags:manage` |
Clause (4) became true for both only with RB-07, which moved `AuditAuthz` from each gate's
deny branch into the gate itself so the allow path is recorded too. Before that, both
surfaces were gated and **not** audited, and this ADR would have ratified a control the code
did not implement.
Org-templates also carry publish/rollback versioning inside the app, which is stronger than
the test requires but not part of it.
Stamdata itself — the rules and reference tables — fails clause (3) by construction and
stays code.
## Consequences