Files
atomic-design-poc/docs/project/archive/refactor-backlog-setup/refactor-backlog/implementation/rb-20.md
T
ehoandClaude Opus 5 12f17d9d73 docs: archive the finished backlogs (RD-30)
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>
2026-09-08 23:00:38 +02:00

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: reverted cancel to try { await this.adapter.cancel(id); } catch { this.state.set(before); }. Reran ng test ssp --include applications.store.spec.ts: 2 of 4 failed — rolls back the removal and surfaces the error when the cancel fails and clears a stale error on the next cancel attempt, both with AssertionError: 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 (Edit back to the runSubmit version); reran: 4/4 green.
  • admin-cases.store.ts: same procedure on delete. Reran ng test ssp --include admin-cases.store.spec.ts: 2 of 4 failed with the identical expected null to be '...' shape. Reverted to the fix; reran: 4/4 green.

Judgement calls

  • Signal naming: private backing field error, public readonly lastError — matching the name BriefStore/OrgTemplateStore already 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/SaveState pair (the fuller shape BriefStore uses for busy-state and save-state together) — CQ-002 explicitly scoped option (a) to "one error signal", 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 as brief.page.ts — composition of an existing atom, no new building block (CLAUDE.md §2).
  • applications.adapter.ts left 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 IdempotencyStore on {SubjectId}:{idemKey}) is unaffected: cancel/delete now mint a key through runSubmit like 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).