CQ-007 expand half. BriefStore.load() treats a 404 as 'no brief yet' and calls
the existing reset() command once. load()'s error channel becomes the
BriefLoadFailure union, because runResult folds the HTTP status away and the
store needs it. Today's backend never 404s, so the branch is a no-op until
RB-23 lands the contract half.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
# Conflicts:
# docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
# libs/shared/docs/behaviour-spec.mdx
BriefStore.load() now treats a 404 from GET /brief as "no brief exists
yet" and calls the existing reset() command once, instead of showing
the generic load-failed error. BriefAdapter.load() gains a
BriefLoadFailure error channel (notFound | error) so the store can
tell a 404 apart from every other failure; every other adapter method
stays on runSubmit, unchanged.
The once-only bound is a field on the store, not a comment: a second
404 (from a later load() call) always falls through to the ordinary
error path, and the recovery path never calls load() again, so no
loop can form.
This is the expand half of CQ-007's split (04-cqrs-light.md). Today's
backend never 404s GET /brief, so the new branch is dead code until
RB-23 (the backend contract half) ships in a later merge.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CQ-002: ApplicationsStore.cancel and AdminCasesStore.delete reached the raw
ApiClient and swallowed the failure in a bare catch, so a failed cancel made the
row reappear with no message. Both now fold through runSubmit and expose
lastError, which the two pages render.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
# Conflicts:
# docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
# libs/shared/docs/behaviour-spec.mdx
BIO-018: the store was a process-global dictionary keyed on the client-supplied
Idempotency-Key alone, so one caller could replay another caller's key and
receive their cached response. The key is now scoped with the caller SubjectId.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
# Conflicts:
# docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
# libs/shared/docs/behaviour-spec.mdx
ApplicationsStore.cancel and AdminCasesStore.delete rolled an optimistic
write back on failure but showed no message — a bare catch with no
Result and no error channel (CQ-002). Both now call runSubmit and set a
lastError signal on failure, mirroring createSubmitChangeRequest in the
same folder. Each page renders the error with the existing app-alert
atom, the same pattern brief.page.ts already uses for lastError.
Added a spec file for ApplicationsStore (none existed) and extended
AdminCasesStore's spec, each asserting the rollback AND the surfaced
error. Verified both new assertions fail without the fix (an Edit
undo/redo of the store method, not git checkout).
Regenerated libs/shared/docs/behaviour-spec.mdx (gen:behaviour-spec) to
pick up the new/renamed test names. Marked RB-20 done in 99-backlog.md
and recorded the change in implementation/rb-20.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CQ-001: createDraftSync was the longest function in the repo and owned three
query paths next to its write path. findConcept and loadConcept are now free
functions that take the adapter, so they have a direct spec without TestBed.
The closure state (id, ensuring, resumeGate) stays where it was, because the
coupling is load-bearing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The architect approved the four ADR-fix tickets. All four change what the
architecture documents claim. No code changes.
ADR-0001, ADR-C-001: the worked example claimed the POC has no real backend.
It rewrites against `backend/src/BigRegister.Api`. Every path it named is
repointed. The out-of-scope list drops two discharged bullets: 33 `parse*`
boundaries exist, and `npm run gen:api` is real.
ADR-0001, ADR-C-003: a new section states that the generated client is the wire
contract. A hand-written `contracts/*.dto.ts` is the exception for two cases
only. The four survivors stay, because NSwag emits every property as optional
and flattens `RegistrationStatusDto` into five optional strings. The `parse*`
trust boundary stays mandatory, because a generated type is a compile-time
claim about the wire and not a runtime guarantee.
ADR-0003, ADR-C-007: four paths moved in WP-67 and are repointed. Point 4 kept
the principle and changed its example to `skeleton` and `spinner`. Two of its
claims were false and the amendment says so: `app-alert` wraps the vendored
`.feedback` classes, and `site-header` composes the vendored `.titlebar`.
ADR-0004, ADR-C-009: the exception section states a four-part test instead of
one named exception. `OrgTemplateStore` and `FeatureFlagStore` both pass it. RB-07
gated this ticket, because clause 4 needs an audited allow path. RB-07 landed
that, so the ADR does not ratify a control that the code lacks.
Three tickets need a matching CLAUDE.md correction in the same diff. CLAUDE.md
section 2 loses the false `alert` example. Section 4 gets the generated-client
rule and the four-part test.
Two findings were wrong. ADR-C-001 asked to keep an out-of-scope bullet that
reads "SessionStore is in-memory". The session persists to `localStorage` now,
so the bullet covers multi-tab sync only. ADR-C-007 flagged one half of point 4
and missed that the other half is equally false.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
IdempotencyStore keyed a replayed submission on the raw Idempotency-Key
header alone. Two different callers who send the same header value
shared one cache slot: the second caller received the first caller's
cached reference instead of running its own submission.
Program.cs now composes the key as "{SubjectId}:{idemKey}" in the
Submit helper, so the cache is scoped per caller. Add a test that
proves a caller cannot replay another caller's idempotency key and
receive their cached result.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
createDraftSync mixed a read path (findConcept, load, the read half of
resume) with its write path (ensureId, flush, submit, reset) in one
187-line function -- CQ-001's finding. Move findConcept and loadConcept
into a new application/find-concept.ts as free functions that take the
adapter, so they get a direct spec with no Angular TestBed.
createDraftSync keeps the closure state (id, ensuring, resumeGate) and
the whole write path unchanged -- this is a move, not a redesign. The
resumeGate coupling that lets the write path wait for the read path
stays exactly where it was.
createDraftSync shrinks from 187 to 169 lines. draft-sync.spec.ts is
unchanged -- it never called resume()/load() directly, and its 409
recovery test for submit() still exercises the extracted findConcept
through ensureId's catch branch.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
All six merged, gate green (14 steps, backend 260/260). Records the three
tickets that could not be built as written — RB-12's wrapper/public binary
does not fit the route table, RB-14's command exits 0 on a High advisory, and
RB-15 needed a third environment name because RB-09 makes Production fail to
boot — plus RB-13's measured duplication drop (168 -> 32 lines per side).
Adds a section on dispatching implementation agents. Four of six agent-runs
were handed a worktree branched from a stale ancestor; batch 3 was three for
three. That, the background-task parking, and the git-checkout-destroys-work
trap are all cheap to prevent in the prompt and expensive to discover.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ADR-0002 SS3 models Zorgverlener/Medewerker as different Principal
variants with different login flows. Actor #2 (apps/behandelportal)
landed in WP-61/67 and the union never followed: grep -rn "Principal"
returned one hit, a comment. Both apps' auth/domain/session.ts stayed
byte-identical (`{ bsn, naam }`), so the backoffice's Behandelaar
carried a BSN and logged into the backoffice as a citizen, by DigiD,
under a fabricated citizen's name (login.page.ts). The divergence
ADR-0002 predicted took an orthogonal side door instead
(medewerker.interceptor.ts's X-Medewerker/X-Rollen stamp, which never
touches SessionStore) -- which is why ssp/auth and bhp/auth still
measured as 100%/84% duplicated after ADR-C-006 shared the route
guards. RB-09 (landed the day before) made the backend's
IIdentityProvider able to say "no identity" and fail closed; this
ticket is its named FE half.
Each app's auth/domain/session.ts becomes principal.ts, holding the
one Principal variant that app actually has an actor for: ssp keeps
`{ kind: 'zorgverlener', bsn, naam }` (G1 still strips the BSN before
persisting); behandelportal gets `{ kind: 'medewerker', medewerkerId,
naam, rollen }` (no BSN to strip -- G2 shape validation only). A new
MedewerkerAdapter replaces DigidAdapter in behandelportal, resolving
the existing MEDEWERKER_ID/currentRollen() dev stand-in into a
Principal; because there is no credential to check, it returns
Principal directly rather than a Result whose error variant could
never occur. login.page.ts stops being a BSN/wachtwoord form -- one
explainer line and an "Inloggen met SSO" button -- and its dead
error-handling branch goes with the Result wrapper that justified it.
Measured with tools/baseline-scan.mjs --dup: auth duplication drops
from 168/168 (ssp) and 168/200 (bhp) to 32/179 and 32/259 -- under the
backlog's <40 target. What remains is the ADR-C-006 route-guard
re-export (deliberately identical), generic test/story-file
boilerplate, and one shared fragment of the root-singleton-store
idiom -- not re-converged identity or login-flow logic. SS3's
prediction that the two actors would authenticate differently enough
to justify not sharing auth has now actually been tested, not just
asserted, and held.
Also: renamed Session.bsn to Principal.bsn in two doc comments
(libs/shared/src/infrastructure/subject.ts, subject.interceptor.ts)
that cited the old type name; regenerated
libs/shared/docs/behaviour-spec.mdx (generated file, per its own
banner); recorded the resolution in ADR-0002 as a new amendment,
replacing its "Known debt" section.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
BIO-019: GET /stamdata/{table}?peildatum= called DateOnly.Parse
directly, which throws FormatException on anything unparseable — an
unhandled 500 (leaking exception detail in Development) instead of
the 400-with-problem-details every other bad-input check in this file
returns. §3c named backend/Stamdata's 71.7% branch coverage (BL-005)
as the weak spot this bug lived in.
Switched to DateOnly.TryParse; an unparseable value now returns
Results.Problem(detail: ..., statusCode: 400), matching the shape the
upload/change-request endpoints already use. Endpoint doc gained
.ProducesProblem(400), so the OpenAPI doc + generated client were
regenerated and committed in this same diff (RB-09's note records a
prior incident where a response-shape change shipped without this and
the drift went unnoticed).
No FE change needed: libs/beheer's stamdata adapter already funnels
every call through runSubmit, which folds any thrown ApiException
(now including this 400) into a generic Result error — ADR-0001's
"the FE renders the decision" already covers "the server rejected
this input".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
BIO-015: app.UseSwagger()/app.UseSwaggerUI() ran unconditionally, so
the full OpenAPI document (every route + request/response shape) and
SwaggerUI's interactive "Try it out" were reachable in every
environment, including a real deployment.
Both now run only inside `if (app.Environment.IsDevelopment())`.
AddSwaggerGen/AddEndpointsApiExplorer stay unconditional — DI
registration only, no HTTP surface by itself.
RB-09 already made a non-Development environment throw at startup,
which broke `npm run gen:api` until that script pinned
ASPNETCORE_ENVIRONMENT=Development for its one CLI invocation. This
change sits in the same pipeline, so it was verified rather than
assumed: `dotnet swagger tofile` resolves ISwaggerProvider straight
out of DI and never sends an HTTP request through this middleware, so
gating it can't affect that tool by construction. Ran the real
`npm run gen:api` to confirm — exit 0, regenerated files byte-identical
to what's committed.
New tests exercise the gate on a third ("Staging") environment name,
not Production — Production already can't boot at all post-RB-09, so
a Production-environment test would only re-prove that unrelated
startup throw, not this gate.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
BL-006: the backend has zero automated architecture enforcement.
BIO-016 names the concrete consequence for authorization — nothing
asserted the *set* of gated endpoints, so BIO-003's X-Admin gate
(outside Authz) and BIO-004's two ungated endpoints were caught only
by a human reading Program.cs, not by CI.
Adds RouteInventoryTests: walks the real app's EndpointDataSource and
asserts every mapped route either carries a .Gate("XAdmin") metadata
marker (added at the 16 call sites that already call one of the five
admin wrappers — OrgAdmin/StamdataAdmin/CasesAdmin/Beoordelen/
FlagsAdmin) or appears in a written-down, reasoned allow-list. Proved
it's hard to fool by adding a throwaway unguarded route, watching the
test go red, and reverting.
The allow-list is not "public routes" as the ticket's shorthand put
it — 19 of its 31 entries are ownership-scoped inline (ctx.Zorgverlener()/
ctx.Caller()) endpoints, not public ones, and labelling them public
would misrepresent the exact property BIO-004 was about. Each entry
instead carries its own reason. Implementation note has the full
route-by-route breakdown and judgement calls.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
runSubmit did two things at once: fold a call into a Result, and mint
an Idempotency-Key for it. Five call sites are reads and had no
business minting one — brief.adapter.ts:load, org-template.adapter.ts
:list/:load, and stamdata.adapter.ts:list/:load. stamdata.adapter.ts's
own docstring already said "Both endpoints are reads … There is no
write method" while both called runSubmit; that mismatch is the
sharpest evidence, and the reason the baseline's original "~13
mutations" count (derived from the helper's name, not the code) was
wrong by five in one direction.
Split submit.ts in place: runResult is the try/catch + problemDetail
fold with no mint; runSubmit is runResult wrapping
withIdempotencyKey. Zero behaviour change for the 8 real mutations
(brief save/submit/approve/reject/send/reset, org-template
save/publish/rollback) — same fold, same mint, same timing. The five
reads now run the fold with no pendingIdempotencyKey touched.
submit.spec.ts asserts the split behaviourally via
currentIdempotencyKey() (two reads inside the same call agree only
when a key was minted and reused) rather than mocking a relative
import, matching this repo's existing vitest convention. Verified red
without the fix by temporarily reintroducing the mint into runResult.
ApplicationsStore.cancel/AdminCasesStore.delete (RB-20) and
FeatureFlagStore.set are out of scope and untouched — the latter
already calls runSubmit correctly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
npm audit --omit=dev gates the shipped frontend bundle; nothing equivalent
existed for the backend, so the entire .NET dependency tree — direct and
transitive — was unscanned (BIO-016 lists it first under "Absent").
The ticket's literal wording would not have worked. `dotnet list package
--vulnerable` is a reporting command: it prints the advisory table and exits
0 regardless. Verified with a throwaway project on System.Net.Http 4.3.0 —
severity High, GHSA-7jgj-8wvc-jh57, exit code 0. A bare `- run: dotnet list
package --vulnerable` would have added a line that reads like coverage in a
compliance review and enforces nothing, which is worse than leaving the gap
visible.
scripts/dotnet-audit.sh runs the scan and matches "has the following
vulnerable packages" — the exact sentence dotnet prints per project on a hit.
One script, two callers (ci.yml and ci-local.sh), so the workflow and the
local gate cannot drift apart.
No severity threshold and no suppression list: picking either before a real
advisory forces the question would be guessing at a policy nobody needs yet.
Secret scanning, BIO-016's other named absence, stays on the checklist.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
All five tickets merged and green on the fixed gate (13/13 steps, exit 0).
RB-07 unblocks ADR-C-009; RB-09 unblocks RB-13 in batch 3.
Adds a "Gate integrity" section recording that every earlier "ci green" in
this file predates the ci-local.sh errexit fix and is weaker than it reads.
Batch 1 has not been re-verified under the honest gate, and the note says so
rather than leaving a reader to assume it was.
Also records what batch 2 leaves open: RB-01's residual is NOT solved by
RB-09 (the upload-content link is still a plain browser navigation with no
credential), and a non-Development non-Production environment fails fast at
GetRequiredService rather than at RB-09's deliberate throw.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Also records what RB-11 turned up: BIO-012 was factually wrong that the
proefbrief error mapping was already a separate function (it was inlined in a
try/catch), and the step-up literal is still a literal, moved one layer up to
the only caller rather than eliminated — BIO-006(c) stays a production gate.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Documents the dotnet swagger tofile crash discovered by actually
running the affected command (not just trusting ci-local.sh's local
"passed" line, which turned out to mask this exact failure via a
set -e && short-circuit gotcha), its root cause, and the two follow-up
fixes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
BIO-012: roleInterceptor/subjectInterceptor are correctly registered
only under isDevMode(), but three hand-written fetch adapters
(reveal-bignummer, letter-preview, org-template's proefbrief) bypass
HttpClient and set X-Role/X-Subject themselves with no guard. The
readers underneath, role.ts and subject.ts, were ungated too: they
read ?role=/?subject= and wrote it into sessionStorage on any
navigation, in any build -- for ?subject= that value is a BSN, which
is exactly what SessionStore's G1 comment promises never happens.
Gate both layers: currentRole()/currentSubject() return their safe
default immediately outside isDevMode() (no query-param read, no
sessionStorage write), and the three adapters additionally wrap their
headers in isDevMode() so a production request carries neither header
at all, matching what an HttpClient request already does once the
interceptors aren't registered.
TE-002: reveal-bignummer's response-shape validation was a "Trust
boundary" a spec could only reach by stubbing globalThis.fetch.
Exported it as parseRevealed(body), matching the other 30 parse*
boundaries in the repo. Same treatment for letter-preview's
errorMessage and org-template's proefbrief error mapping (extracted
from an inline try/catch into a named, exported function first, since
it wasn't already separate).
BIO-006(a): reveal-bignummer sent X-Step-Up: 'true' unconditionally,
so the backend's step-up precondition constrained nothing. reveal()
now takes a stepUp flag; BriefStore.revealBigNummer() -- reachable
only after the UI's confirm() gesture -- is the one that supplies it,
so the literal no longer lives in the transport adapter.
BIO-006(b): documented in roles-and-access.md that drafter is also
the backend's fallback identity (StubIdentityProvider's catch-all
arm), not just the dev switcher's initial choice -- so the
least-privilege consequence of it also being the only role that may
reveal a BSN is visible.
Doc correction, same diff: roles-and-access.md's "wired only under
isDevMode()" claim was false for the three hand-written fetch paths;
it now says where the gate lives (interceptor registration and the
reader functions) so it doesn't go stale the same way again.
CLAUDE.md's dev-only claims needed no correction -- they already
noted these three calls bypass the interceptor.
Every fix has a test confirmed red by temporarily reverting the
source change and rerunning the suite before restoring it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
RB-07 unblocks signing ADR-C-009 (its clause 4, "writes are
admin-capability-gated and audited", now holds) and closes CQ-004's
outstanding half. RB-10 landed parseStoredSession twice, once per app,
deliberately — recorded so a later reader does not file it as duplication.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SessionStore.restore() — identical in both apps — read localStorage itself
and did the parse plus shape validation in the same module-private function,
invoked from a field initializer, so the storage read happened the instant
the singleton was constructed and no spec could feed it a raw string. The
logic it guards is a trust boundary, not incidental validation: the comment
above it names G1 (never persist the BSN) and G2 (validate the shape before
trusting it), and CLAUDE.md mandates a spec for boundary parse* adapters.
ssp/auth and bhp/auth were jointly the worst-covered frontend modules.
parseStoredSession(raw) moves into each app's auth/domain/session.ts, which
is pure TS and already had a spec, so no new scaffolding was needed;
restore() collapses to one line. Four cases: absent, non-JSON, wrong shape,
and — BIO-017's addition — a stored {"bsn":…,"naam":…} restoring with bsn
'', which makes the G1 guarantee executable rather than merely commented.
Verified red without the fix.
Landed twice, once per app, deliberately. TE-001 and BL-002 both say an
extract-to-shared here would contradict ADR-0002, which models the two
actors as different Principal variants and expects the two auth contexts to
diverge; RB-13 is what differentiates them.
Also specs redactProfile (BIO-017's second half) — a pure exported
PII-redaction function that had none.
behaviour-spec.mdx is regenerated, which also picks up the test names RB-07
added; that commit should have carried them and did not.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
IIdentityProvider.Resolve returned a non-nullable CallerIdentity, so
the interface could not express "no identity" - StubIdentityProvider
was forced to invent one for any request carrying no credential at
all. Consequence: a production behandelportal build sends no
X-Medewerker header (medewerkerInterceptor is dev-only), so it used
to authenticate as the seeded citizen, role drafter - failing closed
on backoffice capabilities but open on every citizen-scoped endpoint,
including CanRevealBigNummer.
Resolve now returns CallerIdentity?. StubIdentityProvider keeps a
non-nullable return type (a valid narrower override) since it never
itself has "no identity" to report - it is registered only under
IsDevelopment() now. Production registers nothing and throws an
InvalidOperationException immediately during startup instead: there
is no real DigiD/employee-SSO provider in this POC yet, so a
misconfigured Production deploy must fail before serving a single
request, not resolve one per request. The identity-resolution
middleware turns a null resolution into a 401 rather than passing it
downstream.
Added StubIdentityProviderTests.Never_returns_null_even_with_no_headers_at_all
and ProductionIdentityProviderTests, which builds its own
WebApplicationFactory<Program> with UseEnvironment("Production") and
asserts startup throws. Verified both new tests fail red against the
pre-fix code.
RB-01's residual (GET /uploads/{id}/content reached via plain browser
navigation, no identity header) is confirmed unchanged in Development
and its Production consequence is written up in
implementation/rb-09.md for whoever lands the real identity provider -
no signed-URL/cookie scheme was designed here, per scope.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
DELETE /admin/uploads/{documentId} was gated by a standalone
`X-Admin: true` header check (`IsAdmin`), outside the `Authz` module
entirely and outside the `CasesAdmin`/`StamdataAdmin`/`OrgAdmin`/
`FlagsAdmin` wrappers the four sibling admin surfaces use. It wrote no
AuthzAuditStore row, so a destructive cross-owner document delete never
appeared on /beheer/audit. A repo-wide grep confirmed the only sender of
X-Admin was the backend test itself — no frontend or e2e path depends on
it — so the gate was safe to delete outright.
Routed the endpoint through CasesAdmin (Authz.CanManageCases), the same
wrapper the other admin-cases endpoints use. RB-07 already moved
AuditAuthz onto every *Admin wrapper's allow path, so this gets the
missing audit row for free with no second AuditAuthz call. Deleted the
now-unused IsAdmin function and updated the two comments that referenced
the old X-Admin seam.
Updated EndpointTests.cs's Admin_delete_requires_admin_role to send
X-Role: admin instead of X-Admin: true, and added
AuthzAuditTests.An_admin_upload_delete_is_recorded, which asserts the
cases:manage/allow row count increases by exactly one (a plain
Contains would already be satisfied by this test class's other
cases:manage calls). Verified both tests fail red against the
pre-fix gate.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
All five authorization gates audited only their deny branch, so /beheer/audit
could answer "who was turned away" but never "who changed this" — for a
register whose integrity is the product, the wrong half. Nothing recorded the
flag toggle, either org-template write, the admin case or upload delete, the
three brief transitions, or the besluit; the comment claiming endpoints log
their own effect held for two of the eight.
Each gate now computes the decision once, audits it, and then acts. The row
is written by the gate rather than the endpoint, so a new admin endpoint
cannot be added that forgets to audit itself. Same reasoning for the brief:
every transition already funnelled through LogBrief for its log line, so the
audit row goes there too — submit/approve/reject/send in one place, with the
transition's own outcome as the decision, so a 403 or 409 is as visible as a
success.
FlagsAdmin gained a per-call resource, the one deviation from BIO-007's
minimal remediation: the toggle endpoint writes no log line of its own, so a
constant "feature-flags" row would say a flag changed without saying which.
It now records feature-flags/<key>=<value>. OrgAdmin and CasesAdmin keep
coarse refs because those endpoints do log the specific object.
The besluit gets a second row: the gate records that a behandelaar was
allowed to act, aanvraag:besluit records what they decided.
Row volume goes up — StamdataAdmin gates read endpoints, so admin page loads
now write rows. That is what auditing the allow path means; it is also what
would make retention on AuthzAuditStore necessary later.
Closes CQ-004's outstanding half and unblocks signing ADR-C-009.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The operator approved 99-backlog.md, so Phase 3 started. RB-01..RB-06 are
marked done in the table, _status.md gains a Phase 3 section tracking all six
batches, and the halt notice is replaced by what batch 1 leaves for later
batches to pick up:
- RB-01's residual is RB-09's problem — the document-content endpoint is
reached by a plain browser navigation with no identity header, so it
resolves to the seeded citizen and works only because one citizen owns
every document in the POC. That is BIO-002.
- Pii.MaskTail now lives in Domain/People/Pii.cs; RB-11 should use it rather
than hand-rolling a second masker.
- RB-06 additionally deleted SubmissionRules.RejectRegistratie, which its row
did not ask for.
Also records the standing OpenZaakIntegrationTests failure, which needs a
live container and is unrelated to any of these tickets.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
ZgwHttpClient interpolated the full request uri and up to 500 characters of
the response body into its failure message. That message is persisted as
Aanvraag.ZgwError in SQLite and written to the log, and both halves can carry
a BSN: ZGW filters travel as query parameters (the citizen-scoped zaken list
filters on rol__betrokkeneIdentificatie__natuurlijkPersoon__inpBsn), and
OpenZaak echoes the offending request in its error bodies, so a rejected POST
/rollen comes back holding the owner BSN it was sent.
All three interpolation sites now use Redact(url) — the path without its
query — and the body snippet is replaced by the reason phrase. Status plus
path still routes a failure to the right endpoint; the lost detail already
has a deliberate home in ZGW_DEBUG_HTTP=1 (ZgwDiagnosticHandler), which is
opt-in, dev-only and not persisted.
The new test fails the one call in the fixture whose url carries a query
string and asserts the persisted ZgwError has neither the body snippet nor a
"?", while keeping the path and the 503. Verified red without the fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
DocumentStore wrote one audit row per upload and per user delete carrying the
acting citizen's raw BSN as AuditEntry.Actor, persisted to SQLite — on a
store whose own doc comment says it holds metadata only, never file content
"or other PII". Same shape as RB-02, in a second store.
Masked at the two citizen call sites rather than inside Audit, because the
third actor is the literal "admin" and MaskTail("admin", 3) is "**min";
masking centrally would mean guessing which actors are BSNs and which are
role names. Audit's doc comment now states that actors arrive redacted.
StoredDocument.Owner is untouched: it is the authorization key that
DeleteOwned, ForeignIds and RB-01's content check all compare against, so the
BSN stays where it is load-bearing and leaves the trail where it was only
decoration. No endpoint exposes AuditLog, so no response shape changes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
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>
Completes the pipeline's analysis phase. Agent 07 (BIO2/ISO 27002:2022,
control set stated as an assumption since none was supplied) produced 20
findings — 12 "defect now", 8 "production gate" — and agent 08 consolidated
all 47 findings across 00/02/04/06/07 into 33 tickets, 5 ADR-fixes and a
release checklist.
Two findings are live defects rather than refactoring candidates, both
verified directly:
- RB-01/BIO-004: GET /uploads/{documentId}/content takes only (string
documentId) — no HttpContext, so no authorization is possible. It streams
diploma and identity scans, protected by GUID unguessability alone, while
DELETE on the same resource is owner-scoped.
- RB-02/BIO-008: Program.cs:674 concatenates the caller's BSN into the authz
audit Resource column, which is persisted to SQLite and rendered by the
admin audit page. Four doc comments claim that store holds no PII; the test
cited as enforcing it asserts on column names, so a BSN inside a column
called Resource is invisible to it.
07 also answered the handoff from 06: in a production behandelportal build no
X-Medewerker is sent, so StubIdentityProvider returns the seeded citizen. It
fails closed on backoffice capabilities but open on citizen-scoped ones,
including CanRevealBigNummer. Root cause is IIdentityProvider.Resolve
returning a non-nullable CallerIdentity — the interface cannot express "no
identity", so any provider must invent one.
08's gate was relaxed from all-seven to the four agents that ran; _status.md
records why 01/03/05 were skipped, and the backlog carries a "Coverage"
note naming what those skips leave unowned. It caught two errors in the
orchestrator's handoff: CQ-002 is not fixed (ApplicationsStore.cancel and
AdminCasesStore.delete still swallow errors -> RB-20), and CQ-004 shipped
with half its compliance criterion unmet (PUT /admin/flags/{key} writes no
audit row -> RB-07, which blocks signing ADR-C-009).
Both agents preserved a "verified clean — do not fix" list, so a later pass
does not re-spend effort on the controls that already hold.
Consolidation halted for human approval per its spec. No source file changed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ADR-C-005 from the ADR-conformance pass.
Status Proposed -> Accepted. Two apps have shipped against this ADR and its
structural rulings run in CI at severity: error with 0 violations; the other
five ADRs are all Accepted. A decision CI enforces is not "Proposed".
Drops two "out of scope, not built" bullets that have since shipped
(WP-61..67) — the Behandeling backoffice, and the backend status lifecycle +
authz DTOs: AanvraagStatusTag, GET /me (Program.cs:578), Domain/
Authorization/Authz.cs. Real authentication is the one that genuinely stays.
Replaces the `Session -> Principal` deferral with a Known debt section. The
deferral was conditional on the backoffice not existing yet; it does now, and
the union did not follow. `grep -rn "Principal" apps libs` returns one hit,
a comment. Consequently the two auth contexts are byte-identical (diff -rq:
zero content differences), and behandelportal's Behandelaar still carries a
bsn and logs in through DigiD — a backoffice user authenticating as a
citizen, which is what §3 was written to prevent. The divergence that did
happen took an orthogonal side door (medewerker.interceptor.ts) that never
touches Session.
The section says explicitly that the WP-67 amendment's "expected to diverge"
reasoning still holds but has never been tested, so the identical copies are
evidence §3 is unexecuted — not evidence §3 was wrong. Without that, a future
reader is likely to "simplify" the duplication away and cement the citizen
login into the backoffice.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Runs the multi-agent refactoring-backlog pipeline in docs/project/
refactor-backlog-setup/ up to and including three of the seven Phase 1
agents.
00-baseline.md establishes the metrics every later agent must cite, using
only tooling already in the repo (vitest lcov, coverlet cobertura, ESLint's
core `complexity` rule at threshold 0 for a full distribution, depcruise
--metrics). Duplication and C# complexity had no tooling, so
tools/baseline-scan.mjs adds a deterministic ~200-line text scan rather
than a new dependency; the approximations are labelled as such.
Headline: FE 75.1% line coverage but only over the 98 of 220 source files a
spec loads; BE 97.6% line / 79.6% branch; 0 layering violations; 7.1%
duplication; 25 of 2085 TS functions over CC 10.
Then 02-testability, 04-cqrs-light and 06-adr-conformance (27 findings).
01/03/05 were skipped deliberately — the baseline shows little for them to
find; 07 (BIO2) and 08 (consolidation) are still open.
Each agent corrected a baseline observation of mine, and in every case the
error was in something derived rather than measured:
- BL-007 counted ~13 adapter "mutations" from the `runSubmit` helper name;
5 of those call sites are reads. It also missed 3 real mutations that
reach the raw ApiClient and never return a Result.
- BL-002 diagnosed the 100%-duplicated auth folders as ADR-0002's
divergence prediction failing. It never had a chance to fail: §3's
`Principal` union was never built.
- BL-004 named libs/shared/domain and libs/beheer/contracts as coverage
gaps; both are pure type declarations where 0% is unimprovable.
All three corrections are recorded inline in 00-baseline.md §10, so agent
08 does not inherit the bad numbers.
.prettierignore excludes the agent prompt directories — reflowing their
markdown would edit the prompt text itself.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The repo had good docs that nobody could find. The root README linked to
exactly two documents, while learning-path.mdx — a 375-line paced three-day
onboarding curriculum — had zero inbound links and was reachable only by
running Storybook and spotting it in the sidebar. docs/README.md indexes
~20 documents and nothing at the root pointed at it either.
The README was also describing the pre-monorepo repo. Its centrepiece
atomic-design table was fictional: it claimed the folder structure IS the
hierarchy, with atoms/molecules/organisms/templates/pages directories that
exist nowhere. The truth is a better story and now replaces it — two
orthogonal axes, DDD on disk (context, then layer) and the atomic ladder in
the Storybook sidebar, which comes from story titles. Every other path was
stale too (src/app/, src/styles.scss, src/locale/, proxy.conf.json), the
second app was entirely absent, and 6 of 36 npm scripts were documented,
omitting `npm run ci` — the pre-push gate.
Adds a signpost table organised by what you are trying to do, a repo map,
the commands that matter, and keeps one corrected showcase section so the
repo still makes its case. Also fixes the index it now points at:
docs/README.md cited Foundations pages at src/docs/*.mdx, claimed the
backlog ran to WP-48 (it is at 75 — the range is dropped so it cannot go
stale again), and was missing ADR-0006 and the OpenZaak harness.
Verified rather than assumed: all 12 README links and every docs/README.md
link resolve, every named npm script exists, no stale path survives, and
the quick start was executed — backend serves swagger and the API on :5000,
behandelportal serves on :4201.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Four close-outs and their README rows. The behaviour spec is regenerated
once here rather than per-track — it derives from every test name in the
repo, so any track running it would have conflicted with the other three.
Records two findings the arc surfaced but did not cause: the /brief/preview
staleness for non-DemoOwner identities (blocking per-spec identity isolation
in brief-v2.spec.ts), and that WP-72/73 had to share a commit because both
edit Program.cs — separate execution waves prevented build collisions but
did not produce separable diffs.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Three seams WP-71 documented but left unguarded.
Deletes the FE's isHerregistratieEligible and isStatusConsistent — both
uncalled, the first dead by its own doc-comment. Their tests used fixtures
completely disjoint from the backend's (the backend even had an exact-window
boundary case the FE lacked), so the two sides could diverge indefinitely
without failing anything. CLAUDE.md's policy of keeping server-owned rules
as FE "reference impls" is what kept them alive, so it is amended: the FE may
mirror a server-supplied value for instant feedback, never reimplement the
algorithm. registration.policy.ts keeps its three live exports.
check-seam.sh now also guards the Besluit tag list — the C# enum and the TS
BESLUIT_TAGS array are identical ordered name lists with nothing linking
them, and Enum.TryParse fails at request time rather than build time. Anchored
on the full declaration so it avoids the "greps all matches" trap WP-69 hit.
The phone-format divergence turned out to be real, not latent as recorded:
the backend returned 422 for +31612345678 and (06) 12345678, both of which
the FE's own parseTelefoonnummer accepts. A grep check would have compared
the shared ^0\d{9}$ regex and reported all clear — the difference was in
stripping. RejectPhoneChange now strips what the FE strips, pinned by a
contract test.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
bdd.mdx previously banned "Given/When/Then ceremony" outright, which
directly contradicted WP-70's own acceptance tests (Acceptance/
BesluitLifecycleTests.cs already used // Given/When/Then comments) and
the backend's organically-evolved PascalCase_snake_sentence convention,
which the doc gave zero guidance for. Reverses that rule: every test is
now structured Given -> When -> Then, with a genuinely empty phase
omitted rather than faked; present-tense declarative naming and the
one-behaviour-per-test rule are unchanged. ADR-0006 gets a cross-reference
so both documents agree everywhere, not just in acceptance tests.
Also closes out the doc's other named-but-unenforced rules found by the
audit: fixes the 5 files asserting rendered $localize copy instead of
the underlying tag/message-id (the compliant pattern already existed in
werkvoorraad-item-view.spec.ts), splits the multi-behaviour titles the
doc itself calls a smell (";", "and", "/"), and fixes bdd.mdx's own false
citation of registratie-wizard.machine.spec.ts as "one transition per
test" by actually splitting that test into one-transition-per-test.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Writes up the principle behind WP-70's three tracks ("build test data
through the same door production code uses") as ADR-0006, with a decision
table for which fixture idiom fits which test type. Updates the
test-strategy skill (adds the Fixtures rule, fixes its stale pre-monorepo
src/app/... worked-example paths) and the shared Storybook testing.mdx page
to match. Closes WP-70 with the signatures/counts as actually shipped.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
npm run ci passed fully green (lint, format, tokens, all four test suites, both
localized builds, audit, backend dotnet test at 216 passing, snippet + api-client
drift checks). npm run e2e could not be verified this session: port 4200 was
occupied by an unrelated container from a different repo, so Playwright reused it
instead of starting this app — a pre-existing local port collision, not a
regression, and not part of the local GREEN gate per CLAUDE.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>