Files
ehoandClaude Opus 5 25a5d415a5 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>
2026-08-27 18:29:05 +02:00

9.4 KiB
Raw Permalink Blame History

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.jsonProfessionMapping, 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 $localizeapps/<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 subOrgIdnull → the endpoint 404s one sub-organisation's letterhead OrgAdminorgtemplate:edit
FeatureFlagStore (WP-47) Domain/Features/FeatureFlags.Catalog unknown key → Set returns false (404); IsEnabled → false an on/off rollout switch FlagsAdminflags: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.