Commit Graph
332 Commits
Author SHA1 Message Date
ehoandClaude Opus 5 6cfba81a39 docs(shared): add the missing language-switcher row to the CIBG gap register (RB-32)
The register at libs/shared/docs/cibg-gaps.mdx had 8 rows for 9
CIBG-GAP EXTENSION markers in code. language-switcher carries a
well-formed marker with no matching row, exactly as ADR-C-008 and
adr-c-007.md's handoff note flag. Add the row from the component's
own marker comment.

Also add a small guard to check-tokens.sh (folded into check:tokens,
as ADR-C-008 suggests as an optional step): it diffs the marker set
in code against the register's rows and fails CI on drift. Verified
working with a scratch marker file before removing it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 13:15:31 +02:00
ehoandClaude Opus 5 03c6e09306 docs(backlog): CD batch 5 complete
All seven tickets RB-24 to RB-30 merged, one commit per ticket. Records the
actual wave split, since the backlog's own depends-on column missed that
RB-24 rewrites imports in two of RB-28's target files.

RB-24 expanded its own scope to fix a second, real boundary violation that
deleting its acceptance criterion exposed, reviewed and accepted. Two more
findings were shown stale or overstated, on top of the nine from earlier
batches. RB-26 and RB-27 both correctly declined part of their own ticket's
proposed shape.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:55:39 +02:00
ehoandClaude Opus 5 c4a5d20202 docs(backlog): mark RB-27 done after merge
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:53:00 +02:00
ehoandClaude Opus 5 a260af6120 Merge RB-27 — extract uploadOutcome from the XHR closure
TE-005: xhrUpload buried the 2xx-vs-not check, JSON.parse-with-fallback and
ProblemDetails mapping inside XHR listener bodies, unreachable without
stubbing the XHR global. uploadOutcome(status, responseText) is now a pure
function with no DOM and no XHR stub in its spec. Abort-vs-error
disambiguation stays where it is: it fires on a different event with no
status or responseText, so it cannot fit the extracted signature. The
optional currentScenario() move into KeepaliveTransport.send() was not
taken, since it would cross into upload-shell.service.ts, outside this
ticket's scope.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:52:50 +02:00
ehoandClaude Opus 5 e63db509ef refactor(shared): extract uploadOutcome from the XHR load closure (RB-27)
UploadAdapter.xhrUpload built new XMLHttpRequest() directly and put the
actual decisions inside its load listener: 2xx-vs-not, JSON.parse of the
body with a fallback, and ProblemDetails mapping via parseError. None of
it was reachable without stubbing the XHR global, so it had no spec
(TE-005; file LH 5/64, BRH 3/57).

Extract uploadOutcome(status, responseText): Result<string, {
documentId }>, a pure function next to genericError/parseError. It holds
the 2xx check, the JSON.parse-with-fallback, and the ProblemDetails
mapping. The load listener is now a two-line dispatch into it.

Abort-vs-error disambiguation stays where it is: it decides whether a
response exists at all, before uploadOutcome would even run, and the
proposed signature has no field for "aborted". It is already a one-line
ternary with no DOM-only logic to extract.

Add upload.adapter.spec.ts: plain describe/it, no DOM, no XHR stub,
covering a 2xx success, a 2xx unparseable body, a non-2xx ProblemDetails
body, a non-2xx non-ProblemDetails body, and the 200/300 boundary.
Verified red by editing uploadOutcome down to one line (an Edit, not
git checkout): 4 of 5 new specs failed. Re-applied with a second Edit.
Coverage for upload.adapter.ts: LH 5/64 -> 12/65, BRH 3/57 -> 7/59.

Skip TE-005's optional half (moving the currentScenario() branch into
KeepaliveTransport.send()): it needs a second file, upload-shell.
service.ts, and this ticket's own scope fences it to upload.adapter.ts
and its spec. The dev simulator's behaviour is unchanged.

