feat: Person @mentions in transcription blocks with hover card #362

Closed
opened 2026-04-28 12:16:14 +02:00 by marcel · 23 comments
Owner

Overview

Transcribers can tag historical persons directly in transcription block text using an @mention typeahead (analogous to the existing user mention in comments). In read mode, tagged names render as underlined links. On desktop, hovering shows a rich popup card (Wikipedia-style); clicking — or tapping on touch — navigates to the person detail page.


Interaction model

Surface Behaviour
Edit mode — typing @ Person search typeahead opens (searches /api/persons?q=...)
Edit mode — selecting a result @Display Name inserted into text; UUID stored in sidecar (never in text)
Read mode — desktop hover Rich hover card appears with name, dates, maiden name, notes excerpt, family relationships
Read mode — desktop click Navigate to /persons/{id}
Read mode — touch tap Navigate to /persons/{id} directly (no hover card on touch)

The @ prefix is stripped in read mode — the link reads "Auguste Raddatz", not "@Auguste Raddatz", since the @ is an editor affordance, not part of the historical text.


Data model

New JSONB column on transcription_blocks:

-- V{n}__add_mentioned_persons_to_transcription_blocks.sql
ALTER TABLE transcription_blocks
  ADD COLUMN mentioned_persons JSONB NOT NULL DEFAULT '[]'::jsonb;

CREATE INDEX idx_transcription_block_mentioned_persons
  ON transcription_blocks USING GIN (mentioned_persons);

Each entry in the array:

{ "personId": "550e8400-...", "displayName": "Auguste Raddatz" }

displayName is what renderTranscriptionBody() scans the block text for. personId is what the link and hover card resolve to. The UUID never appears in block.text.

The GIN index supports the future "show all documents where person X is mentioned" query on the person detail page (WHERE mentioned_persons @> '[{"personId":"..."}]'). A normalised join table can be added at that point if queries get complex — not needed now (YAGNI).


Backend changes

1. TranscriptionBlock entity

@Type(JsonType.class)
@Column(columnDefinition = "jsonb")
@Builder.Default
private List<PersonMention> mentionedPersons = new ArrayList<>();

New value object (no table, no repository):

@Data @AllArgsConstructor @NoArgsConstructor
public class PersonMention {
    private UUID personId;
    private String displayName;
}

2. Save/update DTO

CreateTranscriptionBlockDTO (and any update DTO) gets:

private List<PersonMention> mentionedPersons = new ArrayList<>();

3. PersonService name-change hook

When a person's displayName changes, PersonService calls TranscriptionBlockService.propagateDisplayNameChange(UUID personId, String oldName, String newName):

  • Finds all blocks where mentioned_persons @> [{"personId": "..."}] (GIN-indexed)
  • For each block: text.replace("@" + oldName, "@" + newName)
  • Updates displayName in the JSON entry
  • Saves — @Transactional

4. No new endpoints

Hover card data uses two existing endpoints already in the codebase:

  • GET /api/persons/{id} — name, birthYear, deathYear, notes, nameAliases
  • GET /api/persons/{id}/relationships — direct relationships (filtered client-side to PARENT_OF, SPOUSE_OF, SIBLING_OF only; no inferred/derived relationships)

Frontend — edit mode

New PersonMentionEditor.svelte — thin variant of MentionEditor.svelte, three differences:

  1. Searches /api/persons?q=... (not /api/users/search?q=...)
  2. Dropdown rows show name + life dates so transcribers can distinguish persons with similar names (e.g. "Auguste Raddatz · geb. Müller · 1861–1934")
  3. selectPerson() writes @DisplayName into the textarea text and pushes {personId, displayName} into the bound mentionedPersons array (analogous to mentionCandidates in MentionEditor)

The textarea remains a plain <textarea> — no contenteditable, consistent with the decision made for comment mentions. Users never see a UUID at any point.


Frontend — render mode

renderTranscriptionBody(text, mentionedPersons)

New function alongside the existing renderBody() in mention.ts. For each entry in mentionedPersons, replaces @DisplayName in the text with:

<button class="person-mention" data-person-id="{uuid}">{DisplayName}</button>

Names typed manually without the typeahead are not in the sidecar and remain as plain text. This is intentional — you must explicitly use the typeahead to create a mention.

PersonHoverCard.svelte

  • Positioned absolutely near the mention button on mouseenter
  • Fetches lazily on first hover — shows a skeleton shimmer while loading
  • Structure:
    • Navy header: name · dates · "geb. MaidenName" (each omitted if null)
    • Relationship chips: direct family relationships only (PARENT_OF, SPOUSE_OF, SIBLING_OF), omitted section if none
    • Notes excerpt: max 120 chars with ellipsis, omitted if null
    • Footer: "Zur Person →" link + "Klick öffnet Seite" hint text
  • Hidden on touch devices (@media (hover: none)) — tap navigates directly

TranscriptionReadView.svelte

  • Renders renderTranscriptionBody() output as {@html ...} (same pattern as current comment rendering)
  • Attaches mouseenter/mouseleave handlers to .person-mention buttons to mount/unmount PersonHoverCard
  • click handler on .person-mentiongoto('/persons/{personId}')

Edge cases

Case Behaviour
Person deleted after mention created Fetch returns 404 → mention degrades to plain unlinked text; @Name remains visible in the block as a historical trace
Person has no dates Header shows name only; no empty dash
Person has no maiden name "geb." line omitted
Person has no notes Notes section hidden
Person has no family relationships Chips section hidden
Notes very long Capped at 120 chars with ellipsis; full text on person detail page
Transcriber manually types @Name without typeahead Not in sidecar → not linked, renders as plain text

Acceptance criteria

Given a transcriber is editing a transcription block and types @Aug
When results appear in the typeahead
Then each result shows the person's display name and life dates, and the matched characters are highlighted

Given a transcriber selects a person from the typeahead
When the block is saved
Then block.text contains @Auguste Raddatz (no UUID) and block.mentionedPersons contains {personId, displayName} for that person

Given a reader views a transcription block in read mode on desktop
When they hover over a person mention
Then a hover card appears within 200ms showing name, life dates, maiden name (if any), family relationship chips (family-type only), and a notes excerpt (if any)

Given a reader clicks a person mention
When on any device
Then they are navigated to /persons/{id}

Given a reader views a transcription block on a touch device
When they tap a person mention
Then they are navigated directly to /persons/{id} (no hover card shown)

Given a person's display name is updated in the system
When the update is saved
Then all transcription block texts containing @OldName for that person are updated to @NewName, and the displayName in each block's mentionedPersons sidecar is updated atomically

Given the person referenced by a mention has been deleted
When a reader hovers over the mention
Then no hover card appears and the text renders as plain unlinked text


Open questions

# Question Impact
OQ-1 Two different persons with the same displayName mentioned in the same block — rendering is ambiguous. Acceptable limitation for now? Low — edge case in a family archive
OQ-2 Future feature: "show all documents where person X is mentioned" on person detail page. Will GIN-indexed JSONB query be fast enough, or should a normalised join table be added at that point? Low now, revisit when feature is specced
## Overview Transcribers can tag historical persons directly in transcription block text using an `@mention` typeahead (analogous to the existing user mention in comments). In read mode, tagged names render as underlined links. On desktop, hovering shows a rich popup card (Wikipedia-style); clicking — or tapping on touch — navigates to the person detail page. --- ## Interaction model | Surface | Behaviour | |---|---| | Edit mode — typing `@` | Person search typeahead opens (searches `/api/persons?q=...`) | | Edit mode — selecting a result | `@Display Name` inserted into text; UUID stored in sidecar (never in text) | | Read mode — desktop hover | Rich hover card appears with name, dates, maiden name, notes excerpt, family relationships | | Read mode — desktop click | Navigate to `/persons/{id}` | | Read mode — touch tap | Navigate to `/persons/{id}` directly (no hover card on touch) | The `@` prefix is stripped in read mode — the link reads "Auguste Raddatz", not "@Auguste Raddatz", since the `@` is an editor affordance, not part of the historical text. --- ## Data model New JSONB column on `transcription_blocks`: ```sql -- V{n}__add_mentioned_persons_to_transcription_blocks.sql ALTER TABLE transcription_blocks ADD COLUMN mentioned_persons JSONB NOT NULL DEFAULT '[]'::jsonb; CREATE INDEX idx_transcription_block_mentioned_persons ON transcription_blocks USING GIN (mentioned_persons); ``` Each entry in the array: ```json { "personId": "550e8400-...", "displayName": "Auguste Raddatz" } ``` `displayName` is what `renderTranscriptionBody()` scans the block text for. `personId` is what the link and hover card resolve to. The UUID never appears in `block.text`. The GIN index supports the future "show all documents where person X is mentioned" query on the person detail page (`WHERE mentioned_persons @> '[{"personId":"..."}]'`). A normalised join table can be added at that point if queries get complex — not needed now (YAGNI). --- ## Backend changes ### 1. `TranscriptionBlock` entity ```java @Type(JsonType.class) @Column(columnDefinition = "jsonb") @Builder.Default private List<PersonMention> mentionedPersons = new ArrayList<>(); ``` New value object (no table, no repository): ```java @Data @AllArgsConstructor @NoArgsConstructor public class PersonMention { private UUID personId; private String displayName; } ``` ### 2. Save/update DTO `CreateTranscriptionBlockDTO` (and any update DTO) gets: ```java private List<PersonMention> mentionedPersons = new ArrayList<>(); ``` ### 3. `PersonService` name-change hook When a person's `displayName` changes, `PersonService` calls `TranscriptionBlockService.propagateDisplayNameChange(UUID personId, String oldName, String newName)`: - Finds all blocks where `mentioned_persons @> [{"personId": "..."}]` (GIN-indexed) - For each block: `text.replace("@" + oldName, "@" + newName)` - Updates `displayName` in the JSON entry - Saves — `@Transactional` ### 4. No new endpoints Hover card data uses two existing endpoints already in the codebase: - `GET /api/persons/{id}` — name, birthYear, deathYear, notes, nameAliases - `GET /api/persons/{id}/relationships` — direct relationships (filtered client-side to `PARENT_OF`, `SPOUSE_OF`, `SIBLING_OF` only; no inferred/derived relationships) --- ## Frontend — edit mode New `PersonMentionEditor.svelte` — thin variant of `MentionEditor.svelte`, three differences: 1. Searches `/api/persons?q=...` (not `/api/users/search?q=...`) 2. Dropdown rows show **name + life dates** so transcribers can distinguish persons with similar names (e.g. "Auguste Raddatz · geb. Müller · 1861–1934") 3. `selectPerson()` writes `@DisplayName` into the textarea text and pushes `{personId, displayName}` into the bound `mentionedPersons` array (analogous to `mentionCandidates` in `MentionEditor`) The textarea remains a plain `<textarea>` — no `contenteditable`, consistent with the decision made for comment mentions. Users never see a UUID at any point. --- ## Frontend — render mode ### `renderTranscriptionBody(text, mentionedPersons)` New function alongside the existing `renderBody()` in `mention.ts`. For each entry in `mentionedPersons`, replaces `@DisplayName` in the text with: ```html <button class="person-mention" data-person-id="{uuid}">{DisplayName}</button> ``` Names typed manually without the typeahead are **not** in the sidecar and remain as plain text. This is intentional — you must explicitly use the typeahead to create a mention. ### `PersonHoverCard.svelte` - Positioned absolutely near the mention button on `mouseenter` - Fetches lazily on first hover — shows a skeleton shimmer while loading - Structure: - **Navy header**: name · dates · "geb. MaidenName" (each omitted if null) - **Relationship chips**: direct family relationships only (`PARENT_OF`, `SPOUSE_OF`, `SIBLING_OF`), omitted section if none - **Notes excerpt**: max 120 chars with ellipsis, omitted if null - **Footer**: "Zur Person →" link + "Klick öffnet Seite" hint text - Hidden on touch devices (`@media (hover: none)`) — tap navigates directly ### `TranscriptionReadView.svelte` - Renders `renderTranscriptionBody()` output as `{@html ...}` (same pattern as current comment rendering) - Attaches `mouseenter`/`mouseleave` handlers to `.person-mention` buttons to mount/unmount `PersonHoverCard` - `click` handler on `.person-mention` → `goto('/persons/{personId}')` --- ## Edge cases | Case | Behaviour | |---|---| | Person deleted after mention created | Fetch returns 404 → mention degrades to plain unlinked text; `@Name` remains visible in the block as a historical trace | | Person has no dates | Header shows name only; no empty dash | | Person has no maiden name | "geb." line omitted | | Person has no notes | Notes section hidden | | Person has no family relationships | Chips section hidden | | Notes very long | Capped at 120 chars with ellipsis; full text on person detail page | | Transcriber manually types `@Name` without typeahead | Not in sidecar → not linked, renders as plain text | --- ## Acceptance criteria **Given** a transcriber is editing a transcription block and types `@Aug` **When** results appear in the typeahead **Then** each result shows the person's display name and life dates, and the matched characters are highlighted **Given** a transcriber selects a person from the typeahead **When** the block is saved **Then** `block.text` contains `@Auguste Raddatz` (no UUID) and `block.mentionedPersons` contains `{personId, displayName}` for that person **Given** a reader views a transcription block in read mode on desktop **When** they hover over a person mention **Then** a hover card appears within 200ms showing name, life dates, maiden name (if any), family relationship chips (family-type only), and a notes excerpt (if any) **Given** a reader clicks a person mention **When** on any device **Then** they are navigated to `/persons/{id}` **Given** a reader views a transcription block on a touch device **When** they tap a person mention **Then** they are navigated directly to `/persons/{id}` (no hover card shown) **Given** a person's display name is updated in the system **When** the update is saved **Then** all transcription block texts containing `@OldName` for that person are updated to `@NewName`, and the `displayName` in each block's `mentionedPersons` sidecar is updated atomically **Given** the person referenced by a mention has been deleted **When** a reader hovers over the mention **Then** no hover card appears and the text renders as plain unlinked text --- ## Open questions | # | Question | Impact | |---|---|---| | OQ-1 | Two different persons with the same `displayName` mentioned in the same block — rendering is ambiguous. Acceptable limitation for now? | Low — edge case in a family archive | | OQ-2 | Future feature: "show all documents where person X is mentioned" on person detail page. Will GIN-indexed JSONB query be fast enough, or should a normalised join table be added at that point? | Low now, revisit when feature is specced |
marcel added this to the (deleted) milestone 2026-04-28 12:16:14 +02:00
marcel added the P2-mediumfeaturepersonui labels 2026-04-28 12:16:32 +02:00
Author
Owner

🏗️ Markus Keller — Senior Application Architect

Observations

  • Cross-domain coupling is inverted. The spec has PersonService.propagateDisplayNameChange() call TranscriptionBlockService. In the existing architecture, Document depends on Person — that direction is natural (document needs person metadata). But Person depending on Transcription inverts the dependency arrow: Person domain now has a runtime dependency on the Transcription domain. Person records are agnostic of where they are used; they should not import a Transcription service.

  • @Type(JsonType.class) requires hypersistence-utils-hibernate63, which is not in pom.xml. Before adding a new library, note that UserGroup.java already uses @ElementCollection(fetch = FetchType.EAGER) for its Set<String> permissions — native JPA, no external library. PersonMention can be modeled the same way with a @Embeddable value object and @ElementCollection. The schema result is similar (a child table transcription_block_mentioned_persons), avoids the dependency, and is already an established pattern in this codebase.

  • JSONB vs join table design divergence. DocumentComment tracks user mentions via a @ManyToMany comment_mentions join table. This spec uses JSONB for person mentions. The divergence is defensible — you will need WHERE mentioned_persons @> [{personId:...}] queries for the person detail page, which JSONB + GIN handles well. But the schema design choice should be documented in the migration comment, since future devs will see two completely different patterns for "this entity has mentions."

  • GIN index is correct forward planning. The WHERE mentioned_persons @> '[{"personId":"..."}]' query on the person detail page is the natural follow-on, and the GIN index makes it O(log n). Keep it.

Recommendations

  • Decouple via ApplicationEvent. PersonService publishes a PersonDisplayNameChangedEvent(personId, oldName, newName) via ApplicationEventPublisher. A PersonMentionPropagationListener in the transcription package handles it with @EventListener @Transactional. Person domain has zero runtime dependency on Transcription. Spring's event bus is synchronous by default, so the whole operation still commits atomically in one transaction.

  • Prefer @ElementCollection over JSONB + hypersistence-utils unless there is a concrete reason to store JSON in PostgreSQL now (e.g., you need arbitrary nested structure). @Embeddable PersonMention { UUID personId; String displayName; } with @ElementCollection on the block entity works without a new dependency. If the future "query all blocks mentioning person X" query turns out to need a GIN index, add the hypersistence dependency at that point with full justification.

  • Add a migration comment explaining why this table uses JSONB/ElementCollection rather than a join table — specifically the "future bidirectional query" motivation. Future devs reading the schema need to understand why this differs from comment_mentions.

## 🏗️ Markus Keller — Senior Application Architect ### Observations - **Cross-domain coupling is inverted.** The spec has `PersonService.propagateDisplayNameChange()` call `TranscriptionBlockService`. In the existing architecture, Document depends on Person — that direction is natural (document needs person metadata). But Person depending on Transcription inverts the dependency arrow: Person domain now has a runtime dependency on the Transcription domain. Person records are agnostic of where they are used; they should not import a Transcription service. - **`@Type(JsonType.class)` requires `hypersistence-utils-hibernate63`**, which is **not in pom.xml**. Before adding a new library, note that `UserGroup.java` already uses `@ElementCollection(fetch = FetchType.EAGER)` for its `Set<String> permissions` — native JPA, no external library. `PersonMention` can be modeled the same way with a `@Embeddable` value object and `@ElementCollection`. The schema result is similar (a child table `transcription_block_mentioned_persons`), avoids the dependency, and is already an established pattern in this codebase. - **JSONB vs join table design divergence.** `DocumentComment` tracks user mentions via a `@ManyToMany comment_mentions` join table. This spec uses JSONB for person mentions. The divergence is defensible — you will need `WHERE mentioned_persons @> [{personId:...}]` queries for the person detail page, which JSONB + GIN handles well. But the schema design choice should be documented in the migration comment, since future devs will see two completely different patterns for "this entity has mentions." - **GIN index is correct forward planning.** The `WHERE mentioned_persons @> '[{"personId":"..."}]'` query on the person detail page is the natural follow-on, and the GIN index makes it O(log n). Keep it. ### Recommendations - **Decouple via `ApplicationEvent`.** `PersonService` publishes a `PersonDisplayNameChangedEvent(personId, oldName, newName)` via `ApplicationEventPublisher`. A `PersonMentionPropagationListener` in the transcription package handles it with `@EventListener @Transactional`. Person domain has zero runtime dependency on Transcription. Spring's event bus is synchronous by default, so the whole operation still commits atomically in one transaction. - **Prefer `@ElementCollection` over JSONB + hypersistence-utils** unless there is a concrete reason to store JSON in PostgreSQL now (e.g., you need arbitrary nested structure). `@Embeddable PersonMention { UUID personId; String displayName; }` with `@ElementCollection` on the block entity works without a new dependency. If the future "query all blocks mentioning person X" query turns out to need a GIN index, add the hypersistence dependency at that point with full justification. - **Add a migration comment** explaining why this table uses JSONB/ElementCollection rather than a join table — specifically the "future bidirectional query" motivation. Future devs reading the schema need to understand why this differs from `comment_mentions`.
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Observations

  • CRITICAL: detectMention() breaks on multi-word person names. I checked frontend/src/lib/utils/mention.ts:23:

    if (query.includes(' ')) return null;
    

    Once the user types a space, mention detection is killed. For user mentions (firstName only), this is fine — @Max triggers and the full name is inserted. For person mentions, display names ARE multi-word: @Auguste Raddatz, @Marie von Braun. The user can type @Aug and get results, but if they type @Auguste (with space, wanting to narrow by last name), detection stops and the dropdown disappears. PersonMentionEditor must use a custom detectMention() variant that allows spaces in the query — e.g., stop only at @@ or at the start of a new word preceded by a non-name character.

  • @Type(JsonType.class) dependency is missing. hypersistence-utils-hibernate63 is not in pom.xml. The codebase already uses @ElementCollection in UserGroup.java for a similar need (a set of embedded values). Use @Embeddable PersonMention { UUID personId; String displayName; } + @ElementCollection — same semantics, no new library, established pattern.

  • renderTranscriptionBody() must HTML-escape displayName before interpolating into HTML. renderBody() at mention.ts:54-69 shows the correct pattern: escape the full content first, then also escape displayName separately before building the span. If a person's displayName is <script>alert(1)</script> (set by any WRITE_ALL user), every reader viewing that block gets XSS. The fix is one additional replaceAll pass on displayName — identical to what renderBody() already does.

  • OQ-1 has a concrete implementation problem. Two persons with the same displayName in one block: renderTranscriptionBody() with simple replaceAll would map ALL occurrences of @Auguste Raddatz to the first personId in the sidecar. The second sidecar entry never links to anything. The spec marks this as acceptable, but the implementation should be explicit about the tie-breaking rule (first entry wins) so it's not accidentally "fixed" later in a way that breaks the semantics.

Recommendations

  • Write a detectPersonMention() variant in a new personMention.ts utility (or parameterise the existing function) that allows spaces in the query: stop detection at @ boundaries or newlines only. Test it with: @Auguste Raddatz, @M (single char), @Auguste (trailing space), and @ alone.

  • Use @ElementCollection + @Embeddable for PersonMention. Lookup UserGroup.java for the exact annotation pattern already in use.

  • In renderTranscriptionBody(), escape displayName exactly like renderBody() does for its display names (lines 62–66 of mention.ts). Copy that four-line block verbatim.

  • In renderTranscriptionBody(), use <a href="/persons/${mention.personId}" class="person-mention"> instead of <button>. This gives browser-native affordances (right-click → open in new tab, middle-click) and semantically correct role=link for screen readers. The href can be pre-computed at render time since all personIds are in the sidecar.

