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>
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>
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>
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>
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>
The doc-comment claimed "the backend re-validates on submit as the authority" —
it doesn't. Neither SubmitApplicationRequest nor IntakeRequest carries a scholing
answer at all, so there's nothing to re-validate; both submit paths only apply
SubmissionRules.RejectZeroUren. A crafted POST can bypass the scholing requirement
entirely. States the gap and points to WP-69 (opened, not yet planned) for the
enforcement, which needs a wire change.
Also strengthens the F2 concurrency test to assert the persisted status matches
whichever request actually won the race, and updates WP-68's own Decisions/
acceptance-criteria text to reflect two implementation choices that improved on
the original write-up once real constraints surfaced: ProcessingWindow stays on
ApplicationStore (StatusAt is already in the same file), and AanvraagStatusTag is
not given a Concept member (would have broken
AanvraagStatusTag_covers_the_published_lifecycle) — AanvraagStatus.Tag is nullable
instead, null exactly for Concept.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Architecture review found the context boundaries, FP/TEA idioms and read/write
separation sound, and rejected explicit CQRS as the fix for anything found. It
located four real defects clustered in one place: the backend's aggregate roots
don't guard their own invariants, and the aanvraag status lifecycle is a computed
string living in the contracts layer instead of the domain. Full Decisions block
pre-made so implementation can proceed without re-litigating scope.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
WP-65's summary-table row still said "in progress (65a done)" from before
65b shipped, and that one oversized cell was forcing prettier to want to
re-pad the entire ~65-row table. Correcting it to "done" (its actual
status, per the WP-65 file's own outcome notes) removes the outlier —
npm run format:check is green again with zero other rows touched.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
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>
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>
Restructures into apps/ssp + apps/behandelportal (two Angular projects)
plus libs/shared + libs/beheer (cross-app libraries), replacing WP-61's
separate sibling repo. That split had already produced real drift: a
hand-vendored copy of the backend's OpenAPI doc, a shared/ui+layout tree
forked and silently diverging (7 files), and beheer + the styles.scss
token bridge duplicated byte-for-byte across both repos.
- git mv the SSP's src/app/* into apps/ssp/; fold shared/, beheer/,
environments/, the Storybook docs/*.mdx, and styles.scss into
libs/shared + libs/beheer (all confirmed identical between the two
repos before merging). auth stays deliberately duplicated per
ADR-0002 (actor-specific, expected to diverge) - amended there.
- One generated API client (libs/shared), no more vendored swagger.json.
- .dependency-cruiser split into a base factory + one config per app,
and Storybook into .storybook-ssp/.storybook-behandelportal - both
forced by the @auth/* alias resolving to different directories per app.
- SiteHeaderComponent/ShellComponent gained HEADER_NAV_ITEMS/
HEADER_ADMIN_LINKS/DEBUG_PANEL injection tokens so each app supplies
its own nav/admin-links/dev-panel instead of one being hardcoded.
- CLAUDE.md, ARCHITECTURE.md, dependencies.md, and ADR-0002 updated;
WP-67 backlog entry documents the full decision trail.
npm run ci green (lint, dep:check x2, 360 tests across ssp/
behandelportal/shared/beheer, both localized builds, backend tests,
snippet + api-client drift); both dev servers, both Storybook
instances, and docker compose verified working.
The old sibling repo (/home/eho/repos/behandelportal) is left
untouched, not deleted.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Model the full ADR-0002 lifecycle (Ingediend/InBehandeling/
MeerInfoGevraagd/Goedgekeurd/Afgewezen) as a backend enum backing the
existing AanvraagStatusDto.Tag string, and widen the FE union/parse
boundary/switches to match. Ingediend/MeerInfoGevraagd aren't reachable
yet (no behandelaar transition exists) — that's WP-65. Zero DTO shape
change, so gen:api has no drift.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
WP-61 (bootstrap the behandelportal app) is done — a separate sibling repo
at /home/eho/repos/behandelportal, not a commit in this one. Records the
one real kickoff deviation from the vanilla new-ssp recipe: create-ssp.mjs
unconditionally renames the backend project even with --skip-backend, which
conflicts with the WP's "no new backend service" decision. Resolved by
deleting backend/ from the new repo and vendoring its swagger.json into
api-contract/ instead, with gen:api regenerating only the client against
that vendored doc.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Renamed scripts/create-ssp.mjs -> create-frontend.mjs (+ its WP-45 doc, npm
script, and every prose/command reference) since "ssp" reads as an acronym
where "create-frontend" says what it does.
Also fixes two real bugs found while running it for real during WP-61:
scripts/ci-local.sh was missing from RENAME_CONTENT_FILES (any --name'd
clone that keeps a backend would break `npm run ci`, still hardcoding
BigRegister.slnx), and plopfile.mjs's `gen:context` insertion into
.dependency-cruiser.js anchored on the `showcase: null,` line, which
create-ssp/create-frontend has already stripped by the time gen:context
runs in the same invocation — silently leaving a freshly scaffolded
context with no CONTEXT_ALLOWED fence entry at all. Re-anchored on the
`const CONTEXT_ALLOWED = {` line instead, which never moves.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
Drives the DRC upload's vertrouwelijkheidaanduiding from a new stamdata
table instead of the hardcoded "openbaar", following the existing
config-as-code pattern (ADR-0004). Adds the referential-integrity check
StamdataValidationTests was missing for the new table.
OpenZaak doesn't serve the Notificaties API itself (it's a separate app,
open-notificaties) — standing one up for a real abonnement would triple
this harness for a benefit it doesn't need (exactly one subscriber, this
repo's own BFF). Instead, an opt-in compose overlay adds a celery worker
and points OpenZaak's NotificationsConfig straight at the BFF's webhook
via a zgw_consumers Service; bootstrap-notificaties.sh configures it
idempotently and verify-notificatie.sh proves a real write delivers to
the BFF's audit trail end-to-end.
Verified live: preflight proves the webhook's shared-secret gate both
ways (204/401), a zaak PATCH triggers real celery delivery, and rerunning
both scripts against an already-configured harness stays idempotent.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
setup_configuration has no YAML field for granular autorisaties, so
bigregister-test now starts at heeft_alle_autorisaties: false (dev + prod
template) and bootstrap-catalogus.sh grants exactly the ztc/zrc scopes the
harness needs via the Django ORM, sidestepping the zero-scope
chicken-and-egg with the JWT-authenticated Autorisaties REST API.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
bootstrap-catalogus.sh now looks up every resource by its natural key before
creating it (catalogus by domein+rsin, zaaktype by catalogus+identificatie,
statustype by zaaktype+volgnummer, roltype by zaaktype+omschrijvingGeneriek,
zaaktype-publish by checking `concept` first, zaak by identificatie,
status/rol by existence-under-the-zaak), so rerunning against an
already-seeded instance reuses what's there instead of erroring.
The WP's original plan (move this into OpenZaak's `setup_configuration`
mechanism) turned out not to be achievable: reading the actual
django_setup_configuration steps installed inside the open-zaak image shows
no step exists for Catalogi/Zaken content anywhere in this OpenZaak version
— only sites/credentials/applicaties/selectielijst. Documented as a
deviation; the WP's own Risks section already anticipated this and sanctioned
falling back to an idempotent script.
Verified live: fresh instance -> full run (all created) -> integration test
green -> reran the script twice more against the same instance (all reused,
identical URLs, no duplicates) -> integration test still green.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
docker-compose.openzaak.prod.yml layers real SECRET_KEY/DB password/site
domain/allowed-hosts (all required, fail-fast via ${VAR:?...}) on top of the
WP-54 dev harness, switches Postgres off trust auth, and sets IS_HTTPS for a
front-facing reverse-proxy TLS setup. The ZGW client secret lives inside a
file setup_configuration reads rather than a compose env var, so it's
templated (data.prod.yaml.template, no secret) and rendered host-side via
render-prod-secrets.sh into a gitignored data.prod.yaml, mounted over the
container's dev data.yaml. ZgwOptions.cs already binds from IConfiguration,
so the BFF side needed no code change.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
WP-55..60 harden the OpenZaak integration for production (secrets/TLS,
idempotent provisioning, least-privilege scopes, real notifications,
confidentialiteit config, write-divergence resilience). WP-61..66 stand up
a staff-facing behandelportal per ADR-0002, wired to the same backend via
BFF-lite decision DTOs. Both phases are independent tracks; WP-60's
Decisions block is deliberately left open for a planner-agent kickoff.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The watched push came back all-green after ebf1f8f (the non-root Dockerfile
fix semgrep's live run caught). Marks the entire showcase/ZGW backlog
(WP-05..54) done.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Records what's implemented (items 1/3/4/5/6), what's deliberately skipped
this round (item 2, blocked on act_runner access), and that the WP can't be
marked fully done until a real Gitea run confirms the CI-timing/path-filter
behavior this environment can't observe. npm run ci confirmed green locally.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Opt-in docker-compose (postgres+redis+OpenZaak, no celery/nginx) +
bootstrap-catalogus.sh seed a real OpenZaak instance; OpenZaakIntegrationTests
(Category=Integration, excluded from default dotnet test/CI) proves the ZGW
seam against it for the first time. That live run caught a real bug:
ZgwHttpClient never sent Content-Crs/Accept-Crs headers, so every write would
412 against a spec-compliant OpenZaak — fixed alongside the harness.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
Endpoint/DTO/options landed already in c4dd846; this closes the loop with
NotificatieTests.cs (accept/reject/missing-header, asserting the AuthzAuditStore
row), missing appsettings.json keys (also backfills DrcBaseUrl/
InformatieobjecttypeUrls, stale since WP-51), and the webhook + abonnement
provisioning docs.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>