# 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. A table lives **either** as plain typed C# data (records / dictionaries) **or** as a typed JSON data-file deserialized into a record (`professions.json` → `ProfessionMapping`, loaded via `StamdataFile`). Both are checked-in config-as-code, gated the same way; the data-file trades the compiler's *value* check (gate #1 sees only the shape, not a wrong `beroep`) for hand-editing ergonomics and the low-code editor below — the value gate becomes `StamdataValidationTests`. 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`. Tables may carry **valid-time** (`geldigVan`/`geldigTot`, half-open `[van, tot)`); `StamdataCatalog` + `StamdataTable.Of` describe every table generically (columns reflected from the record) so one endpoint pair and one grid editor serve all of them. 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# **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`) | | 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). - **Shipped with this ADR:** the profession↔diploma map (`Stamdata/Professions.cs`) and the policy-question wording (`Stamdata/PolicyQuestions.cs`) extracted from `DiplomaRules`, which now consumes both (behaviour unchanged), guarded by `StamdataValidationTests`. Document-category definitions follow the same pattern as the obvious next step; not moved yet. - **Shipped as a follow-on (WP-29):** the "future low-code editor" and "data-file format" this ADR floated are now real. `professions` moved to `professions.json` (typed, valid-timed) and the generic `StamdataCatalog`/`StamdataTable`/`StamdataFile` model plus read-only, admin-gated `GET /stamdata` endpoints back an Angular `beheer/stamdata` editor. It is **not** a runtime write path: the admin edits a grid and downloads the edited JSON to commit as a reviewed PR — the compile/validation gate stays the authority, so this ADR's core decision is unchanged.