Commit Graph
43 Commits
Author SHA1 Message Date
ehoandClaude Opus 5 0298ecc506 fix(uploads): delete the dead POST /registrations (RB-06)
POST /registrations passed its Documents list straight to Submit, which calls
DocumentStore.Link on every digital documentId in it — and linking a document
blocks its owner from ever deleting it (DeleteOwned returns 409 Linked). That
path had no ForeignIds ownership check, so any authenticated citizen could
post another citizen's document id and permanently block them from deleting
their own diploma scan. POST /applications/{id}/submit, the endpoint actually
in use, has had that guard since it was written.

Deleted rather than guarded: the endpoint is dead. No frontend caller, and
the whole registratie flow goes through /applications/{id}/submit.
RegistratieRequest went with it, and so did SubmissionRules.RejectRegistratie
— reachable only from here, and contradicted by the live path, which treats a
handmatig diploma as "does not auto-approve" rather than a 422 rejection. Its
own message said as much while being returned as a rejection. That last part
is a judgement call beyond the ticket's wording; reverting the two
SubmissionRules hunks restores it in isolation.

Coverage moved rather than vanished: the problem+json shape assertion is now
on /change-requests (the other endpoint on the same Submit helper), and the
linked-delete 409 test goes through the real submit path.

swagger.json, the generated client and the behaviour spec regenerated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 11:04:03 +02:00
ehoandClaude Opus 5 487818e67a fix(privacy): mask the owner BSN on the cross-owner case lists (RB-03)
Mappers.ToAdminSummaryDto set Owner to the raw BSN. Both consumers are
cross-owner lists read by someone who is not the subject — GET /admin/cases
and GET /werkvoorraad — while GET /beoordeling/{id}, the detail view of the
same data, already masked it. The detail screen showed ******782 and the list
one click earlier showed the whole thing.

Masked in the mapper rather than at each endpoint, so a third cross-owner
list cannot be added that forgets to.

MaskTail moves out of Program.cs into Domain/People/Pii.cs: it now has
callers in Contracts, Program.cs and (once RB-04 lands) Data, and a second
hand-rolled copy is how one of them drifts into leaking. Documented as
idempotent, which is what lets /beoordeling/{id} keep its own call —
IZaakSource has a second implementation whose Owner is mapped from the
OpenZaak zaak identificatie, so that endpoint should not depend on which
source answered.

No frontend change: all three consumers display the value, and the parse
boundaries only require a non-empty string.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 10:52:29 +02:00
ehoandClaude Opus 5 6ffd3643b1 fix(audit): stop writing a BSN into the authz audit Resource (RB-02)
Program.cs built the BIG-nummer reveal's audit resource ref as
"brief/" + ctx.Zorgverlener().Bsn. AuditAuthz persists that to the
AuthzAudit.Resource column in SQLite and /admin/audit renders it, so a BSN
reached durable storage and a UI on the one trail four documents describe as
data-minimised and PII-free — on the endpoint whose own comment promises the
audit carries no PII.

The ref is now "brief". Nothing is lost: BriefStore keys one brief per owner,
so the id named what the row's acting principal already implies.

The existing guard, The_audit_schema_carries_no_pii, asserts on column names,
so a BSN inside a column called Resource could never fail it. Added
No_audit_row_carries_a_subjects_bsn, which drives a denied reveal as a
non-default subject and scans every string field of every row for that BSN
and for DemoOwner — asserting on the two BSNs actually in play rather than a
\d{9} shape, since a hex correlation id can hold nine digits by chance.
Verified it goes red when only the Program.cs line is reverted.

AuditEntry.Actor on document audit rows holds a raw BSN too; that is a
different store and stays with RB-04.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 10:49:57 +02:00
ehoandClaude Opus 5 a2e935d1d8 fix(uploads): authorize the document-content and status endpoints (RB-01)
GET /uploads/{documentId}/content took only (string documentId) — no
HttpContext, so no authorization was possible. It streams diploma and
identity scans, protected by GUID unguessability alone, while DELETE on the
same resource has always been owner-scoped. GET /uploads/status had the same
shape and confirmed whether any client-chosen localId exists, plus its
documentId.

Both now take HttpContext. Content is readable by the owning
ZorgverlenerCaller or a caller passing Authz.CanBeoordelen — matched on the
caller kind rather than branched on a boolean, because ctx.Zorgverlener()
throws for a MedewerkerCaller and the behandelportal's beoordeling screen is
a legitimate reader. Status is scoped to ctx.Zorgverlener().Bsn via a new
owner parameter on DocumentStore.ByLocalIds (one call site).

404, not 403, on both: a foreign document id must not be distinguishable
from one that never existed, and a foreign localId reads back as "unknown".

Residual, recorded in the implementation note: both callers reach the URL as
a plain browser navigation (<a href> / previewUrl), which carries no identity
header and no interceptor, so StubIdentityProvider resolves it to the seeded
citizen. That is BIO-002 and belongs to RB-09; the links keep working today
only because one citizen owns every document in the POC.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 10:48:04 +02:00
ehoandClaude Sonnet 5 6bc00a917c refactor(backend): delete dead legacy endpoints, make domain types unions (WP-72 + WP-73)
Two work packages in one commit because both edit Program.cs and splitting
them would leave a commit that does not build.

WP-72 — deletes POST /api/v1/intakes and /herregistraties. Both were dead
from the UI (the wizard submits via /applications/{id}/submit) and strictly
less capable: they minted a bare reference and wrote no Aanvraag, made no
ZGW call, and did no document-ownership check. The shared Submit(...) helper
survives — /registrations and /change-requests still use it. WP-69 hardened
/intakes with a 400 last session; removing the surface is the stronger fix,
and WP-69's /applications/{id}/submit enforcement is untouched.

WP-73 — RegistrationStatus becomes an abstract record with three sealed
variants behind a private base ctor, so only Geregistreerd carries a
herregistratie deadline and reden is required on Geschorst/Doorgehaald
(matching the FE union, which was already right). HerregistratieRule
.IsStatusConsistent and its test are deleted: the type now guarantees what
the runtime check was for, and the test could no longer construct the
illegal state it existed to catch.