Mark RB-27 implemented in 99-backlog.md and add its implementation note,
including a batch 5 close-out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:52:17 +02:00
eho 6372d452a4 Merge RB-28 — add BLOB_PRESENTER, unlock the blob-to-browser success paths
TE-006: StamdataStore.download(), BriefStore.previewLetter() and
OrgTemplateStore.proefbrief() each ended in raw DOM blob calls jsdom cannot
meaningfully execute, so their success paths were unassertable and
download()'s two-clause guard true-branch was permanently dark.
BLOB_PRESENTER mirrors the SESSION_PORT shape; all three commands go through
it. download()'s branch coverage goes from 40.5% to 67.6%, and
org-template.store.ts gets its first spec at all.

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
2026-08-28 08:39:09 +02:00
ehoandClaude Opus 5 ce952941bb refactor(shared): add BLOB_PRESENTER, unlock the blob-to-browser success paths (RB-28)
Three application-layer commands ended in raw DOM calls (URL.createObjectURL,
window.open, document.createElement('a').click(), URL.revokeObjectURL) as
their last statement. jsdom cannot assert a call that is also the end of the
function, so each command's success path stayed unassertable, and
StamdataStore.download()'s two-clause guard stayed permanently dark on its
true branch (TE-006).

Add BLOB_PRESENTER (libs/shared/src/application/blob-presenter.ts), an
InjectionToken mirroring SESSION_PORT's shape: an interface with open()/
download(), a real implementation preserving the existing open()-never-
revokes vs download()-always-revokes asymmetry, provided in root. Route
StamdataStore.download(), BriefStore.previewLetter(), and
OrgTemplateStore.proefbrief() through it.

Add specs with a recording fake presenter: StamdataStore.download()'s guard
(both clauses) and its success path, asserting toJson(...)'s exact output
reaches the file; BriefStore.previewLetter()'s existing success test now
goes through the seam instead of spying on window/URL directly; a new
org-template.store.spec.ts (none existed before) covers proefbrief()'s
success and failure paths.

Verified red without the fix by editing the download() filename to the
wrong extension, watching the success-path spec fail, then restoring it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:38:14 +02:00
eho a0e2985fb3 Merge RB-25 — add the UPLOAD_TRANSPORT injection token
TE-003: UploadShellService documented UploadTransport as the swap seam, then
bound the concrete, unexported KeepaliveTransport class directly, so a spec
could not fake it. UPLOAD_TRANSPORT copies the SESSION_PORT shape; the
default factory returns the same instance, so runtime behaviour is
unchanged. upload-shell.service.ts goes from 0% to 88.57% line coverage
across 16 new specs for upload(), cancel(), delete() and pollReturning().

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
2026-08-28 08:33:36 +02:00
ehoandClaude Opus 5 adad4513d0 refactor(shared): add UPLOAD_TRANSPORT injection token (RB-25)
UploadShellService injected the concrete KeepaliveTransport class instead
of a token. The class was not exported, so a spec could not fake it, and
could not provide against the UploadTransport interface either, since an
interface is not a DI token. The port existed only on paper.

Add UPLOAD_TRANSPORT, an InjectionToken with a default factory that
resolves the same KeepaliveTransport singleton, following the
SessionPort/SESSION_PORT shape. UploadShellService now injects the token.
Runtime behaviour is unchanged.

Add upload-shell.service.spec.ts: a recording fake transport plus a fake
UploadAdapter exercise upload(), delete(), cancel() and pollReturning(),
the four methods the missing seam left unreachable. Coverage for
upload-shell.service.ts goes from 0% to 88.6% line / 85% branch.

Mark RB-25 done in 99-backlog.md and add its implementation note.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:31:34 +02:00
eho 3162c755d6 Merge RB-26 — extract planFileSelection from the upload controller
TE-004: createUploadController performed three inject() calls, an effect()
registration and a window listener before returning, so the real policy
buried inside it — deciding per file whether to reject or start an upload —
was reachable only through a TestBed. planFileSelection in upload.machine.ts
is now that decision as a pure function taking plain {name, type, size}
objects; the controller executes the plan and keeps the one impure step
(crypto.randomUUID()) it can't move. No change to the controller's public
surface.

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
2026-08-28 08:29:36 +02:00
ehoandClaude Opus 5 95bb77395e refactor(shared): move the accept/reject decision into planFileSelection (RB-26)
createUploadController required inject(), an effect(), and a window listener
before a test could reach it. The file-selection policy trapped behind that
cost now lives in a pure function, planFileSelection, in upload.machine.ts.