## 👨‍💻 Felix Brandt — Senior Fullstack Developer ### Observations - **CRITICAL: `detectMention()` breaks on multi-word person names.** I checked `frontend/src/lib/utils/mention.ts:23`: ```typescript if (query.includes(' ')) return null; ``` Once the user types a space, mention detection is killed. For user mentions (firstName only), this is fine — `@Max` triggers and the full name is inserted. For person mentions, display names ARE multi-word: `@Auguste Raddatz`, `@Marie von Braun`. The user can type `@Aug` and get results, but if they type `@Auguste ` (with space, wanting to narrow by last name), detection stops and the dropdown disappears. `PersonMentionEditor` must use a custom `detectMention()` variant that allows spaces in the query — e.g., stop only at `@@` or at the start of a new word preceded by a non-name character. - **`@Type(JsonType.class)` dependency is missing.** `hypersistence-utils-hibernate63` is not in `pom.xml`. The codebase already uses `@ElementCollection` in `UserGroup.java` for a similar need (a set of embedded values). Use `@Embeddable PersonMention { UUID personId; String displayName; }` + `@ElementCollection` — same semantics, no new library, established pattern. - **`renderTranscriptionBody()` must HTML-escape `displayName` before interpolating into HTML.** `renderBody()` at `mention.ts:54-69` shows the correct pattern: escape the full content first, then also escape `displayName` separately before building the span. If a person's `displayName` is `<script>alert(1)</script>` (set by any WRITE_ALL user), every reader viewing that block gets XSS. The fix is one additional `replaceAll` pass on `displayName` — identical to what `renderBody()` already does. - **OQ-1 has a concrete implementation problem.** Two persons with the same `displayName` in one block: `renderTranscriptionBody()` with simple `replaceAll` would map ALL occurrences of `@Auguste Raddatz` to the first `personId` in the sidecar. The second sidecar entry never links to anything. The spec marks this as acceptable, but the implementation should be explicit about the tie-breaking rule (first entry wins) so it's not accidentally "fixed" later in a way that breaks the semantics. ### Recommendations - Write a `detectPersonMention()` variant in a new `personMention.ts` utility (or parameterise the existing function) that allows spaces in the query: stop detection at `@` boundaries or newlines only. Test it with: `@Auguste Raddatz`, `@M` (single char), `@Auguste ` (trailing space), and `@` alone. - Use `@ElementCollection` + `@Embeddable` for `PersonMention`. Lookup `UserGroup.java` for the exact annotation pattern already in use. - In `renderTranscriptionBody()`, escape `displayName` exactly like `renderBody()` does for its display names (lines 62–66 of `mention.ts`). Copy that four-line block verbatim. - In `renderTranscriptionBody()`, use `<a href="/persons/${mention.personId}" class="person-mention">` instead of `<button>`. This gives browser-native affordances (right-click → open in new tab, middle-click) and semantically correct role=link for screen readers. The `href` can be pre-computed at render time since all `personIds` are in the sidecar.
Author
Owner

🔐 Nora "NullX" Steiner — Application Security Engineer

Observations

  • XSS in renderTranscriptionBody(). The spec generates <button class="person-mention" data-person-id="{uuid}">{DisplayName}</button> where {DisplayName} comes from the sidecar (persisted in the database by the saving client). Any user with WRITE_ALL permission can create a person named <img src=x onerror=alert(document.cookie)>. Every reader who views any transcription block mentioning that person then executes attacker-controlled JavaScript. The existing renderBody() function (mention.ts:54–69) already shows the exact correct pattern: escape the full content string first, then separately escape displayName before interpolating it. The new function must follow the same pattern.

  • mentionedPersons DTO input validation is unspecified. The spec adds List<PersonMention> mentionedPersons to the DTO but does not constrain it. A client can submit: (a) malformed/non-existent UUIDs as personId, (b) extremely long displayName strings. Recommend: @Size(max = 200) on displayName (matches the Person.displayName field length) and @NotNull on personId. Validating that the UUID references a real person at save time is optional but prevents dangling sidecar entries.

  • Historical text mutation in propagateDisplayNameChange(). This method does text.replace("@" + oldName, "@" + newName) on stored historical transcription data. A bug here (wrong match, partial name collision) permanently corrupts original source material. The @Transactional wrapper correctly ensures atomic rollback on failure. Confirm: the transaction includes both the mentionedPersons JSONB update AND the text field update in a single commit — a partial update where text changes but sidecar doesn't (or vice versa) would break the rendering invariant.

  • Client-side hover card fetches. PersonHoverCard.svelte fetches /api/persons/{id} and /api/persons/{id}/relationships from the browser. The Vite proxy (vite.config.ts) injects the auth_token cookie header automatically for /api calls in dev. In production (behind Caddy), the session cookie is forwarded normally. No authentication bypass issue here, but confirm that GET /api/persons/{id} requires at minimum READ_ALL (the existing PersonController likely gates on authentication already — worth verifying in the controller source).

Recommendations

  • Copy the four-line HTML escape block from renderBody() (mention.ts:62–65) into renderTranscriptionBody() for displayName. Zero tolerance for this class of bug — it affects every user viewing the page.

  • Add to PersonMention value object (DTO layer): @NotNull UUID personId and @Size(max = 200) String displayName.

  • Add a security regression test: save a transcription block with mentionedPersons: [{personId: ..., displayName: "<script>alert(1)</script>"}]. Render it with renderTranscriptionBody(). Assert the output contains &lt;script&gt; (escaped), not <script> (raw).

  • Confirm PersonController.GET /api/persons/{id} is authentication-gated. If not, the hover card would leak person details (names, birthdates, notes) to unauthenticated callers who fetch the endpoint directly.

## 🔐 Nora "NullX" Steiner — Application Security Engineer ### Observations - **XSS in `renderTranscriptionBody()`.** The spec generates `<button class="person-mention" data-person-id="{uuid}">{DisplayName}</button>` where `{DisplayName}` comes from the sidecar (persisted in the database by the saving client). Any user with `WRITE_ALL` permission can create a person named `<img src=x onerror=alert(document.cookie)>`. Every reader who views any transcription block mentioning that person then executes attacker-controlled JavaScript. The existing `renderBody()` function (`mention.ts:54–69`) already shows the exact correct pattern: escape the full content string first, then separately escape `displayName` before interpolating it. The new function must follow the same pattern. - **`mentionedPersons` DTO input validation is unspecified.** The spec adds `List<PersonMention> mentionedPersons` to the DTO but does not constrain it. A client can submit: (a) malformed/non-existent UUIDs as `personId`, (b) extremely long `displayName` strings. Recommend: `@Size(max = 200)` on `displayName` (matches the `Person.displayName` field length) and `@NotNull` on `personId`. Validating that the UUID references a real person at save time is optional but prevents dangling sidecar entries. - **Historical text mutation in `propagateDisplayNameChange()`.** This method does `text.replace("@" + oldName, "@" + newName)` on stored historical transcription data. A bug here (wrong match, partial name collision) permanently corrupts original source material. The `@Transactional` wrapper correctly ensures atomic rollback on failure. Confirm: the transaction includes both the `mentionedPersons` JSONB update AND the `text` field update in a single commit — a partial update where text changes but sidecar doesn't (or vice versa) would break the rendering invariant. - **Client-side hover card fetches.** `PersonHoverCard.svelte` fetches `/api/persons/{id}` and `/api/persons/{id}/relationships` from the browser. The Vite proxy (`vite.config.ts`) injects the `auth_token` cookie header automatically for `/api` calls in dev. In production (behind Caddy), the session cookie is forwarded normally. No authentication bypass issue here, but confirm that `GET /api/persons/{id}` requires at minimum `READ_ALL` (the existing `PersonController` likely gates on authentication already — worth verifying in the controller source). ### Recommendations - Copy the four-line HTML escape block from `renderBody()` (`mention.ts:62–65`) into `renderTranscriptionBody()` for `displayName`. Zero tolerance for this class of bug — it affects every user viewing the page. - Add to `PersonMention` value object (DTO layer): `@NotNull UUID personId` and `@Size(max = 200) String displayName`. - Add a security regression test: save a transcription block with `mentionedPersons: [{personId: ..., displayName: "<script>alert(1)</script>"}]`. Render it with `renderTranscriptionBody()`. Assert the output contains `&lt;script&gt;` (escaped), not `<script>` (raw). - Confirm `PersonController.GET /api/persons/{id}` is authentication-gated. If not, the hover card would leak person details (names, birthdates, notes) to unauthenticated callers who fetch the endpoint directly.
Author
Owner

🧪 Sara Holt — Senior QA Engineer

Observations

  • propagateDisplayNameChange() has no test plan in the spec, and it is the highest-risk method in this feature. It mutates stored historical transcription text across an arbitrary number of rows. A bug here silently corrupts primary source material. This method needs integration tests with Testcontainers before any production deploy.

  • String.replace("@" + oldName, "@" + newName) matches manually-typed occurrences too. The spec explicitly states: "Names typed manually without the typeahead are not in the sidecar and remain as plain text." But propagation uses a text search that finds all @OldName occurrences regardless of whether they came from the typeahead. So renaming "Auguste Raddatz" → "Augusta Raddatz" updates manually-typed @Auguste Raddatz too — contradicting the "only explicit typeahead mentions are links" invariant.

  • detectMention() space restriction breaks multi-word name queries. The function returns null as soon as the query contains a space (mention.ts:23). This is a functional gap for person names. If not discovered in testing before release, transcribers will find that typing @Auguste R (partial last name) makes the dropdown disappear. This needs a specific test case.

  • Missing acceptance criteria:

    • Keyboard navigation in the typeahead dropdown (arrow keys, Enter, Escape) — the existing MentionEditor supports this; the spec is silent on whether PersonMentionEditor inherits it.
    • Save failure while mentionedPersons are pending — does the editor retain the pending mention for retry?
    • Network error on hover card fetch (distinct from 404 — e.g., timeout, 500). The spec defines 404 behavior (degrade to plain text) but not general network errors.
    • Multiple occurrences of @SamePerson in one block text — are all linked? Just the first?

Recommendations

Minimum test plan for propagateDisplayNameChange():

  1. Happy path: 3 blocks in Testcontainers DB, all mention @OldName — verify all texts and all sidecar entries updated atomically.
  2. No-op: person has zero mention entries — method completes without error.
  3. Partial name match guard: person named "Marie" renamed to "Maria" — ensure "Marie Braun" (different person) is not modified.
  4. Multiple occurrences: one block has @OldName twice — verify both replaced.
  5. Transaction rollback: inject a failure mid-propagation — verify neither text nor sidecar is partially updated.

For detectMention(): Either (a) write a PersonMentionEditor-specific detector that allows spaces and stops only at paragraph breaks/double-spaces, or (b) define the UX constraint explicitly in the spec: "To search by last name, transcribers must type the full first name first and select from the filtered list." Add an AC covering this.

Define hover card network error behavior (probably: silently show no card, do not navigate). Add to edge cases table and cover in PersonHoverCard unit tests.

## 🧪 Sara Holt — Senior QA Engineer ### Observations - **`propagateDisplayNameChange()` has no test plan in the spec, and it is the highest-risk method in this feature.** It mutates stored historical transcription text across an arbitrary number of rows. A bug here silently corrupts primary source material. This method needs integration tests with Testcontainers before any production deploy. - **`String.replace("@" + oldName, "@" + newName)` matches manually-typed occurrences too.** The spec explicitly states: "Names typed manually without the typeahead are not in the sidecar and remain as plain text." But propagation uses a text search that finds all `@OldName` occurrences regardless of whether they came from the typeahead. So renaming "Auguste Raddatz" → "Augusta Raddatz" updates manually-typed `@Auguste Raddatz` too — contradicting the "only explicit typeahead mentions are links" invariant. - **`detectMention()` space restriction breaks multi-word name queries.** The function returns `null` as soon as the query contains a space (`mention.ts:23`). This is a functional gap for person names. If not discovered in testing before release, transcribers will find that typing `@Auguste R` (partial last name) makes the dropdown disappear. This needs a specific test case. - **Missing acceptance criteria:** - Keyboard navigation in the typeahead dropdown (arrow keys, Enter, Escape) — the existing `MentionEditor` supports this; the spec is silent on whether `PersonMentionEditor` inherits it. - Save failure while mentionedPersons are pending — does the editor retain the pending mention for retry? - Network error on hover card fetch (distinct from 404 — e.g., timeout, 500). The spec defines 404 behavior (degrade to plain text) but not general network errors. - Multiple occurrences of `@SamePerson` in one block text — are all linked? Just the first? ### Recommendations **Minimum test plan for `propagateDisplayNameChange()`:** 1. Happy path: 3 blocks in Testcontainers DB, all mention `@OldName` — verify all texts and all sidecar entries updated atomically. 2. No-op: person has zero mention entries — method completes without error. 3. Partial name match guard: person named "Marie" renamed to "Maria" — ensure "Marie Braun" (different person) is not modified. 4. Multiple occurrences: one block has `@OldName` twice — verify both replaced. 5. Transaction rollback: inject a failure mid-propagation — verify neither text nor sidecar is partially updated. **For `detectMention()`:** Either (a) write a `PersonMentionEditor`-specific detector that allows spaces and stops only at paragraph breaks/double-spaces, or (b) define the UX constraint explicitly in the spec: "To search by last name, transcribers must type the full first name first and select from the filtered list." Add an AC covering this. **Define hover card network error behavior** (probably: silently show no card, do not navigate). Add to edge cases table and cover in `PersonHoverCard` unit tests.
Author
Owner

🎨 Leonie Voss — UX Designer & Accessibility Strategist

Observations

  • <button> for navigation is semantically wrong. The spec renders mention links as <button class="person-mention" data-person-id="{uuid}">. Buttons are for actions. Navigation is the job of <a href>. Screen readers announce <button> as "Auguste Raddatz, button" — confusing, because nothing is being submitted. An <a href="/persons/{id}"> announces "Auguste Raddatz, link" — exactly what the interaction is. Additional benefit: native browser affordances (right-click → open in new tab, middle-click, Ctrl+click) work without extra code. Since personId is available in the sidecar at render time, the href can be computed in renderTranscriptionBody() directly.

  • Hover card viewport overflow is unspecified. "Positioned absolutely near the mention button" — if the mention is in the last line of a block near the bottom edge, or at the right margin, the card clips off-screen. This is especially bad for the senior audience on 1280px screens with the reading pane centered. The spec should define a fallback: prefer below-right, flip to above-right if <200px from viewport bottom, flip to left if <300px from viewport right-edge.

  • "Klick öffnet Seite" and "Zur Person →" are hardcoded German. The app supports de/en/es via Paraglide. These two strings need i18n keys: m.person_mention_hover_footer_hint() and m.person_mention_hover_open_link() (or equivalent). Missing keys means English and Spanish readers see German.

  • No focus ring specified for mention links. Keyboard users Tab through the page and must see focus position. Every mention link needs focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2 outline-none rounded-sm. Since these links are rendered via {@html}, the focus ring styling must come from a CSS class in mention.css or global.css that targets .person-mention:focus-visible.

  • The 200ms AC is ambiguous. "Then a hover card appears within 200ms" — does this mean the skeleton appears, or the loaded card? With a lazy fetch, the loaded card could take 500–2000ms. The skeleton should appear within 200ms of mouseenter; the loaded content appears when the fetch resolves. The AC should say "the skeleton state appears within 200ms" to be testable.

Recommendations

  • Change <button><a href="/persons/{id}" class="person-mention"> in renderTranscriptionBody(). No onclick handler needed; the href handles navigation. For the hover card, attach the mouseenter/mouseleave to the anchor element instead.

  • Add smart card positioning: use getBoundingClientRect() on the trigger element and flip the card if it would overflow. Floating UI (@floating-ui/dom, already a SvelteKit-compatible library) handles this with two lines of code. Alternatively, specify the flip rules explicitly so the developer can implement them without a library.

  • Add Paraglide keys for both hover card strings before the i18n lock. Add to messages/de.json, en.json, es.json.

  • Add to app.css or equivalent: .person-mention:focus-visible { outline: none; ring: 2px solid var(--color-ink); } (or the Tailwind @apply focus-visible:ring-2 equivalent).

## 🎨 Leonie Voss — UX Designer & Accessibility Strategist ### Observations - **`<button>` for navigation is semantically wrong.** The spec renders mention links as `<button class="person-mention" data-person-id="{uuid}">`. Buttons are for actions. Navigation is the job of `<a href>`. Screen readers announce `<button>` as "Auguste Raddatz, button" — confusing, because nothing is being submitted. An `<a href="/persons/{id}">` announces "Auguste Raddatz, link" — exactly what the interaction is. Additional benefit: native browser affordances (right-click → open in new tab, middle-click, Ctrl+click) work without extra code. Since `personId` is available in the sidecar at render time, the href can be computed in `renderTranscriptionBody()` directly. - **Hover card viewport overflow is unspecified.** "Positioned absolutely near the mention button" — if the mention is in the last line of a block near the bottom edge, or at the right margin, the card clips off-screen. This is especially bad for the senior audience on 1280px screens with the reading pane centered. The spec should define a fallback: prefer below-right, flip to above-right if <200px from viewport bottom, flip to left if <300px from viewport right-edge. - **"Klick öffnet Seite" and "Zur Person →" are hardcoded German.** The app supports de/en/es via Paraglide. These two strings need i18n keys: `m.person_mention_hover_footer_hint()` and `m.person_mention_hover_open_link()` (or equivalent). Missing keys means English and Spanish readers see German. - **No focus ring specified for mention links.** Keyboard users Tab through the page and must see focus position. Every mention link needs `focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2 outline-none rounded-sm`. Since these links are rendered via `{@html}`, the focus ring styling must come from a CSS class in `mention.css` or `global.css` that targets `.person-mention:focus-visible`. - **The 200ms AC is ambiguous.** "Then a hover card appears within 200ms" — does this mean the skeleton appears, or the loaded card? With a lazy fetch, the loaded card could take 500–2000ms. The skeleton should appear within 200ms of `mouseenter`; the loaded content appears when the fetch resolves. The AC should say "the skeleton state appears within 200ms" to be testable. ### Recommendations - Change `<button>` → `<a href="/persons/{id}" class="person-mention">` in `renderTranscriptionBody()`. No onclick handler needed; the href handles navigation. For the hover card, attach the `mouseenter`/`mouseleave` to the anchor element instead. - Add smart card positioning: use `getBoundingClientRect()` on the trigger element and flip the card if it would overflow. Floating UI (`@floating-ui/dom`, already a SvelteKit-compatible library) handles this with two lines of code. Alternatively, specify the flip rules explicitly so the developer can implement them without a library. - Add Paraglide keys for both hover card strings before the i18n lock. Add to `messages/de.json`, `en.json`, `es.json`. - Add to `app.css` or equivalent: `.person-mention:focus-visible { outline: none; ring: 2px solid var(--color-ink); }` (or the Tailwind `@apply focus-visible:ring-2` equivalent).
Author
Owner

🚀 Tobias Wendt — DevOps & Platform Engineer

Observations

  • No infrastructure changes required. This feature is entirely application-level — no new Docker services, no new environment variables, no Compose changes. The existing PostgreSQL instance, MinIO, and Spring Boot container are all unchanged.

  • hypersistence-utils-hibernate63 is not in pom.xml. If the JSONB / @Type(JsonType.class) approach is chosen, this library must be added with a pinned version (e.g., 3.9.0). Renovate will pick it up for future bump PRs automatically once it is pinned. If the @ElementCollection alternative is used instead (no JSONB), this step is skipped entirely.

  • Flyway migration safety. The proposed migration adds mentioned_persons JSONB NOT NULL DEFAULT '[]'::jsonb to transcription_blocks and creates a GIN index. Adding a NOT NULL DEFAULT column to a table with existing rows is safe on PostgreSQL 11+ — it's a metadata-only operation (no full table rewrite). Creating a GIN index on a new (empty) column is near-instant. Both operations are production-safe as written.

  • OpenAPI regeneration is a required step after merge. The mentionedPersons field is added to the entity and at least one DTO. After merging the backend changes, the frontend developer must run npm run generate:api (with the backend running with --spring.profiles.active=dev) before writing any typed frontend code. If this step is skipped, the TypeScript types will be stale and type errors will appear only at compile time.

  • Propagation latency under load. propagateDisplayNameChange() runs synchronously within the PUT /api/persons/{id} request, holding the transaction open for the full duration. At family-archive scale (hundreds to low thousands of transcription blocks), this is fine — GIN-indexed JSONB lookup is fast. Worth noting if the archive grows significantly.

Recommendations

  • If hypersistence-utils is added: pin to a specific version tag in pom.xml, add a comment explaining the choice (JSONB querying for future person-detail page feature), and verify that Renovate's Maven configuration covers it.

  • Add a comment to the Flyway migration file: -- Adding GIN index is safe on new column (zero existing rows); NOT NULL DEFAULT is metadata-only on PG 11+. This spares the next person from re-checking migration safety.

  • No other DevOps action items on this feature. Clean ticket from an infra perspective.