Aanvraag splits into a Concept | Submitted | Decided union with the EF row
demoted to AanvraagEntity behind a two-way mapper. Submitted carries a
non-null Referentie and SubmittedAt, and Decided.Afgewezen/MeerInfoGevraagd
require a Toelichting — so the five Referentie! null-forgiving derefs in
StatusAt are gone, not merely suppressed. IZaakSource.CreateZaak narrows to
Aanvraag.Submitted, removing the same class of deref in both zaak sources.

Draft is now cleared on submit rather than lingering: ApplicationStore's
doc-comment claimed "Concept only" but Submit never cleared it. Verified
nothing reads a submitted aanvraag's draft (draft-sync's applyResume only
resumes unsubmitted wizards), so the comment is now true instead of
aspirational.

No migration, no schema change, no wire change — RegistrationStatusDto and
the application DTOs are byte-identical, confirmed against a live swagger.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 16:31:38 +02:00
ehoandClaude Sonnet 5 5d73ca21f6 feat(backend): enforce the scholing threshold server-side (WP-69)
ADR-0001's own canonical "config value" example was unenforced: GET
/intake/policy echoed ScholingThreshold, but no request DTO carried a
scholing answer, so the server had nothing to re-validate. A crafted
POST could skip a requirement the wizard presents as mandatory.

IntakePolicy.RejectIncompleteScholing is the authority — three-valued
completeness (below threshold an answer is required; "nee" is legal and
still submits; punten only belong to a followed scholing), living in the
class that owns the constant so scripts/check-seam.sh keeps guarding the
FE/BE literal pair. Both submit paths call it; a violation 400s with
ProblemDetails and leaves the aanvraag a Concept. Gated on
Type == "intake" (the endpoint's switch lumps herregistratie with
intake, which has no scholing question), and guarded by `reject is null`
so a zero-uren submission is still decided on its merits.

Also fixes a live FE bug in the same rule: validateStep required punten
whenever scholingGevolgd was 'ja' regardless of lageUren, while the
template renders those fields only when lageUren — so answering 'ja'
then raising uren either blocked the user on an invisible field or
emitted aanvullendeScholing: undefined alongside punten. punten now
derives from aanvullendeScholing, so that combination is unrepresentable
in ValidIntake.

Note: EndpointTests' Worked_hours_submission_succeeds was itself
asserting the vulnerable payload ({ uren: 40 }, no answer) and needed a
complete answer added; the zero-hours rows are the ordering regression
net and are unmodified.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 22:42:14 +02:00
ehoandClaude Opus 5 fc6e73806a refactor(backend): move aanvraag status lifecycle into the domain (WP-68 F3)
The status was derived in Contracts/Mappers.ToStatusDto, not the domain; Concept was
a magic "Concept" string with no AanvraagStatusTag member; and the besluit endpoint
re-derived its own guard by reading the status back out of the DTO and Enum.Parse-ing
it. New Domain/Applications/AanvraagStatus.cs models the full status (Concept
included, via a null Tag rather than a sixth enum member) as a closed type,
constructible only through its factories. Aanvraag.StatusAt(now) carries the logic
verbatim; Mappers.ToStatusDto and ZgwZaakMapper's two status producers become
one-line projections onto the same wire DTO, so the wire shape is unchanged (gen:api
shows zero diff beyond F1's). The one remaining Enum.Parse (the beoordeling GET,
which crosses the IZaakSource wire boundary) is now non-throwing on an unrecognised
tag.

Also, WP-68 F2: the besluit transition-legality check now runs inside
ApplicationStore.RecordBesluit's write lock instead of in the endpoint beforehand —
two concurrent besluiten used to both pass the check before either wrote, letting
the second silently overwrite a terminal decision. RecordBesluit returns an
Ok/NotFound/Conflict outcome, mirroring DocumentStore.DeleteResult.

Also, WP-68 F6: the "toelichting required" rule moves from an inline endpoint check
into BeoordelingRules.RequiresToelichting, alongside CanDecide.

The three tests naming this refactor's regression net
(AanvraagStatusTag_covers_the_published_lifecycle,
AutoApprovable_flips_to_goedgekeurd_after_the_window, ZgwZaakMapperTests) pass
unmodified.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 15:34:10 +02:00
ehoandClaude Opus 5 a394950a1d fix(backend): reject foreign documentIds on submit and draft-sync (WP-68 F1)
submit and draft-sync took document ids straight from the request body with no
ownership check: a caller who knew a foreign document's id could attach another
citizen's upload to their own aanvraag (surfacing on the behandelaar's beoordeling
screen, POSTed to OpenZaak as their zaakinformatieobject) and permanently block the
victim's own delete by flipping Linked=true. ADR-0001 holds the FE has no authority;
this trusted it anyway.

Adds DocumentStore.ForeignIds(ids, owner) and calls it from both write paths before
any write, 400 ProblemDetails on a mismatch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 15:24:32 +02:00
ehoandClaude Opus 5 6cfd70eeeb fix(backend): resolve besluit endpoint's id via Referentie, not local PK
POST /beoordeling/{id}/besluit always 404'd against a real OpenZaak: {id} is the
FE-facing case id from IZaakSource.ListCases, which under OpenZaakZaakSource is the
ZGW zaak's own uuid, not ApplicationStore's primary key. Resolve the case through
ListCases first (same seam the GET sibling already uses), then to the local Aanvraag
via its Referentie — the one identifier stable across both sources.

Adds ApplicationStore.GetByReferentie and a regression test that reproduces the
divergence with a decorating IZaakSource test double instead of a live OpenZaak.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 15:20:36 +02:00
ehoandClaude Sonnet 5 d996ca2463 feat(behandelportal): WP-66 wire the decision into OpenZaak
Extends IZaakSource with RecordBesluit, mirroring WP-50's CreateZaak write
pattern: OpenZaakZaakSource POSTs a new Statussen entry (highest-volgnummer
statustype, since the harness catalogus has no per-outcome besluittype),
carrying the besluit + toelichting in statustoelichting; LocalZaakSource
no-ops. The beoordeling endpoint calls it after the local decision commits,
flagging a failure via RecordZgwDivergence the same way submit's
create-zaak/document writes do — closing WP-60's "second write pair" gap.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 09:41:28 +02:00
ehoandClaude Sonnet 5 af8a011819 feat(behandelportal): WP-65b beoordeling besluit (decision write)
CI / changes (pull_request) Successful in 17s
CI / lint (pull_request) Failing after 56s
CI / frontend (pull_request) Successful in 2m36s
CI / storybook-a11y (pull_request) Failing after 3m19s
CI / backend (pull_request) Failing after 1m55s
CI / api-client-drift (pull_request) Canceled after 0s
CI / e2e (pull_request) Canceled after 40s
CI / semgrep (pull_request) Canceled after 24s
Adds POST /beoordeling/{id}/besluit: a Besluit enum (Goedkeuren/Afwijzen/
MeerInfoOpvragen) backed by new Aanvraag.BesluitStatus/BesluitToelichting
columns, gated by the same BeoordelingRules.CanDecide the read side's
canBesluiten flag already uses (409 on an illegal transition, 400 on a
missing required toelichting). Mappers.ToStatusDto gains the "a recorded
decision wins" branch. FE: besluit.machine.ts + besluit-form organism
(same form idiom as change-request-form), wired into the beoordeling page
behind the server's canBesluiten flag.

