Merge RB-29 — resolve the body datum placeholder from at, not UtcNow

TE-007: Render already accepts the letter's instant and uses it correctly for
the letterhead, but the body's datum placeholder resolved through ResolveAuto,
which ignored at and read DateTimeOffset.UtcNow. Threaded at through
RenderParagraphs and RenderNode, both already in Render's call chain with at
in scope. Zero public API change, zero call-site change. The bug this
prevents: re-rendering an archive or back-dating a letter would otherwise make
the letterhead and body dates disagree within a single document.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
This commit is contained in:
eho
2026-08-28 08:01:39 +02:00
5 changed files with 198 additions and 9 deletions
@@ -130,7 +130,7 @@ Every ticket tracing to a `BIO-` finding, plus every row on agent 07's authorita
| **RB-26** | libs/shared/upload | testability | Move the accept/reject decision to `planFileSelection` in `upload.machine.ts` | §3a upload 52.0%/50.0%; §4a module max CC 27 | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open |
| **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-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 | — | — | **done** |
| **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-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 |
@@ -0,0 +1,125 @@
# RB-29 — Thread `at` through `LetterHtml.ResolveAuto`'s `datum` case
Status: **implemented** · 2026-08-27 · Source findings: `02-testability.md` TE-007 ·
`99-backlog.md` RB-29
## What was wrong
`LetterHtml.Render(BriefEntity brief, OrgTemplateDto template, string at, bool watermark)`
(`backend/src/BigRegister.Api/Domain/Letters/LetterHtml.cs`) already took the letter's
instant and used it correctly for the letterhead date
(`sb.Append(Enc(FormatDatumNl(at)))`). The body's `datum` placeholder resolved through
the private `ResolveAuto(string key, string label)`, which ignored `at` and called
`FormatDatumNl(DateTimeOffset.UtcNow.ToString("o"))` instead — a pure `Domain/` rule
class reading the wall clock. `ResolveAuto` is reached only through the private chain
`RenderNode``RenderParagraphs``Render`, so no caller outside this file could pin
the value a test would see.
The ticket read as filed against the current code: `Render`'s signature, the letterhead's
correct use of `at`, and `ResolveAuto`'s `UtcNow` read were all exactly as TE-007
described (line numbers had moved — CC around the file has grown since the finding was
written — but the code shape had not). One thing TE-007 named as the visible symptom
also checked out: `LetterHtmlTests.cs` already declares a
`new PlaceholderDefDto("datum", "Datum", true)` in its golden-file fixture, but no
`RichTextNodeDto` in that fixture's `Sections` actually references the `datum` key in
the body — it is declared but never rendered there, so the existing golden-file test
could not have caught this even if it asserted on dates (which it does not either).
## What changed
| File | Change |
| -------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `backend/src/BigRegister.Api/Domain/Letters/LetterHtml.cs` | `ResolveAuto(string key, string label)``ResolveAuto(string key, string label, string at)`; `"datum" => FormatDatumNl(at)`. `at` threaded down through the two private call sites in the chain: `RenderParagraphs` and `RenderNode` both gained an `at` parameter, passed from `Render`'s own `at`. |
| `backend/tests/BigRegister.Tests/LetterHtmlTests.cs` | New fixture `FixtureBriefWithDatumInBody()` — a minimal brief whose body actually references the `datum` placeholder (the golden fixture never does). Two new `[Fact]`s (see below) plus two small extraction helpers. |
| `docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md` | RB-29 status cell: `open``implemented`. |
`Render`'s own signature is unchanged — TE-007's "zero public API change, zero
call-site change" held exactly. `Program.cs:697`, `:708`, and `BriefStore.cs:120` (the
three callers) needed no edit.
## What the fix looks like
```csharp
private static void RenderParagraphs(
StringBuilder sb, IReadOnlyList<ParagraphDto> paragraphs, IReadOnlyDictionary<string, PlaceholderDefDto> defs,
string at)
{
// ... unchanged body, forwards `at` to RenderNode ...
}
private static void RenderNode(
StringBuilder sb, RichTextNodeDto node, IReadOnlyDictionary<string, PlaceholderDefDto> defs, string at)
{
// ... unchanged body, forwards `at` to ResolveAuto ...
}
private static string ResolveAuto(string key, string label, string at) => key switch
{
"naam_zorgverlener" => SeedData.Registration.Naam,
"big_nummer" => SeedData.Registration.BigNummer,
"datum" => FormatDatumNl(at),
_ => label,
};
```
Both call sites already had `at` in scope (`Render`'s own parameter), so this is a pure
threading change — no new state, no new dependency.
## Tests added
TE-007 named the exact gap: the golden-file fixture declares the `datum` placeholder but
never renders it in the body, so no existing assertion could catch a body/letterhead
mismatch. A new fixture and two focused tests close it:
1. **`Render_resolves_the_body_datum_placeholder_from_the_given_at_not_the_wall_clock`** —
renders `FixtureBriefWithDatumInBody()` with a fixed historical `at`
(`2019-03-14T08:00:00.0000000+00:00`) and asserts the body's rendered paragraph is the
exact string `"14 maart 2019"`. A test using today's date would have passed before
and after the fix and proven nothing — this one pins a date nowhere near "now", so it
fails whenever the resolver reads the wall clock instead of `at`.
2. **`Render_keeps_the_letterhead_date_and_the_body_datum_in_agreement_for_a_historical_at`**
— same fixture and historical `at`, asserts the letterhead `<dd>` date and the body's
rendered `datum` paragraph are equal. This is TE-007's stated payoff: not a shipped
bug today (every current caller passes `Now()` at render time, so the two dates always
coincided even with the bug present), but a latent one — the moment `Render` is ever
called with a historical `at` (re-rendering an archive, back-dating a letter), the
letterhead and body would disagree within a single document. This test is the one
that would have caught that.
Both tests use the repo's one date formatter (`FormatDatumNl`, already used by both call
sites under test) only indirectly, through the literal expected string `"14 maart
2019"` — no second hand-rolled `ToString` format was introduced in the test file either.
## Verification
- **Verified red without the fix.** Reverted only the `ResolveAuto` expression (via
`Edit`, not `git checkout`) back to
`"datum" => FormatDatumNl(DateTimeOffset.UtcNow.ToString("o"))`, leaving the new
tests and the threaded signatures in place. Ran the two new tests:
```
Render_resolves_the_body_datum_placeholder_from_the_given_at_not_the_wall_clock [FAIL]
Assert.Equal() Failure: Strings differ
Expected: "14 maart 2019"
Actual: "27 augustus 2026"
Render_keeps_the_letterhead_date_and_the_body_datum_in_agreement_for_a_historical_at [FAIL]
Assert.Equal() Failure: Strings differ
Expected: "14 maart 2019"
Actual: "27 augustus 2026"
```
Both failures show the body rendering the run's actual wall-clock date (today,
2026-08-27) instead of the pinned historical `at` — the precise defect TE-007
describes. Restored the fix with a second `Edit` and re-ran: all 4 tests in
`LetterHtmlTests` green (2 pre-existing + 2 new).
- `grep -n "UtcNow\|DateTime.Now\|DateTime.Today\|DateTimeOffset.Now"
backend/src/BigRegister.Api/Domain/Letters/LetterHtml.cs` — no matches. No ambient
clock read remains anywhere in the file.
- `npm run ci` (foreground, no background/Monitor): see result reported alongside this
ticket.
## What this ticket did not touch
`LetterHtml.cs`'s overall structure and CC (TE-007 records it at 21, the third-highest
in the backend) are unchanged — reducing that is out of scope for this ticket, per its
own text. `Data/BriefStore.cs` and any `BriefRules.cs` file were not touched — a
concurrent ticket owns that file.