Compare commits

...
3 Commits
Author SHA1 Message Date
ehoandClaude Opus 4.8 a7f737e18c docs: ADR-0004 stamdata-as-code
CI / frontend (push) Failing after 1m20s
CI / storybook-a11y (push) Failing after 5m27s
CI / e2e (push) Has been cancelled
CI / codeql (csharp) (push) Has been cancelled
CI / codeql (javascript-typescript) (push) Has been cancelled
CI / api-client-drift (push) Has been cancelled
CI / backend (push) Has been cancelled
Document the config-as-code strategy for business-tunable reference data:
typed checked-in config validated at compile time (never a production DB),
where UI text (), reference tables (Stamdata/), and letter content
each live, and why org-templates are the deliberate runtime-editable exception.
Index it in docs/README.md and add a CLAUDE.md pointer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 07:41:21 +02:00
ehoandClaude Opus 4.8 fa7e9c5cfb feat(stamdata): profession↔diploma map as validated config-as-code
Extract the profession↔diploma table out of DiplomaRules into a dedicated
Stamdata.Professions module (business-editable data, separated from the rules
that consume it) and add StamdataValidationTests as the build-time gate: every
seeded diploma program must resolve to a real profession, no blank entries. A
bad edit now fails the build instead of silently rendering "Onbekend". Rules
and behaviour unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 07:41:04 +02:00
ehoandClaude Opus 4.8 14210fa2b0 docs: document form autosave + FE↔BE request lifecycle in ARCHITECTURE
New §2g explains field persistence (keystroke → model → 600ms snapshot
debounce; blur only marks touched, never saves) and §6a refreshes the stale
backend section with the real request lifecycle (NSwag client, httpClientFetch
seam, read/write traces) — both with relative links to the source files.
Add discovery pointers from the learning path (lesson 2.2 and the capstone).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 21:38:49 +02:00
8 changed files with 256 additions and 18 deletions
+6
View File
@@ -117,6 +117,12 @@ touches only `infrastructure/` + `contracts/` (see ARCHITECTURE §6). Server-own
rules stay in `domain/*.policy.ts` as reference impl + unit test, marked server-owned,
but the FE doesn't call them.
**Business-tunable reference data ("stamdata") is config-as-code, not a DB.** Tables the
business controls (profession↔diploma map, thresholds, policy-question text) live as typed
C# in `backend/.../Stamdata/`, validated at build by `StamdataValidationTests` (a bad edit
fails CI, never prod) — never runtime-editable. Org-templates are the deliberate exception
(operational per-org config in SQLite). UI copy is `$localize`. See ADR-0004.
### 5. Testing
Vitest. Co-locate `*.spec.ts` next to the unit. **Domain and pure logic must have a
@@ -1,29 +1,23 @@
using BigRegister.Stamdata;
namespace BigRegister.Domain.Diplomas;
/// <summary>
/// SERVER-OWNED business rules for diplomas. This is the single place a policy
/// changes: which profession a study program maps to, and which policy questions
/// (geldigheidsvragen) apply to a diploma. The frontend renders these; it never
/// derives them.
/// SERVER-OWNED business rules for diplomas: which profession a study program maps to,
/// and which policy questions (geldigheidsvragen) apply to a diploma. The frontend
/// renders these; it never derives them.
///
/// The profession↔program *data* is business-editable stamdata in
/// <see cref="Professions"/> (config-as-code, ADR-0004); the *rules* below consume it.
/// </summary>
public static class DiplomaRules
{
// RULE: study program → BIG profession.
private static readonly Dictionary<string, string> ProfessionByProgram = new(StringComparer.OrdinalIgnoreCase)
{
["geneeskunde"] = "Arts",
["verpleegkunde"] = "Verpleegkundige",
["fysiotherapie"] = "Fysiotherapeut",
["farmacie"] = "Apotheker",
["tandheelkunde"] = "Tandarts",
};
// RULE: study program → BIG profession (data lives in Stamdata.Professions).
public static string ProfessionFor(Diploma d) =>
ProfessionByProgram.TryGetValue(d.Opleiding, out var beroep) ? beroep : "Onbekend";
Professions.ByProgram.TryGetValue(d.Opleiding, out var beroep) ? beroep : "Onbekend";
/// <summary>Professions a user may declare for a manual (unlisted) diploma.</summary>
public static IReadOnlyList<string> ManualProfessions() =>
ProfessionByProgram.Values.Distinct().ToList();
public static IReadOnlyList<string> ManualProfessions() => Professions.All();
// --- Policy questions (geldigheidsvragen) ---
@@ -0,0 +1,32 @@
namespace BigRegister.Stamdata;
/// <summary>
/// BUSINESS-EDITABLE STAMDATA (config-as-code). Which BIG profession (beroep) each
/// study program (opleiding) maps to. This is the one table the business tunes when
/// a program starts or stops leading to a registered profession.
///
/// Change it by editing this file and opening a PR — NOT via a production database.
/// The C# compiler catches shape/type mistakes; <c>StamdataValidationTests</c> catches
/// the referential integrity it can't (e.g. a seeded diploma whose program has no
/// profession here). So a bad edit fails the build, never prod. See ADR-0004
/// (docs/reference/architecture/0004-stamdata-as-code.md).
///
/// This is DATA, not logic: the rules that consume it (which questions a diploma needs,
/// how a manual diploma is treated) stay in <c>DiplomaRules</c>.
/// </summary>
public static class Professions
{
public static readonly IReadOnlyDictionary<string, string> ByProgram =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["geneeskunde"] = "Arts",
["verpleegkunde"] = "Verpleegkundige",
["fysiotherapie"] = "Fysiotherapeut",
["farmacie"] = "Apotheker",
["tandheelkunde"] = "Tandarts",
};
/// <summary>Distinct professions, in declaration order — the list a user may declare
/// for a manual (unlisted) diploma.</summary>
public static IReadOnlyList<string> All() => ByProgram.Values.Distinct().ToList();
}
@@ -0,0 +1,41 @@
using BigRegister.Api.Data;
using BigRegister.Domain.Diplomas;
using BigRegister.Stamdata;
namespace BigRegister.Tests;
/// <summary>
/// The compile-time gate for business-editable stamdata (ADR-0004). The C# compiler
/// already catches shape/type mistakes; these tests catch the referential integrity it
/// can't, so a bad config edit fails the build instead of reaching production.
/// </summary>
public class StamdataValidationTests
{
[Fact]
public void Every_seeded_diploma_program_maps_to_a_known_profession()
{
// The dangling-reference guard: a seed program with no entry in Professions would
// silently render "Onbekend" to the user. Fail the build instead.
foreach (var d in SeedData.Diplomas)
Assert.True(DiplomaRules.ProfessionFor(d) != "Onbekend",
$"Diploma program '{d.Opleiding}' has no profession in Stamdata.Professions.");
}
[Fact]
public void Profession_map_has_no_blank_programs_or_professions()
{
Assert.All(Professions.ByProgram, kv =>
{
Assert.False(string.IsNullOrWhiteSpace(kv.Key), "A profession-map program key is blank.");
Assert.False(string.IsNullOrWhiteSpace(kv.Value), $"Program '{kv.Key}' maps to a blank profession.");
});
}
[Fact]
public void Manual_professions_are_non_empty_and_distinct()
{
var professions = DiplomaRules.ManualProfessions();
Assert.NotEmpty(professions);
Assert.Equal(professions.Count, professions.Distinct().Count());
}
}
+1
View File
@@ -22,6 +22,7 @@ condensed, cross-linked curriculum.
| [architecture/0001-bff-lite-decision-dtos.md](reference/architecture/0001-bff-lite-decision-dtos.md) | ADR — BFF-lite endpoints + decision DTOs (backend decides, FE renders). |
| [architecture/0002-user-groups-and-bounded-contexts.md](reference/architecture/0002-user-groups-and-bounded-contexts.md) | ADR — user groups as actors; identity vs authorization. |
| [architecture/0003-cibg-huisstijl.md](reference/architecture/0003-cibg-huisstijl.md) | ADR — adopt CIBG Huisstijl (vendored Bootstrap 5.2) + the token bridge. |
| [architecture/0004-stamdata-as-code.md](reference/architecture/0004-stamdata-as-code.md) | ADR — business-tunable reference data as typed, compile-time-validated config (not a production DB). |
| [fp-tea-atomic-design.md](reference/fp-tea-atomic-design.md) | Long-form learning guide: FP + The Elm Architecture + atomic design. |
| [wcag-checklist.md](reference/wcag-checklist.md) | Manual WCAG checks automation can't catch (tab order, focus traps, reflow). |
| [ui-ux-audit.md](reference/ui-ux-audit.md) | Early UI/UX audit against NL Design System (predates ADR-0003 — read in that light). |
@@ -0,0 +1,81 @@
# ADR-0004 — Stamdata as code (config-as-code, not a production database)
Status: Accepted · Date: 2026-07-20
## Context
The business needs to control certain inputs that change over time — the clearest example
being **which professions link to which diplomas** (`geneeskunde → Arts`, …), but also
tunable thresholds, policy-question text, document-category definitions, and some letter
copy. Two hard constraints:
1. **No managing this through a production database.** A live admin surface writing DB rows
means a bad value ships silently and is discovered in production.
2. **Issues must be caught at compile time.** A change should be typed, reviewed, and
versioned before it can affect anyone.
The codebase already leans this way but had never named it as a pattern, and one key table
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
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
in with the rules that consume it, with **no cross-reference check**: a diploma whose
program wasn't in the map silently rendered `"Onbekend"`.
## Decision
Treat business-tunable reference data as **stamdata-as-code**: typed, checked-in
configuration, changed through the normal git → PR → build → deploy pipeline. Never a
production database, never runtime-editable.
1. **One home, typed.** Business-editable reference data lives in the
`BigRegister.Stamdata` namespace (`backend/src/BigRegister.Api/Stamdata/`), one file per
concern, as plain typed C# data (records / dictionaries). Separate the **data** (what the
business tunes) from the **rules** (dev-owned logic that consumes it): the profession
*table* is `Stamdata.Professions`; the *rule* "an English diploma needs a B2 question"
stays in `DiplomaRules`.
2. **Served unchanged.** The existing BFF-lite endpoints keep serving this data
(`/duo/diplomas`, `/intake/policy`, `/uploads/categories`, …). No frontend change — the
FE still renders decisions.
3. **Two gates.** The **C# compiler** catches shape and type mistakes. A build-time
**`StamdataValidationTests`** catches the referential integrity the compiler can't —
every seeded diploma program resolves to a real profession, no blank keys/values,
thresholds in range. CI runs it, so a bad edit fails the build and never merges.
4. **Business control = config-as-code (GitOps).** The business owns the content of these
files; a change is a reviewed edit, not a live DB write. A future low-code editor could
commit a PR on their behalf without changing this model (the compile-time gate stays).
### Where each kind of business-controllable thing lives
| Kind | Home | Gate |
| --- | --- | --- |
| Reference tables + tunable numbers (professions↔diplomas, thresholds, policy questions, document categories) | `Stamdata/` typed C# | compiler + `StamdataValidationTests` |
| User-facing UI copy | `$localize` → `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
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.
## Consequences
- **+** Every change is typed, reviewed, versioned, and rollback-able through git; zero
production-DB risk; a dangling reference fails the build with a clear message instead of
reaching users.
- **−** A change needs the PR pipeline — not instant, and a non-developer may need dev
assistance to edit C# (mitigated later by a low-code editor that emits a PR, or by a
data-file format if hand-editing ergonomics ever outweigh maximal compile-time safety).
- **Pilot shipped with this ADR:** the profession↔diploma map extracted to
`Stamdata/Professions.cs`, `DiplomaRules` refactored to consume it (behaviour unchanged),
and `StamdataValidationTests` added. Policy-question text and document-category
definitions follow the same pattern as obvious next steps; not moved yet.
@@ -333,6 +333,39 @@ stores the result. The route guard (`auth/auth.guard.ts`) just reads
`store.isAuthenticated()` and redirects to `/login` if you're not signed in.
Protected routes list `canActivate: [authGuard]` in `app.routes.ts`.
### 2g. Autosave — keystroke → model → debounced sync (not on blur)
A common assumption is "the form saves on blur." It doesn't. **Blur only marks a field
_touched_** so validation can show; it never writes the value or hits the network. In the
shared atoms, `(blur)="onTouched()"` is the `ControlValueAccessor` touched callback and
nothing more; the value is pushed on `(input)`, every keystroke
([`text-input.component.ts`](../../../src/app/shared/ui/text-input/text-input.component.ts):
`(input)` L29 → `onChange` L62, vs `(blur)="onTouched()"` L30).
The real flow has two stages, neither keyed on focus:
1. **Keystroke → Model.** A field binds `(ngModelChange)`/`(input)` and dispatches
`{ tag: 'SetField', key, value }`. The pure reducer stores it immediately — so the
Model is always current, on every keystroke, while editing.
([`herregistratie-wizard.component.ts`](../../../src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts)
L78 → [`herregistratie.machine.ts`](../../../src/app/herregistratie/domain/herregistratie.machine.ts)
L138-142, `setField`.)
2. **Model → backend (600 ms debounce).** A signal `effect` tracks the machine
`snapshot()`; each change resets a 600 ms timer whose callback does I/O **only** (it
never dispatches, so it can't livelock the store). On the first save it lazily creates
the application and stamps `?aanvraag=<id>` into the URL, so a reload resumes the draft.
([`draft-sync.ts`](../../../src/app/registratie/application/draft-sync.ts):
`DEBOUNCE_MS` L34, `effect` L102-108, `flush` L88-98 → `ApplicationsAdapter.syncDraft`.)
The **brief** context uses the same 600 ms idiom in its own store: `edit()` applies the
edit optimistically in the reducer and records an undo step, then `scheduleSave()` →
`flushSave()` flips a `saveState` (Saving/Saved/Error) and calls `adapter.save`
([`brief.store.ts`](../../../src/app/brief/application/brief.store.ts) L157-166, L192-209).
So it _feels_ like save-on-blur only because you usually stop typing when you leave a
field, and the debounce fires ~600 ms later. The trigger is **"stopped changing," not
"lost focus."** Submit is a separate, explicit action (§2d).
---
## 3. "Parse, don't validate" — value objects
@@ -469,6 +502,50 @@ Practical notes, kept lazy:
single place the wire format meets our types.
- Nothing else moves: `<app-async>`, the stores, and every page keep working unchanged.
### 6a. The request lifecycle today
The sketch above is the _rationale_; the shipped shape has since firmed up. The contract is
no longer hand-written DTOs — it's an **NSwag-generated typed client**
([`api-client.ts`](../../../src/app/shared/infrastructure/api-client.ts), regenerate with
`npm run gen:api` per [`nswag.json`](../../../nswag.json)) — and the boundary is a
`parse*` returning `Result` rather than `httpResource({ parse })`. End to end:
- **Proxy.** The app uses a relative base URL (`apiBaseUrl: ''`), so `/api` calls are
same-origin and `ng serve` proxies them to the backend on `:5000`.
([`environment.ts`](../../../src/environments/environment.ts),
[`proxy.conf.json`](../../../proxy.conf.json); `proxy.conf.docker.json` targets the
compose service.)
- **Client → HttpClient seam.** The NSwag client's `fetch` is routed through Angular's
`HttpClient` by `httpClientFetch` — the one place cross-cutting concerns live:
`X-Correlation-Id` on every call, `Idempotency-Key` on non-GETs, a 10 s timeout, and
GET-only retry. Routing through `HttpClient` is exactly what lets the interceptors see API
traffic. ([`api-client.provider.ts`](../../../src/app/shared/infrastructure/api-client.provider.ts):
`httpClientFetch` L47-82, `provideApiClient` L86-92; registered in
[`app.config.ts`](../../../src/app/app.config.ts) L37.)
- **Interceptors (dev-only, stripped in prod).** `scenario.interceptor.ts` (the `?scenario=`
toggle) and `role.interceptor.ts` (`X-Role` on role-aware endpoints).
**A read (dashboard):** `<app-async [data]="store.profile()">` →
[`BigProfileStore`](../../../src/app/registratie/application/big-profile.store.ts) →
`DashboardViewAdapter.dashboardViewResource()` = `resource({ loader: () =>
client.dashboardView() })`
([`dashboard-view.adapter.ts`](../../../src/app/registratie/infrastructure/dashboard-view.adapter.ts))
→ GET `/api/v1/dashboard-view` → `httpClientFetch` → proxy → backend → back through the
`parseDashboardView(json): Result` trust boundary → `RemoteData<DashboardView>` → rendered.
**A write (change address):** `runIfSubmitting()` (§2d) → `createSubmitChangeRequest`
([`submit-change-request.ts`](../../../src/app/registratie/application/submit-change-request.ts))
→ `runSubmit` — the one try/catch that mints the `Idempotency-Key` and maps RFC-7807
ProblemDetails → string ([`submit.ts`](../../../src/app/shared/application/submit.ts)) →
[`change-request.adapter.ts`](../../../src/app/registratie/infrastructure/change-request.adapter.ts)
→ POST `/api/v1/change-requests` → `ok(referentie)` / `err(detail)` → dispatch
`SubmitConfirmed` / `SubmitFailed`.
**Backend.** A single minimal-API host computes business decisions server-side (BFF-lite),
returns ProblemDetails on rule rejection, and dedupes replays via `Idempotency-Key`
([`Program.cs`](../../../backend/src/BigRegister.Api/Program.cs): `/dashboard-view` L80,
`/change-requests` L120).
---
## 7. Mini-glossary
+7 -1
View File
@@ -153,6 +153,11 @@ the *outcome*. Reducer = "what the new state is"; command = "go do it, then say
happened." And **derive, don't store** anything you can compute — e.g. a wizard's visible
steps are `visibleSteps(answers)`, not a stored field.
A field's value lands in the Model on **every keystroke** (not on blur — blur only marks
the field "touched"); a separate 600 ms debounce off the model snapshot autosaves the
draft to the backend, an effect that lives *outside* the reducer. See
`docs/reference/architecture/ARCHITECTURE.md` §2g.
**Do:** run `/form-machine` for a toy single field (say a "nickname" field with a max
length). Read the generated Model / Msg / reduce and its spec.
@@ -359,7 +364,8 @@ one is the **authority**?
**Go deeper:** `docs/reference/architecture/0001-bff-lite-decision-dtos.md`;
`docs/reference/fp-tea-atomic-design.md` Part 7 (the copy-paste recipes);
`docs/reference/architecture/ARCHITECTURE.md` §4. For how contexts scale to a second
`docs/reference/architecture/ARCHITECTURE.md` §4 (the recipe) and §6a (the full FE⇄BE
request lifecycle, read + write, with file links). For how contexts scale to a second
app and actor-based authorization, ADR-0002 (the advanced read).
---