Completes WP-65 (65a + 65b) — verified end-to-end against a running
backend (werkvoorraad -> beoordeling -> besluit -> status reflected back).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 09:46:20 +02:00
ehoandClaude Sonnet 5 4133b30e5d feat(behandelportal): WP-65a beoordeling detail (read) + fix unreachable medewerker login
CI / changes (pull_request) Successful in 17s
CI / lint (pull_request) Failing after 54s
CI / frontend (pull_request) Successful in 2m38s
CI / storybook-a11y (pull_request) Failing after 3m28s
CI / backend (pull_request) Successful in 2m1s
CI / semgrep (pull_request) Successful in 1m9s
CI / e2e (pull_request) Successful in 2m55s
CI / api-client-drift (pull_request) Successful in 2m1s
New GET /beoordeling/{id} shows one aanvraag's status, linked documents, and a
canBesluiten decision flag, gated by the same CanBeoordelen capability as the
werkvoorraad list. Reads through IZaakSource.ListCases rather than a new seam
method (WP-66 needs one anyway for the real write); owner BSN is masked.

Fixes a real gap found while wiring this up: the behandelportal's login was still
WP-61's copied citizen/BSN DigiD flow, so nothing ever sent X-Medewerker and the
werkvoorraad screen (WP-64) always denied in a real browser. A dev-only
medewerkerInterceptor (mirrors the existing ?role= stand-in as ?rollen=) fixes that.

WP-65's own Risks note authorized splitting read from write across sessions given
its size; this is the read half. The decision-recording mutation is next (65b).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 09:01:09 +02:00
ehoandClaude Sonnet 5 fe69caee63 feat(behandelportal): WP-64 werkvoorraad (queue) screen
CI / changes (pull_request) Successful in 15s
CI / lint (pull_request) Successful in 57s
CI / frontend (pull_request) Successful in 2m36s
CI / storybook-a11y (pull_request) Failing after 3m14s
CI / backend (pull_request) Successful in 2m1s
CI / semgrep (pull_request) Successful in 1m10s
CI / e2e (pull_request) Successful in 3m3s
CI / api-client-drift (pull_request) Successful in 2m1s
New GET /werkvoorraad endpoint lists aanvragen still open (Ingediend/InBehandeling),
gated by the medewerker capability (CanBeoordelen) rather than the admin role — reuses
the existing ApplicationSummaryDto, no new DTO. GET /me now surfaces aanvraag:beoordelen
for a behandelaar so the FE can gate with the same AccessStore/capabilityGuard idiom
every other page uses.

FE: a behandeling domain type deliberately narrower than ssp's full AanvraagStatus
union (only the two open tags — illegal states unrepresentable), composed into a
werkvoorraad-list organism from existing shared/ui molecules. Replaces WP-61's
scaffold placeholder as the app's real landing page.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 22:02:35 +02:00
ehoandClaude Sonnet 5 a09c4ed87b feat(behandelportal): WP-62 medewerker caller identity + authz seam
Splits backend CallerIdentity into the two ADR-0002 §3 actor kinds
(ZorgverlenerCaller/MedewerkerCaller), a stub X-Medewerker/X-Rollen header
path mirroring WP-53's citizen stub, and Authz.CanBeoordelen as the first
medewerker capability — backend-only, no consumer until WP-64. Also fixes
the backlog README's stale WP-61 status (done, but table said todo).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 23:47:43 +02:00
ehoandClaude Sonnet 5 ba24784586 feat(openzaak): one-command UI-OpenZaak bridge, real BSN fix, flake mitigation
CI / changes (push) Successful in 8s
CI / lint (push) Successful in 55s
CI / frontend (push) Successful in 1m33s
CI / backend (push) Successful in 1m46s
CI / e2e (push) Successful in 3m14s
CI / storybook-a11y (push) Successful in 6m50s
CI / semgrep (push) Successful in 1m13s
CI / api-client-drift (push) Successful in 1m47s
scripts/openzaak-ui-up.sh brings up the root app and the OpenZaak harness
together, wires them onto one docker network, seeds the catalogus, grants
the container-alias zaaktype scope, and verifies a real aanvraag submitted
through the UI lands in OpenZaak.

Along the way: DocumentStore.DemoOwner was reusing the seeded doctor's
11-digit BIG-nummer as a stand-in BSN, which isn't a valid 9-digit BSN shape
— OpenZaak rejects it, breaking both submit's rol-creation step and the
citizen's own applications list under Zgw:Enabled=true. Fixed to a real
elfproef-valid BSN.