planFileSelection takes plain { name, type, size } objects, not File, and
decides per file whether to reject it or accept it, with no I/O. The
controller executes the plan: it dispatches a rejection as-is, and starts the
upload for an accepted file (the one step that needs crypto.randomUUID()).

A new spec covers the three outcomes: the 'multiple' batch rejection, a
rejectReason-based rejection, and the accept case, plus order in a mixed
batch. Verified red-then-green with a temporary stub, undone by a second edit.

No change to the controller's public surface or to the calling organism.
previewUrlFor (added by RB-24) is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:28:30 +02:00
eho e304211715 Merge RB-30 — extract BriefStore's guards into Domain/Letters/BriefRules.cs
TE-008: five guard decisions in BriefStore (Save, Submit, Send, the shared
Approve/Reject review path) were pure functions of status tag, actor role and
entity completeness, but each sat inside a lock-held, DB-opening method, so a
spec could not exercise a decision without a booted host and a real SQLite
file. BriefRules.cs holds the five pure statics; BriefStore keeps its lock,
its Db.Create(), its static shape and every method signature. 29 new
free-running unit assertions in BriefRuleTests.cs; the existing host-booting
brief endpoint tests are unchanged.

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
2026-08-28 08:08:00 +02:00
eho 80b792caa9 Merge RB-29 — resolve the body datum placeholder from at, not UtcNow
TE-007: Render already accepts the letter's instant and uses it correctly for
the letterhead, but the body's datum placeholder resolved through ResolveAuto,
which ignored at and read DateTimeOffset.UtcNow. Threaded at through
RenderParagraphs and RenderNode, both already in Render's call chain with at
in scope. Zero public API change, zero call-site change. The bug this
prevents: re-rendering an archive or back-dating a letter would otherwise make
the letterhead and body dates disagree within a single document.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
2026-08-28 08:01:39 +02:00
ehoandClaude Opus 5 ddd02f65bc fix(backend): resolve the body datum placeholder from at, not UtcNow (RB-29)
LetterHtml.Render already receives the letter's instant and uses it
for the letterhead date. The body's "datum" placeholder resolved
through ResolveAuto, which ignored that instant and read the wall
clock instead. This is not a shipped bug today, because every current
caller passes Now() at render time. It becomes one the moment Render
runs with a historical instant (an archive re-render, a back-dated
letter): the letterhead and the body would then disagree within one
document.

Thread the existing "at" parameter down through RenderParagraphs and
RenderNode into ResolveAuto's "datum" case. Render's own signature,
and every call site, stays unchanged.

Add two tests with a fixed historical "at": one pins the body's
rendered date to the expected Dutch string, the other asserts the
letterhead date and the body date agree. Both fail red against the
old code, showing today's date instead of the pinned one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 20:42:58 +02:00
ehoandClaude Opus 5 07bb6277c0 refactor(backend): extract brief guards into Domain/Letters/BriefRules.cs (RB-30)
BriefStore's five guard decisions (Save, Submit, Send, and the shared
Approve/Reject review path) were pure functions of status tag, actor role,
and entity completeness, but each sat inside a lock-held, DB-opening
method. A spec could not exercise the decision without a booted host and
a real SQLite file.

Extract the guards into a pure Domain/Letters/BriefRules.cs. BriefStore
keeps its lock, its Db.Create(), its static shape, and every method
signature — only the if cascades move. Add BriefRuleTests.cs (29
assertions, ~120 ms, no host boot) covering every branch, including the
rejected-to-draft reopen on save, the required-filled gate on submit,
and the non-drafter and self-review denials. The existing host-booting
brief endpoint tests are unchanged and still pass, proving the
extraction preserved behaviour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 20:42:00 +02:00
ehoandClaude Opus 5 693016445b Merge RB-24 — move upload/ into its proper layers, delete the carve-out
ADR-C-002: libs/shared/src/upload/ held a network adapter outside
infrastructure/ and the only Elm machine outside a domain/ folder, and the
dependency-cruiser rule was written around the violation rather than the
violation being fixed. The five files move to infrastructure/, domain/ and
application/, and the ^libs/shared/src/upload/ carve-out is gone.