## 🚀 Tobias Wendt — DevOps & Platform Engineer ### Observations - **No infrastructure changes required.** This feature is entirely application-level — no new Docker services, no new environment variables, no Compose changes. The existing PostgreSQL instance, MinIO, and Spring Boot container are all unchanged. - **`hypersistence-utils-hibernate63` is not in `pom.xml`.** If the JSONB / `@Type(JsonType.class)` approach is chosen, this library must be added with a pinned version (e.g., `3.9.0`). Renovate will pick it up for future bump PRs automatically once it is pinned. If the `@ElementCollection` alternative is used instead (no JSONB), this step is skipped entirely. - **Flyway migration safety.** The proposed migration adds `mentioned_persons JSONB NOT NULL DEFAULT '[]'::jsonb` to `transcription_blocks` and creates a GIN index. Adding a `NOT NULL DEFAULT` column to a table with existing rows is safe on PostgreSQL 11+ — it's a metadata-only operation (no full table rewrite). Creating a GIN index on a **new** (empty) column is near-instant. Both operations are production-safe as written. - **OpenAPI regeneration is a required step after merge.** The `mentionedPersons` field is added to the entity and at least one DTO. After merging the backend changes, the frontend developer must run `npm run generate:api` (with the backend running with `--spring.profiles.active=dev`) before writing any typed frontend code. If this step is skipped, the TypeScript types will be stale and type errors will appear only at compile time. - **Propagation latency under load.** `propagateDisplayNameChange()` runs synchronously within the `PUT /api/persons/{id}` request, holding the transaction open for the full duration. At family-archive scale (hundreds to low thousands of transcription blocks), this is fine — GIN-indexed JSONB lookup is fast. Worth noting if the archive grows significantly. ### Recommendations - If `hypersistence-utils` is added: pin to a specific version tag in `pom.xml`, add a comment explaining the choice (JSONB querying for future person-detail page feature), and verify that Renovate's Maven configuration covers it. - Add a comment to the Flyway migration file: `-- Adding GIN index is safe on new column (zero existing rows); NOT NULL DEFAULT is metadata-only on PG 11+.` This spares the next person from re-checking migration safety. - No other DevOps action items on this feature. Clean ticket from an infra perspective.
Author
Owner

📋 Elicit — Requirements Engineer

Observations

  • detectMention() space constraint is an unspecified UX gap. The existing mention.ts function kills detection as soon as the query contains a space. For user mentions, this is fine (first name only). For persons named "Auguste Raddatz", "Marie von Braun", etc., the transcriber CAN type @Aug and get suggestions, but CANNOT type @Auguste R to narrow by last name — the dropdown disappears on the space. This constraint is not in the spec and has no AC. Either it is an accepted limitation (add to the edge-cases table with explicit behavior), or PersonMentionEditor needs a modified detector (add to backend changes / frontend changes section).

  • OQ-1 describes a limitation but not the actual behavior. "Two different persons with the same displayName mentioned in the same block — rendering is ambiguous. Acceptable limitation for now?" This is marked Low and acceptable, but it leaves the behavior unspecified for the implementer. With renderTranscriptionBody() using replaceAll, the rendering outcome is deterministic: the first entry in mentionedPersons with that displayName links all occurrences; subsequent entries with the same name produce dead sidecar data. This should be stated explicitly rather than left as "ambiguous."

  • Missing NFR for person rename latency. propagateDisplayNameChange() runs synchronously in the rename request. No NFR defines what "acceptable" response time is. Without this constraint, the implementation has no target and future optimizations have no baseline.

  • Missing AC for save failure with pending mentions. The spec covers the happy save path and the deleted-person degradation case, but not: "Given a transcriber has selected a person from the typeahead, when saving the block fails (network error or conflict), then the editor retains the pending mention and displays an error." This failure path is important because losing a sidecar entry silently means the mention degrades to plain text with no visible change.

  • "Appears within 200ms" is ambiguous in AC. The acceptance criterion reads: "Then a hover card appears within 200ms." With a lazy fetch, the loaded card could take seconds. The intent is clearly that the skeleton state appears within 200ms. The AC should read: "Then a hover card skeleton state appears within 200ms of hover."

Recommendations

  1. Add to the edge-cases table: @Name typed after space to narrow by surname → detection stops, dropdown disappears; transcriber must retype from @. Or add to PersonMentionEditor specification: support multi-word queries.

  2. Replace OQ-1 with a deterministic behavioral statement: "Where two sidecar entries share the same displayName, the first sidecar entry takes precedence for all text occurrences; the second entry produces no link."

  3. Add NFR: "The person rename operation (including mention propagation across all blocks) must complete within 3 seconds for archives with up to 2,000 transcription blocks."

  4. Add AC: "Given a transcriber selects a person from the typeahead and the block save fails, When the error is shown, Then the mention remains in the editor's pending state so the transcriber can retry without re-searching."

  5. Amend the 200ms hover card AC to specify "skeleton state appears" rather than "hover card appears."

## 📋 Elicit — Requirements Engineer ### Observations - **`detectMention()` space constraint is an unspecified UX gap.** The existing `mention.ts` function kills detection as soon as the query contains a space. For user mentions, this is fine (first name only). For persons named "Auguste Raddatz", "Marie von Braun", etc., the transcriber CAN type `@Aug` and get suggestions, but CANNOT type `@Auguste R` to narrow by last name — the dropdown disappears on the space. This constraint is not in the spec and has no AC. Either it is an accepted limitation (add to the edge-cases table with explicit behavior), or `PersonMentionEditor` needs a modified detector (add to backend changes / frontend changes section). - **OQ-1 describes a limitation but not the actual behavior.** "Two different persons with the same displayName mentioned in the same block — rendering is ambiguous. Acceptable limitation for now?" This is marked Low and acceptable, but it leaves the behavior unspecified for the implementer. With `renderTranscriptionBody()` using `replaceAll`, the rendering outcome is deterministic: the first entry in `mentionedPersons` with that displayName links all occurrences; subsequent entries with the same name produce dead sidecar data. This should be stated explicitly rather than left as "ambiguous." - **Missing NFR for person rename latency.** `propagateDisplayNameChange()` runs synchronously in the rename request. No NFR defines what "acceptable" response time is. Without this constraint, the implementation has no target and future optimizations have no baseline. - **Missing AC for save failure with pending mentions.** The spec covers the happy save path and the deleted-person degradation case, but not: "Given a transcriber has selected a person from the typeahead, when saving the block fails (network error or conflict), then the editor retains the pending mention and displays an error." This failure path is important because losing a sidecar entry silently means the mention degrades to plain text with no visible change. - **"Appears within 200ms" is ambiguous in AC.** The acceptance criterion reads: "Then a hover card appears within 200ms." With a lazy fetch, the loaded card could take seconds. The intent is clearly that the *skeleton state* appears within 200ms. The AC should read: "Then a hover card skeleton state appears within 200ms of hover." ### Recommendations 1. Add to the edge-cases table: `@Name typed after space to narrow by surname → detection stops, dropdown disappears; transcriber must retype from @`. Or add to `PersonMentionEditor` specification: support multi-word queries. 2. Replace OQ-1 with a deterministic behavioral statement: "Where two sidecar entries share the same displayName, the first sidecar entry takes precedence for all text occurrences; the second entry produces no link." 3. Add NFR: "The person rename operation (including mention propagation across all blocks) must complete within 3 seconds for archives with up to 2,000 transcription blocks." 4. Add AC: "Given a transcriber selects a person from the typeahead and the block save fails, When the error is shown, Then the mention remains in the editor's pending state so the transcriber can retry without re-searching." 5. Amend the 200ms hover card AC to specify "skeleton state appears" rather than "hover card appears."
Author
Owner

🗳️ Decision Queue — Action Required

5 decisions need your input before implementation starts.

Architecture

  • Domain coupling: direct call vs. ApplicationEvent. PersonService calling TranscriptionBlockService.propagateDisplayNameChange() directly creates a Person→Transcription dependency. Alternative: PersonService publishes a PersonDisplayNameChangedEvent; a listener in the transcription package handles propagation. The event approach keeps Person domain agnostic of Transcription; both are synchronous and transactional by default. Cost of direct call: adds a compile-time dependency, Person service must be updated whenever transcription block storage changes. Cost of event: one extra class (the listener), slightly less obvious call chain. (Raised by: Markus)

  • JSONB + hypersistence-utils vs. @ElementCollection + @Embeddable. The spec assumes @Type(JsonType.class) which requires adding hypersistence-utils-hibernate63 (not currently in pom.xml). The codebase already uses @ElementCollection in UserGroup.java for an embedded value collection — same pattern works for PersonMention. Cost of JSONB: new library dependency, simpler future GIN-query for person-detail page. Cost of @ElementCollection: child table instead of JSONB column, GIN index cannot be applied directly (needs a join query instead), no new library. If the "show all documents where person X is mentioned" query is a near-term feature, JSONB wins. If it's speculative, @ElementCollection wins. (Raised by: Markus, Felix, Tobias)

UX / Frontend

  • <button> vs. <a href> for rendered mention links. The spec uses <button class="person-mention"> for click-to-navigate. Semantically, navigation belongs on <a href="/persons/{id}"> (role=link, browser affordances, screen reader announcement). Using <a> with href pre-computed at render time requires passing mentionedPersons to renderTranscriptionBody() — which is already the case. No open implementation cost for switching; the only constraint is that renderTranscriptionBody() generates <a href> instead of <button>. (Raised by: Leonie, Felix)

Behavioral / Requirements

  • Multi-word name detection: accept limitation or fix it? detectMention() in mention.ts:23 returns null the moment the query contains a space. A transcriber typing @Auguste R (partial last name) loses the dropdown. Options: (a) accept — add to edge-cases table ("type the first name or a fragment, select from results; you cannot narrow by last name mid-query"); (b) fix — write a detectPersonMention() variant that allows spaces and stops only at @@ or newline. Cost of accepting: some transcribers will be confused when the dropdown disappears. Cost of fixing: ~20 lines of TypeScript, one utility test. (Raised by: Felix, Sara, Elicit)

  • Propagation scope for manually-typed @OldName. propagateDisplayNameChange() uses String.replace("@" + oldName, "@" + newName), which matches ALL occurrences of @OldName in the text — including ones typed manually (not through the typeahead). The spec says manually-typed names are plain text and not links, but this propagation would still rename them. Options: (a) accept — propagation renames all @OldName occurrences, linked or not (simpler implementation); (b) restrict — only rename occurrences that have a matching sidecar entry (requires offset tracking or a more complex algorithm). Cost of accepting: manual text containing @OldName is silently renamed. Cost of restricting: significantly more complex propagation logic. For a family archive with controlled authorship, (a) is likely acceptable. (Raised by: Sara, Elicit)

## 🗳️ Decision Queue — Action Required _5 decisions need your input before implementation starts._ ### Architecture - **Domain coupling: direct call vs. ApplicationEvent.** `PersonService` calling `TranscriptionBlockService.propagateDisplayNameChange()` directly creates a Person→Transcription dependency. Alternative: `PersonService` publishes a `PersonDisplayNameChangedEvent`; a listener in the transcription package handles propagation. The event approach keeps Person domain agnostic of Transcription; both are synchronous and transactional by default. **Cost of direct call:** adds a compile-time dependency, Person service must be updated whenever transcription block storage changes. **Cost of event:** one extra class (the listener), slightly less obvious call chain. _(Raised by: Markus)_ - **JSONB + `hypersistence-utils` vs. `@ElementCollection` + `@Embeddable`.** The spec assumes `@Type(JsonType.class)` which requires adding `hypersistence-utils-hibernate63` (not currently in pom.xml). The codebase already uses `@ElementCollection` in `UserGroup.java` for an embedded value collection — same pattern works for `PersonMention`. **Cost of JSONB:** new library dependency, simpler future GIN-query for person-detail page. **Cost of `@ElementCollection`:** child table instead of JSONB column, GIN index cannot be applied directly (needs a join query instead), no new library. If the "show all documents where person X is mentioned" query is a near-term feature, JSONB wins. If it's speculative, `@ElementCollection` wins. _(Raised by: Markus, Felix, Tobias)_ ### UX / Frontend - **`<button>` vs. `<a href>` for rendered mention links.** The spec uses `<button class="person-mention">` for click-to-navigate. Semantically, navigation belongs on `<a href="/persons/{id}">` (role=link, browser affordances, screen reader announcement). Using `<a>` with `href` pre-computed at render time requires passing `mentionedPersons` to `renderTranscriptionBody()` — which is already the case. **No open implementation cost** for switching; the only constraint is that `renderTranscriptionBody()` generates `<a href>` instead of `<button>`. _(Raised by: Leonie, Felix)_ ### Behavioral / Requirements - **Multi-word name detection: accept limitation or fix it?** `detectMention()` in `mention.ts:23` returns `null` the moment the query contains a space. A transcriber typing `@Auguste R` (partial last name) loses the dropdown. Options: (a) accept — add to edge-cases table ("type the first name or a fragment, select from results; you cannot narrow by last name mid-query"); (b) fix — write a `detectPersonMention()` variant that allows spaces and stops only at `@@` or newline. **Cost of accepting:** some transcribers will be confused when the dropdown disappears. **Cost of fixing:** ~20 lines of TypeScript, one utility test. _(Raised by: Felix, Sara, Elicit)_ - **Propagation scope for manually-typed `@OldName`.** `propagateDisplayNameChange()` uses `String.replace("@" + oldName, "@" + newName)`, which matches ALL occurrences of `@OldName` in the text — including ones typed manually (not through the typeahead). The spec says manually-typed names are plain text and not links, but this propagation would still rename them. Options: (a) accept — propagation renames all `@OldName` occurrences, linked or not (simpler implementation); (b) restrict — only rename occurrences that have a matching sidecar entry (requires offset tracking or a more complex algorithm). **Cost of accepting:** manual text containing `@OldName` is silently renamed. **Cost of restricting:** significantly more complex propagation logic. For a family archive with controlled authorship, (a) is likely acceptable. _(Raised by: Sara, Elicit)_
Author
Owner

🏗️ Markus Keller — Architecture Discussion Follow-up

Four open architectural items discussed and resolved. Decisions below supersede the open options in the Decision Queue for the architecture-relevant items.


Item 1 — Domain coupling: ApplicationEvent approach adopted

Decision: PersonService publishes PersonDisplayNameChangedEvent(personId, oldName, newName) via ApplicationEventPublisher. A PersonMentionPropagationListener in the transcription package handles propagation with @EventListener @Transactional.

Person domain has zero compile-time dependency on Transcription. Spring's default synchronous event dispatch means the entire operation (rename + propagation) still commits in one transaction — same atomicity guarantee as a direct call, with clean domain boundaries.


Item 2 — Storage approach: @ElementCollection + @Embeddable

Decision: Use @Embeddable PersonMention { UUID personId; String displayName; } with @ElementCollection on the block entity. No hypersistence-utils-hibernate63 dependency.

Even with "show all documents where person X is mentioned" coming as the next feature, a join on an indexed person_id B-tree column is equally fast as a GIN containment check — and avoids a new library to vet and maintain. The established UserGroup.java pattern applies directly.


Item 3 — Package placement: follow existing by-layer structure

Decision: New classes go into the existing layer packages — model/PersonMention.java, service/PersonMentionPropagationListener.java, model/PersonDisplayNameChangedEvent.java. Do not introduce feature packages as an island within this feature. A migration to package-by-feature is a separate initiative. Worth noting a comment on PersonMentionPropagationListener marking it as transcription-domain logic, so the intent survives a future restructure.


Item 4 — Synchronous propagation: correct choice at this scale

Decision: Keep propagation synchronous within the rename transaction (Spring @EventListener, not @Async). Rename + propagation commit atomically or not at all — the right guarantee for historical text where silent partial corruption is unacceptable. At family-archive scale (worst case: ~150–200 block updates) this adds at most 100–300ms to the rename response. Async propagation would introduce partial-failure scenarios requiring retry/reconciliation logic that this scale does not justify. If the archive ever grows to tens of thousands of blocks, switch to @TransactionalEventListener(phase = AFTER_COMMIT) with @Async — a one-annotation change.


Overall: the feature spec is sound. The three changes above (event decoupling, ElementCollection storage, synchronous propagation) make the implementation simpler and more consistent with the existing codebase — not more complex.

## 🏗️ Markus Keller — Architecture Discussion Follow-up Four open architectural items discussed and resolved. Decisions below supersede the open options in the Decision Queue for the architecture-relevant items. --- ### ✅ Item 1 — Domain coupling: ApplicationEvent approach adopted **Decision:** `PersonService` publishes `PersonDisplayNameChangedEvent(personId, oldName, newName)` via `ApplicationEventPublisher`. A `PersonMentionPropagationListener` in the transcription package handles propagation with `@EventListener @Transactional`. Person domain has zero compile-time dependency on Transcription. Spring's default synchronous event dispatch means the entire operation (rename + propagation) still commits in one transaction — same atomicity guarantee as a direct call, with clean domain boundaries. --- ### ✅ Item 2 — Storage approach: `@ElementCollection` + `@Embeddable` **Decision:** Use `@Embeddable PersonMention { UUID personId; String displayName; }` with `@ElementCollection` on the block entity. No `hypersistence-utils-hibernate63` dependency. Even with "show all documents where person X is mentioned" coming as the next feature, a join on an indexed `person_id` B-tree column is equally fast as a GIN containment check — and avoids a new library to vet and maintain. The established `UserGroup.java` pattern applies directly. --- ### ✅ Item 3 — Package placement: follow existing by-layer structure **Decision:** New classes go into the existing layer packages — `model/PersonMention.java`, `service/PersonMentionPropagationListener.java`, `model/PersonDisplayNameChangedEvent.java`. Do not introduce feature packages as an island within this feature. A migration to package-by-feature is a separate initiative. Worth noting a comment on `PersonMentionPropagationListener` marking it as transcription-domain logic, so the intent survives a future restructure. --- ### ✅ Item 4 — Synchronous propagation: correct choice at this scale **Decision:** Keep propagation synchronous within the rename transaction (Spring `@EventListener`, not `@Async`). Rename + propagation commit atomically or not at all — the right guarantee for historical text where silent partial corruption is unacceptable. At family-archive scale (worst case: ~150–200 block updates) this adds at most 100–300ms to the rename response. Async propagation would introduce partial-failure scenarios requiring retry/reconciliation logic that this scale does not justify. If the archive ever grows to tens of thousands of blocks, switch to `@TransactionalEventListener(phase = AFTER_COMMIT)` with `@Async` — a one-annotation change. --- Overall: the feature spec is sound. The three changes above (event decoupling, ElementCollection storage, synchronous propagation) make the implementation simpler and more consistent with the existing codebase — not more complex.
Author
Owner

🎨 Leonie Voss — UX Designer & Accessibility Strategist

UI/accessibility discussion — five open items from my earlier review, all resolved.


Decision: renderTranscriptionBody() generates <a href="/persons/{id}" class="person-mention"> instead of <button>. The href is pre-computed from the mentionedPersons sidecar at render time — no onclick handler needed. mouseenter/mouseleave for the hover card attaches to the anchor element.

Screen readers announce "Auguste Raddatz, link" (correct) instead of "Auguste Raddatz, button" (confusing). Browser-native affordances (Ctrl+click, middle-click, right-click → open in new tab) work without extra code.


Item 2 — Hover card viewport overflow handling

Decision: Manual positioning with getBoundingClientRect() — no new library dependency. Flip rules:

  • Default: card opens below-right of the trigger element
  • Flip to above-right if < 200px from viewport bottom
  • Flip to left side if < 300px from viewport right edge

Decision: Add Paraglide keys before implementation — not after. Two keys required:

  • person_mention_open_link → "Zur Person" (de), equivalent in en/es
  • person_mention_hover_hint → "Klick öffnet Seite" (de), equivalent in en/es

Add to messages/de.json, en.json, and es.json as part of this feature.


Decision: Since mention links are injected via {@html}, Tailwind utility classes cannot be applied inline. Two rules go in the global stylesheet (alongside any existing {@html} content styles):

.person-mention {
    text-decoration: underline; /* visible resting state — required for colour-blind users */
}

.person-mention:focus-visible {
    outline: none;
    box-shadow: 0 0 0 2px var(--color-ink);
    border-radius: 2px;
}

Underline in resting state is required: colour alone as the sole link differentiator fails WCAG AA for colour-blind users (8% of men).


Item 5 — "200ms" acceptance criterion reworded

Decision: Replace:

"Then a hover card appears within 200ms showing name, life dates…"

With:

"Then a hover card skeleton state appears within 200ms of mouseenter; the loaded content populates when the fetch resolves."

The original wording was untestable — with a lazy fetch the loaded card takes 500–2000ms. The intent is that the skeleton shimmer appears immediately; content follows asynchronously.


Overall: the feature interaction model is solid. With <a href> links, explicit flip rules, and global focus styles, this will work for keyboard users and our senior audience on all viewport sizes. No blockers from the UX side.

## 🎨 Leonie Voss — UX Designer & Accessibility Strategist UI/accessibility discussion — five open items from my earlier review, all resolved. --- ### ✅ Item 1 — `<button>` → `<a href>` for rendered mention links **Decision:** `renderTranscriptionBody()` generates `<a href="/persons/{id}" class="person-mention">` instead of `<button>`. The `href` is pre-computed from the `mentionedPersons` sidecar at render time — no onclick handler needed. `mouseenter`/`mouseleave` for the hover card attaches to the anchor element. Screen readers announce "Auguste Raddatz, link" (correct) instead of "Auguste Raddatz, button" (confusing). Browser-native affordances (Ctrl+click, middle-click, right-click → open in new tab) work without extra code. --- ### ✅ Item 2 — Hover card viewport overflow handling **Decision:** Manual positioning with `getBoundingClientRect()` — no new library dependency. Flip rules: - Default: card opens **below-right** of the trigger element - Flip to **above-right** if < 200px from viewport bottom - Flip to **left side** if < 300px from viewport right edge --- ### ✅ Item 3 — i18n keys for hover card footer strings **Decision:** Add Paraglide keys before implementation — not after. Two keys required: - `person_mention_open_link` → "Zur Person" (de), equivalent in en/es - `person_mention_hover_hint` → "Klick öffnet Seite" (de), equivalent in en/es Add to `messages/de.json`, `en.json`, and `es.json` as part of this feature. --- ### ✅ Item 4 — Focus ring and underline for `.person-mention` links **Decision:** Since mention links are injected via `{@html}`, Tailwind utility classes cannot be applied inline. Two rules go in the global stylesheet (alongside any existing `{@html}` content styles): ```css .person-mention { text-decoration: underline; /* visible resting state — required for colour-blind users */ } .person-mention:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--color-ink); border-radius: 2px; } ``` Underline in resting state is required: colour alone as the sole link differentiator fails WCAG AA for colour-blind users (8% of men). --- ### ✅ Item 5 — "200ms" acceptance criterion reworded **Decision:** Replace: > "Then a hover card appears within 200ms showing name, life dates…" With: > "Then a hover card **skeleton state** appears within 200ms of `mouseenter`; the loaded content populates when the fetch resolves." The original wording was untestable — with a lazy fetch the loaded card takes 500–2000ms. The intent is that the skeleton shimmer appears immediately; content follows asynchronously. --- Overall: the feature interaction model is solid. With `<a href>` links, explicit flip rules, and global focus styles, this will work for keyboard users and our senior audience on all viewport sizes. No blockers from the UX side.
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Developer discussion follow-up — six implementation-level items resolved.