Also adds mitigation for a still-unexplained per-container flake (every
outbound ZGW POST fails as if the body were empty, for that container's
whole lifetime) that correlates with host memory pressure: the script now
warns when host swap is heavily used, and an opt-in ZgwDiagnosticHandler
(ZGW_DEBUG_HTTP=1) logs Content-Length vs. actual bytes sent so the next
reproduction can confirm or rule out client-side body corruption.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 08:23:01 +02:00
ehoandClaude Sonnet 5 3ff80c124f feat(openzaak): bounded retry + flagged write divergence (WP-60)
Local aanvraag/document writes and their paired ZGW writes aren't
transactional; a ZGW failure after the local write succeeds used to
diverge silently. ZgwHttpClient now retries transport-shaped failures
(not 500, which can follow a partial commit on the non-idempotent
statussen/rollen POSTs), and a ZGW failure that survives retry sets
Aanvraag.ZgwError plus a zgw:divergence audit row instead of failing
or diverging quietly. No outbox/reconcile job: three request-triggered
write paths don't justify a persisted queue that would also need to
carry citizen PII for the JWT audit claims.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 18:11:55 +02:00
ehoandClaude Sonnet 5 73172510ea feat(zgw): real per-request identity seam + citizen-scoping (WP-53)
CI / frontend (push) Failing after 1m19s
CI / backend (push) Successful in 2m0s
CI / e2e (push) Successful in 3m57s
CI / storybook-a11y (push) Successful in 7m45s
CI / semgrep (push) Successful in 1m6s
CI / api-client-drift (push) Successful in 1m55s
Replaces the hardcoded DocumentStore.DemoOwner and the static ZgwOptions
UserId/UserRepresentation with one per-request CallerIdentity, resolved by a
pluggable IIdentityProvider (StubIdentityProvider reads X-Role/X-Subject
today; a real OIDC/DigiD provider swaps in without touching any consumer).

- Domain/Authorization/{CallerIdentity,IIdentityProvider,StubIdentityProvider}.cs
  + a resolution middleware in Program.cs, right after correlation-id.
- Authz.ResolvePrincipal(ctx) keeps its signature (now reads ctx.Caller().Role),
  so its ~15 call sites needed no changes.
- Every endpoint that passed DocumentStore.DemoOwner to a store now passes
  ctx.Caller().Bsn.
- ZgwTokenProvider gains Mint(CallerIdentity) alongside the original Mint()
  (kept for calls not tied to one citizen); ZgwHttpClient threads an optional
  caller through to pick the right overload.
- IZaakSource gains ListMyCases(caller, now) — the citizen-scoped read
  OpenZaakZaakSource backs with ZGW's rol__...__inpBsn filter. GET /applications
  now routes through it instead of ApplicationStore directly, closing the last
  "reads a static store" gap for a citizen-facing endpoint.

Backend 159/159 tests (+8, incl. an HTTP-level two-identity scoping proof),
npm run ci green, no api-client drift.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 08:27:53 +02:00
ehoandClaude Sonnet 5 c4dd846fbb fix(ci): unbreak backend format, storybook-a11y, and e2e jobs
CI / frontend (push) Successful in 2m12s
CI / backend (push) Successful in 1m37s
CI / e2e (push) Successful in 3m33s
CI / storybook-a11y (push) Successful in 8m23s
CI / semgrep (push) Successful in 1m4s
CI / api-client-drift (push) Successful in 1m52s
- backend: dotnet format the WP-51 migration (2-space indent, no BOM)
  to match .editorconfig — dotnet format --verify-no-changes was failing.
- storybook: stub FeatureFlagStore (WP-47) in shell/site-header stories
  alongside AccessStore, fixing NG0201 no-provider errors; bump the
  storybook-a11y container's memory cap 4g→6g (build-storybook +
  compodoc measured ~5.8GB peak RSS, leaving too little headroom).
- backend: fix a startup-breaking bug in the new (WP-52) POST
  /zgw/notificaties handler — it took ZgwOptions as a minimal-API
  parameter, which isn't registered in DI, so ASP.NET's endpoint-table
  build threw on every request once the route was registered (incl.
  /swagger, which is why Playwright's webServer health check timed
  out). Close over the existing `zgw` local instead.
