diff --git a/.prettierignore b/.prettierignore
index 710a602..deed558 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -11,6 +11,7 @@ package-lock.json
documentation.json
libs/shared/src/infrastructure/api-client.ts
apps/ssp/src/app/showcase/snippets.generated.ts
+libs/shared/docs/behaviour-spec.mdx
# Vendored design system (CIBG Huisstijl)
public/cibg-huisstijl/
diff --git a/libs/shared/docs/behaviour-spec.mdx b/libs/shared/docs/behaviour-spec.mdx
new file mode 100644
index 0000000..52edd58
--- /dev/null
+++ b/libs/shared/docs/behaviour-spec.mdx
@@ -0,0 +1,1093 @@
+{/* GENERATED by `npm run gen:behaviour-spec` (scripts/gen-behaviour-spec.mjs) — do not
+edit. Every bullet below is a real `it()` title or backend test method name, extracted
+verbatim from the suite. The team rejected Cucumber/Gherkin for BDD scenarios (runtime string
+matching undoes the compile-time guarantees WP-70 bought, and needs two frameworks for
+.NET+TS) — this page is the replacement: business-readable documentation generated FROM test
+names, so it can never drift from what the suite actually asserts. A test name changing (or a
+test being added/removed) is the only way this page changes; hand-editing it is pointless,
+the next `npm run gen:behaviour-spec` overwrites it. */}
+
+import { Meta } from '@storybook/addon-docs/blocks';
+
+
+
+# Behaviour spec
+
+_Generated by `npm run gen:behaviour-spec` — do not hand-edit; the next generation
+overwrites this page. See [BDD](?path=/docs/foundations-bdd--docs) for how these names are
+written, and [Testing strategy](?path=/docs/foundations-testing-strategy--docs) for what gets
+tested where._
+
+Every bullet below is a real test name from the suite — an `it()` title (frontend) or a test
+method name (backend), read as a sentence. Nothing here is hand-written prose: this page
+**is** the suite, reshaped for a business reader. 401 frontend behaviours across
+8 contexts; 206 backend behaviours across 33 test
+classes.
+
+## Frontend (by context)
+
+### auth
+
+#### authGuard
+
+- allows an authenticated user
+- redirects an anonymous user to /login
+- allows an authenticated user
+- redirects an anonymous user to /login
+
+#### capabilityGuard
+
+- waits for /me, then allows an entitled admin
+- sends an authenticated-but-unentitled user to /dashboard (not a login loop)
+- redirects an anonymous user to /login without waiting for caps
+- waits for /me, then allows an entitled admin
+- sends an authenticated-but-unentitled user to /dashboard (not a login loop)
+- redirects an anonymous user to /login without waiting for caps
+
+#### isAuthenticated
+
+- narrows a present session to Session
+- reports no session as not authenticated
+- narrows a present session to Session
+- reports no session as not authenticated
+
+### behandeling
+
+#### besluit reduce
+
+- SetField updates the draft while editing
+- Submit with no besluit chosen stays Editing and reports a field error
+- Submit Afwijzen without a toelichting stays Editing and reports a field error
+- Submit Goedkeuren with no toelichting moves to Submitting (optional there)
+- Submit Afwijzen with a toelichting moves to Submitting with the trimmed value
+- SubmitConfirmed maps Submitting to Submitted
+- SubmitFailed maps Submitting to Failed with the error
+- Retry re-submits a failure
+- Reset returns to the initial editing state
+
+#### detailRows
+
+- lists soort/status/referentie/eigenaar/ingediend
+- a reden row is present only for Afgewezen and MeerInfoGevraagd
+
+#### parseBeoordelingStatus
+
+- parses each tag with its required fields
+- rejects a malformed status
+
+#### parseBeoordelingView
+
+- maps a valid DTO to domain
+- rejects a malformed view
+- defaults an absent documenten list to empty
+
+#### parseWerkvoorraad
+
+- parses a list and fails fast on a bad element
+
+#### parseWerkvoorraadItem
+
+- parses Ingediend and InBehandeling
+- rejects a case whose status is not an open queue tag
+- rejects a missing owner, bad type, and non-objects
+
+#### statusLabel
+
+- labels every tag distinctly
+
+#### werkvoorraadRow
+
+- heading is the type, subtitle carries the owner BSN
+- status line carries the status label, reference and submit date
+- manual review is called out distinctly from an automatic InBehandeling
+- a missing submit date leaves no dangling separator
+
+### beheer
+
+#### StamdataStore undo/redo (WP-32)
+
+- records a cell edit, undoes and redoes it
+- records addRow and undoes it
+- clears history when switching table
+
+#### activeOn (valid-time, half-open [van, tot))
+
+- includes a row whose window covers the date
+- excludes a row before its geldigVan
+- excludes on the geldigTot boundary (half-open)
+
+#### changeCounts (diff against the loaded snapshot, by key)
+
+- counts an added key
+- counts a removed key
+- counts an edited cell
+
+#### parseStamdataTable
+
+- maps schema + rows and turns a null cell into empty text
+- falls back to text for an unknown column type
+- rejects a response with no columns
+
+#### rowErrors / isValid (format only)
+
+- flags a blank key
+- flags a missing geldigVan on a temporal table
+- flags geldigTot on or before geldigVan
+- passes a well-formed row
+
+#### stamdata-editor reduce
+
+- Loaded snapshots original independently of rows
+- RowAdded appends an empty row shaped by the schema
+- RowRemoved drops the row at the index
+- edit messages are ignored unless loaded
+- LoadFailed and Loading transition regardless of prior state
+
+#### toJson (draft → file shape)
+
+- reconstructs an open-ended geldigTot as null and pretty-prints
+- coerces a number column
+
+### brief
+
+#### BriefStore action state (Idle | Busy | Failed)
+
+- is Busy synchronously once a transition starts
+- settles to Idle on a successful transition
+- goes Busy then Failed on a failing transition, surfacing the error
+- a subsequent successful transition clears a prior Failed state
+
+#### BriefStore rejection diff
+
+- captures the rejected letter and diffs a subsequent edit against it
+
+#### BriefStore undo/redo history
+
+- starts with nothing to undo
+- records an edit and makes it undoable
+- undo reverts the edit and enables redo
+- redo reapplies the undone edit
+- a no-op edit does not clear the redo future
+- a new edit clears the redo future
+- caps history at 50 snapshots
+
+#### BriefStore.flushPending (CanDeactivate guard / beforeunload)
+
+- flushes a pending debounced edit immediately and clears the pending flag
+- is a no-op when no edit is pending
+
+#### BriefStore.previewLetter
+
+- opens the composed letter in a new tab on success
+- surfaces the error without opening a tab on failure
+
+#### BriefStore.revealBigNummer (PRD-0002 §5c)
+
+- swaps the masked value for the revealed one on success
+- keeps the value masked and surfaces the error on failure
+
+#### besluitGuidance
+
+- positief: counts inserted passages, no reden needed (positief has no redenen)
+- negatief without a reden: flags that a reden must be chosen
+- negatief with a reden: no longer flags, and the reason passage is counted
+
+#### brief selectors
+
+- unresolvedPlaceholders returns deduped manual keys only (auto excluded)
+- allDiagnostics flattens across sections and blocks
+- canSubmit is false when a required section is empty, true otherwise
+
+#### brief.adapter parse boundary
+
+- parses a well-formed view into the domain unions
+- rejects a view whose case context is missing or malformed
+- parses the org template and drops a null logoDocumentId
+- rejects a view whose org template is missing or malformed
+- rejects a view whose decisions are missing or malformed
+- narrows node variants and rejects unknown ones
+- rejects a status DTO missing its required fields
+- reads section.locked (default false) and paragraph.list
+- rejects a library passage with an unknown scope
+- rejects a passage block missing provenance
+
+#### brief.machine reduce
+
+- BriefLoaded moves loading to loaded
+- BriefLoadFailed moves loading to failed with the reason
+- Seed sets the state directly
+- BesluitSelected composes the kern: the besluit passages, in reading order, as frozen local blocks
+- BesluitSelected swaps the passages when the selection changes, keeping free text
+- BesluitSelected deep-copies content — later library mutation does not leak in
+- FreeTextBlockAdded appends an empty free-text block
+- BlockContentEdited replaces content and marks a passage block edited
+- BlockMovedWithinSection reorders blocks within a section
+- BlockRemoved drops a block from a section
+- edits to a locked section are no-ops (besluit, free-text, content, remove, move)
+- edits are no-ops once submitted (status invariant)
+- editing a rejected letter reopens it to draft
+- Submitted fires only from draft and only when required sections are filled
+- approve fires only from submitted
+- reject fires from submitted, carrying comments
+- send fires only from approved
+- a status transition replaces decisions with the fresh server value
+
+#### diffBlocks
+
+- marks added, removed, changed and unchanged by blockId
+- changedBlocks drops unchanged and keeps added/removed/changed
+
+#### inferSelection
+
+- round-trips a positief selection
+- round-trips a negatief selection with redenen (in order)
+- an empty kern (nothing chosen) infers no besluit
+- ignores free-text blocks and unknown passage ids
+
+#### lintPlaceholders
+
+- clean content (auto-resolvable, fillable, current key) yields no diagnostics
+- flags an unknown key as an error
+- flags a not-fillable key as an error
+- flags a deprecated key as a warning
+- flags a manual placeholder as unresolved-at-send (warning)
+- flags raw braces in text as malformed (paste safety net)
+- returns diagnostics in document order across paragraphs/nodes
+- severityOf maps each code to its policy
+
+#### org-template.machine
+
+- DraftLoaded moves to loaded with the draft, clean
+- LoadFailed carries the reason
+- FieldEdited edits the draft and marks dirty
+- MarginEdited edits one edge and marks dirty
+- DraftSaved clears dirty when the saved draft is the current one
+- DraftSaved keeps dirty when an edit landed during the save round-trip
+- edits are no-ops in non-loaded states
+- a completed logo upload sets logoDocumentId + dirty
+- removing the logo clears logoDocumentId + dirty
+- DraftLoaded (sub-org switch) keeps the loaded logo category, drops uploads
+
+#### parseOrgTemplateAdminView
+
+- parses a well-formed admin view
+- rejects a missing draft
+- rejects a missing count field
+- rejects a malformed history entry
+
+#### passagesForBesluit
+
+- positief = shared intro + the positief passage, no negatief/reason passages
+- negatief without redenen = intro + negatief base, but no reason-specific passages
+- negatief with a reden ticked includes that reason-specific passage only
+- preserves library order (= reading order)
+- never offers non-kern passages
+
+#### redenenFor
+
+- derives reason checkboxes (code + label) from the negatief reason passages
+- positief has no reason-specific redenen
+
+### herregistratie
+
+#### IntakeWizardComponent
+
+- renders each field group as its own grey <fieldset>
+
+#### STEPS (fixed) and inline questions
+
+- always has the same three steps
+- reveals the buitenland detail questions inline only when worked abroad
+- reveals the scholing question only when NL-hours are below the threshold
+- uses the (server-owned) threshold passed in, not a hardcoded constant
+
+#### herregistratie hasProgress
+
+- is false for a fresh form
+- is true once a field is filled or the user advances
+
+#### intake acceptance journeys
+
+- high uren, no buitenland werk: no scholing question, straight through to Submitted
+- low uren requires the scholing question, and punten only once scholing is followed
+- buitenland gewerkt requires land and hours abroad before advancing
+- gaNaarStap corrects an earlier answer without losing later ones
+- SetPolicy (server-owned threshold) can turn an already-answered uren into one that now requires scholing
+
+#### intake hasProgress
+
+- is false for a fresh questionnaire
+- is true once an answer is given or the user advances
+
+#### intake-policy.adapter parse boundary
+
+- parses a well-formed policy
+- rejects a missing or non-numeric threshold
+
+#### navigation
+
+- Next is a no-op (sets an error) when the current step is invalid
+- Next advances once the step is valid
+- editing an answer leaves the cursor fixed (steps never collapse)
+- Back never goes below the first step
+- gaNaarStap jumps back to an earlier step, clearing errors
+- gaNaarStap ignores a same/forward jump and jumps outside Answering
+
+#### reduce (message-driven happy path)
+
+- drives abroad branch end to end
+
+#### reduce (message-driven)
+
+- drives the full happy path via messages
+- blocks submit until required documents are satisfied
+- SubmitFailed then Retry returns to Submitting with the same data
+- Seed mounts an arbitrary state
+
+#### submit
+
+- reaches Submitting ONLY with valid answers
+- punten is required only when aanvullende scholing was gevolgd
+- low hours requires the scholing answer before submit
+- resolve maps Submitting to Submitted on a successful submit
+- resolve maps Submitting to Failed on a failed submit
+
+#### wizard.machine
+
+- next advances only when step 1 parses
+- next advances step 2 → 3 only when punten parses
+- submit reaches Submitting ONLY from step 3 with fully valid data
+- next requires BOTH step-1 fields (uren and jaren)
+- back steps down one (3 → 2 → 1) and is a no-op from step 1
+- resolve maps a successful Submitting to Submitted
+- resolve maps a failing Submitting to Failed
+- gaNaarStap jumps back to an earlier step, clearing errors
+- gaNaarStap ignores a same/forward jump and jumps outside Editing
+
+### registratie
+
+#### AdminCasesStore
+
+- loads and parses the cross-owner list
+- deletes optimistically and confirms via the admin endpoint
+- rolls back the removal when the delete fails
+
+#### STEPS (fixed)
+
+- always has the same three steps
+
+#### adres origin (BRP vs handmatig)
+
+- prefillAdres flags origin brp
+- editing a prefilled address field flips origin to handmatig
+- typing an address with no BRP prefill yields handmatig
+- editing the e-mail field does not change the address origin
+- a manually entered address still submits (only manual diploma is gated)
+
+#### big-register.adapter parse boundary
+
+- parses known aantekening types
+- rejects an unknown type
+
+#### blockActions
+
+- a Concept can be resumed or cancelled
+- an in-behandeling aanvraag only exposes its documents
+- ingediend and meer-info-gevraagd behave like in-behandeling
+- resolved aanvragen have no actions
+
+#### change-request reduce
+
+- SetField updates the draft while editing
+- Submit with an invalid draft stays Editing and reports field errors
+- Submit with a valid draft moves to Submitting with parsed (normalised) data
+- SubmitConfirmed maps Submitting to Submitted with the referentie
+- SubmitFailed maps Submitting to Failed with the error
+- Retry re-submits a failure
+- Reset returns to the initial editing state
+
+#### createDraftSync
+
+- coalesces rapid snapshot changes into ONE debounced sync of the latest value
+- a trailing change after the debounce fires schedules its own sync
+
+#### createDraftSync › flushPending (CanDeactivate guard / beforeunload)
+
+- hasPendingSave reflects an armed debounce timer
+- flushPending writes the pending draft immediately, before the debounce fires
+- flushPending is a no-op when nothing is pending
+
+#### createDraftSync › submit
+
+- resolves ok with the server response on success
+- folds a rejected submit into a Result error, never throwing
+- recovers from a create conflict by adopting the existing Concept (WP-35)
+
+#### createSubmitChangeRequest
+
+- resolves ok with the referentie on success
+- folds a rejected call into a Result error, never throwing
+- surfaces a ProblemDetails detail message when the server rejects with one
+
+#### detailRows
+
+- lists soort/waarvoor/status/referentie/ingediend, plus reason when rejected
+- reference falls back to em dash for a Concept
+
+#### hasProgress
+
+- is false for a fresh wizard
+- ignores an auto-prefilled BRP address at step 0
+- is true once the user advances, picks correspondence/diploma, or is past step 0
+
+#### inline document upload (beroep step)
+
+- routes Upload messages through the upload reducer
+- blocks the beroep step until a required category is satisfied
+- includes delivery refs in the submitted data
+
+#### kiesDiploma
+
+- derives the beroep from the chosen diploma and flags origin duo
+
+#### manual diploma fallback
+
+- KiesHandmatig flags handmatig with the maximal question set and no beroep yet
+- requires a declared beroep + all maximal questions before submit
+
+#### navigation
+
+- Next is a no-op (sets errors) when the adres step is invalid
+- Next advances once the adres step is valid
+- requires a valid e-mail only when the channel is email
+- beroep step requires a chosen diploma
+- Back never goes below the first step and preserves the draft
+- GaNaarStap only jumps backwards
+
+#### parseAanvraagStatus
+
+- parses each tag with its required fields
+- rejects a missing status, unknown tag, and wrong-typed fields
+
+#### parseApplicationSummary
+
+- maps a valid DTO to domain
+- rejects a bad type and non-objects
+
+#### parseApplications / parseApplicationDetail
+
+- parses a list and fails fast on a bad element
+- carries the opaque draft through detail
+
+#### parseBigNummer
+
+- accepts exactly 11 digits, trimming whitespace
+- rejects wrong length or non-digits
+
+#### parseBrpAddress (trust boundary)
+
+- accepts a found address
+- accepts "geen adres" (gevonden: false) as a valid outcome
+- rejects malformed responses
+
+#### parseDashboardView (trust boundary)
+
+- maps a valid response into a DashboardView
+- rejects malformed responses instead of trusting them
+
+#### parseDuoLookup (trust boundary)
+
+- maps a valid lookup (diplomas + manual fallback)
+- accepts an empty diploma list (forces manual entry)
+- rejects malformed responses
+
+#### parseEmail
+
+- accepts a well-formed address and trims it
+- rejects malformed addresses
+
+#### parsePostcode
+
+- normalises to "1234 AB" (uppercase, single space, trimmed)
+- rejects malformed postcodes
+
+#### parseTelefoonnummer
+
+- accepts a 10-digit number starting 0 and strips formatting
+- normalises a +31 prefix to a leading 0
+- rejects a too-short number, a non-0 start, and junk
+
+#### parseUren
+
+- accepts non-negative whole numbers, including 0
+- rejects empty, negative, and non-integer input
+
+#### policy questions (geldigheidsvragen)
+
+- a diploma with questions blocks Next until they are answered
+- validateAll keeps only the answers to the questions that applied
+
+#### reduce (message-driven happy path)
+
+- adres and correspondentie set, Next advances from adres to beroep
+- diploma chosen, Next advances from beroep to controle
+- Submit moves a complete Invullen draft to Indienen
+- SubmitConfirmed moves Indienen to Ingediend
+- SubmitFailed moves Indienen to Mislukt
+- Retry returns Mislukt to Indienen with the same data
+
+#### registration.policy
+
+- only an active registration within the window is eligible
+- struck-off / suspended registrations are never eligible
+- statusColor is total over the union
+- a well-formed status is always consistent
+- a Geregistreerd status without its herregistratieDatum is inconsistent
+
+#### submit
+
+- stays in Invullen when the draft is incomplete (no diploma)
+- reaches Indienen with a complete, valid draft, carrying its data
+- resolve maps Indienen to Ingediend with the referentie
+- resolve maps Indienen to Mislukt on a failed submit
+
+#### submittedRow
+
+- heading is the type, subtitle is the purpose
+- status line carries the status label, reference and submit date
+- manual review adds a note
+- rejection adds its reason
+- meer-info-gevraagd adds its reason, like a rejection
+
+#### tasksFromProfile
+
+- offers herregistratie when the server says eligible, with the formatted deadline
+- offers nothing when the server says not eligible
+- surfaces a notice for a suspended registration (independent of eligibility)
+- surfaces a notice for a struck-off registration
+
+### shared
+
+#### BackgroundUploadsReturned
+
+- resolves each in-flight upload to complete or failed
+
+#### CategoriesLoadFailed / BackgroundSyncAvailability
+
+- records the error
+- flips background sync availability
+
+#### CategoriesLoaded
+
+- defaults every category to digital without clobbering existing choices
+- clears a prior categoriesError
+- drops uploads + channel choices for categories that disappear
+
+#### DeliveryChannelChanged
+
+- switching to post removes that category uploads
+- rejects post for a category that does not allow it
+- switching back to digital starts clean
+
+#### FileRejected
+
+- stores a per-category message
+
+#### FileSelected
+
+- queues a new upload and clears any rejection for that category
+- ignores selection for an unknown category
+- ignores selection for a category set to post-delivery
+- single-file category: a new selection replaces the existing upload
+- multiple category: selections accumulate
+
+#### PendingSaves registry
+
+- hasPending is true only while some registered owner has a pending write
+- unregister removes an owner so it no longer counts
+- flushAll flushes only the pending owners
+- flushAll awaits every owner and swallows a rejected flush
+
+#### RemoteData combinators
+
+- map only touches Success
+- map2 precedence: Failure > Loading > Success
+
+#### createDebouncedSave
+
+- flushes after the delay when canSave is true
+- does not schedule when canSave is false
+- coalesces rapid schedules into a single flush
+- flushPending runs the save immediately and clears; no-op when idle
+- cancel drops a scheduled save without running it
+
+#### createHistory
+
+- starts empty; undo/redo are no-ops
+- records pre-edit snapshots, then undoes and redoes through them
+- record() clears the redo stack (no dead redo after a fresh edit)
+- caps the stack depth
+- clear() empties both stacks
+
+#### createStore
+
+- applies the pure update on dispatch
+- dispatch from inside an effect does not self-loop
+
+#### delete flow (optimistic, revertible)
+
+- UploadDeleteRequested keeps the documentId for revert
+- UploadDeleteComplete removes the upload
+- UploadDeleteFailed reverts to complete with the original documentId
+
+#### deliveryRefs
+
+- emits documentId for completed digital uploads and channel for post
+- omits digital categories with no completed upload
+
+#### flushPendingGuard
+
+- flushes then allows navigation when a write is pending
+- allows navigation immediately when nothing is pending
+
+#### formatDatumNl
+
+- formats a Date in long Dutch form
+- formats an ISO string the same way
+- is empty-safe: undefined, null, and empty string all yield the empty string
+- returns empty for an unparseable string rather than "Invalid Date"
+
+#### httpClientFetch
+
+- sends the pending idempotency key as a header for a write, not a fresh one per attempt
+- retries a failing GET twice before giving up
+- never retries a failing write
+
+#### inFlight
+
+- returns only queued/uploading uploads
+
+#### localeLinks (nl at root, en under /en/)
+
+- an nl route (no prefix) links nl to the bare path, en under /en, marks active
+- an en route strips the /en prefix for the nl target (deep path, en active)
+- keeps query + hash on both targets
+- the root maps nl → / and en → /en/
+
+#### machineRemoteData
+
+- maps loading → Loading
+- maps failed → Failure carrying an Error with the reason
+- maps loaded → Success carrying the whole loaded state
+
+#### parseBsn (elfproef)
+
+- accepts a valid BSN (passes the elfproef)
+- rejects a 9-digit number that fails the elfproef
+- rejects wrong length / non-digits / all zeros
+
+#### parseMe (trust boundary)
+
+- parses a known capability list
+- parses an empty list (drafter — no capabilities)
+- recognizes the admin org-template capability (WP-23)
+- recognizes the behandelportal besluit capability (WP-66)
+- drops unrecognized capability strings instead of rejecting the response
+- rejects malformed responses instead of trusting them
+
+#### pii maskers
+
+- maskBsn keeps the last 3 digits
+- maskTail keeps the requested tail length
+- masks the whole value when it is not longer than the kept tail
+
+#### problemDetail
+
+- extracts the detail from an RFC-7807 ProblemDetails
+- falls back when there is no detail
+
+#### problemFieldErrors (G4 seam)
+
+- maps a ValidationProblemDetails errors dict to first-message-per-field
+- returns {} when there is no errors envelope (the current backend shape)
+
+#### rejectReason
+
+- rejects a disallowed type
+- rejects an oversized file
+- accepts a valid file
+- allows any type when the category lists none
+
+#### rich-text
+
+- emptyBlock is one empty paragraph and reads as empty
+- isBlockEmpty is false when any placeholder or non-blank text exists
+- placeholderKeysIn walks in document order, keeping duplicates
+- deepCopyBlock is an independent value copy (frozen snapshot)
+
+#### rich-text DOM boundary
+
+- round-trips text, marks, placeholders, line breaks and multiple paragraphs
+- round-trips an empty paragraph (filler <br> is not a line break)
+- renders a placeholder as a non-editable chip carrying its key and label
+- reads combined marks in canonical order regardless of nesting
+- round-trips bullet and numbered lists mixed with paragraphs
+- groups consecutive same-kind list lines into one <ul>/<ol>
+- adjacentChip finds a chip next to a collapsed caret so Backspace/Delete can remove it
+- marks auto-resolvable vs manual chips with data-auto for styling
+
+#### roleInterceptor
+
+- leaves an unrelated endpoint untouched
+
+#### runSubmit
+
+- folds a resolved call into ok(value)
+- maps a ProblemDetails rejection to err(detail)
+- falls back when the rejection has no detail
+
+#### satisfaction helpers
+
+- a post-delivery choice satisfies a required category
+- an active upload satisfies a required category
+- a failed upload does not satisfy a required category
+- requiredCategoriesSatisfied ignores optional categories
+
+#### scenario (dev mechanism)
+
+- reads a valid ?scenario= from the URL and persists it for the tab
+- falls back to default when nothing is set or the value is invalid
+- setScenario persists the chosen scenario
+
+#### stripDevParams (WP-37)
+
+- removes ?scenario and ?role so the stored dev value wins on reload
+- keeps unrelated query params and the path/hash
+- is a no-op when neither param is present
+
+#### upload lifecycle messages
+
+- queued → progress → complete
+- failed then retried returns to queued
+- UploadRemoved drops the upload
+
+#### withIdempotencyKey / currentIdempotencyKey
+
+- threads the key to every read made inside the wrapped fn
+- clears the key once the wrapped fn settles
+- falls back to a generated uuid-shaped key when none is pending
+
+### showcase
+
+#### highlightTs
+
+- wraps keywords, strings and comments in the styling spans
+- escapes HTML metacharacters so the [innerHTML] sink is safe
+- treats the whole // tail as one comment (keywords after // are not re-highlighted)
+
+## Backend (by test class)
+
+### AdminCasesTests
+
+- Admin lists every case with its owner
+- Non admin is forbidden
+- Admin can delete a submitted case
+- Deleting a missing case is not found
+
+### ApplicationRuleTests
+
+- AanvraagStatusTag covers the published lifecycle
+
+### ApplicationTests
+
+- Create then list shows a concept with step progress
+- Draft sync is readable back from detail
+- Submit duo registratie is in behandeling and auto
+- Submit handmatig registratie succeeds as manual case
+- Submit herregistratie with zero uren is afgewezen
+- Submitting twice conflicts
+- Creating a second concept of the same type conflicts
+- A concept of a different type is allowed
+- A new concept is allowed once the previous one is submitted
+- Cancel concept removes it
+- Cancel submitted aanvraag conflicts
+- Applications are scoped to the caller bsn
+- Submitting a foreign documentId is rejected and leaves it deletable by its owner
+- Draft sync with a foreign documentId is rejected
+- AutoApprovable flips to goedgekeurd after the window
+- Manual case never auto advances
+
+### AuthzAuditTests
+
+- A denied admin action is recorded
+- A reveal attempt is recorded
+- The audit schema carries no pii
+
+### AuthzTests
+
+- Drafter may not approve or reject even when submitted
+- Approver may approve and reject a different drafters letter
+- Send is not role gated
+- Decisions CanEdit true for drafter in editable statuses
+- Decisions CanApprove requires submitted status and approver role
+- Decisions CanSend requires approved status only
+- RoleCapabilities are empty for drafter and the three brief capabilities for approver
+- CanRevealBigNummer only for the case drafter behandelaar
+- Decisions CanRevealBigNummer is status independent
+- CanBeoordelen true for a medewerker with the behandelaar rol
+- CanBeoordelen false for a medewerker without it
+- CanBeoordelen false for a zorgverlener regardless of role
+
+### BeoordelingIdMismatchTests
+
+- Besluit resolves by referentie when the case id differs from the local aanvraag id
+
+### BeoordelingRuleTests
+
+- Only open statuses are decidable
+- Only a non approval requires a toelichting
+- A terminal decision refuses any further besluit
+- MeerInfoOpvragen is not terminal a further besluit is still legal
+
+### BeoordelingTests
+
+- Detail shows status documents and a masked owner
+- Concept and unknown id are not found
+- Zorgverlener is forbidden even with admin role
+- Medewerker without behandelaar rol is forbidden
+- Goedkeuren advances status to Goedgekeurd
+- Afwijzen requires a toelichting
+- MeerInfoOpvragen is still decidable afterwards
+- Already decided case rejects a further besluit
+- Concurrent besluiten on the same aanvraag yield exactly one success
+- Unknown id 404s
+- Zorgverlener is forbidden from deciding
+
+### BesluitLifecycleTests
+
+- A terminal besluit is frozen
+- MeerInfoGevraagd can be decided again
+- A recorded decision wins over the auto approve computation
+- Afwijzen requires a toelichting
+
+### BriefEndpointTests
+
+- Get creates a draft with expected sections locked and empty
+- Get offers only global and arts scoped besluit tagged passages
+- Get joins the case context with the BIG nummer masked
+- Reveal returns the unmasked BIG nummer for the drafter with step up
+- Reveal is forbidden without the step up
+- Reveal is forbidden for a role without the capability
+- Save is drafter only
+- Submit blocks on empty required section
+- Submit succeeds when required sections filled
+- Drafter cannot approve own letter but a different reviewer can
+- Reject returns comments
+- Editing a rejected letter reopens it to draft
+- Send only from approved
+- Decisions on the view mirror the acting principal and live status
+- Me returns no capabilities for drafter and the brief set for approver
+- Reset recreates a fresh draft with locked prefilled sections
+
+### DiplomaRuleTests
+
+- Profession is derived from program
+- English diploma requires dutch proficiency
+- Dutch diploma has no policy questions
+- Manual diploma gets maximal set
+- Manual professions match known programs
+
+### DocumentRuleTests
+
+- Rejects unknown category
+- Rejects disallowed type
+- Rejects oversized file
+- Accepts valid file
+- First load has no diploma upload
+- Manual diploma needs a diploma upload
+- Duo diploma skips diploma upload
+- Confirmed dutch proficiency requires taalvaardigheid proof
+- Unconfirmed proficiency requires no taalvaardigheid proof
+- Find resolves taalvaardigheid for upload validation
+
+### EndpointTests
+
+- DashboardView computes eligibility decision
+- Notes returns seeded aantekeningen
+- Brp returns address
+- Duo lookup carries server decided questions and professions
+- IntakePolicy returns scholing threshold
+- Registration with duo diploma succeeds
+- Registration with manual diploma is rejected with problem details
+- Zero hours submission is rejected
+- Worked hours submission succeeds
+- Change request with valid phone succeeds
+- Change request with bad phone is rejected
+- Health endpoint is ok
+- Correlation id supplied by the caller is echoed back
+- Correlation id is generated when the caller omits it
+- Categories are server owned config
+- Upload then status reports complete for known localId
+- Upload content is served back with its type inline for pdf
+- Upload content 404 for unknown document
+- Upload rejects wrong type
+- User delete succeeds then 404
+- User delete blocked with 409 once linked to submission
+- Admin delete requires admin role
+- Audit log records upload and delete metadata only
+
+### FeatureFlagTests
+
+- The catalog has unique keys
+- Get flags returns the catalog
+- Toggling is admin only and an unknown key is 404
+- Closing inschrijving blocks a registratie then reopening allows it
+
+### HerregistratieRuleTests
+
+- Eligible within window
+- Not eligible before window
+- Eligible on window boundary
+- Suspended is not eligible
+- Status consistency invariant
+
+### IdempotencyTests
+
+- Replaying the same idempotency key returns the same reference not a new one
+- Different idempotency keys are independent submissions
+- A rejected submission replays the same rejection not a retry
+
+### LetterHtmlTests
+
+- Render matches the golden file
+- Every letter prefixed class exists in letter css
+
+### NotificatieTests
+
+- Correct shared secret is accepted and recorded
+- Wrong secret is rejected and recorded
+- Missing authorization header is rejected
+
+### OpenZaakDocumentSourceTests
+
+- Upload registers an eio in drc and persists its url locally
+- Upload falls back to openbaar for a category absent from the confidentialiteit table
+- Upload keeps the local document when the category has no configured informatieobjecttype
+- Upload keeps the local document and does not throw when drc rejects it
+- LinkToZaak posts a zaakinformatieobject per document once a zaak exists
+- LinkToZaak makes no zgw call when the local source created no zaak
+
+### OpenZaakIntegrationTests
+
+- Admin cases returns the seeded zaak mapped through real HTTP and JWT
+
+### OpenZaakZaakSourceTests
+
+- Follows pagination maps the internal aanvraag type and sends bearer token
+- ListCases throws when a zaak zaaktype has no configured aanvraag type
+- ListMyCases filters by the callers bsn
+- CreateZaak posts zaak status and rol and maps the result back
+- CreateZaak throws when the aanvraag type has no configured zaaktype
+- RecordBesluit posts the last statustype with besluit and toelichting
+- RecordBesluit creates a resultaat before posting the eindstatus
+- RecordBesluit does nothing when the aanvraag has no zaak
+- RecordBesluit throws when the aanvraag type has no configured zaaktype
+- CreateZaak retries a transient failure and then succeeds
+- CreateZaak gives up after three attempts on a persistent transient failure
+- CreateZaak does not retry a permanent rejection
+
+### OrgTemplateEndpointTests
+
+- Admin endpoints are admin only
+- Publish increments the version
+- Publish appends to the version history
+- Publish counts the unsent briefs it affects
+- Save draft validates margins
+- Save draft round trips the edited values
+- Rollback copies an old version into the draft without rewriting history
+- Sent brief keeps its pinned template after a republish
+- Unsent brief follows a republish
+- Me returns the orgtemplate capability for admin
+- Admin cannot slip into the brief review flow
+
+### OrgTemplateRuleTests
+
+- Accepts a complete draft within the margin bounds
+- Rejects a missing organisation name
+- Rejects a missing signature name
+- Rejects a margin outside the allowed range
+- Accepts margins on the boundary
+
+### PreviewEndpointTests
+
+- Preview of an unsent brief renders live with a watermark
+- Preview of a sent brief serves the archive unchanged after a republish
+- Proefbrief is admin only
+- Proefbrief renders the draft template with a watermark
+
+### StamdataEndpointTests
+
+- Stamdata reads are admin only
+- Table list exposes the reflected schema
+- Table returns all rows without a peildatum
+- Peildatum before the seed windows hides every row
+- Unknown table is 404
+
+### StamdataValidationTests
+
+- Every declared reference into stamdata resolves
+- Profession map has no blank programs or professions
+- Manual professions are non empty and distinct
+- Every policy question has an id and wording
+- Manual question set has distinct ids
+- Every catalog table is valid
+
+### StubIdentityProviderTests
+
+- No headers resolves to the seeded citizen as a drafter
+- X role maps to the principal role
+- X subject overrides the default bsn
+- No headers resolves a zorgverlener kind
+- X medewerker resolves a medewerker with the default behandelaar rol
+- X medewerker takes precedence over x subject
+- Empty x medewerker falls through to the zorgverlener default
+- X rollen parses known tokens and drops unknown ones
+- X role still applies to a medewerker
+
+### SubmissionRuleTests
+
+- Manual diploma is rejected
+- Duo diploma is accepted
+- Zero hours is rejected
+- Worked hours are accepted
+- Phone change is validated
+
+### WerkvoorraadTests
+
+- Behandelaar sees submitted cases in the queue
+- Queue excludes concepts
+- Zorgverlener is forbidden even with admin role
+- Medewerker without behandelaar rol is forbidden
+- Me reports the capability only for a behandelaar
+
+### ZgwDiagnosticHandlerTests
+
+- Passes request and response through unchanged
+
+### ZgwDivergenceTests
+
+- Submit with a failing zgw flags the divergence instead of diverging silently
+- Submit with a healthy zgw leaves no divergence flag
+
+### ZgwTokenProviderTests
+
+- Mints a three part jwt with the required claims
+- Mint with a caller carries that citizen not the static config identity
+- Mint with a medewerker caller uses the medewerkerId as user id
+- Signature verifies with the shared secret
+
+### ZgwZaakMapperTests
+
+- Maps url identity zaaktype and open status
+- Closed zaak maps to goedgekeurd
+- Uuid extracts trailing segment ignoring trailing slash
diff --git a/scripts/check-seam.sh b/scripts/check-seam.sh
new file mode 100755
index 0000000..7ce848c
--- /dev/null
+++ b/scripts/check-seam.sh
@@ -0,0 +1,33 @@
+#!/usr/bin/env bash
+# WP-71 (Track E): fail if the backend's scholing-threshold policy default and the frontend's
+# offline fallback default drift apart. ADR-0001's "config value" shape means the backend is
+# the authority (GET /intake/policy) and the FE only keeps SCHOLING_THRESHOLD_DEFAULT as an
+# offline/first-paint fallback (intake.machine.ts) — but the two literals are otherwise
+# unlinked, so nothing stops them silently diverging. This is a cheap grep-based tripwire, not
+# a build-time link between the two languages.
+set -uo pipefail
+
+BACKEND_FILE='backend/src/BigRegister.Api/Domain/Intake/IntakePolicy.cs'
+FRONTEND_FILE='apps/ssp/src/app/herregistratie/domain/intake.machine.ts'
+
+backend_value=$(grep -oE 'ScholingThreshold\s*=\s*[0-9]+' "$BACKEND_FILE" | grep -oE '[0-9]+$')
+frontend_value=$(grep -oE 'SCHOLING_THRESHOLD_DEFAULT\s*=\s*[0-9]+' "$FRONTEND_FILE" | grep -oE '[0-9]+$')
+
+if [ -z "$backend_value" ]; then
+ echo "FAIL: could not find IntakePolicy.ScholingThreshold in $BACKEND_FILE"
+ exit 1
+fi
+if [ -z "$frontend_value" ]; then
+ echo "FAIL: could not find SCHOLING_THRESHOLD_DEFAULT in $FRONTEND_FILE"
+ exit 1
+fi
+
+if [ "$backend_value" != "$frontend_value" ]; then
+ echo "FAIL: FE/BE seam drift on the scholing threshold default"
+ echo " $BACKEND_FILE: ScholingThreshold = $backend_value"
+ echo " $FRONTEND_FILE: SCHOLING_THRESHOLD_DEFAULT = $frontend_value"
+ echo 'Both literals represent the same intake policy default (ADR-0001 config value) and must match.'
+ exit 1
+fi
+
+echo "OK: scholing threshold default matches on both sides ($backend_value)"
diff --git a/scripts/gen-behaviour-spec.mjs b/scripts/gen-behaviour-spec.mjs
new file mode 100644
index 0000000..6013922
--- /dev/null
+++ b/scripts/gen-behaviour-spec.mjs
@@ -0,0 +1,218 @@
+#!/usr/bin/env node
+// Generate a business-readable "behaviour spec" page FROM real test names (WP-71, Track D).
+// The team considered Cucumber/Gherkin for BDD scenarios and rejected it (runtime string
+// matching undoes the compile-time guarantees WP-70 just bought, and needs two frameworks for
+// .NET+TS). Instead: test names ARE the spec — this script only extracts and formats them, so
+// the page can never drift from the suite. Mirrors the gen-snippets.mjs pattern (pure Node,
+// reads real source files, writes ONE generated file, checked for drift in CI the same way).
+// Run: `npm run gen:behaviour-spec`.
+import { readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs';
+import { join, relative, sep } from 'node:path';
+
+const EXCLUDED_DIRS = new Set(['node_modules', 'dist', 'coverage', 'bin', 'obj', '.git']);
+
+/** Recursively collect files under `dir` matching `pattern`, skipping excluded directories. */
+function walk(dir, pattern) {
+ const out = [];
+ for (const entry of readdirSync(dir)) {
+ if (EXCLUDED_DIRS.has(entry)) continue;
+ const full = join(dir, entry);
+ const st = statSync(full);
+ if (st.isDirectory()) out.push(...walk(full, pattern));
+ else if (pattern.test(entry)) out.push(full);
+ }
+ return out.sort();
+}
+
+// ---------------------------------------------------------------------------
+// Frontend: apps/**/*.spec.ts + libs/**/*.spec.ts — describe()/it() pairs.
+// ---------------------------------------------------------------------------
+
+const QUOTED = `(?:'([^']*)'|"([^"]*)"|` + '`([^`]*)`)';
+const DESCRIBE_RE = new RegExp(`\\bdescribe(?:\\.\\w+)?\\(\\s*${QUOTED}`);
+const IT_RE = new RegExp(`\\bit(?:\\.\\w+)?\\(\\s*${QUOTED}`);
+
+/** Which app/context folder a spec file belongs to, for grouping (registratie, brief, …). */
+function feContextFor(path) {
+ const norm = path.split(sep).join('/');
+ const appMatch = norm.match(/^apps\/(?:ssp|behandelportal)\/src\/app\/([^/]+)\//);
+ if (appMatch) return appMatch[1];
+ const libMatch = norm.match(/^libs\/([^/]+)\/src\//);
+ if (libMatch) return libMatch[1];
+ return 'other';
+}
+
+/**
+ * Extract { describePath: string[], text: string } for every `it(...)` in a spec file, using
+ * a brace-depth stack to track nested `describe(...)` blocks (a line-scan, not a TS parser —
+ * this repo's spec files are one describe/it call per line, same precedent as gen-snippets.mjs).
+ */
+function extractSpecBehaviours(source) {
+ const lines = source.split('\n');
+ let depth = 0;
+ const stack = []; // { name, depth }
+ const results = [];
+ for (const line of lines) {
+ if (/^\s*\/\//.test(line)) continue; // skip commented-out lines
+ const dm = line.match(DESCRIBE_RE);
+ const im = !dm && line.match(IT_RE);
+ if (dm) {
+ stack.push({ name: dm[1] ?? dm[2] ?? dm[3], depth });
+ } else if (im) {
+ results.push({ describePath: stack.map((s) => s.name), text: im[1] ?? im[2] ?? im[3] });
+ }
+ const open = (line.match(/{/g) || []).length;
+ const close = (line.match(/}/g) || []).length;
+ depth += open - close;
+ while (stack.length && depth <= stack[stack.length - 1].depth) stack.pop();
+ }
+ return results;
+}
+
+const feSpecFiles = [...walk('apps', /\.spec\.ts$/), ...walk('libs', /\.spec\.ts$/)];
+
+/** @type {Map>} context -> describe-block label -> it() texts */
+const feBehaviour = new Map();
+for (const file of feSpecFiles) {
+ const context = feContextFor(file);
+ const relPath = relative('.', file).split(sep).join('/');
+ const behaviours = extractSpecBehaviours(readFileSync(file, 'utf8'));
+ for (const { describePath, text } of behaviours) {
+ const label = describePath.length ? describePath.join(' › ') : `(${relPath})`;
+ if (!feBehaviour.has(context)) feBehaviour.set(context, new Map());
+ const byLabel = feBehaviour.get(context);
+ if (!byLabel.has(label)) byLabel.set(label, []);
+ byLabel.get(label).push(text);
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Backend: backend/tests/BigRegister.Tests/**/*.cs — [Fact]/[Theory] methods.
+// ---------------------------------------------------------------------------
+
+const CLASS_RE = /^\s*(?:public|internal)\s+(?:sealed\s+)?class\s+(\w+)/;
+const FACT_OR_THEORY_RE = /^\s*\[(?:Fact|Theory)\b/;
+const METHOD_RE = /\b(?:void|Task(?:<[^>]*>)?)\s+(\w+)\s*\(/;
+
+/** PascalCase_snake_sentence method name -> readable sentence (just spaces for underscores). */
+function toSentence(methodName) {
+ return methodName.replace(/_/g, ' ');
+}
+
+/** Extract { className, sentence } for every [Fact]/[Theory]-attributed method in a .cs file. */
+function extractCsBehaviours(source) {
+ const lines = source.split('\n');
+ let currentClass = null;
+ const results = [];
+ for (let i = 0; i < lines.length; i++) {
+ const cm = lines[i].match(CLASS_RE);
+ if (cm) {
+ currentClass = cm[1];
+ continue;
+ }
+ if (!FACT_OR_THEORY_RE.test(lines[i])) continue;
+ // Skip any further attribute lines (e.g. [InlineData(...)] rows on a [Theory]) and blank
+ // lines to reach the method declaration itself.
+ let j = i + 1;
+ while (j < lines.length && (/^\s*\[/.test(lines[j]) || /^\s*$/.test(lines[j]))) j++;
+ const mm = lines[j] && lines[j].match(METHOD_RE);
+ if (mm && currentClass) results.push({ className: currentClass, sentence: toSentence(mm[1]) });
+ }
+ return results;
+}
+
+const csFiles = walk('backend/tests/BigRegister.Tests', /\.cs$/);
+
+/** @type {Map} class name -> sentences */
+const beBehaviour = new Map();
+for (const file of csFiles) {
+ for (const { className, sentence } of extractCsBehaviours(readFileSync(file, 'utf8'))) {
+ if (!beBehaviour.has(className)) beBehaviour.set(className, []);
+ beBehaviour.get(className).push(sentence);
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Emit libs/shared/docs/behaviour-spec.mdx
+// ---------------------------------------------------------------------------
+
+// MDX parses markdown as JSX-in-Markdown: a bare ``/`{expr}` in test-name text (e.g.
+// "renders each field group as its own grey