Timeline: derive person life-events (Geburt/Tod/Heirat) #776
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Milestone: Zeitstrahl — Family Timeline
Spec:
docs/superpowers/specs/2026-06-07-family-timeline-design.md§ "Derived person-events"Depends on:
Person.birthYear/deathYearwithPerson.birthDate/birthDatePrecision+deathDate/deathDatePrecision). Confirmed:Person.javastill hasprivate Integer birthYear; private Integer deathYear;— this issue is un-startable until #1 lands.TimelineEvententity and the curated-event DTO (TimelineEntryDTO) this issue reuses. Thetimeline/package does not exist yet; #2 owns its creation, the package/DB-diagram/CLAUDE.mddoc updates, and the DTO shape. This issue must NOT carry that doc work. Note: this issue (#4) adds thederivedTypefield toTimelineEntryDTO— see Tasks below.Context
Births, deaths, and marriages are surfaced automatically from already-curated Person + relationship data — no manual entry, no auto-extraction from transcriptions. They are computed on read, never stored. This issue delivers the in-process assembly layer only (the
GET /api/timelineendpoint, ordering, and bucketing are issue #5). The output of this layer is an unordered set of derived events; #5 owns sort/bucket/year-band ordering.Scope of derived events: derived events are assembled for family-member persons only — persons with
family_member = TRUE(i.e., those returned byPersonService.findAllFamilyMembers()). Correspondents, provisional persons, and historical figures not yet added to the family tree are excluded from this assembly even if they have abirthDate. This matches the "curated family graph" MVP principle.Scope
Assemble derived events from migrated Person + relationship data, in the same DTO shape as curated events, flagged
derived: true,type = PERSONAL:eventDatePerson.birthDatePerson.birthDatePerson.birthDatePrecision(passed through unchanged)Person.getDisplayName()→primaryPersonNamePerson.deathDatePerson.deathDatePerson.deathDatePrecision(passed through unchanged)Person.getDisplayName()→primaryPersonNameSPOUSE_OFedge,fromYearpresent{fromYear}-01-01YEARgetDisplayName()→primaryPersonName+relatedPersonNameSPOUSE_OFedge,fromYearnullnullUNKNOWNgetDisplayName()→primaryPersonName+relatedPersonNameArchitecture & layering
TimelineServiceownsTimelineEventRepository(from #2) and reaches Person/relationship data only throughPersonService/RelationshipService— never injectPersonRelationshipRepositoryorPersonRepositoryinto the timeline domain.personService.getById()per person is a defect:PersonService.findAllFamilyMembers()(one call, family members withfamily_member = TRUEonly).RelationshipService.findAllSpouseEdges()(new minimal method — see below; one call returningList<PersonRelationship>with JOIN FETCH on both person sides).RelationshipServicemethod: addfindAllSpouseEdges() → List<PersonRelationship>wrappingrelationshipRepository.findAllByRelationTypeIn(List.of(SPOUSE_OF))with JOIN FETCH on both person sides.TimelineServicecalls this; it never touchesPersonRelationshipRepositorydirectly. Do NOT callgetFamilyNetwork()(that returns aNetworkDTOshaped for Stammbaum, not for timeline assembly).assembleDerivedEvents()orchestrator calls all three and combines results. Add a Javadoc comment on this method: "Derived events are computed, never persisted, and cannot be mutated via the events API (enforced in #5)."RelationshipDTO(carriesid,relationType,fromYear, both person ids + display names) is the correct wire type returned by existingRelationshipServicemethods.PersonRelationshipentity is used in the newfindAllSpouseEdges()— the entity carries thePersonreferences needed forgetDisplayName().TimelineEntryDTOcontractThe DTO is defined in #2/#3 but this issue (#4) adds
derivedTypeto it. Derived events need a discriminator (BIRTH/DEATH/MARRIAGE) so the frontend can compose localized labels; curated events have atitleinstead. AddingderivedTypeto the shared DTO is a breaking OpenAPI change — see Tasks.Field requirements on
TimelineEntryDTOenforced by this issue:id: String(NOTUUID) — synthetic prefixed ids likebirth:{uuid}are non-UUID by construction; if typedUUIDthey will fail to parse.derivedType: DerivedEventType(nullable; null for curated events) — enum:BIRTH,DEATH,MARRIAGE.primaryPersonName: String— display name of the subject (Geburt/Tod) or first spouse (Heirat).relatedPersonName: String(nullable; null for Geburt/Tod, populated for Heirat) — display name of second spouse. Not pre-concatenated — the frontend (#6/#7) owns the " & " separator and locale-specific formatting. The i18n keytimeline.derived.marriage({nameA}, {nameB})needs two interpolation slots.derived: boolean,type: EventType(alwaysPERSONALfor derived events).Person.getDisplayName()is confirmed null-safe in existing code, but add an assertion test that display names are non-null on emitted events.Synthetic ids
Derived events have no persisted UUID. Mint prefixed synthetic ids:
birth:{personId}(UUID of the Person)death:{personId}(UUID of the Person)marriage:{relationshipId}(UUID of the PersonRelationship row)These are non-UUID by construction and MUST never collide with a real
TimelineEventUUID. Theidfield onTimelineEntryDTOMUST beString, notUUID.Symmetric-marriage dedup
SPOUSE_OFis stored once per couple (enforced by theunique_spouse_pairDB index usingLEAST/GREATEST— V55). The in-memory dedup onSet<UUID>of relationship ids is a correctness assertion, not the primary defense. Add a comment in the service explaining this: "DB constraintunique_spouse_pairis the authoritative enforcement; in-memory dedup is a defensive assertion."Logging
Use parameterized SLF4J (
@Slf4j):logger.debug("Assembled {} derived events for {} persons", events.size(), persons.size()). Never string concatenation. No PII (person names, dates) in logs at INFO or above.Resolved decisions
fromYearmarriage → emit, do not drop. ASPOUSE_OFedge can be saved withfromYear == null. Resolution: emit the Heirat witheventDate = nullandprecision = UNKNOWNso it lands in the "Ohne Datum" bucket. Rationale: dropping it hides a real marriage and violates the spec's "no fabricated dates / honest about precision" principle.derivedTypediscriminator, not a baked Germantitle. The DTO exposesderivedTypeenum (BIRTH/DEATH/MARRIAGE) plus the person name(s); the frontend (#6/#7) composes the localized, precision-aware label from an i18n key. Rationale: de/en/es product; a server-frozen German string would force German labels on all locales.DatePrecisionstays indocument/for this issue. Do not move it as part of this issue — inherit whatever foundational issue #1 decides. If #1 relocates it to a shared package, this issue simply imports the new path.birth:{personId},death:{personId},marriage:{relationshipId}. Non-UUID by construction and MUST never collide with a realTimelineEventUUID.unique_spouse_pairDB index is the authoritative enforcement; in-memorySet<UUID>is a defensive assertion).family_member = TRUE).PersonService.findAllFamilyMembers()is the correct query. Correspondents and provisional persons not yet in the family tree are excluded. Every acceptance criterion that says "a person" means "a family-member person." Rationale: MVP scope matches the curated family graph principle; broadening to all persons is a future enhancement.derivedTypeis added toTimelineEntryDTOby this issue (#4). It was always part of the timeline design but not yet in #2/#3's DTO definition. Adding it here is a breaking OpenAPI change — see Tasks. Rationale: the discriminator is first needed here; delaying it would block localized label rendering in #6/#7.TimelineEntryDTO.idis typedString, notUUID. Synthetic prefixed ids (birth:{uuid}) cannot parse as UUID — the DTO field must beString. Enforce this when #2 defines the DTO; confirm before implementation starts.TimelineEntryDTOfor Heirat.primaryPersonName: StringandrelatedPersonName: String(nullable) — not pre-concatenated. The frontend owns the " & " separator and i18n formatting.RelationshipService.findAllSpouseEdges()is the correct service boundary. A new minimal method wrappingfindAllByRelationTypeIn(List.of(SPOUSE_OF))with JOIN FETCH.TimelineServicenever touchesPersonRelationshipRepositoryorgetFamilyNetwork()directly.buildBirthEvents,buildDeathEvents,buildMarriageEvents) +assembleDerivedEvents()orchestrator. Each is independently testable.Tasks
TimelineEntryDTO.idisString(notUUID) in #2's branch before writing any code — if it isUUID, block on that fix first.derivedType: DerivedEventType(enumBIRTH/DEATH/MARRIAGE),primaryPersonName: String, andrelatedPersonName: String(nullable) toTimelineEntryDTOin thetimeline/package.npm run generate:apiafter addingderivedTypeand the name fields toTimelineEntryDTO— this is a breaking OpenAPI change.RelationshipService.findAllSpouseEdges() → List<PersonRelationship>wrappingrelationshipRepository.findAllByRelationTypeIn(List.of(SPOUSE_OF))with JOIN FETCH on both person sides.buildBirthEvents(List<Person>),buildDeathEvents(List<Person>),buildMarriageEvents(List<PersonRelationship>)private methods inTimelineService.assembleDerivedEvents()orchestrator calling all three. Add Javadoc: "Derived events are computed, never persisted, and cannot be mutated via the events API (enforced in #5)."Person.birthDate+birthDatePrecision(1:1, no dedup). Tod fromPerson.deathDate+deathDatePrecision(1:1, no dedup). Precision passed through unchanged.SPOUSE_OFedges: presentfromYear→{fromYear}-01-01@YEAR; nullfromYear→eventDate = null@UNKNOWN(emitted, not dropped).SPOUSE_OFedge; dedup onSet<UUID>of relationship row ids; add comment explaining DB constraint is authoritative.birth:{personId},death:{personId},marriage:{relationshipId}— never UUIDs.findAllFamilyMembers()+findAllSpouseEdges()) — no per-person N+1.@Slf4jwith parameterized logging; no PII at INFO+.Acceptance criteria
All criteria apply to family-member persons (those with
family_member = TRUE):birthDateyields exactly one Geburt event at the person'sbirthDatePrecision.deathDatebut nobirthDateyields exactly one Tod and zero Geburt events.birthDatebut nodeathDateyields exactly one Geburt and zero Tod.birthDatenordeathDateyields zero derived events.SPOUSE_OFedge) yields exactly one Heirat event, even when both spouses are in the queried scope.SPOUSE_OFrows) yields two distinct Heirat events.SPOUSE_OFedge with nullfromYearyields one Heirat witheventDate = null,precision = UNKNOWN(it is surfaced, not suppressed).DAY-precisionbirthDatearrives on the event asDAY(not collapsed toYEAR); Heirat isYEAR(orUNKNOWNwhenfromYearnull).derived: true,type = PERSONAL, a non-nullderivedType, and a synthetic non-UUIDStringid — produced without a persisted id and excluded from any update/delete path.primaryPersonName; Heirat events also have a non-nullrelatedPersonName.family_member = FALSEand abirthDateyields zero derived events (out of scope).Tests
Pure unit tests with
@ExtendWith(MockitoExtension.class)against mockedPersonService+RelationshipService— no Spring context, no Testcontainers (that cost belongs to #2's migration tests).Factory helpers:
Note:
Person.birthDate/birthDatePrecision/familyMemberfields don't exist until #1 merges — the factory helpers (and thus all tests) will not compile until then. Add a class-level comment:// NOTE: tests require #1 (Person date migration) to be merged before this class compiles.In every event-assertion test also assert structural invariants:
assertThat(event.derived()).isTrue(),assertThat(event.type()).isEqualTo(EventType.PERSONAL),assertThat(event.derivedType()).isEqualTo(DerivedEventType.BIRTH)(or DEATH/MARRIAGE as appropriate). These are not separate behaviors — assert them within each test.For precision tests use:
assertThat(event.precision()).isEqualTo(DatePrecision.DAY)(not just non-null).TDD order:
should_emit_one_geburt_for_person_with_birthdateshould_emit_zero_tod_when_person_has_birthdate_but_no_deathdateshould_emit_no_events_for_person_with_neither_dateshould_emit_one_tod_for_person_with_deathdateshould_emit_one_tod_and_zero_geburt_for_person_with_deathdate_onlyshould_emit_one_heirat_for_spouse_edge_with_fromYear(happy path)should_emit_unknown_precision_heirat_when_fromYear_is_null(eventDate null, not dropped)should_emit_exactly_one_heirat_when_both_spouses_in_scope(symmetric dedup, key = relationship row id)should_emit_two_heirat_for_person_married_to_two_partnersshould_pass_birth_precision_through_unchanged(DAY stays DAY — assert equality, not non-null)should_mint_prefixed_synthetic_ids_never_uuidshould_emit_heirat_with_displayname_for_both_spouses(non-null primaryPersonName + relatedPersonName)should_assume_no_self_spouse_edge_per_db_constraint(assumption test — DB constraintno_self_relis the enforcement; this test documents the invariant, not the guard)should_exclude_non_family_member_persons_from_derived_events(person withfamily_member = falseyields zero events)🏗️ Markus Keller — Senior Application Architect
Observations
Domain boundary is correctly drawn.
TimelineService→PersonService.findAllFamilyMembers()+RelationshipService.findAllSpouseEdges()respects the layering rule. The prohibition on injectingPersonRelationshipRepositorydirectly is explicit and correct.findAllByRelationTypeInalready has JOIN FETCH — but it lives inPersonRelationshipRepositorydirectly. The newRelationshipService.findAllSpouseEdges()wraps exactly this method. That meansRelationshipServicealready owns the boundary correctly — the wrapper is merely the service-layer façade. Good. One detail: the existingfindAllByRelationTypeInquery currently passesFAMILY_RELATION_TYPES(three types) fromgetFamilyNetwork(). The new method should pass onlyList.of(SPOUSE_OF)— not all three family types — to keep the semantic narrow. The issue states this correctly; just confirming it matches the existing repo signature.ADR is overdue. The issue says "An ADR may be warranted for the new
timeline/domain." By Markus's rules, a new backend domain package with its own entity is a structural decision that requires an ADR — not "may." Next sequential number is ADR-035. The ADR should be written before this issue's code is merged, covering: why derived events are computed on read rather than persisted, theStringid type for synthetic ids, and thetimeline/package boundary. This is a blocker by the doc-update table in the persona.Documentation updates this issue triggers:
CLAUDE.mdpackage table must addtimeline/with its contents.docs/architecture/c4/l3-backend-*.pumlmust addTimelineService+ the two new methods it calls on person/relationship services.docs/architecture/db/db-orm.pumlis unaffected by this issue (no new entity here;TimelineEvententity belongs to #2).docs/GLOSSARY.mdneeds entries forDerivedEventType,derivedType,assembleDerivedEvents, and "derived event" as a concept.@Transactional(readOnly = true)onassembleDerivedEvents(). The method callsfindAllFamilyMembers()(PersonRepository) andfindAllSpouseEdges()(RelationshipRepository) — both load lazy associations onPersonRelationship.person/relatedPerson. If the session closes between the two calls (non-transactional context), callinggetDisplayName()on the eagerly-fetched person objects is safe becauseJOIN FETCHforces eager loading. However,findAllFamilyMembers()usesPersonRepository.findByFamilyMemberTrueOrderByLastNameAscFirstNameAsc()which is a derived query with no JOIN FETCH — thenameAliasescollection onPersonisFetchType.LAZY(as seen inPerson.java). If any code path in the assembly touchesperson.getNameAliases(), it will throwLazyInitializationExceptionwithout a transaction. Safe becauseassembleDerivedEventsonly callsgetDisplayName()(a@Transientcomputed fromtitle,firstName,lastName— all eagerly loaded). Still, annotatingassembleDerivedEvents()with@Transactional(readOnly = true)is defensive best practice per ADR-022 and should be added to the Tasks.Precision pass-through is correct architecturally — no conversion in the service layer. The rendering contract lives in the frontend helper (
dateLabel.ts), consistent with the "axis is the year" principle.Recommendations
Stringid rationale,timeline/domain boundary.@Transactional(readOnly = true)toassembleDerivedEvents()as defensive practice per ADR-022, even though the current access paths are safe.findAllSpouseEdges()passes onlyList.of(SPOUSE_OF)— not the fullFAMILY_RELATION_TYPESlist — so the method semantics stay narrow.Open Decisions
None. Architecture is clear and well-constrained by the issue.
👨💻 Felix Brandt — Senior Fullstack Developer
Observations
The factory helpers in the Tests section will not compile until #1 merges. The issue requires a class-level comment stating this — confirmed and correct. One addition: the
makeSpouseEdge()factory should also call.relationType(RelationType.SPOUSE_OF)explicitly (it's shown that way in the issue body, good), and bothpersonandrelatedPersonmust be non-null forgetDisplayName()to work. The factory should create persons viamakePerson()to reuse the builder, not construct inline.The
assembleDerivedEvents()orchestrator calls three private methods and combines results — that's exactly one job per function. Clean. However, the orchestrator itself should be package-private (notprivate) if it's the integration point that #5 will call. The threebuild*helpers should beprivate. The current spec says "private methods" for all three builders and the orchestrator — clarify thatassembleDerivedEvents()itself is package-private or protected (whatever #5 needs to call it). If #5 wires it throughTimelineServiceas a public method, document that.Person.getDisplayName()is@Transientand reads onlytitle,firstName,lastName— confirmed null-safe by looking atDisplayNameFormatter.format(). The assertion testshould_emit_heirat_with_displayname_for_both_spousesmust assert non-null and non-blank (empty string from a null-firstName person is still technically non-null).The test order covers the critical dedup case well. One gap: there is no test for
should_emit_zero_events_for_person_with_familyMember_false_even_when_birthDate_present. This is listed (should_exclude_non_family_member_persons_from_derived_events) — good, but the factorymakePerson()setsfamilyMember(true)by default. A separate factory variantmakeNonFamilyPerson()or an overridemakePerson(LocalDate birthDate, DatePrecision precision, boolean familyMember)should be included to avoid constructing the builder inline in that one test.should_mint_prefixed_synthetic_ids_never_uuidtest — the assertion should verify the format actively:assertThat(event.id()).startsWith("birth:"), not just that it is not a UUID. UseassertThat(UUID.fromString(event.id())).as("id must NOT be parseable as UUID")with anassertThatThrownBy(...)to confirm parse failure. This is the strongest form of the invariant.Naming:
assembleDerivedEvents()is imperative (verb-noun) rather than declarative. Given that it returns a value, a name likederivedEvents()orcomputeDerivedEvents()would read better as a query. However, the issue locks this name in explicitly — if the convention is "assemble" throughout, keep it consistent. No action needed unless the convention is revisited.buildMarriageEvents()receivesList<PersonRelationship>with both person sides JOIN-FETCHed (confirmed:PersonRelationshipRepository.findAllByRelationTypeInhas JOIN FETCH on both sides). Callingr.getPerson().getDisplayName()inside the method is safe without a transaction. Add an inline comment in the implementation referencing this:// JOIN FETCH in findAllSpouseEdges() guarantees person/relatedPerson are loaded.Recommendations
startsWith("birth:")AND UUID parse failure inshould_mint_prefixed_synthetic_ids_never_uuid— the two-sided assertion proves both properties.makeNonFamilyPerson(...)factory variant to keep the exclusion test as readable as the happy-path tests.assembleDerivedEvents()visibility in the issue tasks — should it bepublic(called byTimelineService's read endpoint in #5) or remain package-private? The Javadoc comment on it should also document who calls it.should_emit_heirat_with_displayname_for_both_spouses.Open Decisions
None. TDD order and naming are explicitly spelled out.
🔒 Nora "NullX" Steiner — Application Security Engineer
Observations
No direct attack surface on this issue. The assembly layer is read-only, produces no persisted data, and introduces no new endpoints. The attack surface concern lives in #5 (the
GET /api/timelineendpoint) and its rejection of synthetic ids onPUT/DELETE. This issue correctly defers that enforcement to #5 and calls it out explicitly in the "Carried to #5" note.Synthetic id collision risk is addressed at the right layer — but the guarantee needs a test. The issue states: "MUST never collide with a real
TimelineEventUUID." The prefixed formatbirth:{uuid}is structurally non-UUID, so no UUID-typed field can accidentally hold it. However, if #2 definesTimelineEntryDTO.idasString(notUUID), there is a theoretical risk that someone passesbirth:some-real-uuid-hereas an id to a write endpoint. The issue correctly pushes the rejection test to #5, but I'd recommend adding a comment to theassembleDerivedEvents()Javadoc: "Ids produced by this method are structurally non-UUID (birth:*,death:*,marriage:*) and MUST be rejected by any write endpoint — enforced and tested in #5." This documents the security contract at the source.No PII in logs above INFO — explicitly required and correct. The
@Slf4jparameterized logging rulelogger.debug(...)withevents.size()andpersons.size()(counts, not names/dates) is the right implementation. Recommend adding to the tasks: explicitly check that neitherprimaryPersonNamenorrelatedPersonNameappear in any log statement, even atDEBUG. Names are PII in the GDPR sense.The
family_member = TRUEscope filter is a security boundary in addition to a business rule. Persons withfamily_member = FALSE(correspondents, provisional persons) may be living persons with privacy interests. Excluding them from the timeline is correct — and the testshould_exclude_non_family_member_persons_from_derived_eventsmakes this a regression-tested guarantee.PersonService.findAllFamilyMembers()returns all family members without pagination — this is fine for a family archive (realistically < 500 persons), but a note: if the archive ever grows, a paginated or streamed approach becomes necessary. No action needed now; this is a future-scope observation.No new
ErrorCodeis introduced, and no new error handling paths exist in this assembly layer. The "computed on read, never persisted" design means there are no write-path security concerns in this issue.Resolved decision #8 (
TimelineEntryDTO.idisString) is the correct call for security too — usingUUIDfor a synthetic id field would require either a fake-UUID format (collision risk) or a type cast that breaks downstream validation. TheStringtype prevents misuse of synthetic ids in UUID-typed write paths.Recommendations
assembleDerivedEvents()Javadoc: ids produced are structurally non-UUID; any write endpoint receiving a prefixed id MUST reject with 400 (enforced in #5).TimelineEntryDTO.idisStringnotUUIDbefore writing — this is both a correctness and a security-contract gate.Open Decisions
None. Security boundaries are correctly specified and deferred enforcement is clearly attributed to #5.
🧪 Sara Holt — QA Engineer & Test Strategist
Observations
The TDD order is well-sequenced — it goes from simple (single birth event) to composite (both spouses in scope) to invariant (id format, display names). The ordering ensures each test is independently runnable before the next, which is the right red/green discipline.
Test coverage analysis against the acceptance criteria:
should_emit_one_geburt_for_person_with_birthdateshould_emit_one_tod_and_zero_geburt_for_person_with_deathdate_onlyshould_emit_zero_tod_when_person_has_birthdate_but_no_deathdateshould_emit_no_events_for_person_with_neither_dateshould_emit_one_heirat_for_spouse_edge_with_fromYearshould_emit_exactly_one_heirat_when_both_spouses_in_scopeshould_emit_two_heirat_for_person_married_to_two_partnersshould_emit_unknown_precision_heirat_when_fromYear_is_nullshould_pass_birth_precision_through_unchangedshould_mint_prefixed_synthetic_ids_never_uuidshould_emit_heirat_with_displayname_for_both_spousesshould_assume_no_self_spouse_edge_per_db_constraintshould_exclude_non_family_member_persons_from_derived_eventsGap: Heirat precision = YEAR is untested. The acceptance criteria states "A
DAY-precisionbirthDatearrives on the event asDAY... Heirat isYEAR(orUNKNOWNwhenfromYearnull)." Theshould_pass_birth_precision_through_unchangedtest covers birth precision. The Heirat YEAR case is covered by the happy path test (should_emit_one_heirat_for_spouse_edge_with_fromYear) but there's no explicit assertionassertThat(event.precision()).isEqualTo(DatePrecision.YEAR)listed. This should be added to that test's assertion block. Recommend adding an explicit assertion rather than leaving it implicit.Structural invariants are correctly required within each test — asserting
derived: true,type = PERSONAL,derivedTypein every event test prevents "assertion tourism" where a dedicated test passes while other tests silently produce wrong invariant values.The
should_assume_no_self_spouse_edge_per_db_constrainttest is an assumption test, not a behavior test. It should useAssumptions.assumeThat(...)from AssertJ or a comment explaining this is a documentation test, not a guard. If it usesassertThat, a developer might think the code guards against self-edges (it doesn't — the DB constraint does).Missing:
assembleDerivedEvents()with an empty list of family members should yield zero events — trivial but ensures the orchestrator doesn't NPE on empty input. This is a boundary condition the issue doesn't list.Test layer placement is correct — pure Mockito unit tests, no Spring context, no Testcontainers. The issue correctly assigns migration tests to #2. CI time impact of this test class: ~50ms.
The
@Transactional(readOnly = true)consideration: since these are pure unit tests with mocked services, transaction behavior is irrelevant in the test class. The service-level annotation matters only in integration context (#5's tests).Recommendations
assertThat(event.precision()).isEqualTo(DatePrecision.YEAR)inshould_emit_one_heirat_for_spouse_edge_with_fromYear— the Heirat YEAR precision is an acceptance criterion that needs an explicit assertion, not just an implicit one.should_emit_zero_events_when_family_member_list_is_emptyas a boundary test for the orchestrator.assertThatcomment or restructureshould_assume_no_self_spouse_edge_per_db_constraintto make clear it's a documentation test (asserting what the DB guarantees), not a behavior test (asserting what the service guards). Consider renaming toself_spouse_edge_does_not_exist_per_db_constraint_documentation.Open Decisions
None. Test strategy and pyramid placement are clearly specified.
🎨 Leonie Voss — UI/UX Design Lead & Accessibility Strategist
Observations
This is a pure backend issue — it delivers the assembly layer that #6/#7 will consume. There is no UI to review here. I've read the issue to understand what contract the frontend issues (#6, #7, #10) will receive, and I have observations relevant to those downstream issues.
The
derivedTypediscriminator (BIRTH/DEATH/MARRIAGE) with two name fields is the right contract for localized label composition. The frontend receivesprimaryPersonName+ optionalrelatedPersonName(for Heirat) and composes the label via i18n keys liketimeline.derived.marriage({nameA}, {nameB}). This is correct — it allows all three locales (de/en/es) to format names in their natural order. The German "Heirat: Anna Müller & Hans Schmidt" and English "Marriage: Anna Müller & Hans Schmidt" are produced without server involvement.Precision pass-through to the frontend is the right design.
DatePrecision.DAYstayingDAYmeans thedateLabel.tshelper in #6 can produce "28. Juli 1914" (DAY precision birth), "1923" (YEAR precision Heirat), or "Ohne Datum" (UNKNOWN null-date Heirat). The rendering table in the spec maps these correctly. The frontend must handlenulleventDatewithUNKNOWNprecision — the "Ohne Datum" bucket rendering path inYearBand.sveltemust handle this gracefully (no crash wheneventDateis null).The
relatedPersonName: String (nullable)for Heirat events — when null (for Geburt/Tod), the frontend must not render any separator or "& null" text.EventCard.svelte(#7) should use{#if relatedPersonName}to conditionally render the second name. This is a UX correctness requirement, not just a formatting nicety.Heirat events with
eventDate = nullland in the "Ohne Datum" bucket. For the senior audience (60+), this bucket should render with clear visual separation from the year-dated events, and the Heirat label should read "Heirat (Datum unbekannt)" rather than just "Heirat" — otherwise the event looks incomplete or broken. This label formatting is #6's responsibility, but the contract from this issue (nulleventDate,UNKNOWNprecision) enables it.No i18n keys are defined in this issue — they are deferred to #6/#7. However, the
derivedTypeenum values (BIRTH/DEATH/MARRIAGE) should be documented now as the exact keys the frontend will key off of, so #6 doesn't invent different names. Recommend adding a comment or note in the issue confirming the i18n key naming pattern:timeline.derived.birth,timeline.derived.death,timeline.derived.marriage— and that these are defined in #6.No touch target or accessibility concerns at this layer. This issue produces data DTOs, not UI.
Recommendations
timeline.derived.birth,.death,.marriagewith{name}/{nameA},{nameB}slots) so #6 and #7 implement consistent keys without coordination overhead.EventCard.sveltemust handlerelatedPersonName = nullgracefully — no separator rendered when absent.eventDate = nullwithout a JavaScript crash —new Date(null)is not an error but produces epoch, which is wrong. Use conditional rendering, not default date construction.Open Decisions
None from my angle — the DTO contract is clear and the rendering decisions are correctly deferred to #6/#7.
🚀 Tobias Wendt — DevOps & Platform Engineer
Observations
This issue introduces no new infrastructure — no new Docker services, no new environment variables, no new Flyway migrations (those belong to #2). The operational footprint of this issue is zero. From a platform perspective, this is clean.
The N+1 prohibition is the most operationally relevant concern. The issue mandates two bulk fetches (
findAllFamilyMembers()+findAllSpouseEdges()). I've verified both paths:PersonRepository.findByFamilyMemberTrueOrderByLastNameAscFirstNameAsc()is a derived query (no explicit@Query) — it will fetch all Person fields eagerly but notnameAliases(which isLAZY). SinceassembleDerivedEvents()only callsgetDisplayName()(readstitle,firstName,lastName— all column fields, no lazy associations), this is safe and produces exactly 2 SQL queries regardless of family size. ThefindAllByRelationTypeInquery has JOIN FETCH on both person sides — confirmed in the repository. Good.npm run generate:apiafter addingderivedTypeand name fields — this is listed as a task and is operationally correct. The TypeScript types need regeneration before any frontend issue (#6, #7) starts. If the backend is not running when the developer starts #6, they cannot regenerate. Consider adding a note that the dev stack must be up for this step, or that the generated types can be committed as part of this PR's changes.No new Flyway migration in this issue — confirmed. The
timeline/package andTimelineEvententity belong to #2. This issue correctly avoids touching the schema.The
@Slf4j+ parameterized logging requirement (logger.debug(...)with counts) introduces no operational overhead —DEBUGis typically disabled in production. Confirming this doesn't need a dedicated production log-level check: the existing logging configuration inapplication.yaml/application-prod.yamlshould already suppressDEBUGfor the timeline package (it will, by default — Spring Boot sets the root logger toINFO).CI time impact — pure Mockito unit tests run in milliseconds. No new Testcontainers, no new
@SpringBootTest. This issue adds ~50ms to the test suite at most. No CI pipeline changes needed.One operational edge case to be aware of for #5: when
assembleDerivedEvents()is called from theGET /api/timelineendpoint and the Person table has many family members, the response time is bounded by two sequential DB queries. For a family of ~500 persons (realistic ceiling for a family archive), this is still well within the p95 < 500ms SLA. No caching or optimization needed for MVP.Recommendations
npm run generate:api) should be committed as part of this PR — not left for #6 to discover they need to regenerate. This prevents a "types are stale" CI failure on the frontend issues.Open Decisions
None. This issue has zero platform footprint beyond the code it adds.
📋 Elicit — Requirements Engineer
Observations
The specification is unusually complete for a backend service layer issue. All 11 resolved decisions are documented with rationale. Acceptance criteria map 1:1 to test cases. Dependencies are explicit and the compile-blocking dependency on #1 is called out. This is closer to an IEEE 830 SRS section than a typical feature issue.
One ambiguity in the acceptance criteria: "A married couple (one
SPOUSE_OFedge) yields exactly one Heirat event, even when both spouses are in the queried scope." This criterion conflates two distinct behaviors:SPOUSE_OFedge → one Heirat (dedup from the edge list)The test
should_emit_exactly_one_heirat_when_both_spouses_in_scopetests (b). But what happens iffindAllFamilyMembers()returns spouse A butfindAllSpouseEdges()returns an edge where spouse B hasfamily_member = false? This is an edge case the spec hasn't addressed: is a Heirat emitted when one spouse is a family member and the other is not?Looking at
addRelationship()inRelationshipService: when aSPOUSE_OFedge is added, both endpoints are flaggedfamily_member = true. So in practice, a storedSPOUSE_OFedge implies both spouses are family members. However, it's possible (via direct Person edits or import quirks) to have aSPOUSE_OFedge where one endpoint hasfamily_member = false. The spec is silent on this. Recommendation: add an acceptance criterion — "ASPOUSE_OFedge where one spouse hasfamily_member = falseemits [one Heirat / zero Heirat] — clarify the rule."The "scope of derived events" says family members only — so if the assembly checks
family_memberonly on the Person list (fromfindAllFamilyMembers()), and the spouse edges include persons not in that list,buildMarriageEvents()would still emit the Heirat with both display names (pulled directly from the edge, not filtered by the family member list). This is a scope ambiguity: is the family-member filter applied to persons (births/deaths) but NOT to marriage participants, or to both?The Tasks section includes a pre-condition check ("confirm
TimelineEntryDTO.idisString") but no equivalent check forderivedTypenot being present in #2/#3's DTO definition. Since this issue addsderivedType, there may be a coordination gap if #2/#3 defineTimelineEntryDTOwithoutderivedTypefields — the issue should be implemented on top of #2/#3's branch, not independently.The branch dependency note is precise and correct. The note "cut the feature branch from #1's merged state" is implementation-ready guidance. The equivalent for #2/#3 dependency is implied but not stated as explicitly: "cut this branch from #3's merged state or rebase onto it."
i18n scope: The issue correctly identifies that
derivedTypedrives the frontend i18n keys. However, the i18n keys themselves (timeline.derived.birth, etc.) are not defined in this issue's tasks — they're deferred to #6. This is fine, but the key naming convention should be locked here so #6 doesn't invent a different pattern (e.g.,timeline.event.birthvstimeline.derived.birth). A one-line note in this issue would prevent a cross-issue naming mismatch.Recommendations
SPOUSE_OFedge where one spouse isfamily_member = falseproduce a Heirat event or not? The currentaddRelationship()logic makes this unlikely in practice, but it's an unstated assumption. My recommendation: emit the Heirat regardless of spouse B'sfamily_memberflag (the edge is the source of truth, and the edge's display names come from the entity directly), but document this as a deliberate rule.TimelineEntryDTOandDerivedEventTypemust exist before this issue's code compiles."timeline.derived.{birth|death|marriage}with{name}/{nameA},{nameB}— so #6 implements what #4 implies without a coordination meeting.Open Decisions
family_member = false, doesbuildMarriageEvents()emit a Heirat? The spec is silent. My read of the design intent: emit it (the edge is curated data; filtering marriage partners byfamily_memberwould hide real marriages). But this needs explicit confirmation before implementation, since a test must be written for it.