- e2e: brief-v2.spec.ts's "Voorbeeld" button locator was ambiguous
  once a second "Voorbeeld met testwaarden" button existed (Playwright
  name matching is substring-based) — added `exact: true`. Also fixed
  the sent-letter preview flow to match app-letter-composer's actual
  behavior (single click → fetch, no in-page dialog, unlike
  app-behandel-scherm's), and fixed a watermark assertion that checked
  for the always-present `.preview-watermark` CSS class name instead
  of the conditionally-rendered "VOORBEELD" marker text.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 07:18:07 +02:00
ehoandClaude Sonnet 5 5807937229 feat(zgw): OpenZaak Documenten (DRC) upload + zaak link (WP-51)
Extends the OpenZaak seam with IDocumentSource, sibling of IZaakSource
(WP-49/50): an upload always lands locally first (DocumentStore stays
the record of truth for preview/download/audit) and, when
Zgw:Enabled=true, is also registered as a DRC enkelvoudiginformatie-
object; once a zaak exists (IZaakSource.CreateZaak now also returns
its ZaakUrl), submit links each document to it via zaakinformatie-
object. FE upload/list DTOs are unchanged.

- ZgwOptions gains DrcBaseUrl + a category->informatieobjecttype URL
  map (the document analogue of ZaaktypeUrls).
- LocalDocumentSource is the same DocumentStore.Add/Link calls the
  endpoints used to make inline — zero behaviour change offline.
- OpenZaakDocumentSource POSTs the eio then the zaak link, persisting
  the DRC url (DocumentStore.SetDrcUrl) so linking doesn't re-upload.
- Factored the GET/POST-with-bearer-JWT plumbing shared with
  OpenZaakZaakSource into ZgwHttpClient; shared the stub handler
  between the two source test classes as ZgwStubHandler.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 20:54:31 +02:00
ehoandClaude Sonnet 5 de3bff0d7f feat(zgw): OpenZaak create-zaak, first write slice (WP-50)
Extends the IZaakSource seam (WP-49, read-only) with CreateZaak: submitting
an aanvraag now also registers a Zaak + Status + Rol in OpenZaak when
Zgw:Enabled=true, routed through the existing /applications/{id}/submit
endpoint with the FE response DTO unchanged (ADR-0001/ADR-0005 — the
endpoint never branches on the config flag itself, DI already picked the
implementation).

- ZgwOptions gains a Type→zaaktype-URL map + the two RSINs a Zaak needs.
- LocalZaakSource.CreateZaak is a pure passthrough of what the endpoint
  already computes locally (zero behaviour change for the offline default).
- OpenZaakZaakSource.CreateZaak POSTs the zaak (identificatie = the same
  local reference, so both stay in sync), resolves + POSTs the initial
  status and the initiator rol (BSN) via Catalogi lookups, and maps the
  result back into the submit response.
- Marked ponytail shortcuts: first-statustype/roltype-Catalogi-returns
  (no per-type config) and no compensating transaction on partial failure
  — both fine for a first slice against a demo backend.

Verified: full `npm run ci` green, zero api-client drift, 144/144 backend
tests (142 existing + 2 new stub-handler tests asserting the POST bodies
+ type→zaaktype mapping per the acceptance criteria).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 09:03:13 +02:00
ehoandClaude Opus 4.8 1c3c195d32 feat(backend): expand stamdata + OpenZaak-ready cases seam (WP-49)
CI / frontend (push) Successful in 2m59s
CI / backend (push) Successful in 1m27s
CI / semgrep (push) Successful in 58s
CI / e2e (push) Successful in 2m30s
CI / api-client-drift (push) Canceled after 1m14s
CI / storybook-a11y (push) Canceled after 29m8s
Stamdata: add beroepen, opleidingen (temporal), and specialismen tables to the
schema-driven catalog (zero UI code). opleidingen.beroep and specialismen.beroep
both reference beroepen.code — the first stamdata->stamdata references, enforced by
two new StamdataRef entries in the CI gate.

OpenZaak/ZGW (WP-49, slice 1 — read-only zaken): introduce IZaakSource as the cases
read seam. Default LocalZaakSource reads the local SQLite store (offline); an
OpenZaakZaakSource (Zgw/ client: HS256 per-call JWT, ZGW->existing-DTO mapper,
paginating HTTP source) is selected behind Zgw:Enabled (default false). The FE never
changes — same ApplicationSummaryDto, no api-client drift. Unit-tested with fixtures
+ a stub HttpMessageHandler; no live OpenZaak needed.

Docs: ADR-0005, reference/openzaak-integration.md, WP-49..52 roadmap, stamdata.md
update, README index rows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 15:01:06 +02:00
ehoandClaude Opus 4.8 67802c68b4 feat(admin): runtime feature flags (catalog-in-code, admin toggle, FE+backend)
Catalog declared in code (Domain/Features/FeatureFlags.cs, build-validated), on/off state
persisted in SQLite (FeatureFlagStore + migration). GET /flags (drives FE gating) + admin
PUT /admin/flags/{key} (new flags:manage capability + FlagsAdmin gate). Enforced end-to-end:
the `inschrijving-open` flag hides the Inschrijven nav item + dashboard action (FE) AND makes
POST /applications for a registratie 403 when off (backend). FE FeatureFlagStore mirrors
AccessStore (enabled() deny-by-default); admin toggle page at /beheer/functies in ADMIN_LINKS.
+4 backend tests, /me cap-list updated, client regenerated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 22:29:48 +02:00
ehoandClaude Opus 4.8 0f30143c5d feat(privacy): WP-41 — persisted, queryable authz/PII-reveal audit
Persist the security-relevant events (authz denials + BIG-nummer reveal/step-up) into a
data-minimised EF table (AuthzAuditEntry: At/Action/Resource/Decision/Role/CorrelationId —
never a name/BSN/value), extending the DocumentStore AuditEntry pattern (migration AuthzAudit).
AuditAuthz now persists via AuthzAuditStore.Record alongside its log line. GET /admin/audit
(admin-gated by the existing CasesAdmin) returns the trail newest-first. +3 backend tests incl.
a schema-carries-no-PII reflection test. Typed client regenerated (audit() + AuthzAuditDto);
no FE consumer yet (a future audit view must add the ROLE_AWARE prefix). Finishes WP-42's audit half.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 15:53:33 +02:00
ehoandClaude Opus 4.8 446ea9474b feat(registratie): WP-36 — admin cases page + admin delete
Admin-only overview of all cases across owners + an admin delete, gated by a new
`cases:manage` capability (Authz role→cap + CanManageCases + CasesAdmin gate;
FE capability + guard + nav + role.interceptor prefix — the org-template/stamdata
recipe). Backend adds ApplicationStore.ListAll()/DeleteAny() and GET /admin/cases +
DELETE /admin/cases/{id}; admin delete removes ANY case incl. submitted. Page lives
in registratie/ui (owns the Aanvraag aggregate; reuses aanvraag-view + parse),
routed /beheer/zaken; delete guarded by a native confirm, optimistic with rollback.
Typed client regenerated (documents the new endpoints + owner field).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 12:23:34 +02:00
ehoandClaude Opus 4.8 d1abd35b0d feat(registratie): WP-35 — one Concept per case type (server-enforced)
CI / semgrep (push) Has been cancelled
CI / frontend (push) Successful in 1m47s
CI / storybook-a11y (push) Successful in 5m17s
CI / backend (push) Successful in 1m29s
CI / e2e (push) Successful in 3m1s
CI / api-client-drift (push) Successful in 2m4s
Make "at most one unsubmitted Concept per type" a server invariant instead of a
client-only convenience. ApplicationStore.Create → CreateConcept guards atomically
under the write gate and POST /applications returns 409 when a duplicate would be
created. The FE draft-sync recovers from the 409 by adopting the existing Concept
(ensureId → findConcept) rather than erroring — one-per-type means the second
attempt lands on the existing draft. Typed client regenerated (documents the 409).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 11:07:11 +02:00
ehoandClaude Opus 4.8 0ea43af7b6 feat(registratie): WP-34 — phone field + BRP address read-only
Reshape the adreswijziging form into a contact-change form: the BRP address is
authoritative and shown read-only (you change it at the gemeente), and the phone
number becomes the editable/submittable field. New Telefoonnummer value object
(parse-don't-validate); backend RejectPhoneChange re-validates as authority.
POST /change-requests now carries { telefoon } (typed client regenerated).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 20:11:26 +02:00
ehoandClaude Opus 4.8 0e77faf351 feat(stamdata): admin stamdata maintenance editor (beheer)
Realizes ADR-0004's "future low-code editor that commits a PR": an
admin-only stamdata maintenance editor built on the stamdata-as-code
foundation.

Backend: `professions` moves from a hardcoded C# dictionary to an embedded
`professions.json` data-file (typed as `ProfessionMapping`) with valid-time
(geldigVan/geldigTot, half-open). A generic, reflection-driven
StamdataCatalog/StamdataTable/StamdataFile describes every table so one
endpoint pair + one grid editor serve all of them; add a table in one line.
Two read-only, admin-gated endpoints (GET /stamdata, GET /stamdata/{table}
?peildatum=) — no runtime write path. Generic build gate
`Every_catalog_table_is_valid` (keys non-blank, no overlapping validity,
well-formed windows).

Frontend: new `beheer` context (route beheer/stamdata, capabilityGuard
'stamdata:edit'). A schema-driven grid editor edits rows locally; download()
emits {table}.json for the admin to commit as a reviewed PR (no mutation
command — the CI build + StamdataValidationTests stay the authority).

Full gate GREEN both sides; gen:api leaves no drift; new stamdata story
passes axe. See WP-29 + ADR-0004.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 13:43:51 +02:00
ehoandClaude Opus 4.8 5cae44f163 feat(security): ABAC P2/P3-lite — BIG-nummer redaction, authz audit, guard; clear dev audit
- fix(deps): pin @babel/core ^7.29.7 via overrides → npm audit 0 (dev+prod),
  no --force / no Angular downgrade; README corrected
- feat(brief): field-level PII reveal (PRD-0002 §5c) — CaseContext BIG-nummer
  ships masked; step-up-stubbed (X-Step-Up), audited POST /brief/reveal-bignummer
  unmasks it; drafter-only capability, deny-by-default. Realized on the BIG-nummer
  (no BSN on the wire)
- feat(authz): no-PII AuditAuthz log for reveal attempts + org-admin denials (§8)
- feat(routes): wire capabilityGuard('orgtemplate:edit') onto brief/huisstijl (§6)
- test: backend +5 (Authz + reveal endpoint), FE +3 (adapter boundary, store swap)
- docs: PRD-0002 §5c/§9, WP-18 follow-up, README

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 19:59:20 +02:00
ehoandClaude Opus 4.8 ba32e3dd9f feat(fp): brief v3 — besluit-driven guided drafting
Compose the herregistratie letter from the besluit instead of a library hunt:
the behandelaar picks positief/negatief (+ reden-checkboxes for a negatief) and
the kern's standaardteksten follow the selection live.

Front-end (this increment):
- Kern is recomposed reactively from the besluit selection (new BesluitSelected
  machine msg + composeKern); the "Genereer conceptbrief" button is gone. The
  drafter's free text is preserved across a selection change.
- The editor shows only the editable sections; the locked aanhef/slot render in
  the preview, not the authoring surface. Slot is a case-type template section
  (per templateId), documented as such.
- The panel re-seeds from the letter via inferSelection() — the besluit + redenen
  are read back off the kern's passage blocks, so the selection survives reload
  with no new wire fields (derive, don't store).
- letter-section drops the now-redundant per-section passage picker (besluit owns
  standaardteksten); keeps free-text + block edit/move/remove.

Fix: app-checkbox now falls back to a unique per-instance id when checkboxId is
omitted. The CIBG styled checkbox routes clicks through the label, so the shared
id="undefined" made every reason label toggle the first input — the second
checkbox could never be checked. Verified live (Playwright): each reason toggles
independently.

Backend/seam (brief v3 WIP): besluit/reason passage tags on the wire + seed,
carried through the adapter parse boundary.

Specs updated (besluit, brief.machine) and the affected stories re-pointed at the
new API. FE lint + build + 253 vitest specs green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 18:53:53 +02:00
ehoandClaude Sonnet 5 1bb9383344 feat(fp): WP-25 — server-rendered letter HTML preview
Adds LetterHtml.Render, a pure composer mirroring the FE letter canvas'
class vocabulary, behind two ExcludeFromDescription()'d endpoints
(GET /brief/preview, GET /admin/org-template/{subOrgId}/preview).
Auto-resolvable placeholders pull from seed/case data; unresolved
manual ones render as "[NOG IN TE VULLEN: label]". A sent brief
archives its composed HTML (BriefEntity.ArchivedHtml) so a later
org-template republish never changes it. FE gets a hand-written fetch
adapter (text/html, not JSON) and a "Voorbeeld" button that opens the
preview in a new tab.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-05 12:56:36 +02:00
ehoandClaude Fable 5 5a610c10f0 feat(fp): WP-23 — org-template backend + admin role
Second template axis (org identity: letterhead, footer, signature,
margins) server-side: OrgTemplateStore with JSON version history,
publish/rollback, sent-brief version pinning, admin role + capability,
5 admin endpoints, org-logo upload category. FE seam widened only
(Role/Capability unions, interceptor); WP-24/26 consume it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 11:17:05 +02:00
ehoandClaude Sonnet 5 556f2f47bf feat(fp): WP-22 — durable persistence (SQLite/EF Core)
Applications, documents (+ audit log) and the brief move off static in-memory
Dictionaries onto a real SQLite file via EF Core, so demo data survives a
process restart or `docker compose restart api` for the first time. The three
stores (ApplicationStore/DocumentStore/BriefStore) keep their exact public
signatures and static-class shape — no DI, no async ripple into Program.cs's
minimal-API handlers — each method just opens a short-lived AppDbContext via
Db.Create() under the same lock it already had. Opaque nested shapes (a
wizard's draft snapshot, a brief's sections/placeholders/status) are stored as
JSON text columns rather than redesigned into relational tables, matching the
existing "don't interpret it" posture.

Found two things the WP's own text got wrong, corrected in
docs/backlog/WP-22-durable-persistence.md's Deviations section: SeedData never
seeded these three stores (only the read-only BRP/DUO-mimicking GETs, which
stay in-memory) so there's no seed step; and no new docker-compose volume is
needed since the existing bind mount already covers the SQLite file — verified
against this environment's real podman-backed compose stack, not just by
reading the file.

Also: pinned SQLitePCLRaw.bundle_e_sqlite3 to 3.0.3 (EF Core Sqlite's own
transitive default bundles a pre-3.50.2 SQLite with a known high-severity
memory-corruption advisory); found and fixed a real xUnit test race where
concurrent test-class hosts stomped a shared static connection-string field,
fixed by disabling cross-class test parallelization rather than adding DI the
stores don't otherwise need.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-05 10:19:23 +02:00
ehoandClaude Sonnet 5 40dbcb2606 feat(fp): WP-21 — resilience seams (correlation-id, idempotency, retry)
Correlation id becomes real ASP.NET Core middleware instead of a per-endpoint
read: every request gets one (client-supplied or generated), it's echoed as
an X-Correlation-Id response header, and pushed into the logging scope so
every log line for that request carries it — not just the Submit helper's,
verified against LogBrief which never threads it explicitly.

Idempotency-Key moves from per-HTTP-attempt (defeating its own purpose) to
per-logical-submit: runSubmit mints one key and threads it through a small
bridge (withIdempotencyKey/currentIdempotencyKey) since the NSwag-generated
client has no per-call header hook. Backend gains an IdempotencyStore that
short-circuits a replayed key to the first call's result instead of minting
a second reference — scoped to the Submit-helper endpoints per the WP's own
decision.

GET requests now retry transient failures (rxjs retry({count:2, delay:500}));
writes never auto-retry. Proven with a fake-HttpClient spec
(api-client.provider.spec.ts) rather than a manual network-tab check — the
WP's suggested `?scenario=error` check turned out not to exercise a real
network call at all (the interceptor throws before calling next()), so the
automated test is the actual proof.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 20:03:41 +02:00
ehoandClaude Sonnet 5 7ec13d8b59 feat(brief): WP-18 — ABAC capability spine (PRD-0002 phase P1)
Replace the FE-computed authorization anti-pattern in BriefStore.editable
(derived from the unverified X-Role header) with server-computed decision
flags, mirroring the existing HerregistratieDecisionsDto pattern:

- Backend: Authz.cs is the single authorization helper — the SAME check
  (Authz.CanActOn) both gates BriefStore.Review's mutations and computes
  the BriefDecisionsDto flags shipped on every brief response, so emit
  and enforce can never drift. New GET /me returns coarse, role-derived
  capabilities (PRD-0002 SS6).
- Every brief endpoint (including send, previously ungated on HttpContext)
  now returns a fresh BriefViewDto so decisions never go stale after a
  mutation.
- FE: brief.store.ts reads canEdit/canApprove/canReject/canSend off the
  loaded decisions instead of computing them from currentRole(); the
  brief.machine carries decisions through every status transition.
- New shared/domain/capability.ts + shared/application/access.store.ts +
  shared/infrastructure/me.adapter.ts: the general capability-spine
  infrastructure (AccessStore.can(), capabilityGuard) for future routes.

Deviates from the original WP-18 draft by NOT renaming auth/domain's
Session to a Principal union — ADR-0002 explicitly defers that refactor
until a second actor exists, and the brief workflow's drafter/approver
identity turned out to be a separate axis from the SSP login session
entirely. See docs/backlog/WP-18-abac-capability-spine.md for the full
as-built record.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 20:31:53 +02:00
ehoandClaude Opus 4.8 1137f59f7b style: format backend with dotnet format
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 13:39:31 +02:00
ehoandClaude Opus 4.8 84c2d1b6a0 feat(brief): locked sections, list formatting, auto/manual placeholder chips
- brief.machine: reducer refuses edits to locked (predefined) sections as
  defense-in-depth; LetterSection gains a `locked` flag
- rich-text: paragraphs gain optional `list` kind; editor gets bullet/numbered
  list buttons, keyboard shortcuts, and backspace-deletes-adjacent-chip
- placeholder chips distinguish auto-resolvable (grey) vs manual (yellow), in
  both the editor and the read-only preview
- fix: preview chip now renders matching {…} braces (was a one-sided ⌗ glyph),
  aligned with the editor's chip styling

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 08:50:22 +02:00
ehoandClaude Opus 4.8 053160c5c9 feat(brief): letter composition + two-person approval (teaching slice)
CI / backend (push) Failing after 22s
CI / frontend (push) Successful in 1m26s
CI / api-client-drift (push) Successful in 1m45s
New `brief` context — a letter-composition feature with a drafter/approver
approval workflow, built as a teaching vertical slice on the repo's existing
FP + Elm + atomic-design patterns (see plan in ~/.claude/plans).

Domain (pure):
- Rich text as a serialisable value tree (placeholders are first-class nodes),
  moved to @shared/kernel/rich-text.ts so the shared editor can use it.
- lintPlaceholders: a pure, total content -> Diagnostic[] linter, derived never stored.
- brief.machine.ts: status sum-type with guarded transitions; frozen-snapshot =
  deep value copy; derived diagnostics/editability. Full specs.

Backend (.NET stub):
- BriefStore + seed, GET/PUT /brief and submit/approve/reject/send endpoints,
  role via X-Role header (mirrors X-Admin), transition + approver!=drafter guards,
  audit logging. Regenerated typed client via gen:api. +6 backend tests.

Seam:
- brief.adapter.ts maps flat wire unions <-> domain discriminated unions at the
  parse boundary (+ spec).

UI (atomic):
- shared atoms: checkbox, placeholder-chip; molecule: rich-text-editor (no-dep
  contenteditable, DOM<->RichTextBlock round-trip tested).
- brief/ui: letter-block, passage-picker, diagnostics-panel, rejection-comments,
  letter-section, letter-composer, letter-preview, brief.page + /brief route.
- Dev-only ?role=drafter|approver toggle + roleInterceptor; dashboard nav link.

Enforcement: @brief/* alias + eslint layer boundary (brief depends only on shared).

Also included (same session):
- Value-object specs (postcode/uren/big-nummer) — closes the "domain must have a spec" gap.
- src/docs/ Storybook MDX foundation pages (atomic design, tokens, FP-in-UI).
- .storybook/tsconfig.json: add @angular/localize to types (Storybook was fully
  broken — $localize unresolved — dev + build).

Verified: 168 FE tests, 68 backend tests, lint/build/check:tokens green,
Storybook boots, end-to-end HTTP smoke (self-approve 403, approver 200, full flow).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 21:32:22 +02:00
ehoandClaude Opus 4.8 6a61c179cd Registratie: answer-driven required document uploads
Categories stay server-owned (ADR-0001); the FE sends its answers to
/uploads/categories and re-fetches reactively when they change:
- Diplomabewijs required only for a handmatig diploma (DUO is verified digitally;
  nothing required before a diploma is chosen).
- Bewijs Nederlandse taalvaardigheid required only when the applicant answers "ja"
  to the nl-taalvaardigheid (B2) policy question.
CategoriesFor(wizardId, diplomaHerkomst, taalvaardigheid) decides; Find uses the
maximal set so uploads still validate. CategoriesLoaded drops orphaned uploads
when a category disappears. Also: show the foreground-only upload banner only when
there is at least one category.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 18:35:37 +02:00
ehoandClaude Opus 4.8 7b6aac394b Mijn aanvragen (B): store document bytes + content endpoint
- StoredDocument gains ContentType + byte[] Content; POST /uploads now captures
  the file bytes (in-memory, reset on restart — POC).
- GET /uploads/{documentId}/content streams the bytes: inline for pdf/image
  (browser preview), attachment otherwise (download). 404 for unknown ids
  (covers the demo-* simulation sentinels, which have no bytes).
- Bytes are never serialized into a JSON response; only this endpoint streams them.
- Tests: content served back with type inline for pdf, 404 for unknown. 56/56 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 11:36:28 +02:00
ehoandClaude Opus 4.8 8c3f4c22ee Mijn aanvragen (A): backend Aanvraag store + lifecycle endpoints
Adds the backend-owned Aanvraag aggregate (PRD 0001, phase A) — the system of
record the dashboard will read. In-memory static store mirroring DocumentStore.

- ApplicationStore: create/get/list/draft-sync/cancel/submit; status COMPUTED ON
  READ (Mappers.ToStatusDto(now)) so auto-approval is pure timestamp arithmetic,
  no timers/jobs (ProcessingWindow = 8s).
- Endpoints: GET /applications, GET/POST/PUT/DELETE /applications/{id},
  POST /applications/{id}/submit.
- Lifecycle: registratie duo -> auto (Goedgekeurd after window), handmatig ->
  manual pending (no 422); herregistratie/intake 0 uren -> Afgewezen else auto.
  Cancel blocks submitted aanvragen (409, no withdrawal in scope).
- Old /registrations endpoint + RejectRegistratie 422 left intact (retire in E).
- ApplicationTests: lifecycle + auto-approve window boundary (pure). 54/54 green.

Also checks in the PRD (docs/prd/0001).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 11:35:19 +02:00
ehoandClaude Opus 4.8 0d37cc097a Upload feature (a): BFF endpoints + category config + tests
- Domain/Documents: server-owned category config per wizard + authoritative
  type/size validation (DocumentRules).
- Data/DocumentStore: in-memory metadata store (no file bytes/PII) + audit log;
  user delete (owner-scoped, 409 once linked), admin delete (role seam via
  X-Admin header), link-on-submit, poll-by-localId status.
- Program.cs: GET /uploads/categories, POST /uploads (multipart, excluded from
  OpenAPI — hand-written on FE), GET /uploads/status, DELETE /uploads/{id},
  DELETE /admin/uploads/{id}. Submit links digital docs + records post-delivery.
- Contracts extended (DocumentRefDto on registratie/herregistratie submit);
  regenerated NSwag client + swagger.json (drift check stays green).
- Tests: 16 new (endpoints + DocumentRules); dotnet test 44/44.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 20:15:40 +02:00
ehoandClaude Opus 4.8 d08f3877f7 Architect-review remediation: enforce conventions, prod-safe tooling, one form idiom, resilience seams
Acts on the showcase review. Four workstreams; all tests green
(npm run lint, 70 FE tests, ng build, 33 backend tests).

Enforcement + CI:
- eslint.config.mjs bans `any` and enforces layer/context boundaries
  (domain ≠ Angular; herregistratie → registratie → shared, auth → shared);
  `npm run lint` added; ajv 6 scoped to ESLint via nested override.
- .github/workflows/ci.yml: FE lint+check:tokens+test+build, backend dotnet test,
  and an API-client drift check.

One form idiom (the headline finding):
- change-request-form converged onto the wizard pattern — change-request.machine.ts
  (Model/Msg/reduce + value objects) + submit-change-request.ts (Result) + a real
  POST /api/v1/change-requests (server re-validates). Spec + story added; the detail
  page no longer holds an ad-hoc success signal.

Resilience/observability seam:
- api-client.provider.ts: request timeout, X-Correlation-Id, Idempotency-Key for
  writes; comments naming the retry/auth seams.
- Backend logs correlation id + a no-PII submit-audit line; /api/v1 prefix +
  backward-compat note; client regenerated.

Quick wins:
- Dev tooling excluded from prod: scenario.interceptor wired only under isDevMode()
  (?scenario= inert in prod); debug panel @if(isDev) (tree-shaken out).
- src/environments + apiBaseUrl into provideApiClient (angular.json fileReplacements).
- Backend /health + /health/ready.
- Debug view PII-minimised (redactProfile: name/address/DOB redacted, BIG masked).
- IntakePolicyAdapter (removes inline resource in the intake wizard).
- README de-staled; CLAUDE.md gains EN/NL + forms-one-idiom + lint/CI notes.
- Stories: text-input, link, data-row, site-header, site-footer, change-request-form.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 08:25:51 +02:00
ehoandClaude Opus 4.8 cf570a8132 Add ASP.NET Core backend hosting business rules; FE consumes via typed client
Move the authoritative business rules off the frontend into a real backend,
realising the BFF-lite + decision-DTO design (ADR-0001) that until now lived
only in static mock JSON.

Backend (backend/):
- ASP.NET Core (.NET 10) minimal API, contract-first, Swagger UI at /swagger.
- DDD Domain/ rules layer: profession derivation + applicable policy questions
  (DiplomaRules), herregistratie eligibility + reason (HerregistratieRule),
  scholing threshold (IntakePolicy), submit rejections + reference generation
  (SubmissionRules). In-memory seeded data, ProblemDetails (RFC 7807) errors.
- 27 xUnit tests: rule units + endpoint integration incl. BRP no-address and
  DUO not-found fallbacks and 422 submit paths.

Frontend (only infrastructure/ + contracts/ change, as the architecture promised):
- NSwag-generated typed client (api-client.ts), routed through Angular HttpClient
  via a small fetch adapter so the ?scenario= interceptor still applies.
- GET adapters use resource({ loader: client.x }); submit commands call the client
  and map ProblemDetails -> err. The hardcoded uren==0 / manual-diploma rules are
  deleted (now server-side). Domain, stores, UI and format validators unchanged.
- Deleted the now-dead public/mock/*.json.

Tooling/docs:
- npm start proxies /api -> backend; npm run gen:api regenerates the client;
  docker compose up runs both (bind mounts use :z for SELinux/Fedora).
- backend/README.md walkthrough: adding a policy question is a one-file backend
  change, no FE change, no client regen. Updated CLAUDE.md + ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:05:53 +02:00