Timeline: TimelineEvent entity + Flyway migration #774

Open
opened 2026-06-07 19:28:44 +02:00 by marcel · 8 comments
Owner

Milestone: Zeitstrahl — Family Timeline
Spec: docs/superpowers/specs/2026-06-07-family-timeline-design.md § "Data model"
ADR: this issue ships ADR-035 (docs/architecture/decisions/035-timeline-domain-data-model.md). ADR-033 (tag-name-resolution-tolerates-case-collisions) and ADR-034 (ollama-production-deployment-and-keep-alive) already exist — 035 is next.

Context

Curated timeline events need a home. New timeline/ domain package, deliberately separate from the in-flight Lesereisen/Geschichte work (#750–753). This issue is the architectural commitment for the domain: entity + enum + repository + migration + ADR + doc updates. No service/controller/DTO here — those land in issues 3–5. Independently shippable.

Scope

TimelineEvent entity, mirroring the Document date block so events and letters share one date-handling path. Note the audit footprint intentionally diverges from Document (which has neither version nor a creator): a curated entity edited by multiple curators warrants optimistic locking + creator tracking. The word "mirror" applies to the date block only — do not assume full parity.

Entity fields

Field Type Notes
id UUID @GeneratedValue(GenerationType.UUID), @Schema(requiredMode = REQUIRED)
title String required, @Schema(requiredMode = REQUIRED)
type EventType enum PERSONAL, HISTORICAL. @Enumerated(STRING), @Column(nullable=false, length=16), @Schema(requiredMode = REQUIRED). Enum value names are a frontend styling hook (personal = family accent, historical = muted world accent) — keep them stable, no mapping layer. Renaming requires a coordinated frontend change (documented in ADR-035).
eventDate LocalDate required — most precise date known. @Schema(requiredMode = REQUIRED)
precision DatePrecision column name date_precision (avoid SQL reserved-ish precision). @Enumerated(STRING), @Column(name="date_precision", nullable=false, length=16), @Builder.Default DatePrecision.YEAR, @Schema(requiredMode = REQUIRED). Reuses org.raddatz.familienarchiv.document.DatePrecisionimport it, do not duplicate (one rendering path; the enum's javadoc forbids divergence from tools/import-normalizer/dates.py per ADR-025).
eventDateEnd LocalDate (nullable) non-null iff precision = RANGE (see CHECK below)
description String (nullable) @Column(columnDefinition = "TEXT"), matching Document.transcription/summary
persons ManyToMany Person who it involves. @Builder.Default new HashSet<>(), @BatchSize(size = 50), explicit @JoinTable(name = "timeline_event_persons", joinColumns = @JoinColumn(name = "timeline_event_id"), inverseJoinColumns = @JoinColumn(name = "person_id"))
documents ManyToMany Document optional linked supporting letters. @Builder.Default new HashSet<>(), @BatchSize(size = 50), explicit @JoinTable(name = "timeline_event_documents", joinColumns = @JoinColumn(name = "timeline_event_id"), inverseJoinColumns = @JoinColumn(name = "document_id"))
createdBy UUID bare UUID, @Column(name = "created_by", nullable = false)no FK to app_users (matches DocumentAnnotation/OcrJob/InviteToken sidecar pattern; keeps timeline decoupled from user). Server-populated from session principal in issue 3 — never accepted from client input.
createdAt LocalDateTime @CreationTimestamp (project idiom — never hand-set). LocalDateTime matches DocumentAnnotation precedent.
updatedBy UUID bare UUID, @Column(name = "updated_by", nullable = false) — same rationale as createdBy
updatedAt LocalDateTime @UpdateTimestamp
version Long @Version — optimistic locking for the multi-curator edit flow (issue 3). Long (object, not primitive) so it is null before first persist.

Resolved design decisions

  • ADR number is 035. ADR-033 (tag-name-resolution-tolerates-case-collisions) and ADR-034 (ollama-production-deployment-and-keep-alive) already exist. 035 is the next available number.
  • createdBy/updatedBy = bare UUID, NOT NULL. Rationale: matches the lighter sidecar-entity precedent (DocumentAnnotation), keeps timeline → user decoupled, avoids lazy-load surprises in the read-heavy assembly path. Must be nullable = false in entity and DDL — a curated event with no author is an audit gap. Curator display names resolved through UserService when rendered.
  • createdAt/updatedAt type = LocalDateTime. Rationale: matches DocumentAnnotation precedent; Instant would be a novel divergence requiring new serialization handling across the stack. Both use @CreationTimestamp/@UpdateTimestamp which work with LocalDateTime.
  • Explicit @JoinTable annotations on both ManyToMany fields. Rationale: without explicit @JoinTable(name, joinColumns, inverseJoinColumns), Hibernate's naming strategy may silently diverge from the Flyway DDL's explicit table/column names. Explicit mapping guarantees alignment and future column renames won't cause silent schema mismatches.
  • Keep @Version Long version. Rationale: a curated, multi-curator-editable entity benefits from real optimistic-lock protection; the divergence from Document (no version) is deliberate, not accidental. OptimisticLockingFailureException must be caught and translated to DomainException.conflict(...) in issue 3 — pre-noted in ADR-035.
  • RANGE invariant enforced at the DB (CHECK constraint), not service-only. Rationale: "push integrity to Postgres" — makes bad rows impossible at every write path. This intentionally diverges from Document's nullable-end open-ended ranges, because a curated event always has a closed, known end when it spans a range (it is authored, not OCR-inferred).
  • precision = UNKNOWN is forbidden for a curated event. Rationale: eventDate is required (an event always has at least a year); only letters fall into the "Ohne Datum" bucket. Enforced by the CHECK below.
  • DatePrecision stays in document/. Rationale: moving it is a wider refactor touching Document, importing, and the normalizer contract — out of scope and its own future ADR. The timeline → document compile coupling already has precedent (importing/DocumentImporter). ADR-035 explicitly records this coupling as intentional, citing ADR-025 and the normalizer contract as justification.
  • EventType string values are a stable frontend styling contract. Rationale: the Tailwind class map in future Svelte components will hard-code PERSONAL and HISTORICAL as strings. Renaming either value requires a coordinated frontend change — documented in ADR-035 to prevent silent regressions.

Tasks

  • timeline/TimelineEvent.java — standard Lombok entity conventions (@Entity @Table(name="timeline_events") @Data @NoArgsConstructor @AllArgsConstructor @Builder).
  • timeline/EventType.java enum (PERSONAL, HISTORICAL).
  • @Schema(requiredMode = REQUIRED) on id, title, type, eventDate, precision.
  • @Builder.Default new HashSet<>() on persons and documents; @BatchSize(size = 50) on both.
  • Explicit @JoinTable(name = "timeline_event_persons", joinColumns = @JoinColumn(name = "timeline_event_id"), inverseJoinColumns = @JoinColumn(name = "person_id")) on persons; symmetric @JoinTable for documents.
  • @Builder.Default DatePrecision.YEAR on precision; @Column(name = "date_precision", nullable = false, length = 16).
  • description as @Column(columnDefinition = "TEXT").
  • Audit fields: @Column(name = "created_by", nullable = false) UUID createdBy, @CreationTimestamp LocalDateTime createdAt, @Column(name = "updated_by", nullable = false) UUID updatedBy, @UpdateTimestamp LocalDateTime updatedAt, @Version Long version.
  • timeline/TimelineEventRepository.java (Spring Data JPA).
  • Flyway V72__add_timeline_events.sql (latest on disk is V71__person_delete_on_delete_fk.sql):
    • CREATE TABLE timeline_events (id, title, type, event_date, date_precision, event_date_end nullable, description TEXT nullable, created_by UUID NOT NULL, created_at, updated_by UUID NOT NULL, updated_at, version).
    • CREATE TABLE timeline_event_persons + CREATE TABLE timeline_event_documents join tables with explicit columns (timeline_event_id, person_id) / (timeline_event_id, document_id).
    • Forward-only, additive DDL only — no IF NOT EXISTS, no destructive statements.
    • FK ON DELETE CASCADE on both join tables — timeline_event_persons.person_id, timeline_event_persons.timeline_event_id, timeline_event_documents.document_id, timeline_event_documents.timeline_event_id. Deleting a Person/Document drops the join row; the event survives. (Matches V71/ADR-032 hardening; a person delete must never 500.)
    • CHECK constraint chk_timeline_event_range: (date_precision = 'RANGE') = (event_date_end IS NOT NULL) — enforces eventDateEnd-iff-RANGE.
    • CHECK constraint chk_timeline_event_precision: date_precision <> 'UNKNOWN' — curated events are never undated.
    • Indexes (add now, not as a follow-up — avoid the V62 FK-index retrofit debt): timeline_events(event_date), timeline_events(type), and explicit indexes on all four FK columns: idx_timeline_event_persons_person_id, idx_timeline_event_persons_event_id, idx_timeline_event_documents_document_id, idx_timeline_event_documents_event_id.
  • ADR-035docs/architecture/decisions/035-timeline-domain-data-model.md. Must record: (1) new timeline domain + data-model commitment; (2) version/audit/CHECK decisions; (3) timeline → document.DatePrecision compile coupling is intentional, citing ADR-025; (4) EventType values are a stable frontend styling contract — renaming requires a coordinated frontend change; (5) OptimisticLockingFailureException → DomainException.conflict(...) translation contract (implemented in issue 3); (6) createdBy/updatedBy NOT NULL enforces the audit trail.
  • Doc updates (PR blockers):
    • CLAUDE.md package table — add timeline/.
    • New docs/architecture/c4/l3-backend-timeline.puml.
    • docs/architecture/db/db-orm.puml and db-relationships.puml — new table + two join tables.
    • docs/GLOSSARY.mdTimelineEvent, EventType.

Forward notes (do NOT implement here — for issues 3/5)

  • Issue 3 TimelineEventRequest DTO must accept id lists for persons/documents and resolve them via PersonService/DocumentService — never bind client-supplied Person/Document objects (mass-assignment guard). The entity must not be exposed as a request body. createdBy/updatedBy are never accepted from client input — server-populated from the session principal.
  • Issue 3 service must catch ObjectOptimisticLockingFailureException and translate it to DomainException.conflict(...) — pre-noted in ADR-035 so the data-model ADR documents the full lifecycle of the version field.
  • Issue 3 endpoints: READ_ALL on reads, @RequirePermission(WRITE_ALL) on every write. Regression suite must include create/update/delete → 403 for READ_ALL-only and 401 unauthenticated.

Acceptance criteria

  • Entity persists; ManyToMany join tables to Person/Document work (round-trip membership).
  • App boots with the new migration on a clean DB — exercised by any Flyway-enabled @DataJpaTest running V72 (the migration test is the boot guarantee; already covered by MigrationIntegrationTest which runs all migrations in sequence — no new @SpringBootTest needed just to verify boot).
  • precision defaults to YEAR when not set via builder.
  • A TimelineEvent always has a non-null eventDate; eventDateEnd is non-null iff precision = RANGE (DB CHECK, both directions).
  • precision = UNKNOWN is rejected at the DB (CHECK).
  • Deleting a linked Person or Document removes the join row and leaves the event intact (FK ON DELETE CASCADE).
  • createdBy and updatedBy are NOT NULL in the DB — no event can be created without an audit trail.

Tests — @DataJpaTest, Testcontainers postgres:16-alpine (never H2 — house rule; H2 would hide enum-as-varchar, FK cascade, CHECK, and @Version)

Use a makeEvent(...) factory (sensible defaults: title, eventDate, YEAR precision); each test overrides only what it asserts. One logical assertion per test. TDD red-first order:

Tests #1–9: @DataJpaTest + @AutoConfigureTestDatabase(replace = NONE) + @Import(PostgresContainerConfig.class, FlywayConfig.class) — consistent with MigrationIntegrationTest's existing pattern.

  1. persists_and_loads_event_with_required_fields (assert id generated).
  2. precision_defaults_to_YEAR_when_not_set (build without precision).
  3. persists_event_with_linked_persons (save with Person, flush+clear, reload, assert membership).
  4. persists_event_with_linked_documents (same for Document).
  5. eventDateEnd_round_trips_null_for_non_range.
  6. eventDateEnd_round_trips_value_for_range.
  7. description_round_trips_null.
  8. description_round_trips_multi_kb_text (prove TEXT has no length cap — @Column(columnDefinition = "TEXT") overrides Hibernate's default VARCHAR(255)).
  9. range_invariant_rejects_non_null_end_without_range_precision (precision=YEAR + non-null end → DB rejects). @Transactional(propagation = NOT_SUPPORTED) — constraint violation invalidates the current transaction; NOT_SUPPORTED prevents poisoning subsequent tests.
  10. range_invariant_rejects_range_precision_without_end_date (precision=RANGE + null end → DB rejects). @Transactional(propagation = NOT_SUPPORTED).
  11. unknown_precision_is_rejected (CHECK). @Transactional(propagation = NOT_SUPPORTED).

Tests #12–13: @SpringBootTest(webEnvironment = NONE) + @Import(PostgresContainerConfig.class) — require PersonService/DocumentService full Spring context (matching PersonServiceIntegrationTest pattern).

  1. deleting_linked_person_keeps_event_and_drops_join_row (persist event+Person, delete Person via PersonService, assert event survives + join row gone — proves V72 cascade; permanent V71-class regression guard).
  2. deleting_linked_document_keeps_event_and_drops_join_row (symmetric).

Coverage gate: 88% branch (backend/CLAUDE.md, not the generic 80% floor) — the builder defaults and nullable handling are what JaCoCo measures here.

**Milestone:** Zeitstrahl — Family Timeline **Spec:** `docs/superpowers/specs/2026-06-07-family-timeline-design.md` § "Data model" **ADR:** this issue ships **ADR-035** (`docs/architecture/decisions/035-timeline-domain-data-model.md`). ADR-033 (`tag-name-resolution-tolerates-case-collisions`) and ADR-034 (`ollama-production-deployment-and-keep-alive`) already exist — 035 is next. ## Context Curated timeline events need a home. New `timeline/` domain package, deliberately separate from the in-flight Lesereisen/`Geschichte` work (#750–753). This issue is the architectural commitment for the domain: entity + enum + repository + migration + ADR + doc updates. No service/controller/DTO here — those land in issues 3–5. Independently shippable. ## Scope `TimelineEvent` entity, mirroring the `Document` **date block** so events and letters share one date-handling path. Note the audit footprint **intentionally diverges** from `Document` (which has neither `version` nor a creator): a curated entity edited by multiple curators warrants optimistic locking + creator tracking. The word "mirror" applies to the date block only — do not assume full parity. ### Entity fields | Field | Type | Notes | |---|---|---| | `id` | `UUID` | `@GeneratedValue(GenerationType.UUID)`, `@Schema(requiredMode = REQUIRED)` | | `title` | `String` | required, `@Schema(requiredMode = REQUIRED)` | | `type` | `EventType` enum | `PERSONAL`, `HISTORICAL`. `@Enumerated(STRING)`, `@Column(nullable=false, length=16)`, `@Schema(requiredMode = REQUIRED)`. **Enum value names are a frontend styling hook (personal = family accent, historical = muted world accent) — keep them stable, no mapping layer. Renaming requires a coordinated frontend change (documented in ADR-035).** | | `eventDate` | `LocalDate` | required — most precise date known. `@Schema(requiredMode = REQUIRED)` | | `precision` | `DatePrecision` | **column name `date_precision`** (avoid SQL reserved-ish `precision`). `@Enumerated(STRING)`, `@Column(name="date_precision", nullable=false, length=16)`, `@Builder.Default DatePrecision.YEAR`, `@Schema(requiredMode = REQUIRED)`. Reuses `org.raddatz.familienarchiv.document.DatePrecision` — **import it, do not duplicate** (one rendering path; the enum's javadoc forbids divergence from `tools/import-normalizer/dates.py` per ADR-025). | | `eventDateEnd` | `LocalDate` (nullable) | non-null **iff** `precision = RANGE` (see CHECK below) | | `description` | `String` (nullable) | `@Column(columnDefinition = "TEXT")`, matching `Document.transcription`/`summary` | | `persons` | ManyToMany `Person` | who it involves. `@Builder.Default new HashSet<>()`, `@BatchSize(size = 50)`, explicit `@JoinTable(name = "timeline_event_persons", joinColumns = @JoinColumn(name = "timeline_event_id"), inverseJoinColumns = @JoinColumn(name = "person_id"))` | | `documents` | ManyToMany `Document` | optional linked supporting letters. `@Builder.Default new HashSet<>()`, `@BatchSize(size = 50)`, explicit `@JoinTable(name = "timeline_event_documents", joinColumns = @JoinColumn(name = "timeline_event_id"), inverseJoinColumns = @JoinColumn(name = "document_id"))` | | `createdBy` | `UUID` | bare UUID, `@Column(name = "created_by", nullable = false)` — **no FK to `app_users`** (matches `DocumentAnnotation`/`OcrJob`/`InviteToken` sidecar pattern; keeps `timeline` decoupled from `user`). Server-populated from session principal in issue 3 — never accepted from client input. | | `createdAt` | `LocalDateTime` | `@CreationTimestamp` (project idiom — never hand-set). `LocalDateTime` matches `DocumentAnnotation` precedent. | | `updatedBy` | `UUID` | bare UUID, `@Column(name = "updated_by", nullable = false)` — same rationale as `createdBy` | | `updatedAt` | `LocalDateTime` | `@UpdateTimestamp` | | `version` | `Long` | `@Version` — optimistic locking for the multi-curator edit flow (issue 3). `Long` (object, not primitive) so it is `null` before first persist. | ### Resolved design decisions - **ADR number is 035.** ADR-033 (`tag-name-resolution-tolerates-case-collisions`) and ADR-034 (`ollama-production-deployment-and-keep-alive`) already exist. 035 is the next available number. - **`createdBy`/`updatedBy` = bare `UUID`, `NOT NULL`.** Rationale: matches the lighter sidecar-entity precedent (`DocumentAnnotation`), keeps `timeline → user` decoupled, avoids lazy-load surprises in the read-heavy assembly path. Must be `nullable = false` in entity and DDL — a curated event with no author is an audit gap. Curator display names resolved through `UserService` when rendered. - **`createdAt`/`updatedAt` type = `LocalDateTime`.** Rationale: matches `DocumentAnnotation` precedent; `Instant` would be a novel divergence requiring new serialization handling across the stack. Both use `@CreationTimestamp`/`@UpdateTimestamp` which work with `LocalDateTime`. - **Explicit `@JoinTable` annotations on both ManyToMany fields.** Rationale: without explicit `@JoinTable(name, joinColumns, inverseJoinColumns)`, Hibernate's naming strategy may silently diverge from the Flyway DDL's explicit table/column names. Explicit mapping guarantees alignment and future column renames won't cause silent schema mismatches. - **Keep `@Version Long version`.** Rationale: a curated, multi-curator-editable entity benefits from real optimistic-lock protection; the divergence from `Document` (no version) is deliberate, not accidental. `OptimisticLockingFailureException` must be caught and translated to `DomainException.conflict(...)` in issue 3 — pre-noted in ADR-035. - **RANGE invariant enforced at the DB (CHECK constraint), not service-only.** Rationale: "push integrity to Postgres" — makes bad rows impossible at every write path. This intentionally diverges from `Document`'s nullable-end open-ended ranges, because a *curated* event always has a closed, known end when it spans a range (it is authored, not OCR-inferred). - **`precision = UNKNOWN` is forbidden for a curated event.** Rationale: `eventDate` is required (an event always has at least a year); only *letters* fall into the "Ohne Datum" bucket. Enforced by the CHECK below. - **`DatePrecision` stays in `document/`.** Rationale: moving it is a wider refactor touching `Document`, `importing`, and the normalizer contract — out of scope and its own future ADR. The `timeline → document` compile coupling already has precedent (`importing/DocumentImporter`). ADR-035 explicitly records this coupling as intentional, citing ADR-025 and the normalizer contract as justification. - **`EventType` string values are a stable frontend styling contract.** Rationale: the Tailwind class map in future Svelte components will hard-code `PERSONAL` and `HISTORICAL` as strings. Renaming either value requires a coordinated frontend change — documented in ADR-035 to prevent silent regressions. ## Tasks - [ ] `timeline/TimelineEvent.java` — standard Lombok entity conventions (`@Entity @Table(name="timeline_events") @Data @NoArgsConstructor @AllArgsConstructor @Builder`). - [ ] `timeline/EventType.java` enum (`PERSONAL`, `HISTORICAL`). - [ ] `@Schema(requiredMode = REQUIRED)` on `id`, `title`, `type`, `eventDate`, `precision`. - [ ] `@Builder.Default new HashSet<>()` on `persons` and `documents`; `@BatchSize(size = 50)` on both. - [ ] Explicit `@JoinTable(name = "timeline_event_persons", joinColumns = @JoinColumn(name = "timeline_event_id"), inverseJoinColumns = @JoinColumn(name = "person_id"))` on `persons`; symmetric `@JoinTable` for `documents`. - [ ] `@Builder.Default DatePrecision.YEAR` on `precision`; `@Column(name = "date_precision", nullable = false, length = 16)`. - [ ] `description` as `@Column(columnDefinition = "TEXT")`. - [ ] Audit fields: `@Column(name = "created_by", nullable = false) UUID createdBy`, `@CreationTimestamp LocalDateTime createdAt`, `@Column(name = "updated_by", nullable = false) UUID updatedBy`, `@UpdateTimestamp LocalDateTime updatedAt`, `@Version Long version`. - [ ] `timeline/TimelineEventRepository.java` (Spring Data JPA). - [ ] **Flyway `V72__add_timeline_events.sql`** (latest on disk is `V71__person_delete_on_delete_fk.sql`): - [ ] `CREATE TABLE timeline_events` (id, title, type, event_date, date_precision, event_date_end nullable, description TEXT nullable, created_by UUID NOT NULL, created_at, updated_by UUID NOT NULL, updated_at, version). - [ ] `CREATE TABLE timeline_event_persons` + `CREATE TABLE timeline_event_documents` join tables with explicit columns `(timeline_event_id, person_id)` / `(timeline_event_id, document_id)`. - [ ] **Forward-only, additive DDL only** — no `IF NOT EXISTS`, no destructive statements. - [ ] **FK `ON DELETE CASCADE`** on both join tables — `timeline_event_persons.person_id`, `timeline_event_persons.timeline_event_id`, `timeline_event_documents.document_id`, `timeline_event_documents.timeline_event_id`. Deleting a Person/Document drops the join row; the event survives. (Matches V71/ADR-032 hardening; a person delete must never 500.) - [ ] **CHECK constraint** `chk_timeline_event_range`: `(date_precision = 'RANGE') = (event_date_end IS NOT NULL)` — enforces eventDateEnd-iff-RANGE. - [ ] **CHECK constraint** `chk_timeline_event_precision`: `date_precision <> 'UNKNOWN'` — curated events are never undated. - [ ] **Indexes (add now, not as a follow-up — avoid the V62 FK-index retrofit debt):** `timeline_events(event_date)`, `timeline_events(type)`, and explicit indexes on all four FK columns: `idx_timeline_event_persons_person_id`, `idx_timeline_event_persons_event_id`, `idx_timeline_event_documents_document_id`, `idx_timeline_event_documents_event_id`. - [ ] **ADR-035** — `docs/architecture/decisions/035-timeline-domain-data-model.md`. Must record: (1) new `timeline` domain + data-model commitment; (2) version/audit/CHECK decisions; (3) `timeline → document.DatePrecision` compile coupling is intentional, citing ADR-025; (4) `EventType` values are a stable frontend styling contract — renaming requires a coordinated frontend change; (5) `OptimisticLockingFailureException → DomainException.conflict(...)` translation contract (implemented in issue 3); (6) `createdBy`/`updatedBy` NOT NULL enforces the audit trail. - [ ] **Doc updates (PR blockers):** - [ ] `CLAUDE.md` package table — add `timeline/`. - [ ] New `docs/architecture/c4/l3-backend-timeline.puml`. - [ ] `docs/architecture/db/db-orm.puml` **and** `db-relationships.puml` — new table + two join tables. - [ ] `docs/GLOSSARY.md` — `TimelineEvent`, `EventType`. ## Forward notes (do NOT implement here — for issues 3/5) - Issue 3 `TimelineEventRequest` DTO must accept **id lists** for persons/documents and resolve them via `PersonService`/`DocumentService` — never bind client-supplied `Person`/`Document` objects (mass-assignment guard). The entity must not be exposed as a request body. `createdBy`/`updatedBy` are **never accepted from client input** — server-populated from the session principal. - Issue 3 service must catch `ObjectOptimisticLockingFailureException` and translate it to `DomainException.conflict(...)` — pre-noted in ADR-035 so the data-model ADR documents the full lifecycle of the version field. - Issue 3 endpoints: `READ_ALL` on reads, `@RequirePermission(WRITE_ALL)` on every write. Regression suite must include `create/update/delete → 403 for READ_ALL-only` and `401 unauthenticated`. ## Acceptance criteria - Entity persists; ManyToMany join tables to Person/Document work (round-trip membership). - App boots with the new migration on a clean DB — exercised by any Flyway-enabled `@DataJpaTest` running V72 (the migration test **is** the boot guarantee; already covered by `MigrationIntegrationTest` which runs all migrations in sequence — no new `@SpringBootTest` needed just to verify boot). - `precision` defaults to `YEAR` when not set via builder. - A `TimelineEvent` always has a non-null `eventDate`; `eventDateEnd` is non-null **iff** `precision = RANGE` (DB CHECK, both directions). - `precision = UNKNOWN` is rejected at the DB (CHECK). - Deleting a linked `Person` or `Document` removes the join row and leaves the event intact (FK `ON DELETE CASCADE`). - `createdBy` and `updatedBy` are NOT NULL in the DB — no event can be created without an audit trail. ## Tests — `@DataJpaTest`, Testcontainers `postgres:16-alpine` (never H2 — house rule; H2 would hide enum-as-varchar, FK cascade, CHECK, and `@Version`) Use a `makeEvent(...)` factory (sensible defaults: title, eventDate, YEAR precision); each test overrides only what it asserts. **One logical assertion per test.** TDD red-first order: **Tests #1–9: `@DataJpaTest` + `@AutoConfigureTestDatabase(replace = NONE)` + `@Import(PostgresContainerConfig.class, FlywayConfig.class)` — consistent with `MigrationIntegrationTest`'s existing pattern.** 1. `persists_and_loads_event_with_required_fields` (assert id generated). 2. `precision_defaults_to_YEAR_when_not_set` (build without precision). 3. `persists_event_with_linked_persons` (save with Person, flush+clear, reload, assert membership). 4. `persists_event_with_linked_documents` (same for Document). 5. `eventDateEnd_round_trips_null_for_non_range`. 6. `eventDateEnd_round_trips_value_for_range`. 7. `description_round_trips_null`. 8. `description_round_trips_multi_kb_text` (prove TEXT has no length cap — `@Column(columnDefinition = "TEXT")` overrides Hibernate's default VARCHAR(255)). 9. `range_invariant_rejects_non_null_end_without_range_precision` (precision=YEAR + non-null end → DB rejects). **`@Transactional(propagation = NOT_SUPPORTED)`** — constraint violation invalidates the current transaction; `NOT_SUPPORTED` prevents poisoning subsequent tests. 10. `range_invariant_rejects_range_precision_without_end_date` (precision=RANGE + null end → DB rejects). **`@Transactional(propagation = NOT_SUPPORTED)`**. 11. `unknown_precision_is_rejected` (CHECK). **`@Transactional(propagation = NOT_SUPPORTED)`**. **Tests #12–13: `@SpringBootTest(webEnvironment = NONE)` + `@Import(PostgresContainerConfig.class)` — require `PersonService`/`DocumentService` full Spring context (matching `PersonServiceIntegrationTest` pattern).** 12. `deleting_linked_person_keeps_event_and_drops_join_row` (persist event+Person, delete Person via `PersonService`, assert event survives + join row gone — proves V72 cascade; permanent V71-class regression guard). 13. `deleting_linked_document_keeps_event_and_drops_join_row` (symmetric). Coverage gate: **88% branch** (`backend/CLAUDE.md`, not the generic 80% floor) — the builder defaults and nullable handling are what JaCoCo measures here.
marcel added this to the Zeitstrahl — Family Timeline milestone 2026-06-07 19:28:44 +02:00
marcel added the P2-mediumfeature labels 2026-06-07 19:29:57 +02:00
Author
Owner

🏗️ Markus Keller — Application Architect

Observations

ADR path is wrong. The issue specifies docs/architecture/decisions/035-timeline-domain-data-model.md but that directory does not exist. All 34 existing ADRs live in docs/adr/. The correct path is docs/adr/035-timeline-domain-data-model.md. This is a concrete merge blocker — the PR will create the file in the wrong place if the implementation follows the issue text verbatim.

ADR content scope looks right. The six required ADR sections (domain commitment, version/audit/CHECK decisions, timeline → document.DatePrecision coupling, EventType contract, optimistic-lock translation contract, NOT NULL audit enforcement) are all load-bearing. None is cosmetic.

timeline → document.DatePrecision compile coupling is deliberate and well-reasoned. The issue correctly cites ADR-025 and the normalizer contract as justification. The javadoc on DatePrecision.java confirms the "no translation layer" rule. Importing across domain boundaries is acceptable here; the alternative (duplicating the enum) would silently break import idempotency.

DocumentAnnotation precedent for bare-UUID audit fields is accurately cited. I verified: DocumentAnnotation.createdBy is @Column(name = "created_by") UUID createdBy — no FK, nullable in the entity (but the entity has no updatedBy). The issue correctly escalates to NOT NULL for createdBy/updatedBy on the curated TimelineEvent because an event with no author is an audit gap. The reasoning holds.

@Version Long (object type) is the right call. Primitive long would never be null before first persist, which would confuse Hibernate's new-vs-managed detection. The issue already notes this.

CHECK constraint on (date_precision = 'RANGE') = (event_date_end IS NOT NULL) is strict by design. This is intentionally stricter than Document's check (which allows a RANGE with a null end). The issue explains the rationale (curated events always have a known end when spanning a range). The divergence is deliberate and must be recorded in the ADR.

Doc-update task list is complete. CLAUDE.md package table, C4 L3 backend diagram, both DB diagrams (main table + two join tables), GLOSSARY.md — all required by the documentation table in my persona. The issue lists them all. No gaps found.

Recommendations

  • Fix the ADR path in the task list before implementation: docs/adr/035-timeline-domain-data-model.md not docs/architecture/decisions/035-timeline-domain-data-model.md.
  • Confirm in the ADR that the stricter RANGE CHECK (biconditional vs. Document's open-ended) is intentional divergence — both for future readers and to prevent a "bug fix" that accidentally relaxes it.
  • The timeline/ package appears in neither CLAUDE.md nor the C4 L3 backend diagram yet; the doc-update tasks capture this correctly. Treat both as merge blockers (per the documentation table), not as nice-to-haves.
## 🏗️ Markus Keller — Application Architect ### Observations **ADR path is wrong.** The issue specifies `docs/architecture/decisions/035-timeline-domain-data-model.md` but that directory does not exist. All 34 existing ADRs live in `docs/adr/`. The correct path is `docs/adr/035-timeline-domain-data-model.md`. This is a concrete merge blocker — the PR will create the file in the wrong place if the implementation follows the issue text verbatim. **ADR content scope looks right.** The six required ADR sections (domain commitment, version/audit/CHECK decisions, `timeline → document.DatePrecision` coupling, `EventType` contract, optimistic-lock translation contract, `NOT NULL` audit enforcement) are all load-bearing. None is cosmetic. **`timeline → document.DatePrecision` compile coupling is deliberate and well-reasoned.** The issue correctly cites ADR-025 and the normalizer contract as justification. The javadoc on `DatePrecision.java` confirms the "no translation layer" rule. Importing across domain boundaries is acceptable here; the alternative (duplicating the enum) would silently break import idempotency. **`DocumentAnnotation` precedent for bare-UUID audit fields is accurately cited.** I verified: `DocumentAnnotation.createdBy` is `@Column(name = "created_by") UUID createdBy` — no FK, nullable in the entity (but the entity has no `updatedBy`). The issue correctly escalates to `NOT NULL` for `createdBy`/`updatedBy` on the curated `TimelineEvent` because an event with no author is an audit gap. The reasoning holds. **`@Version Long` (object type) is the right call.** Primitive `long` would never be `null` before first persist, which would confuse Hibernate's new-vs-managed detection. The issue already notes this. **CHECK constraint on `(date_precision = 'RANGE') = (event_date_end IS NOT NULL)` is strict by design.** This is intentionally stricter than `Document`'s check (which allows a RANGE with a null end). The issue explains the rationale (curated events always have a known end when spanning a range). The divergence is deliberate and must be recorded in the ADR. **Doc-update task list is complete.** CLAUDE.md package table, C4 L3 backend diagram, both DB diagrams (main table + two join tables), GLOSSARY.md — all required by the documentation table in my persona. The issue lists them all. No gaps found. ### Recommendations - Fix the ADR path in the task list before implementation: `docs/adr/035-timeline-domain-data-model.md` not `docs/architecture/decisions/035-timeline-domain-data-model.md`. - Confirm in the ADR that the stricter RANGE CHECK (biconditional vs. `Document`'s open-ended) is intentional divergence — both for future readers and to prevent a "bug fix" that accidentally relaxes it. - The `timeline/` package appears in neither CLAUDE.md nor the C4 L3 backend diagram yet; the doc-update tasks capture this correctly. Treat both as merge blockers (per the documentation table), not as nice-to-haves.
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Observations

Entity conventions are fully specified. @Data @NoArgsConstructor @AllArgsConstructor @Builder, @Builder.Default new HashSet<>() on both collections, @Schema(requiredMode = REQUIRED) on the five required fields — all consistent with the codebase pattern (Document, DocumentAnnotation, etc.). No deviations.

@BatchSize(size = 50) on both ManyToMany collections is correctly called out. Without it, loading a list of TimelineEvents with their persons/documents collections would N+1 at the join tables. The existing Document entity uses @BatchSize on its receivers and tags collections — verified in the entity source.

Explicit @JoinTable is necessary. Without it, Hibernate's implicit naming strategy may diverge from what Flyway wrote. The issue cites the correct rationale. Verified against existing join tables in the DB diagrams: document_tags, document_receivers all use explicit @JoinTable in their respective entities.

@Column(name = "date_precision", nullable = false, length = 16) column rename is critical. precision is a reserved keyword in SQL and in some JPA/Hibernate versions. The explicit name attribute prevents silent mapping issues. Good call.

@UpdateTimestamp and updatedBy will drift apart. @UpdateTimestamp fires on every Hibernate UPDATE. updatedBy is service-populated (issue 3). If issue 3 forgets to set updatedBy before saving, the timestamp advances but the "who" is stale. This is a data-quality risk pre-noted for issue 3, but worth noting here because the entity design creates the coupling. Consider adding a note in the entity Javadoc or in ADR-035.

Long version (object, not primitive) is correct. long would initialize to 0 on unpersisted instances, confusing Hibernate's dirty-tracking. Long initializes to null, which is Hibernate's signal for "unmanaged entity."

description @Column(columnDefinition = "TEXT") matches Document.transcription precedent. Verified in Document.javatranscription uses @Column(columnDefinition = "TEXT"). Consistent.

Recommendations

  • Add a Javadoc comment to TimelineEvent.updatedBy: "Populated from session principal in TimelineEventService; must be set before every save() call — @UpdateTimestamp on updatedAt does not set this automatically." This prevents the drift issue without a runtime guard.
  • In the TimelineEventRepository, consider adding findByPersonsContaining(Person p) now rather than deferring — it will be needed in issue 5 for the per-person filter. If deferred, add a // TODO: needed in issue 5 comment to the empty repository so the implementer knows where to start.
  • The makeEvent(...) factory pattern specified in the test section is consistent with project conventions (makeDocument(), makeUser() etc.). Follow it exactly — don't use Document.builder() directly in test bodies.
## 👨‍💻 Felix Brandt — Senior Fullstack Developer ### Observations **Entity conventions are fully specified.** `@Data @NoArgsConstructor @AllArgsConstructor @Builder`, `@Builder.Default new HashSet<>()` on both collections, `@Schema(requiredMode = REQUIRED)` on the five required fields — all consistent with the codebase pattern (`Document`, `DocumentAnnotation`, etc.). No deviations. **`@BatchSize(size = 50)` on both ManyToMany collections is correctly called out.** Without it, loading a list of `TimelineEvent`s with their `persons`/`documents` collections would N+1 at the join tables. The existing `Document` entity uses `@BatchSize` on its `receivers` and `tags` collections — verified in the entity source. **Explicit `@JoinTable` is necessary.** Without it, Hibernate's implicit naming strategy may diverge from what Flyway wrote. The issue cites the correct rationale. Verified against existing join tables in the DB diagrams: `document_tags`, `document_receivers` all use explicit `@JoinTable` in their respective entities. **`@Column(name = "date_precision", nullable = false, length = 16)` column rename is critical.** `precision` is a reserved keyword in SQL and in some JPA/Hibernate versions. The explicit `name` attribute prevents silent mapping issues. Good call. **`@UpdateTimestamp` and `updatedBy` will drift apart.** `@UpdateTimestamp` fires on every Hibernate `UPDATE`. `updatedBy` is service-populated (issue 3). If issue 3 forgets to set `updatedBy` before saving, the timestamp advances but the "who" is stale. This is a data-quality risk pre-noted for issue 3, but worth noting here because the entity design creates the coupling. Consider adding a note in the entity Javadoc or in ADR-035. **`Long version` (object, not primitive) is correct.** `long` would initialize to `0` on unpersisted instances, confusing Hibernate's dirty-tracking. `Long` initializes to `null`, which is Hibernate's signal for "unmanaged entity." **`description @Column(columnDefinition = "TEXT")` matches `Document.transcription` precedent.** Verified in `Document.java` — `transcription` uses `@Column(columnDefinition = "TEXT")`. Consistent. ### Recommendations - Add a Javadoc comment to `TimelineEvent.updatedBy`: "Populated from session principal in `TimelineEventService`; must be set before every `save()` call — `@UpdateTimestamp` on `updatedAt` does not set this automatically." This prevents the drift issue without a runtime guard. - In the `TimelineEventRepository`, consider adding `findByPersonsContaining(Person p)` now rather than deferring — it will be needed in issue 5 for the per-person filter. If deferred, add a `// TODO: needed in issue 5` comment to the empty repository so the implementer knows where to start. - The `makeEvent(...)` factory pattern specified in the test section is consistent with project conventions (`makeDocument()`, `makeUser()` etc.). Follow it exactly — don't use `Document.builder()` directly in test bodies.
Author
Owner

🔒 Nora "NullX" Steiner — Application Security Engineer

Observations

This issue is data-model-only — no controller, no service, no user-facing input. There are no active injection vectors, CORS surface, or mass-assignment risks in this PR scope. The security exposure lives in issues 3–5. That said, several design choices here have downstream security implications worth documenting now.

createdBy / updatedBy as bare UUID with NOT NULL is the right call. The "never accepted from client input" rule (documented in the issue as a Forward Note for issue 3) must be enforced at the service layer, not by the entity. The entity-level NOT NULL ensures no row can be created without an author, but nothing in this PR mechanically prevents a future developer from accepting createdBy from a request body. The ADR-035 entry documenting this constraint is the defense. Recommend the ADR explicitly name the threat: "accepting createdBy from client input is a privilege escalation — it allows an attacker to forge authorship."

@Version Long version introduces ObjectOptimisticLockingFailureException surface. The Forward Note for issue 3 correctly pre-notes that the service must catch ObjectOptimisticLockingFailureException and translate it to DomainException.conflict(...). If issue 3 misses this catch, the exception bubbles as a 500 with a Hibernate stack trace — information leakage. Recommend the ADR-035 entry explicitly state that the translation is mandatory and tested in issue 3's regression suite.

CHECK constraints as security controls. The chk_timeline_event_precision constraint (date_precision <> 'UNKNOWN') and the RANGE biconditional CHECK are structural integrity guarantees. They can't be bypassed by application bugs or missing service validation. This is the correct approach — "push integrity to Postgres."

No FK to app_users reduces attack surface. A createdBy FK would require the timeline package to join or load app_users data, expanding the access surface. The sidecar-entity pattern (bare UUID resolved through UserService only at render time) is correctly cited. This is a sound isolation decision.

@BatchSize(size = 50) is an N+1 mitigation, not a security control — no concern here.

Recommendations

  • ADR-035 should explicitly call out the mass-assignment guard requirement: "createdBy/updatedBy are server-populated only — never bind from request body. Accepting these fields from client input is a CWE-639 (IDOR) vector enabling authorship forgery."
  • Issue 3's regression suite must include: POST /api/timeline/events with a body containing createdBy → assert that the stored createdBy is the session principal's UUID, not the supplied value. This test belongs in the controller-level @WebMvcTest suite.
  • The OptimisticLockingFailureException → DomainException.conflict() translation must have a regression test in issue 3. Without it, a concurrent write conflict returns HTTP 500 with Hibernate internals in the response body — an information disclosure (CWE-209).
## 🔒 Nora "NullX" Steiner — Application Security Engineer ### Observations **This issue is data-model-only — no controller, no service, no user-facing input.** There are no active injection vectors, CORS surface, or mass-assignment risks in this PR scope. The security exposure lives in issues 3–5. That said, several design choices here have downstream security implications worth documenting now. **`createdBy` / `updatedBy` as bare UUID with `NOT NULL` is the right call.** The "never accepted from client input" rule (documented in the issue as a Forward Note for issue 3) must be enforced at the service layer, not by the entity. The entity-level `NOT NULL` ensures no row can be created without an author, but nothing in this PR mechanically prevents a future developer from accepting `createdBy` from a request body. The ADR-035 entry documenting this constraint is the defense. Recommend the ADR explicitly name the threat: "accepting `createdBy` from client input is a privilege escalation — it allows an attacker to forge authorship." **`@Version Long version` introduces `ObjectOptimisticLockingFailureException` surface.** The Forward Note for issue 3 correctly pre-notes that the service must catch `ObjectOptimisticLockingFailureException` and translate it to `DomainException.conflict(...)`. If issue 3 misses this catch, the exception bubbles as a 500 with a Hibernate stack trace — information leakage. Recommend the ADR-035 entry explicitly state that the translation is mandatory and tested in issue 3's regression suite. **CHECK constraints as security controls.** The `chk_timeline_event_precision` constraint (`date_precision <> 'UNKNOWN'`) and the RANGE biconditional CHECK are structural integrity guarantees. They can't be bypassed by application bugs or missing service validation. This is the correct approach — "push integrity to Postgres." **No FK to `app_users` reduces attack surface.** A `createdBy` FK would require the `timeline` package to join or load `app_users` data, expanding the access surface. The sidecar-entity pattern (bare UUID resolved through `UserService` only at render time) is correctly cited. This is a sound isolation decision. **`@BatchSize(size = 50)` is an N+1 mitigation, not a security control** — no concern here. ### Recommendations - ADR-035 should explicitly call out the mass-assignment guard requirement: "`createdBy`/`updatedBy` are server-populated only — never bind from request body. Accepting these fields from client input is a CWE-639 (IDOR) vector enabling authorship forgery." - Issue 3's regression suite must include: `POST /api/timeline/events` with a body containing `createdBy` → assert that the stored `createdBy` is the *session principal's* UUID, not the supplied value. This test belongs in the controller-level `@WebMvcTest` suite. - The `OptimisticLockingFailureException → DomainException.conflict()` translation must have a regression test in issue 3. Without it, a concurrent write conflict returns HTTP 500 with Hibernate internals in the response body — an information disclosure (CWE-209).
Author
Owner

🧪 Sara Holt — QA Engineer & Test Strategist

Observations

Test plan is well-structured and follows the existing MigrationIntegrationTest pattern exactly. I verified the pattern in the codebase: @DataJpaTest + @AutoConfigureTestDatabase(replace = NONE) + @Import({PostgresContainerConfig.class, FlywayConfig.class}) — this is exactly what MigrationIntegrationTest uses. The PersonServiceIntegrationTest pattern (@SpringBootTest(webEnvironment = NONE)) is also verified. Tests #12–13 are correctly using the heavier context because they need PersonService/DocumentService.

@Transactional(propagation = NOT_SUPPORTED) on constraint-violation tests is correct. Verified in MigrationIntegrationTest: v30_partialUniqueIndex_preventsTwoRunningTrainingRuns and v64_rejectsDuplicateGroupPermission both use NOT_SUPPORTED. The issue correctly applies this pattern to tests #9–11 (CHECK constraint violations).

88% branch coverage gate is the backend's actual gate. Confirmed in backend/CLAUDE.md: "Coverage gate: 88% branch coverage (JaCoCo)". The issue correctly quotes this — not the generic 80% from CLAUDE.md. Good spec precision.

makeEvent(...) factory with sensible defaults is the right pattern. Consistent with makeDocument(), makeUser() helpers found in the existing test suite. One-liner call overriding only what the test asserts.

One logical assertion per test is required — the issue states this explicitly. Each of the 13 tests asserts exactly one behavior. No compound assertions.

Tests #12–13 cascade tests need explicit join-row verification. The issue says "assert event survives + join row gone." The assertion for "join row gone" must query the join table directly via JdbcTemplate (like MigrationIntegrationTest does for constraint verification) or via the entity's collection after flush+clear. Using JPQL SELECT against timeline_event_persons via JdbcTemplate is the cleanest approach — consistent with the existing test pattern.

The description_round_trips_multi_kb_text test (test #8) is a quality call. Verifying that @Column(columnDefinition = "TEXT") actually bypasses Hibernate's default VARCHAR(255) is a real regression guard. V69 migration tests do similar things for meta_date_raw. Good pattern to follow.

Missing: a test that version starts as null on an unpersisted entity and increments after first save. The issue documents Long version is null before first persist — this is the whole reason for object vs primitive. A test version_is_null_before_persist_and_increments_after_save would permanently document this behavior and catch a refactor that switches to primitive long.

Recommendations

  • Add test: version_is_null_before_persist_and_increments_after_save — build an event (don't save), assert version is null; save + flush, assert version is 0 (or ≥ 0). This is a @DataJpaTest one-liner that locks the @Version Long behavior the issue explains.
  • Tests #12–13 join-row verification: use JdbcTemplate.queryForObject("SELECT COUNT(*) FROM timeline_event_persons WHERE timeline_event_id = ?", Integer.class, eventId) and assert == 0 after the cascade delete. The entity's persons collection alone won't prove the DB row is gone without hitting the DB.
  • Verify that tests #9–11 (NOT_SUPPORTED) clean up after themselves if they succeed in inserting a row before the constraint fires. Pattern from MigrationIntegrationTest: explicit DELETE in a finally block. For tests using raw JdbcTemplate, this matters; for @DataJpaTest with @Transactional, the rollback handles it — but NOT_SUPPORTED opts out of the transaction, so explicit cleanup is needed.
## 🧪 Sara Holt — QA Engineer & Test Strategist ### Observations **Test plan is well-structured and follows the existing `MigrationIntegrationTest` pattern exactly.** I verified the pattern in the codebase: `@DataJpaTest + @AutoConfigureTestDatabase(replace = NONE) + @Import({PostgresContainerConfig.class, FlywayConfig.class})` — this is exactly what `MigrationIntegrationTest` uses. The `PersonServiceIntegrationTest` pattern (`@SpringBootTest(webEnvironment = NONE)`) is also verified. Tests #12–13 are correctly using the heavier context because they need `PersonService`/`DocumentService`. **`@Transactional(propagation = NOT_SUPPORTED)` on constraint-violation tests is correct.** Verified in `MigrationIntegrationTest`: `v30_partialUniqueIndex_preventsTwoRunningTrainingRuns` and `v64_rejectsDuplicateGroupPermission` both use `NOT_SUPPORTED`. The issue correctly applies this pattern to tests #9–11 (CHECK constraint violations). **88% branch coverage gate is the backend's actual gate.** Confirmed in `backend/CLAUDE.md`: "Coverage gate: 88% branch coverage (JaCoCo)". The issue correctly quotes this — not the generic 80% from CLAUDE.md. Good spec precision. **`makeEvent(...)` factory with sensible defaults is the right pattern.** Consistent with `makeDocument()`, `makeUser()` helpers found in the existing test suite. One-liner call overriding only what the test asserts. **One logical assertion per test is required** — the issue states this explicitly. Each of the 13 tests asserts exactly one behavior. No compound assertions. **Tests #12–13 cascade tests need explicit join-row verification.** The issue says "assert event survives + join row gone." The assertion for "join row gone" must query the join table directly via `JdbcTemplate` (like `MigrationIntegrationTest` does for constraint verification) or via the entity's collection after flush+clear. Using `JPQL SELECT` against `timeline_event_persons` via `JdbcTemplate` is the cleanest approach — consistent with the existing test pattern. **The `description_round_trips_multi_kb_text` test (test #8) is a quality call.** Verifying that `@Column(columnDefinition = "TEXT")` actually bypasses Hibernate's default VARCHAR(255) is a real regression guard. V69 migration tests do similar things for `meta_date_raw`. Good pattern to follow. **Missing: a test that `version` starts as `null` on an unpersisted entity and increments after first save.** The issue documents `Long version` is `null` before first persist — this is the whole reason for object vs primitive. A test `version_is_null_before_persist_and_increments_after_save` would permanently document this behavior and catch a refactor that switches to primitive `long`. ### Recommendations - Add test: `version_is_null_before_persist_and_increments_after_save` — build an event (don't save), assert `version` is null; save + flush, assert `version` is 0 (or ≥ 0). This is a `@DataJpaTest` one-liner that locks the `@Version Long` behavior the issue explains. - Tests #12–13 join-row verification: use `JdbcTemplate.queryForObject("SELECT COUNT(*) FROM timeline_event_persons WHERE timeline_event_id = ?", Integer.class, eventId)` and assert `== 0` after the cascade delete. The entity's `persons` collection alone won't prove the DB row is gone without hitting the DB. - Verify that tests #9–11 (NOT_SUPPORTED) clean up after themselves if they succeed in inserting a row before the constraint fires. Pattern from `MigrationIntegrationTest`: explicit DELETE in a finally block. For tests using raw `JdbcTemplate`, this matters; for `@DataJpaTest` with `@Transactional`, the rollback handles it — but `NOT_SUPPORTED` opts out of the transaction, so explicit cleanup is needed.
Author
Owner

🚀 Tobias Wendt — DevOps & Platform Engineer

Observations

This is a pure backend data-model issue — no infrastructure changes, no Docker Compose changes, no new services. From an infra/CI perspective, V72 is additive DDL (no IF NOT EXISTS, no destructive statements) which is the correct approach for Flyway on a live database.

Flyway migration V72 is forward-only additive. The issue explicitly calls this out: "Forward-only, additive DDL only — no IF NOT EXISTS, no destructive statements." This is correct for a Flyway migration that will run against a production database. A DROP or ALTER COLUMN in this migration would be a production incident waiting to happen.

The Testcontainers postgres:16-alpine pin is consistent with CI. The project already pins to postgres:16-alpine everywhere (verified in MigrationIntegrationTest, PostgresContainerConfig). V72 will be exercised by the existing MigrationIntegrationTest on every CI run — the migration boot guarantee is already in place.

Index coverage is comprehensive and called out explicitly. The issue lists 7 explicit indexes: timeline_events(event_date), timeline_events(type), and FK column indexes on all four join-table FK columns. The issue explicitly references the "V62 FK-index retrofit debt" as the reason to add these now. I verified: V62 was indeed a retrofit migration that added indexes missed at table creation. Correct lesson applied.

The ADR path issue (flagged by Markus) would not break CI — it would just create the file in the wrong directory. The Flyway migration itself has no dependency on the ADR path.

No new Docker service, no new compose configuration. The timeline domain is pure PostgreSQL tables — no new infrastructure dependencies whatsoever.

Recommendations

  • No infrastructure changes needed for this issue. The existing CI pipeline (Testcontainers, JaCoCo gate at 88% branch) already covers this.
  • When V72 is written, run ./mvnw test -Dtest=MigrationIntegrationTest locally before pushing to verify the migration runs clean against a fresh Postgres container. This is the cheapest pre-push verification available.
  • The 88% branch coverage gate applies to the new TimelineEvent.java and EventType.java classes. JaCoCo will count the @Builder.Default field initializers and @Enumerated paths as branches. The 13 tests in the issue are designed to hit all of them — no concern there.
## 🚀 Tobias Wendt — DevOps & Platform Engineer ### Observations **This is a pure backend data-model issue — no infrastructure changes, no Docker Compose changes, no new services.** From an infra/CI perspective, V72 is additive DDL (no `IF NOT EXISTS`, no destructive statements) which is the correct approach for Flyway on a live database. **Flyway migration V72 is forward-only additive.** The issue explicitly calls this out: "Forward-only, additive DDL only — no `IF NOT EXISTS`, no destructive statements." This is correct for a Flyway migration that will run against a production database. A `DROP` or `ALTER COLUMN` in this migration would be a production incident waiting to happen. **The Testcontainers `postgres:16-alpine` pin is consistent with CI.** The project already pins to `postgres:16-alpine` everywhere (verified in `MigrationIntegrationTest`, `PostgresContainerConfig`). V72 will be exercised by the existing `MigrationIntegrationTest` on every CI run — the migration boot guarantee is already in place. **Index coverage is comprehensive and called out explicitly.** The issue lists 7 explicit indexes: `timeline_events(event_date)`, `timeline_events(type)`, and FK column indexes on all four join-table FK columns. The issue explicitly references the "V62 FK-index retrofit debt" as the reason to add these now. I verified: V62 was indeed a retrofit migration that added indexes missed at table creation. Correct lesson applied. **The ADR path issue (flagged by Markus) would not break CI** — it would just create the file in the wrong directory. The Flyway migration itself has no dependency on the ADR path. **No new Docker service, no new compose configuration.** The `timeline` domain is pure PostgreSQL tables — no new infrastructure dependencies whatsoever. ### Recommendations - No infrastructure changes needed for this issue. The existing CI pipeline (Testcontainers, JaCoCo gate at 88% branch) already covers this. - When V72 is written, run `./mvnw test -Dtest=MigrationIntegrationTest` locally before pushing to verify the migration runs clean against a fresh Postgres container. This is the cheapest pre-push verification available. - The 88% branch coverage gate applies to the new `TimelineEvent.java` and `EventType.java` classes. JaCoCo will count the `@Builder.Default` field initializers and `@Enumerated` paths as branches. The 13 tests in the issue are designed to hit all of them — no concern there.
Author
Owner

📋 Elicit — Requirements Engineer

Observations

Scope boundary is clean and well-enforced. The issue declares "No service/controller/DTO here — those land in issues 3–5" and the Forward Notes section explicitly defers TimelineEventRequest, createdBy population, and OptimisticLockingFailureException translation. The acceptance criteria are scoped to what this PR actually delivers: entity persistence, migration, cascade behavior, and CHECK constraints.

Acceptance criteria are measurable and testable. Each criterion maps to at least one numbered test case. No criterion is vague ("fast," "reliable," "clean"). The biconditional RANGE/end-date constraint is specified precisely enough that two separate test cases (#9 and #10) cover both directions.

The precision = UNKNOWN forbidden rule has a clear rationale. "An event always has at least a year; only letters fall into the 'Ohne Datum' bucket." This is domain knowledge encoded as a DB constraint. No ambiguity.

Forward notes are correctly deferred. The mass-assignment guard, optimistic-lock translation, and permission gating are all documented as "do NOT implement here — for issues 3/5." This is good scope hygiene. The risk is that implementers of issues 3–5 miss these notes — consider making them explicit tasks in those issue bodies when they're created.

One gap in the acceptance criteria: there is no AC for "the migration is reversible or safe to roll back." The issue says "forward-only, additive DDL only" but the acceptance criteria don't test rollback behavior. This is probably intentional (Flyway migrations are forward-only in this project) but could be made explicit: "Non-goal: migration rollback. V72 is forward-only; rollback requires manual DDL."

The issue number sequencing claim needs verification. The issue states "ADR-033 and ADR-034 already exist — 035 is next." Verified: docs/adr/033-tag-name-resolution-tolerates-case-collisions.md and docs/adr/034-ollama-production-deployment-and-keep-alive.md both exist. ADR-035 is correctly the next number.

EventType as a frontend styling contract is a non-functional requirement that is functionally enforced. The issue correctly documents that PERSONAL and HISTORICAL are hardcoded into the future Svelte class map. Renaming requires a coordinated frontend change. This is an NFR with implementation consequences — the ADR is the right place to document it.

Recommendations

  • Add a Non-Goal to the acceptance criteria: "Migration rollback is out of scope; V72 is forward-only. A future rollback requires manual DROP TABLE DDL." This prevents confusion during incident response.
  • When creating issues 3–5, copy the Forward Notes from this issue into the respective issue bodies as explicit tasks, not just references. Forward notes in issue 2 are invisible to implementers who only read issue 4.
  • The "independently shippable" claim is accurate for CI purposes (migration runs, entity persists, tests pass). However, this issue delivers zero user-visible value on its own — it's a prerequisite. Label it as such in the milestone tracking so it doesn't sit in "ready for demo" state.
## 📋 Elicit — Requirements Engineer ### Observations **Scope boundary is clean and well-enforced.** The issue declares "No service/controller/DTO here — those land in issues 3–5" and the Forward Notes section explicitly defers `TimelineEventRequest`, `createdBy` population, and `OptimisticLockingFailureException` translation. The acceptance criteria are scoped to what this PR actually delivers: entity persistence, migration, cascade behavior, and CHECK constraints. **Acceptance criteria are measurable and testable.** Each criterion maps to at least one numbered test case. No criterion is vague ("fast," "reliable," "clean"). The biconditional RANGE/end-date constraint is specified precisely enough that two separate test cases (#9 and #10) cover both directions. **The `precision = UNKNOWN` forbidden rule has a clear rationale.** "An event always has at least a year; only letters fall into the 'Ohne Datum' bucket." This is domain knowledge encoded as a DB constraint. No ambiguity. **Forward notes are correctly deferred.** The mass-assignment guard, optimistic-lock translation, and permission gating are all documented as "do NOT implement here — for issues 3/5." This is good scope hygiene. The risk is that implementers of issues 3–5 miss these notes — consider making them explicit tasks in those issue bodies when they're created. **One gap in the acceptance criteria:** there is no AC for "the migration is reversible or safe to roll back." The issue says "forward-only, additive DDL only" but the acceptance criteria don't test rollback behavior. This is probably intentional (Flyway migrations are forward-only in this project) but could be made explicit: "Non-goal: migration rollback. V72 is forward-only; rollback requires manual DDL." **The issue number sequencing claim needs verification.** The issue states "ADR-033 and ADR-034 already exist — 035 is next." Verified: `docs/adr/033-tag-name-resolution-tolerates-case-collisions.md` and `docs/adr/034-ollama-production-deployment-and-keep-alive.md` both exist. ADR-035 is correctly the next number. **`EventType` as a frontend styling contract is a non-functional requirement that is functionally enforced.** The issue correctly documents that `PERSONAL` and `HISTORICAL` are hardcoded into the future Svelte class map. Renaming requires a coordinated frontend change. This is an NFR with implementation consequences — the ADR is the right place to document it. ### Recommendations - Add a Non-Goal to the acceptance criteria: "Migration rollback is out of scope; V72 is forward-only. A future rollback requires manual `DROP TABLE` DDL." This prevents confusion during incident response. - When creating issues 3–5, copy the Forward Notes from this issue into the respective issue bodies as explicit tasks, not just references. Forward notes in issue 2 are invisible to implementers who only read issue 4. - The "independently shippable" claim is accurate for CI purposes (migration runs, entity persists, tests pass). However, this issue delivers zero user-visible value on its own — it's a prerequisite. Label it as such in the milestone tracking so it doesn't sit in "ready for demo" state.
Author
Owner

🎨 Leonie Voss — UX Designer & Accessibility Strategist

Observations

This is a pure backend data-model issue — no UI, no frontend component, no i18n keys in scope. My perspective is largely "no concerns" for this PR, but a few design decisions here have downstream UX consequences worth flagging now.

EventType values (PERSONAL, HISTORICAL) are a styling contract. The issue documents that future Svelte components will hard-code these strings in a Tailwind class map (e.g. PERSONAL → family accent color, HISTORICAL → muted world accent). From a UX perspective, this is the right abstraction — the two types have meaningfully different visual identities (one foregrounds the family, one contextualizes them in the world). The constraint that renaming these values requires a coordinated frontend change is sound.

precision defaulting to YEAR is the correct UX default. Most curated events will be year-precision (wars, moves, marriages by year). A default of DAY would produce spurious "28. Juli" labels for events where only the year is known. YEAR renders as "1923" — honest and unambiguous.

description as nullable TEXT is right for MVP. Not every curated event needs a narrative — a birth or a war is self-explaining. Optional freetext with no length cap leaves room for rich detail without forcing it.

The eventDate representation for year-only events (e.g. 1920-01-01 with precision=YEAR) is UX-critical. The spec confirms this pattern ("vague year → 1920-01-01"). Future rendering components must use DatePrecision to decide what to show — never display "1. Januar 1920" when precision is YEAR. This rendering rule lives in issue 6 (dateLabel.ts), not here, but the data model enables it correctly.

"Ohne Datum" bucket for UNKNOWN precision is correctly forbidden for curated events at the DB level. From a UX standpoint, this is excellent: curated events are author-controlled, so "we don't know when this happened" is never acceptable for a hand-written event. The UNKNOWN bucket exists only for OCR-inferred letter dates. The DB CHECK enforcement means no UI validation bug can accidentally create an undated curated event.

Recommendations

  • No UI concerns for this PR. The data model supports the design spec's needs correctly.
  • When issue 6 (dateLabel.ts) is implemented, note that the 1920-01-01 representation must never leak to the UI for YEAR-precision events — the helper must strip the month/day entirely. The spec's rendering table is clear on this, but it's easy to forget when writing the formatter.
  • Consider requesting that issue 9 (curator event forms) include a precision selector with a sensible default (YEAR) and a contextual hint explaining what each precision level means (e.g. "Nur das Jahr ist bekannt"). This is not blocking for this PR — flagging it now saves a UX review round on issue 9.
## 🎨 Leonie Voss — UX Designer & Accessibility Strategist ### Observations **This is a pure backend data-model issue — no UI, no frontend component, no i18n keys in scope.** My perspective is largely "no concerns" for this PR, but a few design decisions here have downstream UX consequences worth flagging now. **`EventType` values (`PERSONAL`, `HISTORICAL`) are a styling contract.** The issue documents that future Svelte components will hard-code these strings in a Tailwind class map (e.g. `PERSONAL → family accent color, HISTORICAL → muted world accent`). From a UX perspective, this is the right abstraction — the two types have meaningfully different visual identities (one foregrounds the family, one contextualizes them in the world). The constraint that renaming these values requires a coordinated frontend change is sound. **`precision` defaulting to `YEAR` is the correct UX default.** Most curated events will be year-precision (wars, moves, marriages by year). A default of `DAY` would produce spurious "28. Juli" labels for events where only the year is known. `YEAR` renders as "1923" — honest and unambiguous. **`description` as nullable TEXT is right for MVP.** Not every curated event needs a narrative — a birth or a war is self-explaining. Optional freetext with no length cap leaves room for rich detail without forcing it. **The `eventDate` representation for year-only events (e.g. `1920-01-01` with `precision=YEAR`) is UX-critical.** The spec confirms this pattern ("vague year → `1920-01-01`"). Future rendering components must use `DatePrecision` to decide what to show — never display "1. Januar 1920" when precision is `YEAR`. This rendering rule lives in issue 6 (`dateLabel.ts`), not here, but the data model enables it correctly. **"Ohne Datum" bucket for `UNKNOWN` precision is correctly forbidden for curated events at the DB level.** From a UX standpoint, this is excellent: curated events are author-controlled, so "we don't know when this happened" is never acceptable for a hand-written event. The `UNKNOWN` bucket exists only for OCR-inferred letter dates. The DB CHECK enforcement means no UI validation bug can accidentally create an undated curated event. ### Recommendations - No UI concerns for this PR. The data model supports the design spec's needs correctly. - When issue 6 (`dateLabel.ts`) is implemented, note that the `1920-01-01` representation must never leak to the UI for `YEAR`-precision events — the helper must strip the month/day entirely. The spec's rendering table is clear on this, but it's easy to forget when writing the formatter. - Consider requesting that issue 9 (curator event forms) include a precision selector with a sensible default (`YEAR`) and a contextual hint explaining what each precision level means (e.g. "Nur das Jahr ist bekannt"). This is not blocking for this PR — flagging it now saves a UX review round on issue 9.
Author
Owner

🗳️ Decision Queue — Action Required

2 decisions need your input before implementation starts.

Architecture

  • ADR file path must be corrected before implementation — The task list specifies docs/architecture/decisions/035-timeline-domain-data-model.md but that directory does not exist in the repo. All 34 existing ADRs live in docs/adr/. The correct path is docs/adr/035-timeline-domain-data-model.md. If the implementation follows the issue text verbatim, the ADR lands in the wrong place and the documentation table in CLAUDE.md becomes out of sync. Fix the path in the issue body before starting. (Raised by: Markus)

Testing

  • Add version_is_null_before_persist_and_increments_after_save test — The issue specifies @Version Long version (object type) precisely because null signals "unmanaged entity" to Hibernate. There is no test in the 13-test plan that verifies this behavior. This is a @DataJpaTest one-liner that permanently locks the Long vs long decision and would catch a future refactor that switches to primitive. Decide: add it to the task list now, or accept the gap and note it as a follow-up. (Raised by: Sara)

All other findings are concrete recommendations with no blocking decision required.

## 🗳️ Decision Queue — Action Required _2 decisions need your input before implementation starts._ ### Architecture - **ADR file path must be corrected before implementation** — The task list specifies `docs/architecture/decisions/035-timeline-domain-data-model.md` but that directory does not exist in the repo. All 34 existing ADRs live in `docs/adr/`. The correct path is `docs/adr/035-timeline-domain-data-model.md`. If the implementation follows the issue text verbatim, the ADR lands in the wrong place and the documentation table in CLAUDE.md becomes out of sync. Fix the path in the issue body before starting. _(Raised by: Markus)_ ### Testing - **Add `version_is_null_before_persist_and_increments_after_save` test** — The issue specifies `@Version Long version` (object type) precisely because `null` signals "unmanaged entity" to Hibernate. There is no test in the 13-test plan that verifies this behavior. This is a `@DataJpaTest` one-liner that permanently locks the `Long` vs `long` decision and would catch a future refactor that switches to primitive. Decide: add it to the task list now, or accept the gap and note it as a follow-up. _(Raised by: Sara)_ --- _All other findings are concrete recommendations with no blocking decision required._
Sign in to join this conversation.
No Label P2-medium feature
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#774