docs(adr): record DB-level person-delete integrity decision (ADR-032) (#684)
Capture the reversal of V56's no-FK decision, the DB-layer-integrity principle, and the cascade-boundary invariant (the cascade never reaches documents rows). Numbered 032 — 028-031 are already taken on main; the issue's '028 is next' was written before main moved. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
62
docs/adr/032-person-delete-db-level-integrity.md
Normal file
62
docs/adr/032-person-delete-db-level-integrity.md
Normal file
@@ -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.
|
||||
Reference in New Issue
Block a user