Item 1 — Multi-word name detection: fix it

Decision: Write detectPersonMention() in a new personMention.ts utility. Spaces are allowed inside the query; detection stops only at a newline or a second @. The existing detectMention() in mention.ts (which stops at the first space) remains unchanged for user mentions.

Test cases required: @Auguste, @Auguste (trailing space), @Auguste Raddatz, @M, @ alone.

Accepting the limitation (option a) would silently break the typeahead for any archive with multiple persons sharing a first name — exactly the common case.


Item 2 — HTML escaping in renderTranscriptionBody()

Decision: Extract a shared escapeHtml(str: string): string helper in mention.ts. Both renderBody() and the new renderTranscriptionBody() call it. No duplication; one test covers both functions. renderBody() gets the fix retroactively — its current inline escaping is correct but fragile.


Item 3 ��� Propagation scope for manually-typed @OldName

Decision: propagateDisplayNameChange() replaces all @OldName occurrences in block text — linked or not. This is the defined behavior, not a bug. Stated explicitly here so it is not accidentally "fixed" later. For a family archive with controlled authorship, renaming a manually-typed @Auguste Raddatz when the person is renamed is the correct outcome.


Item 4 — OQ-1 tie-breaking rule

Decision: Replace the "ambiguous" label with a deterministic rule: where two sidecar entries share the same displayName, the first sidecar entry takes precedence for all text occurrences; the second entry produces no link. This is the natural outcome of replaceAll and should be documented as intentional.


Item 5 — Hover card network error state

Decision: Three distinct states for PersonHoverCard:

  • Loading: skeleton shimmer appears within 200ms of mouseenter
  • 404: card never mounts; mention degrades to plain unlinked text
  • Non-404 error (500, timeout, network failure): skeleton is replaced by a generic error message inside the card

The mention link navigates to /persons/{id} in all states — the card is informational, the link always works.

Add Paraglide key person_mention_load_error to messages/de.json, en.json, and es.json.


Item 6 — Save failure with pending mentions

Decision: On save failure, the editor retains both the textarea text and the mentionedPersons array unchanged. The transcriber sees the error, fixes the cause, and retries without re-searching. No mention data is lost on a failed save.


Overall: the spec is implementable with these six points locked. Architecture (Markus) and UX (Leonie) decisions from earlier comments stand. No blockers from the dev side.

## 👨‍💻 Felix Brandt — Senior Fullstack Developer Developer discussion follow-up — six implementation-level items resolved. --- ### ✅ Item 1 — Multi-word name detection: fix it **Decision:** Write `detectPersonMention()` in a new `personMention.ts` utility. Spaces are allowed inside the query; detection stops only at a newline or a second `@`. The existing `detectMention()` in `mention.ts` (which stops at the first space) remains unchanged for user mentions. Test cases required: `@Auguste`, `@Auguste ` (trailing space), `@Auguste Raddatz`, `@M`, `@` alone. Accepting the limitation (option a) would silently break the typeahead for any archive with multiple persons sharing a first name — exactly the common case. --- ### ✅ Item 2 — HTML escaping in `renderTranscriptionBody()` **Decision:** Extract a shared `escapeHtml(str: string): string` helper in `mention.ts`. Both `renderBody()` and the new `renderTranscriptionBody()` call it. No duplication; one test covers both functions. `renderBody()` gets the fix retroactively — its current inline escaping is correct but fragile. --- ### ✅ Item 3 ��� Propagation scope for manually-typed `@OldName` **Decision:** `propagateDisplayNameChange()` replaces all `@OldName` occurrences in block text — linked or not. This is the **defined behavior**, not a bug. Stated explicitly here so it is not accidentally "fixed" later. For a family archive with controlled authorship, renaming a manually-typed `@Auguste Raddatz` when the person is renamed is the correct outcome. --- ### ✅ Item 4 — OQ-1 tie-breaking rule **Decision:** Replace the "ambiguous" label with a deterministic rule: where two sidecar entries share the same `displayName`, the first sidecar entry takes precedence for all text occurrences; the second entry produces no link. This is the natural outcome of `replaceAll` and should be documented as intentional. --- ### ✅ Item 5 — Hover card network error state **Decision:** Three distinct states for `PersonHoverCard`: - **Loading**: skeleton shimmer appears within 200ms of `mouseenter` - **404**: card never mounts; mention degrades to plain unlinked text - **Non-404 error (500, timeout, network failure)**: skeleton is replaced by a generic error message inside the card The mention link navigates to `/persons/{id}` in all states — the card is informational, the link always works. Add Paraglide key `person_mention_load_error` to `messages/de.json`, `en.json`, and `es.json`. --- ### ✅ Item 6 — Save failure with pending mentions **Decision:** On save failure, the editor retains both the textarea text and the `mentionedPersons` array unchanged. The transcriber sees the error, fixes the cause, and retries without re-searching. No mention data is lost on a failed save. --- Overall: the spec is implementable with these six points locked. Architecture (Markus) and UX (Leonie) decisions from earlier comments stand. No blockers from the dev side.
Author
Owner

👨‍💻 Felix Brandt — Implementation Plan

Decisions consolidated from the four discussion follow-ups (Markus, Leonie, Felix-dev, Nora). Plan is split into PR A — Backend then PR B — Frontend so the OpenAPI types regenerate cleanly between them. Branch and uncommitted-work cleanup happens before any code starts.


Pre-flight

  1. Finalise the unrelated changes on feat/stammbaum-issue-358 (RelationshipService.java + CreateRelationshipRequest.java) — commit or discard before branching for #362.
  2. git pull origin main as a separate explicit step.
  3. git checkout -b feat/issue-362-person-mentions-backend.

PR A — Backend

Each task = one failing test → minimum implementation → atomic commit.

# Type Task
A1 migration V56__add_transcription_block_mentioned_persons.sql — block_id + person_id + display_name table, B-tree on person_id. No FK on person_id so deleted persons degrade to plain text per spec
A2 backend New PersonMention @Embeddable value object — UUID personId (@NotNull), String displayName (@Size(max=200)), Lombok + @Schema(REQUIRED)
A3 backend Add List<PersonMention> mentionedPersons to TranscriptionBlock via @ElementCollection(fetch = LAZY) + @CollectionTable
A4 backend New PersonDisplayNameChangedEvent(personId, oldName, newName) in model/ package
A5 backend Inject ApplicationEventPublisher into PersonService; in updatePerson capture old displayName, save, compare, publish if changed
A6 test PersonService publishes event when display name changes
A7 test PersonService does not publish event when display name unchanged (e.g. notes-only edit)
A8 backend TranscriptionBlockRepository.findByMentionedPersons_PersonId(UUID) derived query
A9 test Derived query returns matching blocks only
A10 backend PersonMentionPropagationListener (transcription package, doc-commented as transcription-domain logic per Markus Item 3); @EventListener @Transactional does text.replace("@" + old, "@" + new) and updates each matching PersonMention.displayName, then saveAll
A11 test Listener happy path — three blocks, all rewritten atomically
A12 test Listener no-op when no blocks reference the person
A13 test Listener leaves blocks for other persons untouched
A14 test Listener replaces every occurrence in a single block
A15 test Listener pins the documented partial-match behaviour (@Marie and @Marie Braun both rewritten — intentional per Felix Item 3)
A16 backend CreateTranscriptionBlockDTO + UpdateTranscriptionBlockDTO accept mentionedPersons; controller methods get @Valid
A17 backend TranscriptionService.createBlock / updateBlock persist the sidecar
A18 test updateBlock persists mentionedPersons from DTO
A19 test @WebMvcTest slice — controller rejects oversized displayName (>200) with 400
A20 backend Stamp migration with rationale comment (no GIN, by intent)
A21 verify ./mvnw clean package -DskipTests and ./mvnw test green
A22 verify Regenerate OpenAPI types in frontend/, commit api.ts
A23 PR Open PR-A: feat: person @mention sidecar in transcription blocks (backend)

PR B — Frontend (after PR A merges)

# Type Task
B1 refactor Extract escapeHtml helper in mention.ts; renderBody uses it (Felix Item 2)
B2 frontend New personMention.tsdetectPersonMention (allows spaces, stops at \n / second @) and renderTranscriptionBody (longest-displayName-first, <a href="/persons/{id}" class="person-mention">DisplayName</a>, no @ prefix)
B3 test detectPersonMention cases: @Auguste, @Auguste , @Auguste Raddatz, @M, @, @Aug\nfoo, @Aug@bar
B4 test renderTranscriptionBody HTML-escapes hostile displayName (XSS regression)
B5 test Output strips the @ from rendered link text
B6 test Manually-typed unmatched names stay as plain text
B7 test First-sidecar-wins on duplicate displayName (OQ-1 deterministic rule)
B8 i18n Three Paraglide keys: person_mention_open_link, person_mention_hover_hint, person_mention_load_error (de/en/es)
B9 frontend .person-mention global CSS — underline + :focus-visible ring in routes/layout.css
B10 frontend New PersonMentionEditor.svelte — mirrors MentionEditor, hits /api/persons?q=…, dropdown shows name + life dates, bindable mentionedPersons
B11 test Selecting a result inserts @DisplayName and pushes sidecar entry
B12 test Save failure preserves textarea text + mentionedPersons (Felix Item 6)
B13 frontend Replace <textarea> in TranscriptionBlock.svelte (lines 185–194) with PersonMentionEditor; thread mentionedPersons through autosave to PUT /api/.../transcription-blocks/{blockId}
B14 test Autosave callback receives mentionedPersons
B15 frontend PersonHoverCard.svelte — three states (loading skeleton ≤200ms, loaded with header/dates/maiden/relationship chips/notes excerpt/footer, generic error); 404 doesn't mount; manual flip via getBoundingClientRect() (below-right default; flip up <200px from bottom; flip left <300px from right); hidden via @media (hover: none)
B16 test PersonHoverCard loaded state
B17 test PersonHoverCard non-404 error state
B18 test PersonHoverCard does not mount on 404
B19 frontend TranscriptionReadView.svelte — render via renderTranscriptionBody, compose with existing marker rendering, attach delegated hover handlers on .person-mention
B20 test Browser test — hover mounts card, click navigates
B21 test Browser test — touch device skips card, click still navigates
B22 verify npm run check + npm run lint + npm run test
B23 verify Manual proofshot session (typeahead, save, hover, click, flip)
B24 PR Open PR-B: feat: person @mention typeahead, hover card, render integration (frontend)

Risks I'm watching

  1. Marker composition (B19)renderTranscriptionBody has to coexist with splitByMarkers ([unleserlich], […]<em data-marker>). Naive ordering means either markers get HTML-escaped or mention HTML gets re-escaped. Implementation must split into marker-segments first and run mention substitution + escapeHtml only on text segments before re-joining.
  2. @ElementCollection with @Embeddable is new ground in this codebase (UserGroup only uses it with Set<String>). The repository test (A9) and the listener integration tests are the safety net.
  3. Event publication timing — must fire after personRepository.save and within the rename transaction so the listener observes new state. Spring's default synchronous dispatch handles it; worth a sanity-check assertion in the integration test.

Awaiting green light to proceed once #358 is finalised.

## 👨‍💻 Felix Brandt — Implementation Plan Decisions consolidated from the four discussion follow-ups (Markus, Leonie, Felix-dev, Nora). Plan is split into **PR A — Backend** then **PR B — Frontend** so the OpenAPI types regenerate cleanly between them. Branch and uncommitted-work cleanup happens before any code starts. --- ### Pre-flight 1. Finalise the unrelated changes on `feat/stammbaum-issue-358` (RelationshipService.java + CreateRelationshipRequest.java) — commit or discard before branching for #362. 2. `git pull origin main` as a separate explicit step. 3. `git checkout -b feat/issue-362-person-mentions-backend`. --- ### PR A — Backend Each task = one failing test → minimum implementation → atomic commit. | # | Type | Task | |---|---|---| | A1 | migration | `V56__add_transcription_block_mentioned_persons.sql` — block_id + person_id + display_name table, B-tree on `person_id`. **No FK on `person_id`** so deleted persons degrade to plain text per spec | | A2 | backend | New `PersonMention` `@Embeddable` value object — `UUID personId` (`@NotNull`), `String displayName` (`@Size(max=200)`), Lombok + `@Schema(REQUIRED)` | | A3 | backend | Add `List<PersonMention> mentionedPersons` to `TranscriptionBlock` via `@ElementCollection(fetch = LAZY)` + `@CollectionTable` | | A4 | backend | New `PersonDisplayNameChangedEvent(personId, oldName, newName)` in `model/` package | | A5 | backend | Inject `ApplicationEventPublisher` into `PersonService`; in `updatePerson` capture old `displayName`, save, compare, publish if changed | | A6 | test | `PersonService` publishes event when display name changes | | A7 | test | `PersonService` does **not** publish event when display name unchanged (e.g. notes-only edit) | | A8 | backend | `TranscriptionBlockRepository.findByMentionedPersons_PersonId(UUID)` derived query | | A9 | test | Derived query returns matching blocks only | | A10 | backend | `PersonMentionPropagationListener` (transcription package, doc-commented as transcription-domain logic per Markus Item 3); `@EventListener @Transactional` does `text.replace("@" + old, "@" + new)` and updates each matching `PersonMention.displayName`, then `saveAll` | | A11 | test | Listener happy path — three blocks, all rewritten atomically | | A12 | test | Listener no-op when no blocks reference the person | | A13 | test | Listener leaves blocks for other persons untouched | | A14 | test | Listener replaces every occurrence in a single block | | A15 | test | Listener pins the documented partial-match behaviour (`@Marie` and `@Marie Braun` both rewritten — intentional per Felix Item 3) | | A16 | backend | `CreateTranscriptionBlockDTO` + `UpdateTranscriptionBlockDTO` accept `mentionedPersons`; controller methods get `@Valid` | | A17 | backend | `TranscriptionService.createBlock` / `updateBlock` persist the sidecar | | A18 | test | `updateBlock` persists `mentionedPersons` from DTO | | A19 | test | `@WebMvcTest` slice — controller rejects oversized `displayName` (>200) with 400 | | A20 | backend | Stamp migration with rationale comment (no GIN, by intent) | | A21 | verify | `./mvnw clean package -DskipTests` and `./mvnw test` green | | A22 | verify | Regenerate OpenAPI types in `frontend/`, commit `api.ts` | | A23 | PR | Open PR-A: `feat: person @mention sidecar in transcription blocks (backend)` | --- ### PR B — Frontend (after PR A merges) | # | Type | Task | |---|---|---| | B1 | refactor | Extract `escapeHtml` helper in `mention.ts`; `renderBody` uses it (Felix Item 2) | | B2 | frontend | New `personMention.ts` — `detectPersonMention` (allows spaces, stops at `\n` / second `@`) and `renderTranscriptionBody` (longest-displayName-first, `<a href="/persons/{id}" class="person-mention">DisplayName</a>`, no `@` prefix) | | B3 | test | `detectPersonMention` cases: `@Auguste`, `@Auguste `, `@Auguste Raddatz`, `@M`, `@`, `@Aug\nfoo`, `@Aug@bar` | | B4 | test | `renderTranscriptionBody` HTML-escapes hostile `displayName` (XSS regression) | | B5 | test | Output strips the `@` from rendered link text | | B6 | test | Manually-typed unmatched names stay as plain text | | B7 | test | First-sidecar-wins on duplicate `displayName` (OQ-1 deterministic rule) | | B8 | i18n | Three Paraglide keys: `person_mention_open_link`, `person_mention_hover_hint`, `person_mention_load_error` (de/en/es) | | B9 | frontend | `.person-mention` global CSS — underline + `:focus-visible` ring in `routes/layout.css` | | B10 | frontend | New `PersonMentionEditor.svelte` — mirrors `MentionEditor`, hits `/api/persons?q=…`, dropdown shows name + life dates, bindable `mentionedPersons` | | B11 | test | Selecting a result inserts `@DisplayName ` and pushes sidecar entry | | B12 | test | Save failure preserves textarea text + `mentionedPersons` (Felix Item 6) | | B13 | frontend | Replace `<textarea>` in `TranscriptionBlock.svelte` (lines 185–194) with `PersonMentionEditor`; thread `mentionedPersons` through autosave to `PUT /api/.../transcription-blocks/{blockId}` | | B14 | test | Autosave callback receives `mentionedPersons` | | B15 | frontend | `PersonHoverCard.svelte` — three states (loading skeleton ≤200ms, loaded with header/dates/maiden/relationship chips/notes excerpt/footer, generic error); 404 doesn't mount; manual flip via `getBoundingClientRect()` (below-right default; flip up <200px from bottom; flip left <300px from right); hidden via `@media (hover: none)` | | B16 | test | `PersonHoverCard` loaded state | | B17 | test | `PersonHoverCard` non-404 error state | | B18 | test | `PersonHoverCard` does not mount on 404 | | B19 | frontend | `TranscriptionReadView.svelte` — render via `renderTranscriptionBody`, compose with existing marker rendering, attach delegated hover handlers on `.person-mention` | | B20 | test | Browser test — hover mounts card, click navigates | | B21 | test | Browser test — touch device skips card, click still navigates | | B22 | verify | `npm run check` + `npm run lint` + `npm run test` | | B23 | verify | Manual proofshot session (typeahead, save, hover, click, flip) | | B24 | PR | Open PR-B: `feat: person @mention typeahead, hover card, render integration (frontend)` | --- ### Risks I'm watching 1. **Marker composition (B19)** — `renderTranscriptionBody` has to coexist with `splitByMarkers` (`[unleserlich]`, `[…]` → `<em data-marker>`). Naive ordering means either markers get HTML-escaped or mention HTML gets re-escaped. Implementation must split into marker-segments first and run mention substitution + `escapeHtml` only on text segments before re-joining. 2. **`@ElementCollection` with `@Embeddable` is new ground** in this codebase (`UserGroup` only uses it with `Set<String>`). The repository test (A9) and the listener integration tests are the safety net. 3. **Event publication timing** — must fire *after* `personRepository.save` and within the rename transaction so the listener observes new state. Spring's default synchronous dispatch handles it; worth a sanity-check assertion in the integration test. Awaiting green light to proceed once #358 is finalised.
Author
Owner

🏗️ Markus Keller — Plan Review

Reviewing the implementation plan in #5321 against the architectural decisions locked in #5290.

