diff --git a/docs/adr/032-person-delete-db-level-integrity.md b/docs/adr/032-person-delete-db-level-integrity.md new file mode 100644 index 00000000..de44a50c --- /dev/null +++ b/docs/adr/032-person-delete-db-level-integrity.md @@ -0,0 +1,62 @@ +# ADR-032 — Person-delete referential integrity lives in the database, and the cascade never reaches `documents` + +**Date:** 2026-06-06 +**Status:** Accepted +**Issue:** #684 (move person-delete FK detach to database-level `ON DELETE`) +**Milestone:** — + +--- + +## Context + +Deleting a `Person` had to detach the two FKs into `persons` that lacked any `ON DELETE` +behaviour: `documents.sender_id` and `document_receivers.person_id` (both from V1). +`PersonService.deletePerson` and `mergePersons` did this in Java — nulling the sender and +deleting receiver join rows before `deleteById` — so the integrity guarantee lived in +application code. Any other delete path (a future endpoint, a manual `psql`, a batch job) +could still orphan rows or fail with an FK-violation 500. + +A related soft reference made it worse: `transcription_block_mentioned_persons.person_id` +was a UUID column with **no FK** (V56, a deliberate "no FK" choice), so a person delete left +dangling `@`-mention rows. The literal `@DisplayName` lives in `transcription_blocks.text`, +so only the *link* was ever at stake — not the visible name. + +## Decision + +Move person-delete integrity into the database (migration V71) and thin the service to a +plain `deleteById`: + +- `documents.sender_id` → `ON DELETE SET NULL` (`documents.senderText` preserves the raw + textual attribution, so nulling the link loses no historical record). +- `document_receivers.person_id` → `ON DELETE CASCADE` (the symmetric completion of V14, + which gave the `document_id` side the same). +- `transcription_block_mentioned_persons.person_id` → a real FK with `ON DELETE CASCADE`, + reversing V56's "no FK" decision. The read renderer already degrades a `@DisplayName` with + no sidecar row to plain escaped text, so removing the link is invisible to the reader. + +**Cascade-boundary invariant:** the cascade stays strictly at the join/reference layer and +**never reaches `documents` rows** — a cascade into `documents` would destroy historical +letters. This is pinned by a non-negotiable document-survival assertion in +`PersonRepositoryTest`. + +## Consequences + +- A person delete is safe from every path, not just `PersonService`. The service and merge + stay thin (`deleteById` + the cascade); `reassignSenderToNull` and `deleteReceiverReferences` + are deleted. +- This *fixes* the pre-existing dead-link-on-deleted-person case — it is not a purely + invisible refactor. +- DB cascades run below `AuditService`, so the row-level cleanup is intentionally not + audit-logged; the person-delete action itself is still logged at the service layer. +- The V71 FK validation requires cleaning pre-existing orphan mention rows first; the + migration does this in a `DO` block that logs the purge count via `RAISE NOTICE`. + +## Alternatives considered + +- **Keep integrity in Java** — rejected; it only protects the one code path and re-breaks the + moment a second delete path appears. +- **Cascade `documents.sender_id`** — rejected; it would delete historical letters when a + sender is removed. `SET NULL` keeps the letter and its `senderText`. +- **Leave the mention sidecar FK-less (honour V56)** — rejected; the "no FK" rationale was + stale, the name survives in the block text regardless, and the FK removes the orphan-row + class of bug.