docs(adr): renumber tag case-collision ADR 032 → 033 to resolve number clash (#731)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m15s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m40s
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
CI / Unit & Component Tests (push) Successful in 3m13s
CI / OCR Service Tests (push) Successful in 23s
CI / Backend Unit Tests (push) Successful in 3m40s
CI / fail2ban Regex (push) Successful in 46s
CI / Semgrep Security Scan (push) Successful in 21s
CI / Compose Bucket Idempotency (push) Successful in 1m7s
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m15s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m40s
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
CI / Unit & Component Tests (push) Successful in 3m13s
CI / OCR Service Tests (push) Successful in 23s
CI / Backend Unit Tests (push) Successful in 3m40s
CI / fail2ban Regex (push) Successful in 46s
CI / Semgrep Security Scan (push) Successful in 21s
CI / Compose Bucket Idempotency (push) Successful in 1m7s
Both #730 (tag case-collision) and #684 (person-delete DB integrity) landed an ADR-032 on main. Renumber the tag/case-collision one to 033 — it is referenced only from this PR's person-domain comments and its own file, so the move is self-contained and touches no Flyway migration. The person-delete ADR-032 and the V71 migration comment that cites it are deliberately left untouched (editing an applied migration would drift its Flyway checksum). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit was merged in pull request #745.
This commit is contained in:
@@ -31,13 +31,13 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
|||||||
|
|
||||||
// Exact-case alias lookup — the first resolution step in findOrCreateByAlias.
|
// Exact-case alias lookup — the first resolution step in findOrCreateByAlias.
|
||||||
// Case-colliding aliases across persons (müller / Müller) are valid human labels, NOT
|
// Case-colliding aliases across persons (müller / Müller) are valid human labels, NOT
|
||||||
// duplicates: source_ref is the stable identity (ADR-025/032), alias is editable. Do NOT
|
// duplicates: source_ref is the stable identity (ADR-025/033), alias is editable. Do NOT
|
||||||
// add a unique(lower(alias)) constraint — see ADR-032.
|
// add a unique(lower(alias)) constraint — see ADR-033.
|
||||||
Optional<Person> findByAlias(String alias);
|
Optional<Person> findByAlias(String alias);
|
||||||
|
|
||||||
// Plural case-insensitive alias lookup — the fallback step. Returns ALL case-folding
|
// Plural case-insensitive alias lookup — the fallback step. Returns ALL case-folding
|
||||||
// siblings so the service can pick a deterministic one (lowest id) instead of letting a
|
// siblings so the service can pick a deterministic one (lowest id) instead of letting a
|
||||||
// derived Optional<…>IgnoreCase throw NonUniqueResultException. See ADR-032.
|
// derived Optional<…>IgnoreCase throw NonUniqueResultException. See ADR-033.
|
||||||
List<Person> findAllByAliasIgnoreCase(String alias);
|
List<Person> findAllByAliasIgnoreCase(String alias);
|
||||||
|
|
||||||
// Lookup by the normalizer person_id, used for idempotent canonical re-import (Phase 3).
|
// Lookup by the normalizer person_id, used for idempotent canonical re-import (Phase 3).
|
||||||
@@ -46,7 +46,7 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
|||||||
// Exact-case first+last name match — the first step of filename-based sender resolution.
|
// Exact-case first+last name match — the first step of filename-based sender resolution.
|
||||||
// Explicit `=` (HQL, not a derived query) so a null firstName binds as `first_name = NULL`
|
// Explicit `=` (HQL, not a derived query) so a null firstName binds as `first_name = NULL`
|
||||||
// — never a match — instead of the derived-query fold to `first_name IS NULL`, which would
|
// — never a match — instead of the derived-query fold to `first_name IS NULL`, which would
|
||||||
// pull a last-name-only row in as a sender (a provenance defect). See ADR-032.
|
// pull a last-name-only row in as a sender (a provenance defect). See ADR-033.
|
||||||
@Query("SELECT p FROM Person p WHERE p.firstName = :firstName AND p.lastName = :lastName")
|
@Query("SELECT p FROM Person p WHERE p.firstName = :firstName AND p.lastName = :lastName")
|
||||||
Optional<Person> findByFirstNameAndLastName(@Param("firstName") String firstName,
|
Optional<Person> findByFirstNameAndLastName(@Param("firstName") String firstName,
|
||||||
@Param("lastName") String lastName);
|
@Param("lastName") String lastName);
|
||||||
@@ -54,7 +54,7 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
|||||||
// Plural case-insensitive first+last name match — lets findByName bail to empty on 2+ matches
|
// Plural case-insensitive first+last name match — lets findByName bail to empty on 2+ matches
|
||||||
// instead of letting a derived Optional<…>IgnoreCase throw NonUniqueResultException. Same
|
// instead of letting a derived Optional<…>IgnoreCase throw NonUniqueResultException. Same
|
||||||
// null fail-closed guarantee as above: LOWER(:firstName) is NULL for a null arg, so a null
|
// null fail-closed guarantee as above: LOWER(:firstName) is NULL for a null arg, so a null
|
||||||
// first name resolves to no match (not first_name IS NULL widening). See ADR-032.
|
// first name resolves to no match (not first_name IS NULL widening). See ADR-033.
|
||||||
@Query("SELECT p FROM Person p WHERE LOWER(p.firstName) = LOWER(:firstName) "
|
@Query("SELECT p FROM Person p WHERE LOWER(p.firstName) = LOWER(:firstName) "
|
||||||
+ "AND LOWER(p.lastName) = LOWER(:lastName)")
|
+ "AND LOWER(p.lastName) = LOWER(:lastName)")
|
||||||
List<Person> findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase(@Param("firstName") String firstName,
|
List<Person> findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase(@Param("firstName") String firstName,
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ public class PersonService {
|
|||||||
// UNSET rather than picking the lowest id. The archive's value is correct provenance — a
|
// UNSET rather than picking the lowest id. The archive's value is correct provenance — a
|
||||||
// confidently-wrong pre-filled "Hans Müller" is worse than an empty field, because a
|
// confidently-wrong pre-filled "Hans Müller" is worse than an empty field, because a
|
||||||
// reviewer won't re-check a pre-filled value. Do NOT "consistency-clean" this into the
|
// reviewer won't re-check a pre-filled value. Do NOT "consistency-clean" this into the
|
||||||
// lowest-id fallback. See ADR-032.
|
// lowest-id fallback. See ADR-033.
|
||||||
return caseInsensitive.size() == 1 ? Optional.of(caseInsensitive.get(0)) : Optional.empty();
|
return caseInsensitive.size() == 1 ? Optional.of(caseInsensitive.get(0)) : Optional.empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,7 +140,7 @@ public class PersonService {
|
|||||||
|
|
||||||
// Aliases differing only by case (müller / Müller) are valid distinct persons, not
|
// Aliases differing only by case (müller / Müller) are valid distinct persons, not
|
||||||
// duplicates, so a CASE-COLLISION must not throw: exact-case first, then the lowest-id
|
// duplicates, so a CASE-COLLISION must not throw: exact-case first, then the lowest-id
|
||||||
// case-insensitive sibling, then create. Mirrors the tag path — see ADR-032.
|
// case-insensitive sibling, then create. Mirrors the tag path — see ADR-033.
|
||||||
// Scope (#731): "ambiguous" means case-insensitive. Two BYTE-IDENTICAL same-case aliases
|
// Scope (#731): "ambiguous" means case-insensitive. Two BYTE-IDENTICAL same-case aliases
|
||||||
// are a true data anomaly out of scope here; the exact Optional below would surface that
|
// are a true data anomaly out of scope here; the exact Optional below would surface that
|
||||||
// as the opaque INTERNAL_ERROR (never a wrong row), not silently pick one.
|
// as the opaque INTERNAL_ERROR (never a wrong row), not silently pick one.
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ Features: person CRUD, name alias management, person merge (deduplication), fami
|
|||||||
| `getById(UUID)` | document, geschichte, ocr | Fetch one person by ID |
|
| `getById(UUID)` | document, geschichte, ocr | Fetch one person by ID |
|
||||||
| `getAllById(List<UUID>)` | document | Bulk fetch for sender/receiver resolution |
|
| `getAllById(List<UUID>)` | document | Bulk fetch for sender/receiver resolution |
|
||||||
| `findAll(String q)` | document, dashboard | List all persons |
|
| `findAll(String q)` | document, dashboard | List all persons |
|
||||||
| `findByName(String firstName, String lastName)` | document | Filename-based **sender resolution** in `storeDocument`: exact-case match → single case-insensitive match → else **empty** (ambiguous names leave the sender unset; a null first name never matches). See ADR-032. |
|
| `findByName(String firstName, String lastName)` | document | Filename-based **sender resolution** in `storeDocument`: exact-case match → single case-insensitive match → else **empty** (ambiguous names leave the sender unset; a null first name never matches). See ADR-033. |
|
||||||
| `findOrCreateByAlias(String rawName)` | importing | Idempotent create during mass import; type classification happens internally. Resolves exact-case → lowest-id case-insensitive sibling → create — never throws on case-colliding aliases. See ADR-032. |
|
| `findOrCreateByAlias(String rawName)` | importing | Idempotent create during mass import; type classification happens internally. Resolves exact-case → lowest-id case-insensitive sibling → create — never throws on case-colliding aliases. See ADR-033. |
|
||||||
| `findAllFamilyMembers()` | dashboard | Family member list for stats |
|
| `findAllFamilyMembers()` | dashboard | Family member list for stats |
|
||||||
| `findCorrespondents()` | document | Correspondent list for conversation filter |
|
| `findCorrespondents()` | document | Correspondent list for conversation filter |
|
||||||
| `count()` | dashboard | Total person count for stats |
|
| `count()` | dashboard | Total person count for stats |
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# ADR-032 — Tag-name resolution tolerates case-collisions: exact-case first, then a deterministic lowest-id fallback, and never a `unique(lower(name))` constraint
|
# ADR-033 — Tag-name resolution tolerates case-collisions: exact-case first, then a deterministic lowest-id fallback, and never a `unique(lower(name))` constraint
|
||||||
|
|
||||||
**Date:** 2026-06-06
|
**Date:** 2026-06-06
|
||||||
**Status:** Accepted
|
**Status:** Accepted
|
||||||
Reference in New Issue
Block a user