From 8c54ede6ebdd55efbfa39c25f52fdc6988cb8cfc Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Thu, 23 Jul 2026 15:43:53 +0200 Subject: [PATCH] =?UTF-8?q?feat(showcase):=20PII=20section=20=E2=80=94=20m?= =?UTF-8?q?ask=20+=20elfproef=20parse=20(WP-42,=20mask/parse=20half)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 --- docs/project/backlog/README.md | 96 +++++++++---------- .../WP-42-privacy-security-showcase.md | 15 ++- scripts/gen-snippets.mjs | 2 + src/app/shared/kernel/bsn.ts | 4 +- src/app/shared/kernel/pii.ts | 2 + src/app/showcase/concepts.page.ts | 73 ++++++++++++++ src/app/showcase/snippets.generated.ts | 2 + 7 files changed, 144 insertions(+), 50 deletions(-) diff --git a/docs/project/backlog/README.md b/docs/project/backlog/README.md index 33b487d..5e27fdc 100644 --- a/docs/project/backlog/README.md +++ b/docs/project/backlog/README.md @@ -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 for its existing violations, so every WP ends green. -| WP | Title | Phase | Status | -| ---------------------------------------------- | --------------------------------------------------------------------------------- | --------------------------- | ------ | -| [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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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 | Title | Phase | Status | +| ---------------------------------------------- | --------------------------------------------------------------------------------- | --------------------------- | ------- | +| [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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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 | partial | +| [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-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 | 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 diff --git a/docs/project/backlog/WP-42-privacy-security-showcase.md b/docs/project/backlog/WP-42-privacy-security-showcase.md index 5a26b06..a7451f9 100644 --- a/docs/project/backlog/WP-42-privacy-security-showcase.md +++ b/docs/project/backlog/WP-42-privacy-security-showcase.md @@ -1,10 +1,23 @@ # WP-42 — Privacy & security showcase page -Status: todo +Status: partial — mask/parse showcase done; audit half pending WP-41 Phase: 8 — platform/DX/showcase Priority: P2 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 `` 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 Once the reusable privacy pieces exist (WP-40 masked-value atom + pure maskers, WP-41 persisted diff --git a/scripts/gen-snippets.mjs b/scripts/gen-snippets.mjs index 80b87ed..5058f43 100644 --- a/scripts/gen-snippets.mjs +++ b/scripts/gen-snippets.mjs @@ -14,6 +14,8 @@ const SOURCES = [ 'src/app/registratie/domain/value-objects/postcode.ts', 'src/app/registratie/domain/change-request.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-]+)/; diff --git a/src/app/shared/kernel/bsn.ts b/src/app/shared/kernel/bsn.ts index d9ec4cf..1afb2b9 100644 --- a/src/app/shared/kernel/bsn.ts +++ b/src/app/shared/kernel/bsn.ts @@ -11,6 +11,7 @@ export type Bsn = Brand; // 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]; +// #region showcase:parseBsn export function parseBsn(raw: string): Result { const t = raw.trim(); if (!/^\d{9}$/.test(t)) { @@ -22,5 +23,6 @@ export function parseBsn(raw: string): Result { $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 diff --git a/src/app/shared/kernel/pii.ts b/src/app/shared/kernel/pii.ts index 85b54f2..aeeb5f4 100644 --- a/src/app/shared/kernel/pii.ts +++ b/src/app/shared/kernel/pii.ts @@ -5,6 +5,7 @@ */ export const REDACTED = '‹redacted›'; +// #region showcase:mask /** Keep the last `keep` characters, mask the rest with `*`. */ export function maskTail(value: string, keep: number): string { 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 { return maskTail(value, 3); } +// #endregion showcase:mask diff --git a/src/app/showcase/concepts.page.ts b/src/app/showcase/concepts.page.ts index 6f65517..cb4eeee 100644 --- a/src/app/showcase/concepts.page.ts +++ b/src/app/showcase/concepts.page.ts @@ -11,6 +11,9 @@ import { HerregistratieWizardComponent } from '@herregistratie/ui/herregistratie import { IntakeWizardComponent } from '@herregistratie/ui/intake-wizard/intake-wizard.component'; import { Registration } from '@registratie/domain/registration'; 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 { highlightTs } from './highlight-ts'; @@ -39,6 +42,7 @@ function fakeResource(status: string, value?: T, error?: Error): Resource RegistrationSummaryComponent, HerregistratieWizardComponent, IntakeWizardComponent, + MaskedValueComponent, ], styles: [ ` @@ -371,6 +375,65 @@ function fakeResource(status: string, value?: T, error?: Error): Resource + + +
+ 6 · PII — maskeren & parsen +

+ 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 elfproef afdwingt. +

+
+
+

Maskeren — atom

+

+ BSN: + +

+
+

+              
↳ {{ src['mask'] }}
+
+

+ Standaard gemaskeerd; het echte tonen is step-up-geverifieerd én vastgelegd (zie het + behandelscherm). De atom bevat de maskeer-detectie — geen los *-gesnuffel + bij elke gebruiker. +

+
+
+

Parse (elfproef) → Result

+ + @let b = bsnParsed(); + @if (bsnRaw()) { +
+ @if (b.ok) { +

ok

+
Bsn ="{{ b.value }}"
+ } @else { +

err

+
{{ b.error }}
+ } +
+ } +
+

+              
↳ {{ src['parseBsn'] }}
+
+
+
+
`, }) @@ -394,6 +457,14 @@ export class ConceptsPage { raw = signal(''); 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). private readonly illustrations: Record = { unionBad: `interface Registration { @@ -419,5 +490,7 @@ errors = signal<...>({}); parse: 'registratie/domain/value-objects/postcode.ts', machine: 'registratie/domain/change-request.machine.ts', steps: 'herregistratie/domain/intake.machine.ts', + parseBsn: 'shared/kernel/bsn.ts', + mask: 'shared/kernel/pii.ts', }; } diff --git a/src/app/showcase/snippets.generated.ts b/src/app/showcase/snippets.generated.ts index dcbaac8..1ad0235 100644 --- a/src/app/showcase/snippets.generated.ts +++ b/src/app/showcase/snippets.generated.ts @@ -3,7 +3,9 @@ export const SNIPPETS: Record = { "fold": "/** Exhaustive fold: you must handle every case, checked at compile time. */\nexport function foldRemote(\n rd: RemoteData,\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 };", + "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 {\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 {\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'];", "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 };", };