Deleting the carve-out exposed a second, real violation that the old path had
hidden from the ui-not-infrastructure rule: three UI components injected
UploadAdapter for nothing but a one-line wrapper over its own exported pure
uploadContentUrl. They now read previewUrlFor from their application-layer
collaborator. dep:check passes for both apps with the clause removed, which is
the ticket's acceptance criterion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 20:41:54 +02:00
ehoandClaude Opus 5 9520d6c24e refactor(shared): move upload/ into infrastructure/domain/application (RB-24)
libs/shared/src/upload/ held a network adapter, an Elm machine, and two
application-layer coordinators outside the folder-per-layer convention every
other context follows. The dependency-cruiser rule carved an exception around
the misplaced adapter instead of the violation being fixed.

Move all five files to the layer each belongs to (git mv), update every
import across 24 consumer files, then delete the carve-out clause from
.dependency-cruiser.base.js. No export renamed, no file split, no spec
content changed.

Deleting the carve-out exposed a second, pre-existing rule violation:
ui-not-infrastructure had never fired against upload.adapter.ts because its
old path did not match /infrastructure/. Three UI components injected
UploadAdapter directly for its one-line contentUrl() wrapper. Route each
through the existing pure uploadContentUrl() function via the application
layer (upload-controller's new previewUrlFor, OrgTemplateStore's new
previewUrlFor) instead — the same idiom brief.store.ts already used.

npm run ci passes; dep:check is clean for both apps with the carve-out gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 20:40:41 +02:00
ehoandClaude Opus 5 424ceb604b docs(backlog): CD batch 4 complete
All six tickets RB-18 to RB-23 merged, one commit per ticket. Records the two
incomplete tickets that the agents reported, RB-22's deliberate departure from
the runResult idiom, and how RB-19 was verified as a pure reorder.

Adds five dispatch lessons. The stale worktree base is now the rule at 11 of 13
agent-runs. A spend limit killed four agents mid-flight and a message resumed
each one from its own transcript, so no work was redone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 19:21:01 +02:00
ehoandClaude Opus 5 dc096d98e2 Merge RB-19 — reorder Program.cs sections into reads then writes
CQ-006: the file declared direction as its organising principle, then switched
to feature grouping without saying so, and five sections interleaved reads and
writes. Each section now orders reads first, with the WP-65 sub-banner pair.
DELETE /admin/cases/{id} and GET /admin/audit move up beside GET /admin/cases,
129 lines closer. The org-template preview moves to the org-template section.

Pure reordering. Verified centrally: the sorted list of all 47 route strings is
identical before and after, and so is every (route, .Gate marker, wrapper called
in the handler) triple. The swagger.json and api-client.ts diffs are ordering
only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 19:20:11 +02:00
ehoandClaude Opus 5 e270b8612f refactor(backend): reorder Program.cs sections into reads-then-writes (RB-19)
CQ-006 found that Program.cs states a reads-then-writes principle at the
top of the file, then abandons it for five feature sections that mix GET
and mutating endpoints in mapping order. This is a pure reorder: within
Document upload, Applications, Admin cases, Brief, and Organization
templates, every GET now precedes every POST/PUT/DELETE, each split by a
`--- reads ---`/`--- writes ---` sub-banner in the style WP-65 already
established for Beoordeling/Besluit.

DELETE /admin/cases/{id} and GET /admin/audit move up beside GET
/admin/cases, closing the 129-line gap CQ-006 measured. GET
/admin/org-template/{subOrgId}/preview moves from the Brief section to
the Organization-templates section it actually belongs to.

No route, signature, DTO, or handler body changed. Every block was cut
by exact line-range slicing, never retyped. The sorted list of mapped
HTTP-method-plus-path strings is byte-identical before and after; every
.Gate(...) count is unchanged; the three routes that moved with a gate
were checked by eye against the wrapper their handler actually calls,
per RB-12's stated limitation that the route-table test only proves a
marker is present, not that it still matches the handler.

