# ADR-043 — Derived person life-events: on-read assembly strategy

**Status:** Proposed  
**Date:** 2026-06-13  
**Issue:** #776 — Timeline: derive person life-events (Geburt/Tod/Heirat)

---

## Context

The Zeitstrahl (family timeline) must surface births, deaths, and marriages alongside
manually curated `TimelineEvent` rows. This data already exists in the `Person` entity
(`birthDate`, `deathDate`, `birthDatePrecision`, `deathDatePrecision`) and in
`PersonRelationship` rows with `relationType = SPOUSE_OF`.

Three architectural decisions needed before implementation could start:

1. **Computation strategy:** should derived events be materialised to the `timeline_events`
   table, or assembled on every read from the source tables?
2. **Id format:** how do we give derived events stable, unambiguous ids that cannot collide
   with real `TimelineEvent` UUIDs and signal read-only semantics to consumers?
3. **Service contract:** where does the assembly method live, and what is its public API?

---

## Decision 1 — On-read assembly, never persisted

Derived events are computed on every call to `assembleDerivedEvents()` and are never written
to any table.

**Alternatives rejected:**

| Alternative | Reason rejected |
|-------------|-----------------|
| Materialise to `timeline_events` | Requires a synchronisation job or domain-event wiring every time a `Person` or `PersonRelationship` is mutated. Adds complexity, drift risk, and a write path for data that is fundamentally derived. |
| Separate `derived_events` table | Same sync problem; adds schema migration for data that is a pure projection. |
| Cache in-process | Adds invalidation complexity for MVP scale (tens to low hundreds of persons). Can be added later if `findAllFamilyMembers()` exceeds ~500 rows. |

**Consequences:**
- No schema changes. No Flyway migration.
- The method must be `@Transactional(readOnly = true)` to keep the Hibernate session open
  across the lazy-association reads that `buildMarriageEvents()` performs via JOIN FETCH.
- Every caller of `assembleDerivedEvents()` triggers two DB queries: one for family-member
  persons, one for spouse edges with JOIN FETCH. Acceptable at MVP scale.

---

## Decision 2 — Synthetic prefixed String ids

Derived events receive ids of the form `birth:{personId}`, `death:{personId}`,
`marriage:{relationshipId}`, where the suffix is the UUID of the source entity.

**Format rules:**
- `id` field on `TimelineEntryDTO` is typed `String`, NOT `UUID`.
- `UUID.fromString(derivedEvent.id())` always throws `IllegalArgumentException` — id is
  structurally non-UUID by construction.
- The `unique_spouse_pair` DB index (V55) is the authoritative dedup guard for marriages;
  the in-memory `Set<UUID>` used during assembly is a defensive assertion, not primary
  enforcement.

**Alternatives rejected:**

| Alternative | Reason rejected |
|-------------|-----------------|
| Random UUID for each call | Not stable across calls — consumers (frontend, #5 sort/bucket) could not use ids as stable keys. |
| UUID typed field with a sentinel namespace (RFC 4122 v5) | Requires hashing; still looks like a UUID and could be confused with real event ids by write endpoints. |
| Numeric sequence | No natural source sequence; would require a counter, adding state. |

**Consequences:**
- `TimelineEntryDTO.id` must be `String`. The existing `TimelineEventView.id` is `UUID` and
  serves a different purpose (CRUD admin view); it is not changed.
- Any write endpoint that accepts a timeline event id (`PUT`, `DELETE`) must reject ids that
  do not parse as `UUID` — enforced and tested in issue #5, not here.
- Ids are deterministic and stable for the lifetime of the source entity, enabling client-side
  caching and deduplication.

---

## Decision 3 — `assembleDerivedEvents()` as the public cross-issue contract

The assembly method lives on `TimelineService` as a `public` method. Issue #5 (the
`GET /api/timeline` endpoint) calls it directly on the injected `TimelineService` bean.

**Domain boundary rules enforced by this decision:**
- `TimelineService` reaches `Person` and `PersonRelationship` data **only through
  `PersonService.findAllFamilyMembers()` and `RelationshipService.findAllSpouseEdges()`**.
  It never injects `PersonRepository` or `PersonRelationshipRepository`.
- The three private builder methods (`buildBirthEvents`, `buildDeathEvents`,
  `buildMarriageEvents`) are implementation details; only `assembleDerivedEvents()` is public.
- **Authorization:** `assembleDerivedEvents()` performs no authorization check. The calling
  endpoint in #5 must enforce `READ_ALL` before invoking this method. Any future caller
  outside #5 must do the same — this obligation is documented in the Javadoc of the method.

**Alternatives rejected:**

| Alternative | Reason rejected |
|-------------|-----------------|
| Separate `DerivedEventService` | Adds a class for a cohesive set of methods that belong to the timeline domain. Timeline owns the DTO shape; splitting it out is premature. |
| Expose via `PersonService` | Person domain should not know about `TimelineEntryDTO`. Cross-cutting concern belongs in timeline. |

---

## Related decisions

- ADR-039 — Person life-dates stored as `LocalDate` + `DatePrecision` (the source data this
  issue reads)
- ADR-040 — Timeline domain data model (establishes the `timeline/` package and
  `TimelineEvent` entity this issue extends)
- ADR-036 — Responses as views, never raw entities (why `assembleDerivedEvents()` returns
  `List<TimelineEntryDTO>`, not raw `Person` or `PersonRelationship` entities)
