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>
128 lines
9.4 KiB
Markdown
128 lines
9.4 KiB
Markdown
# 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`** (`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
|
||
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<T>` 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` → `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: operational configuration
|
||
|
||
"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
|
||
|
||
- **+** 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.
|