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>
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.
4. precision = UNKNOWN is forbidden; every other value is legal
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). ObjectLong(not primitive) so it isnullbefore first persist; Hibernate sets0on insert. The service must catchObjectOptimisticLockingFailureExceptionand translate it toDomainException.conflict(...). Without that translation a concurrent-write conflict surfaces as HTTP 500 with Hibernate internals in the body — information disclosure (CWE-209).createdBy/updatedByas bareUUID,NOT NULL, no FK toapp_users(sidecar pattern, matchingDocumentAnnotation/OcrJob; keepstimelinedecoupled fromuser, 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.createdByis nullable and has noupdatedBy; the escalation here is deliberate because curated events are multi-author. Curator display names resolve throughUserServiceat 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 TABLEthe two join tables, thentimeline_events). No rollback script, no rollback test. - The
timeline → document.DatePrecisioncompile 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, mirroringGESCHICHTE_TITLE_TOO_LONG) —titleisVARCHAR(255), and without the guard an over-long title surfaces as a rawDataIntegrityViolationException→ HTTP 500.