refactor(backend): extract brief guards into Domain/Letters/BriefRules.cs (RB-30)

BriefStore's five guard decisions (Save, Submit, Send, and the shared
Approve/Reject review path) were pure functions of status tag, actor role,
and entity completeness, but each sat inside a lock-held, DB-opening
method. A spec could not exercise the decision without a booted host and
a real SQLite file.

Extract the guards into a pure Domain/Letters/BriefRules.cs. BriefStore
keeps its lock, its Db.Create(), its static shape, and every method
signature — only the if cascades move. Add BriefRuleTests.cs (29
assertions, ~120 ms, no host boot) covering every branch, including the
rejected-to-draft reopen on save, the required-filled gate on submit,
and the non-drafter and self-review denials. The existing host-booting
brief endpoint tests are unchanged and still pass, proving the
extraction preserved behaviour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-08-27 20:42:00 +02:00
co-authored by Claude Opus 5
parent 424ceb604b
commit 07bb6277c0
6 changed files with 456 additions and 13 deletions
@@ -131,7 +131,7 @@ Every ticket tracing to a `BIO-` finding, plus every row on agent 07's authorita
| **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | SM | Low | P2 | 5 | RB-25 | **SIGN-OFF** | open |
| **RB-28** | libs/beheer + ssp/brief | testability | `BLOB_PRESENTER` token; the 3 commands' success paths become assertable | §3a beheer/application **40.5% branch — worst FE**; brief.store BRH 32/64 | SM | Low | P2 | 5 | — | **SIGN-OFF** | open |
| **RB-29** | backend/Domain | testability | Thread the existing `at` through `LetterHtml.ResolveAuto` instead of reading `UtcNow` | §3c Domain 82.0% branch; §4b `LetterHtml.cs` CC 21 | S | Low | P2 | 5 | — | — | open |
| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | open |
| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | **done** |
| **RB-31** | 4 app contexts (specs only) | ADR conform. | Replace hand-rolled state literals with `given(reduce, initial)` replays in 4 machine specs | §7 Elm machines 9 (1 has a `*.testing.ts`); §3a herreg 67.8% / brief 68.8% branch | M | Low | P2 | 6 | — | — | open |
| **RB-32** | libs/shared/docs | ADR conform. | Add the missing `language-switcher` row to the CIBG gap register (9 markers vs 8 rows) | §2 libs/shared 86 files / 5 194 lines; §6 layout Ca 22 | S | Low | P3 | 6 | — | — | open |
| **RB-33** | libs/shared/testing | ADR conform. | Adopt `unwrapOk` at its one call site — **or delete it**; both satisfy ADR-0006 §3 | BL-004; §3a libs/shared/testing 3 files, 100% line | S | Low | P3 | 6 | — | — | open |
@@ -0,0 +1,210 @@
# RB-30 — extract `BriefStore`'s guards into `Domain/Letters/BriefRules.cs`
Status: **implemented** · 2026-08-27 · Source finding: `02-testability.md` TE-008 ·
`99-backlog.md` RB-30
RB-30 moves the brief workflow's five guard decisions out of `BriefStore` (a
lock-held, DB-opening static store) into a pure `Domain/Letters/BriefRules.cs`, and
adds a free-running unit test file for them. This is a pure extraction: the store
keeps its lock, its `Db.Create()`, its static shape, and every method's signature.
## What was wrong
Five guard clusters in `Data/BriefStore.cs` are pure decisions over `(status tag,
actor role, entity completeness)` — Save, Submit, Send, and the shared Approve/Reject
review path each start with an `if` cascade that is a function of two enums and a
bool. But every one of those `if`s sat inside a method that had already done `lock
(_gate) { using var db = Db.Create(); ... }`, so a spec could not exercise the
decision without a booted host and a real SQLite file. `Domain/Letters/` held only
`LetterHtml.cs` and `OrgTemplateRules.cs`; there was no `BriefRules` class, even
though `Authz.CanActOn` — a pure `Domain/Authorization/` call one line away from
three of the guards — already proved the pattern worked for this exact file.
## What changed
| File | Change |
| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `backend/src/BigRegister.Api/Domain/Letters/BriefRules.cs` | New. Five pure statics: `CanSave`, `StatusAfterSave`, `RequiredFilled` + `CanSubmit`, `CanSend`, `CanDecide`. All take `BriefStatusDto`/`bool`/`Principal`/`string`, never `BriefEntity` — no persistence type reaches this file. |
| `backend/src/BigRegister.Api/Data/BriefStore.cs` | `Save`, `Submit`, `Send`, and the private `Review` (the Approve/Reject shared path) each replace their inline `if` cascade with one call into `BriefRules`, then branch only on the returned `Outcome`. The private `RequiredFilled(BriefEntity e)` helper is deleted — `BriefRules.RequiredFilled(IReadOnlyList<LetterSectionDto>)` replaces it. Lock, `Db.Create()`, method signatures, and the public `Outcome` enum are all unchanged. |
| `backend/tests/BigRegister.Tests/Domain/BriefRuleTests.cs` | New. 29 `[Fact]`/`[Theory]` assertions covering every branch of all five rules — see "Tests added" below. |
| `docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md` | RB-30's status cell: `open``done`. |
## The surface, as built — and where it differs from TE-008's proposal
TE-008 proposed:
```
CanSave(BriefStatusDto status, bool isDrafter) → Outcome
StatusAfterSave(BriefStatusDto) → BriefStatusDto
CanSubmit(status, isDrafter, bool requiredFilled) → Outcome
CanSend(status)
CanDecide(status, Principal, drafterId)
```
The ticket explicitly names this a proposal, not a specification. What was built
matches it almost exactly, with two adjustments forced by the real code:
- **`Outcome` is `BriefStore.Outcome`, not a new type.** `BriefStore` already
exposes a public `enum Outcome { Ok, Forbidden, Conflict }`, and `Program.cs`'s
`BriefResult` switches on it directly across every brief endpoint. TE-008 itself
says: "if `Outcome` does not already exist as a domain concept, use whatever the
sibling rule classes already return" — it does exist, so `BriefRules` returns it
rather than inventing a second result shape. This does mean `Domain/Letters/`
references a type nested in `Api.Data`; the same cross-reference already exists in
this file's neighbor, `LetterHtml.cs` (`using BigRegister.Api.Data;`, for
`BriefEntity`), and in `Authz.cs` (for `BriefStore`'s role-id constants) — both in
the same single-assembly project, so this is a namespace convention, not an
assembly boundary. `Outcome` itself is a plain three-value enum with no EF/ASP.NET
attached, so this does not pull a persistence type into `Domain/`.
- **`CanDecide` takes an explicit `BriefAction action` parameter**, not just
`(status, Principal, drafterId)`. The real guard — `BriefStore.Review` — is one
private method shared by both `Approve` and `Reject`, and it calls
`Authz.CanActOn(action, principal, drafterId)`, which needs to know which action is
being attempted. `BriefRules.CanDecide` composes that existing pure
`Authz.CanActOn` call with the status check, rather than re-implementing the SoD
logic a second time — so the four-eyes rule still has exactly one source of truth.
The `RequiredFilled` predicate is a sixth pure static, not one of the five guards
proper — TE-008 names it separately ("plus the `RequiredFilled(e)` predicate") and it
is built the same way: `RequiredFilled(IReadOnlyList<LetterSectionDto> sections) →
bool`, taking the section list rather than the entity.
## Order and behaviour preserved
Every rule keeps the original check order, which matters because `Outcome.Forbidden`
must outrank `Outcome.Conflict` (a non-drafter or non-entitled caller sees Forbidden
even against an otherwise-invalid status):
- `CanSave`: `!isDrafter` (Forbidden) before the status-tag check (Conflict).
- `CanSubmit`: `!isDrafter` (Forbidden) before `status.Tag != "draft" ||
!requiredFilled` (Conflict).
- `CanDecide`: `!Authz.CanActOn(...)` (Forbidden) before `status.Tag != "submitted"`
(Conflict) — the exact order the old inline check in `Review` used, per its own
comment ("checked BEFORE the status guard").
- `CanSave`'s entity-not-found branch (`e is null → Conflict`) stays inline in
`BriefStore` — it is a persistence fact ("no row for this owner"), not one of the
three business axes TE-008 names (status tag, actor role, entity completeness), so
it was left where it was rather than forced into a rule that would then need to
accept a nullable entity.
## Tests added
`backend/tests/BigRegister.Tests/Domain/BriefRuleTests.cs`, 29 assertions, alongside
the seven Domain test files that already existed:
- **`CanSave`** — drafter saves draft/rejected (Ok, `[Theory]`); drafter saves
submitted/approved/sent (Conflict, `[Theory]`); non-drafter saves draft or submitted
(Forbidden both times — proves role beats status).
- **`StatusAfterSave`** — rejected → draft; draft stays draft.
- **`RequiredFilled`** — no sections; an unfilled optional section; a filled required
section; an unfilled required section; one filled + one unfilled required section
(proves one bad section blocks the whole letter).
- **`CanSubmit`** — filled draft (Ok); unfilled draft (Conflict — **the required-filled
gate the ticket explicitly asked for**); non-draft status (Conflict); non-drafter
on a filled draft (Forbidden — role beats completeness).
- **`CanSend`** — approved (Ok); draft/submitted/rejected/sent (Conflict, `[Theory]`).
- **`CanDecide`** — approver decides a submitted letter drafted by someone else, for
both Approve and Reject (Ok, `[Theory]`); a drafter attempting to decide (Forbidden
— **the non-drafter denial the ticket asked for**); an approver whose acting id
equals the drafter id, i.e. self-review (Forbidden — the four-eyes/SoD case); an
approver deciding a non-submitted letter (Conflict); an approver who is also the
drafter AND the status is non-submitted (Forbidden, not Conflict — proves the
priority order survived the extraction).
## Verified red without the fix
Inverted `CanSubmit`'s completeness check (`!requiredFilled` → `requiredFilled`) with
an `Edit`, ran `BriefRuleTests` alone:
```
[xUnit.net] BigRegister.Tests.Domain.BriefRuleTests.A_drafter_may_not_submit_an_unfilled_draft [FAIL]
Assert.Equal() Failure: Values differ
Expected: Conflict
Actual: Ok
[xUnit.net] BigRegister.Tests.Domain.BriefRuleTests.A_drafter_may_submit_a_filled_draft [FAIL]
Assert.Equal() Failure: Values differ
Expected: Ok
Actual: Conflict
Failed! - Failed: 2, Passed: 27, Skipped: 0, Total: 29
```
Reverted with a second `Edit` (never `git checkout` — that would have discarded the
whole file). Reran: 29/29 green.
## Existing tests — unchanged
`BriefEndpointTests.cs`, `PreviewEndpointTests.cs`, and `OrgTemplateEndpointTests.cs`
(the three host-booting suites that exercise the brief endpoints) needed **no
changes**. Ran together: 32/32 passing, proving the extraction preserved every HTTP
outcome (`Save_is_drafter_only`, `Submit_blocks_on_empty_required_section`,
`Submit_succeeds_when_required_sections_filled`,
`Drafter_cannot_approve_own_letter_but_a_different_reviewer_can`,
`Reject_returns_comments`, `Editing_a_rejected_letter_reopens_it_to_draft`,
`Send_only_from_approved`, and the rest, all unmodified).
## The metric TE-008 cares about: host-booting brief-rule assertions
Before this ticket, the five guard decisions had **zero** free-running unit
assertions. Every branch of every guard was reachable only through the seven
host-booting endpoint test methods above (six of them containing an explicit
`Assert.Equal(HttpStatusCode.Forbidden/Conflict, ...)`, each paying a full
`TestWebApplicationFactory` host boot plus a real SQLite round-trip, run serially
process-wide because of `[assembly: DisableTestParallelization]`).
After this ticket:
- **0 → 29** free-running unit assertions covering these branches
(`BriefRuleTests.cs`, `dotnet test --filter FullyQualifiedName~BriefRuleTests`
completes in **~120 ms**, no host, no SQLite file).
- **7 → 7** host-booting endpoint tests, unchanged. They stay — they are now the
proof that `BriefStore` wires `BriefRules`'s answer to the right HTTP status, not
the only place the business decision itself is checked. That split (wiring proven
at the integration layer, decision logic proven at the unit layer) is the seam
TE-008 argued for.
- New branches this ticket made assertable that the endpoint suite never covered
directly: the SoD self-review case (`An_approver_may_not_decide_a_letter_they_drafted_themselves`)
and the Forbidden-beats-Conflict priority ordering for both `CanSave`/`CanSubmit`
(role checked first) and `CanDecide` (entitlement checked first) — these existed as
implicit behaviour in the original `if` cascades but had no assertion pinning them
before RB-30.
## What was not extracted
Nothing — all five guards named in TE-008, plus the `RequiredFilled` predicate, moved
cleanly. None needed the `DbContext`: each was already a function of values already
resident on the in-memory `BriefEntity` (its `Status`, `Sections`, `DrafterId`), never
of a query against the database itself.
## Scope respected
- `Domain/Letters/LetterHtml.cs` was not touched (a concurrent agent owns it).
- `BriefEntity.ToDto()` was not touched — its CC 16 is a separate, out-of-scope
finding per the ticket.
- `Data/Db.cs`'s static-store decision and `TestWebApplicationFactory`'s serialized-test
position were not challenged; the store's lock, `Db.Create()`, and public shape are
byte-for-byte the same as before this ticket, other than the `if` cascades moving
out.
## Verification
- `dotnet build`: 0 warnings, 0 errors.
- `dotnet test --filter FullyQualifiedName~BriefRuleTests`: 29/29, ~120 ms.
- `dotnet test --filter FullyQualifiedName~BriefEndpointTests|...PreviewEndpointTests|...OrgTemplateEndpointTests`:
32/32, unchanged.
- Full backend suite: **291/292 passing**, plus the one known, pre-existing,
container-dependent failure
(`OpenZaakIntegrationTests.Admin_cases_returns_the_seeded_zaak_mapped_through_real_HTTP_and_JWT`,
"Connection refused (localhost:8000)") — not this ticket's bug, does not run under
`npm run ci`, reproduces on a clean tree with no OpenZaak container running.
- `npm run ci` (foreground, no background/Monitor): see the commit message / session
report for the exit code and step count.
## What this ticket did not touch
No frontend file was touched — the brief workflow's status machine is server-
authoritative, and the FE's own pure reducer (mirroring these same transitions for
UX) was already out of this ticket's scope. No file outside `backend/Data/BriefStore.cs`,
`backend/Domain/Letters/BriefRules.cs`,
`backend/tests/BigRegister.Tests/Domain/BriefRuleTests.cs`, and `99-backlog.md` was
changed.