fix(tag): resolve case-colliding tag names without throwing (#730) #733
Reference in New Issue
Block a user
Delete Branch "fix/issue-730-tag-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?
Fixes #730.
Problem
Saving any edit to a document failed with HTTP 500 when the document carried a tag whose name collides case-insensitively with another tag in the canonical tree (e.g. parent
Weihnachten+ childweihnachten). Every save re-resolves the document's tags by name viaTagService.findOrCreate, which didtagRepository.findByNameIgnoreCase(name)→Optional<Tag>. Two case-insensitive matches →NonUniqueResultException→ generic 500. ~180 document-tag attachments across 10 canonical tags were un-editable on staging, with no end-user workaround.The colliding names are legitimate distinct tree nodes (own
source_ref, own document attachments) — a lookup bug, not a data bug.Fix (code-only, zero migration, fully reversible)
TagService.findOrCreatenow resolves unambiguously and never throws:findByName(name)— exact-case wins (the edit round-trip replays the stored name),findAllByNameIgnoreCase(name)→ lowest-id match (deterministic tie-break, never throws),Optional<Tag> findByNameIgnoreCaseis deleted so the throwing call can't be reintroduced (its only production caller wasfindOrCreate). Load-bearing comments atfindOrCreateand the repo methods warn against adding aunique(lower(name))constraint — it would be wrong (collisions are valid) and would fail to apply against the existing rows.Single source of truth preserved: single-doc edit, bulk-edit, and upload-batch all funnel through
resolveTags → findOrCreate, so this one change fixes all three.Tests
TagServiceTest(mocked repo) — four resolution branches: exact-case wins; single CI; multiple CI → lowest id asserted across two calls (proves determinism, not luck); create-when-absent; whitespace+collision. The 3 pre-existingfindByNameIgnoreCasemocks were updated.TagCaseCollisionIntegrationTest(Testcontainerspostgres:16-alpine) — saving a doc tagged with childweihnachtenkeeps exactly the child (id-asserted, not the parent); theGlückwünsche/glückwünscheumlaut pair resolves deterministically without throwing (the regression a plain-ASCII test would miss — only the real DB provesLOWER()foldsü); bulk-edit path guarded.TagServiceTest45 ✅ ·TagCaseCollisionIntegrationTest3 ✅ ·DocumentServiceTest187 ✅ ·clean package✅.Notes
GlobalExceptionHandlerkeeps the wire response opaque (no Hibernate/SQL leak) — verified, no new handler needed.PersonService.findOrCreateByAlias, latent), #732 (round-trip tag IDs instead of names — the cleaner long-term shape, out of scope here).🤖 Generated with Claude Code
Mocked TagServiceTest can't prove the two things that actually broke: that findAllByNameIgnoreCase folds umlauts the way Postgres LOWER() does, and that saving a document tagged with a case-colliding tag no longer throws NonUniqueResultException. Testcontainers postgres:16-alpine: - updateDocument on a doc tagged with the child "weihnachten" succeeds and keeps exactly the child tag (not the parent). - findOrCreate("GLÜCKWÜNSCHE") resolves the Glückwünsche/glückwünsche umlaut pair deterministically (lowest id) without throwing — the regression catcher a plain-ASCII pair would miss. - bulk-edit funnels through resolveTags → findOrCreate, guarding a future refactor that bypasses it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>👨💻 Felix Brandt — Senior Fullstack Developer
Verdict: ⚠️ Approved with concerns
I reviewed the four changed files against the TDD discipline, clean-code, and reliability lenses. The fix is small, correct, and the single-source-of-truth funnel (
resolveTags → findOrCreate) is preserved exactly as the issue demanded. The deletedfindByNameIgnoreCasehas no remaining callers anywhere inbackend/src(main or test) — I grepped; the only production caller wasfindOrCreate, so removing it to prevent reintroduction is clean and correct.Blockers
None that block merge. The behaviour is right and tested.
Suggestions
TDD red-phase evidence is asserted in prose, not provable from the diff.
TagService.java:67-73and the rewrittenTagServiceTesttests look correct, but the PR rewrites the three existing mocks in the same commit as the production change. BecausefindByNameIgnoreCaseis deleted, the old tests would not have compiled — so there is no moment where the new tests ran red against the old implementation. That is acceptable for a method-signature swap, but the determinism test (findOrCreate_returnsLowestIdDeterministically...) is the one behaviour that genuinely needed a red phase, and I'd want to trust it failed against a naive.get(0)implementation. Consider noting in the PR that the determinism assertion was verified to fail againstfindFirst()-style code.Optional.get()on line 71 (.min(...).get()) violates our own guard-clause rule (developer.md: "neverOptional.get()without guard"). It is safe here because the list is non-empty (guarded by!caseInsensitive.isEmpty()on line 70), but.orElseThrow()would read with intent and survive a future refactor that moves the emptiness check. Minor.Comment density. Lines 68/71/73 carry trailing
//comments explaining what the code does. Our style says rename/extract rather than comment the "what". The// never throwand// exact-case winsnotes are arguably why comments (they encode the #730 contract), so I'd keep them — but they sit right at the edge of the rule. Fine as-is given the load-bearing nature.Guard-clause style at line 68 (
if (exact.isPresent()) return exact.get();) is clean and reads well — good. No nesting. This is the shape I want.Solid, disciplined fix. Ship after a nod on point 1.
🏛️ Markus Keller — Senior Application Architect
Verdict: ⚠️ Approved with concerns
This is the right architectural call, and I want to say so plainly: the issue correctly rejects the instinct to add a
unique(lower(name))constraint. Normally I push integrity down to PostgreSQL — but here case-collision is a valid domain state (a parent container and its same-named lowercase child are distinct canonical nodes, each with its ownsource_refand document attachments). A DB uniqueness constraint would both reject legitimate rows and fail to apply against the existing 10 staging rows, turning a clean code deploy into a failed Flyway migration that blocks startup. The load-bearing comment atTagRepository.java:23-27documenting this is exactly the kind of "memory of why" I want in the codebase. Good.The identity model is sound:
source_ref(tag_path) is the stable identity per ADR-025;nameis a human-editable label.findByNameIgnoreCasewas always a leaky identity that only held while names were globally case-insensitively unique — an invariant the canonical tree explicitly violates. Resolving by name is a smell, and the PR honestly names the cleaner long-term shape (round-trip IDs) as out-of-scope follow-up #732. Correct scoping.Blockers
docs/adr/. Right now the rationale lives only in a repo comment and the issue body — both of which are easy to lose. A short ADR (Context: canonical tree has valid case-colliding nodes; Decision: exact-case → lowest-id CI → create, nounique(lower(name)); Alternatives: DB constraint [rejected], ID round-trip [deferred to #732]; Consequences: free-textweihnachtenbinds to the deep child) would lock this in. I treat a missing ADR for a decision of this weight as a blocker, not a concern.Suggestions
Glossary. If "case-colliding tag" / "orphan tag" (the null-
sourceRef/parentIdtag created on line 73) are not already indocs/GLOSSARY.md, add them — both are now load-bearing domain terms.Layering is respected:
DocumentService.resolveTagscallsTagService.findOrCreate, neverTagRepository. No boundary leak. Good.No DB diagram update needed — no schema change. Approve once the ADR lands.
🧪 Sara Holt — Senior QA Engineer
Verdict: ⚠️ Approved with concerns
The test strategy is genuinely good: the four resolution branches are unit-tested with a mocked repo (fast layer), and the things a mock cannot prove — Postgres
LOWER()folding and the real save round-trip — are pinned with Testcontainerspostgres:16-alpine(correct: never H2 for this). The umlaut pair is the right instinct; a plain-ASCII test would stay green whileü/äfolding regressed. The determinism test callingfindOrCreatetwice and asserting the same id is exactly how you distinguish "deterministic" from "happened to pick this one". I want to highlight that as a model.Blockers
None.
Suggestions
The integration "umlaut" test does NOT actually exercise the multi-row case-insensitive resolution branch with full confidence — verify the seeding. In
findOrCreate_resolvesUmlautCollisionDeterministically_withoutThrow, the query is"GLÜCKWÜNSCHE"(all-caps) and the test asserts no exact-case row exists so it falls through to the CI branch. Good. But it leans onassertThat(tagRepository.findAllByNameIgnoreCase("glückwünsche")).hasSize(2)to prove folding. That assertion proves the repository folds correctly, but the subsequentfindOrCreate("GLÜCKWÜNSCHE")is a different input string — if Postgres foldedÜ→üfor the lowercase query but not for the uppercase one (it won't, but the test doesn't prove symmetry), the two could diverge. Tiny gap. Consider assertingfindAllByNameIgnoreCase("GLÜCKWÜNSCHE")(the actual resolution input) also returns 2, so the proof matches the path under test.No
@Transactionalrollback concern, but watch cross-test tag bleed. The class is@Transactionalso each test rolls back — good, deterministic. But all three tests seedWeihnachten/weihnachtenorGlückwünschewith the same names; if the rollback ever weakened (e.g. a@Commitslips in, or a nested new-transaction service call), you'd get >2 CI matches and silent flake. The id-asserted assertions would catch it, so acceptable.Missing negative/edge cases at the unit layer: no test for
findOrCreatewith leading/trailing case-collision where exact-case and a CI sibling both exist but the exact row has the higher id — proving exact-case wins over the lowest-id rule. The exact-case test exists, but not the adversarial "exact-case row is not the lowest id" variant. One more unit test closes it.Bulk-edit test is a good regression guard against a future refactor bypassing
findOrCreate— exactly the "thin bulk-edit assertion" the issue asked for. The upload-batch path is only covered by the shared-code comment, which the issue explicitly accepted. Fine.Strong coverage. Address point 1's symmetry gap and I'm fully satisfied.
🔐 Nora "NullX" Steiner — Application Security Engineer
Verdict: ✅ Approved
I reviewed this for injection, exception opacity, authorization, and information disclosure. No security regressions; the change actually closes a small availability/info-leak surface.
What I checked and why it's safe:
Exception opacity (the issue's verification item). I traced
GlobalExceptionHandler.java. There is no dedicated handler forIncorrectResultSizeDataAccessException/NonUniqueResultException; they fall through to@ExceptionHandler(Exception.class)(line ~100), which returns a fixedErrorResponse(ErrorCode.INTERNAL_ERROR, "An unexpected error occurred")and routes the stack trace to Sentry viaSentry.captureException(ex). So the Hibernate/SQL detail never reached the wire — the response body was already opaque. The PR's claim is accurate; no new handler is needed. More importantly, this fix removes the throw path entirely, so the DoS-flavoured failure (every save on ~180 doc-tag attachments returning 500) can no longer be triggered by ordinary user input. Net availability improvement.No injection introduced.
findByNameandfindAllByNameIgnoreCaseare Spring Data derived queries — parameterized by construction, no JPQL/SQL string concatenation. User-supplied tag names flow through bound parameters only. Clean (CWE-89 not applicable).No mass-assignment / IDOR. Resolution is by name→entity within the document's own tag set; no client-controlled ID is trusted to cross an authorization boundary. The write entry points (
updateDocument,applyBulkEditToDocument) retain their existing@RequirePermission(WRITE_ALL)enforcement at the controller — unchanged by this diff.Determinism is also a mild anti-abuse property. Lowest-id tie-break means a malicious actor cannot influence which of two colliding tags they bind to by racing inserts — the oldest row always wins. Fine.
Blockers
None.
Suggestions
No CWE findings. Approved.
🛠️ Tobias Wendt — DevOps & Platform Engineer
Verdict: ✅ Approved
This is the kind of fix I like: code-only, zero-migration, fully reversible by rolling back the JAR. No Compose change, no new service, no env var, no schema touch. From an operability standpoint there is nothing that can fail at deploy time that wasn't already there.
What I checked:
No Flyway migration in the PR's 4-file scope. Confirmed — and that is the correct decision. A
unique(lower(name))index would have been an operational landmine: it would fail to apply against the existing 10 colliding rows on staging, and a failed migration blocks Spring Boot startup (Flyway runs on boot). That converts a clean rollout into a wedged container. The PR explicitly resisting this is the right call for a single-VPS deployment where a failed migration = downtime.Testcontainers, not H2.
TagCaseCollisionIntegrationTestpinspostgres:16-alpinevia@Import(PostgresContainerConfig.class)— image tag is pinned (good, reproducible) and matches the production engine. This runs in CI integration layer; no shared staging DB dependency, ephemeral container per run. Deterministic. This is exactly how migrations/queries should be verified before they hit prod.Rollback story. Reverting is
git revert+ redeploy JAR. The deletedfindByNameIgnoreCasewould come back, but no data has changed, so there is no asymmetric rollback hazard. Clean.Post-deploy verification. The issue lays out a sensible staging smoke check (grab the
NonUniqueResultExceptionGlitchTip group, save the4423782d…repro doc → expect 200, spot-check one doc per colliding tag). I'd add: make that a scripted smoke assertion rather than a manual log-read, consistent with our "automate post-deploy verification" rule. Not a blocker — it's a runbook nicety.Blockers
None.
Suggestions
IncorrectResultSizeDataAccessExceptionstops receiving new events (theSentry.captureExceptionin the generic handler will have been feeding it). A silent drop-to-zero is your proof the fix took.No infra concerns. Approved.
📋 Elicit — Requirements Engineer & Business Analyst
Verdict: ⚠️ Approved with concerns
I assessed this against the issue's acceptance criteria (the three Gherkin scenarios) and the requirements-completeness lens — does the implementation satisfy what was specified, and are the unspecified behaviours surfaced rather than silently resolved?
Acceptance-criteria traceability — all three map to tests:
updateDocument_succeedsAndKeepsExactChildTag_whenTaggedWithCaseCollidingChildasserts both no-throw and the surviving tag is the child's id, not the parent's. The criterion said "keeps exactly theweihnachtentag (notWeihnachten)" — the test assertshasSize(1)+ id equality. ✅ Fully covered, including the "No 500 alone is too weak" caveat.findOrCreate_exactCaseWins_overCaseInsensitiveSibling. ✅findOrCreate_returnsLowestIdDeterministically...(twice-called, same-id). ✅Every Must in the issue is traceable to a named test. That is the standard I want.
Blockers
None — no acceptance criterion is unmet.
Concerns / Open Questions (surfacing, not resolving)
Free-text authoring semantics are an accepted-but-user-invisible behaviour. The issue's "Decision (option A)" explicitly accepts that typing bare
weihnachtenin new-doc/bulk-edit binds to the deep child (Weihnachten/weihnachten), whileWeihnachtenbinds to the parent container. That is a latent usability cliff: an author cannot tell, from the typeahead, that they are tagging a leaf vs. a container. The issue correctly defers the typeahead/chip disambiguation to a follow-up — but I want to confirm a follow-up issue actually exists for "show tree path in tag typeahead so a container is distinguishable from its same-named child." The PR notes #731 (PersonService sibling) and #732 (ID round-trip) but I do not see the typeahead-disambiguation follow-up referenced. If it's unfiled, it will be lost. Please file it or point me at it.Observability acceptance item is unverified in-PR. The issue lists a verification step: confirm no new
NonUniqueResultExceptionoccurrences post-deploy on staging via the GlitchTip group + the4423782d…repro. That is a real acceptance gate that cannot be ticked from the diff alone — it requires the deploy + proofshot. Flagging it as an open item against "done," not against the code.No ambiguity in the Must requirements — the three scenarios are testable and unambiguous. Good. The single residual ambiguity (free-text binding direction) was consciously resolved by the product owner as Option A, documented in the issue. I accept it as a deliberate decision, not a gap.
Approve the code; please confirm the typeahead follow-up is filed so concern #1 doesn't evaporate.
🎨 Leonie Voss — UX Designer & Accessibility Strategist
Verdict: ✅ Approved (LGTM — out of my domain, with one downstream flag)
This is a backend-only change: four files touching
TagService,TagRepository, and their tests. There is no Svelte component, no markup, no CSS token, no message-catalog (messages/{de,en,es}.json) change in this PR's scope. So there is nothing here for me to assess on contrast, touch targets, focus indicators, semantic HTML, or 320px layout. I checked specifically for any frontend or i18n surface in the diff and found none.What I can speak to is the user-facing experience this fix restores, and it's a meaningful win for our audience:
weihnachten-tagged letter changed the date, hit save, and got the opaque generic error ("Ein unerwarteter Fehler ist aufgetreten") with no hint that a tag was the cause and no workaround. That is a Critical-severity failure by my rubric — it blocks the core task and the error message fails Nielsen heuristic #9 (help users diagnose and recover). This PR removes the failure entirely. Good.Blockers
None in my domain.
Suggestion (downstream, not this PR)
weihnachtensilently binds to the deep child andWeihnachtento the parent container — but the tag chip and typeahead give zero visual cue which one they got. For our senior authors especially, "looks the same, behaves differently" is a recognition-over-recall failure (heuristic #6). When that follow-up is built, the fix is concrete: show the tree path in the typeahead row and on the chip (e.g.Weihnachten / weihnachtenwith the parent segment intext-ink-3), so a container is visually distinct from its same-named leaf. Tag that issue for my review.Nothing to change here. Approved.
Review feedback addressed ✅
Thanks all. Pushed
80f6468d..bafbf609.Markus (Architect) — 🚫→ blocker resolved
bafbf609docs/adr/032-tag-name-resolution-tolerates-case-collisions.md. Records exact-case-first resolution, the deterministic lowest-id fallback, and the explicit refusal of aunique(lower(name))constraint, with consequences/alternatives. The decision no longer lives only in code comments + the issue.Sara (QA)
2710f2e2. The precondition assertsfindAllByNameIgnoreCase("GLÜCKWÜNSCHE")(the uppercase stringfindOrCreateactually passes), so it exercisesLOWER('GLÜCKWÜNSCHE')folding rather than a lowercase probe.2710f2e2,findOrCreate_exactCaseWins_evenWhenItsIdIsNotTheLowest: the exact row carries the higher id andfindAllByNameIgnoreCaseis verified never called, proving exact-case short-circuits before the lowest-id rule (a naive "lowest id across all CI matches" would fail this).Felix (Dev)
.get()→.orElseThrow()—80f6468d. The stream is guarded non-empty, but this honours the project's no-Optional.get()rule.findOrCreate_exactCaseWins…(assertedexpected geburt but was nullbefore the rewrite). The determinism/multi-CI branch was written in the green step, not isolated-red — being transparent: its red wasn't separately captured. The newevenWhenItsIdIsNotTheLowesttest plus the verifiednever()interaction now pin the ordering adversarially.Elicit + Leonie (Reqs/UX)
Verification:
TagServiceTest46 ✅ ·TagCaseCollisionIntegrationTest3 ✅ ·clean package✅.marcel referenced this pull request2026-06-06 12:19:22 +02:00
marcel referenced this pull request2026-06-06 12:20:02 +02:00