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>
9.6 KiB
RB-20 — route ApplicationsStore.cancel / AdminCasesStore.delete through runSubmit, surface the error
Status: implemented · 2026-08-27 · Source finding: 04-cqrs-light.md CQ-002 ·
00-baseline.md BL-007 · 99-backlog.md RB-20 · SIGN-OFF: consolidation approved
2026-08-27, HALT lifted
What was wrong
ApplicationsStore.cancel and AdminCasesStore.delete both owned an optimistic write next
to their RemoteData read, and both reached ApplicationsAdapter directly instead of going
through runSubmit (the fold + Idempotency-Key mint every other mutation in the repo uses,
including createSubmitChangeRequest in the same folder). The failure path was a bare
catch { this.state.set(before); }: a failed cancel or delete rolled the row back, but the
user saw no message at all — no ActionState, no ProblemDetails detail, nothing. The
Idempotency-Key on the wire was also a fresh UUID per HTTP attempt (minted by
api-client.provider.ts's default), not the per-logical-submit key runSubmit promises —
harmless today only because Program.cs happens to ignore the header outside the Submit
helper (CQ-005's note).
What changed
CQ-002's option (a) — the smallest fix, applied identically to both stores. No new command factory, no adapter split (CQ-002's own "Not filed" note reserves that split for option (b), which this ticket does not take).
| File | Change |
|---|---|
apps/ssp/src/app/registratie/application/applications.store.ts |
cancel now calls runSubmit(() => this.adapter.cancel(id), SUBMIT_FAILED); added a private error signal, exposed read-only as lastError. On failure: roll back AND this.error.set(r.error). On the next attempt, the error is cleared before the call so a stale message never survives a fresh action. |
apps/ssp/src/app/registratie/application/applications.store.spec.ts (new) |
4 specs: load+parse, optimistic cancel, roll-back-and-surface-error on failure, stale-error-clears-on-next-attempt. No spec file existed for this store before RB-20. |
apps/ssp/src/app/registratie/application/admin-cases.store.ts |
Same shape as applications.store.ts: delete through runSubmit, error/lastError signal pair. |
apps/ssp/src/app/registratie/application/admin-cases.store.spec.ts |
Existing "rolls back … when the delete fails" spec extended to also assert lastError(); one new stale-error-clears spec added. |
apps/ssp/src/app/registratie/ui/dashboard.page.ts |
One @if (cancelError(); as err) { <app-alert type="error">{{ err }}</app-alert> } above the aanvragen list, mirroring brief.page.ts's lastError rendering. cancelError is a computed(() => this.apps.lastError()). |
apps/ssp/src/app/registratie/ui/admin-cases.page.ts |
Same @if (store.lastError(); as err) { <app-alert type="error">{{ err }}</app-alert> }, placed above <app-async> inside the canManage() branch (store was already protected, so no new exposure needed). |
applications.adapter.ts (cancel, deleteAny) is unchanged — the fix is entirely in
the two stores, which now wrap the existing thin adapter calls in runSubmit at the call
site, exactly as createSubmitChangeRequest wraps ChangeRequestAdapter.changeRequest. The
adapter methods still return a bare Promise<void>; runSubmit is what folds that into a
Result.
Neither UI change introduces a new user-facing string: the rendered text is either the
existing SUBMIT_FAILED constant (@@submit.failed, already translated in
messages.en.xlf since RB-17) or, when the backend sends one, a ProblemDetails detail
string carried verbatim from the server — never a new $localize id. messages.en.xlf did
not need a new <target>.
The tests, and their red failures
Both specs assert store.lastError() after a rejected adapter call, which only the fix can
satisfy — the old bare catch { this.state.set(before) } never touched an error signal, so
lastError() stayed null forever.
Verified red without the fix (an Edit undo of the store method, not git checkout, so
the rest of the change — imports, the other store, the UI, the specs — stayed in place):
applications.store.ts: revertedcanceltotry { await this.adapter.cancel(id); } catch { this.state.set(before); }. Reranng test ssp --include applications.store.spec.ts: 2 of 4 failed —rolls back the removal and surfaces the error when the cancel failsandclears a stale error on the next cancel attempt, both withAssertionError: expected null to be 'Het indienen is niet gelukt. Probeer het later opnieuw.'. The other two specs (load, optimistic-cancel-success) stayed green, as expected — they don't touch the error path. Re-applied the fix (Editback to therunSubmitversion); reran: 4/4 green.admin-cases.store.ts: same procedure ondelete. Reranng test ssp --include admin-cases.store.spec.ts: 2 of 4 failed with the identicalexpected null to be '...'shape. Reverted to the fix; reran: 4/4 green.
Judgement calls
- Signal naming: private backing field
error, public readonlylastError— matching the nameBriefStore/OrgTemplateStorealready expose for exactly this purpose (CQ-002's own citation), rather than inventing a new name per store. - Error cleared at the start of each write, not only on success, so a second cancel/delete attempt after a failure doesn't leave a stale banner up if the retry itself is still in flight. Covered by the "clears a stale error on the next attempt" spec in each file.
- No
ActionState/SaveStatepair (the fuller shapeBriefStoreuses for busy-state and save-state together) — CQ-002 explicitly scoped option (a) to "oneerrorsignal", and neither store needs a busy indicator: the row already disappears optimistically the instant the click happens, so there is nothing for a spinner to cover. - UI placement: one alert per page, above the list the mutated row belongs to, using the
same
@if (x(); as err) { <app-alert type="error">{{ err }}</app-alert> }shape asbrief.page.ts— composition of an existing atom, no new building block (CLAUDE.md §2). applications.adapter.tsleft untouched, on purpose — CQ-002's "Not filed" note ties the read/write file split to option (b) only; taking option (a) means this ticket changes no adapter code at all, matching the ticket's own framing ("(a) touches 2 files plus a UI line each").
Ticket accuracy
CQ-002's description matched the code as found: both stores' cancel/delete reached the
adapter directly with a bare catch { this.state.set(before); }, no Result, no error
channel — no discrepancy to flag.
Residuals (not this ticket)
- RB-18 (key
IdempotencyStoreon{SubjectId}:{idemKey}) is unaffected:cancel/deletenow mint a key throughrunSubmitlike every other mutation, so it lands on the same write-only call set RB-18 already targets. - RB-21 (extract
createDraftSync's read half) is a separate CQRS-light finding in the same context, untouched by this ticket.
Verification
npm run ci (foreground, timeout: 600000): green — ✔ local CI passed. Lint,
typecheck, dep:check (342 + 226 modules, 0 violations), format:check, check:tokens,
check:seam, tests (ssp 263/263 — 5 more than the pre-RB-20 258, from the new/extended
specs above — behandelportal 37/37, shared 138/138, beheer 23/23), ng build --localize
(both apps), npm audit (0 vulnerabilities), backend dotnet format --verify-no-changes +
dotnet test --filter "Category!=Integration" (260/260 — this filter is what keeps the
known OpenZaakIntegrationTests.Admin_cases_… container-dependent test, which needs a live
OpenZaak container, out of npm run ci entirely; it is a standing caveat, not introduced by
this change, and not exercised by this run), backend dependency audit (0 vulnerable
packages), gen:snippets / gen:behaviour-spec / gen:api drift checks all clean once the
regenerated behaviour-spec.mdx was staged alongside the code (the local gate's
git diff --exit-code compares the working tree to the index, so it is clean once the file
is staged — this is the documented pre-commit behaviour from RB-17's note, not a defect).