Files
atomic-design-poc/docs/project/archive/refactor-backlog-setup/refactor-backlog/implementation/rb-18.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

6.5 KiB

RB-18 — key IdempotencyStore on {SubjectId}:{idemKey}

Status: implemented · 2026-08-27 · Source findings: 07-bio2-compliance.md BIO-018 · 00-baseline.md §7 (IdempotencyStore listed among the 7 stores "Not behind any port"), agent 02's backend/Data note ("no Reset() and no TTL") · 99-backlog.md RB-18

What was wrong

Data/IdempotencyStore.cs is a process-global Dictionary<string, IResult> keyed only on the raw Idempotency-Key header value. Program.cs's Submit helper read and wrote it with that raw value, never composed with the caller's identity:

var idemKey = ctx.Request.Headers.TryGetValue("Idempotency-Key", out var k) && !string.IsNullOrEmpty(k)
    ? k.ToString()
    : null;

The client picks the header value. Two different callers who happen to send the same value shared one cache slot: the second caller's request short-circuited to the first caller's cached IResult instead of running its own submission. BIO-018 rates this severity low — the cached value is only a ReferentieResponse (a reference number) or a ProblemDetails, never personal data — but flags it as a defect in an access-control path with a trivial fix.

Location check against the finding. BIO-018 cites Program.cs:901-909 for the read/ write and Data/IdempotencyStore.cs:11-27 for the store. RB-17 (landed the day before, same file, unrelated change) shifted line numbers; the real call sites are Program.cs:994 (read) and :1028 (write), inside the local Submit helper starting at :991. The store file itself is untouched by RB-17 and matches the finding's shape exactly. Submit has exactly one call site (POST /change-requests, :239) — the ChangeRequestRequesttelefoonwijziging endpoint — so the scoping change lands on a single endpoint, not the "smaller call set" RB-17 was sequenced ahead of this ticket to produce; RB-17 removed idempotency-key minting from 5 read call sites, none of which used this helper in the first place, so its ordering benefit does not change what this ticket touches. Reported for completeness, not as a discrepancy: RB-17's own note already scoped its residual to "this ticket is unaffected by this split beyond it now landing on a correctly write-only call set" — true, and the call set was already this one endpoint before and after RB-17.

What changed

File Change
src/BigRegister.Api/Program.cs Submit's idemKey is now $"{ctx.Caller().SubjectId}:{k}" instead of the raw header value k.ToString(); doc comment above Submit states the scoping and cites RB-18/BIO-018
tests/BigRegister.Tests/IdempotencyTests.cs new A_caller_replaying_another_callers_idempotency_key_does_not_get_their_cached_result

ctx.Caller() (Domain/Authorization/CallerIdentity.cs) is already in scope in Program.csctx.Zorgverlener() is used elsewhere in the same file — and it throws if the identity middleware did not run, so this composition cannot silently fall back to an unscoped key. SubjectId is the BSN for a ZorgverlenerCaller and the medewerkerId for a MedewerkerCaller; either way it is stable per caller and never empty.

This is exactly the ticket's minimal remediation, no more: no TTL, no eviction, no bound, no Reset(), no port/interface extraction. IdempotencyStore's own ponytail: comment ("no TTL/eviction … an unbounded dictionary keyed on client-supplied strings is a memory leak at scale") is untouched — the store is still unbounded and still keyed on a client-supplied string, only now composed with a server-resolved one first. The comment stays accurate; this ticket did not touch the part it would need to correct.

The test

IdempotencyTests.cs already existed (RB-17's predecessor work, not this ticket) with three cases exercising same-caller replay/independence. Added a fourth:

[Fact]
public async Task A_caller_replaying_another_callers_idempotency_key_does_not_get_their_cached_result()
{
  var sharedKey = Guid.NewGuid().ToString();

  var callerARequest = ChangeRequestWithKey(sharedKey);
  callerARequest.Headers.Add("X-Subject", "111222333");
  var callerA = await _client.SendAsync(callerARequest);
  callerA.EnsureSuccessStatusCode();
  var callerABody = await callerA.Content.ReadFromJsonAsync<ReferentieResponse>();

  var callerBRequest = ChangeRequestWithKey(sharedKey);
  callerBRequest.Headers.Add("X-Subject", "999888777");
  var callerB = await _client.SendAsync(callerBRequest);
  callerB.EnsureSuccessStatusCode();
  var callerBBody = await callerB.Content.ReadFromJsonAsync<ReferentieResponse>();

  Assert.NotEqual(callerABody!.Referentie, callerBBody!.Referentie);
}

X-Subject is StubIdentityProvider's existing header for setting the caller's BSN in a test (the same idiom ApplicationTests.cs and UploadAccessTests.cs use), so caller A and caller B are two different ZorgverlenerCallers sending the identical Idempotency-Key.

Verified red without the fix. Reverted Program.cs's idemKey line to k.ToString() with an Edit (not git checkout, so the rest of the working tree stayed intact), reran dotnet test --filter "FullyQualifiedName~IdempotencyTests":

Failed BigRegister.Tests.IdempotencyTests.A_caller_replaying_another_callers_idempotency_key_does_not_get_their_cached_result [4 ms]
  Error Message:
   Assert.NotEqual() Failure: Strings are equal
Expected: Not "BIG-2026-476969"
Actual:       "BIG-2026-476969"
Failed!  - Failed:     1, Passed:     3, Skipped:     0, Total:     4

Caller B received caller A's cached reference. Then reapplied the fix with a second Edit and reran: Passed! - Failed: 0, Passed: 4, Skipped: 0, Total: 4.

Verification

dotnet test (full suite): 261 passed, 1 failed — the failure is OpenZaakIntegrationTests.Admin_cases_returns_the_seeded_zaak_mapped_through_real_HTTP_and_JWT, which needs a live OpenZaak container and fails identically on a stashed tree; it predates this change and is not run by npm run ci.

npm run ci (foreground): green — see the commit's own record for the full step list.