feat(search): auto-select a single direct person match in smart search (#763) #769

Merged
marcel merged 21 commits from feat/issue-763-nl-search-direct-match into main 2026-06-07 08:47:49 +02:00
Owner

Closes #763.

What & why

In NL ("smart") search, NlQueryParserService.resolveNames() resolved person names by substring match and treated any result count > 1 as ambiguous — so "Briefe von Clara Cram" forced the disambiguation picker whenever a Clara Cramer/Crammond coexisted, even though exactly one person is literally Clara Cram. For the 60+ audience that's a needless, confusing click.

This introduces a token / word-boundary, alias-aware notion of a direct match that lives in the Person domain. One direct match auto-selects; multiple direct or partial-only matches show the picker (with case-appropriate copy); no candidates fold into full-text search.

How

  • PersonService.resolveByName(String) → NameMatches(direct, partial) — orchestrates tokenize → cap(8) → fetch pool → classify → cap(10) after classification. Empty-token guard first; PII-free log.debug of the outcome bucket. Read-only transaction keeps lazy nameAliases reachable during classification (ADR-022).
  • Tokenizer — lowercase, split on whitespace/hyphen/apostrophe, drop empties; applied symmetrically to query and every candidate name component (firstName, lastName, alias, each PersonNameAlias first+last, title). title is classification-only.
  • PersonRepository.searchByName extended with a.firstName so a candidate matchable only via an alias first name is fetched (reuses the bound :query; no migration).
  • NlQueryParserService.resolveNames maps NameMatches: 1 direct → resolved (auto-select), ≥2 direct → ambiguous, partial-only → ambiguous suggestions, 0 → full-text. MAX_CANDIDATES moved into PersonService (after classification); MAX_NAME_LENGTH guard, resolved-cap overflow, sender/receiver mapping preserved.
  • Frontend — a 1-item picker now reads "Meintest du …?" in a visible, non-truncated panel heading and drops the "(auswählen…)" cue; ≥2 keeps the "Person auswählen" framing. DisambiguationPicker takes heading + showCue props; the page derives both. New search_disambiguation_did_you_mean key in de/en/es.
  • Docs — GLOSSARY NameMatches entry; person/README public surface. No ADR, no DB-diagram change.

Tests (TDD, red/green per criterion)

  • PersonServiceTest — tokenizer pins + resolveByName classification (direct/partial, maiden alias, alias first name, middle name, reordered, cram-vs-cramer, empty-after-tokenizing, direct-sorts-beyond-cap, ≤8 fetches).
  • PersonRepositoryTest (real Postgres slice) — searchByName by alias first name + ordering.
  • NlQueryParserServiceTest — 23 person mocks re-pointed to resolveByName/NameMatches; new partial-only (1 and 2 candidates) and one-direct cases.
  • DisambiguationPicker.svelte.spec.ts — visible heading + cue suppression.

Backend: 179 affected tests green; mvnw package succeeds. Frontend: picker spec 8/8 green; npm run check adds no new errors over baseline.

Note: based on feat/issue-743-nl-search-tag-resolution (unmerged). Until #743 merges, this PR's diff against main includes #743's commits.

🤖 Generated with Claude Code

Closes #763. ## What & why In NL ("smart") search, `NlQueryParserService.resolveNames()` resolved person names by **substring** match and treated any result count > 1 as ambiguous — so "Briefe von Clara Cram" forced the disambiguation picker whenever a *Clara Cramer*/*Crammond* coexisted, even though exactly one person is literally *Clara Cram*. For the 60+ audience that's a needless, confusing click. This introduces a **token / word-boundary, alias-aware** notion of a *direct* match that lives in the Person domain. One direct match auto-selects; multiple direct or partial-only matches show the picker (with case-appropriate copy); no candidates fold into full-text search. ## How - **`PersonService.resolveByName(String) → NameMatches(direct, partial)`** — orchestrates tokenize → cap(8) → fetch pool → classify → cap(10) after classification. Empty-token guard first; PII-free `log.debug` of the outcome bucket. Read-only transaction keeps lazy `nameAliases` reachable during classification (ADR-022). - **Tokenizer** — lowercase, split on whitespace/hyphen/apostrophe, drop empties; applied symmetrically to query and every candidate name component (`firstName`, `lastName`, `alias`, each `PersonNameAlias` first+last, `title`). `title` is classification-only. - **`PersonRepository.searchByName`** extended with `a.firstName` so a candidate matchable only via an alias first name is fetched (reuses the bound `:query`; no migration). - **`NlQueryParserService.resolveNames`** maps `NameMatches`: 1 direct → resolved (auto-select), ≥2 direct → ambiguous, partial-only → ambiguous suggestions, 0 → full-text. `MAX_CANDIDATES` moved into PersonService (after classification); `MAX_NAME_LENGTH` guard, resolved-cap overflow, sender/receiver mapping preserved. - **Frontend** — a 1-item picker now reads "Meintest du …?" in a visible, non-truncated panel heading and drops the "(auswählen…)" cue; ≥2 keeps the "Person auswählen" framing. `DisambiguationPicker` takes `heading` + `showCue` props; the page derives both. New `search_disambiguation_did_you_mean` key in de/en/es. - **Docs** — GLOSSARY `NameMatches` entry; person/README public surface. No ADR, no DB-diagram change. ## Tests (TDD, red/green per criterion) - `PersonServiceTest` — tokenizer pins + `resolveByName` classification (direct/partial, maiden alias, alias first name, middle name, reordered, cram-vs-cramer, empty-after-tokenizing, direct-sorts-beyond-cap, ≤8 fetches). - `PersonRepositoryTest` (real Postgres slice) — `searchByName` by alias first name + ordering. - `NlQueryParserServiceTest` — 23 person mocks re-pointed to `resolveByName`/`NameMatches`; new partial-only (1 and 2 candidates) and one-direct cases. - `DisambiguationPicker.svelte.spec.ts` — visible heading + cue suppression. Backend: 179 affected tests green; `mvnw package` succeeds. Frontend: picker spec 8/8 green; `npm run check` adds no new errors over baseline. > Note: based on `feat/issue-743-nl-search-tag-resolution` (unmerged). Until #743 merges, this PR's diff against `main` includes #743's commits. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
marcel added 18 commits 2026-06-07 01:18:18 +02:00
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Positional record fields added; all 3 construction sites updated with neutral
defaults; NlQueryParserService wired for TagService (4th constructor arg);
NlQueryParserServiceTest and NlSearchControllerTest synced.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Pre-implementation step for #743: ChipType union extracted from
InterpretationChipRow and +page.svelte into shared chip-types.ts;
resolvedTags/tagsApplied neutral defaults added to test fixtures.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Keywords that substring-match the tag taxonomy become OR-union tag filters;
non-matching keywords stay as FTS text. Resolved tags surface in the
NlQueryInterpretation as TagHint objects with effective colours. The
rawQuery fallback is now guarded by hadStructuredMatch to prevent
double-apply when all keywords resolve.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Covers multi-tag match, no-match FTS fallback, mixed resolution, personRole
bypass, cap at 10, short-keyword skip, dedup, rawQuery suppression when all
keywords resolve, flag independence, colour propagation via resolveEffectiveColors,
and colour=null when depth constraint prevents resolution.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Verifies the recursive CTE in findDescendantIdsByName expands a parent tag
to include all child IDs, and that findByNameContainingIgnoreCase matches
both parent and child names when the fragment appears in both.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Manual update since Docker compose backend runs old build; regenerate with
npm run generate:api once new backend is deployed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When tagsApplied is true, each resolvedTag renders as a 'Thema: Name'
chip with optional inline color style from the tag's resolved color.
Clicking × calls onRemoveChip('theme', tag.name).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
docs(search): ADR-028 fix + glossary + C4 diagram for tag resolution (#743)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m25s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 4m1s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 23s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m7s
64b7b2315d
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Lowercase, split on whitespace/hyphen/apostrophe, drop empties. Applied
symmetrically to query and candidate name components so "Anna-Maria" and
"Anna Maria" tokenize alike. Foundation for resolveByName direct matching.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The direct-match classifier accepts alias firstName tokens, so the fetch must
surface candidates matchable only via an alias first name. Add a.firstName to
the searchByName LIKE clause (reuses the bound :query — injection-proof). The
person_name_aliases.first_name column already exists; no migration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Token-set containment over all of a person's name components (firstName,
lastName, alias, each PersonNameAlias first+last, title) decides direct vs
partial. Orchestrates tokenize → cap(8) → fetch pool → classify → cap(10)
after classification, with an empty-token guard and a PII-free debug log of
the outcome bucket. MAX_TOKENS is a DoS control; the after-classify cap keeps a
direct match that sorts past position 10 among partials. Read-only transaction
keeps lazy nameAliases reachable during classification (ADR-022).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
resolveNames now delegates to PersonService.resolveByName and maps by match
strength: 1 direct → resolved (auto-select), ≥2 direct → ambiguous, 0 direct
with partials → ambiguous suggestions, 0 candidates → folded into full-text.
A single direct match no longer forces the picker when looser substring hits
coexist. The MAX_CANDIDATES cap moved into PersonService (after classification);
the MAX_NAME_LENGTH guard, resolved-cap overflow, and sender/receiver mapping
are preserved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A 1-item picker now reads "Meintest du …?" (a single direct match auto-selects
and never reaches the picker), while ≥2 keeps the "Person auswählen" framing.
The prompt lives in a visible, non-truncated panel heading (the trigger span
clips at 320px), and the "(auswählen…)" cue is dropped for the 1-item case.
DisambiguationPicker takes heading + showCue props; the page derives both from
ambiguousPersons.length. New search_disambiguation_did_you_mean key in de/en/es.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
docs(search): document NameMatches and resolveByName (#763)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m19s
CI / OCR Service Tests (pull_request) Successful in 24s
CI / Backend Unit Tests (pull_request) Successful in 3m48s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 23s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m4s
c0edbd436d
GLOSSARY entry for NameMatches (direct vs partial name-match strength and how
the search layer maps it); person/README adds resolveByName to the public
surface. No ADR — the matching rule is localized and justified inline.

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

🏛️ Markus Keller — Senior Application Architect

Verdict: Approved

I reviewed only the #763 work (the person-domain matching rule, the searchByName extension, the NlQueryParserService.resolveNames mapping, the picker change, and the docs). The #743 tag code is out of scope here.

What I checked

  • Module boundaries — clean. The matching rule lives in the Person domain (PersonService.resolveByNameNameMatches), exactly where the resolved design decision #2 placed it. The search layer maps NameMatches into its own resolved/ambiguous/noMatch vocabulary and never reaches into PersonRepository. NlQueryParserService talks to PersonService only. No boundary leak.
  • Vocabulary disciplineNameMatches(direct, partial) uses name-match-strength terms, not the search layer's resolved/ambiguous. The Javadoc on NameMatches.java documents this deliberately. This is the right call: it keeps the two layers' concepts from bleeding.
  • Push-down of the constraintMAX_TOKENS/MAX_CANDIDATES are co-located in the layer that owns the fetch loop (PersonService), MAX_NAME_LENGTH stays at the search boundary. No duplicated constants. Good.
  • resolveByName shape — a short orchestrator (tokenize → cap → fetchPool → classify → cap-after-classify) with each step a private helper. Reads top-to-bottom. This is the structure I'd have asked for.

Documentation currency (my hard gate)

  • docs/GLOSSARY.mdNameMatches entry added. New domain concept documented.
  • backend/.../person/README.mdresolveByName added to the public-surface table.
  • No new Flyway migration — correct, the person_name_aliases.first_name column already exists; the searchByName change is a query-only OR a.firstName LIKE. No DB-diagram update needed.
  • No new service/controller/routeresolveByName is a new method on an existing service and NameMatches a new value record in an existing package, so per my doc table neither triggers a c4 l3-backend-*.puml change. (The search-domain diagram was already touched by #743's commits.)
  • No ADR — justified. This is a localized matching rule with the rationale captured inline and in GLOSSARY. The read-only transaction reaching lazy nameAliases correctly references the existing ADR-022 rather than minting a new one.

Nothing for me to block. This is a well-bounded change that respects the layering rule.

## 🏛️ Markus Keller — Senior Application Architect **Verdict: ✅ Approved** I reviewed only the #763 work (the person-domain matching rule, the `searchByName` extension, the `NlQueryParserService.resolveNames` mapping, the picker change, and the docs). The #743 tag code is out of scope here. ### What I checked - **Module boundaries** — clean. The matching rule lives in the Person domain (`PersonService.resolveByName` → `NameMatches`), exactly where the resolved design decision #2 placed it. The search layer maps `NameMatches` into its own resolved/ambiguous/noMatch vocabulary and never reaches into `PersonRepository`. `NlQueryParserService` talks to `PersonService` only. No boundary leak. - **Vocabulary discipline** — `NameMatches(direct, partial)` uses name-match-strength terms, not the search layer's resolved/ambiguous. The Javadoc on `NameMatches.java` documents this deliberately. This is the right call: it keeps the two layers' concepts from bleeding. - **Push-down of the constraint** — `MAX_TOKENS`/`MAX_CANDIDATES` are co-located in the layer that owns the fetch loop (`PersonService`), `MAX_NAME_LENGTH` stays at the search boundary. No duplicated constants. Good. - **`resolveByName` shape** — a short orchestrator (`tokenize → cap → fetchPool → classify → cap-after-classify`) with each step a private helper. Reads top-to-bottom. This is the structure I'd have asked for. ### Documentation currency (my hard gate) - `docs/GLOSSARY.md` — `NameMatches` entry added. ✅ New domain concept documented. - `backend/.../person/README.md` — `resolveByName` added to the public-surface table. ✅ - **No new Flyway migration** — correct, the `person_name_aliases.first_name` column already exists; the `searchByName` change is a query-only `OR a.firstName LIKE`. No DB-diagram update needed. - **No new service/controller/route** — `resolveByName` is a new *method* on an existing service and `NameMatches` a new value record in an existing package, so per my doc table neither triggers a c4 `l3-backend-*.puml` change. (The search-domain diagram was already touched by #743's commits.) - **No ADR** — justified. This is a localized matching rule with the rationale captured inline and in GLOSSARY. The read-only transaction reaching lazy `nameAliases` correctly references the existing ADR-022 rather than minting a new one. Nothing for me to block. This is a well-bounded change that respects the layering rule.
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Verdict: Approved

Reviewing only the #763 work. This is clean, TDD-evident code — exactly the kind of change I like to read.

TDD evidence

Every behavior has a test that clearly precedes it: tokenizer pins (Anna-Maria, D'Angelo, Müller, double-space, all-whitespace, null), the direct/partial classification cases, maiden-alias-as-direct, alias-first-name fetch, middle name, reordered tokens, cram-vs-cramer, empty-after-tokenizing (with verify(..., never()).searchByName), direct-sorts-beyond-cap, and the atMost(8) fetch invariant. The frontend picker spec gained heading-visible + cue-suppressed + cue-shown cases. Coverage maps 1:1 to the acceptance criteria.

Clean code

  • resolveByName orchestratortokenize → cap → fetchPool → classify, each helper under 20 lines, guard clause (empty tokens) first. Textbook.
  • Guard clause — the empty-token early return is correct and load-bearing: Set.containsAll(emptySet) returns true, so without it every candidate would be marked direct. Good that there's a test pinning it.
  • tokenize is static and package-private — testable in isolation without a Person, which is why the tokenizer pins are one-liners. Right visibility choice.
  • $derived not $effect in +page.sveltedisambiguationHeading and showDisambiguationCue are both $derived. The comment explaining "a 1-item picker is always a did-you-mean" earns its place (it's a why, not a what).
  • DisambiguationPicker stays dumbheading + showCue are props; the page decides. The {#each persons as person (person.id)} is keyed. The <ul> now has aria-labelledby={headingId} pointing at a real visible <p> — better than the old aria-label.

Minor observations (non-blocking, no change requested)

  • cap() uses subList(0, MAX_CANDIDATES) which returns a view backed by the original ArrayList. It's never mutated afterward and the source list is local, so this is safe — just noting I checked it.
  • personTokens() rebuilds a LinkedHashSet per candidate inside the classify loop. For a pool capped at the fetch level this is fine; no premature optimization warranted.

No blockers. Ship it.

## 👨‍💻 Felix Brandt — Senior Fullstack Developer **Verdict: ✅ Approved** Reviewing only the #763 work. This is clean, TDD-evident code — exactly the kind of change I like to read. ### TDD evidence Every behavior has a test that clearly precedes it: tokenizer pins (`Anna-Maria`, `D'Angelo`, `Müller`, double-space, all-whitespace, null), the direct/partial classification cases, maiden-alias-as-direct, alias-first-name fetch, middle name, reordered tokens, cram-vs-cramer, empty-after-tokenizing (with `verify(..., never()).searchByName`), direct-sorts-beyond-cap, and the `atMost(8)` fetch invariant. The frontend picker spec gained heading-visible + cue-suppressed + cue-shown cases. Coverage maps 1:1 to the acceptance criteria. ### Clean code - **`resolveByName` orchestrator** — `tokenize → cap → fetchPool → classify`, each helper under 20 lines, guard clause (empty tokens) first. Textbook. - **Guard clause** — the empty-token early return is correct and load-bearing: `Set.containsAll(emptySet)` returns `true`, so without it every candidate would be marked direct. Good that there's a test pinning it. - **`tokenize` is `static` and package-private** — testable in isolation without a Person, which is why the tokenizer pins are one-liners. Right visibility choice. - **`$derived` not `$effect`** in `+page.svelte` — `disambiguationHeading` and `showDisambiguationCue` are both `$derived`. The comment explaining "a 1-item picker is always a did-you-mean" earns its place (it's a *why*, not a *what*). - **`DisambiguationPicker` stays dumb** — `heading` + `showCue` are props; the page decides. The `{#each persons as person (person.id)}` is keyed. The `<ul>` now has `aria-labelledby={headingId}` pointing at a real visible `<p>` — better than the old `aria-label`. ### Minor observations (non-blocking, no change requested) - `cap()` uses `subList(0, MAX_CANDIDATES)` which returns a *view* backed by the original `ArrayList`. It's never mutated afterward and the source list is local, so this is safe — just noting I checked it. - `personTokens()` rebuilds a `LinkedHashSet` per candidate inside the classify loop. For a pool capped at the fetch level this is fine; no premature optimization warranted. No blockers. Ship it.
Author
Owner

🔒 Nora Steiner ("NullX") — Application Security Engineer

Verdict: Approved

Reviewing only the #763 surface. No vulnerabilities, no security smells.

What I checked

  • SQL/JPQL injectionPersonRepository.searchByName adds OR LOWER(a.firstName) LIKE LOWER(CONCAT('%', :query, '%')) reusing the same bound :query named parameter. No string concatenation, no new parameter, injection-proof by construction. (CWE-89 N/A.)
  • DoS / unbounded work — this is the part I scrutinize hardest, and it's handled well. MAX_TOKENS = 8 caps the number of unindexed leading-wildcard LIKE scans per name before the fetch loop, and there's a dedicated test (over8Tokens_issuesAtMost8Fetches) asserting atMost(8) actual invocations — the guarantee is about fetches issued, not list size, which is the correct thing to pin. MAX_NAME_LENGTH = 200 remains the first-line guard at the search boundary. Defense in depth, and tested.
  • PII in logslog.debug records only the outcome bucket (direct=1/direct>=2/partial-only/no-match) and the token count, never the raw name or any name component. This is exactly right for a private family archive — no name leaks into Loki. SLF4J parameterized form ("... outcome={} tokens={}") used throughout, so no log-injection risk either.
  • Authorization — unchanged. resolveByName is a read path reached through the already-authenticated /api/search/nl endpoint; no new endpoint, no @RequirePermission gap introduced.
  • Frontend — picker renders heading/displayName as text interpolation ({heading}, {person.displayName}), never innerHTML. No XSS vector. Names originate server-side from the DB, not from raw user input.

No failing-test-first security finding to raise — the DoS invariant the issue called out is already codified as a permanent regression test. Clean.

## 🔒 Nora Steiner ("NullX") — Application Security Engineer **Verdict: ✅ Approved** Reviewing only the #763 surface. No vulnerabilities, no security smells. ### What I checked - **SQL/JPQL injection** — `PersonRepository.searchByName` adds `OR LOWER(a.firstName) LIKE LOWER(CONCAT('%', :query, '%'))` reusing the *same bound `:query` named parameter*. No string concatenation, no new parameter, injection-proof by construction. ✅ (CWE-89 N/A.) - **DoS / unbounded work** — this is the part I scrutinize hardest, and it's handled well. `MAX_TOKENS = 8` caps the number of unindexed leading-wildcard `LIKE` scans per name *before* the fetch loop, and there's a dedicated test (`over8Tokens_issuesAtMost8Fetches`) asserting `atMost(8)` actual invocations — the guarantee is about fetches issued, not list size, which is the correct thing to pin. `MAX_NAME_LENGTH = 200` remains the first-line guard at the search boundary. Defense in depth, and tested. - **PII in logs** — `log.debug` records only the *outcome bucket* (`direct=1`/`direct>=2`/`partial-only`/`no-match`) and the *token count*, never the raw name or any name component. This is exactly right for a private family archive — no name leaks into Loki. SLF4J parameterized form (`"... outcome={} tokens={}"`) used throughout, so no log-injection risk either. - **Authorization** — unchanged. `resolveByName` is a read path reached through the already-authenticated `/api/search/nl` endpoint; no new endpoint, no `@RequirePermission` gap introduced. - **Frontend** — picker renders `heading`/`displayName` as text interpolation (`{heading}`, `{person.displayName}`), never `innerHTML`. No XSS vector. Names originate server-side from the DB, not from raw user input. No failing-test-first security finding to raise — the DoS invariant the issue called out is already codified as a permanent regression test. Clean.
Author
Owner

🧪 Sara Holt — Senior QA Engineer

Verdict: ⚠️ Approved with concerns

Reviewing only the #763 test work. The coverage is strong and lands at the right pyramid layers — I have one gap and one suggestion, neither a merge blocker.

What's done well

  • Right layer for each behavior. Classification logic is unit-tested with @Mock PersonRepository (no Spring context, milliseconds). The fetch-query change (a.firstName, ordering) is tested in PersonRepositoryTest against real Postgres via the existing Testcontainers slice — correct, because H2 differs on LOWER/CONCAT-over-nulls. No new container spun up; you reused the existing slice as the issue asked. The search-layer mapping is unit-tested in NlQueryParserServiceTest. Frontend picker behavior is in the vitest-browser-svelte spec. Textbook pyramid discipline.
  • One logical assertion per test, sentence-style names (resolveByName_maidenAliasToken_classifiesAsDirect, searchByName_ordersByLastNameThenFirstName). Easy to read a CI failure.
  • The DoS invariant is tested as an invocation count (verify(..., atMost(8)).searchByName), not a list-size proxy. That's the meaningful assertion.
  • makeNameMatches factory with overloads (empty / direct-only / direct+partial) keeps the re-pointed 23 mocks one-liners. Good factory hygiene.
  • Test #20 was correctly rewritten from elevenCandidates_capsAtTen to tenDirectMatches_allShownAsAmbiguous — it now verifies the cap lives in resolveByName (after classification) and the search layer adds no second cap. The behavior moved, and the test moved with it.

Concern (non-blocking)

  • No end-to-end / integration assertion that the maiden-alias path resolves direct through the real fetch query. resolveByName_maidenAliasToken_classifiesAsDirect and aliasFirstNameToken_isFetchedAndClassified both stub searchByName to return the candidate, so they verify the classifier, not that searchByName actually finds a person by maiden a.lastName + a.firstName and feeds it into classification. PersonRepositoryTest.searchByName_findsByAliasFirstName covers the fetch in isolation, but nothing exercises fetch→classify together for the alias case. The acceptance criterion "a person matchable only via an alias first name is fetched and classifiable" is split across two tests that never meet. A single @DataJpaTest-or-service-level test (real Postgres, real resolveByName) saving a person with a maiden alias and asserting result.direct() would close the seam. Low effort, high confidence — worth a follow-up if not this PR.

Suggestion

  • Consider one resolveByName test where the same person appears in the fetch pool via two different tokens (dedup by id in fetchPool) to pin the putIfAbsent behavior — currently implied by the single-direct tests but not asserted directly.

Quality gate (88% branch) — the empty-token-guard, direct-sorts-beyond-cap, and direct/partial branches are all hit, so I don't expect a JaCoCo regression. Approving with the fetch→classify integration gap noted as a follow-up.

## 🧪 Sara Holt — Senior QA Engineer **Verdict: ⚠️ Approved with concerns** Reviewing only the #763 test work. The coverage is strong and lands at the right pyramid layers — I have one gap and one suggestion, neither a merge blocker. ### What's done well - **Right layer for each behavior.** Classification logic is unit-tested with `@Mock PersonRepository` (no Spring context, milliseconds). The fetch-query change (`a.firstName`, ordering) is tested in `PersonRepositoryTest` against **real Postgres** via the existing Testcontainers slice — correct, because H2 differs on `LOWER`/`CONCAT`-over-nulls. No new container spun up; you reused the existing slice as the issue asked. The search-layer mapping is unit-tested in `NlQueryParserServiceTest`. Frontend picker behavior is in the `vitest-browser-svelte` spec. Textbook pyramid discipline. - **One logical assertion per test**, sentence-style names (`resolveByName_maidenAliasToken_classifiesAsDirect`, `searchByName_ordersByLastNameThenFirstName`). Easy to read a CI failure. - **The DoS invariant is tested as an invocation count** (`verify(..., atMost(8)).searchByName`), not a list-size proxy. That's the meaningful assertion. - **`makeNameMatches` factory with overloads** (empty / direct-only / direct+partial) keeps the re-pointed 23 mocks one-liners. Good factory hygiene. - Test #20 was correctly rewritten from `elevenCandidates_capsAtTen` to `tenDirectMatches_allShownAsAmbiguous` — it now verifies the cap lives in `resolveByName` (after classification) and the search layer adds no second cap. The behavior moved, and the test moved with it. ### Concern (non-blocking) - **No end-to-end / integration assertion that the maiden-alias path resolves direct through the *real* fetch query.** `resolveByName_maidenAliasToken_classifiesAsDirect` and `aliasFirstNameToken_isFetchedAndClassified` both **stub** `searchByName` to return the candidate, so they verify the classifier, not that `searchByName` actually *finds* a person by maiden `a.lastName` + `a.firstName` and feeds it into classification. `PersonRepositoryTest.searchByName_findsByAliasFirstName` covers the fetch in isolation, but nothing exercises fetch→classify together for the alias case. The acceptance criterion "a person matchable only via an alias first name is fetched **and** classifiable" is split across two tests that never meet. A single `@DataJpaTest`-or-service-level test (real Postgres, real `resolveByName`) saving a person with a maiden alias and asserting `result.direct()` would close the seam. Low effort, high confidence — worth a follow-up if not this PR. ### Suggestion - Consider one `resolveByName` test where the **same person** appears in the fetch pool via two different tokens (dedup by id in `fetchPool`) to pin the `putIfAbsent` behavior — currently implied by the single-direct tests but not asserted directly. Quality gate (88% branch) — the empty-token-guard, direct-sorts-beyond-cap, and direct/partial branches are all hit, so I don't expect a JaCoCo regression. Approving with the fetch→classify integration gap noted as a follow-up.
Author
Owner

🎨 Leonie Voss — Senior UX Designer & Accessibility Strategist

Verdict: ⚠️ Approved with concerns

Reviewing only the #763 frontend changes. The core UX win is real and well-executed — but there's one accessibility seam in the trigger that I want on record for our 60+ audience.

What's done well

  • The "Meintest du …?" prompt is in the panel heading, not the truncated trigger. This is the right fix. The trigger name span is max-w-[8rem] truncate sm:max-w-[12rem], which would clip "Meintest du Clara Cra…" at 320px and hide the suggestion from a senior on a small phone. Putting the full suggested name in the <p> panel heading at body size guarantees it renders in full when the picker opens. Exactly the constraint-first call I'd make.
  • Cue suppression for the 1-item case — dropping "(auswählen…)" when there's a single "did you mean" suggestion is correct; the prompt already implies the action. Kept for ≥2. Matches Nielsen #2 (system matches reality) — a 1-item picker no longer says "Mehrere Personen gefunden".
  • aria-labelledby upgrade — the <ul> now references a visible <p id="disambiguation-heading"> instead of a hidden aria-label. The heading is announced AND seen. Better than before.
  • Touch targets preserved — trigger and every option keep min-h-[44px]. i18n added to all three locales (de/en/es) with sensible copy. $derived heading on the page; component stays dumb.

Concern (High — non-blocking, but please track)

  • The trigger button's accessible name is wrong for the 1-item case. DisambiguationPicker.svelte hardcodes aria-label={m.search_disambiguation_trigger_label()} = "Mehrere Personen gefunden — zum Auswählen klicken" / "Several people found — click to choose" on the trigger regardless of person count. So for a single "did you mean Clara Cramer" suggestion, a screen-reader user hears "Several people found" while sighted users see one name and a "Meintest du" heading inside. That's a system-status mismatch (Nielsen #1/#2) specifically for the assistive-tech path — and the picker spec even locates the 1-item trigger via getByRole('button', { name: /Mehrere Personen gefunden/ }), which confirms the stale label is what's exposed.

    The issue spec said "trigger stays as-is" to avoid the 320px truncation, and that's right for the visible label — but the accessible label should still switch with count. Concrete fix: pass a triggerLabel prop (or derive from persons.length) so the 1-item case announces something like "Meintest du {name}? Zum Auswählen klicken" / "Did you mean {name}? Click to choose", while the visible truncated span is unchanged. This keeps the visual fix intact and gives SR users the correct status.

Note

  • No new axe coverage was added for the 1-item picker state. The existing picker a11y baseline (44px, aria-haspopup/expanded/controls, focus-visible, Esc-to-close + focus return) is intact, but the trigger-label mismatch above is exactly the kind of thing a per-state axe assertion or an aria-name assertion would have caught. Worth a small follow-up.

Approving — the visible UX is a genuine improvement for the senior audience. Please file the trigger aria-label fix; it's the one thing standing between this and a clean from me.

## 🎨 Leonie Voss — Senior UX Designer & Accessibility Strategist **Verdict: ⚠️ Approved with concerns** Reviewing only the #763 frontend changes. The core UX win is real and well-executed — but there's one accessibility seam in the trigger that I want on record for our 60+ audience. ### What's done well - **The "Meintest du …?" prompt is in the panel heading, not the truncated trigger.** This is the right fix. The trigger name span is `max-w-[8rem] truncate sm:max-w-[12rem]`, which would clip "Meintest du Clara Cra…" at 320px and hide the suggestion from a senior on a small phone. Putting the full suggested name in the `<p>` panel heading at body size guarantees it renders in full when the picker opens. Exactly the constraint-first call I'd make. - **Cue suppression for the 1-item case** — dropping "(auswählen…)" when there's a single "did you mean" suggestion is correct; the prompt already implies the action. Kept for ≥2. Matches Nielsen #2 (system matches reality) — a 1-item picker no longer says "Mehrere Personen gefunden". - **`aria-labelledby` upgrade** — the `<ul>` now references a *visible* `<p id="disambiguation-heading">` instead of a hidden `aria-label`. The heading is announced AND seen. Better than before. - **Touch targets preserved** — trigger and every option keep `min-h-[44px]`. i18n added to all three locales (de/en/es) with sensible copy. `$derived` heading on the page; component stays dumb. ### Concern (High — non-blocking, but please track) - **The trigger button's accessible name is wrong for the 1-item case.** `DisambiguationPicker.svelte` hardcodes `aria-label={m.search_disambiguation_trigger_label()}` = *"Mehrere Personen gefunden — zum Auswählen klicken"* / *"Several people found — click to choose"* on the trigger **regardless of person count**. So for a single "did you mean Clara Cramer" suggestion, a screen-reader user hears **"Several people found"** while sighted users see one name and a "Meintest du" heading inside. That's a system-status mismatch (Nielsen #1/#2) specifically for the assistive-tech path — and the picker spec even locates the 1-item trigger via `getByRole('button', { name: /Mehrere Personen gefunden/ })`, which confirms the stale label is what's exposed. The issue spec said "trigger stays as-is" to avoid the 320px truncation, and that's right for the *visible* label — but the *accessible* label should still switch with count. Concrete fix: pass a `triggerLabel` prop (or derive from `persons.length`) so the 1-item case announces something like *"Meintest du {name}? Zum Auswählen klicken"* / *"Did you mean {name}? Click to choose"*, while the visible truncated span is unchanged. This keeps the visual fix intact and gives SR users the correct status. ### Note - No new axe coverage was added for the 1-item picker state. The existing picker a11y baseline (44px, `aria-haspopup`/`expanded`/`controls`, focus-visible, Esc-to-close + focus return) is intact, but the trigger-label mismatch above is exactly the kind of thing a per-state axe assertion or an aria-name assertion would have caught. Worth a small follow-up. Approving — the visible UX is a genuine improvement for the senior audience. Please file the trigger aria-label fix; it's the one thing standing between this and a clean ✅ from me.
Author
Owner

🔧 Tobias Wendt — DevOps & Platform Engineer

Verdict: Approved

Reviewing only the #763 work. Nothing in my domain to flag — and I checked specifically because the new code path can issue multiple DB queries.

What I checked

  • No infrastructure surface touched — no Compose changes, no new service, no env var, no CI workflow edit, no image tag, no port. Pure application code + tests + docs.
  • Query fan-out is bounded — my one operational concern with a "fetch a pool of candidates per token" design is unbounded DB load. MAX_TOKENS = 8 caps it at 8 searchByName invocations per name, each a single parameterized query, and there's a test asserting the cap. The LIKE '%...%' leading-wildcard scans are unindexed, but at ≤8 per NL search on a family-sized persons table this is a non-issue — and the NL search endpoint is already rate-limited (NlSearchRateLimiter, 5/min/user) upstream, so there's no amplification vector. No need to profile or add an index.
  • Observability — the new log.debug is at DEBUG and structured (outcome bucket + token count), so it's queryable in Loki without flooding INFO and without redeploying to answer "is auto-select firing?". This is the kind of operability hook I like to see added proactively. PII-free, so it's safe to leave on in a troubleshooting session.
  • Test stage impact — the repository tests reuse the existing Postgres Testcontainers slice (no second container), and the unit tests are mock-only. Negligible CI time delta. No DinD, no new artifact.

No changes requested.

## 🔧 Tobias Wendt — DevOps & Platform Engineer **Verdict: ✅ Approved** Reviewing only the #763 work. Nothing in my domain to flag — and I checked specifically because the new code path can issue multiple DB queries. ### What I checked - **No infrastructure surface touched** — no Compose changes, no new service, no env var, no CI workflow edit, no image tag, no port. Pure application code + tests + docs. - **Query fan-out is bounded** — my one operational concern with a "fetch a pool of candidates per token" design is unbounded DB load. `MAX_TOKENS = 8` caps it at 8 `searchByName` invocations per name, each a single parameterized query, and there's a test asserting the cap. The `LIKE '%...%'` leading-wildcard scans are unindexed, but at ≤8 per NL search on a family-sized `persons` table this is a non-issue — and the NL search endpoint is already rate-limited (`NlSearchRateLimiter`, 5/min/user) upstream, so there's no amplification vector. No need to profile or add an index. - **Observability** — the new `log.debug` is at DEBUG and structured (outcome bucket + token count), so it's queryable in Loki without flooding INFO and without redeploying to answer "is auto-select firing?". This is the kind of operability hook I like to see added proactively. PII-free, so it's safe to leave on in a troubleshooting session. - **Test stage impact** — the repository tests reuse the *existing* Postgres Testcontainers slice (no second container), and the unit tests are mock-only. Negligible CI time delta. No DinD, no new artifact. No changes requested.
Author
Owner

📋 "Elicit" — Requirements Engineer & Business Analyst

Verdict: ⚠️ Approved with concerns

I traced the PR against the acceptance criteria of #763 (the source of truth). My job here is requirements coverage, not code — does the implementation satisfy every confirmed criterion, and is anything ambiguous or untraced?

Acceptance-criteria traceability

# Acceptance criterion (#763) Covered by Status
1 One Clara Cram + a Clara Cramer → auto-select Clara Cram resolveByName_singleDirectMatch + cramVsCramer_classifiesAsPartial + oneDirect_executesSearch
2 Two literal Clara Cram → picker lists both direct.size() >= 2 branch in resolveNames; partial-twoCandidates analog tested
3 "Clara Cram" matches "Clara Maria Cram" (middle name) resolveByName_middleName_stillDirect
4 displayName "Clara Müller" + maiden alias "Cram" → direct resolveByName_maidenAliasToken_classifiesAsDirect (classifier only — see flag)
5 Matchable only via alias first name → fetched & classifiable searchByName_findsByAliasFirstName + aliasFirstNameToken_isFetchedAndClassified (split across two tests — see flag)
6 Tokenizer pins (Anna-Maria, D'Angelo, Müller, lowercase, drop empties) 6 tokenize_* tests
7 "Cram" + only "Clara Cramer" → 1-item picker, heading "Meintest du …?", cue suppressed partialOnly_oneCandidate + picker spec (heading visible, cue absent)
8 Partial-only ≥2 → keeps "Mehrere Personen gefunden" framing partialOnly_twoCandidates + disambiguationHeading derivation (≥2 → heading)
9 No substring candidates at all → folded into full-text noMatchFragments branch; search_noMatchName_isFoldedIntoText
10 All-punctuation / empty-after-tokenizing → full-text, no false direct emptyAfterTokenizing_returnsNoCandidates (guard clause)
11 Direct sorting beyond MAX_CANDIDATES still auto-selected directSortsBeyondCap_stillReturnedAsDirect
12 >8 distinct tokens → at most 8 fetches over8Tokens_issuesAtMost8Fetches
13 Two distinct names, each one direct → sender/receiver mapping, no picker existing two-name mapping tests re-pointed to resolveByName
14 Case-insensitive & order-independent reorderedTokens_stillDirect + lowercase tokenizer

Every one of the 14 acceptance criteria is traceable to code + a test. That is excellent requirements discipline — this is the spec-dense, testable style the issue itself modeled.

Flags (non-blocking)

  • AC #4 / #5 verification seam. Criteria #4 and #5 are each satisfied by two tests that don't meet — the repository test proves the fetch finds the person, the service test proves the classifier accepts it, but no single test walks fetch→classify for the alias path end-to-end. From a requirements standpoint the criterion as written ("is fetched and classifiable") implies one continuous behavior; today's coverage proves the two halves independently. Sara raised the same seam from the test-strategy side. Recommend a follow-up issue rather than blocking — confidence is already high.
  • Implicit-requirement drift (assistive tech). The frontend criteria are written entirely about the visible panel heading. They are silent on the trigger's accessible name — and the implementation leaves it reading "Several people found" even in the 1-item case (Leonie's finding). This is a genuine requirements gap, not an implementation bug: AC #7 says "panel heading reads Meintest du", which is literally satisfied, but the unstated requirement "the announced status must match the shown status for a single suggestion" was never captured. Recommend adding it as an explicit AC on a follow-up so it's tracked, not silently resolved.

Scope check

No scope creep within #763. (The PR also carries #743's tag work, but that's the unmerged base branch and out of scope for this review per the reviewer's instruction — not a #763 concern.)

Approving on coverage. The two flags are best handled as explicit follow-up acceptance criteria so the assistive-tech and fetch→classify gaps are tracked rather than assumed.

## 📋 "Elicit" — Requirements Engineer & Business Analyst **Verdict: ⚠️ Approved with concerns** I traced the PR against the acceptance criteria of #763 (the source of truth). My job here is requirements coverage, not code — does the implementation satisfy every confirmed criterion, and is anything ambiguous or untraced? ### Acceptance-criteria traceability | # | Acceptance criterion (#763) | Covered by | Status | |---|---|---|---| | 1 | One *Clara Cram* + a *Clara Cramer* → auto-select *Clara Cram* | `resolveByName_singleDirectMatch` + `cramVsCramer_classifiesAsPartial` + `oneDirect_executesSearch` | ✅ | | 2 | Two literal *Clara Cram* → picker lists both | `direct.size() >= 2` branch in `resolveNames`; partial-twoCandidates analog tested | ✅ | | 3 | "Clara Cram" matches "Clara Maria Cram" (middle name) | `resolveByName_middleName_stillDirect` | ✅ | | 4 | displayName "Clara Müller" + maiden alias "Cram" → direct | `resolveByName_maidenAliasToken_classifiesAsDirect` | ✅ (classifier only — see flag) | | 5 | Matchable only via alias **first** name → fetched & classifiable | `searchByName_findsByAliasFirstName` + `aliasFirstNameToken_isFetchedAndClassified` | ✅ (split across two tests — see flag) | | 6 | Tokenizer pins (`Anna-Maria`, `D'Angelo`, `Müller`, lowercase, drop empties) | 6 `tokenize_*` tests | ✅ | | 7 | "Cram" + only "Clara Cramer" → 1-item picker, heading "Meintest du …?", cue suppressed | `partialOnly_oneCandidate` + picker spec (heading visible, cue absent) | ✅ | | 8 | Partial-only ≥2 → keeps "Mehrere Personen gefunden" framing | `partialOnly_twoCandidates` + `disambiguationHeading` derivation (≥2 → heading) | ✅ | | 9 | No substring candidates at all → folded into full-text | `noMatchFragments` branch; `search_noMatchName_isFoldedIntoText` | ✅ | | 10 | All-punctuation / empty-after-tokenizing → full-text, no false direct | `emptyAfterTokenizing_returnsNoCandidates` (guard clause) | ✅ | | 11 | Direct sorting beyond `MAX_CANDIDATES` still auto-selected | `directSortsBeyondCap_stillReturnedAsDirect` | ✅ | | 12 | >8 distinct tokens → at most 8 fetches | `over8Tokens_issuesAtMost8Fetches` | ✅ | | 13 | Two distinct names, each one direct → sender/receiver mapping, no picker | existing two-name mapping tests re-pointed to `resolveByName` | ✅ | | 14 | Case-insensitive & order-independent | `reorderedTokens_stillDirect` + lowercase tokenizer | ✅ | **Every one of the 14 acceptance criteria is traceable to code + a test.** That is excellent requirements discipline — this is the spec-dense, testable style the issue itself modeled. ### Flags (non-blocking) - **AC #4 / #5 verification seam.** Criteria #4 and #5 are each satisfied by *two tests that don't meet* — the repository test proves the fetch finds the person, the service test proves the classifier accepts it, but no single test walks fetch→classify for the alias path end-to-end. From a requirements standpoint the criterion *as written* ("is fetched **and** classifiable") implies one continuous behavior; today's coverage proves the two halves independently. Sara raised the same seam from the test-strategy side. Recommend a follow-up issue rather than blocking — confidence is already high. - **Implicit-requirement drift (assistive tech).** The frontend criteria are written entirely about the *visible* panel heading. They are silent on the trigger's *accessible* name — and the implementation leaves it reading "Several people found" even in the 1-item case (Leonie's finding). This is a genuine **requirements gap, not an implementation bug**: AC #7 says "panel heading reads Meintest du", which is literally satisfied, but the unstated requirement "the announced status must match the shown status for a single suggestion" was never captured. Recommend adding it as an explicit AC on a follow-up so it's tracked, not silently resolved. ### Scope check No scope creep within #763. (The PR also carries #743's tag work, but that's the unmerged base branch and out of scope for this review per the reviewer's instruction — not a #763 concern.) Approving on coverage. The two flags are best handled as explicit follow-up acceptance criteria so the assistive-tech and fetch→classify gaps are tracked rather than assumed.
marcel added 3 commits 2026-06-07 01:31:48 +02:00
The trigger hardcoded the multiple-people label for every count, so a
single did-you-mean picker announced "Mehrere Personen gefunden" to
screen readers while sighted users saw one name and a "Meintest du …?"
heading. Derive the trigger's accessible name from persons.length: a
single suggestion reuses the heading prop, two or more keep the
multiple-people label. Visible truncated name span unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
AC#4 (maiden alias -> direct) and AC#5 (alias first name -> fetchable +
classifiable) were each split across PersonRepositoryTest (the fetch) and
PersonServiceTest (the classifier with stubs) -- nothing walked
searchByName -> resolveByName end-to-end on real Postgres. Add two tests
in the existing @DataJpaTest slice that build a real PersonService over
the autowired repositories, persist a person with a MAIDEN_NAME alias and
one with an alias firstName, and assert both classify as direct.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
test(person): pin fetchPool dedup when one person matches two tokens (#763 review)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m22s
CI / OCR Service Tests (pull_request) Successful in 24s
CI / Backend Unit Tests (pull_request) Successful in 3m50s
CI / fail2ban Regex (pull_request) Successful in 48s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
79bcd1b31b
Assert that when the same person id is returned by two different token
fetches, the person appears exactly once in the result -- pinning
fetchPool's putIfAbsent dedup so a future refactor can't silently
double-classify a candidate.

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

👨‍💻 Felix Brandt — review concerns addressed

All three review concerns are resolved via red/green TDD, one atomic commit each. Zero blockers were raised; these were the three non-blocking concerns from Leonie and Sara.

# Concern (reviewer) Fix Commit
1 Trigger aria-label wrong for the 1-item case (Leonie, High) — a single did-you-mean picker announced "Mehrere Personen gefunden" to screen readers while sighted users saw one name + a "Meintest du …?" heading. DisambiguationPicker now derives the trigger's accessible name from persons.length: a single suggestion reuses the heading prop ("Meintest du {name}?"), ≥2 keeps search_disambiguation_trigger_label. Visible truncated name span unchanged. Spec re-pointed: the two 1-item tests now locate the trigger by the did-you-mean name, plus explicit accessible-name assertions for both the 1-item and ≥2 cases. Validated with the Svelte autofixer (no issues). 3f0d37bf
2 Fetch→classify seam for the alias path (Sara / Elicit, AC#4 & AC#5) — the maiden-alias and alias-first-name criteria were each split across PersonRepositoryTest (the fetch) and PersonServiceTest (the classifier with stubs); nothing walked searchByName → resolveByName end-to-end on real Postgres. Added two tests to the existing @DataJpaTest + Testcontainers slice (no new slice/container) that build a real PersonService over the autowired repositories, persist a person with a MAIDEN_NAME alias "Cram" (displayName "Clara Müller") and one with an alias firstName "Wilhelmina", then assert resolveByName(...) classifies each as direct. 751ac1c4
3 Pin fetchPool dedup (Sara, suggestion) Added a PersonServiceTest where the same person id is returned by two different token fetches and asserts the person appears exactly once (direct size 1, partial empty) — pins the putIfAbsent dedup against a future double-classify regression. 79bcd1b3

Verification (touched classes only — full suite left to CI per repo convention)

  • DisambiguationPicker.svelte.spec.ts10 passed (10) (was 8; +2 accessible-name assertions)
  • PersonRepositoryTestTests run: 56, Failures: 0, Errors: 0 (real Postgres slice, +2 end-to-end)
  • PersonServiceTestTests run: 81, Failures: 0, Errors: 0 (+1 dedup)

Pre-commit frontend lint (prettier + eslint) passes. Branch pushed to feat/issue-763-nl-search-direct-match (c0edbd43..79bcd1b3).

🤖 Generated with Claude Code

## 👨‍💻 Felix Brandt — review concerns addressed All three review concerns are resolved via red/green TDD, one atomic commit each. Zero blockers were raised; these were the three non-blocking concerns from Leonie and Sara. | # | Concern (reviewer) | Fix | Commit | |---|---|---|---| | 1 | **Trigger `aria-label` wrong for the 1-item case** (Leonie, High) — a single did-you-mean picker announced *"Mehrere Personen gefunden"* to screen readers while sighted users saw one name + a "Meintest du …?" heading. | `DisambiguationPicker` now derives the trigger's accessible name from `persons.length`: a single suggestion reuses the `heading` prop ("Meintest du {name}?"), ≥2 keeps `search_disambiguation_trigger_label`. Visible truncated name span unchanged. Spec re-pointed: the two 1-item tests now locate the trigger by the did-you-mean name, plus explicit accessible-name assertions for both the 1-item and ≥2 cases. Validated with the Svelte autofixer (no issues). | `3f0d37bf` | | 2 | **Fetch→classify seam for the alias path** (Sara / Elicit, AC#4 & AC#5) — the maiden-alias and alias-first-name criteria were each split across `PersonRepositoryTest` (the fetch) and `PersonServiceTest` (the classifier with stubs); nothing walked `searchByName → resolveByName` end-to-end on real Postgres. | Added two tests to the existing `@DataJpaTest` + Testcontainers slice (no new slice/container) that build a real `PersonService` over the autowired repositories, persist a person with a `MAIDEN_NAME` alias "Cram" (displayName "Clara Müller") and one with an alias `firstName` "Wilhelmina", then assert `resolveByName(...)` classifies each as **direct**. | `751ac1c4` | | 3 | **Pin `fetchPool` dedup** (Sara, suggestion) | Added a `PersonServiceTest` where the same person id is returned by two different token fetches and asserts the person appears exactly once (`direct` size 1, `partial` empty) — pins the `putIfAbsent` dedup against a future double-classify regression. | `79bcd1b3` | ### Verification (touched classes only — full suite left to CI per repo convention) - `DisambiguationPicker.svelte.spec.ts` — **10 passed (10)** (was 8; +2 accessible-name assertions) - `PersonRepositoryTest` — **Tests run: 56, Failures: 0, Errors: 0** (real Postgres slice, +2 end-to-end) - `PersonServiceTest` — **Tests run: 81, Failures: 0, Errors: 0** (+1 dedup) Pre-commit frontend lint (prettier + eslint) passes. Branch pushed to `feat/issue-763-nl-search-direct-match` (`c0edbd43..79bcd1b3`). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Author
Owner

🏛️ Markus Keller — Senior Application Architect · re-review (cycle 2)

Verdict: Approved

I re-checked the three follow-up commits (3f0d37bf, 751ac1c4, 79bcd1b3) against my cycle-1 position. I raised no concerns in cycle 1, and nothing in the new work touches my domain in a way that would change that.

What changed since cycle 1 — architectural impact

  • 3f0d37bf (trigger aria-label) — frontend-only. DisambiguationPicker gains heading/showCue props and derives triggerLabel from persons.length; the page already owned the heading derivation. The component stays presentation-dumb and the page keeps the policy. No new boundary, no coupling shift.
  • 751ac1c4 (real-Postgres fetch→classify tests) — test-only. Notably it constructs new PersonService(personRepository, aliasRepository) over the autowired repositories inside the existing @DataJpaTest slice. That respects the layering rule (the test wires the service over its own domain's repositories; it does not reach across domains). No production code touched.
  • 79bcd1b3 (fetchPool dedup test) — test-only, pins putIfAbsent behaviour already present in fetchPool.

Confirmation

  • No new service, controller, route, value record, or Flyway migration in these commits — so no c4 l3-backend-*.puml or DB-diagram trigger. Docs currency from cycle 1 (GLOSSARY NameMatches, person/README resolveByName) remains accurate; nothing in the new commits invalidates it.
  • The NameMatches ↔ search-layer vocabulary boundary is unchanged.

No regressions in my lens. Still a cleanly bounded change. Approved.

## 🏛️ Markus Keller — Senior Application Architect · re-review (cycle 2) **Verdict: ✅ Approved** I re-checked the three follow-up commits (`3f0d37bf`, `751ac1c4`, `79bcd1b3`) against my cycle-1 position. I raised no concerns in cycle 1, and nothing in the new work touches my domain in a way that would change that. ### What changed since cycle 1 — architectural impact - **`3f0d37bf` (trigger aria-label)** — frontend-only. `DisambiguationPicker` gains `heading`/`showCue` props and derives `triggerLabel` from `persons.length`; the page already owned the heading derivation. The component stays presentation-dumb and the page keeps the policy. No new boundary, no coupling shift. - **`751ac1c4` (real-Postgres fetch→classify tests)** — test-only. Notably it constructs `new PersonService(personRepository, aliasRepository)` over the autowired repositories inside the existing `@DataJpaTest` slice. That respects the layering rule (the test wires the service over its own domain's repositories; it does not reach across domains). No production code touched. - **`79bcd1b3` (fetchPool dedup test)** — test-only, pins `putIfAbsent` behaviour already present in `fetchPool`. ### Confirmation - No new service, controller, route, value record, or Flyway migration in these commits — so no c4 `l3-backend-*.puml` or DB-diagram trigger. Docs currency from cycle 1 (GLOSSARY `NameMatches`, person/README `resolveByName`) remains accurate; nothing in the new commits invalidates it. - The `NameMatches` ↔ search-layer vocabulary boundary is unchanged. No regressions in my lens. Still a cleanly bounded change. Approved.
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer · re-review (cycle 2)

Verdict: Approved

I approved in cycle 1; the three follow-up commits are exactly the disciplined red/green increments I'd expect, one atomic concern each. Re-checked all three.

3f0d37bf — trigger aria-label (Leonie's concern)

  • triggerLabel = $derived(persons.length === 1 ? heading : m.search_disambiguation_trigger_label()) — the right place for the branch. The visible truncated <span>{names}</span> is untouched, so the 320px clipping fix is preserved while the accessible name now matches the shown status. This is the surgical fix, not a rewrite.
  • The picker also got a structural cleanup: the panel is now a <div> wrapping a visible <p id="disambiguation-heading"> + <ul aria-labelledby={headingId}>. The {#each persons as person (person.id)} stays keyed. Clean.
  • Spec grew 8→10 with two explicit accessible-name assertions (1-item locates the trigger by the did-you-mean name; ≥2 keeps Mehrere Personen gefunden). The pre-existing tests were re-pointed via a multiProps spread — minimal churn, no behavioural drift.

751ac1c4 — fetch→classify e2e (Sara/Elicit's seam)

  • The two …_endToEnd tests do the right thing: persist a real person + alias, then entityManager.flush(); entityManager.clear(); before calling resolveByName, so the lazy nameAliases are genuinely re-fetched from Postgres rather than served from the persistence context. That's what makes this a real fetch→classify proof and not just a dressed-up unit test. The detach comment earns its place (a why).

79bcd1b3 — fetchPool dedup

  • samePersonFromTwoTokens_appearsOnce asserts direct().hasSize(1) + partial().isEmpty() — pins putIfAbsent against a future double-classify regression. One logical assertion, good name.

No new production logic, no regressions. Ship it.

## 👨‍💻 Felix Brandt — Senior Fullstack Developer · re-review (cycle 2) **Verdict: ✅ Approved** I approved in cycle 1; the three follow-up commits are exactly the disciplined red/green increments I'd expect, one atomic concern each. Re-checked all three. ### `3f0d37bf` — trigger aria-label (Leonie's concern) - `triggerLabel = $derived(persons.length === 1 ? heading : m.search_disambiguation_trigger_label())` — the right place for the branch. The visible truncated `<span>{names}</span>` is untouched, so the 320px clipping fix is preserved while the *accessible* name now matches the shown status. This is the surgical fix, not a rewrite. - The picker also got a structural cleanup: the panel is now a `<div>` wrapping a visible `<p id="disambiguation-heading">` + `<ul aria-labelledby={headingId}>`. The `{#each persons as person (person.id)}` stays keyed. Clean. - Spec grew 8→10 with two explicit accessible-name assertions (1-item locates the trigger by the did-you-mean name; ≥2 keeps `Mehrere Personen gefunden`). The pre-existing tests were re-pointed via a `multiProps` spread — minimal churn, no behavioural drift. ### `751ac1c4` — fetch→classify e2e (Sara/Elicit's seam) - The two `…_endToEnd` tests do the right thing: persist a real person + alias, then `entityManager.flush(); entityManager.clear();` before calling `resolveByName`, so the lazy `nameAliases` are genuinely re-fetched from Postgres rather than served from the persistence context. That's what makes this a *real* fetch→classify proof and not just a dressed-up unit test. The detach comment earns its place (a *why*). ### `79bcd1b3` — fetchPool dedup - `samePersonFromTwoTokens_appearsOnce` asserts `direct().hasSize(1)` + `partial().isEmpty()` — pins `putIfAbsent` against a future double-classify regression. One logical assertion, good name. No new production logic, no regressions. Ship it.
Author
Owner

🔒 Nora Steiner ("NullX") — Application Security Engineer · re-review (cycle 2)

Verdict: Approved

I approved in cycle 1. The three follow-up commits introduce no new attack surface — re-checked specifically for that.

Security review of the new commits

  • 3f0d37bf — the new triggerLabel and the panel heading both render via Svelte text interpolation (aria-label={triggerLabel}, {heading}, {person.displayName}). No innerHTML, no {@html}. The heading text originates from server-side person displayName values, not from raw query input. No XSS / DOM-injection vector. The aria-labelledby={headingId} references a static id, not user data.
  • 751ac1c4 / 79bcd1b3 — test-only, no production code path. The e2e tests exercise the existing parameterized searchByName query; no new query, no string concatenation.
  • No regression to the DoS invariantMAX_TOKENS = 8 is unchanged, and the over8Tokens_issuesAtMost8Fetches atMost(8) regression test is still present. The new dedup test actually strengthens the bound posture (confirms a flooded pool collapses to one classification). NL search remains rate-limited upstream (5/min/user).
  • PII in logs — unchanged; still outcome-bucket + token-count only, SLF4J parameterized. No name leaks.

Nothing to raise. Clean.

## 🔒 Nora Steiner ("NullX") — Application Security Engineer · re-review (cycle 2) **Verdict: ✅ Approved** I approved in cycle 1. The three follow-up commits introduce no new attack surface — re-checked specifically for that. ### Security review of the new commits - **`3f0d37bf`** — the new `triggerLabel` and the panel heading both render via Svelte text interpolation (`aria-label={triggerLabel}`, `{heading}`, `{person.displayName}`). No `innerHTML`, no `{@html}`. The heading text originates from server-side person `displayName` values, not from raw query input. No XSS / DOM-injection vector. The `aria-labelledby={headingId}` references a static id, not user data. - **`751ac1c4` / `79bcd1b3`** — test-only, no production code path. The e2e tests exercise the existing parameterized `searchByName` query; no new query, no string concatenation. - **No regression to the DoS invariant** — `MAX_TOKENS = 8` is unchanged, and the `over8Tokens_issuesAtMost8Fetches` `atMost(8)` regression test is still present. The new dedup test actually strengthens the bound posture (confirms a flooded pool collapses to one classification). NL search remains rate-limited upstream (5/min/user). - **PII in logs** — unchanged; still outcome-bucket + token-count only, SLF4J parameterized. No name leaks. Nothing to raise. Clean.
Author
Owner

🧪 Sara Holt — Senior QA Engineer · re-review (cycle 2)

Verdict: Approved (upgraded from ⚠️ Approved with concerns)

Both items I raised in cycle 1 are now closed. Re-verified the new tests.

Cycle-1 concern (fetch→classify seam) — RESOLVED

751ac1c4 adds resolveByName_maidenAlias_classifiesAsDirect_endToEnd and resolveByName_aliasFirstName_classifiesAsDirect_endToEnd to the existing @DataJpaTest + Testcontainers slice (no new container, as I asked). These build a real PersonService over the autowired repositories, persist a person with a MAIDEN_NAME / BIRTH alias, and assert resolveByName(...).direct() contains the saved id. Crucially they call entityManager.flush(); entityManager.clear(); before resolving, so the lazy nameAliases are re-read from Postgres — the fetch and the classify steps now genuinely meet in one test, which is exactly the seam AC#4/#5 implied. The supporting searchByName_findsByAliasFirstName and searchByName_ordersByLastNameThenFirstName repository tests are also present and correct.

Cycle-1 suggestion (fetchPool dedup) — RESOLVED

79bcd1b3 adds resolveByName_samePersonFromTwoTokens_appearsOnce: both token fetches return the same id, asserting direct size 1 and partial empty. That pins putIfAbsent directly rather than leaving it implied.

Regression check on the new tests

  • The two e2e tests assert on getId() after a real save — deterministic and not dependent on insertion order. Good.
  • searchByName_ordersByLastNameThenFirstName pins the ORDER BY (Anna, Bernd, Clara) on real Postgres collation — the right place for an ordering assertion (not H2).
  • The detach-before-resolve pattern is the meaningful detail; without clear() the test would pass off the persistence context and prove nothing about the fetch. They got it right.

Pyramid discipline intact, no JaCoCo regression expected (these are net-new covered branches). Clean approve.

## 🧪 Sara Holt — Senior QA Engineer · re-review (cycle 2) **Verdict: ✅ Approved** (upgraded from ⚠️ Approved with concerns) Both items I raised in cycle 1 are now closed. Re-verified the new tests. ### Cycle-1 concern (fetch→classify seam) — RESOLVED ✅ `751ac1c4` adds `resolveByName_maidenAlias_classifiesAsDirect_endToEnd` and `resolveByName_aliasFirstName_classifiesAsDirect_endToEnd` to the existing `@DataJpaTest` + Testcontainers slice (no new container, as I asked). These build a real `PersonService` over the autowired repositories, persist a person with a `MAIDEN_NAME` / `BIRTH` alias, and assert `resolveByName(...).direct()` contains the saved id. Crucially they call `entityManager.flush(); entityManager.clear();` before resolving, so the lazy `nameAliases` are re-read from Postgres — the fetch and the classify steps now genuinely *meet* in one test, which is exactly the seam AC#4/#5 implied. The supporting `searchByName_findsByAliasFirstName` and `searchByName_ordersByLastNameThenFirstName` repository tests are also present and correct. ### Cycle-1 suggestion (fetchPool dedup) — RESOLVED ✅ `79bcd1b3` adds `resolveByName_samePersonFromTwoTokens_appearsOnce`: both token fetches return the same id, asserting `direct` size 1 and `partial` empty. That pins `putIfAbsent` directly rather than leaving it implied. ### Regression check on the new tests - The two e2e tests assert on `getId()` after a real save — deterministic and not dependent on insertion order. Good. - `searchByName_ordersByLastNameThenFirstName` pins the ORDER BY (`Anna, Bernd, Clara`) on real Postgres collation — the right place for an ordering assertion (not H2). - The detach-before-resolve pattern is the meaningful detail; without `clear()` the test would pass off the persistence context and prove nothing about the fetch. They got it right. Pyramid discipline intact, no JaCoCo regression expected (these are net-new covered branches). Clean approve.
Author
Owner

🎨 Leonie Voss — Senior UX Designer & Accessibility Strategist · re-review (cycle 2)

Verdict: Approved (upgraded from ⚠️ Approved with concerns)

My one High concern from cycle 1 — the trigger's accessible name mismatching the visible status for a single suggestion — is fully resolved in 3f0d37bf.

Cycle-1 concern (trigger aria-label) — RESOLVED

DisambiguationPicker.svelte now derives the trigger's accessible name from count:

const triggerLabel = $derived(persons.length === 1 ? heading : m.search_disambiguation_trigger_label());
…
aria-label={triggerLabel}

So a single did-you-mean suggestion now announces "Meintest du {name}?" to a screen-reader user — matching what sighted users see in the panel heading — while ≥2 keeps "Mehrere Personen gefunden". The status the assistive-tech path hears now equals the status shown (Nielsen #1/#2 satisfied for both paths). The visible truncated name span is untouched, so the 320px clipping fix I cared about is preserved. This is exactly the fix I proposed.

Regression check

  • The panel restructure (<div> → visible <p id="disambiguation-heading"> + <ul aria-labelledby={headingId}>) keeps the heading both announced and seen. Per-option aria-label, min-h-[44px] touch targets, aria-haspopup/expanded/controls, focus-visible rings, and Esc-to-close-with-focus-return are all intact in the diff.
  • Coverage gap I flagged is closed: the spec now has explicit accessible-name assertions for both states — the 1-item case is located by getByRole('button', { name: 'Meintest du Clara Cramer?' }) and the ≥2 case by /Mehrere Personen gefunden/. That's the per-state aria-name assertion I wanted.
  • Cue suppression (1-item drops (auswählen…), ≥2 keeps it) is verified by showCue true/false specs.

The senior-audience UX win is intact and the assistive-tech seam is closed. Clean .

## 🎨 Leonie Voss — Senior UX Designer & Accessibility Strategist · re-review (cycle 2) **Verdict: ✅ Approved** (upgraded from ⚠️ Approved with concerns) My one High concern from cycle 1 — the trigger's accessible name mismatching the visible status for a single suggestion — is fully resolved in `3f0d37bf`. ### Cycle-1 concern (trigger aria-label) — RESOLVED ✅ `DisambiguationPicker.svelte` now derives the trigger's accessible name from count: ``` const triggerLabel = $derived(persons.length === 1 ? heading : m.search_disambiguation_trigger_label()); … aria-label={triggerLabel} ``` So a single did-you-mean suggestion now announces *"Meintest du {name}?"* to a screen-reader user — matching what sighted users see in the panel heading — while ≥2 keeps *"Mehrere Personen gefunden"*. The status the assistive-tech path hears now equals the status shown (Nielsen #1/#2 satisfied for both paths). The visible truncated name span is untouched, so the 320px clipping fix I cared about is preserved. This is exactly the fix I proposed. ### Regression check - The panel restructure (`<div>` → visible `<p id="disambiguation-heading">` + `<ul aria-labelledby={headingId}>`) keeps the heading both announced and seen. Per-option `aria-label`, `min-h-[44px]` touch targets, `aria-haspopup`/`expanded`/`controls`, focus-visible rings, and Esc-to-close-with-focus-return are all intact in the diff. - Coverage gap I flagged is closed: the spec now has explicit accessible-name assertions for **both** states — the 1-item case is located by `getByRole('button', { name: 'Meintest du Clara Cramer?' })` and the ≥2 case by `/Mehrere Personen gefunden/`. That's the per-state aria-name assertion I wanted. - Cue suppression (1-item drops `(auswählen…)`, ≥2 keeps it) is verified by `showCue` true/false specs. The senior-audience UX win is intact and the assistive-tech seam is closed. Clean ✅.
Author
Owner

🔧 Tobias Wendt — DevOps & Platform Engineer · re-review (cycle 2)

Verdict: Approved

I approved in cycle 1. Re-checked the three follow-up commits for any operational/CI impact — there is none.

Operational impact of the new commits

  • No infrastructure surface touched3f0d37bf is frontend code+spec; 751ac1c4 and 79bcd1b3 are backend tests. No Compose, env var, CI workflow, image, or port change.
  • CI stage impact negligible — the two new repository tests reuse the existing Postgres Testcontainers slice (no second container, confirmed: they construct new PersonService(...) over the autowired repos rather than spinning anything up). The new service test and the frontend spec are mock/component-level. Marginal runtime delta.
  • Query fan-out unchangedMAX_TOKENS = 8 cap and its atMost(8) regression test are intact; the new dedup test confirms a duplicate-heavy pool doesn't amplify classification work. The DEBUG outcome log is unchanged (PII-free, Loki-queryable). No new amplification vector; NL search still rate-limited upstream.

No changes requested.

## 🔧 Tobias Wendt — DevOps & Platform Engineer · re-review (cycle 2) **Verdict: ✅ Approved** I approved in cycle 1. Re-checked the three follow-up commits for any operational/CI impact — there is none. ### Operational impact of the new commits - **No infrastructure surface touched** — `3f0d37bf` is frontend code+spec; `751ac1c4` and `79bcd1b3` are backend tests. No Compose, env var, CI workflow, image, or port change. - **CI stage impact negligible** — the two new repository tests reuse the *existing* Postgres Testcontainers slice (no second container, confirmed: they construct `new PersonService(...)` over the autowired repos rather than spinning anything up). The new service test and the frontend spec are mock/component-level. Marginal runtime delta. - **Query fan-out unchanged** — `MAX_TOKENS = 8` cap and its `atMost(8)` regression test are intact; the new dedup test confirms a duplicate-heavy pool doesn't amplify classification work. The DEBUG outcome log is unchanged (PII-free, Loki-queryable). No new amplification vector; NL search still rate-limited upstream. No changes requested.
Author
Owner

📋 "Elicit" — Requirements Engineer & Business Analyst · re-review (cycle 2)

Verdict: Approved (upgraded from ⚠️ Approved with concerns)

Both flags I raised in cycle 1 are now closed with traceable coverage. All 14 acceptance criteria of #763 remain satisfied; the two seams are now backed by explicit tests.

Cycle-1 flag 1 — AC#4 / AC#5 fetch→classify seam — RESOLVED

The criteria "matchable only via an alias first name is fetched and classifiable" (AC#5) and the maiden-alias direct case (AC#4) were previously split across two tests that never met. 751ac1c4 adds two @DataJpaTest real-Postgres tests (resolveByName_maidenAlias_classifiesAsDirect_endToEnd, resolveByName_aliasFirstName_classifiesAsDirect_endToEnd) that walk searchByName → resolveByName continuously, with a flush()/clear() detach so the alias is genuinely re-fetched. The criterion as written ("fetched and classifiable" = one continuous behaviour) is now proven as one continuous behaviour, not two halves. Traceability for AC#4/#5 upgraded from split to end-to-end.

Cycle-1 flag 2 — assistive-tech requirement gap — RESOLVED

I flagged that the visible-heading criteria (AC#7) were silent on the trigger's accessible name, leaving SR users hearing "Several people found" for a 1-item suggestion. 3f0d37bf makes the trigger's aria-label derive from count (1 → did-you-mean heading, ≥2 → multi-people label), and adds explicit accessible-name assertions for both states. The unstated requirement — "announced status must match shown status for a single suggestion" — is now implemented and pinned by test. The implicit requirement is satisfied and verified.

Updated traceability

  • AC#1–14: all still , none regressed by the new commits.
  • AC#4, AC#5: now covered end-to-end (was: split).
  • AC#7 (now extended with the assistive-tech sub-requirement): covered visible + accessible.
  • Dedup behaviour (implementation invariant supporting AC#1/#13): now explicitly pinned (samePersonFromTwoTokens_appearsOnce).

Scope check

Still no scope creep within #763. The #743 tag work in the diff remains out of scope per the review instruction.

Every confirmed and implicit requirement is now traceable to code + test. Approving on coverage — no remaining follow-up needed.

## 📋 "Elicit" — Requirements Engineer & Business Analyst · re-review (cycle 2) **Verdict: ✅ Approved** (upgraded from ⚠️ Approved with concerns) Both flags I raised in cycle 1 are now closed with traceable coverage. All 14 acceptance criteria of #763 remain satisfied; the two seams are now backed by explicit tests. ### Cycle-1 flag 1 — AC#4 / AC#5 fetch→classify seam — RESOLVED ✅ The criteria "matchable only via an alias **first** name is fetched **and** classifiable" (AC#5) and the maiden-alias direct case (AC#4) were previously split across two tests that never met. `751ac1c4` adds two `@DataJpaTest` real-Postgres tests (`resolveByName_maidenAlias_classifiesAsDirect_endToEnd`, `resolveByName_aliasFirstName_classifiesAsDirect_endToEnd`) that walk `searchByName → resolveByName` continuously, with a `flush()/clear()` detach so the alias is genuinely re-fetched. The criterion as written ("fetched and classifiable" = one continuous behaviour) is now proven as one continuous behaviour, not two halves. Traceability for AC#4/#5 upgraded from split to end-to-end. ### Cycle-1 flag 2 — assistive-tech requirement gap — RESOLVED ✅ I flagged that the visible-heading criteria (AC#7) were silent on the trigger's *accessible* name, leaving SR users hearing "Several people found" for a 1-item suggestion. `3f0d37bf` makes the trigger's `aria-label` derive from count (1 → did-you-mean heading, ≥2 → multi-people label), and adds explicit accessible-name assertions for both states. The unstated requirement — "announced status must match shown status for a single suggestion" — is now implemented and pinned by test. The implicit requirement is satisfied and verified. ### Updated traceability - AC#1–14: all still ✅, none regressed by the new commits. - AC#4, AC#5: now covered end-to-end (was: split). - AC#7 (now extended with the assistive-tech sub-requirement): covered visible + accessible. - Dedup behaviour (implementation invariant supporting AC#1/#13): now explicitly pinned (`samePersonFromTwoTokens_appearsOnce`). ### Scope check Still no scope creep within #763. The #743 tag work in the diff remains out of scope per the review instruction. Every confirmed and implicit requirement is now traceable to code + test. Approving on coverage — no remaining follow-up needed.
marcel merged commit 09b77e9b36 into main 2026-06-07 08:47:49 +02:00
marcel deleted branch feat/issue-763-nl-search-direct-match 2026-06-07 08:47:49 +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#769