Two backlog trees are complete: `docs/project/backlog/` (75 files, every WP done) and `docs/project/refactor-backlog-setup/` (the arc before it). Move both under `docs/project/archive/` with `git mv`, so history stays intact through `git log --follow`. `SHOWCASE-ROADMAP.md` moves with them, because it points at the now-archived backlog README. Add `docs/project/archive/README.md`. It states that these trees are historical and names the two directories that are still live. Repoint every inbound reference named in RD-30's Files table: CLAUDE.md, the root README, both backend READMEs, `LetterHtml.cs`, `a11y.mdx`, the `document-feature` and `new-ssp` skills, and the readable-codebase PLAN, README, and RD-19 ticket. Fix two upward-relative links inside the moved WP files (WP-68, WP-69) that gained a directory level and would otherwise break. Repoint `.prettierignore`'s two agent-prompt exclusions to their new path, so prettier keeps leaving those files' exact wording alone. Mark RD-30 done and check off its acceptance criteria; flip its README row to done. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 KiB
RB-22 — BriefStore.load() tolerates a 404, calling reset() exactly once
Status: implemented · 2026-08-27 · Source findings: 04-cqrs-light.md CQ-007 ·
99-backlog.md RB-22, "Tickets that were rejected and split" · implementation/rb-17.md
(the runResult/runSubmit seam this store already sits on)
This is the expand half of an expand/contract pair. RB-23 (backend: GET /brief 404s
when absent; BriefStore.GetOrCreate splits into Get + ResetAndCreate) ships after this
ticket, in a later merge. Today's backend never 404s GET /brief, so this ticket's new
branch is dead code in the running app — provably backend-frontend-safe by construction.
What was wrong
CQ-007 flags GET /brief (Program.cs:603 → BriefStore.GetOrCreate,
Data/BriefStore.cs:50) as the one endpoint in the backend where a GET performs a
persisted write, breaking the read/write split every other endpoint respects. The fix is
split across both sides of the seam because the FE must be ready to receive a 404 before
the backend can safely start sending one. This ticket is the FE half: BriefStore.load()
(apps/ssp/src/app/brief/application/brief.store.ts) had no notion of "no brief exists
yet" — every adapter failure, 404 included, dispatched BriefLoadFailed and showed the
generic error banner. BriefAdapter.load() (brief.adapter.ts) also had no way to tell
the store a failure was specifically an HTTP 404: it folded every failure through the
shared runResult helper (RB-17), which keeps only a human-readable string and throws
away the HTTP status.
The ticket read as filed against the current code: BriefStore.load() is exactly where
CQ-007 says it is, BriefAdapter.load() is exactly the read runResult call RB-17 pointed
at it, and nothing about either file was factually wrong. Nothing to flag here.
What changed
| File | Change |
|---|---|
apps/ssp/src/app/brief/infrastructure/brief.adapter.ts |
load()'s error channel becomes BriefLoadFailure ({tag:'notFound'} | {tag:'error', reason:string}) instead of a plain string. load() no longer routes through the shared runResult — it does its own try/catch so it can read the thrown value's HTTP status before folding it away, via the new local isHttpNotFound predicate. Every other method (save/submit/approve/reject/send/reset) is untouched, still on runSubmit. |
apps/ssp/src/app/brief/application/brief.store.ts |
load() branches on BriefLoadFailure: notFound (and not already recovered) calls the existing reset() command directly and applies its result; every other failure (including a repeated notFound) dispatches BriefLoadFailed as before. Extracted applyLoadedView (the success-path body shared by load() and the new recovery path) and added recoverFromMissingBrief. |
apps/ssp/src/app/brief/application/brief.store.spec.ts |
New describe('BriefStore.load — 404 tolerance (RB-22)') with the two required cases. Six pre-existing load: fakes' explicit Result<string, BriefView> return-type annotations updated to Result<BriefLoadFailure, BriefView> (they only ever produce the ok: true branch, so this is a type-only change); two shared ok(v) test helpers that build fakes for both load and save had their return-type annotation dropped in favour of as const inference, since one helper now serves two different error-channel types. |
libs/shared/docs/behaviour-spec.mdx |
Regenerated (npm run gen:behaviour-spec) — picks up the two new it() titles. |
No backend/ file was touched — Program.cs and Data/BriefStore.cs are RB-23's, per the
ticket's explicit scope.
How the once-only bound is structural
BriefStore gains one field: private hasRecoveredFromMissingBrief = false. load()
takes the recovery branch only when r.error.tag === 'notFound' && !this.hasRecoveredFromMissingBrief,
and the branch's first statement sets the flag before doing anything else. A second 404 —
whether from a second load() call, or in principle from reset() itself somehow also
404ing — falls through to the plain BriefLoadFailed branch instead, on every subsequent
call, for the life of the store instance. This is a field on the singleton store, not a
comment: nothing in the reachable call graph can flip it back to false.
The loop CQ-007's proposed change warns about ("the reset's own load must not be able to
loop") is not merely bounded, it is structurally absent: recoverFromMissingBrief
calls this.adapter.reset() and applies its BriefView result directly (the same
applyLoadedView the success path uses) — it never calls this.load() again. There is no
recursive edge from the recovery path back into load() for the once-only flag to have to
stop; the flag exists only to stop a second, separate load() invocation (e.g. a
caller retrying navigation) from reaching reset() again.
Judgement calls
load()no longer usesrunResult, only for this one method.runResult(libs/shared/src/application/submit.ts, RB-17) intentionally keeps only a string — every other read in the app is fine with that. This is the first read that needs one more bit (the HTTP status) thanrunResultexposes, soload()does its own try/catch instead, matchingrunResult's shape (problemDetail(e, fallback)on the non-404 path) but adding the 404 branch first.libs/shared/src/application/submit.tsitself is untouched — changing a shared helper used by many call sites for one adapter's need was out of scope and unjustified.- 404 detection reads
(e as {status?:unknown}).status === 404, notSwaggerException.isSwaggerException. The generated client throws a plainSwaggerExceptionforGET /brieftoday (no OpenAPI 404 response is declared for it yet), but throws the parsedProblemDetailsobject instead for an endpoint whose spec does declare a 404 (both shapes carry astatusfield). Checkingstatusalone, not theSwaggerExceptiontype, means this predicate keeps working unchanged once RB-23 regenerates the client with a documented 404 response forbriefGET()— no follow-up FE edit needed for detection to keep working. BriefLoadFailureis a new exported type, not a sentinel string. CLAUDE.md's default reflex is a discriminated union over a second flag; a magic string ('__not_found__') compared by identity would have keptload()'s signature atResult<string, BriefView>and touched fewer test lines, but it is exactly the kind of stringly-typed control flow the union tool exists to avoid. The touched-test cost was six type annotations plus two helper signatures, all in the one already-scoped spec file — judged worth it for the correct shape.BriefLoadFailureis exported.- On a repeated 404, the store shows
BRIEF_LOAD_FAILED(the same generic banner textload()already used for every other failure), not a distinct "still missing" message. No new user-facing copy was needed or added, so no new$localizeid and nomessages.en.xlfchange — confirmed by diffing for$localizeoccurrences: both hits in the diff are unchanged context lines, not new additions. resetDemo()(the "start over" button) was left untouched, even though it duplicates part of the same apply-a-fresh-view logic now factored intoapplyLoadedView. It also managesactionState/saveState/rejectionSnapshotthatrecoverFromMissingBriefcorrectly does not touch (an automatic recovery on first load is not a user-initiated "start over" action), and refactoring it was not asked for by this ticket.
Verification
- Verified red without the fix. Temporarily replaced
load()'s body (viaEdit, notgit checkout) with the pre-fix shape — every failure,notFoundincluded, dispatchesBriefLoadFailedstraight away, noreset()call — and reran the spec file. Both new tests failed:expected "vi.fn()" to be called 1 times, but got 0 timesonreset, for both "a 404 drives exactly one reset()" and "a second 404 does not drive a second reset()"; the other 18 tests in the file stayed green. Restored the real fix with a secondEditand reran: all 20 tests in the file green, 30/30 across both touched spec files. npm run ci(foreground, no background/Monitor): green, exit 0 — lint, typecheck,dep:check(341 + 226 modules, 0 violations),format:check,check:tokens,check:seam, tests (ssp 260/260, behandelportal 37/37, shared 138/138, beheer 23/23 — 458 total),ng build --localize(both apps),npm audit(0 vulnerabilities), backenddotnet test(260/260 — the knownOpenZaakIntegrationTests.Admin_cases_…container-dependent failure did not reproduce on this run, matching the standing caveat that it needs a live OpenZaak container and is not this ticket's bug), backend dependency audit clean,gen:snippetsdrift clean,gen:behaviour-specdrift clean once the regenerated file was staged (the local gate diffs the working tree against the index, so it necessarily shows a diff before the file is staged/committed — the same documented, expected behaviour RB-17 recorded, not a defect).npx prettier --checkon every touched file (including the reformatted99-backlog.mdtable and the regeneratedbehaviour-spec.mdx): clean.
What RB-23 must do
Once GET /brief in Program.cs returns a real 404 (no ProblemDetails body is
required — BriefAdapter.load()'s isHttpNotFound only reads the HTTP status, not the
response body), and BriefStore.GetOrCreate splits into Get (query) + the existing
ResetAndCreate (already there, already used by POST /brief/reset), this ticket's
notFound branch stops being dead code and starts running on first-ever page load for any
owner with no persisted brief. Run npm run gen:api as part of RB-23 so briefGET()
regenerates with a documented status === 404 branch throwing the parsed ProblemDetails
(matching the shape most other endpoints already use) — the detection predicate here
already tolerates that shape and needs no FE follow-up change. Two things worth
re-verifying once RB-23 lands, not fixing preemptively here: first, that the resulting
double round-trip (404, then reset()) is an acceptable UX cost on a first visit, per
CQ-007's own framing of this as its least certain finding; second, that this store's
hasRecoveredFromMissingBrief field — private to one store instance, reset only by a full
reload — is still the right lifetime for the once-only guard once a real 404 can occur in
production traffic, not only in a test's fake adapter.