docs(adr): add ADR-040 timeline domain data model
Records the architectural commitment for the timeline domain: views-not- entities for issue 3 (ADR-036 rationale), DatePrecision import coupling (ADR-025), the UNKNOWN-forbidden / SEASON-APPROX-legal precision contract, the strict biconditional RANGE CHECK as a deliberate divergence from Document, the @Version + NOT NULL audit-trail decisions, the optimistic- lock-to-conflict translation contract (CWE-209), the server-populated-only createdBy/updatedBy forgery guard (CWE-639), and the EventType stable frontend styling contract. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
112
docs/adr/040-timeline-domain-data-model.md
Normal file
112
docs/adr/040-timeline-domain-data-model.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# 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 (issue 3) 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`.
|
||||
Issue 3 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 (issue 3).
|
||||
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). Issue 3'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 (issue 3) inherits the view-assembly, optimistic-lock
|
||||
translation, forgery-guard, and permission obligations recorded above.
|
||||
Reference in New Issue
Block a user