Move person-delete FK detach to database-level ON DELETE (#684) #736

Merged
marcel merged 10 commits from feat/issue-684-person-delete-db-fk into main 2026-06-06 12:34:47 +02:00
Owner

Closes #684.

Moves person-delete referential integrity from application code into the database, removes the now-redundant app-layer detach, simplifies mergePersons, and closes the @-mention soft-reference gap. This fixes the pre-existing dead-link-on-deleted-person case — it is not a purely invisible refactor.

What changed

  • V71__person_delete_on_delete_fk.sql (one atomic migration):
    • documents.sender_idON DELETE SET NULL (document survives; senderText preserves the raw attribution).
    • document_receivers.person_idON DELETE CASCADE (symmetric completion of V14).
    • transcription_block_mentioned_persons.person_id → real FK with ON DELETE CASCADE, reversing V56's "no FK". Pre-existing orphan rows are purged first in a DO/RAISE NOTICE block (Flyway discards a trailing SELECT).
    • Stale V56 comment rewritten.
  • PersonService.deletePerson thinned to getById + deleteById; mergePersons drops the explicit deleteReceiverReferences and leans on the cascade.
  • PersonRepository: reassignSenderToNull + deleteReceiverReferences deleted; reassignSender/insertMissingReceiverReference get clearAutomatically/flushAutomatically. No EntityManager injected.
  • Docs: both DB diagrams annotated; ADR-032 added.

Acceptance criteria

AC Where
AC-1 sender SET NULL PersonRepositoryTest cascade tests
AC-2 receiver CASCADE + co-receivers intact PersonRepositoryTest
AC-3 mention CASCADE + block text survives PersonRepositoryTest
AC-4 service-path delete no regression PersonServiceIntegrationTest
AC-5 cleanup (methods removed, no EntityManager) PersonService/PersonRepository + rewritten unit tests
AC-6 mention graceful degradation mention.spec.ts
AC-7 merge no regression PersonServiceIntegrationTest
AC-8 same-document sender+receiver edge PersonRepositoryTest
Cascade-boundary guard (Nora) non-negotiable "both documents survive" assertion

Testing

  • Backend: 118 green across PersonServiceTest (57) + PersonRepositoryTest (49) + PersonServiceIntegrationTest (12); migrations V1→V71 run against real Postgres via Testcontainers. Full backend compiles.
  • Frontend: mention.spec.ts 47 green; zero new svelte-check errors in touched files.

Notes

  • ADR is 032, not 028 as the issue stated — main advanced and 028–031 are now taken.
  • Before deploy: size the orphan cleanup with SELECT count(*) FROM transcription_block_mentioned_persons m WHERE NOT EXISTS (SELECT 1 FROM persons p WHERE p.id = m.person_id); — V71 logs the purge as V71 orphaned_mention_rows_removed=N.

🤖 Generated with Claude Code

Closes #684. Moves person-delete referential integrity from application code into the database, removes the now-redundant app-layer detach, simplifies `mergePersons`, and closes the `@`-mention soft-reference gap. This **fixes the pre-existing dead-link-on-deleted-person case** — it is not a purely invisible refactor. ## What changed - **`V71__person_delete_on_delete_fk.sql`** (one atomic migration): - `documents.sender_id` → `ON DELETE SET NULL` (document survives; `senderText` preserves the raw attribution). - `document_receivers.person_id` → `ON DELETE CASCADE` (symmetric completion of V14). - `transcription_block_mentioned_persons.person_id` → real FK with `ON DELETE CASCADE`, reversing V56's "no FK". Pre-existing orphan rows are purged first in a `DO`/`RAISE NOTICE` block (Flyway discards a trailing `SELECT`). - Stale V56 comment rewritten. - **`PersonService.deletePerson`** thinned to `getById` + `deleteById`; **`mergePersons`** drops the explicit `deleteReceiverReferences` and leans on the cascade. - **`PersonRepository`**: `reassignSenderToNull` + `deleteReceiverReferences` deleted; `reassignSender`/`insertMissingReceiverReference` get `clearAutomatically`/`flushAutomatically`. No `EntityManager` injected. - **Docs**: both DB diagrams annotated; **ADR-032** added. ## Acceptance criteria | AC | Where | |---|---| | AC-1 sender `SET NULL` | `PersonRepositoryTest` cascade tests | | AC-2 receiver `CASCADE` + co-receivers intact | `PersonRepositoryTest` | | AC-3 mention `CASCADE` + block text survives | `PersonRepositoryTest` | | AC-4 service-path delete no regression | `PersonServiceIntegrationTest` | | AC-5 cleanup (methods removed, no `EntityManager`) | `PersonService`/`PersonRepository` + rewritten unit tests | | AC-6 mention graceful degradation | `mention.spec.ts` | | AC-7 merge no regression | `PersonServiceIntegrationTest` | | AC-8 same-document sender+receiver edge | `PersonRepositoryTest` | | Cascade-boundary guard (Nora) | non-negotiable "both documents survive" assertion | ## Testing - Backend: 118 green across `PersonServiceTest` (57) + `PersonRepositoryTest` (49) + `PersonServiceIntegrationTest` (12); migrations V1→V71 run against real Postgres via Testcontainers. Full backend compiles. - Frontend: `mention.spec.ts` 47 green; zero new `svelte-check` errors in touched files. ## Notes - **ADR is 032, not 028** as the issue stated — `main` advanced and 028–031 are now taken. - **Before deploy:** size the orphan cleanup with `SELECT count(*) FROM transcription_block_mentioned_persons m WHERE NOT EXISTS (SELECT 1 FROM persons p WHERE p.id = m.person_id);` — V71 logs the purge as `V71 orphaned_mention_rows_removed=N`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
marcel added 7 commits 2026-06-06 12:05:13 +02:00
Add ON DELETE behaviour to the two V1 FKs into persons (documents.sender_id
-> SET NULL, document_receivers.person_id -> CASCADE) and a real FK with
ON DELETE CASCADE on the transcription_block_mentioned_persons soft reference,
cleaning up pre-existing orphan mention rows first. The cascade stays strictly
at the join/reference layer and never reaches documents rows.

Proven by new Postgres-backed PersonRepositoryTest cascade tests (AC-1/2/3/8
plus the cascade-boundary document-survival guard). Rewrites the now-stale
V56 'no FK' comment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Drop the application-layer sender/receiver detach from deletePerson — the
V71 ON DELETE constraints now enforce it. Remove the now-unused
reassignSenderToNull repository method and rewrite the unit test to assert
only the existence check plus deleteById (verifyNoMoreInteractions).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Drop the explicit deleteReceiverReferences call from mergePersons — the
source's leftover receiver join rows now cascade-drop via V71's ON DELETE
CASCADE on deleteById. Remove the now-unused deleteReceiverReferences
repository method (and its repo test), and add clearAutomatically +
flushAutomatically to the remaining merge native queries so the L1 cache
cannot desync from the bulk updates. Rewrite the merge unit test with
verifyNoMoreInteractions and add an end-to-end merge regression test (AC-7).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The deletePerson service-path guard (AC-4) is unchanged behaviourally, but its
comments described the removed reassignSenderToNull/deleteReceiverReferences
chain. Update them to the V71 ON DELETE cascade mechanism.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Strengthen one renderTranscriptionBody case into the AC-6 contract: a
@DisplayName with an empty mentionedPersons array (the deleted-person case
V71 produces) must render as plain readable text with no <a>, person-mention
class, data-person-id, or href. Guards against a future renderer refactor
silently reintroducing the dead-link-on-deleted-person degradation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Annotate SET NULL on documents.sender_id and CASCADE on
document_receivers.person_id, and add the new
transcription_block_mentioned_persons -> persons person_id FK (CASCADE)
to both db-relationships.puml and db-orm.puml.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
docs(adr): record DB-level person-delete integrity decision (ADR-032) (#684)
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 3m21s
CI / OCR Service Tests (pull_request) Successful in 24s
CI / Backend Unit Tests (pull_request) Failing after 3m32s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m8s
24af76662b
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>
Author
Owner

📋 Elicit — Requirements Engineer

Verdict: Approved

Clean-room review of PR #736 against issue #684 (AC-1..AC-8, D1-D5). Every acceptance criterion is satisfied and traceable to a test; every locked decision is honored. No scope gaps, no scope creep. The one stated deviation (ADR-032 vs the issue's ADR-028) is correct and properly justified.


AC-by-AC coverage trace (AC → test)

AC Requirement Covered Where (verified in diff)
AC-1 sender → SET NULL, document survives PersonRepositoryTest.deleteById_personSenderOfAReceiverOfB_… — asserts reloadedSent.getSender() is null and doc present. Migration sets ON DELETE SET NULL.
AC-2 receiver → CASCADE, doc + co-receivers survive Same test + deleteById_receiverWithCoReceiver_dropsOnlyDeletedPersonsJoinRow — co-receiver kept via containsExactly(coReceiver).
AC-3 mention CASCADE and block text unchanged deleteById_mentionedPerson_dropsMentionRow_blockTextSurvives — asserts 0 mention rows and text == "Brief an @Auguste Raddatz". Both halves of the AC tested.
AC-4 service-path delete, no FK error, no UI regression PersonServiceIntegrationTest.deletePerson_…_noOrphan kept green; D3/Leonie confirmed no /persons/review copy change — correctly absent.
AC-5 structural cleanup (methods gone, no EntityManager, unit tests rewritten) reassignSenderToNull + deleteReceiverReferences deleted from PersonRepository; deletePerson thinned to getById+deleteById; no EntityManager injected; both Mockito tests rewritten with verifyNoMoreInteractions.
AC-6 user-observable graceful degradation mention.spec.ts strengthened — asserts plain text Auguste Raddatz present and none of <a, person-mention, data-person-id, href. Exactly the contract D3 specified.
AC-7 merge, no FK error, target inherits, no co-receiver lost PersonServiceIntegrationTest.mergePersons_targetInheritsReferences_sourceJoinRowCascadeDrops_noFkError — target becomes sender of A, receivers containsExactlyInAnyOrder(target, coReceiver).
AC-8 same-document sender+receiver edge deleteById_personIsSenderAndReceiverOfSameDocument_… — the trickiest interaction, with a co-receiver guard. This was the AC I (Elicit) and Sara pushed for; it is present and precise.

Bonus guard honored: Nora's non-negotiable cascade-boundary assertion ("both documents survive") is present and explicitly commented in the AC-1/2 test — this protects against a future migration silently turning SET NULL into CASCADE on documents.sender_id. Good.

Decisions D1-D5

  • D1 (FK + cascade on the soft reference) — honored; fk_tbmp_person added with ON DELETE CASCADE, orphans purged first.
  • D2 (simplify mergePersons) — honored; deleteReceiverReferences call removed, cascade relied upon, reassignSender/insertMissingReceiverReference kept.
  • D3 (frontend = regression test, not rewrite) — honored; one strengthened case, no parallel test, no dialog copy change.
  • D4 (no EntityManager; @Modifying(clear/flush) on native queries; test-side staleness) — honored exactly.
  • D5 (write the ADR) — honored; ADR captures the V56 reversal, the DB-integrity principle, and the cascade-boundary invariant.

On the ADR-028 → ADR-032 deviation — acceptable

I independently checked docs/adr/ on the branch: 028-pdfjs-wasm-decoders-and-csp-constraint.md through 031-document-title-… are all occupied. The issue's "028" was written before main advanced; 032 is the genuine next free number. The PR notes call this out explicitly. From a requirements standpoint the intent of D5 — "a durable, discoverable record exists" — is fully met; the number is an addressing detail, not a requirement. No concern.

Blockers

None.

Suggestions (non-blocking, optional)

  1. Traceability hygiene: consider adding "Closes #684" auto-link plus a one-line note in the issue itself that the ADR landed as 032, so the next reader doesn't trip on the 028↔032 mismatch when archaeology-diving. The PR body covers it; the issue body still says 028.
  2. Deploy-note follow-through: the issue (Tobias) and PR both flag sizing the prod orphan count before deploy. That's an operational checklist item, not a code gap — just make sure the captured count lands in the deploy notes, since it's the only part of this work that can't be proven by CI.

Requirements verdict: this PR is a model of spec-to-test traceability. Every AC has a named, asserting test; every decision is visible in the diff; and the work correctly reframes itself as a fix for the dead-link-on-deleted-person case (AC-6) rather than an invisible refactor — exactly as the issue's framing note asked.

## 📋 Elicit — Requirements Engineer **Verdict: ✅ Approved** Clean-room review of PR #736 against issue #684 (AC-1..AC-8, D1-D5). Every acceptance criterion is satisfied and traceable to a test; every locked decision is honored. No scope gaps, no scope creep. The one stated deviation (ADR-032 vs the issue's ADR-028) is correct and properly justified. --- ### AC-by-AC coverage trace (AC → test) | AC | Requirement | Covered | Where (verified in diff) | |----|-------------|---------|--------------------------| | **AC-1** sender → `SET NULL`, document survives | ✅ | `PersonRepositoryTest.deleteById_personSenderOfAReceiverOfB_…` — asserts `reloadedSent.getSender()` is null **and** doc present. Migration sets `ON DELETE SET NULL`. | | **AC-2** receiver → `CASCADE`, doc + co-receivers survive | ✅ | Same test + `deleteById_receiverWithCoReceiver_dropsOnlyDeletedPersonsJoinRow` — co-receiver kept via `containsExactly(coReceiver)`. | | **AC-3** mention `CASCADE` **and** block text unchanged | ✅ | `deleteById_mentionedPerson_dropsMentionRow_blockTextSurvives` — asserts 0 mention rows **and** `text == "Brief an @Auguste Raddatz"`. Both halves of the AC tested. | | **AC-4** service-path delete, no FK error, no UI regression | ✅ | `PersonServiceIntegrationTest.deletePerson_…_noOrphan` kept green; D3/Leonie confirmed no `/persons/review` copy change — correctly absent. | | **AC-5** structural cleanup (methods gone, no `EntityManager`, unit tests rewritten) | ✅ | `reassignSenderToNull` + `deleteReceiverReferences` deleted from `PersonRepository`; `deletePerson` thinned to `getById`+`deleteById`; no `EntityManager` injected; both Mockito tests rewritten with `verifyNoMoreInteractions`. | | **AC-6** user-observable graceful degradation | ✅ | `mention.spec.ts` strengthened — asserts plain text `Auguste Raddatz` present **and** none of `<a`, `person-mention`, `data-person-id`, `href`. Exactly the contract D3 specified. | | **AC-7** merge, no FK error, target inherits, no co-receiver lost | ✅ | `PersonServiceIntegrationTest.mergePersons_targetInheritsReferences_sourceJoinRowCascadeDrops_noFkError` — target becomes sender of A, receivers `containsExactlyInAnyOrder(target, coReceiver)`. | | **AC-8** same-document sender+receiver edge | ✅ | `deleteById_personIsSenderAndReceiverOfSameDocument_…` — the trickiest interaction, with a co-receiver guard. This was the AC I (Elicit) and Sara pushed for; it is present and precise. | **Bonus guard honored:** Nora's non-negotiable cascade-boundary assertion ("both documents survive") is present and explicitly commented in the AC-1/2 test — this protects against a future migration silently turning `SET NULL` into `CASCADE` on `documents.sender_id`. Good. ### Decisions D1-D5 - **D1** (FK + cascade on the soft reference) — ✅ honored; `fk_tbmp_person` added with `ON DELETE CASCADE`, orphans purged first. - **D2** (simplify `mergePersons`) — ✅ honored; `deleteReceiverReferences` call removed, cascade relied upon, `reassignSender`/`insertMissingReceiverReference` kept. - **D3** (frontend = regression test, not rewrite) — ✅ honored; one strengthened case, no parallel test, no dialog copy change. - **D4** (no `EntityManager`; `@Modifying(clear/flush)` on native queries; test-side staleness) — ✅ honored exactly. - **D5** (write the ADR) — ✅ honored; ADR captures the V56 reversal, the DB-integrity principle, and the cascade-boundary invariant. ### On the ADR-028 → ADR-032 deviation — acceptable I independently checked `docs/adr/` on the branch: `028-pdfjs-wasm-decoders-and-csp-constraint.md` through `031-document-title-…` are all occupied. The issue's "028" was written before `main` advanced; **032 is the genuine next free number.** The PR notes call this out explicitly. From a requirements standpoint the *intent* of D5 — "a durable, discoverable record exists" — is fully met; the number is an addressing detail, not a requirement. No concern. ### Blockers None. ### Suggestions (non-blocking, optional) 1. **Traceability hygiene:** consider adding "Closes #684" auto-link plus a one-line note in the issue itself that the ADR landed as 032, so the next reader doesn't trip on the 028↔032 mismatch when archaeology-diving. The PR body covers it; the *issue* body still says 028. 2. **Deploy-note follow-through:** the issue (Tobias) and PR both flag sizing the prod orphan count before deploy. That's an operational checklist item, not a code gap — just make sure the captured count lands in the deploy notes, since it's the only part of this work that can't be proven by CI. Requirements verdict: this PR is a model of spec-to-test traceability. Every AC has a named, asserting test; every decision is visible in the diff; and the work correctly reframes itself as a *fix* for the dead-link-on-deleted-person case (AC-6) rather than an invisible refactor — exactly as the issue's framing note asked.
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Approved

Clean-room review of the diff, no prior context. This is exactly how I'd want a "move integrity to the DB" refactor to land: the tests prove the new behaviour against the substrate that actually exercises it, the service shrinks honestly, and the dead code is deleted rather than commented out.

TDD & test soundness — verified, not assumed

The whole premise of this PR is "Postgres ON DELETE does the work now," so I checked that the cascade tests run on real Postgres and not H2 (where FK ON DELETE silently no-ops):

  • PersonRepositoryTest@DataJpaTest + @AutoConfigureTestDatabase(replace = NONE) + @Import({PostgresContainerConfig.class, FlywayConfig.class}). PostgresContainerConfig spins up postgres:16-alpine via Testcontainers with @ServiceConnection, and FlywayConfig runs V1→V71. The cascades genuinely fire.
  • PersonServiceIntegrationTest@SpringBootTest + @Import(PostgresContainerConfig.class), same container.

The four repo cascade tests each flush()/clear() before and after the deleteById, so the assertions read post-cascade DB state through a cold L1 cache rather than stale managed entities. The same-document sender+receiver edge (AC-8) and the co-receiver-survival case are the two I'd have asked for explicitly — both present. The Nora cascade-boundary guard (both documents survive) is a real assertion, not a comment, and it's the right thing to pin against a future migration flipping SET NULL into CASCADE.

verifyNoMoreInteractions rewrites — sound

Cross-checked both unit verifications against the service bodies:

  • deletePersongetByIdfindById + deleteById, and verifyNoMoreInteractions now catches any reintroduced detach call.
  • mergePersonsfindById(source), findById(target), reassignSender, insertMissingReceiverReference, deleteById — all five verified, exhaustively closed. This is stricter than the loose verifies it replaced; the old tests would have passed even if deleteReceiverReferences were still called. Good upgrade.

Frontend degradation test — contract is accurate

Decoded mention.ts: with mentionedPersons: [], renderTranscriptionBody short-circuits the substitution loop and returns escapeHtml(text) unchanged. So the test's four negative assertions (<a, person-mention, data-person-id, href all absent) plus the verbatim-text positive assertion genuinely lock the "deleted person degrades to plain text" contract. The renamed test (renders nothing…renders a deleted-person @mention as plain text…) now describes a real AC-6 behaviour rather than an empty-input no-op — net coverage gain, not a swap. No Svelte 5 concerns; this is a pure-function unit test.

Clean code

  • deletePerson / mergePersons are down to guard-then-act, happy path at the lowest indentation. The new Javadoc explains why (the cascade) rather than what — correct use of comments.
  • Migration comments are load-bearing (the DO/RAISE NOTICE vs trailing-SELECT Flyway detail, the orphan pre-clean rationale) — keep them.
  • reassignSenderToNull and deleteReceiverReferences fully removed from both repo and tests; no orphaned references. No EntityManager injected into the repo.
  • DB diagrams + ADR-032 updated per the doc-touch matrix. ADR honestly records reversing V56's "no FK" decision instead of pretending it never existed.

Suggestions (non-blocking)

  1. PersonRepository.java has no trailing newline (\ No newline at end of file). Trivial, but worth fixing while the file is open — POSIX-clean and avoids a noisy diff next time the interface changes.
  2. mergePersons still throws ResponseStatusException for the same-source-target guard while the not-found paths use DomainException. Pre-existing, untouched by this PR, so not a finding here — but it's the kind of inconsistency I'd fold into a follow-up: per our error-handling rule, domain guards should carry an ErrorCode.

No blockers. The refactor closes a real bug (the dead-link-on-deleted-person case), the behaviour is proven on the right substrate, and the cleanup is complete. Nice work.

## 👨‍💻 Felix Brandt — Senior Fullstack Developer **✅ Approved** Clean-room review of the diff, no prior context. This is exactly how I'd want a "move integrity to the DB" refactor to land: the tests prove the new behaviour against the substrate that actually exercises it, the service shrinks honestly, and the dead code is *deleted* rather than commented out. ### TDD & test soundness — verified, not assumed The whole premise of this PR is "Postgres `ON DELETE` does the work now," so I checked that the cascade tests run on real Postgres and not H2 (where FK `ON DELETE` silently no-ops): - `PersonRepositoryTest` — `@DataJpaTest` + `@AutoConfigureTestDatabase(replace = NONE)` + `@Import({PostgresContainerConfig.class, FlywayConfig.class})`. `PostgresContainerConfig` spins up `postgres:16-alpine` via Testcontainers with `@ServiceConnection`, and `FlywayConfig` runs V1→V71. The cascades genuinely fire. ✅ - `PersonServiceIntegrationTest` — `@SpringBootTest` + `@Import(PostgresContainerConfig.class)`, same container. ✅ The four repo cascade tests each `flush()/clear()` before *and* after the `deleteById`, so the assertions read post-cascade DB state through a cold L1 cache rather than stale managed entities. The same-document sender+receiver edge (AC-8) and the co-receiver-survival case are the two I'd have asked for explicitly — both present. The Nora cascade-boundary guard (`both documents survive`) is a real assertion, not a comment, and it's the right thing to pin against a future migration flipping `SET NULL` into `CASCADE`. ### `verifyNoMoreInteractions` rewrites — sound Cross-checked both unit verifications against the service bodies: - `deletePerson` → `getById`→`findById` + `deleteById`, and `verifyNoMoreInteractions` now catches any reintroduced detach call. ✅ - `mergePersons` → `findById(source)`, `findById(target)`, `reassignSender`, `insertMissingReceiverReference`, `deleteById` — all five verified, exhaustively closed. This is *stricter* than the loose verifies it replaced; the old tests would have passed even if `deleteReceiverReferences` were still called. Good upgrade. ### Frontend degradation test — contract is accurate Decoded `mention.ts`: with `mentionedPersons: []`, `renderTranscriptionBody` short-circuits the substitution loop and returns `escapeHtml(text)` unchanged. So the test's four negative assertions (`<a`, `person-mention`, `data-person-id`, `href` all absent) plus the verbatim-text positive assertion genuinely lock the "deleted person degrades to plain text" contract. The renamed test (`renders nothing…` → `renders a deleted-person @mention as plain text…`) now describes a real AC-6 behaviour rather than an empty-input no-op — net coverage gain, not a swap. No Svelte 5 concerns; this is a pure-function unit test. ### Clean code - `deletePerson` / `mergePersons` are down to guard-then-act, happy path at the lowest indentation. The new Javadoc explains *why* (the cascade) rather than *what* — correct use of comments. - Migration comments are load-bearing (the `DO`/`RAISE NOTICE` vs trailing-`SELECT` Flyway detail, the orphan pre-clean rationale) — keep them. - `reassignSenderToNull` and `deleteReceiverReferences` fully removed from both repo and tests; no orphaned references. No `EntityManager` injected into the repo. ✅ - DB diagrams + ADR-032 updated per the doc-touch matrix. ADR honestly records reversing V56's "no FK" decision instead of pretending it never existed. ### Suggestions (non-blocking) 1. **`PersonRepository.java` has no trailing newline** (`\ No newline at end of file`). Trivial, but worth fixing while the file is open — POSIX-clean and avoids a noisy diff next time the interface changes. 2. **`mergePersons` still throws `ResponseStatusException`** for the same-source-target guard while the not-found paths use `DomainException`. Pre-existing, untouched by this PR, so not a finding here — but it's the kind of inconsistency I'd fold into a follow-up: per our error-handling rule, domain guards should carry an `ErrorCode`. No blockers. The refactor closes a real bug (the dead-link-on-deleted-person case), the behaviour is proven on the right substrate, and the cleanup is complete. Nice work.
Author
Owner

🛠️ Tobias Wendt — DevOps

Verdict: Approved — migration is operationally sound for this data scale. One pre-deploy guidance correction and a couple of suggestions below, none blocking.

What I checked

  • Constraint names verified against the real schema. Pulled V1__initial_schema.sql and V14. V71 drops exactly the right pre-existing constraints:
    • documents.fkl5xhww7es3b4um01vmly4y18m (sender_id → persons, no ON DELETE in V1) ✓
    • document_receivers.fkcg7r68qvosqricx1betgrlt7s (person_id → persons, no ON DELETE in V1) ✓
    • V14 only added CASCADE to the document_id side (fks7t60twjgfmpeqcuc3g0fvjpm), so the "symmetric completion of V14" claim is accurate — person_id genuinely had no ON DELETE behaviour until now.
      A wrong constraint name here would fail the DROP CONSTRAINT and abort the migration in CI before prod — but it's correct, so no surprise on deploy.
  • Forward-only / numbering. V71 is the highest version in db/migration (V70 is the prior max). No gaps introduced, no edits to applied migrations (V56's change is comment-only — Flyway doesn't checksum comments differently, but be aware: editing an already-applied migration file changes its checksum). See blocker-adjacent note below.
  • CI coverage. Migrations V1→V71 run against real Postgres via Testcontainers, and PersonRepositoryTest exercises the actual ON DELETE behaviour (correctly noted as never firing on H2). The orphan-purge DO block executes on every CI run from a clean DB (zero orphans there), so the happy path is covered; the non-empty purge path is not (clean CI DB has no orphans to delete) — acceptable, the DELETE is trivial SQL.

Migration safety / locking

  • DROP CONSTRAINT ... ADD CONSTRAINT takes ACCESS EXCLUSIVE on documents and document_receivers, and the new FK on transcription_block_mentioned_persons requires a validating scan under ACCESS EXCLUSIVE. At the stated scale (thousands of rows) this is sub-second. The migration's own header comment already documents this and warns against copying the drop-and-recreate pattern onto a large table — good operational hygiene, exactly the kind of comment I want to see.
  • NOT VALID + VALIDATE two-step: correctly not warranted here. That pattern exists to avoid a long-held ACCESS EXCLUSIVE during the full-table validation scan on large tables. At thousands of rows the scan is sub-second, so the two-step would add complexity for no benefit. Agree with the decision and the inline rationale.
  • Statement ordering is correct: orphan purge runs before ADD CONSTRAINT, so the validation scan won't trip over dangling rows. If the purge missed any row the ADD would fail loudly and roll back the whole migration (Flyway runs the script in one transaction on Postgres) — fail-safe, not fail-open.

Observability of the purge

  • The RAISE NOTICE 'V71 orphaned_mention_rows_removed=%' is the right call. GET DIAGNOSTICS ... ROW_COUNT is the correct way to capture the count inside the DO block, and the comment explaining why a trailing SELECT wouldn't surface anything (Flyway discards the trailing result set) is genuinely useful for the next person.
  • Suggestion (not blocking): confirm the deploy runner actually surfaces Postgres NOTICE lines. By default the JDBC driver buffers NOTICEs into connection warnings; whether they reach Flyway's log / your deploy console depends on log level. If you want a guaranteed audit trail of the purge count in prod, the safest belt-and-suspenders is to run the sizing SELECT count(*) from the PR notes manually right before deploy and record the number — don't rely solely on the NOTICE showing up in the deploy log.

Pre-deploy orphan-count guidance — one correction

The PR body sizing query is:

SELECT count(*) FROM transcription_block_mentioned_persons m
WHERE NOT EXISTS (SELECT 1 FROM persons p WHERE p.id = m.person_id);

This is correct and matches the DELETE predicate exactly — good. Two operational notes:

  1. Run it against production, not a stale dump. The whole point is to know the real blast radius before the ACCESS EXCLUSIVE window. A count from last week's backup tells you nothing about today's orphans.
  2. The guidance frames this as informational, which is right — there's no abort path if the count is large. At family-archive scale that's fine. But if the count ever came back in the tens of thousands (it won't, but as a habit), the validating ADD scan duration is what you'd actually care about, not the DELETE.

Deploy ordering / downtime

  • This is a backward-compatible schema change paired with code that removes now-redundant app-layer detach. Ordering is safe in both directions: old code against the new schema still works (it would just redundantly null/delete rows the DB would also handle), and new code requires the new constraints. Standard migrate-then-deploy ordering applies; no special choreography or downtime window needed beyond the sub-second lock.

Note worth flagging (not a blocker)

  • V56 file was edited (comment-only). Editing an already-applied migration changes its file checksum. Flyway will flag a checksum mismatch on the next startup against any DB where V56 already ran, unless validateMigrationNaming/repair is handled — but since the change is only comments, confirm your Flyway config isn't set to fail on checksum drift, or run flyway repair post-deploy. Comment-only edits to applied migrations are a common source of "works in CI (clean DB), fails on prod (V56 already applied)" surprises. Please double-check Flyway's validateOnMigrate behaviour against prod before merging — this is the one thing most likely to bite on the real deploy.

Nice work overall — the migration is well-commented, the cascade boundary is pinned by a test, and the constraint names are correct.

## 🛠️ Tobias Wendt — DevOps **Verdict: ✅ Approved** — migration is operationally sound for this data scale. One pre-deploy guidance correction and a couple of suggestions below, none blocking. ### What I checked - **Constraint names verified against the real schema.** Pulled `V1__initial_schema.sql` and `V14`. V71 drops exactly the right pre-existing constraints: - `documents.fkl5xhww7es3b4um01vmly4y18m` (sender_id → persons, no ON DELETE in V1) ✓ - `document_receivers.fkcg7r68qvosqricx1betgrlt7s` (person_id → persons, no ON DELETE in V1) ✓ - V14 only added CASCADE to the `document_id` side (`fks7t60twjgfmpeqcuc3g0fvjpm`), so the "symmetric completion of V14" claim is accurate — `person_id` genuinely had no ON DELETE behaviour until now. A wrong constraint name here would fail the `DROP CONSTRAINT` and abort the migration in CI before prod — but it's correct, so no surprise on deploy. - **Forward-only / numbering.** V71 is the highest version in `db/migration` (V70 is the prior max). No gaps introduced, no edits to applied migrations (V56's change is comment-only — Flyway doesn't checksum comments differently, but be aware: editing an *already-applied* migration file changes its checksum). See blocker-adjacent note below. - **CI coverage.** Migrations V1→V71 run against real Postgres via Testcontainers, and `PersonRepositoryTest` exercises the actual ON DELETE behaviour (correctly noted as never firing on H2). The orphan-purge `DO` block executes on every CI run from a clean DB (zero orphans there), so the happy path is covered; the *non-empty* purge path is not (clean CI DB has no orphans to delete) — acceptable, the DELETE is trivial SQL. ### Migration safety / locking - `DROP CONSTRAINT ... ADD CONSTRAINT` takes `ACCESS EXCLUSIVE` on `documents` and `document_receivers`, and the new FK on `transcription_block_mentioned_persons` requires a validating scan under `ACCESS EXCLUSIVE`. At the stated scale (thousands of rows) this is sub-second. The migration's own header comment already documents this and warns against copying the drop-and-recreate pattern onto a large table — good operational hygiene, exactly the kind of comment I want to see. - **NOT VALID + VALIDATE two-step: correctly *not* warranted here.** That pattern exists to avoid a long-held `ACCESS EXCLUSIVE` during the full-table validation scan on large tables. At thousands of rows the scan is sub-second, so the two-step would add complexity for no benefit. Agree with the decision and the inline rationale. - **Statement ordering is correct:** orphan purge runs *before* `ADD CONSTRAINT`, so the validation scan won't trip over dangling rows. If the purge missed any row the ADD would fail loudly and roll back the whole migration (Flyway runs the script in one transaction on Postgres) — fail-safe, not fail-open. ### Observability of the purge - The `RAISE NOTICE 'V71 orphaned_mention_rows_removed=%'` is the right call. `GET DIAGNOSTICS ... ROW_COUNT` is the correct way to capture the count inside the `DO` block, and the comment explaining *why* a trailing `SELECT` wouldn't surface anything (Flyway discards the trailing result set) is genuinely useful for the next person. - **Suggestion (not blocking):** confirm the deploy runner actually surfaces Postgres `NOTICE` lines. By default the JDBC driver buffers `NOTICE`s into connection warnings; whether they reach Flyway's log / your deploy console depends on log level. If you want a guaranteed audit trail of the purge count in prod, the safest belt-and-suspenders is to run the sizing `SELECT count(*)` from the PR notes manually right before deploy and record the number — don't rely solely on the NOTICE showing up in the deploy log. ### Pre-deploy orphan-count guidance — one correction The PR body sizing query is: ```sql SELECT count(*) FROM transcription_block_mentioned_persons m WHERE NOT EXISTS (SELECT 1 FROM persons p WHERE p.id = m.person_id); ``` This is correct and matches the DELETE predicate exactly — good. Two operational notes: 1. **Run it against production, not a stale dump.** The whole point is to know the real blast radius before the `ACCESS EXCLUSIVE` window. A count from last week's backup tells you nothing about today's orphans. 2. **The guidance frames this as informational, which is right** — there's no abort path if the count is large. At family-archive scale that's fine. But if the count ever came back in the tens of thousands (it won't, but as a habit), the validating ADD scan duration is what you'd actually care about, not the DELETE. ### Deploy ordering / downtime - This is a backward-compatible schema change paired with code that *removes* now-redundant app-layer detach. Ordering is safe in both directions: old code against the new schema still works (it would just redundantly null/delete rows the DB would also handle), and new code requires the new constraints. Standard migrate-then-deploy ordering applies; no special choreography or downtime window needed beyond the sub-second lock. ### Note worth flagging (not a blocker) - **V56 file was edited (comment-only).** Editing an already-applied migration changes its file checksum. Flyway will flag a checksum mismatch on the next startup against any DB where V56 already ran, unless `validateMigrationNaming`/repair is handled — but since the change is *only* comments, confirm your Flyway config isn't set to fail on checksum drift, or run `flyway repair` post-deploy. Comment-only edits to applied migrations are a common source of "works in CI (clean DB), fails on prod (V56 already applied)" surprises. **Please double-check Flyway's `validateOnMigrate` behaviour against prod before merging** — this is the one thing most likely to bite on the real deploy. Nice work overall — the migration is well-commented, the cascade boundary is pinned by a test, and the constraint names are correct.
Author
Owner

🎨 Leonie — UI/UX Expert

Approved

This is a backend-heavy PR (DB migration + service thinning) with a single user-facing surface: the graceful degradation of @-mentions when a mentioned person is deleted. I reviewed that surface end to end and it is correct, honest, and well-protected by the contract test. Nothing of UX concern beyond the degradation contract — and that contract is solid.

What I checked

1. The user-observable behaviour — does the reader still see the name?
renderTranscriptionBody (frontend/src/lib/shared/discussion/mention.ts) escapes the full block text first, then only injects an <a class="person-mention" …> for each entry in mentionedPersons. When V71's ON DELETE CASCADE drops the sidecar row, the renderer receives an empty array, the substitution loop never runs, and the escaped text — including the literal @Auguste Raddatz — falls through verbatim. The reader sees:

  • the full readable name (Auguste Raddatz), in the surrounding sentence,
  • no dead <a>, no orphaned anchor, no console/render error.

This is exactly the right degradation: a deleted link is invisible to the reader; the name survives because it lives in transcription_blocks.text. The backend test deleteById_mentionedPerson_dropsMentionRow_blockTextSurvives (PersonRepositoryTest) correctly pins that the block text is untouched, so the two halves of the contract (DB keeps the text, renderer shows it) are both locked.

2. Does the contract test protect the right property?
The new mention.spec.ts test (renders a deleted-person @mention as plain text with no dead link) is the strongest version of this assertion I'd want:

  • Positive: toContain('Auguste Raddatz') + toBe('Brief an @Auguste Raddatz vom Mai') — the reader-facing property ("reader still sees the name") is asserted directly, not inferred.
  • Negative: <a, person-mention, data-person-id, href all absent — every anchor artifact that could silently reintroduce a dead link is named and excluded.

It also strengthens coverage rather than weakening it: the replaced "renders nothing when mentionedPersons is empty" test had no @-trigger in its input, so it never exercised the degradation path. Its plain-text property is still independently covered by returns escaped plain text when no mentions. Net coverage is up.

Suggestions (non-blocking)

  • @ prefix asymmetry. In the degraded path the trigger @ is preserved (@Auguste Raddatz), whereas a live mention strips it (anchor text is the bare Auguste Raddatz, per the strips the @ prefix test). So a reader on a page mixing live and deleted mentions sees Auguste Raddatz next to @Auguste Raddatz. This is cosmetic — the name is fully readable either way and the @ reads naturally as historical-text shorthand — and the issue explicitly scoped out copy/affordance changes. Worth a one-line note in ADR-032 so a future reader knows the asymmetry is known and accepted, not an oversight. Not a blocker.

  • No affordance is needed here, and adding one would be wrong. I considered whether the reader deserves a "this person was removed" cue. They don't: the mention was an editor convenience, the historical name is intact, and a tooltip/badge on every former-mention would add noise to the read path for the 60+ audience we optimise for. Silent degradation to plain text is the correct, calm choice. Agreeing with the issue's decision to leave the /persons/review dialog copy unchanged for the same reason.

Blockers: none.
Verdict: Approved — the degradation is correct and graceful, the contract test guards the reader-facing property explicitly, and there is no missing UX affordance on this surface.

## 🎨 Leonie — UI/UX Expert **✅ Approved** This is a backend-heavy PR (DB migration + service thinning) with a single user-facing surface: the graceful degradation of `@`-mentions when a mentioned person is deleted. I reviewed that surface end to end and it is correct, honest, and well-protected by the contract test. Nothing of UX concern beyond the degradation contract — and that contract is solid. ### What I checked **1. The user-observable behaviour — does the reader still see the name?** ✅ `renderTranscriptionBody` (`frontend/src/lib/shared/discussion/mention.ts`) escapes the full block text first, then *only* injects an `<a class="person-mention" …>` for each entry in `mentionedPersons`. When V71's `ON DELETE CASCADE` drops the sidecar row, the renderer receives an empty array, the substitution loop never runs, and the escaped text — including the literal `@Auguste Raddatz` — falls through verbatim. The reader sees: - the full readable name (`Auguste Raddatz`), in the surrounding sentence, - no dead `<a>`, no orphaned anchor, no console/render error. This is exactly the right degradation: a deleted *link* is invisible to the reader; the *name* survives because it lives in `transcription_blocks.text`. The backend test `deleteById_mentionedPerson_dropsMentionRow_blockTextSurvives` (`PersonRepositoryTest`) correctly pins that the block text is untouched, so the two halves of the contract (DB keeps the text, renderer shows it) are both locked. **2. Does the contract test protect the right property?** ✅ The new `mention.spec.ts` test (`renders a deleted-person @mention as plain text with no dead link`) is the strongest version of this assertion I'd want: - **Positive:** `toContain('Auguste Raddatz')` + `toBe('Brief an @Auguste Raddatz vom Mai')` — the reader-facing property ("reader still sees the name") is asserted directly, not inferred. - **Negative:** `<a`, `person-mention`, `data-person-id`, `href` all absent — every anchor artifact that could silently reintroduce a dead link is named and excluded. It also *strengthens* coverage rather than weakening it: the replaced "renders nothing when mentionedPersons is empty" test had no `@`-trigger in its input, so it never exercised the degradation path. Its plain-text property is still independently covered by `returns escaped plain text when no mentions`. Net coverage is up. ### Suggestions (non-blocking) - **`@` prefix asymmetry.** In the degraded path the trigger `@` is preserved (`@Auguste Raddatz`), whereas a *live* mention strips it (anchor text is the bare `Auguste Raddatz`, per the `strips the @ prefix` test). So a reader on a page mixing live and deleted mentions sees `Auguste Raddatz` next to `@Auguste Raddatz`. This is cosmetic — the name is fully readable either way and the `@` reads naturally as historical-text shorthand — and the issue explicitly scoped out copy/affordance changes. Worth a one-line note in ADR-032 so a future reader knows the asymmetry is known and accepted, not an oversight. Not a blocker. - **No affordance is needed here, and adding one would be wrong.** I considered whether the reader deserves a "this person was removed" cue. They don't: the mention was an editor convenience, the historical name is intact, and a tooltip/badge on every former-mention would add noise to the read path for the 60+ audience we optimise for. Silent degradation to plain text is the correct, calm choice. Agreeing with the issue's decision to leave the `/persons/review` dialog copy unchanged for the same reason. **Blockers:** none. **Verdict:** Approved — the degradation is correct and graceful, the contract test guards the reader-facing property explicitly, and there is no missing UX affordance on this surface.
Author
Owner

🧪 Sara — QA Engineer

Verdict: Approved with one doc-accuracy nit

I came in cold and went straight for the question that decides whether this PR is real coverage or theatre: do these tests actually fire the database FK cascade, or are they passing against an in-memory fake that silently ignores ON DELETE? The answer is yes — this is honest, real-Postgres coverage. Details below.

What I verified

The cascade tests run against real Postgres, not H2. This was my top concern and it checks out:

  • PostgresContainerConfignew PostgreSQLContainer<>("postgres:16-alpine") with @ServiceConnection. The canonical setup.
  • PersonRepositoryTest: @DataJpaTest + @AutoConfigureTestDatabase(replace = NONE) + @Import({PostgresContainerConfig.class, FlywayConfig.class}). replace=NONE is the load-bearing annotation — without it @DataJpaTest would swap in an embedded DB and the FK ON DELETE clauses would never fire, so the assertions would pass against nothing. It's present. Flyway runs V1→V71, so the V71 constraints actually exist in the schema under test.
  • PersonServiceIntegrationTest: @SpringBootTest(NONE) + @Import(PostgresContainerConfig) + @Transactional. Real Postgres, service path.

Flush/clear discipline is correct where a DB cascade is asserted. Every cascade test does flush()/clear() after the fixture build and after deleteById/mergePersons, then re-reads via findById. The native cascade runs beneath the Hibernate session, so without the post-delete clear() the L1 cache would serve a stale managed Document with the old receivers/sender and the assertion would be a false green. This is the correct guard and it's applied consistently — no flakiness risk from stale L1 state.

Edge cases are genuinely tested, not stubbed:

  • AC-8 same-document (deleteById_personIsSenderAndReceiverOfSameDocument_...) — sender nulled, own receiver row dropped, co-receiver survives. This is the interaction the cross-document cases can't reach; good that it's separate.
  • Cascade-boundary guard — assertThat(documentRepository.findById(sent.getId())).isPresent() for both documents. This is the real Nora invariant: it would fail loudly if a future migration flipped sender_id from SET NULL to CASCADE and started eating historical letters. Non-trivial, non-negotiable, present.
  • AC-3 mention CASCADE + text survival — inserts the annotation/block/mention triple via native SQL, asserts mention row count is 0 AND the block text is byte-exact "Brief an @Auguste Raddatz". Both halves prove something: the sidecar link dies, the visible name stays. This is the soft-reference contract, properly pinned.

Unit-test rewrites are meaningful. verifyNoMoreInteractions(personRepository) on both deletePerson and mergePersons is the right tool here — it's what actually proves the AC-5 cleanup. Without it, the removed reassignSenderToNull/deleteReceiverReferences calls could silently creep back and the verify(...) allowlist wouldn't catch it. verifyNoMoreInteractions turns "we deleted these calls" into an enforced invariant. Good rigor.

Frontend mention.spec.ts (AC-6). The rewritten degradation case is stronger than what it replaced: it asserts the verbatim sentence survives and negatively asserts no <a, person-mention, data-person-id, or href leaks through. That locks the no-dead-link contract against a future renderer refactor. Exactly the kind of negative assertion I want on a degradation path.

Suggestion (non-blocking)

Inaccurate justification comment in PersonServiceIntegrationTest.deletePerson_...noOrphan. The comment reads:

"the EAGER @ElementCollection on receivers makes this load-bearing"

Document.receivers is a @ManyToMany(fetch = FetchType.LAZY) join (document_receivers), not an @ElementCollection, and it's LAZY, not EAGER. The flush()/clear() is still correct and genuinely necessary (the native cascade runs below the session → L1 must be cleared so the re-read sees DB state), so this is purely a misleading rationale, not a test defect. Tighten the comment to "the L1 cache holds a stale managed Document after the below-session cascade" so the next maintainer isn't misled about the mapping. Pure comment fix — does not block merge.

Coverage gaps I considered and am OK with

  • No test for the V71 orphan-purge DO block itself (the pre-existing-dangling-rows cleanup). Hard to exercise cleanly since Flyway runs once at context start and you can't easily seed orphans before the constraint exists. Acceptable to leave to the deploy-time SELECT count(*) sizing step the PR body documents.
  • No negative/auth test added, but this PR is a DB-integrity refactor with no endpoint surface change, so that's correctly out of scope.

Solid test pyramid: meaningful Mockito unit layer, real-Postgres integration for the behaviour that only exists at the DB layer, and a tightened frontend degradation spec. The one nit is cosmetic. Nice work.

— Sara

## 🧪 Sara — QA Engineer **Verdict: ✅ Approved with one doc-accuracy nit** I came in cold and went straight for the question that decides whether this PR is real coverage or theatre: *do these tests actually fire the database FK cascade, or are they passing against an in-memory fake that silently ignores `ON DELETE`?* The answer is yes — this is honest, real-Postgres coverage. Details below. ### What I verified **The cascade tests run against real Postgres, not H2.** This was my top concern and it checks out: - `PostgresContainerConfig` → `new PostgreSQLContainer<>("postgres:16-alpine")` with `@ServiceConnection`. The canonical setup. - `PersonRepositoryTest`: `@DataJpaTest` + `@AutoConfigureTestDatabase(replace = NONE)` + `@Import({PostgresContainerConfig.class, FlywayConfig.class})`. `replace=NONE` is the load-bearing annotation — without it `@DataJpaTest` would swap in an embedded DB and the FK `ON DELETE` clauses would never fire, so the assertions would pass against nothing. It's present. Flyway runs V1→V71, so the V71 constraints actually exist in the schema under test. - `PersonServiceIntegrationTest`: `@SpringBootTest(NONE)` + `@Import(PostgresContainerConfig)` + `@Transactional`. Real Postgres, service path. **Flush/clear discipline is correct where a DB cascade is asserted.** Every cascade test does `flush()`/`clear()` after the fixture build *and* after `deleteById`/`mergePersons`, then re-reads via `findById`. The native cascade runs beneath the Hibernate session, so without the post-delete `clear()` the L1 cache would serve a stale managed `Document` with the old `receivers`/`sender` and the assertion would be a false green. This is the correct guard and it's applied consistently — no flakiness risk from stale L1 state. **Edge cases are genuinely tested, not stubbed:** - AC-8 same-document (`deleteById_personIsSenderAndReceiverOfSameDocument_...`) — sender nulled, own receiver row dropped, co-receiver survives. This is the interaction the cross-document cases can't reach; good that it's separate. - Cascade-boundary guard — `assertThat(documentRepository.findById(sent.getId())).isPresent()` for *both* documents. This is the real Nora invariant: it would fail loudly if a future migration flipped `sender_id` from `SET NULL` to `CASCADE` and started eating historical letters. Non-trivial, non-negotiable, present. - AC-3 mention CASCADE + text survival — inserts the annotation/block/mention triple via native SQL, asserts mention row count is `0` AND the block text is byte-exact `"Brief an @Auguste Raddatz"`. Both halves prove something: the sidecar link dies, the visible name stays. This is the soft-reference contract, properly pinned. **Unit-test rewrites are meaningful.** `verifyNoMoreInteractions(personRepository)` on both `deletePerson` and `mergePersons` is the right tool here — it's what actually proves the AC-5 cleanup. Without it, the removed `reassignSenderToNull`/`deleteReceiverReferences` calls could silently creep back and the `verify(...)` allowlist wouldn't catch it. `verifyNoMoreInteractions` turns "we deleted these calls" into an enforced invariant. Good rigor. **Frontend `mention.spec.ts` (AC-6).** The rewritten degradation case is stronger than what it replaced: it asserts the verbatim sentence survives *and* negatively asserts no `<a`, `person-mention`, `data-person-id`, or `href` leaks through. That locks the no-dead-link contract against a future renderer refactor. Exactly the kind of negative assertion I want on a degradation path. ### Suggestion (non-blocking) **Inaccurate justification comment in `PersonServiceIntegrationTest.deletePerson_...noOrphan`.** The comment reads: > *"the EAGER @ElementCollection on receivers makes this load-bearing"* `Document.receivers` is a `@ManyToMany(fetch = FetchType.LAZY)` join (`document_receivers`), not an `@ElementCollection`, and it's `LAZY`, not `EAGER`. The `flush()`/`clear()` is still correct and genuinely necessary (the native cascade runs below the session → L1 must be cleared so the re-read sees DB state), so this is purely a misleading rationale, not a test defect. Tighten the comment to "the L1 cache holds a stale managed Document after the below-session cascade" so the next maintainer isn't misled about the mapping. Pure comment fix — does not block merge. ### Coverage gaps I considered and am OK with - No test for the V71 *orphan-purge `DO` block* itself (the pre-existing-dangling-rows cleanup). Hard to exercise cleanly since Flyway runs once at context start and you can't easily seed orphans before the constraint exists. Acceptable to leave to the deploy-time `SELECT count(*)` sizing step the PR body documents. - No negative/auth test added, but this PR is a DB-integrity refactor with no endpoint surface change, so that's correctly out of scope. Solid test pyramid: meaningful Mockito unit layer, real-Postgres integration for the behaviour that only exists at the DB layer, and a tightened frontend degradation spec. The one nit is cosmetic. Nice work. — Sara
Author
Owner

🏛️ Markus Keller — Architect

Approved

This is the textbook version of a change I keep asking for: integrity enforced at the lowest trustworthy layer, the service thinned to a deleteById, and the why captured in an ADR. I formed my view from the diff and verified the load-bearing facts against the schema history. Nothing in my domain blocks it.

What I checked (and it holds up)

  • Integrity pushed down to PostgreSQL. V71 replaces three app-layer detach steps with ON DELETE behaviour. This is the right direction — PersonService.deletePerson/mergePersons were the only safe delete path; any future endpoint, batch job, or manual psql would have orphaned rows or 500'd. The DB is now the single source of truth. Exactly the principle in my review lens (constraints over application checks).
  • Constraint names are real, not guessed. I decoded V1__initial_schema.sql: documents.sender_idfkl5xhww7es3b4um01vmly4y18m and document_receivers.person_idfkcg7r68qvosqricx1betgrlt7s both exist as named FKs. V71's drop-and-recreate by name is safe for any DB that ran V1.
  • "Symmetric completion of V14" claim is accurate. V14 only touched the document_id side of document_receivers (fks7t60twjgfmpeqcuc3g0fvjpm, → CASCADE). The person_id side (fkcg7r68...) was left bare. V71 closing it is genuinely the missing half, not a re-do.
  • Cascade boundary is correct and pinned. sender_idSET NULL (document + senderText survive), joins/sidecar → CASCADE, and the cascade never reaches documents. The non-negotiable "both documents survive" assertion in PersonRepositoryTest guards against a future migration silently upgrading SET NULL to CASCADE and destroying historical letters. Good defensive instinct — that's the kind of invariant that deserves a test, and it has one.
  • Migration safety. Drop-and-recreate takes a brief ACCESS EXCLUSIVE lock; the inline comment correctly scopes that to small tables and warns against copying the pattern onto large ones. Orphan transcription_block_mentioned_persons rows are purged before ADD CONSTRAINT validation — without that, the validation scan would fail on existing dangling rows. The DO/RAISE NOTICE purge-count log (vs. a trailing SELECT Flyway would discard) shows real understanding of the JDBC execution path.
  • Schema-tested against real Postgres. The cascade tests run on Testcontainers, not H2 — the only place FK ON DELETE actually fires. V1→V71 runs in CI. Schema drift is caught before production.
  • ADR-032 quality. Context / Decision / Consequences / Alternatives all present and substantive. The three rejected alternatives (keep it in Java, cascade documents, honour V56's no-FK) are each argued, not hand-waved. Number 032 is unique and correctly sequenced — I verified docs/adr/. The "audit cascade runs below AuditService — accepted trade" note is exactly the kind of consequence an ADR exists to record.
  • Doc currency. New FK on transcription_block_mentioned_persons.person_id → both db-orm.puml and db-relationships.puml updated (person_id marked <<FK>>, all three relationship lines annotated with their ON DELETE mode). No new package/service/route, so the C4 diagrams are correctly untouched. The stale V56 "no FK" comment was rewritten to point at V71 + ADR-032. Per my doc-currency table, this PR's obligations are all met.
  • Module boundaries respected. No EntityManager injected into the repository; cross-domain access untouched; two now-dead repository methods removed rather than left to rot. The clearAutomatically/flushAutomatically on the surviving bulk updates is correctly justified in the comment (L1 cache desync below Hibernate).

Blockers

None.

Suggestions (non-blocking, do not hold the merge)

  1. Operational sizing of the orphan purge. The PR body already flags running the count query before deploy — good. Consider folding a one-line "expected: low hundreds at most" expectation into the deploy note, or confirm the purge is acceptable to run inside the migration transaction at production scale. On a small table it's a non-issue; I just want the number looked at once, not assumed.
  2. Minor: pre-existing duplicate ADR numbers (012, 022, 030 each appear twice in docs/adr/). Not introduced here and not this PR's job to fix — flagging only so it lands on someone's radar before the next ADR collides.

Clean change. Merge it.

— @mkeller

## 🏛️ Markus Keller — Architect **✅ Approved** This is the textbook version of a change I keep asking for: integrity enforced at the lowest trustworthy layer, the service thinned to a `deleteById`, and the *why* captured in an ADR. I formed my view from the diff and verified the load-bearing facts against the schema history. Nothing in my domain blocks it. ### What I checked (and it holds up) - **Integrity pushed down to PostgreSQL.** V71 replaces three app-layer detach steps with `ON DELETE` behaviour. This is the right direction — `PersonService.deletePerson`/`mergePersons` were the *only* safe delete path; any future endpoint, batch job, or manual `psql` would have orphaned rows or 500'd. The DB is now the single source of truth. Exactly the principle in my review lens (constraints over application checks). - **Constraint names are real, not guessed.** I decoded `V1__initial_schema.sql`: `documents.sender_id` → `fkl5xhww7es3b4um01vmly4y18m` and `document_receivers.person_id` → `fkcg7r68qvosqricx1betgrlt7s` both exist as named FKs. V71's drop-and-recreate by name is safe for any DB that ran V1. - **"Symmetric completion of V14" claim is accurate.** V14 only touched the `document_id` side of `document_receivers` (`fks7t60twjgfmpeqcuc3g0fvjpm`, → CASCADE). The `person_id` side (`fkcg7r68...`) was left bare. V71 closing it is genuinely the missing half, not a re-do. - **Cascade boundary is correct and pinned.** `sender_id` → `SET NULL` (document + `senderText` survive), joins/sidecar → `CASCADE`, and the cascade never reaches `documents`. The non-negotiable "both documents survive" assertion in `PersonRepositoryTest` guards against a future migration silently upgrading `SET NULL` to `CASCADE` and destroying historical letters. Good defensive instinct — that's the kind of invariant that deserves a test, and it has one. - **Migration safety.** Drop-and-recreate takes a brief `ACCESS EXCLUSIVE` lock; the inline comment correctly scopes that to small tables and warns against copying the pattern onto large ones. Orphan `transcription_block_mentioned_persons` rows are purged *before* `ADD CONSTRAINT` validation — without that, the validation scan would fail on existing dangling rows. The `DO`/`RAISE NOTICE` purge-count log (vs. a trailing `SELECT` Flyway would discard) shows real understanding of the JDBC execution path. - **Schema-tested against real Postgres.** The cascade tests run on Testcontainers, not H2 — the only place FK `ON DELETE` actually fires. V1→V71 runs in CI. Schema drift is caught before production. - **ADR-032 quality.** Context / Decision / Consequences / Alternatives all present and substantive. The three rejected alternatives (keep it in Java, cascade `documents`, honour V56's no-FK) are each argued, not hand-waved. Number 032 is unique and correctly sequenced — I verified `docs/adr/`. The "audit cascade runs below AuditService — accepted trade" note is exactly the kind of consequence an ADR exists to record. - **Doc currency.** New FK on `transcription_block_mentioned_persons.person_id` → both `db-orm.puml` and `db-relationships.puml` updated (person_id marked `<<FK>>`, all three relationship lines annotated with their `ON DELETE` mode). No new package/service/route, so the C4 diagrams are correctly untouched. The stale V56 "no FK" comment was rewritten to point at V71 + ADR-032. Per my doc-currency table, this PR's obligations are all met. - **Module boundaries respected.** No `EntityManager` injected into the repository; cross-domain access untouched; two now-dead repository methods removed rather than left to rot. The `clearAutomatically`/`flushAutomatically` on the surviving bulk updates is correctly justified in the comment (L1 cache desync below Hibernate). ### Blockers None. ### Suggestions (non-blocking, do not hold the merge) 1. **Operational sizing of the orphan purge.** The PR body already flags running the count query before deploy — good. Consider folding a one-line "expected: low hundreds at most" expectation into the deploy note, or confirm the purge is acceptable to run inside the migration transaction at production scale. On a small table it's a non-issue; I just want the number looked at once, not assumed. 2. **Minor: pre-existing duplicate ADR numbers** (012, 022, 030 each appear twice in `docs/adr/`). Not introduced here and not this PR's job to fix — flagging only so it lands on someone's radar before the next ADR collides. Clean change. Merge it. — @mkeller
Author
Owner

🔒 Nora — Security Expert

Verdict: Approved

This is a clean-room security pass. I treated the migration as hostile DDL and the renderer change as a potential stored-XSS regression. Both hold up. This is a defensive change — it moves a fragile application-layer invariant into the database, which is exactly where referential-integrity guarantees belong (fail-closed by construction, enforced on every delete path, not just PersonService).


What I checked (and why it's safe)

1. Data-loss / data-retention — the cascade never destroys a historical letter

  • documents.sender_idON DELETE SET NULL, not CASCADE. The document row survives; documents.senderText preserves the raw textual attribution, so nulling the FK loses no historical record. This is the correct non-destructive choice and the ADR-032 "Alternatives considered" explicitly rejects cascading documents.sender_id. Good — that rejected alternative is the one that would have deleted family letters.
  • The two CASCADEs (document_receivers.person_id, transcription_block_mentioned_persons.person_id) reach only join/sidecar rows, never a documents row.

2. Cascade-boundary invariant is test-pinned (this was my non-negotiable)
PersonRepositoryTest.deleteById_personSenderOfAReceiverOfB_... asserts documentRepository.findById(sent) and findById(received) are both present after the delete, with an explicit comment naming the threat (a future migration flipping SET NULL → CASCADE). That is the regression lock I wanted — a renderer/schema refactor cannot silently turn this into a letter-shredder without a red test. AC-8 (...senderAndReceiverOfSameDocument...) covers the trickier same-row interaction. Tests run against real Postgres via Testcontainers, which is mandatory here — H2 would never fire the FK ON DELETE behaviour, so a unit-only test would be security theater.

3. Constraint names verified against source — migration is correct and complete
I cross-checked the DROP CONSTRAINT names in V71 against V1__initial_schema.sql:

  • documents.sender_id = fkl5xhww7es3b4um01vmly4y18m
  • document_receivers.person_id = fkcg7r68qvosqricx1betgrlt7s
  • V14 only altered the document_id sides of the join tables, so the person_id FK on document_receivers was still the V1-default (no ON DELETE) — V71 dropping/recreating it is correct, not a double-apply.

4. Orphan-purge DELETE is bounded — no over-delete

DELETE FROM transcription_block_mentioned_persons m
WHERE NOT EXISTS (SELECT 1 FROM persons p WHERE p.id = m.person_id);

Correlated NOT EXISTS on person_id — deletes only rows already orphaned (a person that no longer exists). It cannot touch a row whose person_id still resolves, and it physically cannot reach documents or persons. The (block_id, person_id) PK (V65) means these are whole tuples; no duplicate/partial-row hazard. The purge is gated behind the ADD CONSTRAINT validation scan, so it is also necessary (the scan would 500 the migration on pre-existing dangling rows otherwise). Count is surfaced via RAISE NOTICE — good operability, no sensitive data in the log line.

5. @-mention XSS / escaping degradation contract
Read mention.ts in full. renderTranscriptionBody escapes the entire block text first (escapeHtml, all five special chars incl. apostrophe), then only emits an <a> for entries present in mentionedPersons, and refuses to render an anchor for a non-UUID personId (CWE-601 open-redirect defense — good forward-thinking guard). With the sidecar row gone (post-cascade), mentionedPersons is empty → the @DisplayName falls through as plain escaped text. The new mention.spec.ts test locks this precisely: asserts the name survives verbatim and that none of <a, person-mention, data-person-id, href leak through. That is the right contract — degradation must not silently reintroduce a dead/injectable link. The SafeHtml brand keeps {@html} consumers honest.

6. Permission posture unchanged
PersonController still carries @RequirePermission(Permission.WRITE_ALL) on DELETE /{id} and POST /{id}/merge. Thinning deletePerson/mergePersons did not touch the authorization boundary. The DELETE endpoint is @DeleteMapping (explicit verb), not a wildcard @RequestMapping.


Auditability (Suggestion, not a blocker)

DB ON DELETE cascades run below AuditService, so the row-level cleanup (which receiver/mention links were dropped) is not audit-logged. This is acceptable: the person-delete action is still logged at the service layer, ADR-032 documents the trade explicitly, and the dropped rows are pure derived link-state (the human-readable @Name survives in transcription_blocks.text, senderText survives on the document). For an 8-person family archive this is the right cost/benefit. I'd only revisit if a future compliance requirement demands per-link delete provenance — note it in the ADR's Consequences if that ever lands.

Blockers

None.

Suggestions

  • (minor) Consider a tiny follow-up assertion in the orphan-purge test path that a non-orphan mention row for a still-existing person is not deleted by the DO block — it's implied by NOT EXISTS but an explicit "innocent row survives" test would pin the over-delete boundary the same way the document-survival test pins the cascade boundary. Not blocking; the constraint logic is unambiguous.

Nice work — security-positive refactor, fail-closed, and the one assertion I'd have insisted on is already in the suite.

## 🔒 Nora — Security Expert **Verdict: ✅ Approved** This is a clean-room security pass. I treated the migration as hostile DDL and the renderer change as a potential stored-XSS regression. Both hold up. This is a *defensive* change — it moves a fragile application-layer invariant into the database, which is exactly where referential-integrity guarantees belong (fail-closed by construction, enforced on every delete path, not just `PersonService`). --- ### What I checked (and why it's safe) **1. Data-loss / data-retention — the cascade never destroys a historical letter ✅** - `documents.sender_id` → `ON DELETE SET NULL`, **not** CASCADE. The document row survives; `documents.senderText` preserves the raw textual attribution, so nulling the FK loses no historical record. This is the correct non-destructive choice and the ADR-032 "Alternatives considered" explicitly rejects cascading `documents.sender_id`. Good — that rejected alternative is the one that would have deleted family letters. - The two CASCADEs (`document_receivers.person_id`, `transcription_block_mentioned_persons.person_id`) reach only **join/sidecar** rows, never a `documents` row. **2. Cascade-boundary invariant is test-pinned ✅ (this was my non-negotiable)** `PersonRepositoryTest.deleteById_personSenderOfAReceiverOfB_...` asserts `documentRepository.findById(sent)` **and** `findById(received)` are both `present` after the delete, with an explicit comment naming the threat (a future migration flipping SET NULL → CASCADE). That is the regression lock I wanted — a renderer/schema refactor cannot silently turn this into a letter-shredder without a red test. AC-8 (`...senderAndReceiverOfSameDocument...`) covers the trickier same-row interaction. Tests run against real Postgres via Testcontainers, which is mandatory here — H2 would never fire the FK `ON DELETE` behaviour, so a unit-only test would be security theater. **3. Constraint names verified against source — migration is correct and complete ✅** I cross-checked the `DROP CONSTRAINT` names in V71 against `V1__initial_schema.sql`: - `documents.sender_id` = `fkl5xhww7es3b4um01vmly4y18m` ✓ - `document_receivers.person_id` = `fkcg7r68qvosqricx1betgrlt7s` ✓ - V14 only altered the `document_id` sides of the join tables, so the `person_id` FK on `document_receivers` was still the V1-default (no `ON DELETE`) — V71 dropping/recreating it is correct, not a double-apply. **4. Orphan-purge DELETE is bounded — no over-delete ✅** ```sql DELETE FROM transcription_block_mentioned_persons m WHERE NOT EXISTS (SELECT 1 FROM persons p WHERE p.id = m.person_id); ``` Correlated `NOT EXISTS` on `person_id` — deletes *only* rows already orphaned (a person that no longer exists). It cannot touch a row whose `person_id` still resolves, and it physically cannot reach `documents` or `persons`. The `(block_id, person_id)` PK (V65) means these are whole tuples; no duplicate/partial-row hazard. The purge is gated behind the `ADD CONSTRAINT` validation scan, so it is also necessary (the scan would 500 the migration on pre-existing dangling rows otherwise). Count is surfaced via `RAISE NOTICE` — good operability, no sensitive data in the log line. **5. @-mention XSS / escaping degradation contract ✅** Read `mention.ts` in full. `renderTranscriptionBody` escapes the *entire* block text first (`escapeHtml`, all five special chars incl. apostrophe), then only emits an `<a>` for entries present in `mentionedPersons`, and refuses to render an anchor for a non-UUID `personId` (CWE-601 open-redirect defense — good forward-thinking guard). With the sidecar row gone (post-cascade), `mentionedPersons` is empty → the `@DisplayName` falls through as plain *escaped* text. The new `mention.spec.ts` test locks this precisely: asserts the name survives verbatim **and** that none of `<a`, `person-mention`, `data-person-id`, `href` leak through. That is the right contract — degradation must not silently reintroduce a dead/injectable link. The `SafeHtml` brand keeps `{@html}` consumers honest. **6. Permission posture unchanged ✅** `PersonController` still carries `@RequirePermission(Permission.WRITE_ALL)` on `DELETE /{id}` and `POST /{id}/merge`. Thinning `deletePerson`/`mergePersons` did not touch the authorization boundary. The DELETE endpoint is `@DeleteMapping` (explicit verb), not a wildcard `@RequestMapping`. --- ### Auditability (Suggestion, not a blocker) DB `ON DELETE` cascades run *below* `AuditService`, so the row-level cleanup (which receiver/mention links were dropped) is not audit-logged. This is **acceptable**: the person-delete *action* is still logged at the service layer, ADR-032 documents the trade explicitly, and the dropped rows are pure derived link-state (the human-readable `@Name` survives in `transcription_blocks.text`, `senderText` survives on the document). For an 8-person family archive this is the right cost/benefit. I'd only revisit if a future compliance requirement demands per-link delete provenance — note it in the ADR's Consequences if that ever lands. ### Blockers None. ### Suggestions - (minor) Consider a tiny follow-up assertion in the orphan-purge test path that a *non-orphan* mention row for a still-existing person is **not** deleted by the `DO` block — it's implied by `NOT EXISTS` but an explicit "innocent row survives" test would pin the over-delete boundary the same way the document-survival test pins the cascade boundary. Not blocking; the constraint logic is unambiguous. Nice work — security-positive refactor, fail-closed, and the one assertion I'd have insisted on is already in the suite.
marcel added 2 commits 2026-06-06 12:15:53 +02:00
Editing an already-applied migration changes its Flyway checksum and would
fail validateOnMigrate against prod (where V56 is applied). Revert the V56
comment edit; V71 now records that it reverses V56's no-FK choice and points
to ADR-032 as the authoritative record, so the V56 -> V71 trail stays
discoverable without touching the applied migration. (DevOps review, PR #736.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
test(person): address PR #736 review nits
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 3m21s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Failing after 3m20s
CI / fail2ban Regex (pull_request) Successful in 49s
CI / Semgrep Security Scan (pull_request) Successful in 23s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m17s
4c2d7ba128
- AC-3 cascade test: assert an innocent bystander's mention row survives the
  delete, proving the cascade is scoped to the deleted person (Nora).
- Fix integration-test comment: receivers is @ManyToMany(LAZY), not an EAGER
  @ElementCollection (Sara).
- ADR-032: note the @ prefix is kept in the degraded path, stripped in live
  mentions (Leonie).
- Add trailing newline to PersonRepository.java (Felix).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
marcel added 1 commit 2026-06-06 12:26:37 +02:00
test(transcription): persist real persons for mention FK after V71 (#684)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m19s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m34s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m3s
a5cf5c1054
V71 gives transcription_block_mentioned_persons.person_id a real FK, so two
TranscriptionBlockMentionsRepositoryTest cases that inserted mention rows with
random (non-existent) person ids now violate fk_tbmp_person. Persist real
Person rows and use their ids. Caught by CI's full suite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
marcel merged commit 43601a3770 into main 2026-06-06 12:34:47 +02:00
marcel deleted branch feat/issue-684-person-delete-db-fk 2026-06-06 12:34:47 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#736