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>
8.0 KiB
RB-17 — split runResult (fold) from runSubmit (fold + idempotency mint)
Status: implemented · 2026-08-27 · Source findings: 04-cqrs-light.md CQ-003 + CQ-005 ·
00-baseline.md BL-007 (+ its §10 amendment) · 99-backlog.md RB-17
What was wrong
runSubmit (libs/shared/src/application/submit.ts) did two things in one function: fold a
call into a Result, and mint an Idempotency-Key for it
(withIdempotencyKey(crypto.randomUUID(), fn)). Its own docstring called that mint "the one
place a logical submit's Idempotency-Key is minted". Five call sites are reads and had no
business minting one:
| Adapter | Method | Wire call |
|---|---|---|
brief.adapter.ts:56 |
load() |
briefGET() |
org-template.adapter.ts:42 |
list() |
orgTemplates() |
org-template.adapter.ts:54 |
load(subOrgId) |
orgTemplateGET(...) |
stamdata.adapter.ts:27 |
list() |
stamdataTables() |
stamdata.adapter.ts:42 |
load(tableId, ...) |
stamdataTable(...) |
That is exactly five — verified by grepping every runSubmit call site in
libs/shared/src/application plus the brief and beheer scopes (13 call sites total) and
reading each one's wire call for a request body / non-GET verb. The other 8 are genuine
writes (brief.adapter.ts save/submit/approve/reject/send/reset,
org-template.adapter.ts save/publish/rollback) and stay on runSubmit unchanged.
stamdata.adapter.ts's own module docstring already said "Both endpoints are reads … There
is no write method" while both called runSubmit — the sharpest instance of the mismatch,
and the one BL-007's original "~13 mutations" count mis-classified because the count was
derived from the helper's name, not from what the call actually does.
Not this ticket, seen while auditing: ApplicationsStore.cancel, AdminCasesStore.delete
(RB-20) and FeatureFlagStore.set reach ApiClient more directly; the baseline's §10
amendment flags these as writes the original "~13" count missed. Grepping confirms
FeatureFlagStore.set (libs/shared/src/application/feature-flags.store.ts:63) already
calls runSubmit correctly and returns a Result — it is not broken, just outside this
ticket's five. ApplicationsStore.cancel/AdminCasesStore.delete were not touched; they are
RB-20's.
What changed
| File | Change |
|---|---|
libs/shared/src/application/submit.ts |
Split: runResult = the try/catch + problemDetail fold, no mint. runSubmit = runResult wrapping withIdempotencyKey. |
libs/shared/src/application/submit.spec.ts |
Specs for both, including one that would catch a read minting a key again (see below). |
apps/ssp/src/app/brief/infrastructure/brief.adapter.ts |
load() → runResult; docstring updated to name both halves. |
apps/ssp/src/app/brief/infrastructure/org-template.adapter.ts |
list(), load() → runResult. |
libs/beheer/src/infrastructure/stamdata.adapter.ts |
list(), load() → runResult; import trimmed to runResult only (no writes in this file). |
libs/shared/docs/behaviour-spec.mdx |
Regenerated (npm run gen:behaviour-spec) — picks up the new runResult describe block and the extra runSubmit case. |
runSubmit's new body is exactly the minimal composition the ticket asked for:
export function runSubmit<T>(fn: () => Promise<T>, fallback: string): Promise<Result<string, T>> {
return runResult(() => withIdempotencyKey(crypto.randomUUID(), fn), fallback);
}
Zero behaviour change for the 8 write call sites — same fold, same mint, same timing (the key
is still minted before fn runs and cleared in withIdempotencyKey's .finally). The five
reads now run the fold with no pendingIdempotencyKey touched at all.
The spec that would catch a regression
currentIdempotencyKey() (api-client.provider.ts) returns the pending key while one is
"in flight" for the duration of a withIdempotencyKey call, and a fresh crypto.randomUUID()
on every call otherwise. That gives a real, mock-free way to assert "no key was minted": call
currentIdempotencyKey() twice inside the function passed to runResult/runSubmit — two
different reads means no pending key existed (each fell back to its own random UUID); two
equal reads means one pending key was minted and reused.
it('mints no Idempotency-Key — the read fold', async () => {
let first = '',
second = '';
await runResult(async () => {
first = currentIdempotencyKey();
second = currentIdempotencyKey();
return 'x';
}, 'fallback');
expect(first).not.toBe(second);
});
This mirrors the house convention of not mocking relative imports under this repo's
Angular/vitest setup (see role.interceptor.spec.ts's comment) — it asserts on real,
exported behaviour instead of a spy.
Verified red without the fix: temporarily changed runResult to also call
withIdempotencyKey (i.e. reintroduced the bug it exists to prevent) and reran ng test shared. Result: runResult > mints no Idempotency-Key — the read fold failed
(expected 'd185d827-...' not to be 'd185d827-...'), all 137 other tests stayed green. Then
reverted the temporary edit back to the real fix (an Edit undo, not git checkout, so the
rest of the change stayed in place) and reran — 138/138 green.
Judgement calls
- Docstring on
brief.adapter.tswas rewritten (it previously said only "Mutations go throughrunSubmit") to nameload'srunResultpath explicitly, since the file mixes both now and a future reader needs the split spelled out at the top, not just per-method.org-template.adapter.tsandstamdata.adapter.ts's docstrings needed no change — neither namedrunSubmitspecifically (stamdata.adapter.ts's already correctly said "no write method"). - No new concept, per the ticket's "minimal" framing —
runSubmitstays exported with the same signature and the same call sites for the 8 real mutations; only its body changed to delegate. - Left
runResult's JSDoc pointing atrunSubmit("never route a read through that one") rather than duplicating the Idempotency-Key explanation, so the two docs stay synchronized by cross-reference instead of by copy.
Residuals (not this ticket)
- RB-18 (key the
IdempotencyStoreon{SubjectId}:{idemKey}) is sequenced behind this one per99-backlog.mdand is unaffected by this split beyond it now landing on a correctly write-only call set. - RB-20 (
ApplicationsStore.cancel/AdminCasesStore.deletethroughrunSubmit) is untouched, as scoped.
Verification
npm run ci (foreground): green — lint, typecheck, dep:check (341 + 226 modules, 0
violations), format:check, check:tokens, check:seam, tests (ssp 258/258, behandelportal
31/31, shared 138/138, beheer 23/23 — 450 total), ng build --localize (both apps), npm audit (0 vulnerabilities), backend dotnet test (255/255 — the known
OpenZaakIntegrationTests.Admin_cases_… container-dependent failure did not reproduce on
this run), gen:snippets drift clean, gen:behaviour-spec drift clean once the regenerated
file is committed alongside the code (the local gate compares the working tree to HEAD, so
it necessarily shows a diff pre-commit — this is the documented "will conflict at merge time"
behaviour, not a defect).