feat(beheer): stamdata deletion protection — CI referential gate + editor expire/warn
CI / frontend (push) Successful in 2m27s
CI / backend (push) Successful in 2m3s
CI / storybook-a11y (push) Failing after 6m30s
CI / semgrep (push) Successful in 1m5s
CI / e2e (push) Successful in 3m22s
CI / api-client-drift (push) Successful in 2m10s

CI gate (authoritative): generalize the dangling-reference test in StamdataValidationTests
into a declared, extensible reference list ("every declared reference into a stamdata key
resolves against the currently-valid stamdata"), starting with Diploma.Opleiding →
professions.program. Removing/renaming a referenced program, or expiring it while current
data still references it, fails the PR build (ADR-0004). Editor (fast feedback): confirm
before delete (warns a referenced row fails CI) and, for temporal tables, a "Sluiten per
vandaag" action that closes validity (geldigTot) — steering to expire over hard delete.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
eho
2026-07-23 22:35:17 +02:00
co-authored by Claude Opus 4.8
parent 67802c68b4
commit fbc4bf51d0
6 changed files with 122 additions and 22 deletions
@@ -11,14 +11,31 @@ namespace BigRegister.Tests;
/// </summary>
public class StamdataValidationTests
{
[Fact]
public void Every_seeded_diploma_program_maps_to_a_known_profession()
/// Declared references INTO stamdata keys — the FK-like invariants the build gate enforces
/// (WP-48). Add an entry when a consumer starts depending on a stamdata key; the gate then
/// fails a delete/rename/expire that orphans it. Resolvers use the "valid today" views, so
/// expiring a row (geldigTot in the past) that current data still references also fails —
/// which steers the editor toward closing validity only once nothing current relies on it.
private sealed record StamdataRef(string Description, IEnumerable<string> Keys, Func<string, bool> Resolves);
private static readonly IReadOnlyList<StamdataRef> References = new[]
{
// 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.");
new StamdataRef(
"Diploma.Opleiding → professions.program (valid today)",
SeedData.Diplomas.Select(d => d.Opleiding),
key => Professions.ByProgram.ContainsKey(key)),
};
[Fact]
public void Every_declared_reference_into_stamdata_resolves()
{
// The dangling-reference guard (generalized): a referenced key with no (currently valid)
// stamdata row would silently break its consumer. Fail the build instead of prod.
foreach (var r in References)
foreach (var key in r.Keys)
Assert.True(r.Resolves(key),
$"Dangling stamdata reference [{r.Description}]: '{key}' no longer resolves — " +
"deleting or expiring the referenced row would break it.");
}
[Fact]
+1
View File
@@ -92,6 +92,7 @@ for its existing violations, so every WP ends green.
| [WP-45](WP-45-create-ssp-generator.md) | `create-ssp` bootstrap generator (mechanise new-ssp) | 8 · platform/DX/showcase | todo |
| [WP-46](WP-46-vitest-coverage.md) | Vitest coverage (report + report-only thresholds) | 8 · platform/DX/showcase | done |
| [WP-47](WP-47-feature-flags.md) | Runtime feature flags (catalog-in-code, admin toggle, FE+backend) | 8 · platform/DX/showcase | done |
| [WP-48](WP-48-stamdata-deletion-protection.md) | Stamdata deletion protection (CI referential gate + editor expire/warn) | 8 · platform/DX/showcase | done |
Sequencing dependencies (stated in the WPs too): 01 before 1015 (axe covers story churn);
03/04 before 0509 (boundaries stop new violations during refactors); 06 before 07 (typed
@@ -0,0 +1,40 @@
# WP-48 — Stamdata deletion protection (referential integrity)
Status: done
Phase: 8 — platform/DX/showcase
## Why
Deleting a stamdata row that something relies on (e.g. a `professions.program` a diploma maps
through) would silently break behaviour. Stamdata is config-as-code (PR-applied, CI-gated), so the
authoritative guard belongs at the build gate; the editor gets a fast-feedback nudge.
## Decisions (locked with the user)
- **CI gate (authoritative) + editor warning (fast feedback).**
- **Steer temporal rows toward expiring** (set `geldigTot`) over hard delete.
## Outcome
- **CI gate:** generalized the dangling-reference test in `StamdataValidationTests` into a declared,
extensible reference list (`StamdataRef` records) — "every declared reference into a stamdata key
resolves against the currently-valid stamdata." Today one entry: `Diploma.Opleiding →
professions.program (valid today)`. Resolvers use the "valid today" view (`Professions.ByProgram`),
so removing/renaming a referenced program OR expiring it while current data still references it
**fails the PR build**; expiring once nothing current relies on it passes. Adding a future FK is
one list entry.
- **Editor (fast feedback):** `stamdata-table-editor` now confirms before delete (`@@beheer.removeConfirm`
— warns that a referenced row fails CI and, for a dated table, to close validity instead) and, for
**temporal** tables, adds a **"Sluiten per vandaag"** action that sets `geldigTot` to today
(reusing `CellEdited`) — steering to expire over hard delete. CI stays the authority.
## Acceptance criteria
- [x] A delete/expire that orphans a declared reference fails the build gate (existing seed passes).
- [x] Editor confirms deletes and offers expire (close validity) for temporal tables.
- [x] `npm run ci` green (backend `dotnet test`, localized build).
## Deferred (noted)
A per-row "referenced" hint in the editor DTO (server-computed usage) — would let the editor warn on
the _specific_ referenced rows rather than a generic confirm. Not needed for the authoritative gate.
@@ -145,10 +145,15 @@ interface DisplayRow {
}
<td>
@if (!previewing()) {
@if (table().temporal) {
<app-button variant="subtle" (click)="onExpire(item.index)">{{
expireLabel
}}</app-button>
}
<app-button
variant="subtle"
[attr.aria-label]="removeLabel"
(click)="rowRemoved.emit(item.index)"
(click)="onRemove(item.index)"
>{{ removeLabel }}</app-button
>
}
@@ -234,6 +239,21 @@ export class StamdataTableEditorComponent {
protected previewNote = $localize`:@@beheer.previewNote:Voorbeeld: alleen de rijen die op deze datum geldig zijn. Bewerken staat uit.`;
protected actionsLabel = $localize`:@@beheer.actions:Acties`;
protected removeLabel = $localize`:@@beheer.remove:Verwijderen`;
protected expireLabel = $localize`:@@beheer.expire:Sluiten per vandaag`;
private removeConfirm = $localize`:@@beheer.removeConfirm:Rij verwijderen? Als andere gegevens ernaar verwijzen, faalt de build-controle (CI). Bij een tabel met een geldigheidsperiode kunt u de rij beter sluiten (geldig tot) in plaats van verwijderen.`;
/** Deletions can orphan a reference (the CI gate catches it); confirm first (WP-48). */
protected onRemove(index: number) {
if (confirm(this.removeConfirm)) this.rowRemoved.emit(index);
}
/** Steer temporal tables toward expiring (close the validity per today) over hard delete —
preserves history and can't orphan a reference that was valid earlier (WP-48). */
protected onExpire(index: number) {
const col = this.table().columns.find((c) => /geldigtot/i.test(c.name));
if (col) this.cellEdited.emit({ row: index, column: col.name, value: this.today });
}
private today = new Date().toISOString().slice(0, 10);
protected undoLabel = $localize`:@@beheer.undo:Ongedaan maken`;
protected redoLabel = $localize`:@@beheer.redo:Opnieuw uitvoeren`;
protected addRowLabel = $localize`:@@beheer.addRow:Rij toevoegen`;
+8
View File
@@ -3016,6 +3016,14 @@
<context context-type="linenumber">226</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.expire" datatype="html">
<source>Sluiten per vandaag</source>
<target datatype="html">Close as of today</target>
</trans-unit>
<trans-unit id="beheer.removeConfirm" datatype="html">
<source>Rij verwijderen? Als andere gegevens ernaar verwijzen, faalt de build-controle (CI). Bij een tabel met een geldigheidsperiode kunt u de rij beter sluiten (geldig tot) in plaats van verwijderen.</source>
<target datatype="html">Delete this row? If other data references it, the build check (CI) will fail. For a table with a validity period, prefer closing the row (valid until) over deleting it.</target>
</trans-unit>
<trans-unit id="beheer.addRow" datatype="html">
<source>Rij toevoegen</source>
<target datatype="html">Add row</target>
+28 -14
View File
@@ -245,98 +245,112 @@
<source>toegevoegd</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">223</context>
<context context-type="linenumber">228</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.edited" datatype="html">
<source>gewijzigd</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">224</context>
<context context-type="linenumber">229</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.removed" datatype="html">
<source>verwijderd</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">225</context>
<context context-type="linenumber">230</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.table" datatype="html">
<source>Tabel</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">231</context>
<context context-type="linenumber">236</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.peildatum" datatype="html">
<source>Toon geldig op</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">232</context>
<context context-type="linenumber">237</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.showAll" datatype="html">
<source>Toon alles</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">233</context>
<context context-type="linenumber">238</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.previewNote" datatype="html">
<source>Voorbeeld: alleen de rijen die op deze datum geldig zijn. Bewerken staat uit.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">234</context>
<context context-type="linenumber">239</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.actions" datatype="html">
<source>Acties</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">235</context>
<context context-type="linenumber">240</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.remove" datatype="html">
<source>Verwijderen</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">236</context>
<context context-type="linenumber">241</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.expire" datatype="html">
<source>Sluiten per vandaag</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">242</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.removeConfirm" datatype="html">
<source>Rij verwijderen? Als andere gegevens ernaar verwijzen, faalt de build-controle (CI). Bij een tabel met een geldigheidsperiode kunt u de rij beter sluiten (geldig tot) in plaats van verwijderen.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">243</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.undo" datatype="html">
<source>Ongedaan maken</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">237</context>
<context context-type="linenumber">257</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.redo" datatype="html">
<source>Opnieuw uitvoeren</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">238</context>
<context context-type="linenumber">258</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.addRow" datatype="html">
<source>Rij toevoegen</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">239</context>
<context context-type="linenumber">259</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.download" datatype="html">
<source>Download JSON</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">240</context>
<context context-type="linenumber">260</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.applyHint" datatype="html">
<source>Wijzigingen worden als JSON-bestand gedownload en via een pull request toegepast — de build (CI) controleert ze.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">241</context>
<context context-type="linenumber">261</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.page.heading" datatype="html">