feat(beheer): stamdata deletion protection — CI referential gate + editor expire/warn
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:
@@ -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]
|
||||
|
||||
@@ -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 10–15 (axe covers story churn);
|
||||
03/04 before 05–09 (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`;
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user