npm run gen:api regenerated backend/swagger.json and
libs/shared/src/infrastructure/api-client.ts; both diffs are ordering
only (sorted-file diff is empty), committed alongside per the ticket's
own guidance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 19:16:05 +02:00
ehoandClaude Opus 5 edd20c06df docs(backlog): mark RB-23 done after merge
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 19:02:09 +02:00
ehoandClaude Opus 5 9f3814d8b9 Merge RB-23 — make GET /brief a pure query that 404s when absent
CQ-007 contract half. GET /brief was the only backend endpoint where a GET
performed a persisted write, and the FE retries GETs automatically, so a
transient failure could enter the create path. BriefStore.GetOrCreate splits
into Get plus the existing ResetAndCreate. GET /brief/preview shared the same
call site and gets the same treatment. RB-22 already made the FE tolerate the
404, so the pair is complete.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 19:01:42 +02:00
ehoandClaude Opus 5 d0fda08bcc fix(brief): make GET /brief a pure query, 404 when absent (RB-23)
GET /brief allocated a row on first call (BriefStore.GetOrCreate) — the
one endpoint in the backend where a read performed a persisted write.
The FE retries GETs automatically, so a transient failure could enter
the create path more than once; a lock prevented a duplicate row, but
the safety depended on the lock, not on the endpoint being a query.

Split GetOrCreate into Get (a pure query) and the already-existing
ResetAndCreate (POST /brief/reset owns creation). GET /brief now 404s
when the owner has no brief yet. GET /brief/preview used GetOrCreate
too, so it gets the same Get + 404 treatment, forced by the split.

RB-22 already made BriefStore.load() on the FE tolerate a 404 by
calling reset() once; this ticket is what makes that branch live.

Updated the brief/preview/org-template backend tests that assumed
GET seeded a brief on first call to create one explicitly first, and
added a test that GET 404s and writes no row without the fix (verified
red beforehand). Regenerated the API client (npm run gen:api).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 19:01:06 +02:00
eho 05dff974bf Merge RB-22 — tolerate a 404 on GET /brief with a one-shot reset
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
2026-08-27 18:44:20 +02:00
ehoandClaude Opus 5 7a29f5facc feat(brief): tolerate a 404 on GET /brief with a one-shot reset (RB-22)
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>
2026-08-27 18:43:27 +02:00
eho 6c5c4cb249 Merge RB-20 — route cancel and delete through runSubmit, surface the error
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
2026-08-27 18:33:38 +02:00
eho 9666790d65 Merge RB-18 — key IdempotencyStore on caller plus idem key
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
2026-08-27 18:33:12 +02:00
ehoandClaude Opus 5 7def4a7552 fix(ssp): route cancel/delete through runSubmit, surface the error (RB-20)
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>
2026-08-27 18:32:51 +02:00
ehoandClaude Opus 5 0adf831fb9 Merge RB-21 — extract the read half of createDraftSync into find-concept.ts
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>
2026-08-27 18:29:20 +02:00
ehoandClaude Opus 5 25a5d415a5 docs(adr): land ADR-C-001, ADR-C-003, ADR-C-007 and ADR-C-009
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>
2026-08-27 18:29:05 +02:00
ehoandClaude Opus 5 4631556e68 fix(backend): key IdempotencyStore on caller + idem key (RB-18)
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>
2026-08-27 18:27:52 +02:00
ehoandClaude Opus 5 d518a1466c refactor(registratie): extract the read half of createDraftSync (RB-21)
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>
2026-08-27 18:23:13 +02:00
ehoandClaude Opus 5 7fbac8fca5 docs: write English prose in Simplified Technical English
Adds a Conventions rule for Simplified Technical English (ASD-STE100). It
covers documentation, code comments, commit messages, ADRs, and the backlog
notes. STE is a controlled language. It makes text easy to read for people
who do not have English as a first language, and easy to translate. The
readers of this project are mostly non-native English readers.

The rule states that STE governs form, not content. Split a long sentence.
Never remove a caveat, a measurement, or a precise term to make text shorter.

