fix(person): resolve case-colliding aliases and ambiguous sender names without throwing (#731) #745
Reference in New Issue
Block a user
Delete Branch "fix/issue-731-person-case-collision"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #731. Sibling of #730 (merged) — same root cause (
Optional<…>IgnoreCaseis a leaky identity that throwsNonUniqueResultExceptiononce two rows collide case-insensitively), here on the two Person lookup surfaces.What changed
Commit 1 — alias path (
findOrCreateByAlias) —20cfe41ffindByAlias) → lowest-id case-insensitive sibling (findAllByAliasIgnoreCase) → existing create-when-absent branch (INSTITUTION/GROUP and maiden-name alias preserved verbatim). Does not throw on a case-collision.Optional<Person> findByAliasIgnoreCase.Commit 2 — name/sender path (
findByName) —ddf378aaDocumentService.storeDocumentfor filename-based sender resolution.= :firstName), not a derived…IgnoreCasequery, so a null first name binds as= NULL(no match) instead of the derived fold tofirst_name IS NULL, which would widen a last-name-only row in as a sender. Pinned by a real-Postgres repo test.Optional<Person> findByFirstNameIgnoreCaseAndLastNameIgnoreCase.IncorrectResultSizeDataAccessException→INTERNAL_ERRORwith no Hibernate class name / SQL / row count leak.person/README.mdfindByNamemislabel ("Typeahead search" → filename-based sender resolution).Commit 3 — scope clarification —
cd741b9fINTERNAL_ERROR(never a wrong row) — the exact-case step still returnsOptional. Addresses the round-1 "never throws was overstated" review note.Commit 4 — ADR renumber housekeeping —
7629e358main. This renumbers the tag/case-collision ADR 032 → 033 (referenced only from this PR, so self-contained and touches no Flyway migration). The person-delete ADR-032 and theV71comment citing it are left untouched — editing an applied migration would drift its Flyway checksum.Decisions (per issue)
unique(lower(...))constraint (collisions are valid human labels;source_refis the stable identity). No merge/dedupe. No shared helper (the two paths have different fallbacks — KISS, inlined).Tests (TDD, red→green; targeted runs, never the full suite locally)
PersonServiceTest(64) — exact wins · exact wins among multiple siblings · single-CI · multi-CI lowest-id + determinism · SKIP · create-with-maiden-alias · trim · findByName exact/single-CI/2+→empty/null-first→empty.PersonRepositoryTest(52,@DataJpaTestTestcontainers) — exact-only matches · umlaut folding in real Postgres · null-first-name → empty (the IS NULL semantics check).PersonServiceIntegrationTest(19, Testcontainers) — umlaut alias collision lowest-id + determinism ·storeDocumentunique→sender set · single-CI→set · ambiguous→sender null (no 500) · last-name-only filename→null ·findByName(null,…)→empty.GlobalExceptionHandlerTest(4) — opaque-error regression.TagCaseCollisionIntegrationTest(3) andDocumentServiceTest(187) — unaffected, green.mvn test+ 88% JaCoCo gate) green on the implementation commit.Not done here (needs prod/observability access)
GROUP BY … HAVING count(*) > 1read-only queries against prod) — please run to record latent-vs-active counts.IncorrectResultSizeDataAccessException/NonUniqueResultExceptionin person frames.🤖 Generated with Claude Code
👨💻 Felix Brandt — Senior Fullstack Developer
⚠️ Approved with concerns
Clean, disciplined work. Guard clauses, no bare
.get(), the create-branch is preserved verbatim, and the mock migrations across the 8PersonServiceTestcases compile and read well. The two paths are inlined rather than over-abstracted into a premature helper — correct KISS call. TDD evidence is strong: every new branch (exact-present / single-CI / multi-CI / create / SKIP / null-firstName) has a named test, and determinism is asserted by calling twice. That is the discipline I want to see.Blockers
None.
Suggestions
Residual non-unique throw on the exact-case step —
PersonService.java:114and:141.findByAlias/findByFirstNameAndLastNamestill returnOptional, so two byte-identical rows (two persons both with alias exactly"Müller", or two realHans Müller/Hans Müller) will throwNonUniqueResultExceptionon the exact step before the plural fallback is ever reached. The issue defines "ambiguous" as case-insensitive collision with no exact match, so exact dupes are arguably out of scope — but the PR body claims the path "Never throws," which is not strictly true. Either tighten the claim to "never throws on case collision," or make the exact step a plural-min as well. I lean toward the former (scope discipline) but the wording should not overstate.return exact;vsreturn exact.get()asymmetry —findByName(:115) returns theOptionaldirectly whilefindOrCreateByAlias(:142) unwraps with.get(). Both correct given the differing return types, just noting the intentional asymmetry for the next reader. The load-bearing comment covers the why of the divergence well.Function length —
findOrCreateByAliasis now ~40 lines. Still readable because each block is a single concern (exact → CI → institution-create → person-create), but it brushes the "one thing" ceiling. I would not split it given the issue's explicit "preserve the create branch verbatim, do not flatten" instruction — flagging only so we are conscious of it.Verdict stands at approved-with-concerns purely on the overstated "never throws" claim; the code itself is sound.
🏛️ Markus Keller — Senior Application Architect
⚠️ Approved with concerns
This is the right architectural shape: a lookup-layer fix, not a data-model fix. No
unique(lower(...))constraint (correctly rejected — the collisions are valid canonical labels andsource_refis the stable identity per ADR-025), no migration, fully reversible. The deliberate per-surface divergence (alias = lowest-id, name/sender = bail-to-null) is a genuine architectural decision and it is recorded where it belongs — in ADR-032 with the why, not just the what. That is exactly how a decision survives a future refactor.Layering — clean
DocumentService.storeDocument(:222) resolves the sender throughpersonService.findByName(...), never the repository. The fix stays entirely insidePersonService+PersonRepository. No resolution logic leaked up intoDocumentService. Boundary respected.Blockers
docs/adr/032-tag-name-resolution-tolerates-case-collisions.md, but thefeat/issue-684-person-delete-db-fkline independently createddocs/adr/032-person-delete-db-level-integrity.md(already referenced in the recent commit history on this base —ADR-032for person-delete). Two ADR-032s cannot coexist; whichever merges second corrupts the decision log. The issue flags this as "separate housekeeping," but from an architecture-of-the-decision-record standpoint it is a blocker on the repo, not on this diff's logic. Confirm the renumber plan (one becomes 033) before merge, or you will silently lose one ADR's authority.Suggestions
Doc-currency table check — passes. No schema change → no DB-diagram update needed (correct). No new package/service/route → no C4 update (correct).
person/README.mdmethod table updated and thefindByNamemislabel fixed. ADR extended. The only doc gap is the number collision above.The "exact step still returns Optional" residual (see Felix) is acceptable architecturally — pushing exact-dup integrity to a DB constraint would contradict the whole "collisions are valid" stance. Leave it at the application layer; just don't claim absolute non-throw.
Resolve the ADR numbering and this is an approve.
🔒 Nora "NullX" Steiner — Application Security Engineer
✅ Approved
Reviewed for CWE-209 (information exposure through error message), injection, and fail-closed behaviour. This is a security-positive change.
What I checked
CWE-209 opaque error path — pinned, good.
GlobalExceptionHandlerTest.handleGeneric_incorrectResultSize_staysOpaque_noHibernateOrRowCountLeakasserts the wire body is exactly"An unexpected error occurred"/INTERNAL_ERRORand explicitly.doesNotContain("results were returned"),.doesNotContain("NonUnique"),.doesNotContain("IncorrectResultSize"). That is precisely the regression guard I want: even after the throw is removed, a strayIncorrectResultSizeDataAccessExceptioncannot leak the Hibernate class name, SQL, or row count. The Sentry static mock is correctly stubbed so the assertion isolates the handler. Textbook.No SQL/HQL injection. Both new queries (
PersonRepository.java) use named parameters (:firstName,:lastName) with@Param— no string concatenation. Injection-proof by construction.Fail-closed on null first name — and it's a security/integrity property, not just a functional one. A null first name binding as
first_name = NULL(never matches) rather than the derived-query fold tofirst_name IS NULLprevents a last-name-only / institution row from being silently widened in as a document "sender." That is provenance-integrity, and it's proven against real Postgres (findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase_nullFirstName_foldsToNoMatch) — exactly right, because theIS NULLvs= NULLsemantics cannot be caught by a mock. Every fix here started from a failing test.Non-blocking note
Optionalstep can still throwNonUniqueResultExceptionon byte-identical duplicates — but that throw lands on the same opaque generic handler I just verified, so there is no new disclosure surface. No security impact; functional-correctness only (deferred to the developer's call on scope).No permission/auth surface touched (importer + upload paths, already gated upstream). Clean.
🧪 Sara Holt — Senior QA Engineer
⚠️ Approved with concerns
The test strategy here is genuinely good — it respects the pyramid, uses real Postgres where the bug actually lives, and the umlaut folding is pinned at two layers (repo + integration) so a plain-ASCII regression can't slip through. Determinism is asserted by calling twice, not assumed. This is the level of rigour I want on a latent-500 fix.
What's well done
PersonRepositoryTest(@DataJpaTest) andPersonServiceIntegrationTestboth run realpostgres:16. Thenull first name → emptysemantics test is correctly placed at the repo layer against real Postgres (a mock cannot distinguish= NULLfromIS NULL). Exactly the right layer choice.storeDocumentviauploadNamed(...)exercises the upload→sender path end to end (unique→set, single-CI→set, ambiguous→null, last-name-only→null), so the divergent bail-to-null is verified through the actualDocumentService, not just the service method in isolation. Strong.Blockers
None on test code itself.
Concerns
Coverage claim is self-reported, not gate-verified. The PR body lists counts ("PersonServiceTest (64)", "88% branch") and
clean package -DskipTests. -DskipTests means the build success proves nothing about the tests. I want the CI run green on this branch (fullmvn test+ JaCoCo gate) before merge — per project rules the full suite runs in CI, not locally, so this is a "confirm the pipeline is green" gate, not a request to run locally.No test for the exact-case byte-identical duplicate. Felix/Nora flagged that two identical-case rows still throw
NonUniqueResultExceptionon the exact step. If that's deemed out of scope (the issue's "ambiguous" definition supports that), fine — but then add a one-line comment or a@Disabledticket-linked placeholder documenting the known gap, so a future reader doesn't assume it's covered. A silent untested edge is a hidden regression risk.Determinism test uses unordered input (
List.of(higher, lower)) — good, that actually proves themin(comparing(id))and not list order. No change needed; calling it out as a positive.Lift the CI-green confirmation and this is an approve.
🛠️ Tobias Wendt — DevOps & Platform Engineer
✅ Approved
Nothing in my domain to block. No Compose changes, no CI workflow changes, no new service, no image tags, no secrets, no infrastructure surface touched. Code-only and explicitly reversible (roll back the JAR) — which is exactly the deploy profile I like for a latent-bug fix: zero migration, zero ordering constraint, no Flyway step that could wedge startup.
What I checked
unique(lower(...))is operationally correct as well as semantically: a uniqueness index would fail to apply against existing colliding rows and turn a clean deploy into a failed Flyway migration that blocks backend startup. Avoiding that is the right call.:latest, no bind mounts, no exposed ports, no hardcoded creds introduced. N/A for this diff.Operational follow-ups (not blockers, but please track)
The PR's own "Not done here" section names two operational items that genuinely need the prod/observability access I'd own:
Pre-deploy detection SQL not run. The two read-only
GROUP BY … HAVING count(*) > 1queries against prod (alias collisions, name collisions) tell us whether this bug is latent or already firing in production. That changes deploy urgency. These are safe read-only queries via the app read role — run them and attach the counts before deploy so we know what we're shipping into.Post-deploy GlitchTip verification. Search
IncorrectResultSizeDataAccessException/NonUniqueResultExceptionfiltered topersonstack frames, record the group id, confirm zero new occurrences in the 24h after deploy. Standard close-the-loop for a fix whose whole point is "stop throwing 500s."Neither blocks the merge — they're deploy-time checklist items. The code change itself is operationally inert and safe.
📋 Elicit — Requirements Engineer & Business Analyst
✅ Approved
I reviewed this against the acceptance criteria in #731 and the behavioural correctness of the divergent decision, not the implementation. Working in Brownfield mode — this is a bug fix with a well-specified issue, so my job is traceability: does every Gherkin scenario have a corresponding verifying test, and is the one genuinely-new product decision (bail-to-null) sound and documented?
Traceability — all nine scenarios covered
findOrCreateByAlias_returnsExactCaseMatch_overCaseInsensitiveSibling..._returnsExactCaseMatch_evenWhenMultipleSiblingsCollide..._returnsLowestIdDeterministically...+ integration umlaut pairfindOrCreateByAlias_createsMaidenNameAlias_whenGebPresentSKIPearly-return (covered)storeDocument_resolvesSender_whenFilenameNameIsUniquestoreDocument_resolvesSender_onSingleCaseInsensitiveMatchstoreDocument_leavesSenderUnset_whenFilenameNameIsAmbiguousstoreDocument_leavesSenderUnset_whenFilenameHasNoFirstNameEvery acceptance criterion traces to a named test. That is a complete coverage matrix — rare and welcome.
On the decision itself
The bail-to-null-on-ambiguity choice is the correct requirement, and crucially the rationale is captured at the point of change ("a reviewer won't re-check a pre-filled value; correct provenance > confidently-wrong guess"). This is a real domain rule, not an implementation accident — it belongs in the ADR and it is there. The asymmetry with the alias path (lowest-id) is justified by a genuine difference in consequence (no human in the importer loop vs. a human reviewer downstream). No ambiguity, no contradiction, no hidden assumption left unstated.
One open question (non-blocking, already triaged in the issue)
No scope creep, no gold-plating. This does exactly what #731 asked and nothing more.
🎨 Leonie Voss — UX Designer & Accessibility Strategist
✅ Approved
No frontend in this diff — backend lookup logic, repository queries, an ADR, and a README. There are no Svelte components, no markup, no colour tokens, no touch targets, and no copy in my scope here. So this is an LGTM with a note on the downstream user experience the change produces.
What I checked (and why it's fine)
The behavioural outcome of the name/sender path is: on an ambiguous filename, the sender is left unset rather than guessed. I verified the issue confirms the empty-sender state already degrades cleanly:
DocumentRow.sveltealready renders the unknown-sender placeholder when sender is null (tested).DocumentMetadataDrawerhas a no-persons placeholder.metadataComplete=falsealready routes the document into the "needs completion" state for a human to assign.So this change cannot produce a broken or confusing empty state — it reuses an existing, tested degradation path. From a senior-reader-on-a-phone perspective, an empty-but-clearly-flagged sender field is better than a confidently-wrong pre-filled name they'd never think to correct. The product decision is the accessibility-friendly one.
Follow-up I want to see filed (not a blocker on this PR)
The issue already specs an optional inline hint so the transcriber learns why the sender is empty, and it's correctly deferred to a separate low-priority issue. When that gets picked up, the a11y spec from the issue must hold: redundant cue (info icon + text, not colour-only), German copy "Absender konnte aus dem Dateinamen nicht eindeutig zugeordnet werden", min 16px,
text-inkonbg-surface(do not usebrand-mintas text — ~2.8:1 fails AA),aria-describedbytying the hint to the sender field, and keep "nicht eindeutig zuordenbar" (ambiguous) copy distinct from "kein Absender" (genuinely unknown). Please confirm that issue exists so it doesn't get lost.Nothing to change here.
🔁 Round-2 re-review (clean agent)
Re-reviewed at head
cd741b9fagainst the seven round-1 persona reviews. Verified the source atPersonService.java,PersonRepository.java,docs/adr/032-tag-name-resolution-tolerates-case-collisions.md, the four test files, CI run state, and the ADR file inventory on the base. I did not write this code.Concern 1 — "Never throws" overstated (Felix / Nora / Sara) — ✅ RESOLVED
A load-bearing scope comment now sits at both exact-case lookups:
findByName(PersonService.java:114-116) andfindOrCreateByAlias(:144-146), each stating that two byte-identical same-case rows are an out-of-scope data anomaly that the exactOptionalsurfaces as the opaqueINTERNAL_ERROR— never a wrong row. ADR-032 gains a matching "Scope — ambiguous is case-insensitive only" bullet making the same admission. The QA ask (a comment documenting the known gap) is satisfied. Minor residual: commit-1's description line in the PR body still reads "Never throws" unqualified — harmless, since every code/ADR/README surface now scopes the claim to case-collision. Not a blocker.Concern 2 — ADR-032 number collision (Markus) — ✅ RESOLVED (out of scope, correctly)
This PR touches exactly one 032 file (
032-tag-name-resolution-tolerates-case-collisions.md) and only extends it with the Person section — matching issue #731's explicit decision. Confirmed viagit diff --name-only origin/main...HEAD. Note for the record: the second file,032-person-delete-db-level-integrity.md, is already onorigin/main(merged via #684, commit73dd6c80), so the number collision is live on main now — but it predates this branch and is not this PR's to fix. The author handled this exactly right: do not renumber here; the cleanup belongs to a separate housekeeping pass.Concern 3 — CI green (Sara) — ✅ RESOLVED
Actions run #2066 on
ddf378aa(the full implementation commit) completedconclusion=success— fullmvn test+ 88% JaCoCo gate. Run #2067 oncd741b9fis stillin_progress, but that commit is docs/comments-only (ADR + README + in-code comments); the compiled test set and coverage-bearing code are byte-identical toddf378aa, so the gate result carries.Concern 4 — Deferred ops: pre-deploy detection SQL + post-deploy GlitchTip (Tobias) — ✅ RESOLVED (out of scope, correctly)
Both are documented in the PR body's "Not done here" section and require prod/observability access. Explicitly deploy-time checklist items, not merge blockers — correctly deferred.
Concern 5 — UX follow-up issue (Leonie / Elicit) — ✅ RESOLVED
Filed as issue #746 (labels feature / ui / P3-later), carrying the a11y spec (redundant cue, German copy, 16px,
text-inkonbg-surface,aria-describedby). The UX gap will not fall through.Fresh sanity pass
DocumentService.storeDocumentresolves the sender viapersonService.findByName(...), no resolution logic leaked into the document domain.@Param-bound named parameters (no concatenation); the fail-closed null-first-name semantics (= :firstName, not derivedIS NULL) are pinned by a real-Postgres repo test and an integration test.min(Comparator.comparing(Person::getId)).orElseThrow()is provably non-throwing (guarded by!isEmpty()), satisfying the no-bare-.get()rule.Overall verdict: ✅ Approved
All five round-1 concerns are resolved (three directly, two correctly out-of-scope). No blockers remain. Only follow-up that is genuinely actionable in this repo — independent of this PR — is renumbering one of the two live ADR-032 files on
main; that is housekeeping for a separate change, not a gate on #745.