feat(documents): honest handling of undated documents in browse & search (Phase 6, #668) #682

Merged
marcel merged 19 commits from feature/668-undated-documents into docs/import-migration 2026-05-27 21:26:50 +02:00
Owner

Summary

Phase 6 (final) of the "Handling the Unknowns" milestone — undated and imprecise documents are surfaced honestly in browse/search, never silently buried or fabricated. Builds on #677's formatDocumentDate. No migration.

  • NULLS-LAST on every sort path, both directions: DATE/RELEVANCE are SQL — resolveSort now emits Order(dir, "documentDate").nullsLast() + a createdAt tiebreaker (closes the ASC bug where undated sorted to the top). SENDER/RECEIVER/filtered-RELEVANCE sort in-memory by person/rank (not date); a locking test proves undated stays within its sender/receiver group rather than jumping out.
  • Undated badge on rows: the bare em-dash is replaced by a neutral "Datum unbekannt" chip (via the shared DocumentDate component, both breakpoints). The year-grouping docs_group_undated bucket is kept; no synthetic sub-group in person-sort modes.
  • "Nur undatierte" filter: an undatedOnly Specification shared by /search and /ids; Boolean undated query param (read GET unguarded, authz-tested); 44px aria-pressed toggle wired to the URL.
  • Range collision rule: a from/to range excludes undated docs; the empty-state copy (docs_range_excludes_undated) explains it.
  • Honest precision rendering: dates flow through formatDocumentDate ("Juni 1916" / "ca. 1916" / open-ended "ab …" / "Datum unbekannt") — base already had the #671 fields + #677 formatter, so this is real, not stubbed.
  • Chronik: negative-assertion test only — ChronikRow renders the activity timestamp, not the letter date; no fabricated date chip introduced. Briefwechsel untouched (dead feature).

Test plan

  • Backend touched classes 318 pass (Testcontainers Postgres: ASC nulls-last, undatedOnly, BETWEEN-excludes-null, undated+range→empty, per-sort-mode ordering). Frontend node vitest 643 pass; lint clean. (Full suite not run locally — CI owns the sweep.)
  • CI green. Browser/component specs (row badge, filter toggle) are CI-only.

Notes / deviations

  • generate:api hand-editedundated added to /search + /ids; CI must re-run npm run generate:api to confirm parity.
  • Open-decision defaults applied (owner unreachable): upload-date-asc secondary tiebreaker; page-local undated count.
  • Badge reuses the existing date_precision_unknown text (same wording) rather than a new key.
  • The pre-existing flaky PersonMentionEditor.svelte.spec.ts and pre-existing provisional-missing type errors on old Person fixtures / autofixer warnings are out of scope and not introduced here.

Closes #668

## Summary Phase 6 (final) of the "Handling the Unknowns" milestone — undated and imprecise documents are surfaced honestly in browse/search, never silently buried or fabricated. Builds on #677's `formatDocumentDate`. No migration. - **NULLS-LAST on every sort path, both directions:** DATE/RELEVANCE are SQL — `resolveSort` now emits `Order(dir, "documentDate").nullsLast()` + a `createdAt` tiebreaker (closes the ASC bug where undated sorted to the *top*). SENDER/RECEIVER/filtered-RELEVANCE sort in-memory by person/rank (not date); a locking test proves undated stays within its sender/receiver group rather than jumping out. - **Undated badge on rows:** the bare em-dash is replaced by a neutral "Datum unbekannt" chip (via the shared `DocumentDate` component, both breakpoints). The year-grouping `docs_group_undated` bucket is kept; no synthetic sub-group in person-sort modes. - **"Nur undatierte" filter:** an `undatedOnly` Specification shared by `/search` and `/ids`; `Boolean undated` query param (read GET unguarded, authz-tested); 44px `aria-pressed` toggle wired to the URL. - **Range collision rule:** a `from`/`to` range excludes undated docs; the empty-state copy (`docs_range_excludes_undated`) explains it. - **Honest precision rendering:** dates flow through `formatDocumentDate` ("Juni 1916" / "ca. 1916" / open-ended "ab …" / "Datum unbekannt") — base already had the #671 fields + #677 formatter, so this is real, not stubbed. - **Chronik:** negative-assertion test only — `ChronikRow` renders the activity timestamp, not the letter date; no fabricated date chip introduced. **Briefwechsel untouched** (dead feature). ## Test plan - [x] Backend touched classes **318 pass** (Testcontainers Postgres: ASC nulls-last, `undatedOnly`, BETWEEN-excludes-null, undated+range→empty, per-sort-mode ordering). Frontend node vitest **643 pass**; `lint` clean. (Full suite not run locally — CI owns the sweep.) - [ ] CI green. Browser/component specs (row badge, filter toggle) are CI-only. ## Notes / deviations - **`generate:api` hand-edited** — `undated` added to `/search` + `/ids`; **CI must re-run `npm run generate:api`** to confirm parity. - Open-decision defaults applied (owner unreachable): upload-date-asc secondary tiebreaker; page-local undated count. - Badge reuses the existing `date_precision_unknown` text (same wording) rather than a new key. - The pre-existing flaky `PersonMentionEditor.svelte.spec.ts` and pre-existing `provisional`-missing type errors on old Person fixtures / autofixer warnings are out of scope and not introduced here. Closes #668
marcel added 8 commits 2026-05-27 18:57:41 +02:00
resolveSort produced Sort.by(direction, "documentDate") with NATIVE null
handling, so Postgres surfaced undated (null meta_date) documents FIRST on
an ASC sort. Apply nullsLast() so undated rows order last for both ASC and
DESC, with a createdAt-asc tiebreaker for a stable total order when every
row is null-dated (the upcoming "Nur undatierte" filter).

Refs #668
undatedOnly(false) is a no-op (null predicate); undatedOnly(true) returns
documentDate IS NULL, matching the existing hasStatus null-as-no-op pattern.
Real-Postgres tests pin the load-bearing guarantees H2 cannot prove: ASC
NULLS-LAST ordering, BETWEEN excludes null-dated rows, and that undated=true
combined with a from/to range returns empty (the collision rule).

Refs #668
Adds an optional `undated` query param to GET /api/documents/search and
/api/documents/ids, threaded through searchDocuments and findIdsForFilter
into the shared buildSearchSpec via undatedOnly(boolean). undated=true also
bypasses the pure-text RELEVANCE SQL shortcut, which skips buildSearchSpec
and would otherwise drop the predicate. The read GET stays unguarded
(WebMvc authz test pins 200 for an authenticated user, 401 unauthenticated).
A locking test proves the in-memory SENDER sort keeps undated letters under
their sender.

Refs #668
Parses ?undated strictly (=== 'true', mirroring the tagOp clamp), forwards
it as undated || undefined so the absent case drops out of the query, and
returns the flag in page data for the control to reflect. Adds the
docs_filter_undated_only toggle label and the explanatory
docs_range_excludes_undated empty-state copy in de/en/es. The badge reuses
the existing date_precision_unknown ("Datum unbekannt") key from #677.

OpenAPI types hand-edited for the new undated query param on /search and
/ids — CI must run `npm run generate:api` to confirm parity with the spec.

Refs #668
DocumentRow rendered a bare em-dash for null-dated letters — a glyph a
screen reader announces as nothing. Both breakpoints now render the single
DocumentDate component unconditionally (no {#if}/—/{:else}), so the cue
cannot drift; its unknown state is a neutral metadata chip ("Datum
unbekannt", text-ink-3, ≥4.5:1 both themes) with a non-color calendar glyph,
never red/amber. Present dates render at honest precision via
formatDocumentDate ("Juni 1916", not a fabricated day).

Refs #668
DocumentList gains from/to props; when a date range is active and yields no
results, the empty state shows the localized docs_range_excludes_undated
note instead of the generic copy, so the reader understands undated letters
aren't part of a range. Person-grouped modes keep undated letters under
their sender/receiver (badge-on-row, no synthetic sub-group).

Refs #668
SearchFilterBar gains an aria-pressed "Nur undatierte" toggle in the
advanced row (min-h-[44px] touch target, labels the state not the colour).
The documents page threads `undated` through the filter snapshot so it is a
shareable URL param picked up by both filter-change nav and pagination, and
flows into the bulk-edit "select all" /ids request. Toggling resets to page
0 via the existing implicit page-drop.

Refs #668
test(activity): assert Chronik rows never fabricate a letter date
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m54s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m30s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 20s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m3s
a345bba74b
Negative guarantee for #668: ChronikRow renders the activity timestamp
(happenedAt), and ActivityFeedItemDTO carries no document-date surface, so
no undated badge or "Datum unbekannt" letter-date label may appear. Pins
this as a regression fixture so a future change can't quietly add a date
chip to the activity feed.

Refs #668
Author
Owner

Felix Brandt — Senior Fullstack Developer

Verdict: Approved with concerns

I read the full diff and the touched source files. The TDD discipline is visible — every behavioral change has a paired test, and the two service-level searchDocuments_dateSort_ASC/DESC_ordersUndatedLast tests are written as genuine red/green captures of the NULLS-LAST Sort.Order. Clean work overall.

Concerns (not blockers)

  1. Boolean flag argument threaded through searchDocuments / findIdsForFilterDocumentService.java. I normally flag boolean undated per my own "no boolean flag arguments" rule. Here the alternative (two methods) would be worse given the parameter is already a 12-arg method backed by a Specification, and the param is symmetric with from/to/status. I'm not asking for a split, but searchDocuments(...) now has 13 positional parameters including two booleans-adjacent values (String dir, boolean undated, Pageable). This is past the readable threshold. Consider a SearchCriteria/SearchFilters record in a follow-up so call sites stop reading as null, null, null, ..., false, PAGE. The test churn in this PR (every call site re-counted by hand) is the cost of the long signature.

  2. resolveSort visibility widened from private to package-privateDocumentService.java. Done solely to unit-test it indirectly via the captor, but the tests actually assert through searchDocuments + ArgumentCaptor<Pageable>, not by calling resolveSort directly. If nothing calls it package-privately, revert to private — don't widen API surface for a test that doesn't use the widened access. (If a test does call it directly, ignore this.)

  3. DocumentDate.svelte duplicated <span class="inline-flex items-center gap-1"> wrapper — the {:else} branch wraps {label} in a flex span containing a single child span. That's a redundant nesting (<span class="inline-flex flex-col"><span class="inline-flex…"><span>{label}</span></span>). Collapse to one span. Minor.

What I checked and liked

  • The !undated && isPureTextRelevance(...) guard with its inline comment explaining why (the SQL shortcut bypasses buildSearchSpec, dropping the predicate) — that's a "why" comment, exactly right, and it's locked by searchDocuments_undatedTrue_usesSpecificationPath_notPureTextRelevanceShortcut.
  • Frontend undated is $bindable and the $effect re-syncs from data.undated on navigation — no stale-state cycle, correct Svelte 5.
  • +page.server.ts clamps === 'true' mirroring the tagOp clamp; undated || undefined keeps the param off the query string when false. Consistent with the existing pattern.
## Felix Brandt — Senior Fullstack Developer **Verdict: Approved with concerns** I read the full diff and the touched source files. The TDD discipline is visible — every behavioral change has a paired test, and the two service-level `searchDocuments_dateSort_ASC/DESC_ordersUndatedLast` tests are written as genuine red/green captures of the NULLS-LAST `Sort.Order`. Clean work overall. ### Concerns (not blockers) 1. **Boolean flag argument threaded through `searchDocuments` / `findIdsForFilter`** — `DocumentService.java`. I normally flag `boolean undated` per my own "no boolean flag arguments" rule. Here the alternative (two methods) would be worse given the parameter is already a 12-arg method backed by a `Specification`, and the param is symmetric with `from/to/status`. I'm not asking for a split, but `searchDocuments(...)` now has **13 positional parameters** including two booleans-adjacent values (`String dir`, `boolean undated`, `Pageable`). This is past the readable threshold. Consider a `SearchCriteria`/`SearchFilters` record in a follow-up so call sites stop reading as `null, null, null, ..., false, PAGE`. The test churn in this PR (every call site re-counted by hand) is the cost of the long signature. 2. **`resolveSort` visibility widened from `private` to package-private** — `DocumentService.java`. Done solely to unit-test it indirectly via the captor, but the tests actually assert through `searchDocuments` + `ArgumentCaptor<Pageable>`, not by calling `resolveSort` directly. If nothing calls it package-privately, revert to `private` — don't widen API surface for a test that doesn't use the widened access. (If a test *does* call it directly, ignore this.) 3. **`DocumentDate.svelte` duplicated `<span class="inline-flex items-center gap-1">` wrapper** — the `{:else}` branch wraps `{label}` in a flex span containing a single child span. That's a redundant nesting (`<span class="inline-flex flex-col"><span class="inline-flex…"><span>{label}</span></span>`). Collapse to one span. Minor. ### What I checked and liked - The `!undated && isPureTextRelevance(...)` guard with its inline comment explaining *why* (the SQL shortcut bypasses `buildSearchSpec`, dropping the predicate) — that's a "why" comment, exactly right, and it's locked by `searchDocuments_undatedTrue_usesSpecificationPath_notPureTextRelevanceShortcut`. - Frontend `undated` is `$bindable` and the `$effect` re-syncs from `data.undated` on navigation — no stale-state cycle, correct Svelte 5. - `+page.server.ts` clamps `=== 'true'` mirroring the `tagOp` clamp; `undated || undefined` keeps the param off the query string when false. Consistent with the existing pattern.
Author
Owner

Markus Keller — Application Architect

Verdict: Approved

I reviewed this for layer boundaries, where the null-date semantics are enforced, and documentation currency.

Boundaries — clean

  • The new undated predicate lives in DocumentSpecifications.undatedOnly(boolean) as a static factory alongside hasSender, isBetween, etc. — the right home. DocumentService.buildSearchSpec(...) composes it via .and(undatedOnly(undated)). Controller → service → spec; no controller-to-repository shortcut, no cross-domain repository reach. The /ids and /search paths share the single buildSearchSpec builder, so selection (bulk-edit) and the visible list are guaranteed to agree — this is the correct way to avoid filter drift between two endpoints.

Where the rule is enforced — good instinct

  • NULLS-LAST and BETWEEN-excludes-NULL are Postgres semantics, and the team pushed the verification down to the database via UndatedDocumentOrderingIntegrationTest against postgres:16-alpine (not H2). The class JavaDoc explicitly calls out that H2 disagrees on NULLS defaults and BETWEEN/NULL — that is exactly the "push integrity to the lowest trustworthy layer + test it there" principle. Nicely reasoned.
  • The createdAt tiebreaker giving a deterministic total order when every row is null-dated (the "Nur undatierte" filter, so pagination is stable) is a real correctness consideration, not gold-plating.

Documentation currency — no blocker

Per my doc-trigger table: this PR adds no Flyway migration, no new table/column/FK, no new ErrorCode, no new Permission, no new backend package, no new SvelteKit route (it modifies /documents, which already exists). The undated query param is an additive parameter on existing /search + /ids endpoints — not a new domain term warranting a GLOSSARY entry (the concept "undated/Undatiert" already exists in the year-grouping bucket). So no diagram or doc update is required. I confirmed the L3 frontend diagram for documents does not enumerate query params. Clear to merge on docs grounds.

One forward-looking note (not blocking)

The "honest handling of unknowns" is now a recurring cross-cutting concern (precision rendering, undated triage, range collision). If a 7th surface ever needs the same null-date logic, that is the trigger to consider documenting the rule once in docs/GLOSSARY.md ("Undatiert / undated"). Not now.

## Markus Keller — Application Architect **Verdict: Approved** I reviewed this for layer boundaries, where the null-date semantics are enforced, and documentation currency. ### Boundaries — clean - The new `undated` predicate lives in `DocumentSpecifications.undatedOnly(boolean)` as a static factory alongside `hasSender`, `isBetween`, etc. — the right home. `DocumentService.buildSearchSpec(...)` composes it via `.and(undatedOnly(undated))`. Controller → service → spec; no controller-to-repository shortcut, no cross-domain repository reach. The `/ids` and `/search` paths share the single `buildSearchSpec` builder, so selection (bulk-edit) and the visible list are guaranteed to agree — this is the correct way to avoid filter drift between two endpoints. ### Where the rule is enforced — good instinct - NULLS-LAST and BETWEEN-excludes-NULL are **Postgres semantics**, and the team pushed the verification down to the database via `UndatedDocumentOrderingIntegrationTest` against `postgres:16-alpine` (not H2). The class JavaDoc explicitly calls out that H2 disagrees on NULLS defaults and BETWEEN/NULL — that is exactly the "push integrity to the lowest trustworthy layer + test it there" principle. Nicely reasoned. - The `createdAt` tiebreaker giving a deterministic total order when every row is null-dated (the "Nur undatierte" filter, so pagination is stable) is a real correctness consideration, not gold-plating. ### Documentation currency — no blocker Per my doc-trigger table: this PR adds **no** Flyway migration, **no** new table/column/FK, **no** new `ErrorCode`, **no** new `Permission`, **no** new backend package, **no** new SvelteKit route (it modifies `/documents`, which already exists). The `undated` query param is an additive parameter on existing `/search` + `/ids` endpoints — not a new domain term warranting a GLOSSARY entry (the concept "undated/Undatiert" already exists in the year-grouping bucket). So no diagram or doc update is required. I confirmed the L3 frontend diagram for documents does not enumerate query params. Clear to merge on docs grounds. ### One forward-looking note (not blocking) The "honest handling of unknowns" is now a recurring cross-cutting concern (precision rendering, undated triage, range collision). If a 7th surface ever needs the same null-date logic, that is the trigger to consider documenting the rule once in `docs/GLOSSARY.md` ("Undatiert / undated"). Not now.
Author
Owner

Sara Holt — QA Engineer

Verdict: Approved with concerns

Strong test work. Real Postgres for the null-date guarantees, MockMvc for the authz boundary, browser specs for the badge/toggle, and a load-function spec for the param plumbing. The pyramid is respected. My concerns are about coverage gaps in the in-memory sort paths, not about what's there.

Concerns

  1. The SENDER/RECEIVER "undated stays in group" guarantee is under-locked. searchDocuments_senderSort_keepsUndatedDocumentUnderItsSender asserts both docs come back under Alice and uses containsExactlyInAnyOrder("Dated","Undated")In Any Order. So it proves neither doc is dropped, but it does not prove the relative ordering of the undated doc within the group, nor that it doesn't sort to the top of a multi-person result. The PR description claims "undated genuinely does not jump to the top in any mode." For the in-memory comparator that claim is currently asserted only weakly. Add a test with two senders, each with a dated + an undated doc, asserting the exact containsExactly(...) sequence — that locks "grouped by person, undated kept inside its group" with an ordered assertion. Right now a comparator regression that floated all undated docs to the front of the page would still pass.

  2. No DESC test for the in-memory paths. All SENDER/RECEIVER tests use "asc". The SQL DATE path has both ASC and DESC locked; the in-memory path does not. One DocumentSort.SENDER, "desc" case would close the symmetry.

  3. undatedOnly combined with a person sort is untested. undated=true + sort=SENDER is a reachable UI state (toggle on while grouped by sender). Worth one integration assertion that the spec still filters to null-dated rows on that path.

What I verified as solid

  • UndatedDocumentOrderingIntegrationTest uses @AutoConfigureTestDatabase(replace = NONE) + PostgresContainerConfig — correct, not H2. dateRange_excludesUndatedRows, undatedOnly_combinedWithDateRange_returnsEmpty, and the ASC nulls-last ordering are all asserted against real Postgres with ordered assertThat(result.get(n)). This is the canonical pattern.
  • Controller authz: both 401-unauthenticated and forwarded-true/forwarded-false-default are tested via ArgumentCaptor<Boolean>. Exactly the "test both the accept and the reject" rigor I ask for.
  • ChronikRow negative assertion (undated-badge absent, no "Datum unbekannt" text) is a good regression lock against fabricating a letter date in the activity feed.
  • Frontend page.server.spec.ts covers true → query.undated: true, absent → undefined, '1' → false (the clamp), and page-reset. Thorough boundary coverage on the param surface.

No Thread.sleep, no @Disabled, no flaky patterns introduced. Address concern #1 (ordered multi-sender assertion) and I'm fully satisfied; it's the one place the headline claim outruns the test.

## Sara Holt — QA Engineer **Verdict: Approved with concerns** Strong test work. Real Postgres for the null-date guarantees, MockMvc for the authz boundary, browser specs for the badge/toggle, and a load-function spec for the param plumbing. The pyramid is respected. My concerns are about **coverage gaps in the in-memory sort paths**, not about what's there. ### Concerns 1. **The SENDER/RECEIVER "undated stays in group" guarantee is under-locked.** `searchDocuments_senderSort_keepsUndatedDocumentUnderItsSender` asserts both docs come back under Alice and uses `containsExactlyInAnyOrder("Dated","Undated")` — **In Any Order**. So it proves neither doc is *dropped*, but it does **not** prove the relative ordering of the undated doc within the group, nor that it doesn't sort to the *top* of a multi-person result. The PR description claims "undated genuinely does not jump to the top in any mode." For the in-memory comparator that claim is currently asserted only weakly. Add a test with **two senders, each with a dated + an undated doc**, asserting the exact `containsExactly(...)` sequence — that locks "grouped by person, undated kept inside its group" with an ordered assertion. Right now a comparator regression that floated all undated docs to the front of the page would still pass. 2. **No DESC test for the in-memory paths.** All SENDER/RECEIVER tests use `"asc"`. The SQL DATE path has both ASC and DESC locked; the in-memory path does not. One `DocumentSort.SENDER, "desc"` case would close the symmetry. 3. **`undatedOnly` combined with a person sort is untested.** `undated=true` + `sort=SENDER` is a reachable UI state (toggle on while grouped by sender). Worth one integration assertion that the spec still filters to null-dated rows on that path. ### What I verified as solid - `UndatedDocumentOrderingIntegrationTest` uses `@AutoConfigureTestDatabase(replace = NONE)` + `PostgresContainerConfig` — correct, not H2. `dateRange_excludesUndatedRows`, `undatedOnly_combinedWithDateRange_returnsEmpty`, and the ASC nulls-last ordering are all asserted against real Postgres with ordered `assertThat(result.get(n))`. This is the canonical pattern. - Controller authz: **both** 401-unauthenticated and forwarded-true/forwarded-false-default are tested via `ArgumentCaptor<Boolean>`. Exactly the "test both the accept and the reject" rigor I ask for. - `ChronikRow` negative assertion (`undated-badge` absent, no "Datum unbekannt" text) is a good regression lock against fabricating a letter date in the activity feed. - Frontend `page.server.spec.ts` covers true → `query.undated: true`, absent → undefined, `'1'` → false (the clamp), and page-reset. Thorough boundary coverage on the param surface. No `Thread.sleep`, no `@Disabled`, no flaky patterns introduced. Address concern #1 (ordered multi-sender assertion) and I'm fully satisfied; it's the one place the headline claim outruns the test.
Author
Owner

Nora "NullX" Steiner — Application Security Engineer

Verdict: Approved

I reviewed the new undated parameter end-to-end for authz, injection, and input handling. No vulnerabilities, no smells. This is a read-side filter and it's handled correctly.

Authorization — correct on both endpoints

  • GET /api/documents/search carries no @RequirePermission and relies on the global .anyRequest().authenticated() rule — a read GET, reachable by any authenticated READ_ALL user. The PR adds a regression test for exactly this: search_undatedTrue_returns401_whenUnauthenticated (no @WithMockUser) asserts 401, and search_undatedTrue_isReachableByAuthenticatedUser asserts 200. That's the both-sides coverage I insist on — and it guards against a future refactor accidentally write-guarding the triage path. Good.
  • GET /api/documents/ids keeps @RequirePermission(Permission.WRITE_ALL). That's the bulk-edit selection endpoint (it feeds "Alle X editieren"), so WRITE_ALL is the right gate and adding undated does not change its exposure. No IDOR surface — it returns IDs the caller is already authorized to edit, capped at BULK_EDIT_FILTER_MAX_IDS.

Injection / input handling — clean

  • undatedOnly(boolean) builds cb.isNull(root.get("documentDate")) via the JPA Criteria API — no string concatenation, injection-proof by construction. The whole spec chain uses Criteria/named parameters.
  • The HTTP boundary uses Boolean.TRUE.equals(undated), so a missing param (null) and any non-true value collapse to false — fail-safe defaulting. The frontend +page.server.ts independently clamps === 'true' (defense in depth: server-side load won't reflect '1' into the query). Spring's Boolean binder rejects non-parseable values with a 400 at the boundary, which is acceptable.
  • No new error code, no reflected backend string. The empty-state copy docs_range_excludes_undated is a localized constant, not a reflected server message — the DocumentList comment even calls this out explicitly. No info leak.

Note (informational, not a finding)

Boolean.TRUE.equals(undated) silently treats undated=false and undated=garbage-that-bound-to-null identically. That's the intended fail-closed behavior for a filter and is fine. No action needed.

Nothing to fix. The 401/200 pair on the triage path is the kind of explicit boundary test I want to see stay in the suite permanently.

## Nora "NullX" Steiner — Application Security Engineer **Verdict: Approved** I reviewed the new `undated` parameter end-to-end for authz, injection, and input handling. No vulnerabilities, no smells. This is a read-side filter and it's handled correctly. ### Authorization — correct on both endpoints - **`GET /api/documents/search`** carries no `@RequirePermission` and relies on the global `.anyRequest().authenticated()` rule — a read GET, reachable by any authenticated `READ_ALL` user. The PR adds a regression test for exactly this: `search_undatedTrue_returns401_whenUnauthenticated` (no `@WithMockUser`) asserts 401, and `search_undatedTrue_isReachableByAuthenticatedUser` asserts 200. That's the both-sides coverage I insist on — and it guards against a future refactor accidentally write-guarding the triage path. Good. - **`GET /api/documents/ids`** keeps `@RequirePermission(Permission.WRITE_ALL)`. That's the bulk-edit selection endpoint (it feeds "Alle X editieren"), so WRITE_ALL is the right gate and adding `undated` does not change its exposure. No IDOR surface — it returns IDs the caller is already authorized to edit, capped at `BULK_EDIT_FILTER_MAX_IDS`. ### Injection / input handling — clean - `undatedOnly(boolean)` builds `cb.isNull(root.get("documentDate"))` via the JPA Criteria API — no string concatenation, injection-proof by construction. The whole spec chain uses Criteria/named parameters. - The HTTP boundary uses `Boolean.TRUE.equals(undated)`, so a missing param (`null`) and any non-`true` value collapse to `false` — fail-safe defaulting. The frontend `+page.server.ts` independently clamps `=== 'true'` (defense in depth: server-side load won't reflect `'1'` into the query). Spring's `Boolean` binder rejects non-parseable values with a 400 at the boundary, which is acceptable. - No new error code, no reflected backend string. The empty-state copy `docs_range_excludes_undated` is a **localized constant**, not a reflected server message — the DocumentList comment even calls this out explicitly. No info leak. ### Note (informational, not a finding) `Boolean.TRUE.equals(undated)` silently treats `undated=false` and `undated=garbage`-that-bound-to-null identically. That's the intended fail-closed behavior for a filter and is fine. No action needed. Nothing to fix. The 401/200 pair on the triage path is the kind of explicit boundary test I want to see stay in the suite permanently.
Author
Owner

Leonie Voss — UX Designer & Accessibility Strategist

Verdict: Approved with concerns

This is honest-by-design UX done right: an undated letter is rendered as a neutral absence, not an error, and never silently buried. I checked the badge, the toggle, the empty state, and i18n. One real touch-target concern on the toggle's check-box affordance, plus a couple of polish notes.

Concerns

  1. (High) Toggle inner check-box is decorative but visually reads as the control. In SearchFilterBar.svelte the aria-pressed <button> is correctly min-h-[44px] (good — meets WCAG 2.5.5 for the senior audience). But the inner <span aria-hidden="true"> h-4 w-4 check box looks like a checkbox the user should aim at. Seniors on a phone will tap the 16px glyph, not realize the whole 44px row is the target. The full button is tappable, so this isn't a functional blocker — but please verify in proofshot/manual at 320px that the tap target visually communicates "tap anywhere on this pill," e.g. the hover/active state covers the whole pill (it does — hover:bg-line is on the button). Acceptable, but confirm the affordance reads correctly at small size.

  2. (Medium) Badge text is 10px. The undated badge in DocumentDate.svelte uses text-[10px] uppercase. My standing rule is 12px minimum for visible text; the senior audience (60+) needs larger. I understand it's a metadata chip matching the existing archive-chip pattern in the row, and uppercase 10px tracked-widest chips are an established convention here — but "Datum unbekannt" is semantically meaningful (it's the date surface for that row), not decorative chrome like a tag count. If the row layout allows, bump to text-[11px]/text-xs. If the chip pattern is locked project-wide, this is consistent and I'll accept it — flag for the row-density owner.

What I verified as correct (and good)

  • Non-color cue (WCAG 1.4.1): the badge pairs a calendar-with-question glyph + the visible "Datum unbekannt" text. Not color-only. The neutral text-ink-3 chip avoids red/amber — correct: an absent date is not an error state.
  • A11y wiring: decorative SVG is aria-hidden="true"; the label text is announced inline (never aria-hidden). The toggle state is conveyed by aria-pressed, not by color alone — screen readers get the real state. This is exactly the redundant-cue discipline I push.
  • Empty state honesty: the range-excludes-undated copy (docs_range_excludes_undated) explains why a date range yields nothing — that's "help users diagnose and recover," not a dead end. Localized in de/en/es, all three present and accurate (I read the Spanish — "no pueden pertenecer a ningún periodo" is correct and natural).
  • The em-dash → badge swap removes a meaningless cue (a bare "—" tells a senior nothing) and replaces it with a labeled, scannable chip. Good call, and the does not render a bare em-dash test locks it.

No blockers. Confirm concern #1 visually at 320px and consider the 11px bump in #2.

## Leonie Voss — UX Designer & Accessibility Strategist **Verdict: Approved with concerns** This is honest-by-design UX done right: an undated letter is rendered as a *neutral absence*, not an error, and never silently buried. I checked the badge, the toggle, the empty state, and i18n. One real touch-target concern on the toggle's check-box affordance, plus a couple of polish notes. ### Concerns 1. **(High) Toggle inner check-box is decorative but visually reads as the control.** In `SearchFilterBar.svelte` the `aria-pressed` `<button>` is correctly `min-h-[44px]` (good — meets WCAG 2.5.5 for the senior audience). But the inner `<span aria-hidden="true">` `h-4 w-4` check box looks like a checkbox the user should aim at. Seniors on a phone will tap the 16px glyph, not realize the whole 44px row is the target. The full button *is* tappable, so this isn't a functional blocker — but please verify in `proofshot`/manual at 320px that the tap target visually communicates "tap anywhere on this pill," e.g. the hover/active state covers the whole pill (it does — `hover:bg-line` is on the button). Acceptable, but confirm the affordance reads correctly at small size. 2. **(Medium) Badge text is 10px.** The undated badge in `DocumentDate.svelte` uses `text-[10px]` uppercase. My standing rule is 12px minimum for visible text; the senior audience (60+) needs larger. I understand it's a metadata chip matching the existing archive-chip pattern in the row, and uppercase 10px tracked-widest chips are an established convention here — but "Datum unbekannt" is *semantically meaningful* (it's the date surface for that row), not decorative chrome like a tag count. If the row layout allows, bump to `text-[11px]`/`text-xs`. If the chip pattern is locked project-wide, this is consistent and I'll accept it — flag for the row-density owner. ### What I verified as correct (and good) - **Non-color cue (WCAG 1.4.1):** the badge pairs a calendar-with-question glyph + the visible "Datum unbekannt" text. Not color-only. The neutral `text-ink-3` chip avoids red/amber — correct: an absent date is not an error state. - **A11y wiring:** decorative SVG is `aria-hidden="true"`; the label text is announced inline (never aria-hidden). The toggle state is conveyed by `aria-pressed`, not by color alone — screen readers get the real state. This is exactly the redundant-cue discipline I push. - **Empty state honesty:** the range-excludes-undated copy (`docs_range_excludes_undated`) explains *why* a date range yields nothing — that's "help users diagnose and recover," not a dead end. Localized in de/en/es, all three present and accurate (I read the Spanish — "no pueden pertenecer a ningún periodo" is correct and natural). - The em-dash → badge swap removes a meaningless cue (a bare "—" tells a senior nothing) and replaces it with a labeled, scannable chip. Good call, and the `does not render a bare em-dash` test locks it. No blockers. Confirm concern #1 visually at 320px and consider the 11px bump in #2.
Author
Owner

Elicit — Requirements Engineer (Brownfield)

Verdict: Approved with concerns

I assessed this against the issue it closes (#668, "honest handling of undated/imprecise documents") and the dual-audience constraints. The acceptance surface is well covered and the requirement is stated in behavior language, not solution language. Two requirement-traceability gaps and a few decisions I want surfaced rather than silently resolved.

Requirements coverage — traced

The PR delivers, with a test each: NULLS-LAST both directions (SQL path); undated kept in person group (in-memory path); undatedOnly filter shared by /search + /ids; range-excludes-undated empty state; precision rendering via formatDocumentDate; Chronik does-not-fabricate. That maps cleanly to "surface undated honestly, never bury, never fabricate." Good behavioral framing.

Concerns / open items to surface (per my "never silently resolve a TBD" rule)

  1. Two open decisions were defaulted because "owner unreachable": (a) upload-date (createdAt) ASC as the secondary tiebreaker, and (b) page-local undated count. These are defensible defaults, but they are product decisions made by the implementer. Please confirm them explicitly, @marcel, or log them in an Open-Questions note on #668 so they don't become invisible accidental requirements. The createdAt tiebreaker in particular sets the order seniors will scan undated letters in — worth a conscious yes.
  2. Acceptance criterion gap — "undated never jumps to the top in ANY mode" (the headline AC) is only weakly locked for SENDER/RECEIVER. Sara has the test detail; from a requirements view: the AC as written in the PR body is stronger than the test that backs it (containsExactlyInAnyOrder). Either tighten the test to an ordered multi-person assertion, or soften the AC wording to match what's actually guaranteed. Requirement and verification must agree.
  3. undated + a from/to range is a contradiction the UI can still express. The backend correctly returns empty (mutually exclusive by definition), and the empty-state explains it. But nothing prevents the user from setting both — is the intended behavior "range wins, undated yields empty + explanation" (current) or "disable the undated toggle while a range is active"? Current behavior is honest and fine; just confirm it's the intended resolution of the contradiction, not an accident.

Non-functional checks

  • i18n: all three locales (de/en/es) carry docs_filter_undated_only and docs_range_excludes_undated. Pass.
  • Accessibility: toggle is aria-pressed + 44px; badge has redundant cues. Pass (Leonie owns the depth).
  • No scope creep: Briefwechsel correctly left untouched (documented as a dead feature). No gold-plating.

Approving on the understanding that the two defaulted decisions get an explicit owner sign-off and AC #2 is reconciled with its test.

## Elicit — Requirements Engineer (Brownfield) **Verdict: Approved with concerns** I assessed this against the issue it closes (#668, "honest handling of undated/imprecise documents") and the dual-audience constraints. The acceptance surface is well covered and the requirement is stated in behavior language, not solution language. Two requirement-traceability gaps and a few decisions I want surfaced rather than silently resolved. ### Requirements coverage — traced The PR delivers, with a test each: NULLS-LAST both directions (SQL path); undated kept in person group (in-memory path); `undatedOnly` filter shared by `/search` + `/ids`; range-excludes-undated empty state; precision rendering via `formatDocumentDate`; Chronik does-not-fabricate. That maps cleanly to "surface undated honestly, never bury, never fabricate." Good behavioral framing. ### Concerns / open items to surface (per my "never silently resolve a TBD" rule) 1. **Two open decisions were defaulted because "owner unreachable":** (a) upload-date (`createdAt`) ASC as the secondary tiebreaker, and (b) page-local undated count. These are defensible defaults, but they are **product decisions made by the implementer**. Please confirm them explicitly, @marcel, or log them in an Open-Questions note on #668 so they don't become invisible accidental requirements. The `createdAt` tiebreaker in particular sets the order seniors will scan undated letters in — worth a conscious yes. 2. **Acceptance criterion gap — "undated never jumps to the top in ANY mode" (the headline AC) is only weakly locked for SENDER/RECEIVER.** Sara has the test detail; from a requirements view: the AC as written in the PR body is stronger than the test that backs it (`containsExactlyInAnyOrder`). Either tighten the test to an ordered multi-person assertion, or soften the AC wording to match what's actually guaranteed. Requirement and verification must agree. 3. **`undated` + a `from/to` range is a contradiction the UI can still express.** The backend correctly returns empty (mutually exclusive by definition), and the empty-state explains it. But nothing *prevents* the user from setting both — is the intended behavior "range wins, undated yields empty + explanation" (current) or "disable the undated toggle while a range is active"? Current behavior is honest and fine; just confirm it's the *intended* resolution of the contradiction, not an accident. ### Non-functional checks - **i18n:** all three locales (de/en/es) carry `docs_filter_undated_only` and `docs_range_excludes_undated`. Pass. - **Accessibility:** toggle is `aria-pressed` + 44px; badge has redundant cues. Pass (Leonie owns the depth). - **No scope creep:** Briefwechsel correctly left untouched (documented as a dead feature). No gold-plating. Approving on the understanding that the two defaulted decisions get an explicit owner sign-off and AC #2 is reconciled with its test.
Author
Owner

Tobias Wendt — DevOps & Platform Engineer

Verdict: Approved

Nothing in my domain to flag, but I checked the things that bite at deploy time rather than just rubber-stamping.

Infra surface — none

No Compose changes, no Dockerfile, no CI workflow, no new service, no new env var, no migration, no port. This is application-only (Java + Svelte). My usual checks (pinned image tags, named volumes, exposed ports, root creds, deprecated action versions) are all N/A here.

The one operational risk worth naming: the hand-edited generated client

frontend/src/lib/generated/api.ts was edited by hand to add undated?: boolean to /search and /ids, because npm run generate:api needs the backend running. The PR body correctly flags that CI must re-run generate:api. From a pipeline view:

  • This is the recurring #673 drift risk: a hand-edited generated artifact can silently diverge from the OpenAPI spec the backend actually emits. I confirmed the hand-edit is consistent — both endpoints got the undated?: boolean query field, matching the two new @RequestParam Boolean undated on the controller. So it's correct today.
  • Action for CI, not a code change: ensure the pipeline's generate:api step runs against the built backend and fails the build if the regenerated api.ts differs from the committed one (a git diff --exit-code after regen). If that gate already exists, this PR is fully safe; if it doesn't, that's a standing CI gap that this PR happens to exercise — worth a devops issue independent of #682. Either way, not a blocker for this merge since the hand-edit is verifiably correct.

Test infra

The new UndatedDocumentOrderingIntegrationTest uses Testcontainers postgres:16-alpine via PostgresContainerConfig — same pattern as the rest of the suite, no new container or resource footprint, no CI time concern beyond one more @DataJpaTest class. Fine.

Approved. The only follow-up is a CI-hygiene check on the generate:api drift gate, which is orthogonal to merging this.

## Tobias Wendt — DevOps & Platform Engineer **Verdict: Approved** Nothing in my domain to flag, but I checked the things that bite at deploy time rather than just rubber-stamping. ### Infra surface — none No Compose changes, no Dockerfile, no CI workflow, no new service, no new env var, no migration, no port. This is application-only (Java + Svelte). My usual checks (pinned image tags, named volumes, exposed ports, root creds, deprecated action versions) are all N/A here. ### The one operational risk worth naming: the hand-edited generated client `frontend/src/lib/generated/api.ts` was **edited by hand** to add `undated?: boolean` to `/search` and `/ids`, because `npm run generate:api` needs the backend running. The PR body correctly flags that CI must re-run `generate:api`. From a pipeline view: - This is the recurring #673 drift risk: a hand-edited generated artifact can silently diverge from the OpenAPI spec the backend actually emits. I confirmed the hand-edit is **consistent** — both endpoints got the `undated?: boolean` query field, matching the two new `@RequestParam Boolean undated` on the controller. So it's correct *today*. - **Action for CI, not a code change:** ensure the pipeline's `generate:api` step runs against the built backend and fails the build if the regenerated `api.ts` differs from the committed one (a `git diff --exit-code` after regen). If that gate already exists, this PR is fully safe; if it doesn't, that's a standing CI gap that this PR happens to exercise — worth a `devops` issue independent of #682. Either way, not a blocker for *this* merge since the hand-edit is verifiably correct. ### Test infra The new `UndatedDocumentOrderingIntegrationTest` uses Testcontainers `postgres:16-alpine` via `PostgresContainerConfig` — same pattern as the rest of the suite, no new container or resource footprint, no CI time concern beyond one more `@DataJpaTest` class. Fine. Approved. The only follow-up is a CI-hygiene check on the generate:api drift gate, which is orthogonal to merging this.
marcel added 4 commits 2026-05-27 19:09:53 +02:00
No test calls resolveSort directly — the sort tests assert through
searchDocuments + ArgumentCaptor<Pageable>, so the package-private widening
added no value. Narrow the API surface back to private.

Refs #668

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replace the single-sender containsExactlyInAnyOrder check with a two-sender
fixture and ordered containsExactly proving an undated doc stays within its
sender group and never floats to the page head. Add a DESC-direction case for
in-memory-path symmetry and an undated=true + sort=SENDER case capturing the
Specification to prove undatedOnly is still applied on the person-sort path.

Refs #668

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
"Datum unbekannt" is a semantically meaningful date surface, not decorative
chrome, so the 10px chip text is too small for the senior reader audience.
Bump to text-xs (≥12px) per the WCAG min-legible-text guidance.

Refs #668

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
refactor(documents): collapse redundant span nesting in DocumentDate else branch
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m51s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m43s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m1s
508575eccb
The dated branch wrapped {label} in a flex span containing a single child
span — redundant nesting. Render the label directly in one span.

Refs #668

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Author
Owner

Felix Brandt — review concerns addressed

Pushed four atomic commits to feature/668-undated-documents. Red→green throughout; backend touched classes and frontend node-vitest + lint all green locally.

Sara — in-memory sort coverage

  • #1 (strengthened ordered test): Replaced the single-sender containsExactlyInAnyOrder check with searchDocuments_senderSort_asc_keepsUndatedInsideSenderGroupNotAtHead — a two-sender fixture (Adler/Ziegler, each a dated + undated doc) with an ordered containsExactly. Input interleaves dated/undated; the assertion proves grouping-by-sender wins (stable sort preserves intra-group input order) and the undated doc never floats to the page head. (caec92e7)
  • #2 (DESC + undated+SENDER): Added searchDocuments_senderSort_desc_keepsUndatedInsideSenderGroupNotAtHead for in-memory-path symmetry, and searchDocuments_undatedTrue_withSenderSort_appliesUndatedSpecification — captures the Specification handed to findAll on the SENDER path, proving undatedOnly(true) is still composed when the toggle is on while grouped by sender. (caec92e7)

Felix — cleanups

  • resolveSort → private: No test calls it directly (the sort tests assert through searchDocuments), so the package-private widening added nothing. Reverted. (eacfd15f)
  • DocumentDate {:else} span nesting: Collapsed the <span class="inline-flex…"><span>{label}</span></span> wrapper to a single <span>{label}</span>. (508575ec)

Leonie — badge & toggle

  • #2 badge text: Bumped the meaningful "Datum unbekannt" chip from text-[10px] to text-xs (≥12px). (85372e36)
  • #1 toggle tap area: Confirmed — the aria-pressed <button> already is the full 44px pill (min-h-[44px], px-3, hover:bg-line over the whole button); the inner 16px glyph is an aria-hidden decorative child inside the button, so a tap anywhere on the pill fires the toggle. No markup change needed. Visual confirmation at 320px remains a CI/proofshot step (browser specs are CI-only).

Tests

  • Backend (touched, no-Testcontainers classes run locally): DocumentServiceTest 165, DocumentControllerTest 102, DocumentServiceSortTest 7 → 274 pass. Testcontainers-Postgres integration classes (UndatedDocumentOrderingIntegrationTest et al.) unchanged — CI owns the sweep.
  • Frontend: vitest --project=server 643 pass; npm run lint clean. Browser/component specs (badge/toggle) CI-only.

Not done (out of scope per follow-up)

  • SearchFilters record extraction (Felix #1) — flagged follow-up, filed separately.
  • generate:api drift gate (Tobias) — tracked in #680.

🤖 Generated with Claude Code

## Felix Brandt — review concerns addressed Pushed four atomic commits to `feature/668-undated-documents`. Red→green throughout; backend touched classes and frontend node-vitest + lint all green locally. ### Sara — in-memory sort coverage - **#1 (strengthened ordered test):** Replaced the single-sender `containsExactlyInAnyOrder` check with `searchDocuments_senderSort_asc_keepsUndatedInsideSenderGroupNotAtHead` — a **two-sender** fixture (Adler/Ziegler, each a dated + undated doc) with an **ordered `containsExactly`**. Input interleaves dated/undated; the assertion proves grouping-by-sender wins (stable sort preserves intra-group input order) and the undated doc never floats to the page head. (`caec92e7`) - **#2 (DESC + undated+SENDER):** Added `searchDocuments_senderSort_desc_keepsUndatedInsideSenderGroupNotAtHead` for in-memory-path symmetry, and `searchDocuments_undatedTrue_withSenderSort_appliesUndatedSpecification` — captures the `Specification` handed to `findAll` on the SENDER path, proving `undatedOnly(true)` is still composed when the toggle is on while grouped by sender. (`caec92e7`) ### Felix — cleanups - **resolveSort → private:** No test calls it directly (the sort tests assert through `searchDocuments`), so the package-private widening added nothing. Reverted. (`eacfd15f`) - **DocumentDate `{:else}` span nesting:** Collapsed the `<span class="inline-flex…"><span>{label}</span></span>` wrapper to a single `<span>{label}</span>`. (`508575ec`) ### Leonie — badge & toggle - **#2 badge text:** Bumped the meaningful "Datum unbekannt" chip from `text-[10px]` to `text-xs` (≥12px). (`85372e36`) - **#1 toggle tap area:** Confirmed — the `aria-pressed` `<button>` already **is** the full 44px pill (`min-h-[44px]`, `px-3`, `hover:bg-line` over the whole button); the inner 16px glyph is an `aria-hidden` decorative child *inside* the button, so a tap anywhere on the pill fires the toggle. No markup change needed. Visual confirmation at 320px remains a CI/`proofshot` step (browser specs are CI-only). ### Tests - Backend (touched, no-Testcontainers classes run locally): `DocumentServiceTest` 165, `DocumentControllerTest` 102, `DocumentServiceSortTest` 7 → **274 pass**. Testcontainers-Postgres integration classes (`UndatedDocumentOrderingIntegrationTest` et al.) unchanged — CI owns the sweep. - Frontend: `vitest --project=server` **643 pass**; `npm run lint` clean. Browser/component specs (badge/toggle) CI-only. ### Not done (out of scope per follow-up) - `SearchFilters` record extraction (Felix #1) — flagged follow-up, filed separately. - `generate:api` drift gate (Tobias) — tracked in #680. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Author
Owner

Markus Keller — Senior Application Architect

Verdict: Approved

I reviewed module boundaries, the layering rule, database-vs-application enforcement, and documentation currency.

What I checked

  • Layering held. DocumentController forwards the new undated flag straight into DocumentService; no repository reached from the controller, no cross-domain repo access. undatedOnly(boolean) lives in DocumentSpecifications alongside its siblings — correct placement.
  • NULLS-LAST is pushed to the DB, not faked in Java. resolveSort emits Order(dir, "documentDate").nullsLast() plus a createdAt.asc() tiebreaker. This is the right layer: Postgres owns ordering semantics; the in-memory SENDER/RECEIVER path is a deliberate, ADR-008-documented exception for the INNER-JOIN-drops-nulls problem, and the UndatedDocumentOrderingIntegrationTest correctly runs on real postgres:16-alpine (its class comment explicitly justifies why H2 is unacceptable for NULLS/BETWEEN semantics). Good architectural hygiene.
  • No schema change. meta_date IS NULL is filtered, no migration, so no DB-diagram update is owed.
  • No new route, package, controller, service, ErrorCode, Permission, or external system — the doc-update matrix is not triggered. undated is a query param on an existing endpoint.

Notes (not blockers)

  • The searchDocuments(...) signature now carries 13 positional parameters. The boolean undated wedged before Pageable is a long, type-ambiguous argument list. The SearchFilters record extraction (#683) is the right fix and is already tracked — I am content to let it land separately rather than block Phase 6.
  • The undated && isPureTextRelevance(...) short-circuit in searchDocuments is a sharp edge: the SQL relevance shortcut bypasses buildSearchSpec, so the predicate would be silently dropped. The code comment names this precisely and the test searchDocuments_undatedTrue_usesSpecificationPath_notPureTextRelevanceShortcut locks it. This is exactly the kind of invariant I want guarded by a test, not a comment alone — well done.

Boring, correct, well-bounded. Ship it.

## Markus Keller — Senior Application Architect **Verdict: Approved** I reviewed module boundaries, the layering rule, database-vs-application enforcement, and documentation currency. ### What I checked - **Layering held.** `DocumentController` forwards the new `undated` flag straight into `DocumentService`; no repository reached from the controller, no cross-domain repo access. `undatedOnly(boolean)` lives in `DocumentSpecifications` alongside its siblings — correct placement. - **NULLS-LAST is pushed to the DB, not faked in Java.** `resolveSort` emits `Order(dir, "documentDate").nullsLast()` plus a `createdAt.asc()` tiebreaker. This is the right layer: Postgres owns ordering semantics; the in-memory SENDER/RECEIVER path is a deliberate, ADR-008-documented exception for the INNER-JOIN-drops-nulls problem, and the `UndatedDocumentOrderingIntegrationTest` correctly runs on real `postgres:16-alpine` (its class comment explicitly justifies why H2 is unacceptable for NULLS/BETWEEN semantics). Good architectural hygiene. - **No schema change.** `meta_date IS NULL` is filtered, no migration, so no DB-diagram update is owed. - **No new route, package, controller, service, ErrorCode, Permission, or external system** — the doc-update matrix is not triggered. `undated` is a query param on an existing endpoint. ### Notes (not blockers) - The `searchDocuments(...)` signature now carries 13 positional parameters. The boolean `undated` wedged before `Pageable` is a long, type-ambiguous argument list. The `SearchFilters` record extraction (#683) is the right fix and is already tracked — I am content to let it land separately rather than block Phase 6. - The `undated && isPureTextRelevance(...)` short-circuit in `searchDocuments` is a sharp edge: the SQL relevance shortcut bypasses `buildSearchSpec`, so the predicate would be silently dropped. The code comment names this precisely and the test `searchDocuments_undatedTrue_usesSpecificationPath_notPureTextRelevanceShortcut` locks it. This is exactly the kind of invariant I want guarded by a test, not a comment alone — well done. Boring, correct, well-bounded. Ship it.
Author
Owner

Felix Brandt — Senior Fullstack Developer

Verdict: Approved

I checked TDD evidence, naming, function size, guard clauses, Svelte 5 rules, and dead code.

TDD evidence — solid

The red is captured where it matters. searchDocuments_dateSort_ASC_ordersUndatedLast carries the comment "This is the red" and asserts NullHandling.NULLS_LAST on the ASC order — that is the exact bug (#668) where undated docs floated to the top. The DESC counterpart proves symmetry. The two new SENDER locking tests are the strongest part of the PR:

  • searchDocuments_senderSort_asc_keepsUndatedInsideSenderGroupNotAtHead and its _desc_ sibling use a two-sender fixture (Adler Bob, Ziegler Anna), each with one dated + one undated doc, input interleaved, asserted with ordered containsExactly. I verified sortBySender in the source: it uses Stream.sorted() (a guaranteed stable sort) keyed purely on lastName + " " + firstName, never on date. So the assertion "Bob undated", "Bob dated", "Anna dated", "Anna undated" genuinely proves the undated doc stays inside its sender group and never jumps to the head — and a date-based regression would clump the two undated docs together, breaking the order. The lock is real, in both directions.
  • searchDocuments_undatedTrue_withSenderSort_appliesUndatedSpecification captures the Specification and confirms the in-memory SENDER path still composes undatedOnly(true) rather than skipping to a sorted findAll. Good coverage of the reachable "Nur undatierte while grouped by sender" UI state.

Clean code

  • undatedOnly(boolean) is a one-line guard-clause Specification — false → null (no-op), true → cb.isNull(...). Reads exactly as intended.
  • DocumentRow.svelte: the {#if doc.documentDate} … {:else} — {/if} was collapsed to a single <DocumentDate> call; DocumentDate.svelte now owns the unknown/dated branch with the redundant {:else} span nesting removed. Cleaner, single source of truth for date rendering. No dead branches left behind.
  • resolveSort is back to private — no test reaches into it directly; the new sort tests assert through the public searchDocuments via a captured Pageable. Correct: test behavior, not internals.
  • Frontend undated flows server→props (+page.server.ts clamps to the literal "true", mirroring the tagOp clamp), $bindable in SearchFilterBar, $derived hasDateRange in DocumentList. No $effect-to-compute, keyed each blocks untouched.

Minor (non-blocking)

The 13-arg searchDocuments is past the point where I'd normally extract a params object — but #683 tracks the SearchFilters record and I'd rather not balloon this PR. Agreed as a follow-up.

Disciplined work. Approved.

## Felix Brandt — Senior Fullstack Developer **Verdict: Approved** I checked TDD evidence, naming, function size, guard clauses, Svelte 5 rules, and dead code. ### TDD evidence — solid The red is captured where it matters. `searchDocuments_dateSort_ASC_ordersUndatedLast` carries the comment "This is the red" and asserts `NullHandling.NULLS_LAST` on the ASC order — that is the exact bug (#668) where undated docs floated to the top. The DESC counterpart proves symmetry. The two new SENDER locking tests are the strongest part of the PR: - `searchDocuments_senderSort_asc_keepsUndatedInsideSenderGroupNotAtHead` and its `_desc_` sibling use a **two-sender fixture** (`Adler Bob`, `Ziegler Anna`), each with one dated + one undated doc, input interleaved, asserted with **ordered `containsExactly`**. I verified `sortBySender` in the source: it uses `Stream.sorted()` (a guaranteed *stable* sort) keyed purely on `lastName + " " + firstName`, never on date. So the assertion `"Bob undated", "Bob dated", "Anna dated", "Anna undated"` genuinely proves the undated doc stays inside its sender group and never jumps to the head — and a date-based regression would clump the two undated docs together, breaking the order. The lock is real, in both directions. - `searchDocuments_undatedTrue_withSenderSort_appliesUndatedSpecification` captures the `Specification` and confirms the in-memory SENDER path still composes `undatedOnly(true)` rather than skipping to a sorted `findAll`. Good coverage of the reachable "Nur undatierte while grouped by sender" UI state. ### Clean code - `undatedOnly(boolean)` is a one-line guard-clause Specification — `false → null` (no-op), `true → cb.isNull(...)`. Reads exactly as intended. - `DocumentRow.svelte`: the `{#if doc.documentDate} … {:else} — {/if}` was collapsed to a single `<DocumentDate>` call; `DocumentDate.svelte` now owns the unknown/dated branch with the redundant `{:else}` span nesting removed. Cleaner, single source of truth for date rendering. No dead branches left behind. - `resolveSort` is back to `private` — no test reaches into it directly; the new sort tests assert through the public `searchDocuments` via a captured `Pageable`. Correct: test behavior, not internals. - Frontend `undated` flows server→props (`+page.server.ts` clamps to the literal `"true"`, mirroring the `tagOp` clamp), `$bindable` in `SearchFilterBar`, `$derived` `hasDateRange` in `DocumentList`. No `$effect`-to-compute, keyed each blocks untouched. ### Minor (non-blocking) The 13-arg `searchDocuments` is past the point where I'd normally extract a params object — but #683 tracks the `SearchFilters` record and I'd rather not balloon this PR. Agreed as a follow-up. Disciplined work. Approved.
Author
Owner

Sara Holt — Senior QA Engineer

Verdict: Approved with concerns

I assessed the test pyramid, determinism, real-infra coverage, and boundary cases. CI owns the full sweep; I reviewed by reading.

Strong points

  • Right tests at the right layer. The NULLS-LAST contract is asserted at the unit layer (capture the Pageable, assert NullHandling.NULLS_LAST + direction) AND at the integration layer against real Postgres (UndatedDocumentOrderingIntegrationTest with postgres:16-alpine, @AutoConfigureTestDatabase(replace = NONE)). The class comment correctly states H2 disagrees with Postgres on NULLS and BETWEEN-vs-NULL — exactly why this must not be an in-memory DB test. This is the discipline I want.
  • The collision rule is tested end to end: dateRange_excludesUndatedRows, undatedOnly_combinedWithDateRange_returnsEmpty (the BETWEEN-excludes-null guarantee on real Postgres), plus the frontend empty-state copy test. Good vertical coverage of one rule.
  • Auth boundaries on the new param are explicit: search_undatedTrue_returns401_whenUnauthenticated and search_undatedTrue_isReachableByAuthenticatedUser. Both 401 and reachable-200 are covered — not just the happy path.
  • The SENDER locking tests use factory-built fixtures, ordered containsExactly, ASC+DESC, and a captured-Specification case. One behavior per test, names read as sentences. Textbook.
  • Negative assertion for Chronik (renders the activity timestamp, not a letter date, and no undated badge) guards against a fabricated date chip leaking into the activity feed.

Concerns (non-blocking)

  1. Browser/component specs are CI-only and unverified locally — the DocumentRow, DocumentList, and SearchFilterBar badge/toggle specs are vitest-browser-svelte. Per project convention these are trusted to CI. They look correct (use getByTestId('undated-badge'), aria-pressed, getByText), but the verdict is contingent on CI going green. Flag, not block.
  2. page-local undated count is a defaulted product decision: the undated count reflects only the current page, not the full result set. That is a behavior worth a test asserting the documented semantics so a future "make it global" change is a conscious break. Owner sign-off pending — note it.
  3. The pre-existing flaky PersonMentionEditor.svelte.spec.ts and the pre-existing provisional-fixture svelte-check errors are unrelated to this diff and correctly out of scope.

Quality gate is met pending the CI sweep. Approved with the count-semantics note.

## Sara Holt — Senior QA Engineer **Verdict: Approved with concerns** I assessed the test pyramid, determinism, real-infra coverage, and boundary cases. CI owns the full sweep; I reviewed by reading. ### Strong points - **Right tests at the right layer.** The NULLS-LAST contract is asserted at the unit layer (capture the `Pageable`, assert `NullHandling.NULLS_LAST` + direction) AND at the integration layer against real Postgres (`UndatedDocumentOrderingIntegrationTest` with `postgres:16-alpine`, `@AutoConfigureTestDatabase(replace = NONE)`). The class comment correctly states H2 disagrees with Postgres on NULLS and BETWEEN-vs-NULL — exactly why this must not be an in-memory DB test. This is the discipline I want. - **The collision rule is tested end to end:** `dateRange_excludesUndatedRows`, `undatedOnly_combinedWithDateRange_returnsEmpty` (the BETWEEN-excludes-null guarantee on real Postgres), plus the frontend empty-state copy test. Good vertical coverage of one rule. - **Auth boundaries on the new param are explicit:** `search_undatedTrue_returns401_whenUnauthenticated` and `search_undatedTrue_isReachableByAuthenticatedUser`. Both 401 and reachable-200 are covered — not just the happy path. - **The SENDER locking tests** use factory-built fixtures, ordered `containsExactly`, ASC+DESC, and a captured-`Specification` case. One behavior per test, names read as sentences. Textbook. - **Negative assertion for Chronik** (`renders the activity timestamp, not a letter date, and no undated badge`) guards against a fabricated date chip leaking into the activity feed. ### Concerns (non-blocking) 1. **Browser/component specs are CI-only and unverified locally** — the `DocumentRow`, `DocumentList`, and `SearchFilterBar` badge/toggle specs are `vitest-browser-svelte`. Per project convention these are trusted to CI. They look correct (use `getByTestId('undated-badge')`, `aria-pressed`, `getByText`), but the verdict is contingent on CI going green. Flag, not block. 2. **`page-local undated count`** is a defaulted product decision: the undated count reflects only the current page, not the full result set. That is a behavior worth a test asserting the documented semantics so a future "make it global" change is a conscious break. Owner sign-off pending — note it. 3. The pre-existing flaky `PersonMentionEditor.svelte.spec.ts` and the pre-existing `provisional`-fixture svelte-check errors are unrelated to this diff and correctly out of scope. Quality gate is met pending the CI sweep. Approved with the count-semantics note.
Author
Owner

Nora Steiner ("NullX") — Application Security Engineer

Verdict: Approved

I reviewed the new query param for injection, authorization, mass-assignment, and untrusted-input rendering.

Findings

  • No injection surface. undatedOnly(boolean) builds cb.isNull(root.get("documentDate")) via the Criteria API — fully parameterized, no string concatenation. The boolean is a closed type with two states; there is no user-controlled string reaching JPQL. Clean.
  • Authorization is correct and tested. The undated param rides on the existing GET /api/documents/search and /ids, both of which sit behind anyRequest().authenticated(). The read GET is intentionally not @RequirePermission-guarded — correct, since this is a READ path and write-guarding it would be wrong. The pair search_undatedTrue_returns401_whenUnauthenticated (401 for anonymous) and search_undatedTrue_isReachableByAuthenticatedUser (200 for @WithMockUser) is exactly the both-sides boundary test I require. The comment "guards against a future refactor accidentally write-guarding the undated triage path" shows the right threat-model thinking.
  • Input is clamped to a literal allow-value. +page.server.ts does url.searchParams.get('undated') === 'true' — anything else ('1', 'TRUE', garbage) is false, and the test treats any undated value other than the literal "true" as not-undated proves it. No truthy-coercion footgun. Backend mirrors with Boolean.TRUE.equals(undated), so a missing/garbage param is a safe false, not an NPE on an unboxed primitive.
  • No untrusted reflection into HTML. DocumentDate.svelte renders raw (verbatim spreadsheet text) via default Svelte interpolation — the existing comment correctly notes it HTML-escapes (never {@html}, CWE-79). The new "Datum unbekannt" label is a static i18n string. The empty-state copy docs_range_excludes_undated is a localized constant, never a reflected backend string — the code comment says so and the diff confirms it.

No CWE in scope here. Nothing to fix. Approved.

## Nora Steiner ("NullX") — Application Security Engineer **Verdict: Approved** I reviewed the new query param for injection, authorization, mass-assignment, and untrusted-input rendering. ### Findings - **No injection surface.** `undatedOnly(boolean)` builds `cb.isNull(root.get("documentDate"))` via the Criteria API — fully parameterized, no string concatenation. The boolean is a closed type with two states; there is no user-controlled string reaching JPQL. Clean. - **Authorization is correct and tested.** The `undated` param rides on the existing `GET /api/documents/search` and `/ids`, both of which sit behind `anyRequest().authenticated()`. The read GET is intentionally *not* `@RequirePermission`-guarded — correct, since this is a READ path and write-guarding it would be wrong. The pair `search_undatedTrue_returns401_whenUnauthenticated` (401 for anonymous) and `search_undatedTrue_isReachableByAuthenticatedUser` (200 for `@WithMockUser`) is exactly the both-sides boundary test I require. The comment "guards against a future refactor accidentally write-guarding the undated triage path" shows the right threat-model thinking. - **Input is clamped to a literal allow-value.** `+page.server.ts` does `url.searchParams.get('undated') === 'true'` — anything else (`'1'`, `'TRUE'`, garbage) is `false`, and the test `treats any undated value other than the literal "true" as not-undated` proves it. No truthy-coercion footgun. Backend mirrors with `Boolean.TRUE.equals(undated)`, so a missing/garbage param is a safe `false`, not an NPE on an unboxed primitive. - **No untrusted reflection into HTML.** `DocumentDate.svelte` renders `raw` (verbatim spreadsheet text) via default Svelte interpolation — the existing comment correctly notes it HTML-escapes (never `{@html}`, CWE-79). The new "Datum unbekannt" label is a static i18n string. The empty-state copy `docs_range_excludes_undated` is a localized constant, never a reflected backend string — the code comment says so and the diff confirms it. No CWE in scope here. Nothing to fix. Approved.
Author
Owner

Tobias Wendt — DevOps & Platform Engineer

Verdict: Approved

I checked for infrastructure impact, CI implications, and migration/deploy risk.

Findings

  • No infra surface touched. No Compose file, no Caddyfile, no CI workflow, no Dockerfile, no new service or port, no env var. This is application code only. Nothing for me to size or operate.
  • No migration, no deploy ordering risk. undated filters on an existing nullable column (meta_date IS NULL); zero schema change means zero rollback/PITR concern and no startup-order dependency.
  • The one operational item is the generate:api drift. The PR notes the OpenAPI types (frontend/src/lib/generated/api.ts) were hand-edited to add undated?: boolean on /search and /ids. Hand-editing generated artifacts is exactly the kind of drift that silently rots — the generator output and the backend spec can diverge with no one noticing. The author flagged that CI must re-run npm run generate:api to confirm parity, and a drift-gate is already tracked as #680. That is the right resolution: a CI gate that fails when the committed api.ts differs from a fresh generation. I'd want that gate landed soon, but it is a tracked follow-up, not a blocker for this PR. Until #680 exists, the safety net is the CI regeneration step the author named.

The hand-edit itself looks correct (both query objects gained undated?: boolean with a matching description), so functionally CI should pass. No infrastructure objections. Approved.

## Tobias Wendt — DevOps & Platform Engineer **Verdict: Approved** I checked for infrastructure impact, CI implications, and migration/deploy risk. ### Findings - **No infra surface touched.** No Compose file, no Caddyfile, no CI workflow, no Dockerfile, no new service or port, no env var. This is application code only. Nothing for me to size or operate. - **No migration, no deploy ordering risk.** `undated` filters on an existing nullable column (`meta_date IS NULL`); zero schema change means zero rollback/PITR concern and no startup-order dependency. - **The one operational item is the `generate:api` drift.** The PR notes the OpenAPI types (`frontend/src/lib/generated/api.ts`) were **hand-edited** to add `undated?: boolean` on `/search` and `/ids`. Hand-editing generated artifacts is exactly the kind of drift that silently rots — the generator output and the backend spec can diverge with no one noticing. The author flagged that **CI must re-run `npm run generate:api`** to confirm parity, and a drift-gate is already tracked as #680. That is the right resolution: a CI gate that fails when the committed `api.ts` differs from a fresh generation. I'd want that gate landed soon, but it is a tracked follow-up, not a blocker for this PR. Until #680 exists, the safety net is the CI regeneration step the author named. The hand-edit itself looks correct (both query objects gained `undated?: boolean` with a matching description), so functionally CI should pass. No infrastructure objections. Approved.
Author
Owner

Leonie Voss — UX Designer & Accessibility Strategist

Verdict: Approved

I reviewed brand compliance, WCAG (contrast, touch targets, non-color cues, focus), and the 320px / senior-audience constraint.

Findings — the prior-round fixes landed correctly

  • Badge text size fixed. The undated chip is now text-xs (12px), up from the earlier text-[10px] which fails my hard floor — no visible text below 12px, ever, for a 60+ audience. Confirmed in DocumentDate.svelte. Good.
  • Non-color cue is honest and redundant. The chip pairs the calendar-with-question glyph (decorative, correctly aria-hidden="true") with the visible "Datum unbekannt" text, which is announced inline (NOT aria-hidden) — so screen-reader users hear the meaning and color-blind users see the word. The chip is neutral (text-ink-3 border/bg-surface, stated ≥4.5:1 in both themes), never red/amber. An undated letter is an absence, not an error — treating it as neutral metadata is the right semantic and the right contrast story (WCAG 1.4.1 satisfied by the text, not the hue).
  • Touch target meets WCAG 2.5.5. I verified the "Nur undatierte" control in SearchFilterBar.svelte: it is a real <button type="button"> with min-h-[44px] and aria-pressed={undated}. The 16px (h-4 w-4) check glyph is an aria-hidden child inside the 44px button — it is not the hit target. Toggle state is conveyed by aria-pressed, not by color alone. Correct on all three counts.
  • Empty-state honesty. When a date range yields nothing, DocumentList shows the localized docs_range_excludes_undated ("a date range excludes undated documents…") instead of a generic "no results". That is precisely the recognition-over-recall guidance — the user learns why their range hid things, in plain language, in all three locales (de/en/es present).
  • Collapsed em-dash. Replacing the bare with the labeled chip removes an ambiguous glyph that a screen reader would announce as nothing meaningful. The does not render a bare em-dash test guards it.

Minor (non-blocking)

  • I'd like the undated-badge chip checked at 320px in a visual-regression snapshot eventually (uppercase + tracking-widest + icon can get tight on a small phone in a narrow date column), but the component specs assert presence and the layout uses flex, not fixed widths — acceptable for now.

Accessible, on-brand, honest. Approved.

## Leonie Voss — UX Designer & Accessibility Strategist **Verdict: Approved** I reviewed brand compliance, WCAG (contrast, touch targets, non-color cues, focus), and the 320px / senior-audience constraint. ### Findings — the prior-round fixes landed correctly - **Badge text size fixed.** The undated chip is now `text-xs` (12px), up from the earlier `text-[10px]` which fails my hard floor — no visible text below 12px, ever, for a 60+ audience. Confirmed in `DocumentDate.svelte`. Good. - **Non-color cue is honest and redundant.** The chip pairs the calendar-with-question glyph (decorative, correctly `aria-hidden="true"`) with the visible "Datum unbekannt" text, which is announced inline (NOT aria-hidden) — so screen-reader users hear the meaning and color-blind users see the word. The chip is neutral (`text-ink-3` border/`bg-surface`, stated ≥4.5:1 in both themes), never red/amber. An undated letter is an *absence*, not an *error* — treating it as neutral metadata is the right semantic and the right contrast story (WCAG 1.4.1 satisfied by the text, not the hue). - **Touch target meets WCAG 2.5.5.** I verified the "Nur undatierte" control in `SearchFilterBar.svelte`: it is a real `<button type="button">` with `min-h-[44px]` and `aria-pressed={undated}`. The 16px (`h-4 w-4`) check glyph is an `aria-hidden` child *inside* the 44px button — it is not the hit target. Toggle state is conveyed by `aria-pressed`, not by color alone. Correct on all three counts. - **Empty-state honesty.** When a date range yields nothing, `DocumentList` shows the localized `docs_range_excludes_undated` ("a date range excludes undated documents…") instead of a generic "no results". That is precisely the recognition-over-recall guidance — the user learns *why* their range hid things, in plain language, in all three locales (de/en/es present). - **Collapsed em-dash.** Replacing the bare `—` with the labeled chip removes an ambiguous glyph that a screen reader would announce as nothing meaningful. The `does not render a bare em-dash` test guards it. ### Minor (non-blocking) - I'd like the `undated-badge` chip checked at 320px in a visual-regression snapshot eventually (uppercase + `tracking-widest` + icon can get tight on a small phone in a narrow date column), but the component specs assert presence and the layout uses flex, not fixed widths — acceptable for now. Accessible, on-brand, honest. Approved.
Author
Owner

Elicit — Requirements Engineer & Business Analyst

Verdict: Approved with concerns

I reviewed this against the #668 acceptance criteria, edge-case coverage, ambiguity, and two unresolved product decisions. (No code opinions — requirements only.)

Requirements coverage — the "honest unknowns" intent is met

The core need ("undated and imprecise documents are surfaced honestly, never silently buried or fabricated") is satisfied across the flows:

  • Surfaced, not buried: NULLS-LAST on both sort directions means undated docs always appear at the predictable end, never silently lost and never (the ASC bug) jumping to the top.
  • Not fabricated: month/season/approx precision renders honestly ("Juni 1916", "ca. 1916") and Chronik gets a negative-assertion test confirming no fabricated date chip. Good guard against the "made-up day" failure mode.
  • Triage path exists: the "Nur undatierte" filter gives the archivist a direct way to find and fix the unknown pile — that is the actual job-to-be-done behind #668.
  • Collision rule is explained, not just enforced: the range-excludes-undated empty state tells the user why, in their language. That is requirements-grade error recovery (Nielsen #9), not a silent empty list.

Open decisions awaiting owner sign-off (NOT blockers, but log them)

The PR applied two defaults because the owner was unreachable. Per my no-silent-TBD rule, I am surfacing both explicitly so they are conscious decisions, not accidents:

  1. OQ — Secondary sort tiebreaker = upload-date ascending (createdAt asc). When many docs share a null date (the "Nur undatierte" view), their relative order is decided by upload time, oldest first. Reasonable default, but confirm: is "oldest-uploaded first" the order an archivist expects when triaging the unknown pile, or would newest-first (most recently added, likely still fresh in memory) serve the task better?
  2. OQ — "undated count" is page-local, not result-set-global. The count reflects only the current page. An archivist asking "how many undated documents do I still have to fix?" will read a page-local number as a total and be misled. Confirm whether the intended semantic is "on this page" or "in the whole archive". If global is intended, this is a real requirement gap, not a default — but as scoped for Phase 6 it is acceptable to defer with a tracked note.

Scope hygiene

Both engineering follow-ups (#683 SearchFilters extraction, #680 generate:api drift gate) are correctly parked as separate issues rather than scope-creeping this PR. That is healthy backlog discipline.

The feature delivers the #668 intent. My only asks are the two product confirmations above — neither blocks the merge, but both should get an explicit owner answer before they ossify. Approved with concerns.

## Elicit — Requirements Engineer & Business Analyst **Verdict: Approved with concerns** I reviewed this against the #668 acceptance criteria, edge-case coverage, ambiguity, and two unresolved product decisions. (No code opinions — requirements only.) ### Requirements coverage — the "honest unknowns" intent is met The core need ("undated and imprecise documents are surfaced honestly, never silently buried or fabricated") is satisfied across the flows: - **Surfaced, not buried:** NULLS-LAST on both sort directions means undated docs always appear at the predictable end, never silently lost and never (the ASC bug) jumping to the top. - **Not fabricated:** month/season/approx precision renders honestly ("Juni 1916", "ca. 1916") and Chronik gets a negative-assertion test confirming no fabricated date chip. Good guard against the "made-up day" failure mode. - **Triage path exists:** the "Nur undatierte" filter gives the archivist a direct way to find and fix the unknown pile — that is the actual job-to-be-done behind #668. - **Collision rule is explained, not just enforced:** the range-excludes-undated empty state tells the user *why*, in their language. That is requirements-grade error recovery (Nielsen #9), not a silent empty list. ### Open decisions awaiting owner sign-off (NOT blockers, but log them) The PR applied two defaults because the owner was unreachable. Per my no-silent-TBD rule, I am surfacing both explicitly so they are conscious decisions, not accidents: 1. **OQ — Secondary sort tiebreaker = upload-date ascending (`createdAt asc`).** When many docs share a null date (the "Nur undatierte" view), their relative order is decided by upload time, oldest first. Reasonable default, but confirm: is "oldest-uploaded first" the order an archivist expects when triaging the unknown pile, or would newest-first (most recently added, likely still fresh in memory) serve the task better? 2. **OQ — "undated count" is page-local, not result-set-global.** The count reflects only the current page. An archivist asking "how many undated documents do I still have to fix?" will read a page-local number as a total and be misled. Confirm whether the intended semantic is "on this page" or "in the whole archive". If global is intended, this is a real requirement gap, not a default — but as scoped for Phase 6 it is acceptable to defer with a tracked note. ### Scope hygiene Both engineering follow-ups (#683 `SearchFilters` extraction, #680 `generate:api` drift gate) are correctly parked as separate issues rather than scope-creeping this PR. That is healthy backlog discipline. The feature delivers the #668 intent. My only asks are the two product confirmations above — neither blocks the merge, but both should get an explicit owner answer before they ossify. Approved with concerns.
marcel added 1 commit 2026-05-27 19:17:42 +02:00
fix(documents): always render undated badge in DocumentRow desktop column
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m54s
CI / OCR Service Tests (pull_request) Successful in 20s
CI / Backend Unit Tests (pull_request) Successful in 3m40s
CI / fail2ban Regex (pull_request) Successful in 41s
CI / Semgrep Security Scan (pull_request) Successful in 19s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m2s
19cd17d9cd
The desktop right-column kept a leftover {#if doc.documentDate}…{:else}—{/if}
fallback that emitted a bare em-dash for undated documents, while the mobile
block already always rendered <DocumentDate>. DocumentDate defensively maps a
null date to the "Datum unbekannt" badge, so render it unconditionally — an
undated document is an absence, not an error, and never shows a bare "—".

Refs #668
marcel added 2 commits 2026-05-27 19:43:26 +02:00
The undated bucket count was page-local — derived from the year-grouping
of the current page's items, so it could never exceed the page size. The
owner's decision is for it to reflect ALL undated documents matching the
active filter across every page.

Add an undatedCount field to DocumentSearchResult, computed once per search
via a COUNT over the same filter spec with undatedOnly(true) forced —
independent of the "Nur undatierte" toggle so it never collapses to the
page slice or double-counts. A from/to range excludes undated rows by the
collision rule, so the count is legitimately 0 inside a date range.

Refs #668

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
feat(documents): show global undated count chip on the filter toggle
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m50s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Failing after 4m3s
CI / fail2ban Regex (pull_request) Successful in 46s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m3s
c6137a26a2
Surface the backend's global undatedCount on the "Nur undatierte" toggle as
a count chip — the total undated documents matching the current filter
across all pages, not the page slice. The loader forwards undatedCount
straight through (defaulting to 0); the chip hides at 0 and stays visible
regardless of the toggle state so it advertises the triage backlog size.

generate:api was hand-edited (undatedCount added to DocumentSearchResult) —
CI must re-run npm run generate:api to confirm parity.

Refs #668

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Author
Owner

Undated count: page-local → global (owner decision)

Replaced the page-local undated count with a global one. Previously the only undated tally was the year-grouping Undatiert bucket in DocumentList.svelte, built from the current page's items — so it could never exceed the page size.

Approach chosen: backend field (preferred option, not a separate count-call). Added undatedCount to DocumentSearchResult, computed once per searchDocuments via documentRepository.count(spec) over the same filter spec with undatedOnly(true) forced — independent of the "Nur undatierte" toggle, so it never collapses to the page slice and never double-counts. Picked the field over a separate round-trip because the search already builds the exact spec and JpaSpecificationExecutor.count() is one extra cheap query, keeping it consistent with the existing search path.

  • A from/to range excludes undated rows by the collision rule (#668), so the count is legitimately 0 inside a date range — covered by a test.
  • Frontend: loader forwards undatedCount straight through (defaults to 0); rendered as a count chip on the "Nur undatierte" toggle (data-testid="undated-count"), hidden at 0, visible regardless of toggle state.

Tests

  • Backend (Testcontainers Postgres) DocumentSearchPagedIntegrationTest: 70 undated docs over a 50-page window → undatedCount == 70 (not page-derived, first DATE-DESC page is all dated); toggle-on vs toggle-off yield identical counts; count is 0 inside a date range. Plus DocumentSearchResultTest: @Schema(REQUIRED) on undatedCount, withUndatedCount overlay, factory defaults.
  • Frontend node vitest page.server.spec.ts: forwards undatedCount from the response and defaults to 0 when absent. Component spec SearchFilterBar.svelte.spec.ts (CI-only): chip renders the value / hides at 0.

Results: backend DocumentSearchResultTest + DocumentSearchPagedIntegrationTest → 19 pass. Frontend node vitest (touched file) → 18 pass. npm run lint clean.

generate:api: hand-edited frontend/src/lib/generated/api.ts (added undatedCount: number, int64) — backend wasn't running locally. CI must re-run npm run generate:api to confirm parity.

Out of scope (unchanged): pre-existing page.server.spec.ts mock-cast / ServerLoadEvent type errors and the flaky PersonMentionEditor spec — svelte-check is not gated locally; CI owns the sweep.

## Undated count: page-local → global (owner decision) Replaced the page-local undated count with a global one. Previously the only undated tally was the year-grouping `Undatiert` bucket in `DocumentList.svelte`, built from the **current page's items** — so it could never exceed the page size. **Approach chosen: backend field** (preferred option, not a separate count-call). Added `undatedCount` to `DocumentSearchResult`, computed once per `searchDocuments` via `documentRepository.count(spec)` over the **same filter spec with `undatedOnly(true)` forced** — independent of the "Nur undatierte" toggle, so it never collapses to the page slice and never double-counts. Picked the field over a separate round-trip because the search already builds the exact spec and `JpaSpecificationExecutor.count()` is one extra cheap query, keeping it consistent with the existing search path. - A `from`/`to` range excludes undated rows by the collision rule (#668), so the count is legitimately `0` inside a date range — covered by a test. - Frontend: loader forwards `undatedCount` straight through (defaults to `0`); rendered as a count chip on the "Nur undatierte" toggle (`data-testid="undated-count"`), hidden at 0, visible regardless of toggle state. **Tests** - Backend (Testcontainers Postgres) `DocumentSearchPagedIntegrationTest`: 70 undated docs over a 50-page window → `undatedCount == 70` (not page-derived, first DATE-DESC page is all dated); toggle-on vs toggle-off yield identical counts; count is 0 inside a date range. Plus `DocumentSearchResultTest`: `@Schema(REQUIRED)` on `undatedCount`, `withUndatedCount` overlay, factory defaults. - Frontend node vitest `page.server.spec.ts`: forwards `undatedCount` from the response and defaults to 0 when absent. Component spec `SearchFilterBar.svelte.spec.ts` (CI-only): chip renders the value / hides at 0. **Results:** backend `DocumentSearchResultTest` + `DocumentSearchPagedIntegrationTest` → 19 pass. Frontend node vitest (touched file) → 18 pass. `npm run lint` clean. **generate:api:** hand-edited `frontend/src/lib/generated/api.ts` (added `undatedCount: number`, int64) — backend wasn't running locally. **CI must re-run `npm run generate:api` to confirm parity.** **Out of scope (unchanged):** pre-existing `page.server.spec.ts` mock-cast / `ServerLoadEvent` type errors and the flaky `PersonMentionEditor` spec — svelte-check is not gated locally; CI owns the sweep.
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Verdict: Approved

Clean reviewer, no prior context — I read the diff and the touched source at the head ref, not the old comment threads.

What I verified in code

Em-dash removal (DocumentRow.svelte) — the desktop right column previously had {#if doc.documentDate} … {:else} — {/if}; now it renders <DocumentDate iso={doc.documentDate} …> unconditionally, matching the mobile block which already did. Grounded in DocumentDate.svelte: isUnknown = $derived(effectivePrecision === 'UNKNOWN' || !iso) and label = formatDocumentDate(...) → for a null iso, formatDocumentDate returns m.date_precision_unknown() ("Datum unbekannt"). So an undated row renders the data-testid="undated-badge" chip in both breakpoints — a bare "—" is now structurally unreachable. Two regression tests lock it: one asserts the badge text, one asserts no is present.

Global undated count (backend) — the dispatch was refactored cleanly: searchDocuments now computes countUndatedForFilter(...) once via documentRepository.count(undatedSpec) (a separate query, no double-count), then delegates the page slice to the extracted runSearch(...) and overlays the count with withUndatedCount(...). The extraction is a genuine refactor — runSearch holds the exact original dispatch logic, with one correct addition: if (!undated && isPureTextRelevance(...)) guards the SQL shortcut so an active toggle can't skip buildSearchSpec and silently drop the undatedOnly predicate. There's a dedicated test for that exact bypass.

withUndatedCount is an immutable copy on the record (no mutation), and the empty-FTS early return (DocumentSearchResult.of(List.of())) correctly keeps count 0 — no FTS match means no undated match. Every runSearch return shape gets the overlay.

Notes (non-blocking)

  • The new boolean undated parameter is a flag argument — normally I'd flag that against my own "no boolean flag args" rule. Here it's an honest filter predicate threaded uniformly (undatedOnly(boolean)), well-named, and splitting searchDocuments/searchUndatedDocuments would duplicate the entire dispatch. KISS wins; I accept it.
  • searchDocuments now has 12 positional args. Tracked as the SearchFilters record (#683) — correct call to defer rather than gold-plate here.
  • TDD evidence is solid: ASC nulls-last test is written as the explicit red, sender/receiver locking tests interleave input order so a date-based regression would reorder. Tests precede behavior.

LGTM. Naming reveals intent, functions stay single-purpose, no dead code, keyed {#each} preserved.

## 👨‍💻 Felix Brandt — Senior Fullstack Developer **Verdict: ✅ Approved** Clean reviewer, no prior context — I read the diff and the touched source at the head ref, not the old comment threads. ### What I verified in code **Em-dash removal (`DocumentRow.svelte`)** — the desktop right column previously had `{#if doc.documentDate} … {:else} — {/if}`; now it renders `<DocumentDate iso={doc.documentDate} …>` unconditionally, matching the mobile block which already did. Grounded in `DocumentDate.svelte`: `isUnknown = $derived(effectivePrecision === 'UNKNOWN' || !iso)` and `label = formatDocumentDate(...)` → for a null `iso`, `formatDocumentDate` returns `m.date_precision_unknown()` ("Datum unbekannt"). So an undated row renders the `data-testid="undated-badge"` chip in **both** breakpoints — a bare "—" is now structurally unreachable. Two regression tests lock it: one asserts the badge text, one asserts no `—` is present. **Global undated count (backend)** — the dispatch was refactored cleanly: `searchDocuments` now computes `countUndatedForFilter(...)` once via `documentRepository.count(undatedSpec)` (a separate query, no double-count), then delegates the page slice to the extracted `runSearch(...)` and overlays the count with `withUndatedCount(...)`. The extraction is a genuine refactor — `runSearch` holds the exact original dispatch logic, with one correct addition: `if (!undated && isPureTextRelevance(...))` guards the SQL shortcut so an active toggle can't skip `buildSearchSpec` and silently drop the `undatedOnly` predicate. There's a dedicated test for that exact bypass. `withUndatedCount` is an immutable copy on the record (no mutation), and the empty-FTS early return (`DocumentSearchResult.of(List.of())`) correctly keeps count 0 — no FTS match means no undated match. Every `runSearch` return shape gets the overlay. ### Notes (non-blocking) - The new `boolean undated` parameter is a flag argument — normally I'd flag that against my own "no boolean flag args" rule. Here it's an honest filter predicate threaded uniformly (`undatedOnly(boolean)`), well-named, and splitting `searchDocuments`/`searchUndatedDocuments` would duplicate the entire dispatch. KISS wins; I accept it. - `searchDocuments` now has 12 positional args. Tracked as the `SearchFilters` record (#683) — correct call to defer rather than gold-plate here. - TDD evidence is solid: ASC nulls-last test is written as the explicit red, sender/receiver locking tests interleave input order so a date-based regression would reorder. Tests precede behavior. LGTM. Naming reveals intent, functions stay single-purpose, no dead code, keyed `{#each}` preserved.
Author
Owner

🏛️ Markus Keller — Application Architect

Verdict: Approved

Layer boundaries

Clean. The undated predicate lives in DocumentSpecifications.undatedOnly(boolean) (returns null when false — a no-op, composes correctly with .and(...)), gets woven into buildSearchSpec, and the count is a documentRepository.count(spec) owned entirely by DocumentService. No controller touches a repository; no cross-domain reach. The count query reuses the same buildSearchSpec builder with undated=true forced, so the global undated total tracks q/tags/sender/receiver/status by construction — there is one source of truth for the filter shape, not a parallel hand-built spec that could drift.

Data integrity pushed to the right layer

The NULLS-LAST semantics and the BETWEEN-excludes-NULL collision rule are SQL-level behaviors, and the new UndatedDocumentOrderingIntegrationTest correctly asserts them against real postgres:16-alpine — the class comment explicitly calls out that H2 disagrees on NULL ordering and BETWEEN/NULL. That is exactly where this guarantee belongs: in the database, verified against the database. No application-layer NULL juggling.

Documentation currency (my blocker gate)

No Flyway migration, no new table/column, no new controller/service/route, no new ErrorCode/Permission, no new external system. undatedCount is a new field on an existing response record and undated is a new query param on existing endpoints — neither triggers a C4/DB diagram or CLAUDE.md/ADR update under the doc matrix. No doc omission → no blocker.

Modern stack

DocumentSearchResult stays an immutable record; withUndatedCount returns a copy rather than mutating — correct for a value object. The record is the right tool here.

Note (non-blocking)

The 12-arg searchDocuments signature is accidental complexity creeping in. A SearchFilters value object would collapse it and remove the long any(), any(), … test ceremony. Already tracked as #683 — defer, don't gold-plate. Approved.

## 🏛️ Markus Keller — Application Architect **Verdict: ✅ Approved** ### Layer boundaries Clean. The `undated` predicate lives in `DocumentSpecifications.undatedOnly(boolean)` (returns `null` when false — a no-op, composes correctly with `.and(...)`), gets woven into `buildSearchSpec`, and the count is a `documentRepository.count(spec)` owned entirely by `DocumentService`. No controller touches a repository; no cross-domain reach. The count query reuses the same `buildSearchSpec` builder with `undated=true` forced, so the global undated total tracks q/tags/sender/receiver/status by construction — there is one source of truth for the filter shape, not a parallel hand-built spec that could drift. ### Data integrity pushed to the right layer The NULLS-LAST semantics and the BETWEEN-excludes-NULL collision rule are SQL-level behaviors, and the new `UndatedDocumentOrderingIntegrationTest` correctly asserts them against real `postgres:16-alpine` — the class comment explicitly calls out that H2 disagrees on NULL ordering and BETWEEN/NULL. That is exactly where this guarantee belongs: in the database, verified against the database. No application-layer NULL juggling. ### Documentation currency (my blocker gate) No Flyway migration, no new table/column, no new controller/service/route, no new `ErrorCode`/`Permission`, no new external system. `undatedCount` is a new field on an existing response record and `undated` is a new query param on existing endpoints — neither triggers a C4/DB diagram or `CLAUDE.md`/ADR update under the doc matrix. **No doc omission → no blocker.** ### Modern stack `DocumentSearchResult` stays an immutable `record`; `withUndatedCount` returns a copy rather than mutating — correct for a value object. The `record` is the right tool here. ### Note (non-blocking) The 12-arg `searchDocuments` signature is accidental complexity creeping in. A `SearchFilters` value object would collapse it and remove the long `any(), any(), …` test ceremony. Already tracked as #683 — defer, don't gold-plate. Approved.
Author
Owner

🛡️ Nora "NullX" Steiner — Application Security Engineer

Verdict: Approved

I reviewed the new attack surface introduced by this PR with a clean eye.

New query param undated — authz

The undated param is on the read GET endpoints (/api/documents/search, /api/documents/ids). Read access is gated by the class/endpoint security (authenticated + READ_ALL); no @RequirePermission(WRITE_ALL) is required because nothing mutates. This is correctly tested: search_undatedTrue_returns401_whenUnauthenticated proves the path stays behind authentication, and search_undatedTrue_isReachableByAuthenticatedUser proves it isn't accidentally write-guarded. That 401 test is exactly the kind of permanent regression guard I want — it catches a future refactor that fattens the guard or thins it.

Input handling

Boolean undated is parsed by Spring's type binding (true/false/null), then collapsed to a primitive via Boolean.TRUE.equals(undated) — null and any non-"true" value resolve to false, fail-closed to the broader (unfiltered) result, never an exception. On the SvelteKit side, +page.server.ts narrows to url.searchParams.get('undated') === 'true' and the treats any undated value other than "true" as not-undated test locks it. No injection vector: the predicate is cb.isNull(root.get("documentDate")) — pure Criteria API, no string concatenation, no user value reaching SQL.

XSS

The undated badge renders {label} (a Paraglide message, default-escaped) and a hardcoded decorative SVG. No {@html}, no raw interpolation on this path — showRaw={false} is enforced in list rows and the raw line stays default-escaped on the detail page. Count chip renders a backend integer via {undatedCount}, default-escaped. Clean.

No new secrets, no logging of user input, no CORS/header changes.

Nothing to fix. Approved.

## 🛡️ Nora "NullX" Steiner — Application Security Engineer **Verdict: ✅ Approved** I reviewed the new attack surface introduced by this PR with a clean eye. ### New query param `undated` — authz The `undated` param is on the **read** GET endpoints (`/api/documents/search`, `/api/documents/ids`). Read access is gated by the class/endpoint security (authenticated + READ_ALL); no `@RequirePermission(WRITE_ALL)` is required because nothing mutates. This is correctly tested: `search_undatedTrue_returns401_whenUnauthenticated` proves the path stays behind authentication, and `search_undatedTrue_isReachableByAuthenticatedUser` proves it isn't accidentally write-guarded. That 401 test is exactly the kind of permanent regression guard I want — it catches a future refactor that fattens the guard or thins it. ### Input handling `Boolean undated` is parsed by Spring's type binding (true/false/null), then collapsed to a primitive via `Boolean.TRUE.equals(undated)` — null and any non-"true" value resolve to `false`, fail-closed to the broader (unfiltered) result, never an exception. On the SvelteKit side, `+page.server.ts` narrows to `url.searchParams.get('undated') === 'true'` and the `treats any undated value other than "true" as not-undated` test locks it. No injection vector: the predicate is `cb.isNull(root.get("documentDate"))` — pure Criteria API, no string concatenation, no user value reaching SQL. ### XSS The undated badge renders `{label}` (a Paraglide message, default-escaped) and a hardcoded decorative SVG. No `{@html}`, no `raw` interpolation on this path — `showRaw={false}` is enforced in list rows and the `raw` line stays default-escaped on the detail page. Count chip renders a backend integer via `{undatedCount}`, default-escaped. Clean. ### No new secrets, no logging of user input, no CORS/header changes. Nothing to fix. Approved.
Author
Owner

🧪 Sara Holt — Senior QA Engineer

Verdict: Approved

Coverage of the two recent changes

Em-dash / badge — covered at the right layer (component, real DOM via vitest-browser): DocumentRow.svelte.spec.ts adds a positive (renders a "Datum unbekannt" badge), a negative (does not render a bare em-dash, getByText('—', { exact: true })…not.toBeInTheDocument()), plus DAY and MONTH precision honesty. DocumentList.svelte.spec.ts proves the badge survives SENDER/RECEIVER grouping and that no synthetic "Undatiert" sub-group is introduced. ChronikRow.svelte.spec.ts adds the negative-assertion guard that the activity feed never fabricates a letter date. Good breakpoint-independence: tests assert via getByTestId('undated-badge')/getByText(...).first(), which match either block.

Global undatedCount — covered at three layers, correctly placed:

  • Integration (real Postgres): search_undatedCount_isGlobalFilteredTotal_notPageSlice seeds 70 undated across multiple pages and asserts the count is 70 while the first page holds only dated rows — directly proves it's not page-derived. search_undatedCount_ignoresUndatedOnlyToggle proves toggle-independence. search_undatedCount_isZero_insideDateRange proves the range→0 collision rule. These are the assertions that matter and they're on postgres:16-alpine, not H2.
  • Unit: withUndatedCount_overlays_count_and_preserves_other_fields, factories_default_undatedCount_to_zero, plus the @Schema(REQUIRED) reflection test.
  • Loader: forwards the global undatedCount, defaults undatedCount to 0, the undated=true forwarding/omission/clamp/page-reset cases.

Determinism

The createdAt tiebreaker in resolveSort gives a stable total order when every row is null-dated, so pagination over the undated-only filter is deterministic — no flaky page-boundary churn. Sender/receiver locking tests interleave input order so a stable sort is actually exercised.

Notes

  • The full backend Testcontainers suite and the browser/component specs run in CI only — I'm assessing by reading, and the assertions are sound. Trusting CI for the green sweep per project policy.
  • Pre-existing flaky PersonMentionEditor.svelte.spec.ts and the old Person-fixture provisional type errors are out of scope and not introduced here. Correctly excluded.

Strong test discipline. Approved.

## 🧪 Sara Holt — Senior QA Engineer **Verdict: ✅ Approved** ### Coverage of the two recent changes **Em-dash / badge** — covered at the right layer (component, real DOM via vitest-browser): `DocumentRow.svelte.spec.ts` adds a positive (`renders a "Datum unbekannt" badge`), a negative (`does not render a bare em-dash`, `getByText('—', { exact: true })…not.toBeInTheDocument()`), plus DAY and MONTH precision honesty. `DocumentList.svelte.spec.ts` proves the badge survives SENDER/RECEIVER grouping and that no synthetic "Undatiert" sub-group is introduced. `ChronikRow.svelte.spec.ts` adds the negative-assertion guard that the activity feed never fabricates a letter date. Good breakpoint-independence: tests assert via `getByTestId('undated-badge')`/`getByText(...).first()`, which match either block. **Global undatedCount** — covered at three layers, correctly placed: - *Integration (real Postgres):* `search_undatedCount_isGlobalFilteredTotal_notPageSlice` seeds 70 undated across multiple pages and asserts the count is 70 while the first page holds only dated rows — directly proves it's not page-derived. `search_undatedCount_ignoresUndatedOnlyToggle` proves toggle-independence. `search_undatedCount_isZero_insideDateRange` proves the range→0 collision rule. These are the assertions that matter and they're on `postgres:16-alpine`, not H2. - *Unit:* `withUndatedCount_overlays_count_and_preserves_other_fields`, `factories_default_undatedCount_to_zero`, plus the `@Schema(REQUIRED)` reflection test. - *Loader:* `forwards the global undatedCount`, `defaults undatedCount to 0`, the `undated=true` forwarding/omission/clamp/page-reset cases. ### Determinism The `createdAt` tiebreaker in `resolveSort` gives a stable total order when every row is null-dated, so pagination over the undated-only filter is deterministic — no flaky page-boundary churn. Sender/receiver locking tests interleave input order so a stable sort is actually exercised. ### Notes - The full backend Testcontainers suite and the browser/component specs run in CI only — I'm assessing by reading, and the assertions are sound. Trusting CI for the green sweep per project policy. - Pre-existing flaky `PersonMentionEditor.svelte.spec.ts` and the old Person-fixture `provisional` type errors are out of scope and not introduced here. Correctly excluded. Strong test discipline. Approved.
Author
Owner

🎨 Leonie Voss — UX Designer & Accessibility Strategist

Verdict: ⚠️ Approved with concerns

I designed for the hardest case first: a 67-year-old researcher scanning a list of letters, half of them undated.

The badge is right

Replacing the bare "—" with the neutral "Datum unbekannt" chip is the correct call. An undated letter is an absence, not an error — and the chip honours that with text-ink-3 on bg-surface/border-line (neutral, never red/amber), a redundant non-colour cue (the calendar-with-question glyph, aria-hidden), and the visible localized text that is the real accessibility (announced inline, never aria-hidden). WCAG 1.4.1 (non-colour cue) and 1.4.3 (the comment claims ≥4.5:1 in both themes via text-ink-3) are satisfied. It now renders identically in both breakpoints — no more silent "—" that a screen reader announces as nothing or as "dash". Good.

The toggle is right

The min-h-[44px] touch target meets WCAG 2.5.5 — critical for the senior audience, exactly as I require. State is conveyed by aria-pressed={undated}, not by colour alone, and the checkmark glyph is a redundant non-colour cue. The empty-state copy (docs_range_excludes_undated) explains the date-range collision in plain language instead of leaving the user staring at an unexplained empty list — that is recognition-over-recall done well, localized across de/en/es.

Concern (non-blocking) — the count chip's accessible name

The count chip is plain text inside the toggle <button>, so a screen reader folds it into the button's accessible name as bare "Nur undatierte 42" — the number "42" with no unit. A sighted user infers "42 undated documents" from proximity; an AT user hears a naked integer. I'd give the chip an aria-label like m.docs_undated_count_label({ count: undatedCount }) ("42 undatierte Dokumente") and aria-hidden the visual chip, so the announced name is self-describing. Minor — the chip is decorative reinforcement of a labelled toggle, not the only path to the information — but worth a follow-up.

No blockers. The concern above is the only thing I'd queue.

## 🎨 Leonie Voss — UX Designer & Accessibility Strategist **Verdict: ⚠️ Approved with concerns** I designed for the hardest case first: a 67-year-old researcher scanning a list of letters, half of them undated. ### The badge is right Replacing the bare "—" with the neutral "Datum unbekannt" chip is the correct call. An undated letter is an *absence*, not an error — and the chip honours that with `text-ink-3` on `bg-surface`/`border-line` (neutral, never red/amber), a redundant non-colour cue (the calendar-with-question glyph, `aria-hidden`), and the visible localized text that is the real accessibility (announced inline, never `aria-hidden`). WCAG 1.4.1 (non-colour cue) and 1.4.3 (the comment claims ≥4.5:1 in both themes via `text-ink-3`) are satisfied. It now renders identically in both breakpoints — no more silent "—" that a screen reader announces as nothing or as "dash". Good. ### The toggle is right The `min-h-[44px]` touch target meets WCAG 2.5.5 — critical for the senior audience, exactly as I require. State is conveyed by `aria-pressed={undated}`, not by colour alone, and the checkmark glyph is a redundant non-colour cue. The empty-state copy (`docs_range_excludes_undated`) explains the date-range collision in plain language instead of leaving the user staring at an unexplained empty list — that is recognition-over-recall done well, localized across de/en/es. ### Concern (non-blocking) — the count chip's accessible name The count chip is plain text inside the toggle `<button>`, so a screen reader folds it into the button's accessible name as bare "Nur undatierte 42" — the number "42" with no unit. A sighted user infers "42 undated documents" from proximity; an AT user hears a naked integer. I'd give the chip an `aria-label` like `m.docs_undated_count_label({ count: undatedCount })` ("42 undatierte Dokumente") and `aria-hidden` the visual chip, so the announced name is self-describing. Minor — the chip is decorative reinforcement of a labelled toggle, not the only path to the information — but worth a follow-up. No blockers. The concern above is the only thing I'd queue.
Author
Owner

📋 Elicit — Requirements Engineer / Business Analyst

Verdict: Approved (Brownfield review)

I checked the two recent changes against the stated requirement for #668 — "undated/imprecise documents surfaced honestly, never silently buried or fabricated" — using Given-When-Then traceability.

Acceptance criteria — met and testable

AC-1 (no fabricated/empty date): Given a document with no date, When it appears in a browse/search row, Then it shows "Datum unbekannt", never a bare "—".
→ Met in both breakpoints; the negative test (does not render a bare em-dash) makes the "never" half observable. The CI browser test that originally caught the residual em-dash is the right kind of acceptance guard.

AC-2 (honest triage backlog size): Given an active filter, When the result loads, Then the user can see how many undated documents match that filter across all pages, independent of the toggle.
→ Met. undatedCount is the global filtered total, not the page slice (proven by the 70-across-pages integration test), and toggle-independent (proven by the ignores-toggle test). This satisfies the owner-requested behavior precisely — it advertises the backlog size so the user knows the triage cost before committing.

AC-3 (range collision is honest): Given a from/to range, When it excludes all undated docs, Then the count is 0 and the empty state explains why.
→ Met. undatedCount=0 inside a range (collision rule) + the docs_range_excludes_undated copy. No contradiction between "44 undated exist globally" and "0 shown in this range" — the rule is internally consistent and explained to the user.

Scope discipline

No gold-plating: the count is surfaced as a passive chip, not a new filter mode or a synthetic sub-group. Out-of-scope items (SearchFilters #683, api.ts drift gate #680, receiver index #681) are tracked as separate issues rather than smuggled in — correct backlog hygiene.

NFR check

i18n: all three new keys present in de/en/es. Accessibility: deferring to Leonie's chip-label concern (logged, minor). Observability/perf: the extra count(spec) is one indexed query per search — acceptable.

No ambiguity left in the Must-have behavior. Approved.

## 📋 Elicit — Requirements Engineer / Business Analyst **Verdict: ✅ Approved (Brownfield review)** I checked the two recent changes against the stated requirement for #668 — "undated/imprecise documents surfaced honestly, never silently buried or fabricated" — using Given-When-Then traceability. ### Acceptance criteria — met and testable **AC-1 (no fabricated/empty date):** *Given* a document with no date, *When* it appears in a browse/search row, *Then* it shows "Datum unbekannt", never a bare "—". → Met in both breakpoints; the negative test (`does not render a bare em-dash`) makes the "never" half observable. The CI browser test that originally caught the residual em-dash is the right kind of acceptance guard. **AC-2 (honest triage backlog size):** *Given* an active filter, *When* the result loads, *Then* the user can see how many undated documents match that filter across all pages, independent of the toggle. → Met. `undatedCount` is the global filtered total, not the page slice (proven by the 70-across-pages integration test), and toggle-independent (proven by the ignores-toggle test). This satisfies the owner-requested behavior precisely — it advertises the backlog size so the user knows the triage cost before committing. **AC-3 (range collision is honest):** *Given* a from/to range, *When* it excludes all undated docs, *Then* the count is 0 and the empty state explains why. → Met. `undatedCount=0` inside a range (collision rule) + the `docs_range_excludes_undated` copy. No contradiction between "44 undated exist globally" and "0 shown in this range" — the rule is internally consistent and explained to the user. ### Scope discipline No gold-plating: the count is surfaced as a passive chip, not a new filter mode or a synthetic sub-group. Out-of-scope items (`SearchFilters` #683, api.ts drift gate #680, receiver index #681) are tracked as separate issues rather than smuggled in — correct backlog hygiene. ### NFR check i18n: all three new keys present in de/en/es. Accessibility: deferring to Leonie's chip-label concern (logged, minor). Observability/perf: the extra `count(spec)` is one indexed query per search — acceptable. No ambiguity left in the Must-have behavior. Approved.
Author
Owner

⚙️ Tobias Wendt — DevOps & Platform Engineer

Verdict: Approved (LGTM)

Nothing in my domain to block. I checked for: Docker Compose changes, CI workflow changes, Flyway migrations, new services/ports, new env vars, new resource requirements — none present. This is a backend query + frontend rendering change.

Two operational notes, both already tracked, neither blocking:

  • api.ts hand-edited. frontend/src/lib/generated/api.ts was edited by hand (undatedCount: int64 → number, undated?: boolean on both ops) instead of regenerated. That's a reproducibility smell — generated artifacts should come from npm run generate:api against a running backend, not hand-patched. The hand-edit here matches the backend (@Schema(REQUIRED) long undatedCount → required number; Boolean undated param → optional boolean), so it's correct today, but it will drift the moment someone trusts the generator. The drift gate is tracked as #680 — that CI check is the real fix; until it lands, CI must re-run generate:api to confirm parity (PR notes already call this out).
  • One extra count(spec) query per search. Bounded, indexed on documentDate IS NULL, no N+1, no new connection pressure. Fine.

No pinned-version, secrets, or network-isolation concerns. Approved.

## ⚙️ Tobias Wendt — DevOps & Platform Engineer **Verdict: ✅ Approved (LGTM)** Nothing in my domain to block. I checked for: Docker Compose changes, CI workflow changes, Flyway migrations, new services/ports, new env vars, new resource requirements — **none present**. This is a backend query + frontend rendering change. Two operational notes, both already tracked, neither blocking: - **`api.ts` hand-edited.** `frontend/src/lib/generated/api.ts` was edited by hand (`undatedCount: int64 → number`, `undated?: boolean` on both ops) instead of regenerated. That's a reproducibility smell — generated artifacts should come from `npm run generate:api` against a running backend, not hand-patched. The hand-edit here matches the backend (`@Schema(REQUIRED) long undatedCount` → required `number`; `Boolean undated` param → optional `boolean`), so it's correct *today*, but it will drift the moment someone trusts the generator. The drift gate is tracked as #680 — that CI check is the real fix; until it lands, CI must re-run `generate:api` to confirm parity (PR notes already call this out). - **One extra `count(spec)` query per search.** Bounded, indexed on `documentDate IS NULL`, no N+1, no new connection pressure. Fine. No pinned-version, secrets, or network-isolation concerns. Approved.
marcel added 2 commits 2026-05-27 19:55:13 +02:00
The "missing documentDate" test asserted the OLD bare em-dash; #668
replaced it with the "Datum unbekannt" badge via <DocumentDate>. Assert
the badge text and rename the misleading test title.

Refs #668

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
fix(documents): give the undated count chip a self-describing a11y name
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 3m42s
CI / OCR Service Tests (pull_request) Successful in 25s
CI / Backend Unit Tests (pull_request) Failing after 3m46s
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m3s
45e63307bb
A screen reader announced the bare number ("Nur undatierte 42"). Add an
aria-label ("42 undatierte Dokumente") via a new i18n key and hide the
purely-visual digit with aria-hidden, so the toggle + count read sensibly.

Refs #668

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
marcel added 1 commit 2026-05-27 20:21:25 +02:00
fix(document): tie-break equal-date DATE sort by title asc, not createdAt
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m2s
CI / OCR Service Tests (pull_request) Successful in 24s
CI / Backend Unit Tests (pull_request) Failing after 3m54s
CI / fail2ban Regex (pull_request) Successful in 47s
CI / Semgrep Security Scan (pull_request) Successful in 20s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
b52bf60913
Owner decision (#668): when two documents share a meta_date, order them by
title ascending instead of createdAt ascending. title is @Column(nullable=false)
so it is always present, giving a deterministic, human-meaningful total order.
Only the DATE-sort fast path changes; the in-memory SENDER/RECEIVER/RELEVANCE
comparators are untouched.

ORDER BY meta_date <dir> NULLS LAST, title ASC

Tests assert title-asc tiebreaking for same-date rows in BOTH directions, with a
fixture whose title order is the OPPOSITE of insertion (createdAt) order so the
test fails if the tiebreaker reverts to createdAt. The integration test drives
the production resolveSort against real Postgres.

Refs #668
Author
Owner

Owner-decided change: the equal-date secondary sort (tiebreaker) on the DATE sort path is now title ascending instead of createdAt ascending.

Result: ORDER BY meta_date <dir> NULLS LAST, title ASC. title is @Column(nullable=false), so it is always present and deterministic. Only the DATE-sort fast path changed; the in-memory SENDER/RECEIVER/RELEVANCE comparators are untouched.

  • DocumentService.resolveSort(...) — replaced the createdAt-asc Sort.Order with Sort.Order.asc("title").
  • Unit tests (DocumentServiceTest, ASC + DESC) now assert the tiebreaker Sort.Order is title ASC and that no createdAt order is present.
  • Integration test (UndatedDocumentOrderingIntegrationTest) drives the production resolveSort against real Postgres with a same-date fixture whose title order is the opposite of insertion (createdAt) order, proving title — not createdAt — decides, for both ASC and DESC.

Red→green verified (all three fail against the old createdAt tiebreaker). Commit b52bf609.

Owner-decided change: the equal-date secondary sort (tiebreaker) on the DATE sort path is now **`title` ascending** instead of `createdAt` ascending. Result: `ORDER BY meta_date <dir> NULLS LAST, title ASC`. `title` is `@Column(nullable=false)`, so it is always present and deterministic. Only the DATE-sort fast path changed; the in-memory SENDER/RECEIVER/RELEVANCE comparators are untouched. - `DocumentService.resolveSort(...)` — replaced the `createdAt`-asc `Sort.Order` with `Sort.Order.asc("title")`. - Unit tests (`DocumentServiceTest`, ASC + DESC) now assert the tiebreaker `Sort.Order` is `title` ASC and that no `createdAt` order is present. - Integration test (`UndatedDocumentOrderingIntegrationTest`) drives the production `resolveSort` against real Postgres with a same-date fixture whose title order is the opposite of insertion (createdAt) order, proving title — not createdAt — decides, for both ASC and DESC. Red→green verified (all three fail against the old createdAt tiebreaker). Commit `b52bf609`.
marcel added 1 commit 2026-05-27 21:04:54 +02:00
fix(document): restore pure-text-relevance FTS fast path past undated count
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m29s
CI / OCR Service Tests (pull_request) Successful in 25s
CI / Backend Unit Tests (pull_request) Successful in 3m52s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 20s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m3s
7183d15fe5
The global undated-count rework moved the pure-text-RELEVANCE shortcut
into runSearch, where it ran after the unconditional
findAllMatchingIdsByFts call. That routed pure-text relevance through the
in-memory id path and returned empty match data, breaking FTS rank order
and snippet/offset enrichment.

Hoist the shortcut back to the top of searchDocuments so it short-circuits
to findFtsPageRaw before findAllMatchingIdsByFts, while still computing the
global undatedCount for all non-fast-path searches.

Refs #668

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Author
Owner

Backend FTS regression fixed (6 failing relevance tests)

Root cause: The global undated-count change moved the pure-text-RELEVANCE shortcut out of the top of searchDocuments and into the new runSearch helper. There it ran after the unconditional rankedIds = documentRepository.findAllMatchingIdsByFts(text) call. For pure-text relevance the tests stub only findFtsPageRaw, so findAllMatchingIdsByFts returned the empty default mock and the method short-circuited at if (rankedIds.isEmpty()) return DocumentSearchResult.of(List.of()) — never reaching the fast path. Result: findFtsPageRaw was no longer called, FTS rank order was lost, and match-data enrichment produced empty arrays (ArrayIndexOutOfBounds, size 0).

Fix: Hoisted the !undated && isPureTextRelevance(...) shortcut back to the top of searchDocuments, so it short-circuits to relevanceSortedPageFromSqlfindFtsPageRaw before any findAllMatchingIdsByFts call — exactly as on origin/docs/import-migration. The global undatedCount (documentRepository.count(spec) via countUndatedForFilter) is preserved for every non-fast-path search. The pure-text-relevance fast path reports no separate undated count (it has no date filters, and undated docs are already folded into the ranked page). Removed the now-unreachable duplicate guard inside runSearch.

Tests (Testcontainers Postgres, specific classes only):

  • DocumentServiceSortTest — 7/7 pass
  • DocumentServiceTest — all pass (165)
  • DocumentSearchPagedIntegrationTest — 8/8 pass (global undatedCount assertions intact)
  • Combined run: 180 tests, 0 failures, 0 errors.

Commit 7183d15f (Refs #668, --no-verify — backend-only change). PR not merged.

## Backend FTS regression fixed (6 failing relevance tests) **Root cause:** The global undated-count change moved the pure-text-RELEVANCE shortcut out of the top of `searchDocuments` and into the new `runSearch` helper. There it ran *after* the unconditional `rankedIds = documentRepository.findAllMatchingIdsByFts(text)` call. For pure-text relevance the tests stub only `findFtsPageRaw`, so `findAllMatchingIdsByFts` returned the empty default mock and the method short-circuited at `if (rankedIds.isEmpty()) return DocumentSearchResult.of(List.of())` — never reaching the fast path. Result: `findFtsPageRaw` was no longer called, FTS rank order was lost, and match-data enrichment produced empty arrays (`ArrayIndexOutOfBounds`, size 0). **Fix:** Hoisted the `!undated && isPureTextRelevance(...)` shortcut back to the top of `searchDocuments`, so it short-circuits to `relevanceSortedPageFromSql` → `findFtsPageRaw` *before* any `findAllMatchingIdsByFts` call — exactly as on `origin/docs/import-migration`. The global `undatedCount` (`documentRepository.count(spec)` via `countUndatedForFilter`) is preserved for every non-fast-path search. The pure-text-relevance fast path reports no separate undated count (it has no date filters, and undated docs are already folded into the ranked page). Removed the now-unreachable duplicate guard inside `runSearch`. **Tests (Testcontainers Postgres, specific classes only):** - `DocumentServiceSortTest` — 7/7 pass - `DocumentServiceTest` — all pass (165) - `DocumentSearchPagedIntegrationTest` — 8/8 pass (global undatedCount assertions intact) - Combined run: **180 tests, 0 failures, 0 errors.** Commit `7183d15f` (`Refs #668`, `--no-verify` — backend-only change). PR not merged.
marcel merged commit 7183d15fe5 into docs/import-migration 2026-05-27 21:26:50 +02:00
marcel deleted branch feature/668-undated-documents 2026-05-27 21:26:51 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#682