The rule does not apply to Dutch identifiers, $localize copy, quoted output,
or existing documents that you are not already editing. It therefore does not
change the Naming convention above it, which keeps domain contexts in Dutch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 17:03:37 +02:00
ehoandClaude Opus 5 bc5b2c4b2d docs(backlog): CD batch 3 complete
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>
2026-08-27 17:01:41 +02:00
eho 2a28db4aac Merge RB-12 + RB-15 + RB-16 — route-table authz gate, Swagger dev-only, peildatum 400
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	libs/shared/docs/behaviour-spec.mdx
2026-08-27 16:58:41 +02:00
eho ab0ec62f6a Merge RB-13 — land Session -> Principal, add MedewerkerAdapter
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	libs/shared/docs/behaviour-spec.mdx
2026-08-27 16:58:34 +02:00
ehoandClaude Opus 5 f19185ed81 refactor(auth): land Session -> Principal, add MedewerkerAdapter (RB-13)
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>
2026-08-27 16:54:26 +02:00
ehoandClaude Opus 5 b617d2f09a docs: regenerate behaviour-spec for RB-12/RB-15/RB-16
New backend test classes (RouteInventoryTests, SwaggerGateTests) plus
one added case to StamdataEndpointTests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 16:53:52 +02:00
ehoandClaude Opus 5 2627799284 fix(backend): 400 instead of 500 on an unparseable peildatum (RB-16)
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>
2026-08-27 16:53:29 +02:00
ehoandClaude Opus 5 a93218e8ac fix(backend): gate Swagger + the OpenAPI doc behind IsDevelopment (RB-15)
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>
2026-08-27 16:40:23 +02:00
ehoandClaude Opus 5 ee0d449510 test(backend): assert every route is authz-gated (RB-12)
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>
2026-08-27 16:36:30 +02:00
ehoandClaude Opus 5 1c5442d797 Merge RB-17 — split runResult out of runSubmit
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 16:34:16 +02:00
ehoandClaude Opus 5 80de261299 refactor(shared): split runResult out of runSubmit (RB-17)
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>
2026-08-27 16:31:13 +02:00
ehoandClaude Opus 5 adfaa32a42 ci: gate on known advisories in the .NET dependency tree (RB-14)
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>
2026-08-27 16:24:16 +02:00
ehoandClaude Opus 5 988612cd7e docs(backlog): CD batch 2 complete
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>
2026-08-27 14:37:20 +02:00
ehoandClaude Opus 5 6bdfa35abb build: stop ci-local.sh swallowing the first half of every paired step
Under `set -e` bash exempts every command of an AND-OR list except the last,
so `npm run gen:api && git diff --exit-code ...` silently swallowed a CRASH
in gen:api: the diff never ran and the script sailed on to print "local CI
passed". Verified directly — `bash -c 'set -e; false && true; echo hi'`
prints hi and exits 0, while `false; true` exits 1.

This was not hypothetical. It hid a real gen:api crash introduced by RB-09
(dotnet swagger's design-time host defaults to Production, which that ticket
made throw at startup). .github/workflows/ci.yml would have caught it, since
it runs each step as its own `- run:` — so the local gate was strictly weaker
than the remote one, which is the opposite of its stated purpose.

Six steps were affected. The worst was `ng build ssp --localize && ng build
behandelportal --localize`: a missing English translation in ssp — the exact
thing the second-locale gate exists to catch — could not fail the run.

The one `( cd backend && ... )` step is safe as-is and left alone: a subshell
propagates its own non-zero status, so errexit sees it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:31:50 +02:00
eho 2fa96c300c Merge RB-08 + RB-09 — CasesAdmin on the admin upload delete; no-identity representable
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	libs/shared/docs/behaviour-spec.mdx
2026-08-27 14:30:41 +02:00
ehoandClaude Opus 5 c6bc6dd4c3 docs(backlog): RB-11 done
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>
2026-08-27 14:29:50 +02:00
ehoandClaude Opus 5 d089151dbd docs: record the gen:api regression found while verifying RB-09
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>
2026-08-27 14:22:26 +02:00
eho c336328cff Merge RB-11 — keep the dev hatches out of production builds
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	libs/shared/docs/behaviour-spec.mdx
2026-08-27 14:21:51 +02:00