feat(showcase): PII section — mask + elfproef parse (WP-42, mask/parse half)
CI / storybook-a11y (push) Successful in 5m3s
CI / frontend (push) Successful in 1m53s
CI / backend (push) Successful in 1m26s
CI / e2e (push) Successful in 2m46s
CI / semgrep (push) Successful in 59s
CI / api-client-drift (push) Successful in 2m7s

Add a "PII — maskeren & parsen" section to /concepts demonstrating the WP-40 pieces
with FP + atomic design, framed for AVG art. 9 / data-minimisation: a live
<app-masked-value> atom (masked BSN that reveals on click; real reveal is step-up +
audited in behandel-scherm) and a live parseBsn elfproef parse. Both show the real
linked source via the WP-39 snippet mechanism (new showcase regions in bsn.ts + pii.ts,
registered in gen-snippets.mjs). Delivers WP-42's showcase demo; the persisted-audit
half stays pending WP-41.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
eho
2026-07-23 15:43:53 +02:00
co-authored by Claude Opus 4.8
parent 62cb34b60f
commit 8c54ede6eb
7 changed files with 144 additions and 50 deletions
+48 -48
View File
@@ -43,54 +43,54 @@ WP-19's own file), so it's a separate manual/CI step, not chained into the other
Gates land before the work they cover; each lint rule lands in the same WP as the fixes Gates land before the work they cover; each lint rule lands in the same WP as the fixes
for its existing violations, so every WP ends green. for its existing violations, so every WP ends green.
| WP | Title | Phase | Status | | WP | Title | Phase | Status |
| ---------------------------------------------- | --------------------------------------------------------------------------------- | --------------------------- | ------ | | ---------------------------------------------- | --------------------------------------------------------------------------------- | --------------------------- | ------- |
| [WP-01](WP-01-axe-ci-gate.md) | Axe-on-every-story CI gate | 0 · gates | done | | [WP-01](WP-01-axe-ci-gate.md) | Axe-on-every-story CI gate | 0 · gates | done |
| [WP-02](WP-02-check-tokens.md) | Harden `check:tokens` + fix what it catches | 0 · gates | done | | [WP-02](WP-02-check-tokens.md) | Harden `check:tokens` + fix what it catches | 0 · gates | done |
| [WP-03](WP-03-contracts-purity.md) | Boundaries I: contracts purity + ApiClient confinement | 0 · gates | done | | [WP-03](WP-03-contracts-purity.md) | Boundaries I: contracts purity + ApiClient confinement | 0 · gates | done |
| [WP-04](WP-04-ui-not-infrastructure.md) | Boundaries II: `ui ↛ infrastructure` + showcase sanction | 0 · gates | done | | [WP-04](WP-04-ui-not-infrastructure.md) | Boundaries II: `ui ↛ infrastructure` + showcase sanction | 0 · gates | done |
| [WP-05](WP-05-parse-boundaries.md) | Parse-don't-validate closure + MDX | 1 · FP/DDD | done | | [WP-05](WP-05-parse-boundaries.md) | Parse-don't-validate closure + MDX | 1 · FP/DDD | done |
| [WP-06](WP-06-typed-async.md) | Generic async template contexts — kill `$any()` | 1 · FP/DDD | done | | [WP-06](WP-06-typed-async.md) | Generic async template contexts — kill `$any()` | 1 · FP/DDD | done |
| [WP-07](WP-07-brief-idioms.md) | Brief on the shared idioms + RemoteData MDX | 1 · FP/DDD | done | | [WP-07](WP-07-brief-idioms.md) | Brief on the shared idioms + RemoteData MDX | 1 · FP/DDD | done |
| [WP-08](WP-08-store-idiom.md) | One store idiom + machine naming + TEA MDX | 1 · FP/DDD | done | | [WP-08](WP-08-store-idiom.md) | One store idiom + machine naming + TEA MDX | 1 · FP/DDD | done |
| [WP-09](WP-09-pure-logic.md) | Pure-logic closure: dates + missing command specs | 1 · FP/DDD | done | | [WP-09](WP-09-pure-logic.md) | Pure-logic closure: dates + missing command specs | 1 · FP/DDD | done |
| [WP-10](WP-10-button-fidelity.md) | CIBG button fidelity | 2 · CIBG | done | | [WP-10](WP-10-button-fidelity.md) | CIBG button fidelity | 2 · CIBG | done |
| [WP-11](WP-11-markup-fidelity.md) | CIBG markup fidelity: application-link + absent-class triage | 2 · CIBG | done | | [WP-11](WP-11-markup-fidelity.md) | CIBG markup fidelity: application-link + absent-class triage | 2 · CIBG | done |
| [WP-12](WP-12-datablock.md) | CIBG Datablock for application data | 2 · CIBG | done | | [WP-12](WP-12-datablock.md) | CIBG Datablock for application data | 2 · CIBG | done |
| [WP-13](WP-13-cibg-gap-register.md) | CIBG-gap register + hygiene + MDX | 2 · CIBG | done | | [WP-13](WP-13-cibg-gap-register.md) | CIBG-gap register + hygiene + MDX | 2 · CIBG | done |
| [WP-14](WP-14-storybook-taxonomy.md) | Storybook taxonomy reorg + Layers MDX | 3 · Storybook | done | | [WP-14](WP-14-storybook-taxonomy.md) | Storybook taxonomy reorg + Layers MDX | 3 · Storybook | done |
| [WP-15](WP-15-missing-stories.md) | Missing stories: shell + brief components | 3 · Storybook | done | | [WP-15](WP-15-missing-stories.md) | Missing stories: shell + brief components | 3 · Storybook | done |
| [WP-16](WP-16-component-a11y.md) | Component a11y: description wiring + alert role | 4 · a11y | done | | [WP-16](WP-16-component-a11y.md) | Component a11y: description wiring + alert role | 4 · a11y | done |
| [WP-17](WP-17-app-a11y.md) | App-level a11y: route focus, template lint, WCAG checklist | 4 · a11y | done | | [WP-17](WP-17-app-a11y.md) | App-level a11y: route focus, template lint, WCAG checklist | 4 · a11y | done |
| [WP-18](WP-18-abac-capability-spine.md) | ABAC capability spine (Principal + capabilities, phase P1) | 5 · productie-volwassenheid | done | | [WP-18](WP-18-abac-capability-spine.md) | ABAC capability spine (Principal + capabilities, phase P1) | 5 · productie-volwassenheid | done |
| [WP-19](WP-19-e2e-smoke.md) | Playwright e2e smoke | 5 · productie-volwassenheid | done | | [WP-19](WP-19-e2e-smoke.md) | Playwright e2e smoke | 5 · productie-volwassenheid | done |
| [WP-20](WP-20-second-locale.md) | Second locale proof | 5 · productie-volwassenheid | done | | [WP-20](WP-20-second-locale.md) | Second locale proof | 5 · productie-volwassenheid | done |
| [WP-21](WP-21-resilience-seams.md) | Resilience seams (correlation-id, idempotency, retry) | 5 · productie-volwassenheid | done | | [WP-21](WP-21-resilience-seams.md) | Resilience seams (correlation-id, idempotency, retry) | 5 · productie-volwassenheid | done |
| [WP-22](WP-22-durable-persistence.md) | Durable persistence (optional tier) | 5 · productie-volwassenheid | done | | [WP-22](WP-22-durable-persistence.md) | Durable persistence (optional tier) | 5 · productie-volwassenheid | done |
| [WP-23](WP-23-org-template-backend.md) | Org-template backend + admin role | 6 · Brief v2 | done | | [WP-23](WP-23-org-template-backend.md) | Org-template backend + admin role | 6 · Brief v2 | done |
| [WP-24](WP-24-letter-canvas.md) | Letter canvas (edit on the letter) | 6 · Brief v2 | done | | [WP-24](WP-24-letter-canvas.md) | Letter canvas (edit on the letter) | 6 · Brief v2 | done |
| [WP-25](WP-25-letter-preview-html.md) | Server-rendered letter preview (HTML; PDF deferred) | 6 · Brief v2 | done | | [WP-25](WP-25-letter-preview-html.md) | Server-rendered letter preview (HTML; PDF deferred) | 6 · Brief v2 | done |
| [WP-26](WP-26-org-template-editor.md) | Admin org-template editor | 6 · Brief v2 | done | | [WP-26](WP-26-org-template-editor.md) | Admin org-template editor | 6 · Brief v2 | done |
| [WP-27](WP-27-brief-ux-layer.md) | Brief UX layer (undo/redo, standaardbrief, diff) | 6 · Brief v2 | done | | [WP-27](WP-27-brief-ux-layer.md) | Brief UX layer (undo/redo, standaardbrief, diff) | 6 · Brief v2 | done |
| [WP-28](WP-28-brief-v2-demo-polish.md) | Brief v2 demo polish (scenarios, e2e, docs) | 6 · Brief v2 | todo | | [WP-28](WP-28-brief-v2-demo-polish.md) | Brief v2 demo polish (scenarios, e2e, docs) | 6 · Brief v2 | todo |
| [WP-29](WP-29-stamdata-beheer-editor.md) | Stamdata beheer editor (low-code, PR-emitting) | follow-on · ADR-0004 | done | | [WP-29](WP-29-stamdata-beheer-editor.md) | Stamdata beheer editor (low-code, PR-emitting) | follow-on · ADR-0004 | done |
| [WP-30](WP-30-ci-perf-followups.md) | CI performance follow-ups (node_modules cache, runner image, path filters) | follow-on · CI/infra | todo | | [WP-30](WP-30-ci-perf-followups.md) | CI performance follow-ups (node_modules cache, runner image, path filters) | follow-on · CI/infra | todo |
| [WP-31](WP-31-shared-store-helpers.md) | Shared store helpers (ActionState/SaveState, history, debounced-save, RemoteData) | 7 · refinements | done | | [WP-31](WP-31-shared-store-helpers.md) | Shared store helpers (ActionState/SaveState, history, debounced-save, RemoteData) | 7 · refinements | done |
| [WP-32](WP-32-stamdata-undo.md) | Undo/redo in the stamdata editor | 7 · refinements | done | | [WP-32](WP-32-stamdata-undo.md) | Undo/redo in the stamdata editor | 7 · refinements | done |
| [WP-33](WP-33-dev-switchers.md) | In-app dev switchers (scenario + role) | 7 · refinements | done | | [WP-33](WP-33-dev-switchers.md) | In-app dev switchers (scenario + role) | 7 · refinements | done |
| [WP-34](WP-34-adres-phone-brp-readonly.md) | Adres: phone field + BRP address read-only | 7 · refinements | done | | [WP-34](WP-34-adres-phone-brp-readonly.md) | Adres: phone field + BRP address read-only | 7 · refinements | done |
| [WP-35](WP-35-one-concept-per-type.md) | One Concept per case type (server-enforced) | 7 · refinements | done | | [WP-35](WP-35-one-concept-per-type.md) | One Concept per case type (server-enforced) | 7 · refinements | done |
| [WP-36](WP-36-admin-cases.md) | Admin cases page + admin delete | 7 · refinements | done | | [WP-36](WP-36-admin-cases.md) | Admin cases page + admin delete | 7 · refinements | done |
| [WP-37](WP-37-dev-switcher-reset.md) | Dev-switcher reset fix (scenario/role URL param) | 8 · platform/DX/showcase | done | | [WP-37](WP-37-dev-switcher-reset.md) | Dev-switcher reset fix (scenario/role URL param) | 8 · platform/DX/showcase | done |
| [WP-38](WP-38-dependency-graph-boundaries.md) | Dependency graph + declarative boundaries (visualize + enforce) | 8 · platform/DX/showcase | done | | [WP-38](WP-38-dependency-graph-boundaries.md) | Dependency graph + declarative boundaries (visualize + enforce) | 8 · platform/DX/showcase | done |
| [WP-39](WP-39-showcase-snippets-animations.md) | Showcase: linked code snippets + teaching animations | 8 · platform/DX/showcase | done | | [WP-39](WP-39-showcase-snippets-animations.md) | Showcase: linked code snippets + teaching animations | 8 · platform/DX/showcase | done |
| [WP-40](WP-40-pii-kernel.md) | PII kernel: branded `Bsn` VO (elfproef) + masked-value atom | 8 · platform/DX/showcase | done | | [WP-40](WP-40-pii-kernel.md) | PII kernel: branded `Bsn` VO (elfproef) + masked-value atom | 8 · platform/DX/showcase | done |
| [WP-41](WP-41-persisted-authz-audit.md) | Persisted, queryable authz/PII-reveal audit (no PII) | 8 · platform/DX/showcase | todo | | [WP-41](WP-41-persisted-authz-audit.md) | Persisted, queryable authz/PII-reveal audit (no PII) | 8 · platform/DX/showcase | todo |
| [WP-42](WP-42-privacy-security-showcase.md) | Privacy & security showcase page (mask + no-PII log) | 8 · platform/DX/showcase | todo | | [WP-42](WP-42-privacy-security-showcase.md) | Privacy & security showcase page (mask + no-PII log) | 8 · platform/DX/showcase | partial |
| [WP-43](WP-43-scaffold-generators.md) | Runnable generators: value-object / form-machine / bff-endpoint / ui-component | 8 · platform/DX/showcase | todo | | [WP-43](WP-43-scaffold-generators.md) | Runnable generators: value-object / form-machine / bff-endpoint / ui-component | 8 · platform/DX/showcase | todo |
| [WP-44](WP-44-context-generator.md) | Runnable generator: `gen:context` | 8 · platform/DX/showcase | todo | | [WP-44](WP-44-context-generator.md) | Runnable generator: `gen:context` | 8 · platform/DX/showcase | todo |
| [WP-45](WP-45-create-ssp-generator.md) | `create-ssp` bootstrap generator (mechanise new-ssp) | 8 · platform/DX/showcase | todo | | [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-46](WP-46-vitest-coverage.md) | Vitest coverage (report + report-only thresholds) | 8 · platform/DX/showcase | done |
Sequencing dependencies (stated in the WPs too): 01 before 1015 (axe covers story churn); 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 03/04 before 0509 (boundaries stop new violations during refactors); 06 before 07 (typed
@@ -1,10 +1,23 @@
# WP-42 — Privacy & security showcase page # WP-42 — Privacy & security showcase page
Status: todo Status: partial — mask/parse showcase done; audit half pending WP-41
Phase: 8 — platform/DX/showcase Phase: 8 — platform/DX/showcase
Priority: P2 Priority: P2
Depends on: WP-40, WP-41 Depends on: WP-40, WP-41
## Outcome (mask/parse half — done, on user request ahead of WP-41)
Added a "6 · PII — maskeren & parsen" section to `/concepts` demonstrating the WP-40 pieces with
FP + atomic design, framed for AVG art. 9 / data-minimisation: a live `<app-masked-value>` atom
(masked-by-default BSN that reveals on click; note points to the real step-up + audited reveal in
behandel-scherm) and a live `parseBsn` elfproef parse mirroring the postcode demo. Both show the
real linked source via the WP-39 snippet mechanism (`// #region showcase:parseBsn` in bsn.ts,
`showcase:mask` in pii.ts, registered in `gen-snippets.mjs``snippets.generated.ts`, drift-gated).
No i18n (showcase is Dutch-only teaching text). No behaviour change outside the showcase.
**Still pending (needs WP-41):** the "log PII / no-PII audit trail" half — visualizing the
persisted authz/reveal audit — plus an optional Foundations MDX writeup.
## Why ## Why
Once the reusable privacy pieces exist (WP-40 masked-value atom + pure maskers, WP-41 persisted Once the reusable privacy pieces exist (WP-40 masked-value atom + pure maskers, WP-41 persisted
+2
View File
@@ -14,6 +14,8 @@ const SOURCES = [
'src/app/registratie/domain/value-objects/postcode.ts', 'src/app/registratie/domain/value-objects/postcode.ts',
'src/app/registratie/domain/change-request.machine.ts', 'src/app/registratie/domain/change-request.machine.ts',
'src/app/herregistratie/domain/intake.machine.ts', 'src/app/herregistratie/domain/intake.machine.ts',
'src/app/shared/kernel/bsn.ts',
'src/app/shared/kernel/pii.ts',
]; ];
const START = /\/\/\s*#region showcase:([\w-]+)/; const START = /\/\/\s*#region showcase:([\w-]+)/;
+3 -1
View File
@@ -11,6 +11,7 @@ export type Bsn = Brand<string, 'Bsn'>;
// Positional weights for the elfproef: 9·d1 + 8·d2 + … + 2·d8 1·d9 ≡ 0 (mod 11). // Positional weights for the elfproef: 9·d1 + 8·d2 + … + 2·d8 1·d9 ≡ 0 (mod 11).
const WEIGHTS = [9, 8, 7, 6, 5, 4, 3, 2, -1]; const WEIGHTS = [9, 8, 7, 6, 5, 4, 3, 2, -1];
// #region showcase:parseBsn
export function parseBsn(raw: string): Result<string, Bsn> { export function parseBsn(raw: string): Result<string, Bsn> {
const t = raw.trim(); const t = raw.trim();
if (!/^\d{9}$/.test(t)) { if (!/^\d{9}$/.test(t)) {
@@ -22,5 +23,6 @@ export function parseBsn(raw: string): Result<string, Bsn> {
$localize`:@@validation.bsnElfproef:Dit is geen geldig BSN (klopt niet met de elfproef).`, $localize`:@@validation.bsnElfproef:Dit is geen geldig BSN (klopt niet met de elfproef).`,
); );
} }
return ok(t as Bsn); return ok(t as Bsn); // holding a Bsn is proof it passed the elfproef
} }
// #endregion showcase:parseBsn
+2
View File
@@ -5,6 +5,7 @@
*/ */
export const REDACTED = 'redacted'; export const REDACTED = 'redacted';
// #region showcase:mask
/** Keep the last `keep` characters, mask the rest with `*`. */ /** Keep the last `keep` characters, mask the rest with `*`. */
export function maskTail(value: string, keep: number): string { export function maskTail(value: string, keep: number): string {
if (value.length <= keep) return '*'.repeat(value.length); if (value.length <= keep) return '*'.repeat(value.length);
@@ -15,3 +16,4 @@ export function maskTail(value: string, keep: number): string {
export function maskBsn(value: string): string { export function maskBsn(value: string): string {
return maskTail(value, 3); return maskTail(value, 3);
} }
// #endregion showcase:mask
+73
View File
@@ -11,6 +11,9 @@ import { HerregistratieWizardComponent } from '@herregistratie/ui/herregistratie
import { IntakeWizardComponent } from '@herregistratie/ui/intake-wizard/intake-wizard.component'; import { IntakeWizardComponent } from '@herregistratie/ui/intake-wizard/intake-wizard.component';
import { Registration } from '@registratie/domain/registration'; import { Registration } from '@registratie/domain/registration';
import { parsePostcode } from '@registratie/domain/value-objects/postcode'; import { parsePostcode } from '@registratie/domain/value-objects/postcode';
import { parseBsn } from '@shared/kernel/bsn';
import { maskBsn } from '@shared/kernel/pii';
import { MaskedValueComponent } from '@shared/ui/masked-value/masked-value.component';
import { SNIPPETS } from './snippets.generated'; import { SNIPPETS } from './snippets.generated';
import { highlightTs } from './highlight-ts'; import { highlightTs } from './highlight-ts';
@@ -39,6 +42,7 @@ function fakeResource<T>(status: string, value?: T, error?: Error): Resource<T>
RegistrationSummaryComponent, RegistrationSummaryComponent,
HerregistratieWizardComponent, HerregistratieWizardComponent,
IntakeWizardComponent, IntakeWizardComponent,
MaskedValueComponent,
], ],
styles: [ styles: [
` `
@@ -371,6 +375,65 @@ function fakeResource<T>(status: string, value?: T, error?: Error): Resource<T>
</div> </div>
</div> </div>
</section> </section>
<!-- 6. PII: mask + parse -->
<section class="section">
<app-heading [level]="2">6 · PII — maskeren & parsen</app-heading>
<p class="lead">
Een BSN is bijzondere persoonsgegevens (AVG art. 9). Dataminimalisatie: standaard
gemaskeerd tonen, alleen tonen na een vastgelegde handeling; en "parse, don't validate" op
het gevoeligste veld — een pure functie die de <em>elfproef</em> afdwingt.
</p>
<div class="cols">
<div class="card card--good">
<p class="tag good">Maskeren — atom</p>
<p>
BSN:
<app-masked-value
[value]="bsnShown()"
[canReveal]="true"
revealLabel="Toon BSN"
(reveal)="bsnRevealed.set(true)"
/>
</p>
<figure class="linked">
<pre [innerHTML]="code['mask']"></pre>
<figcaption class="src">↳ {{ src['mask'] }}</figcaption>
</figure>
<p class="note">
Standaard gemaskeerd; het echte tonen is step-up-geverifieerd én vastgelegd (zie het
behandelscherm). De atom bevat de maskeer-detectie — geen los <code>*</code>-gesnuffel
bij elke gebruiker.
</p>
</div>
<div class="card">
<p class="tag good">Parse (elfproef) → Result</p>
<app-text-input
inputId="bsn"
[ngModel]="bsnRaw()"
(ngModelChange)="bsnRaw.set($event)"
name="bsn"
placeholder="Typ een BSN, bijv. 123456782"
/>
@let b = bsnParsed();
@if (bsnRaw()) {
<div animate.enter="app-item-enter">
@if (b.ok) {
<p class="tag good">ok</p>
<pre>Bsn ="{{ b.value }}"</pre>
} @else {
<p class="tag bad">err</p>
<pre>{{ b.error }}</pre>
}
</div>
}
<figure class="linked">
<pre [innerHTML]="code['parseBsn']"></pre>
<figcaption class="src">↳ {{ src['parseBsn'] }}</figcaption>
</figure>
</div>
</div>
</section>
</app-page-shell> </app-page-shell>
`, `,
}) })
@@ -394,6 +457,14 @@ export class ConceptsPage {
raw = signal(''); raw = signal('');
parsed = computed(() => parsePostcode(this.raw())); parsed = computed(() => parsePostcode(this.raw()));
// 6 · PII demo. Masked-by-default value that reveals locally (the real reveal is
// step-up-gated + audited elsewhere); plus a live elfproef parse mirroring the postcode demo.
demoBsn = '123456782';
bsnRevealed = signal(false);
bsnShown = computed(() => (this.bsnRevealed() ? this.demoBsn : maskBsn(this.demoBsn)));
bsnRaw = signal('');
bsnParsed = computed(() => parseBsn(this.bsnRaw()));
// Deliberately-wrong illustrations (no real source to link — they show the anti-pattern). // Deliberately-wrong illustrations (no real source to link — they show the anti-pattern).
private readonly illustrations: Record<string, string> = { private readonly illustrations: Record<string, string> = {
unionBad: `interface Registration { unionBad: `interface Registration {
@@ -419,5 +490,7 @@ errors = signal<...>({});
parse: 'registratie/domain/value-objects/postcode.ts', parse: 'registratie/domain/value-objects/postcode.ts',
machine: 'registratie/domain/change-request.machine.ts', machine: 'registratie/domain/change-request.machine.ts',
steps: 'herregistratie/domain/intake.machine.ts', steps: 'herregistratie/domain/intake.machine.ts',
parseBsn: 'shared/kernel/bsn.ts',
mask: 'shared/kernel/pii.ts',
}; };
} }
+2
View File
@@ -3,7 +3,9 @@
export const SNIPPETS: Record<string, string> = { export const SNIPPETS: Record<string, string> = {
"fold": "/** Exhaustive fold: you must handle every case, checked at compile time. */\nexport function foldRemote<E, T, R>(\n rd: RemoteData<E, T>,\n h: { loading: () => R; empty: () => R; failure: (e: E) => R; success: (v: T) => R },\n): R {\n switch (rd.tag) {\n case 'Loading':\n return h.loading();\n case 'Empty':\n return h.empty();\n case 'Failure':\n return h.failure(rd.error);\n case 'Success':\n return h.success(rd.value);\n default:\n return assertNever(rd); // add a variant → compile error until handled\n }\n}", "fold": "/** Exhaustive fold: you must handle every case, checked at compile time. */\nexport function foldRemote<E, T, R>(\n rd: RemoteData<E, T>,\n h: { loading: () => R; empty: () => R; failure: (e: E) => R; success: (v: T) => R },\n): R {\n switch (rd.tag) {\n case 'Loading':\n return h.loading();\n case 'Empty':\n return h.empty();\n case 'Failure':\n return h.failure(rd.error);\n case 'Success':\n return h.success(rd.value);\n default:\n return assertNever(rd); // add a variant → compile error until handled\n }\n}",
"machine": "export type ChangeRequestState =\n | { tag: 'Editing'; draft: Draft; errors: Errors } // draft/errors exist ONLY while editing\n | { tag: 'Submitting'; data: Valid } // carries the parsed value, no errors\n | { tag: 'Submitted'; data: Valid; referentie: string }\n | { tag: 'Failed'; data: Valid; error: string };", "machine": "export type ChangeRequestState =\n | { tag: 'Editing'; draft: Draft; errors: Errors } // draft/errors exist ONLY while editing\n | { tag: 'Submitting'; data: Valid } // carries the parsed value, no errors\n | { tag: 'Submitted'; data: Valid; referentie: string }\n | { tag: 'Failed'; data: Valid; error: string };",
"mask": "/** Keep the last `keep` characters, mask the rest with `*`. */\nexport function maskTail(value: string, keep: number): string {\n if (value.length <= keep) return '*'.repeat(value.length);\n return '*'.repeat(value.length - keep) + value.slice(-keep);\n}\n\n/** Mask a BSN / BIG-nummer for display: keep the last 3 digits, mask the rest. */\nexport function maskBsn(value: string): string {\n return maskTail(value, 3);\n}",
"parse": "export function parsePostcode(raw: string): Result<string, Postcode> {\n const t = raw.trim().toUpperCase();\n if (!/^[1-9]\\d{3}\\s?[A-Z]{2}$/.test(t)) {\n return err($localize`:@@validation.postcode:Voer een geldige postcode in, bijv. 1234 AB.`);\n }\n // Normalise to \"1234 AB\" — the parser also cleans up.\n return ok(t.replace(/^(\\d{4})\\s?([A-Z]{2})$/, '$1 $2') as Postcode);\n}", "parse": "export function parsePostcode(raw: string): Result<string, Postcode> {\n const t = raw.trim().toUpperCase();\n if (!/^[1-9]\\d{3}\\s?[A-Z]{2}$/.test(t)) {\n return err($localize`:@@validation.postcode:Voer een geldige postcode in, bijv. 1234 AB.`);\n }\n // Normalise to \"1234 AB\" — the parser also cleans up.\n return ok(t.replace(/^(\\d{4})\\s?([A-Z]{2})$/, '$1 $2') as Postcode);\n}",
"parseBsn": "export function parseBsn(raw: string): Result<string, Bsn> {\n const t = raw.trim();\n if (!/^\\d{9}$/.test(t)) {\n return err($localize`:@@validation.bsn:Voer een geldig BSN van 9 cijfers in.`);\n }\n const sum = [...t].reduce((acc, ch, i) => acc + Number(ch) * WEIGHTS[i], 0);\n if (t === '000000000' || sum % 11 !== 0) {\n return err(\n $localize`:@@validation.bsnElfproef:Dit is geen geldig BSN (klopt niet met de elfproef).`,\n );\n }\n return ok(t as Bsn); // holding a Bsn is proof it passed the elfproef\n}",
"steps": "/** The fixed step list. Number of steps never changes; questions reveal inline. */\nexport const STEPS: StepId[] = ['buitenland', 'werk', 'review'];", "steps": "/** The fixed step list. Number of steps never changes; questions reveal inline. */\nexport const STEPS: StepId[] = ['buitenland', 'werk', 'review'];",
"union": "export type RegistrationStatus =\n | { tag: 'Geregistreerd'; herregistratieDatum: string } // only this variant carries the date\n | { tag: 'Geschorst'; geschorstTot: string; reden: string }\n | { tag: 'Doorgehaald'; doorgehaaldOp: string; reden: string };", "union": "export type RegistrationStatus =\n | { tag: 'Geregistreerd'; herregistratieDatum: string } // only this variant carries the date\n | { tag: 'Geschorst'; geschorstTot: string; reden: string }\n | { tag: 'Doorgehaald'; doorgehaaldOp: string; reden: string };",
}; };