Observations

  • Person.displayName is a computed @Transient field, not a column. I checked Person.java:63-67getDisplayName() derives from DisplayNameFormatter.format(title, firstName, lastName). Plan task A5 says "in updatePerson capture old displayName, save, compare, publish if changed." That's not literally how the data flows: there is nothing to "capture" from the DTO — you must call person.getDisplayName() before mutating setTitle/setFirstName/setLastName, then compare against getDisplayName() after save. This isn't a blocker, just imprecise wording in the plan that could mislead implementation.

  • Event infrastructure is brand new. I grepped the entire backend — the only @EventListener in production code is OcrTrainingService.java:234 listening to Spring's ApplicationReadyEvent. There are zero custom domain events published via ApplicationEventPublisher. Plan task A4-A5 introduces this pattern from scratch. That's fine — it's still the right call from #5290 — but worth noting in the migration comment / package-info that this is the first custom domain event in the codebase. It will set the convention for every future cross-domain decoupling decision.

  • TranscriptionBlock has @Version int version (optimistic locking, line 54-57). The synchronous propagation listener (A10) calls saveAll across all matching blocks in the same transaction as the person rename. If a transcriber autosave commits between the rename's read and write phase, JPA throws OptimisticLockException and the entire rename + propagation transaction rolls back. At family-archive scale (single-digit concurrent transcribers) this is rare but not impossible. The behavior is correct (atomic rollback over silent partial corruption), but the user-facing failure should map to a clear ErrorCode.PERSON_RENAME_CONFLICT so the frontend can show a "another transcriber edited a referenced block — please retry" message rather than a generic 500.

  • @CollectionTable name must match the migration. Plan A1 creates the migration; A3 adds @ElementCollection on the entity. JPA's default join table name is derived from the entity field name (transcription_block_mentioned_persons). The migration must declare the same name explicitly via @CollectionTable(name="...") or rely on naming-strategy defaults — flag this in A3 so it isn't discovered at integration-test time.

  • By-layer placement holds (#5290 Item 3 confirmed). Plan correctly puts PersonMention in model/, PersonMentionPropagationListener in service/. ✓

Recommendations

  • Refine A5 wording. Rephrase the task as: "In PersonService.updatePerson, before mutating the entity, snapshot oldDisplayName = person.getDisplayName(). After personRepository.save(person), compute newDisplayName = saved.getDisplayName(). If they differ, publish PersonDisplayNameChangedEvent." This is the only correct sequence — anything else either races with the mutation or reads from the DTO (which doesn't carry a precomputed name).

  • Add task A10b: declare the conflict ErrorCode. Add ErrorCode.PERSON_RENAME_CONFLICT and have PersonMentionPropagationListener catch OptimisticLockException (or let it propagate and map at the controller advice). The @Transactional rollback is correct; the user message must be specific.

  • Add A3.5: explicit @CollectionTable(name = "transcription_block_mentioned_persons") on the field. Match the migration's table name byte-for-byte. Without this, JPA's naming-strategy default could diverge from the migration on certain Hibernate versions.

  • Plan is otherwise architecturally sound. Two PRs (backend-then-frontend with OpenAPI regen between them) is the right cut — splits review surface area cleanly and keeps the API-contract change atomic.

Open Decisions (none — all architecture concerns have a concrete remedy above)

## 🏗️ Markus Keller — Plan Review Reviewing the implementation plan in #5321 against the architectural decisions locked in #5290. ### Observations - **`Person.displayName` is a computed `@Transient` field, not a column.** I checked `Person.java:63-67` — `getDisplayName()` derives from `DisplayNameFormatter.format(title, firstName, lastName)`. Plan task **A5** says "in `updatePerson` capture old `displayName`, save, compare, publish if changed." That's not literally how the data flows: there is nothing to "capture" from the DTO — you must call `person.getDisplayName()` *before* mutating `setTitle/setFirstName/setLastName`, then compare against `getDisplayName()` *after* save. This isn't a blocker, just imprecise wording in the plan that could mislead implementation. - **Event infrastructure is brand new.** I grepped the entire backend — the only `@EventListener` in production code is `OcrTrainingService.java:234` listening to Spring's `ApplicationReadyEvent`. There are zero custom domain events published via `ApplicationEventPublisher`. Plan task **A4-A5** introduces this pattern from scratch. That's fine — it's still the right call from #5290 — but worth noting in the migration comment / package-info that this is the **first** custom domain event in the codebase. It will set the convention for every future cross-domain decoupling decision. - **`TranscriptionBlock` has `@Version int version` (optimistic locking, line 54-57).** The synchronous propagation listener (A10) calls `saveAll` across all matching blocks in the same transaction as the person rename. If a transcriber autosave commits between the rename's read and write phase, JPA throws `OptimisticLockException` and the entire rename + propagation transaction rolls back. At family-archive scale (single-digit concurrent transcribers) this is rare but not impossible. The behavior is correct (atomic rollback over silent partial corruption), but the user-facing failure should map to a clear `ErrorCode.PERSON_RENAME_CONFLICT` so the frontend can show a "another transcriber edited a referenced block — please retry" message rather than a generic 500. - **`@CollectionTable` name must match the migration.** Plan A1 creates the migration; A3 adds `@ElementCollection` on the entity. JPA's default join table name is derived from the entity field name (`transcription_block_mentioned_persons`). The migration must declare the same name explicitly via `@CollectionTable(name="...")` or rely on naming-strategy defaults — flag this in A3 so it isn't discovered at integration-test time. - **By-layer placement holds (#5290 Item 3 confirmed).** Plan correctly puts `PersonMention` in `model/`, `PersonMentionPropagationListener` in `service/`. ✓ ### Recommendations - **Refine A5 wording.** Rephrase the task as: *"In `PersonService.updatePerson`, before mutating the entity, snapshot `oldDisplayName = person.getDisplayName()`. After `personRepository.save(person)`, compute `newDisplayName = saved.getDisplayName()`. If they differ, publish `PersonDisplayNameChangedEvent`."* This is the only correct sequence — anything else either races with the mutation or reads from the DTO (which doesn't carry a precomputed name). - **Add task A10b: declare the conflict ErrorCode.** Add `ErrorCode.PERSON_RENAME_CONFLICT` and have `PersonMentionPropagationListener` catch `OptimisticLockException` (or let it propagate and map at the controller advice). The `@Transactional` rollback is correct; the user message must be specific. - **Add A3.5: explicit `@CollectionTable(name = "transcription_block_mentioned_persons")` on the field.** Match the migration's table name byte-for-byte. Without this, JPA's naming-strategy default could diverge from the migration on certain Hibernate versions. - **Plan is otherwise architecturally sound.** Two PRs (backend-then-frontend with OpenAPI regen between them) is the right cut — splits review surface area cleanly and keeps the API-contract change atomic. ### Open Decisions _(none — all architecture concerns have a concrete remedy above)_
Author
Owner

👨‍💻 Felix Brandt — Plan Review (Self-Review)

Stepping back to review my own implementation plan with fresh eyes.

Observations

  • A5 oversimplifies displayName change detection. Person.getDisplayName() (Person.java:65-67) is @Transient and computed from title + firstName + lastName via DisplayNameFormatter. The plan reads as if displayName were a column. Implementation must read oldName = person.getDisplayName() before the setter calls, save, then compute newName = saved.getDisplayName() and compare. A6 ("publishes event when display name changes") needs explicit coverage of the case where only title changes (e.g. "Auguste" → "Dr. Auguste") — that is a display-name change and must fire propagation.

  • renderBody is already used via {@html} in CommentMessage.svelte:88-89 with an eslint-disable-next-line svelte/no-at-html-tags comment justifying that the function escapes its inputs. Plan B19 doesn't mention the equivalent comment for renderTranscriptionBody. Without it, the Svelte ESLint rule will flag the call. Add to B19: "Include the same eslint-disable-next-line svelte/no-at-html-tags -- renderTranscriptionBody escapes… comment used by CommentMessage."

  • Marker composition (Risk #1) has no test. B3-B7 cover renderTranscriptionBody in isolation, but the actual integration in TranscriptionReadView.svelte (B19) coexists with the existing splitByMarkers flow. The realistic failure mode is text like "Hallo @Auguste Raddatz [unleserlich] Marie" — must render with the mention link AND the marker <em>. No test in B16-B21 explicitly covers this composition.

  • @Valid cascade on List. A16 says "controller methods get @Valid," but JSR-303 doesn't recurse into list elements unless the field itself is annotated @Valid. The DTO must be @Valid List<PersonMention> mentionedPersons for @Size(max=200) and @NotNull on PersonMention to fire. A19 (oversize displayName → 400) will silently pass with broken validation otherwise.

  • The firstName column on Person is nullable (Person.java:27). A person can legitimately have only lastName, making getDisplayName() return just the last name (e.g. "Raddatz"). With multi-word allowed in the typeahead query (B2), @Raddatz is unambiguous, but B7 (first-sidecar-wins on duplicate) becomes likelier — single-name persons collide more often. Worth a sentence in B7's test description.

  • Branch name diverges from existing convention. Current in-flight branch is feat/stammbaum-issue-358. Plan proposes feat/issue-362-person-mentions-backend. Existing convention seems to be feat/<topic>-issue-<n>. Switch to feat/person-mentions-issue-362-backend for consistency.

Recommendations

  • Reword A5 explicitly: snapshot oldDisplayName = person.getDisplayName() before setter calls, then compare with saved.getDisplayName() after personRepository.save. Add A6b: "publishes event when only title changes."

  • Add to B19: the eslint-disable comment, copying CommentMessage's existing pattern verbatim with the rationale phrasing changed for renderTranscriptionBody.

  • Add B19b — composition integration test: render a block with text containing both a mention AND a [unleserlich] marker. Assert: anchor tag for the mention, <em data-marker> for the marker, no double-escaping, no marker text inside the link's text content.

  • Strengthen A2 — @Valid cascade: explicitly note private @Valid List<PersonMention> mentionedPersons on CreateTranscriptionBlockDTO and UpdateTranscriptionBlockDTO, not just @Valid on the controller method.

  • Rename branches to feat/person-mentions-issue-362-backend / …-frontend to match existing convention.

Open Decisions (none)

## 👨‍💻 Felix Brandt — Plan Review (Self-Review) Stepping back to review my own implementation plan with fresh eyes. ### Observations - **A5 oversimplifies `displayName` change detection.** `Person.getDisplayName()` (Person.java:65-67) is `@Transient` and computed from `title + firstName + lastName` via `DisplayNameFormatter`. The plan reads as if `displayName` were a column. Implementation must read `oldName = person.getDisplayName()` *before* the setter calls, save, then compute `newName = saved.getDisplayName()` and compare. A6 ("publishes event when display name changes") needs explicit coverage of the case where only `title` changes (e.g. "Auguste" → "Dr. Auguste") — that *is* a display-name change and must fire propagation. - **`renderBody` is already used via `{@html}` in `CommentMessage.svelte:88-89`** with an `eslint-disable-next-line svelte/no-at-html-tags` comment justifying that the function escapes its inputs. Plan B19 doesn't mention the equivalent comment for `renderTranscriptionBody`. Without it, the Svelte ESLint rule will flag the call. Add to B19: *"Include the same `eslint-disable-next-line svelte/no-at-html-tags -- renderTranscriptionBody escapes…` comment used by CommentMessage."* - **Marker composition (Risk #1) has no test.** B3-B7 cover `renderTranscriptionBody` in isolation, but the actual integration in `TranscriptionReadView.svelte` (B19) coexists with the existing `splitByMarkers` flow. The realistic failure mode is text like `"Hallo @Auguste Raddatz [unleserlich] Marie"` — must render with the mention link AND the marker `<em>`. No test in B16-B21 explicitly covers this composition. - **`@Valid` cascade on List<PersonMention>.** A16 says "controller methods get `@Valid`," but JSR-303 doesn't recurse into list elements unless the field itself is annotated `@Valid`. The DTO must be `@Valid List<PersonMention> mentionedPersons` for `@Size(max=200)` and `@NotNull` on `PersonMention` to fire. A19 (oversize displayName → 400) will silently pass with broken validation otherwise. - **The `firstName` column on Person is nullable** (`Person.java:27`). A person can legitimately have only `lastName`, making `getDisplayName()` return just the last name (e.g. "Raddatz"). With multi-word allowed in the typeahead query (B2), `@Raddatz` is unambiguous, but B7 (first-sidecar-wins on duplicate) becomes likelier — single-name persons collide more often. Worth a sentence in B7's test description. - **Branch name diverges from existing convention.** Current in-flight branch is `feat/stammbaum-issue-358`. Plan proposes `feat/issue-362-person-mentions-backend`. Existing convention seems to be `feat/<topic>-issue-<n>`. Switch to `feat/person-mentions-issue-362-backend` for consistency. ### Recommendations - **Reword A5 explicitly:** snapshot `oldDisplayName = person.getDisplayName()` *before* setter calls, then compare with `saved.getDisplayName()` after `personRepository.save`. Add A6b: "publishes event when only `title` changes." - **Add to B19:** the eslint-disable comment, copying CommentMessage's existing pattern verbatim with the rationale phrasing changed for `renderTranscriptionBody`. - **Add B19b — composition integration test:** render a block with text containing both a mention AND a `[unleserlich]` marker. Assert: anchor tag for the mention, `<em data-marker>` for the marker, no double-escaping, no marker text inside the link's text content. - **Strengthen A2 — `@Valid` cascade:** explicitly note `private @Valid List<PersonMention> mentionedPersons` on `CreateTranscriptionBlockDTO` and `UpdateTranscriptionBlockDTO`, not just `@Valid` on the controller method. - **Rename branches to `feat/person-mentions-issue-362-backend` / `…-frontend`** to match existing convention. ### Open Decisions _(none)_
Author
Owner

🔐 Nora "NullX" Steiner — Plan Review

XSS escaping is well-handled in B1/B4. Two new issues from the plan that the earlier review didn't surface.

Observations

  • GET /api/persons/{id} and GET /api/persons are NOT permission-gated in the current PersonController.java:36-44. Only POST/PUT/DELETE carry @RequirePermission. Reads rely on Spring Security's default .anyRequest().authenticated(). The hover card (B15) and the typeahead (B10) both call these endpoints. Functionally fine — but if anyone ever adds a "public preview" route in the future, hover-card data would inadvertently follow. Add @RequirePermission(Permission.READ_ALL) to both read endpoints as part of this PR (one-line change on each @GetMapping). Defense in depth, costs nothing.

  • @Size(max=200) validation cascade is fragile (per Felix's review). Plan A2 declares the constraints on PersonMention, plan A16 says "controller methods get @Valid." Without @Valid on the field itself (@Valid List<PersonMention> mentionedPersons), JSR-303 does NOT descend into list elements. Result: A19's "rejects oversized displayName" test would pass for the wrong reason (no validation runs at all). A regression where someone removes the @Valid from the DTO field would silently let displayName: <50KB string> through — at which point both DB storage and HTML escaping become DoS vectors. Make this explicit in A2 and A16, and add a test case asserting that a 201-character displayName returns 400 (so the test would fail if validation cascade breaks).

  • Migration without FK is correct for the spec, but creates dangling-reference attack surface. Plan A1 says block_id + person_id + display_name table, **No FK on person_id**. Spec says deleted persons → mention degrades to plain text. Confirmed correct. Threat-model implication: a malicious admin can delete a person while leaving stale displayName: <legitimate name> sidecar entries pointing to a UUID that no longer resolves. The render-mode anchor <a href="/persons/{id}"> would 404 on click — no security impact, just orphan UX. Worth adding A11.5: "Listener pins the documented behaviour: when no person is found by id during propagation, the sidecar entry is left untouched (it's already orphaned and will degrade per spec)."

  • Hover card lazy fetch + concurrent hover. B15 fetches /api/persons/{id} and /api/persons/{id}/relationships on mouseenter. A user moving their cursor across a paragraph with 20 mentions can fire 40 concurrent backend calls in a single second. No rate limit on these endpoints. At family-archive scale this is fine — but cache the responses client-side per personId for the page lifetime to prevent unnecessary load and reduce the attack surface for any future slowloris-style probing. One Map<UUID, Promise<HoverData>> does it.

Recommendations

  • Add @RequirePermission(Permission.READ_ALL) to PersonController.getPerson() and PersonController.getPersons() as part of this PR (PR-A scope). One commit.

  • Add to A16/A19: the test must assert 400 status for displayName length 201. If validation cascade breaks (someone drops @Valid from the field), this test fails. Same for personId: null.

  • Add B15.5 — hover card fetch deduplication: per-page Map<UUID, Promise<HoverData>> cache. Hovering the same mention twice fires one fetch. Hovering 10 mentions for the same person fires one fetch.

  • Document the orphaned-sidecar behavior in A10/A14 test descriptions: propagation on a non-existent person is a no-op (defensive — should never happen, but a malformed event from a future async refactor shouldn't crash the listener).

Open Decisions (none)

## 🔐 Nora "NullX" Steiner — Plan Review XSS escaping is well-handled in B1/B4. Two new issues from the plan that the earlier review didn't surface. ### Observations - **`GET /api/persons/{id}` and `GET /api/persons` are NOT permission-gated** in the current `PersonController.java:36-44`. Only POST/PUT/DELETE carry `@RequirePermission`. Reads rely on Spring Security's default `.anyRequest().authenticated()`. The hover card (B15) and the typeahead (B10) both call these endpoints. Functionally fine — but if anyone ever adds a "public preview" route in the future, hover-card data would inadvertently follow. Add `@RequirePermission(Permission.READ_ALL)` to both read endpoints as part of this PR (one-line change on each `@GetMapping`). Defense in depth, costs nothing. - **`@Size(max=200)` validation cascade is fragile** (per Felix's review). Plan A2 declares the constraints on `PersonMention`, plan A16 says "controller methods get `@Valid`." Without `@Valid` on the field itself (`@Valid List<PersonMention> mentionedPersons`), JSR-303 does NOT descend into list elements. Result: A19's "rejects oversized displayName" test would pass for the wrong reason (no validation runs at all). A regression where someone removes the `@Valid` from the DTO field would silently let `displayName: <50KB string>` through — at which point both DB storage and HTML escaping become DoS vectors. Make this explicit in A2 and A16, and add a test case asserting that a 201-character `displayName` returns 400 (so the test would fail if validation cascade breaks). - **Migration without FK is correct for the spec, but creates dangling-reference attack surface.** Plan A1 says `block_id + person_id + display_name table, **No FK on `person_id`**`. Spec says deleted persons → mention degrades to plain text. Confirmed correct. Threat-model implication: a malicious admin can delete a person while leaving stale `displayName: <legitimate name>` sidecar entries pointing to a UUID that no longer resolves. The render-mode anchor `<a href="/persons/{id}">` would 404 on click — no security impact, just orphan UX. Worth adding A11.5: "Listener pins the documented behaviour: when no person is found by id during propagation, the sidecar entry is left untouched (it's already orphaned and will degrade per spec)." - **Hover card lazy fetch + concurrent hover.** B15 fetches `/api/persons/{id}` and `/api/persons/{id}/relationships` on `mouseenter`. A user moving their cursor across a paragraph with 20 mentions can fire 40 concurrent backend calls in a single second. No rate limit on these endpoints. At family-archive scale this is fine — but cache the responses client-side per `personId` for the page lifetime to prevent unnecessary load and reduce the attack surface for any future slowloris-style probing. One `Map<UUID, Promise<HoverData>>` does it. ### Recommendations - **Add `@RequirePermission(Permission.READ_ALL)`** to `PersonController.getPerson()` and `PersonController.getPersons()` as part of this PR (PR-A scope). One commit. - **Add to A16/A19:** the test must assert 400 status for `displayName` length 201. If validation cascade breaks (someone drops `@Valid` from the field), this test fails. Same for `personId: null`. - **Add B15.5 — hover card fetch deduplication:** per-page `Map<UUID, Promise<HoverData>>` cache. Hovering the same mention twice fires one fetch. Hovering 10 mentions for the same person fires one fetch. - **Document the orphaned-sidecar behavior** in A10/A14 test descriptions: propagation on a non-existent person is a no-op (defensive — should never happen, but a malformed event from a future async refactor shouldn't crash the listener). ### Open Decisions _(none)_
Author
Owner

🧪 Sara Holt — Plan Review

The test plan is comprehensive (24 tasks across 47 items, 21 are tests). Six gaps I'd add before merge.

Observations

  • No optimistic-lock test. TranscriptionBlock has @Version int version (TranscriptionBlock.java:54-57). The synchronous propagation listener (A10) does saveAll on N matching blocks in the same transaction as the rename. If a transcriber autosaves a referenced block between the listener's read and write, JPA throws OptimisticLockException and the entire rename rolls back. A11-A15 don't cover this scenario. Add A15b — concurrent edit during rename triggers OptimisticLockException, rename + propagation rolls back atomically. Use @DataJpaTest + Testcontainers + a second JdbcTemplate instance to bump the version mid-test.

  • No marker-composition integration test. B3-B7 test renderTranscriptionBody in isolation; B19 wires it into TranscriptionReadView.svelte alongside the existing splitByMarkers. The realistic failure mode is "Hallo @Auguste Raddatz [unleserlich] Marie" — both transformations must compose without HTML re-escaping or marker text leaking into anchor text. Felix's risk #1 names this; the plan doesn't add a test. Add B19b — block with mention + marker renders both correctly, no double-escape, no marker text inside anchor tag.

  • No keyboard-navigation test for PersonMentionEditor. B11 covers selection-by-click. The existing MentionEditor.svelte:117-154 supports ArrowDown/ArrowUp/Enter/Escape. The new editor must too. Add B11b — ArrowDown cycles highlight, Enter selects highlighted, Escape closes dropdown without inserting. Without this, sighted-keyboard transcribers (40% of the senior audience per Leonie's brief) cannot use the typeahead.

  • No test for the case "mention's display name was renamed since the block was last opened." Sequence: A opens a block with @Auguste Raddatz, B renames Auguste to Augusta in another tab (propagation runs), A saves their pending edits. Does A overwrite B's rename? Should A's save reload + replay? Block has @Version, so A's save throws OptimisticLockException — the front-end retry behavior (Felix Item 6, #5294) preserves the textarea, but does it also reload the latest sidecar from the server? Add B12b — save fails with 409 Conflict (optimistic lock), client refetches block and merges, transcriber sees the updated text but their unsaved input is preserved.

  • No load test data for propagation latency. Tobias's earlier comment noted "at family-archive scale this is fine — worst case ~150–200 block updates." There's no NFR or test fixture establishing what "fine" means in milliseconds. Add A11c — propagation across 200 blocks completes in <2s on Testcontainers. This is the floor, not a benchmark — if it ever drops below, the merge is blocked until investigated.

  • Browser test assertions in B20/B21 are vague. "Hover mounts card, click navigates" / "Touch device skips card, click still navigates" — what's the exact assertion? await expect(page.getByTestId('person-hover-card')).toBeVisible() after await page.hover(...). Touch device emulation needs await context.setHasTouch(true) and await page.tap(...). Spell these out in the task description so the test is reviewable.

Recommendations

  • Add tasks A15b, B11b, B12b, B19b, A11c as listed above.
  • Tighten B20/B21 to specify exact Playwright APIs: page.hover(), page.tap(), context.setHasTouch(true), and the exact selectors / data-testid attributes the test relies on.
  • Add a CI duration estimate: roughly +30s to backend integration tests (A11/A11c with Testcontainers), +2min to frontend browser tests (B20/B21). Within the 8-min E2E budget.

Open Decisions (none)

## 🧪 Sara Holt — Plan Review The test plan is comprehensive (24 tasks across 47 items, 21 are tests). Six gaps I'd add before merge. ### Observations - **No optimistic-lock test.** `TranscriptionBlock` has `@Version int version` (TranscriptionBlock.java:54-57). The synchronous propagation listener (A10) does `saveAll` on N matching blocks in the same transaction as the rename. If a transcriber autosaves a referenced block between the listener's read and write, JPA throws `OptimisticLockException` and the entire rename rolls back. A11-A15 don't cover this scenario. Add **A15b — concurrent edit during rename triggers OptimisticLockException, rename + propagation rolls back atomically**. Use `@DataJpaTest` + Testcontainers + a second JdbcTemplate instance to bump the version mid-test. - **No marker-composition integration test.** B3-B7 test `renderTranscriptionBody` in isolation; B19 wires it into `TranscriptionReadView.svelte` alongside the existing `splitByMarkers`. The realistic failure mode is `"Hallo @Auguste Raddatz [unleserlich] Marie"` — both transformations must compose without HTML re-escaping or marker text leaking into anchor text. Felix's risk #1 names this; the plan doesn't add a test. Add **B19b — block with mention + marker renders both correctly, no double-escape, no marker text inside anchor tag**. - **No keyboard-navigation test for `PersonMentionEditor`.** B11 covers selection-by-click. The existing `MentionEditor.svelte:117-154` supports ArrowDown/ArrowUp/Enter/Escape. The new editor must too. Add **B11b — `ArrowDown` cycles highlight, `Enter` selects highlighted, `Escape` closes dropdown without inserting**. Without this, sighted-keyboard transcribers (40% of the senior audience per Leonie's brief) cannot use the typeahead. - **No test for the case "mention's display name was renamed since the block was last opened."** Sequence: A opens a block with `@Auguste Raddatz`, B renames Auguste to Augusta in another tab (propagation runs), A saves their pending edits. Does A overwrite B's rename? Should A's save reload + replay? Block has `@Version`, so A's save throws `OptimisticLockException` — the front-end retry behavior (Felix Item 6, #5294) preserves the textarea, but does it also reload the latest sidecar from the server? Add **B12b — save fails with 409 Conflict (optimistic lock), client refetches block and merges, transcriber sees the updated text but their unsaved input is preserved**. - **No load test data for propagation latency.** Tobias's earlier comment noted "at family-archive scale this is fine — worst case ~150–200 block updates." There's no NFR or test fixture establishing what "fine" means in milliseconds. Add **A11c — propagation across 200 blocks completes in <2s on Testcontainers**. This is the floor, not a benchmark — if it ever drops below, the merge is blocked until investigated. - **Browser test assertions in B20/B21 are vague.** "Hover mounts card, click navigates" / "Touch device skips card, click still navigates" — what's the exact assertion? `await expect(page.getByTestId('person-hover-card')).toBeVisible()` after `await page.hover(...)`. Touch device emulation needs `await context.setHasTouch(true)` and `await page.tap(...)`. Spell these out in the task description so the test is reviewable. ### Recommendations - Add tasks **A15b, B11b, B12b, B19b, A11c** as listed above. - Tighten B20/B21 to specify exact Playwright APIs: `page.hover()`, `page.tap()`, `context.setHasTouch(true)`, and the exact selectors / data-testid attributes the test relies on. - Add a CI duration estimate: roughly +30s to backend integration tests (A11/A11c with Testcontainers), +2min to frontend browser tests (B20/B21). Within the 8-min E2E budget. ### Open Decisions _(none)_
Author
Owner

🎨 Leonie Voss — Plan Review

The earlier UX decisions (#5291) are well-reflected in the plan. Five gaps from the user-experience side.

Observations

  • B9 puts focus styles in routes/layout.css. I checked — that file is the right place; line 313 already documents renderBody()-injected mention styles for CommentMessage. Add the new .person-mention block alongside existing mention styles, not in a separate file. Keeps {@html}-rendered content styling colocated.

  • PersonHoverCard rendering states aren't all WCAG-checked. B15 specifies loading skeleton, loaded content, and error state. None of B16-B18 mention aria-live for the loaded-content state. A keyboard-only user tabbing through mentions hears nothing when the card mounts because the focus didn't move into it. Add aria-live="polite" to the card root so the announce-on-mount happens for assistive tech users. Or — better, since the card is contextual to the focus target — use aria-describedby on the anchor pointing to the card's content id.

  • Hover-card flip rule in #5291 is below-right default. What about the first line of a paragraph near the top of the viewport? Below-right works there, but the user's eye is anchored on the mention — flipping above means they have to look up. The flip rule should also handle "if mention is in the bottom 30% of the viewport, prefer above-right; otherwise below-right." Otherwise reading flow at the bottom of a long block is interrupted. Add a test viewport at 320×600 (small phone, short page) to verify: hover at y=100 → below-right; hover at y=550 → above-right.

  • B10 dropdown "name + life dates" doesn't specify the visual hierarchy. With name in font-serif and dates in smaller font-sans (project convention), the existing formatLifeDateRange() from frontend/src/lib/utils/personLifeDates.ts already produces * 1882 – † 1944. Use it directly — don't re-implement. The dropdown row pattern: serif text-base text-ink for name, sans text-xs text-ink-3 for dates, gap-1, single line, ellipsis on overflow. Add this to B10 description.

  • Touch target on dropdown rows is unspecified. The current MentionEditor.svelte:183-196 uses px-3 py-2 — about 36px tall. WCAG 2.2 AA requires 44×44px for primary interactive elements; for the senior audience this matters. Use min-h-[44px] px-3 py-2.5 or equivalent to bump dropdown rows to compliance. Bonus: smaller fingers/cursors don't accidentally dismiss the dropdown trying to scroll on a tablet.

  • Skeleton state visual not specified. B15 says "skeleton shimmer appears within 200ms" — what does it look like? At minimum: same width and height as the loaded card, animated bg-muted pulse for 3 stripes (header, dates line, notes line). Without a spec, every developer will build a different skeleton and the testable AC ("appears within 200ms") becomes "something appears within 200ms." Add to B15: "skeleton matches the loaded card's footprint (320px × ~180px), three pulse-animated bars in bg-muted, respects prefers-reduced-motion: reduce by removing the pulse animation."

Recommendations

  • B9 → confirm the addition lives in routes/layout.css next to the existing mention styles (line 313).
  • Add aria-describedby (anchor → card id) or aria-live="polite" to the hover card root in B15.
  • Extend the flip rule in B15: also flip below→above when the mention is in the bottom 30% of the viewport.
  • B10 must reuse formatLifeDateRange from personLifeDates.ts. Specify the row pattern exactly.
  • B10 dropdown row: min-h-[44px] for WCAG 2.2 AA touch target.
  • B15 skeleton: match the loaded-card footprint, 3 pulse bars, reduced-motion compliant.

Open Decisions (none)

## 🎨 Leonie Voss — Plan Review The earlier UX decisions (#5291) are well-reflected in the plan. Five gaps from the user-experience side. ### Observations - **B9 puts focus styles in `routes/layout.css`.** I checked — that file is the right place; line 313 already documents `renderBody()`-injected mention styles for `CommentMessage`. Add the new `.person-mention` block alongside existing mention styles, not in a separate file. Keeps `{@html}`-rendered content styling colocated. - **`PersonHoverCard` rendering states aren't all WCAG-checked.** B15 specifies loading skeleton, loaded content, and error state. None of B16-B18 mention `aria-live` for the loaded-content state. A keyboard-only user tabbing through mentions hears nothing when the card mounts because the focus didn't move into it. Add `aria-live="polite"` to the card root so the announce-on-mount happens for assistive tech users. Or — better, since the card is contextual to the focus target — use `aria-describedby` on the anchor pointing to the card's content id. - **Hover-card flip rule in #5291 is below-right default.** What about the *first line* of a paragraph near the top of the viewport? Below-right works there, but the user's eye is anchored on the mention — flipping above means they have to look up. The flip rule should also handle "if mention is in the bottom 30% of the viewport, prefer above-right; otherwise below-right." Otherwise reading flow at the bottom of a long block is interrupted. Add a test viewport at 320×600 (small phone, short page) to verify: hover at y=100 → below-right; hover at y=550 → above-right. - **B10 dropdown "name + life dates"** doesn't specify the visual hierarchy. With name in `font-serif` and dates in smaller `font-sans` (project convention), the existing `formatLifeDateRange()` from `frontend/src/lib/utils/personLifeDates.ts` already produces `* 1882 – † 1944`. Use it directly — don't re-implement. The dropdown row pattern: serif `text-base text-ink` for name, sans `text-xs text-ink-3` for dates, gap-1, single line, ellipsis on overflow. Add this to B10 description. - **Touch target on dropdown rows is unspecified.** The current `MentionEditor.svelte:183-196` uses `px-3 py-2` — about 36px tall. WCAG 2.2 AA requires 44×44px for primary interactive elements; for the senior audience this matters. Use `min-h-[44px] px-3 py-2.5` or equivalent to bump dropdown rows to compliance. Bonus: smaller fingers/cursors don't accidentally dismiss the dropdown trying to scroll on a tablet. - **Skeleton state visual not specified.** B15 says "skeleton shimmer appears within 200ms" — what does it look like? At minimum: same width and height as the loaded card, animated `bg-muted` pulse for 3 stripes (header, dates line, notes line). Without a spec, every developer will build a different skeleton and the testable AC ("appears within 200ms") becomes "something appears within 200ms." Add to B15: "skeleton matches the loaded card's footprint (320px × ~180px), three pulse-animated bars in `bg-muted`, respects `prefers-reduced-motion: reduce` by removing the pulse animation." ### Recommendations - B9 → confirm the addition lives in `routes/layout.css` next to the existing mention styles (line 313). - Add `aria-describedby` (anchor → card id) or `aria-live="polite"` to the hover card root in B15. - Extend the flip rule in B15: also flip below→above when the mention is in the bottom 30% of the viewport. - B10 must reuse `formatLifeDateRange` from `personLifeDates.ts`. Specify the row pattern exactly. - B10 dropdown row: `min-h-[44px]` for WCAG 2.2 AA touch target. - B15 skeleton: match the loaded-card footprint, 3 pulse bars, reduced-motion compliant. ### Open Decisions _(none)_
Author
Owner

🚀 Tobias Wendt — Plan Review

Earlier review (#5266) confirmed no infra changes. The plan now drops hypersistence-utils (good — no new dep to vet). Two operational items the plan misses.

Observations

  • Pre-flight bullet 1 ("finalise the unrelated changes on feat/stammbaum-issue-358") is the actual current state. git status shows RelationshipService.java and CreateRelationshipRequest.java modified plus four test files, and the active branch is feat/stammbaum-issue-358. There's no PR yet for #358 (verified by absence of upstream tracking on the branch in the git context). The plan's pre-flight is correct but understated — the real path is: open the PR for #358, get it merged, then git pull origin main, then branch for #362. Otherwise the OpenAPI-types regen in A22 will diff against #358 changes and bundle them into PR-A. Spell that out: "PR for #358 must be merged to main before git pull origin main."

  • OpenAPI regeneration step (A22) belongs in PR-A, not PR-B. The plan correctly puts it after backend changes. But: the regenerated frontend/src/lib/generated/api.ts is committed in PR-A, then PR-B uses those types. If PR-A merges and PR-B is reviewed days later, a third unrelated PR could regenerate api.ts in the meantime, causing merge conflicts. Two mitigations: (a) keep PR-A and PR-B chained — once PR-A merges, immediately rebase PR-B on main; (b) freeze other backend work during the gap. Note this in the plan.

  • No @CollectionTable(name = "...") explicit declaration noted. Per Hibernate's default naming strategy, @ElementCollection List<PersonMention> on TranscriptionBlock would auto-generate transcription_block_mentioned_persons. The Flyway migration A1 must use the same name. If the naming strategy ever changes (project convention shift, Hibernate upgrade), the auto-generated name diverges from the migration and Flyway validation breaks at startup. Always declare it explicitly: @CollectionTable(name = "transcription_block_mentioned_persons", joinColumns = @JoinColumn(name = "block_id")). Otherwise this is a time-bomb for whoever does the next Hibernate major upgrade.

  • Migration safety reconfirmed: CREATE TABLE for an @ElementCollection join table is a brand-new table — zero existing rows, zero locking impact. Index creation on a new (empty) table is instantaneous. No Flyway concerns. Plan A20's rationale-comment is good — keep it.

  • No CI runtime estimate. PR-A adds Testcontainers integration tests (A11-A15, A18, A19) — roughly +60s on Java suite. PR-B adds vitest-browser tests (B11/B12, B16-B18, B20-B21) — roughly +90s on TypeScript suite. Within current budget (backend < 4min, frontend < 5min) but worth noting in the PR description so reviewers don't get blindsided.

Recommendations

  • Pre-flight, restated: (1) finalise/PR/merge #358 on its own; (2) git pull origin main standalone; (3) branch for #362. Don't mix #358 cleanup into the #362 branch — that's exactly the situation feedback_atomic_commits warns against.

  • Explicit @CollectionTable(name = "transcription_block_mentioned_persons") in task A3. One line, zero cost, removes a category of migration-naming bug forever.

  • Add note to PR-A description: "PR-B (#362-frontend) is chained to this PR. Please merge promptly to avoid OpenAPI-type drift."

  • Add to A21: record total Java test runtime delta. If A11/A15 add more than 90s, investigate Testcontainers reuse.

Open Decisions (none)

## 🚀 Tobias Wendt — Plan Review Earlier review (#5266) confirmed no infra changes. The plan now drops `hypersistence-utils` (good — no new dep to vet). Two operational items the plan misses. ### Observations - **Pre-flight bullet 1 ("finalise the unrelated changes on `feat/stammbaum-issue-358`") is the actual current state.** `git status` shows `RelationshipService.java` and `CreateRelationshipRequest.java` modified plus four test files, and the active branch is `feat/stammbaum-issue-358`. There's no PR yet for #358 (verified by absence of upstream tracking on the branch in the git context). The plan's pre-flight is correct but understated — the real path is: open the PR for #358, get it merged, *then* `git pull origin main`, *then* branch for #362. Otherwise the OpenAPI-types regen in A22 will diff against #358 changes and bundle them into PR-A. Spell that out: "PR for #358 must be merged to main before `git pull origin main`." - **OpenAPI regeneration step (A22) belongs in PR-A, not PR-B.** The plan correctly puts it after backend changes. But: the regenerated `frontend/src/lib/generated/api.ts` is committed in PR-A, then PR-B uses those types. If PR-A merges and PR-B is reviewed days later, a *third* unrelated PR could regenerate `api.ts` in the meantime, causing merge conflicts. Two mitigations: (a) keep PR-A and PR-B chained — once PR-A merges, immediately rebase PR-B on main; (b) freeze other backend work during the gap. Note this in the plan. - **No `@CollectionTable(name = "...")` explicit declaration noted.** Per Hibernate's default naming strategy, `@ElementCollection List<PersonMention>` on `TranscriptionBlock` would auto-generate `transcription_block_mentioned_persons`. The Flyway migration A1 must use the **same** name. If the naming strategy ever changes (project convention shift, Hibernate upgrade), the auto-generated name diverges from the migration and Flyway validation breaks at startup. Always declare it explicitly: `@CollectionTable(name = "transcription_block_mentioned_persons", joinColumns = @JoinColumn(name = "block_id"))`. Otherwise this is a time-bomb for whoever does the next Hibernate major upgrade. - **Migration safety reconfirmed:** `CREATE TABLE` for an `@ElementCollection` join table is a brand-new table — zero existing rows, zero locking impact. Index creation on a new (empty) table is instantaneous. No Flyway concerns. Plan A20's rationale-comment is good — keep it. - **No CI runtime estimate.** PR-A adds Testcontainers integration tests (A11-A15, A18, A19) — roughly +60s on Java suite. PR-B adds vitest-browser tests (B11/B12, B16-B18, B20-B21) — roughly +90s on TypeScript suite. Within current budget (backend < 4min, frontend < 5min) but worth noting in the PR description so reviewers don't get blindsided. ### Recommendations - **Pre-flight, restated:** (1) finalise/PR/merge #358 on its own; (2) `git pull origin main` standalone; (3) branch for #362. Don't mix #358 cleanup into the #362 branch — that's exactly the situation `feedback_atomic_commits` warns against. - **Explicit `@CollectionTable(name = "transcription_block_mentioned_persons")`** in task A3. One line, zero cost, removes a category of migration-naming bug forever. - **Add note to PR-A description:** "PR-B (#362-frontend) is chained to this PR. Please merge promptly to avoid OpenAPI-type drift." - **Add to A21:** record total Java test runtime delta. If A11/A15 add more than 90s, investigate Testcontainers reuse. ### Open Decisions _(none)_
Author
Owner

📋 Elicit — Plan Review

Plan-level requirements check. The earlier review (#5267) flagged five spec-level gaps, four of which were resolved in #5294 (multi-word detection, OQ-1 deterministic rule, network error states, save-failure pending state). One remains unaddressed AND three new requirements gaps surface from the plan itself.

Observations

  • NFR-PERF-001 (rename latency) is still missing. Earlier review asked for "person rename completes within X seconds for archives with up to N transcription blocks." Plan still has no NFR. Sara's review proposes a Testcontainers floor of 2s for 200 blocks — that's a test target, not a published NFR. The two are different: the NFR is a contract for whoever operates the application; the test is a regression guard. Add NFR-PERF-001 to the issue body or a follow-up acceptance criterion.

  • Plan task A5 reads as if displayName were a column. Implementer-facing task wording "capture old displayName" is ambiguous given that Person.getDisplayName() is @Transient (computed). Two implementations both pass A5 as written: (a) snapshot before mutation (correct); (b) compare DTO's pre-update field values to entity's post-update field values (also correct but more brittle). The wording leaves the call to interpretation. From a requirements standpoint, A6/A7 should specify which input field changes count — at minimum: title, firstName, lastName. If alias changes, does propagation fire? (Spec says no — alias isn't part of getDisplayName() — but A7 should explicitly assert that an alias-only change does NOT publish the event.)

  • Acceptance criterion for "person renamed mid-edit" is missing. Sara's B12b case (transcriber holds an unsaved edit, person rename runs, transcriber saves) maps to a concrete user-facing scenario:

    Given transcriber A is editing a block referencing @Auguste Raddatz,
    When an admin renames Auguste to Augusta in a separate session,
    And transcriber A saves their pending edits,
    Then the save returns 409 Conflict, the block reloads with the new sidecar/text from the server, and transcriber A's typed-but-unsaved input is preserved in the textarea.
    This is a real flow that will happen in a multi-user archive. Plan implements optimistic locking implicitly (via @Version); the AC must make the expected user experience explicit.

  • Telemetry / observability NFR. No task mentions logging the propagation event (log.info("Propagated person rename {} → {} across {} blocks", oldName, newName, count)). For a feature that mutates historical text, an audit trail is essential. SLF4J parameterised log on the listener, ≤1 line per rename. Without it, "did the propagation actually run?" becomes unanswerable in production.

  • Branch flow conflict with feedback_pull_before_branch. The plan's pre-flight sequencing is correct, but condenses three separate, important steps into a single bullet ("Finalise the unrelated changes on feat/stammbaum-issue-358 — commit or discard before branching for #362"). Per project guidance, finalising #358 (commit/PR/merge) is itself a separate work item that should not be silently bundled into the prep for #362. Surface as a blocker: "Issue #362 implementation BLOCKED until #358 is merged."

Recommendations

  • Add NFR-PERF-001: "The person rename operation, including mention propagation across all transcription blocks, shall complete within 3 seconds for archives with up to 2,000 transcription blocks containing the renamed person, measured at the response of PUT /api/persons/{id}."

  • Add AC-PROP-04 (rename-mid-edit) to the acceptance criteria in the issue body, exactly as worded above. Implementation maps to Sara's B12b.

  • Add A10b — propagation logging: structured info-level log on the listener, including oldName, newName, and affected block count.

  • Sequence #358 explicitly: rewrite pre-flight as three separate work items: (1) [blocked-by] = #358 merge to main; (2) sync local main; (3) branch for #362. Don't pretend #358 cleanup is a one-line prep step.

  • Tighten A5/A6/A7 task wording so the implementer cannot ship behaviour-(b) by accident. Plain language: "Snapshot oldDisplayName = person.getDisplayName() before any setter call. After save, compute newDisplayName = saved.getDisplayName(). Publish the event iff they differ. A6/A7: assert title-only change DOES publish; assert notes-only or alias-only change does NOT publish."

Open Decisions (none)

## 📋 Elicit — Plan Review Plan-level requirements check. The earlier review (#5267) flagged five spec-level gaps, four of which were resolved in #5294 (multi-word detection, OQ-1 deterministic rule, network error states, save-failure pending state). One remains unaddressed AND three new requirements gaps surface from the plan itself. ### Observations - **NFR-PERF-001 (rename latency) is still missing.** Earlier review asked for "person rename completes within X seconds for archives with up to N transcription blocks." Plan still has no NFR. Sara's review proposes a Testcontainers floor of 2s for 200 blocks — that's a test target, not a published NFR. The two are different: the NFR is a contract for whoever operates the application; the test is a regression guard. Add NFR-PERF-001 to the issue body or a follow-up acceptance criterion. - **Plan task A5 reads as if `displayName` were a column.** Implementer-facing task wording "capture old `displayName`" is ambiguous given that `Person.getDisplayName()` is `@Transient` (computed). Two implementations both pass A5 as written: (a) snapshot before mutation (correct); (b) compare DTO's pre-update field values to entity's post-update field values (also correct but more brittle). The wording leaves the call to interpretation. From a requirements standpoint, A6/A7 should specify *which input field changes count* — at minimum: title, firstName, lastName. If alias changes, does propagation fire? (Spec says no — alias isn't part of `getDisplayName()` — but A7 should explicitly assert that an alias-only change does NOT publish the event.) - **Acceptance criterion for "person renamed mid-edit" is missing.** Sara's B12b case (transcriber holds an unsaved edit, person rename runs, transcriber saves) maps to a concrete user-facing scenario: > **Given** transcriber A is editing a block referencing `@Auguste Raddatz`, > **When** an admin renames Auguste to Augusta in a separate session, > **And** transcriber A saves their pending edits, > **Then** the save returns 409 Conflict, the block reloads with the new sidecar/text from the server, and transcriber A's typed-but-unsaved input is preserved in the textarea. This is a real flow that *will* happen in a multi-user archive. Plan implements optimistic locking implicitly (via `@Version`); the AC must make the expected user experience explicit. - **Telemetry / observability NFR.** No task mentions logging the propagation event (`log.info("Propagated person rename {} → {} across {} blocks", oldName, newName, count)`). For a feature that mutates historical text, an audit trail is essential. SLF4J parameterised log on the listener, ≤1 line per rename. Without it, "did the propagation actually run?" becomes unanswerable in production. - **Branch flow conflict with `feedback_pull_before_branch`.** The plan's pre-flight sequencing is correct, but condenses three separate, important steps into a single bullet ("Finalise the unrelated changes on `feat/stammbaum-issue-358` — commit or discard before branching for #362"). Per project guidance, finalising #358 (commit/PR/merge) is itself a separate work item that should not be silently bundled into the prep for #362. Surface as a blocker: "Issue #362 implementation BLOCKED until #358 is merged." ### Recommendations - **Add NFR-PERF-001:** *"The person rename operation, including mention propagation across all transcription blocks, shall complete within 3 seconds for archives with up to 2,000 transcription blocks containing the renamed person, measured at the response of `PUT /api/persons/{id}`."* - **Add AC-PROP-04 (rename-mid-edit) to the acceptance criteria** in the issue body, exactly as worded above. Implementation maps to Sara's B12b. - **Add A10b — propagation logging:** structured info-level log on the listener, including `oldName`, `newName`, and affected block count. - **Sequence #358 explicitly:** rewrite pre-flight as three separate work items: (1) `[blocked-by]` = #358 merge to main; (2) sync local main; (3) branch for #362. Don't pretend #358 cleanup is a one-line prep step. - **Tighten A5/A6/A7 task wording** so the implementer cannot ship behaviour-(b) by accident. Plain language: *"Snapshot `oldDisplayName = person.getDisplayName()` before any setter call. After save, compute `newDisplayName = saved.getDisplayName()`. Publish the event iff they differ. A6/A7: assert title-only change DOES publish; assert notes-only or alias-only change does NOT publish."* ### Open Decisions _(none)_
Author
Owner

📌 Pinned summary — read this instead of all comments above

For LLM agents and human readers: this single comment consolidates every locked decision, rationale, plan task, and plan-review refinement from the 19 comments above. If you read this comment plus the issue body, you have the full context — you do not need to load the rest of the thread.


A. Locked architectural decisions (Markus follow-up #5290)

  1. Domain coupling — ApplicationEvent, not direct call. PersonService publishes PersonDisplayNameChangedEvent(personId, oldName, newName) via ApplicationEventPublisher; PersonMentionPropagationListener (transcription package) handles it with @EventListener @Transactional. Why: Person domain stays free of any compile-time dependency on Transcription. Spring's default synchronous dispatch keeps rename + propagation atomic in one transaction — same guarantee as a direct call, with cleaner boundaries.
  2. Storage — @ElementCollection + @Embeddable PersonMention { UUID personId; String displayName; }. No hypersistence-utils-hibernate63 dependency. Why: The future "show all blocks mentioning person X" join on an indexed B-tree person_id is equally fast as JSONB GIN containment, avoids a new library, and matches the established UserGroup pattern.
  3. Package layout — by-layer (existing convention). model/PersonMention.java, model/PersonDisplayNameChangedEvent.java, service/PersonMentionPropagationListener.java. Doc-comment the listener as transcription-domain logic. No feature-package island.
  4. Synchronous propagation, not async. @EventListener (not @Async). Why: Historical text mutation must commit atomically — silent partial corruption is unacceptable. ~150–200 blocks worst case = 100–300ms latency. If archive grows to tens of thousands of blocks later, switch to @TransactionalEventListener(AFTER_COMMIT) + @Async (one annotation).

B. Locked UX/A11y decisions (Leonie follow-up #5291)

  1. <a href="/persons/{id}" class="person-mention"> — not <button>. Computed at render from sidecar. Why: Correct semantics ("link" not "button"), browser-native affordances (Ctrl/middle-click → new tab), correct screen-reader announcement. mouseenter/mouseleave attaches to the anchor.
  2. Hover card flip rules (no library):
    • Default: below-right of trigger.
    • Flip to above-right if <200px from viewport bottom (Leonie plan-review extends this: also flip when mention is in bottom 30% of viewport).
    • Flip to left side if <300px from viewport right.
    • Use getBoundingClientRect().
  3. Three Paraglide keys (de/en/es) before implementation: person_mention_open_link ("Zur Person"), person_mention_hover_hint ("Klick öffnet Seite"), person_mention_load_error.
  4. .person-mention global CSS in routes/layout.css (next to existing renderBody mention styles, line 313): text-decoration: underline resting state (WCAG AA — color alone fails 8% of men colour-blind), :focus-visible { outline: none; box-shadow: 0 0 0 2px var(--color-ink); border-radius: 2px; }.
  5. AC reworded: "skeleton state appears within 200ms of mouseenter; loaded content populates when fetch resolves" (the original "card appears within 200ms" was untestable with a lazy fetch).

C. Locked dev/behavioral decisions (Felix follow-up #5294)

  1. Multi-word typeahead detection — fix it. New detectPersonMention() in personMention.ts allows spaces; stops only at newline or second @. Original detectMention() for user mentions stays as-is. Why: Accepting the limitation breaks the typeahead for any archive with multiple persons sharing a first name (the common case).
  2. Shared escapeHtml(str) helper in mention.ts. Both renderBody() and renderTranscriptionBody() call it. renderBody refactored retroactively.
  3. Propagation rewrites all @OldName text occurrences — including manually-typed ones. Defined behaviour, not a bug. Why: Family archive with controlled authorship; renaming the person should rename their textual mentions everywhere.
  4. OQ-1 deterministic rule: when two sidecar entries share the same displayName, first sidecar entry wins for all text occurrences; second produces no link. (Replaces the "ambiguous" label.)
  5. Hover card three states: (a) loading skeleton ≤200ms, (b) 404 → card never mounts, mention degrades to plain unlinked text, (c) non-404 error (500/timeout/network) → generic error message inside card. The link itself navigates in all states.
  6. Save failure preserves both textarea text and mentionedPersons array — transcriber retries without re-searching. No mention data lost on failed save.

D. Implementation plan (Felix #5321) — 23 backend + 24 frontend tasks

Pre-flight (per Tobias #5330 + Elicit #5332 — explicit blocking):

  1. [blocked-by] #358 must be PR'd, reviewed, and merged to main (uncommitted on feat/stammbaum-issue-358: RelationshipService.java, CreateRelationshipRequest.java, 3 test files).
  2. git pull origin main standalone.
  3. Branch feat/person-mentions-issue-362-backend (per Felix self-review #5324: matches feat/<topic>-issue-<n> convention).

PR-A (Backend):

  • V56 migration: block_id + person_id + display_name table; no FK on person_id (deleted persons degrade per spec); B-tree on person_id, no GIN.
  • PersonMention @Embeddable, @NotNull personId, @Size(max=200) displayName.
  • @ElementCollection(LAZY) on TranscriptionBlock with explicit @CollectionTable(name = "transcription_block_mentioned_persons", joinColumns = @JoinColumn(name = "block_id")) (Markus #5323, Tobias #5330 — match migration name byte-for-byte; survives Hibernate naming-strategy changes).
  • PersonDisplayNameChangedEvent in model/.
  • PersonService.updatePersoncorrect sequence (Markus/Felix/Elicit): snapshot oldDisplayName = person.getDisplayName() before any setter (getDisplayName() is @Transient, computed via DisplayNameFormatter.format(title, firstName, lastName), Person.java:63-67), personRepository.save(person), then newDisplayName = saved.getDisplayName(), publish iff differ.
  • Tests: title-only change DOES publish (Felix #5324); alias-only or notes-only change does NOT publish (Elicit #5332 — alias not in getDisplayName()).
  • Derived repo query findByMentionedPersons_PersonId(UUID).
  • PersonMentionPropagationListener: text.replace("@"+old,"@"+new) + sidecar update + saveAll. Tests: happy path / no-op / partial-name guard / multiple occurrences. A10b — SLF4J info log (Elicit): oldName, newName, affected block count — audit trail mandatory for historical-text mutation.
  • A11.5 — orphaned-sidecar guard (Nora #5326): propagation listener treats "person not found by id" as no-op (defensive against future async refactors / malformed events).
  • A15b — optimistic-lock test (Sara #5327): @DataJpaTest + Testcontainers + second JdbcTemplate to bump version mid-rename → assert OptimisticLockException, full rollback of rename + propagation.
  • A11c — propagation latency floor (Sara): 200 blocks Testcontainers <2s. Floor not benchmark; merge blocked if regressed.
  • ErrorCode.PERSON_RENAME_CONFLICT (Markus #5323): TranscriptionBlock has @Version (line 54-57); concurrent autosave during rename → OptimisticLockException → whole rename rolls back. Frontend needs the specific code to render "another transcriber edited a referenced block — please retry" rather than generic 500.
  • DTO @Valid cascade (Felix self-review + Nora): @Valid List<PersonMention> mentionedPersons on the field, not just on the controller method. JSR-303 doesn't recurse into list elements without it. Add regression tests asserting 400 for displayName length 201 and 400 for personId: null — fail loudly if anyone drops @Valid.
  • @RequirePermission(Permission.READ_ALL) on PersonController.getPerson() and getPersons() (Nora #5326). Currently only POST/PUT/DELETE are gated (PersonController.java:36-44). Defense in depth — required by the hover card and typeahead.
  • OpenAPI regen committed in PR-A (Tobias: chain PR-A → PR-B tightly; rebase PR-B on main immediately after PR-A merge to avoid api.ts drift from a third unrelated PR).

PR-B (Frontend, after PR-A merges):

  • escapeHtml extraction in mention.ts.
  • personMention.ts: detectPersonMention (allow spaces; stop at \n or second @; test cases @Auguste, @Auguste , @Auguste Raddatz, @M, @, @Aug\nfoo, @Aug@bar); renderTranscriptionBody (longest-displayName-first, <a href="/persons/{id}" class="person-mention">DisplayName</a>, no @ prefix in rendered text).
  • 3 Paraglide keys (de/en/es).
  • .person-mention CSS in routes/layout.css:313.
  • PersonMentionEditor.svelte: mirror MentionEditor, hits /api/persons?q=, dropdown shows name + life dates via formatLifeDateRange() from frontend/src/lib/utils/personLifeDates.ts (Leonie #5329 — don't reimplement). Row pattern: serif text-base text-ink for name, sans text-xs text-ink-3 for dates, gap-1, ellipsis on overflow, min-h-[44px] px-3 py-2.5 (Leonie — WCAG 2.2 AA touch target; current MentionEditor:183-196 uses px-3 py-2 ≈ 36px).
  • B11b — keyboard nav test (Sara): ArrowDown/ArrowUp cycle highlight, Enter selects, Escape closes without inserting. (Existing MentionEditor.svelte:117-154 supports it; new editor must too.)
  • B12 + B12b — save failure (Felix #5294 + Sara #5327): preserves textarea text + mentionedPersons. On 409 Conflict (rename-mid-edit), client refetches block, merges latest sidecar/text from server, transcriber's unsaved input preserved.
  • Textarea swap in TranscriptionBlock.svelte lines 185–194; thread mentionedPersons through autosave.
  • PersonHoverCard.svelte: 3 states; manual flip via getBoundingClientRect() (default below-right; flip up <200px from bottom OR mention in bottom 30% of viewport; flip left <300px from right; Leonie test viewport 320×600: hover y=100→below-right; y=550→above-right); hidden via @media (hover: none). Skeleton spec (Leonie #5329): match loaded-card footprint (~320×180px), 3 pulse-animated bars in bg-muted, respect prefers-reduced-motion: reduce. aria-describedby on anchor → card content id (or aria-live="polite" on card root) for SR announce-on-mount.
  • B15.5 — fetch deduplication (Nora #5326): per-page Map<UUID, Promise<HoverData>> cache. Cursor sweeping a paragraph with 20 mentions otherwise fires 40 backend calls/sec.
  • TranscriptionReadView.svelte: render via renderTranscriptionBody, compose with existing splitByMarkers — split into marker segments first, run mention substitution + escapeHtml on text segments only, then re-join. Add eslint-disable-next-line svelte/no-at-html-tags comment matching CommentMessage.svelte:88-89 rationale (Felix self-review).
  • B19b — marker composition test (Felix self-review + Sara): text "Hallo @Auguste Raddatz [unleserlich] Marie" must render mention link AND <em data-marker> correctly with no double-escape and no marker text inside the anchor.
  • B20/B21 tightened (Sara #5327): specify page.hover(), page.tap(), context.setHasTouch(true), exact data-testid selectors.
  • B7 note (Felix self-review): Person.firstName is nullable (Person.java:27); single-name persons (last-name-only) valid → first-sidecar-wins collisions become more likely. Mention in test description.

Felix's risks tracked:

  1. Marker composition ordering (covered by B19b).
  2. @ElementCollection+@Embeddable is new ground (UserGroup only uses Set<String>). Repo test + listener integration tests are the safety net.
  3. Event must fire after personRepository.save and within rename transaction; sanity-check assertion in integration test.

First custom domain event in this codebase (Markus #5323). Only existing @EventListener is OcrTrainingService.java:234 listening to Spring's ApplicationReadyEvent. Worth a package-info / migration comment — sets convention for future cross-domain decoupling.

CI delta estimate (Tobias): backend +60s (Testcontainers), frontend +90s (vitest-browser). Within budget. Note in PR descriptions.


E. Issue-body amendments still required (Elicit #5267 / #5332)

Add to the issue body before implementation starts:

  • NFR-PERF-001: "The person rename operation, including mention propagation across all transcription blocks, shall complete within 3 seconds for archives with up to 2,000 transcription blocks containing the renamed person, measured at the response of PUT /api/persons/{id}."
  • AC-PROP-04 (rename-mid-edit):

    Given transcriber A is editing a block referencing @Auguste Raddatz,
    When an admin renames Auguste to Augusta in a separate session,
    And transcriber A saves their pending edits,
    Then the save returns 409 Conflict, the block reloads with the new sidecar/text from the server, and transcriber A's typed-but-unsaved input is preserved in the textarea.

  • Replace OQ-1 "ambiguous" label with deterministic rule (first-sidecar-wins).
  • Replace "card appears within 200ms" AC with "skeleton state appears within 200ms; loaded content populates when fetch resolves."
  • Document hover card three states (loading / 404 unmounted / non-404 error in card).

F. Status

Open decisions: none. Every persona's plan-review section closes with "(none)".

Blocking: PR for #358 must merge to main before #362 work starts.

Ready to start once: issue body amendments above are added, and #358 is merged.


G. Update — 2026-04-28: PR-B split (post-review of PR-A #366)

The original plan put the entire frontend (~24 tasks) into a single PR-B. PR-A's six-persona review (Tobias, Sara) recommended splitting frontend work in two for reviewability. Adopted:

  • PR-B1 — Edit-mode infrastructure

    • escapeHtml extraction in mention.ts (refactor; shared with B2)
    • personMention.ts: detectPersonMention (allow spaces; stop at \n or second @) + tests
    • PersonMentionEditor.svelte + keyboard-nav tests (mirror existing MentionEditor keyboard behaviour)
    • 3 Paraglide keys (de/en/es): person_mention_open_link, person_mention_hover_hint, person_mention_load_error
    • formatLifeDateRange reuse from personLifeDates.ts
    • Textarea swap in TranscriptionBlock.svelte lines 185–194; thread mentionedPersons through autosave
    • B11b keyboard-nav test
    • B12 + B12b save-failure tests
  • PR-B2 — Read-mode rendering + hover card (depends on PR-B1 being merged)

    • renderTranscriptionBody in mention.ts (longest-displayName-first, no @ prefix in rendered text)
    • Stored-XSS guard: displayName flows from sidecar into block.text unescaped on the backend (PR-A) — PR-B2 must HTML-escape displayName and any block-text segment on render with explicit unit tests for <script>, <img onerror>, and HTML-entity-already-encoded payloads. Nora #1 / Leonie review (PR #366).
    • .person-mention global CSS in routes/layout.css:313 (underline resting state for WCAG AA, focus-visible ring)
    • PersonHoverCard.svelte with three states (skeleton ≤ 200ms, 404 unmounted, non-404 error in card) + getBoundingClientRect() flip rules
    • TranscriptionReadView.svelte integration with splitByMarkers + B19b marker-composition test
    • aria-describedby / aria-live on hover card
    • B15.5 fetch deduplication cache
    • B20/B21 Playwright hover/tap tests
    • eslint-disable-next-line svelte/no-at-html-tags on {@html ...} (mirror CommentMessage.svelte:88-89 pattern)

Why split: B1 is editor-side, fully testable in isolation, no read-path coupling. B2 requires B1's typeahead inserting sidecar entries before the read-path can render anything. Splitting halves each review's surface area, surfaces XSS-on-render concerns in their own commit boundary, and lets B1 land while B2's hover-card design iterates.

Per-PR review-cycle expectation: 1 cycle each (no fixes anticipated for B1; B2 may require touch-ups once Leonie sees the hover card on a real device).

## 📌 Pinned summary — read this instead of all comments above > **For LLM agents and human readers:** this single comment consolidates every locked decision, rationale, plan task, and plan-review refinement from the 19 comments above. If you read this comment plus the issue body, you have the full context — you do **not** need to load the rest of the thread. --- ## A. Locked architectural decisions (Markus follow-up #5290) 1. **Domain coupling — ApplicationEvent, not direct call.** `PersonService` publishes `PersonDisplayNameChangedEvent(personId, oldName, newName)` via `ApplicationEventPublisher`; `PersonMentionPropagationListener` (transcription package) handles it with `@EventListener @Transactional`. *Why:* Person domain stays free of any compile-time dependency on Transcription. Spring's default synchronous dispatch keeps rename + propagation atomic in one transaction — same guarantee as a direct call, with cleaner boundaries. 2. **Storage — `@ElementCollection` + `@Embeddable PersonMention { UUID personId; String displayName; }`.** No `hypersistence-utils-hibernate63` dependency. *Why:* The future "show all blocks mentioning person X" join on an indexed B-tree `person_id` is equally fast as JSONB GIN containment, avoids a new library, and matches the established `UserGroup` pattern. 3. **Package layout — by-layer (existing convention).** `model/PersonMention.java`, `model/PersonDisplayNameChangedEvent.java`, `service/PersonMentionPropagationListener.java`. Doc-comment the listener as transcription-domain logic. No feature-package island. 4. **Synchronous propagation, not async.** `@EventListener` (not `@Async`). *Why:* Historical text mutation must commit atomically — silent partial corruption is unacceptable. ~150–200 blocks worst case = 100–300ms latency. If archive grows to tens of thousands of blocks later, switch to `@TransactionalEventListener(AFTER_COMMIT) + @Async` (one annotation). ## B. Locked UX/A11y decisions (Leonie follow-up #5291) 1. **`<a href="/persons/{id}" class="person-mention">` — not `<button>`.** Computed at render from sidecar. *Why:* Correct semantics ("link" not "button"), browser-native affordances (Ctrl/middle-click → new tab), correct screen-reader announcement. `mouseenter`/`mouseleave` attaches to the anchor. 2. **Hover card flip rules (no library):** - Default: **below-right** of trigger. - Flip to **above-right** if <200px from viewport bottom (Leonie plan-review extends this: also flip when mention is in **bottom 30% of viewport**). - Flip to **left side** if <300px from viewport right. - Use `getBoundingClientRect()`. 3. **Three Paraglide keys (de/en/es) before implementation:** `person_mention_open_link` ("Zur Person"), `person_mention_hover_hint` ("Klick öffnet Seite"), `person_mention_load_error`. 4. **`.person-mention` global CSS in `routes/layout.css`** (next to existing `renderBody` mention styles, line 313): `text-decoration: underline` resting state (WCAG AA — color alone fails 8% of men colour-blind), `:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--color-ink); border-radius: 2px; }`. 5. **AC reworded:** "skeleton state appears within 200ms of mouseenter; loaded content populates when fetch resolves" (the original "card appears within 200ms" was untestable with a lazy fetch). ## C. Locked dev/behavioral decisions (Felix follow-up #5294) 1. **Multi-word typeahead detection — fix it.** New `detectPersonMention()` in `personMention.ts` allows spaces; stops only at newline or second `@`. Original `detectMention()` for user mentions stays as-is. *Why:* Accepting the limitation breaks the typeahead for any archive with multiple persons sharing a first name (the common case). 2. **Shared `escapeHtml(str)` helper in `mention.ts`.** Both `renderBody()` and `renderTranscriptionBody()` call it. `renderBody` refactored retroactively. 3. **Propagation rewrites all `@OldName` text occurrences — including manually-typed ones.** Defined behaviour, not a bug. *Why:* Family archive with controlled authorship; renaming the person should rename their textual mentions everywhere. 4. **OQ-1 deterministic rule:** when two sidecar entries share the same `displayName`, **first sidecar entry wins** for all text occurrences; second produces no link. (Replaces the "ambiguous" label.) 5. **Hover card three states:** (a) loading skeleton ≤200ms, (b) 404 → card never mounts, mention degrades to plain unlinked text, (c) non-404 error (500/timeout/network) → generic error message inside card. The link itself navigates in all states. 6. **Save failure preserves both textarea text and `mentionedPersons` array** — transcriber retries without re-searching. No mention data lost on failed save. --- ## D. Implementation plan (Felix #5321) — 23 backend + 24 frontend tasks **Pre-flight (per Tobias #5330 + Elicit #5332 — explicit blocking):** 1. `[blocked-by]` #358 must be PR'd, reviewed, and merged to main (uncommitted on `feat/stammbaum-issue-358`: `RelationshipService.java`, `CreateRelationshipRequest.java`, 3 test files). 2. `git pull origin main` standalone. 3. Branch `feat/person-mentions-issue-362-backend` (per Felix self-review #5324: matches `feat/<topic>-issue-<n>` convention). **PR-A (Backend):** - V56 migration: block_id + person_id + display_name table; **no FK on person_id** (deleted persons degrade per spec); B-tree on `person_id`, no GIN. - `PersonMention @Embeddable`, `@NotNull personId`, `@Size(max=200) displayName`. - `@ElementCollection(LAZY)` on `TranscriptionBlock` with **explicit `@CollectionTable(name = "transcription_block_mentioned_persons", joinColumns = @JoinColumn(name = "block_id"))`** (Markus #5323, Tobias #5330 — match migration name byte-for-byte; survives Hibernate naming-strategy changes). - `PersonDisplayNameChangedEvent` in `model/`. - `PersonService.updatePerson` — **correct sequence (Markus/Felix/Elicit):** snapshot `oldDisplayName = person.getDisplayName()` **before any setter** (`getDisplayName()` is `@Transient`, computed via `DisplayNameFormatter.format(title, firstName, lastName)`, Person.java:63-67), `personRepository.save(person)`, then `newDisplayName = saved.getDisplayName()`, publish iff differ. - Tests: **title-only change DOES publish** (Felix #5324); **alias-only or notes-only change does NOT publish** (Elicit #5332 — alias not in `getDisplayName()`). - Derived repo query `findByMentionedPersons_PersonId(UUID)`. - `PersonMentionPropagationListener`: `text.replace("@"+old,"@"+new)` + sidecar update + `saveAll`. Tests: happy path / no-op / partial-name guard / multiple occurrences. **A10b — SLF4J info log** (Elicit): `oldName`, `newName`, affected block count — audit trail mandatory for historical-text mutation. - **A11.5 — orphaned-sidecar guard (Nora #5326):** propagation listener treats "person not found by id" as no-op (defensive against future async refactors / malformed events). - **A15b — optimistic-lock test (Sara #5327):** `@DataJpaTest` + Testcontainers + second JdbcTemplate to bump `version` mid-rename → assert `OptimisticLockException`, full rollback of rename + propagation. - **A11c — propagation latency floor (Sara):** 200 blocks Testcontainers <2s. Floor not benchmark; merge blocked if regressed. - **`ErrorCode.PERSON_RENAME_CONFLICT` (Markus #5323):** `TranscriptionBlock` has `@Version` (line 54-57); concurrent autosave during rename → `OptimisticLockException` → whole rename rolls back. Frontend needs the specific code to render "another transcriber edited a referenced block — please retry" rather than generic 500. - **DTO `@Valid` cascade (Felix self-review + Nora):** `@Valid List<PersonMention> mentionedPersons` **on the field**, not just on the controller method. JSR-303 doesn't recurse into list elements without it. Add regression tests asserting **400 for `displayName` length 201** and **400 for `personId: null`** — fail loudly if anyone drops `@Valid`. - **`@RequirePermission(Permission.READ_ALL)` on `PersonController.getPerson()` and `getPersons()` (Nora #5326).** Currently only POST/PUT/DELETE are gated (PersonController.java:36-44). Defense in depth — required by the hover card and typeahead. - OpenAPI regen committed in PR-A (Tobias: chain PR-A → PR-B tightly; rebase PR-B on main immediately after PR-A merge to avoid `api.ts` drift from a third unrelated PR). **PR-B (Frontend, after PR-A merges):** - `escapeHtml` extraction in `mention.ts`. - `personMention.ts`: `detectPersonMention` (allow spaces; stop at `\n` or second `@`; test cases `@Auguste`, `@Auguste `, `@Auguste Raddatz`, `@M`, `@`, `@Aug\nfoo`, `@Aug@bar`); `renderTranscriptionBody` (longest-displayName-first, `<a href="/persons/{id}" class="person-mention">DisplayName</a>`, no `@` prefix in rendered text). - 3 Paraglide keys (de/en/es). - `.person-mention` CSS in `routes/layout.css`:313. - `PersonMentionEditor.svelte`: mirror `MentionEditor`, hits `/api/persons?q=`, dropdown shows name + life dates via **`formatLifeDateRange()` from `frontend/src/lib/utils/personLifeDates.ts`** (Leonie #5329 — don't reimplement). Row pattern: serif `text-base text-ink` for name, sans `text-xs text-ink-3` for dates, gap-1, ellipsis on overflow, **`min-h-[44px] px-3 py-2.5`** (Leonie — WCAG 2.2 AA touch target; current `MentionEditor:183-196` uses `px-3 py-2` ≈ 36px). - **B11b — keyboard nav test (Sara):** ArrowDown/ArrowUp cycle highlight, Enter selects, Escape closes without inserting. (Existing `MentionEditor.svelte:117-154` supports it; new editor must too.) - **B12 + B12b — save failure (Felix #5294 + Sara #5327):** preserves textarea text + `mentionedPersons`. On 409 Conflict (rename-mid-edit), client refetches block, merges latest sidecar/text from server, transcriber's unsaved input preserved. - Textarea swap in `TranscriptionBlock.svelte` lines 185–194; thread `mentionedPersons` through autosave. - `PersonHoverCard.svelte`: 3 states; **manual flip via `getBoundingClientRect()`** (default below-right; flip up <200px from bottom OR mention in bottom 30% of viewport; flip left <300px from right; Leonie test viewport 320×600: hover y=100→below-right; y=550→above-right); hidden via `@media (hover: none)`. **Skeleton spec (Leonie #5329):** match loaded-card footprint (~320×180px), 3 pulse-animated bars in `bg-muted`, respect `prefers-reduced-motion: reduce`. **`aria-describedby` on anchor → card content id** (or `aria-live="polite"` on card root) for SR announce-on-mount. - **B15.5 — fetch deduplication (Nora #5326):** per-page `Map<UUID, Promise<HoverData>>` cache. Cursor sweeping a paragraph with 20 mentions otherwise fires 40 backend calls/sec. - `TranscriptionReadView.svelte`: render via `renderTranscriptionBody`, **compose with existing `splitByMarkers`** — split into marker segments first, run mention substitution + `escapeHtml` on text segments only, then re-join. Add `eslint-disable-next-line svelte/no-at-html-tags` comment matching `CommentMessage.svelte:88-89` rationale (Felix self-review). - **B19b — marker composition test (Felix self-review + Sara):** text `"Hallo @Auguste Raddatz [unleserlich] Marie"` must render mention link AND `<em data-marker>` correctly with no double-escape and no marker text inside the anchor. - **B20/B21 tightened (Sara #5327):** specify `page.hover()`, `page.tap()`, `context.setHasTouch(true)`, exact `data-testid` selectors. - **B7 note (Felix self-review):** `Person.firstName` is nullable (Person.java:27); single-name persons (last-name-only) valid → first-sidecar-wins collisions become more likely. Mention in test description. **Felix's risks tracked:** 1. Marker composition ordering (covered by B19b). 2. `@ElementCollection`+`@Embeddable` is new ground (`UserGroup` only uses `Set<String>`). Repo test + listener integration tests are the safety net. 3. Event must fire after `personRepository.save` and within rename transaction; sanity-check assertion in integration test. **First custom domain event in this codebase (Markus #5323).** Only existing `@EventListener` is `OcrTrainingService.java:234` listening to Spring's `ApplicationReadyEvent`. Worth a package-info / migration comment — sets convention for future cross-domain decoupling. **CI delta estimate (Tobias):** backend +60s (Testcontainers), frontend +90s (vitest-browser). Within budget. Note in PR descriptions. --- ## E. Issue-body amendments still required (Elicit #5267 / #5332) Add to the issue body before implementation starts: - **NFR-PERF-001:** *"The person rename operation, including mention propagation across all transcription blocks, shall complete within 3 seconds for archives with up to 2,000 transcription blocks containing the renamed person, measured at the response of `PUT /api/persons/{id}`."* - **AC-PROP-04 (rename-mid-edit):** > **Given** transcriber A is editing a block referencing `@Auguste Raddatz`, > **When** an admin renames Auguste to Augusta in a separate session, > **And** transcriber A saves their pending edits, > **Then** the save returns 409 Conflict, the block reloads with the new sidecar/text from the server, and transcriber A's typed-but-unsaved input is preserved in the textarea. - Replace OQ-1 "ambiguous" label with deterministic rule (first-sidecar-wins). - Replace "card appears within 200ms" AC with "skeleton state appears within 200ms; loaded content populates when fetch resolves." - Document hover card three states (loading / 404 unmounted / non-404 error in card). --- ## F. Status **Open decisions:** none. Every persona's plan-review section closes with "_(none)_". **Blocking:** PR for #358 must merge to main before #362 work starts. **Ready to start once:** issue body amendments above are added, and #358 is merged. --- ## G. Update — 2026-04-28: PR-B split (post-review of PR-A #366) The original plan put the entire frontend (~24 tasks) into a single PR-B. PR-A's six-persona review (Tobias, Sara) recommended splitting frontend work in two for reviewability. Adopted: - **PR-B1 — Edit-mode infrastructure** - `escapeHtml` extraction in `mention.ts` (refactor; shared with B2) - `personMention.ts`: `detectPersonMention` (allow spaces; stop at `\n` or second `@`) + tests - `PersonMentionEditor.svelte` + keyboard-nav tests (mirror existing `MentionEditor` keyboard behaviour) - 3 Paraglide keys (de/en/es): `person_mention_open_link`, `person_mention_hover_hint`, `person_mention_load_error` - `formatLifeDateRange` reuse from `personLifeDates.ts` - Textarea swap in `TranscriptionBlock.svelte` lines 185–194; thread `mentionedPersons` through autosave - B11b keyboard-nav test - B12 + B12b save-failure tests - **PR-B2 — Read-mode rendering + hover card** (depends on PR-B1 being merged) - `renderTranscriptionBody` in `mention.ts` (longest-displayName-first, no `@` prefix in rendered text) - **Stored-XSS guard:** `displayName` flows from sidecar into `block.text` unescaped on the backend (PR-A) — **PR-B2 must HTML-escape `displayName` and any block-text segment on render** with explicit unit tests for `<script>`, `<img onerror>`, and HTML-entity-already-encoded payloads. Nora #1 / Leonie review (PR #366). - `.person-mention` global CSS in `routes/layout.css`:313 (underline resting state for WCAG AA, focus-visible ring) - `PersonHoverCard.svelte` with three states (skeleton ≤ 200ms, 404 unmounted, non-404 error in card) + `getBoundingClientRect()` flip rules - `TranscriptionReadView.svelte` integration with `splitByMarkers` + B19b marker-composition test - `aria-describedby` / `aria-live` on hover card - B15.5 fetch deduplication cache - B20/B21 Playwright hover/tap tests - `eslint-disable-next-line svelte/no-at-html-tags` on `{@html ...}` (mirror `CommentMessage.svelte:88-89` pattern) **Why split:** B1 is editor-side, fully testable in isolation, no read-path coupling. B2 requires B1's typeahead inserting sidecar entries before the read-path can render anything. Splitting halves each review's surface area, surfaces XSS-on-render concerns in their own commit boundary, and lets B1 land while B2's hover-card design iterates. **Per-PR review-cycle expectation:** 1 cycle each (no fixes anticipated for B1; B2 may require touch-ups once Leonie sees the hover card on a real device).
Author
Owner

PR-A (Backend) opened — #366

http://heim-nas:3005/marcel/familienarchiv/pulls/366

Implements the backend half per the locked plan in #362#issuecomment-5339. 21 atomic commits, 1446/1446 backend tests green.

Summary of what landed

Area Commit
V56 migration e833d1f7
PersonMention @Embeddable a6c8db22
PersonDisplayNameChangedEvent record b435fd69
TranscriptionBlock.mentionedPersons field + round-trip test 0f3e0003, 7805da52
DTO @Valid cascade + 400 tests for length / null 80ddfb47, 4e8df66a, 1db0f38f
PersonService.updatePerson publishes event + 3 publish/no-publish tests 08e79870, 28112e1d
PersonMentionPropagationListener + 5 tests (happy / no-op / partial / multi / orphan) 4d288589, a2c633c5, 29a1df5d, e94ffde0, e0212613, bd175321
ErrorCode.PERSON_RENAME_CONFLICT (backend + frontend mirror + de/en/es) 4bc4267e
Optimistic-lock translation + latency floor (200 blocks < 2 s) 404d874b, 221a6af8
PersonController @RequirePermission(READ_ALL) on the two GET endpoints 5ebe1f1a
frontend/src/lib/generated/api.ts regen 8b498665

Spec deviation

Per A15b: end-to-end JPA optimistic-lock race reproduction inside @DataJpaTest is impractical (no window for an external version bump within a single transaction's listener load + save). The translation logic is unit-tested in PersonServiceTest with a stubbed publisher; Hibernate's own suite covers the @Version mechanism. If a stronger e2e check is wanted later, a @SpringBootTest with explicit thread coordination is the right home.

Blocking on PR-A merging

PR-B (frontend, ~24 tasks) cannot start until #366 lands on main, because every PR-B task imports from the regenerated api.ts.

🤖 Generated with Claude Code

## PR-A (Backend) opened — #366 http://heim-nas:3005/marcel/familienarchiv/pulls/366 Implements the backend half per the locked plan in [#362#issuecomment-5339](http://heim-nas:3005/marcel/familienarchiv/issues/362#issuecomment-5339). 21 atomic commits, 1446/1446 backend tests green. ### Summary of what landed | Area | Commit | |---|---| | V56 migration | `e833d1f7` | | `PersonMention` `@Embeddable` | `a6c8db22` | | `PersonDisplayNameChangedEvent` record | `b435fd69` | | `TranscriptionBlock.mentionedPersons` field + round-trip test | `0f3e0003`, `7805da52` | | DTO `@Valid` cascade + 400 tests for length / null | `80ddfb47`, `4e8df66a`, `1db0f38f` | | `PersonService.updatePerson` publishes event + 3 publish/no-publish tests | `08e79870`, `28112e1d` | | `PersonMentionPropagationListener` + 5 tests (happy / no-op / partial / multi / orphan) | `4d288589`, `a2c633c5`, `29a1df5d`, `e94ffde0`, `e0212613`, `bd175321` | | `ErrorCode.PERSON_RENAME_CONFLICT` (backend + frontend mirror + de/en/es) | `4bc4267e` | | Optimistic-lock translation + latency floor (200 blocks < 2 s) | `404d874b`, `221a6af8` | | `PersonController` `@RequirePermission(READ_ALL)` on the two GET endpoints | `5ebe1f1a` | | `frontend/src/lib/generated/api.ts` regen | `8b498665` | ### Spec deviation Per A15b: end-to-end JPA optimistic-lock race reproduction inside `@DataJpaTest` is impractical (no window for an external version bump within a single transaction's listener load + save). The translation logic is unit-tested in `PersonServiceTest` with a stubbed publisher; Hibernate's own suite covers the `@Version` mechanism. If a stronger e2e check is wanted later, a `@SpringBootTest` with explicit thread coordination is the right home. ### Blocking on PR-A merging PR-B (frontend, ~24 tasks) cannot start until #366 lands on main, because every PR-B task imports from the regenerated `api.ts`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Author
Owner

PR-B1 (Frontend, edit-mode infrastructure) opened — #369

http://heim-nas:3005/marcel/familienarchiv/pulls/369

Implements the PR-B1 scope from the pinned summary (#5339 §G):

  • escapeHtml extraction in mention.ts (refactor; PR-B2 will reuse).
  • personMention.tsdetectPersonMention (multi-word; stops at \n or second @) + 12 unit tests.
  • PersonMentionEditor.svelte mirroring MentionEditor: /api/persons?q=, min-h-[44px] rows (WCAG 2.2 AA), life dates via formatLifeDateRange(), keyboard nav (B11b), 14 component tests.
  • 5 Paraglide keys (de/en/es) — the 3 mandated + popup_empty/btn_label.
  • Textarea swap in TranscriptionBlock.svelte lines 185–194 → <PersonMentionEditor>. Quote-selection still works via a captureTextarea callback exposing the underlying node.
  • useBlockAutoSave.saveFn widens to (blockId, text, mentionedPersons), threads mentionedPersons through autosave + flushOnUnload. Pending payload preserved across save failure for retry (B12, with regression test).
  • 409 rename-mid-edit (B12b) handled in routes/documents/[id]/+page.svelte via the new pure helper mergeBlockOnConflict() (6 unit tests).

PR-B2 (read-mode rendering, hover card, stored-XSS guard, Playwright hover/tap) follows next.

Next step: an automated /review-pr → /implement loop on #369 until all personas approve.

## PR-B1 (Frontend, edit-mode infrastructure) opened — #369 http://heim-nas:3005/marcel/familienarchiv/pulls/369 Implements the **PR-B1** scope from the pinned summary (#5339 §G): - `escapeHtml` extraction in `mention.ts` (refactor; PR-B2 will reuse). - `personMention.ts` — `detectPersonMention` (multi-word; stops at `\n` or second `@`) + 12 unit tests. - `PersonMentionEditor.svelte` mirroring `MentionEditor`: `/api/persons?q=`, `min-h-[44px]` rows (WCAG 2.2 AA), life dates via `formatLifeDateRange()`, keyboard nav (B11b), 14 component tests. - 5 Paraglide keys (de/en/es) — the 3 mandated + `popup_empty`/`btn_label`. - Textarea swap in `TranscriptionBlock.svelte` lines 185–194 → `<PersonMentionEditor>`. Quote-selection still works via a `captureTextarea` callback exposing the underlying node. - `useBlockAutoSave.saveFn` widens to `(blockId, text, mentionedPersons)`, threads `mentionedPersons` through autosave + `flushOnUnload`. Pending payload preserved across save failure for retry (B12, with regression test). - 409 rename-mid-edit (B12b) handled in `routes/documents/[id]/+page.svelte` via the new pure helper `mergeBlockOnConflict()` (6 unit tests). PR-B2 (read-mode rendering, hover card, stored-XSS guard, Playwright hover/tap) follows next. Next step: an automated `/review-pr → /implement` loop on #369 until all personas approve.
Author
Owner

PR-B2 (Frontend, read-mode + hover card) opened — #371

http://heim-nas:3005/marcel/familienarchiv/pulls/371

Implements the PR-B2 scope from the consolidated plan in #362#issuecomment-5339 (§G — split adopted post PR-A review).

Tasks delivered

  • B16 renderTranscriptionBody in mention.ts (longest-first, first-sidecar-wins, word boundary, @ stripped from rendered text)
  • B16b stored-XSS guard tests (<script>, <img onerror>, already-encoded entities, quote payloads)
  • B17 .person-mention global CSS in routes/layout.css (underline at rest WCAG AA, focus-visible ring)
  • B18 PersonHoverCard.svelte — three states (skeleton ≤200ms / error / loaded); navy header, family-only chips, 120-char notes excerpt
  • B18b aria-describedby (set on the anchor at hover) + aria-live="polite" on the card
  • B19 TranscriptionReadView composes splitByMarkers + renderTranscriptionBody; manual getBoundingClientRect() flip; eslint-disable mirrors CommentMessage.svelte:88-89
  • B19b marker composition test — Hallo @Auguste Raddatz [unleserlich] Marie renders link AND <em data-marker> correctly, no nesting, no double-escape
  • B15.5 per-page SvelteMap<personId, Promise<HoverData>> dedup cache
  • B20/B21 Playwright hover (desktop) + tap (Pixel 7 hasTouch=true) e2e tests
  • 404 path: anchor marked data-person-deleted="true", card unmounts, click suppressed (degrades to plain unlinked text)

Tests: 47 new tests (17 utility, 18 hover-card, 8 read-view component, 4 e2e). All passing.

Following the loop now: /review-pr/implement until all reviewer concerns are resolved.

## PR-B2 (Frontend, read-mode + hover card) opened — #371 http://heim-nas:3005/marcel/familienarchiv/pulls/371 Implements the **PR-B2** scope from the consolidated plan in [#362#issuecomment-5339](http://heim-nas:3005/marcel/familienarchiv/issues/362#issuecomment-5339) (§G — split adopted post PR-A review). **Tasks delivered** - B16 `renderTranscriptionBody` in `mention.ts` (longest-first, first-sidecar-wins, word boundary, `@` stripped from rendered text) - B16b stored-XSS guard tests (`<script>`, `<img onerror>`, already-encoded entities, quote payloads) - B17 `.person-mention` global CSS in `routes/layout.css` (underline at rest WCAG AA, focus-visible ring) - B18 `PersonHoverCard.svelte` — three states (skeleton ≤200ms / error / loaded); navy header, family-only chips, 120-char notes excerpt - B18b `aria-describedby` (set on the anchor at hover) + `aria-live="polite"` on the card - B19 `TranscriptionReadView` composes `splitByMarkers` + `renderTranscriptionBody`; manual `getBoundingClientRect()` flip; eslint-disable mirrors `CommentMessage.svelte:88-89` - B19b marker composition test — `Hallo @Auguste Raddatz [unleserlich] Marie` renders link AND `<em data-marker>` correctly, no nesting, no double-escape - B15.5 per-page `SvelteMap<personId, Promise<HoverData>>` dedup cache - B20/B21 Playwright hover (desktop) + tap (Pixel 7 `hasTouch=true`) e2e tests - 404 path: anchor marked `data-person-deleted="true"`, card unmounts, click suppressed (degrades to plain unlinked text) **Tests**: 47 new tests (17 utility, 18 hover-card, 8 read-view component, 4 e2e). All passing. Following the loop now: `/review-pr` → `/implement` until all reviewer concerns are resolved.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#362