Files
familienarchiv/docs/adr/040-timeline-domain-data-model.md
Marcel bde1237358
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 4m53s
CI / OCR Service Tests (pull_request) Successful in 25s
CI / Backend Unit Tests (pull_request) Successful in 5m42s
CI / fail2ban Regex (pull_request) Successful in 54s
CI / Semgrep Security Scan (pull_request) Successful in 27s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m13s
CI / Unit & Component Tests (push) Successful in 4m59s
CI / OCR Service Tests (push) Successful in 29s
CI / Backend Unit Tests (push) Successful in 6m20s
CI / fail2ban Regex (push) Successful in 48s
CI / Semgrep Security Scan (push) Successful in 25s
CI / Compose Bucket Idempotency (push) Successful in 1m3s
docs(adr): record title-length guard obligation for #775
title is VARCHAR(255) with no planned service-level check; ADR-040's
consequences listed the optimistic-lock and forgery obligations for #775 but
not this one, so an over-long title would have surfaced as a raw
DataIntegrityViolationException -> HTTP 500. Mirrors GESCHICHTE_TITLE_TOO_LONG.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 00:47:53 +02:00

6.8 KiB

ADR-040 — Timeline domain data model

Status: Accepted Date: 2026-06-12 Issue: #774 (Zeitstrahl milestone, foundational)

Context

The Zeitstrahl (family timeline) needs a home for curated events — births, weddings, moves, and world-historical context a curator types in by hand, distinct from the OCR-derived Document letters. This ADR commits the new timeline domain's data model: the TimelineEvent entity, the EventType enum, a repository, and the V77 migration. No service, controller, or DTO ships here — those land in later issues.

A TimelineEvent carries the same date block as Document (eventDate + precision + eventDateEnd) so events and letters render through one path, but its audit footprint deliberately diverges (see below).

Decisions

1. New timeline domain package, separate from geschichte

Curated timeline events are their own concern, not a Lesereise/Geschichte subtype. The domain owns TimelineEvent, EventType, and TimelineEventRepository.

2. Responses (#775) will be views, not serialized entities

TimelineEvent has two LAZY ManyToMany collections (persons, documents) and open-in-view is false — exactly the shape that motivated ADR-036 for geschichte. #775 must assemble TimelineEventView/TimelineEventSummary inside the service transaction; a serialized entity is a 500 waiting to happen. Decided up front so it is not retrofitted later.

3. precision reuses document.DatePrecision — imported, not duplicated

The timeline package imports org.raddatz.familienarchiv.document.DatePrecision directly, the same cross-domain value-type sharing ADR-039 established for person. An enum has no behaviour and no persistence side effects, so the layering rule (services → own repository) does not govern it. The enum stays a verbatim mirror of the import normalizer's Precision values (ADR-025); changes must stay in sync with tools/import-normalizer/dates.py. Moving DatePrecision into a shared package is a wider refactor (touching Document, importing, person) and its own future ADR.

eventDate is NOT NULL — a curated event always has at least a year, so only OCR letters fall into the "Ohne Datum" bucket. The CHECK chk_timeline_event_precision (date_precision <> 'UNKNOWN') forbids exactly that one value. SEASON ("Sommer 1914") and APPROX ("ca. 1914") are explicitly legal — family memory is full of both, and the spec's rendering table covers them. Do not narrow the CHECK to an allow-list; an over-tight constraint would force curators to fake YEAR and render dishonest dates.

5. RANGE invariant is a strict biconditional at the DB, intentionally tighter than Document

chk_timeline_event_range enforces (date_precision = 'RANGE') = (event_date_end IS NOT NULL)eventDateEnd is non-null iff precision is RANGE, both directions. This is stricter than Document's open-ended ranges (which allow a null end on a RANGE) because a curated event always has a known, closed end when it spans a range — it is authored, not inferred. This divergence is deliberate: a future "bug fix" must not relax it to match Document.

6. Audit trail: @Version + NOT NULL createdBy/updatedBy, diverging from Document

Document has neither a version nor a creator. A curated entity edited by multiple curators warrants real protection, so TimelineEvent adds:

  • @Version Long version — optimistic locking for the multi-curator edit flow (#775). Object Long (not primitive) so it is null before first persist; Hibernate sets 0 on insert. The service must catch ObjectOptimisticLockingFailureException and translate it to DomainException.conflict(...). Without that translation a concurrent-write conflict surfaces as HTTP 500 with Hibernate internals in the body — information disclosure (CWE-209).
  • createdBy/updatedBy as bare UUID, NOT NULL, no FK to app_users (sidecar pattern, matching DocumentAnnotation/OcrJob; keeps timeline decoupled from user, avoids lazy-load surprises in the read-heavy assembly path). NOT NULL makes a curated event with no author impossible — an audit gap closed at the schema level. DocumentAnnotation.createdBy is nullable and has no updatedBy; the escalation here is deliberate because curated events are multi-author. Curator display names resolve through UserService at render time.

updatedBy is not advanced by @UpdateTimestamp — the service must set it from the session principal before every save(), or the timestamp moves while the "who" goes stale.

7. createdBy/updatedBy are server-populated only — never bound from client input

Both are set from the session principal in the service, never from a request body. Binding them from client input is an authorship-forgery / mass-assignment vector (CWE-639). #775's regression suite must include forgery cases on both write paths (POST body with createdBy, PUT body with updatedBy) — create and update are separate binding paths, so testing only one leaves half the vector open. The update test must assert updatedBy equals the second editor's UUID, not merely non-null.

8. EventType string values are a stable frontend styling contract

The Tailwind class map in the timeline Svelte components hard-codes PERSONAL (family accent) and HISTORICAL (muted world accent) as strings. There is no mapping layer — renaming either value requires a coordinated frontend change. Recorded here to prevent a silent regression.

9. Explicit @JoinTable on both ManyToMany fields

Without explicit @JoinTable(name, joinColumns, inverseJoinColumns), Hibernate's naming strategy could diverge from the V77 DDL's explicit table/column names. Explicit mapping guarantees alignment and makes future column renames a deliberate, visible change. All four FK columns are ON DELETE CASCADE: deleting a Person or Document drops the join row and leaves the event intact (V71/ADR-032 hardening — a person delete must never 500).

Consequences

  • V77 is forward-only; rollback is manual DDL (DROP TABLE the two join tables, then timeline_events). No rollback script, no rollback test.
  • The timeline → document.DatePrecision compile coupling is permanent until a shared-package refactor; precedent already exists (importing/DocumentImporter, person).
  • The service/controller/DTO layer (#775) inherits the view-assembly, optimistic-lock translation, forgery-guard, and permission obligations recorded above. It must also add a service-level title-length check (new ErrorCode, e.g. TIMELINE_TITLE_TOO_LONG, mirroring GESCHICHTE_TITLE_TOO_LONG) — title is VARCHAR(255), and without the guard an over-long title surfaces as a raw DataIntegrityViolationException → HTTP 500.