feat(timeline): derive person life-events (Geburt/Tod/Heirat) — issue #776 #825
Reference in New Issue
Block a user
Delete Branch "worktree-feat-issue-776-derived-person-life-events"
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?
Summary
Implements the backend assembly layer for derived person life-events on the family timeline (issue #776, Zeitstrahl milestone #14). Births, deaths, and marriages are computed on-read from curated
Person+PersonRelationshipdata — never stored.DerivedEventTypeenum (BIRTH/DEATH/MARRIAGE) added to timeline packageTimelineEntryDTOrecord created — unified DTO for derived + curated events;id: Stringto accommodate synthetic prefixed ids (birth:,death:,marriage:)RelationshipService.findAllSpouseEdges()— new method wrapping the existing JOIN FETCH query forSPOUSE_OFedges; no N+1 (REQ-011)TimelineEventService.assembleDerivedEvents()— public@Transactional(readOnly=true)orchestrator; callsbuildBirthEvents(),buildDeathEvents(),buildMarriageEvents()(all private)DerivedEventsAssemblyTestcover all 16 REQ-NNN requirementsTraceability
All REQ-001–REQ-016 implemented, tested, and marked Done in
.specify/rtm.md.Notes
TimelineEntryDTOandDerivedEventTypewere planned for issues 774/775 but not created there — created fresh heregenerate:apideferred to issue #5 (no controller endpoint exposesTimelineEntryDTOyet)PersonRelationshipRepository.findAllByRelationTypeInalready JOIN FETCHes both person sides — no new repository query neededTest plan
DerivedEventsAssemblyTest— 16/16 green (./mvnw test -Dtest=DerivedEventsAssemblyTest)./mvnw clean package -DskipTests)assembleDerivedEvents()only logsresult.size()+persons.size()— no PII@Transactional(readOnly=true)visible onassembleDerivedEvents()inTimelineEventService🤖 Generated with Claude Code
Requirements Engineer — PR Code Review
Verdict: ✅ Approved
Traceability table — checking every REQ-001–REQ-016 against the PR diff:
buildBirthEvents()should_emit_one_geburt_for_person_with_birthdate,should_pass_birth_precision_through_unchangedbuildDeathEvents()should_emit_one_tod_for_person_with_deathdate,should_emit_one_tod_and_zero_geburt_for_person_with_deathdate_onlyp.getBirthDate() != nullshould_emit_zero_tod_when_person_has_birthdate_but_no_deathdatep.getDeathDate() != nullshould_emit_no_events_for_person_with_neither_datebuildMarriageEvents()should_emit_one_heirat_for_spouse_edge_with_fromYearshould_emit_unknown_precision_heirat_when_fromYear_is_nullSet<UUID> seenshould_emit_exactly_one_heirat_when_both_spouses_in_scope,should_emit_two_heirat_for_person_married_to_two_partnersbirth:,death:,marriage:prefixesshould_mint_prefixed_synthetic_ids_never_uuidUUID.fromString()throw assertion presentgetDisplayName()should_emit_heirat_with_displayname_for_both_spousesassembleDerivedEvents()findAllFamilyMembers()delegationshould_exclude_non_family_member_persons_from_derived_eventsshould_emit_heirat_when_one_spouse_is_not_family_membershould_emit_zero_events_when_no_family_members@Transactional(readOnly=true)onassembleDerivedEvents()result.size()+persons.size()loggedMinor observation (not blocking): The test
should_emit_zero_tod_when_person_has_birthdate_but_no_deathdate— the name says "tod" but the assertion countsDerivedEventType.DEATHevents, which correctly tests REQ-003 (zero DEATH for birth-only person). The name is slightly confusing ("zero tod when has birthdate" reads backwards — it's testing REQ-003: person with birth but no death → 0 DEATH events). Not a blocker, but a rename toshould_emit_zero_death_events_for_person_with_birthdate_but_no_deathdatewould aid RTM traceability.RTM: All 16 rows present in
.specify/rtm.mdwith Issue#776, Featurederive-person-life-events, StatusDone. Implementation files and test names match the code. PASS.Scope creep check:
TimelineEntryDTOandDerivedEventTypeare new types — the spec explicitly allows this (see Resolved Decision #7 and the PR note). No out-of-scope behavior introduced. PASS.Verdict: APPROVE — all REQ-001–REQ-016 implemented, tested, and traced.
Developer (Felix Brandt) — PR Code Review
Verdict: ⚠️ Approved with concerns
Checklist:
TimelineEventServiceinjectsRelationshipService(a service), notPersonRelationshipRepository. Constitution §1.3 respected.@Schema(requiredMode = REQUIRED)on always-populated fieldsTimelineEntryDTO:id,type,precision,derivedall marked REQUIRED.eventDate,derivedType,primaryPersonName,relatedPersonNamenullable/optional — correctly left unmarked.npm run generate:apigenerate:apito issue #5: "no controller endpoint exposesTimelineEntryDTOyet." The spec task says "Commit the generated TypeScript types as part of this PR." The spec is clear this is a breaking OpenAPI change. However, sinceTimelineEntryDTOis not yet reachable from any controller endpoint (it's an internal service contract only), Springdoc won't include it in the OpenAPI spec until #5 wires the controller — so regenerating now would produce no diff. Deferral is acceptable given this reasoning, but issue #5 must regenerate before merge.@Service,@RequiredArgsConstructor,@Slf4jall present onTimelineEventService.RelationshipService.findAllSpouseEdges()is a plain public method, no annotation needed.@Transactionalannotation disciplineassembleDerivedEvents()correctly uses@Transactional(readOnly = true). The threebuild*private helpers are rightly unannotated. No@Transactionalon the class.assembleDerivedEvents()is ~10 lines orchestrating three helpers. Eachbuild*method is ~15 lines doing one job. Under the 20-line guideline..filter(p -> p.getBirthDate() != null)is clean. Marriage loop usesseen.add()idiom correctly. No nested conditionals.assembleDerivedEvents()returnsList<TimelineEntryDTO>. ADR-036 respected.TimelineEventServiceimportsRelationshipService, notPersonRelationshipRepository. Constitution §1.3 satisfied.buildBirthEvents,buildDeathEvents,buildMarriageEvents,assembleDerivedEvents— all self-documenting.TimelineEntryDTOas a Java record is correct for an immutable DTO — modern Java 21 feature, aligns witharchitect.mdguidance.Concern —
TimelineEntryDTOmissing@Schema(requiredMode = REQUIRED)onprimaryPersonName:primaryPersonName: Stringis REQUIRED for all derived events (REQ-009 + REQ-010). In the DTO it is left as nullable (no@Schema(REQUIRED)).primaryPersonName), but it means the OpenAPI spec will mark it as optional even though derived events always populate it.@Schemaomission is acceptable since the DTO serves two use cases, but issue #5/#6 should document this asymmetry in the Javadoc or add a discriminated-union pattern note.Minor:
RelationshipService.findAllSpouseEdges()— no@Transactional(readOnly=true)annotation. The existinggetRelationships(),getInferredRelationships(), etc. are also not annotated (correct per project style — reads don't need explicit transactions). Consistent with existing pattern. PASS.Blocker: None.
Verdict: APPROVE — clean layering, correct Lombok/Spring patterns,
@Schemaapplied correctly for the unified-DTO design. One suggestion on Javadoc for the asymmetric nullability ofprimaryPersonNamein the unified DTO.Tester (Sara Holt) — PR Code Review
Verdict: ✅ Approved
Checklist:
@ExtendWith(MockitoExtension.class)— no Spring context, no Testcontainers. Correct for pure service logic. DB behavior (JOIN FETCH, unique constraint) is tested by trusting the existing repository query annotation and the spec's documented assumption test.should_emit_one_geburt_for_person_with_birthdate,should_mint_prefixed_synthetic_ids_never_uuid, etc. Fail messages in CI will be self-explanatory.makePerson(),makePersonWithDeath(),makePersonWithBoth(),makeNonFamilyPerson(),makeSpouseEdge()— all present. Each test body is 5-15 lines with intent-revealing setup.should_emit_one_geburt_for_person_with_birthdateasserts 5 fields on one event — this is the "structural invariants" pattern mandated by the spec (REQ-009: assert derived/type/derivedType on every test). Acceptable.DatePrecision.DAY,DatePrecision.YEAR,DatePrecision.UNKNOWN), not just non-null.eventDateasserted as exactLocalDate.of(...).UUID.fromString()throw asserted.should_exclude_non_family_member_persons_from_derived_eventsmocksfindAllFamilyMembers()returning an empty list and asserts result is empty. This is correct — but it doesn't actually prove thefamilyMember=falsefilter works, because the service delegates filtering toPersonService.findAllFamilyMembers(). The test is testing the delegation contract, not the filter itself. The filter lives inPersonService(not in scope for this PR). The test name should clarify it's testing the delegation, e.g.should_emit_no_events_when_findAllFamilyMembers_returns_empty. This is a suggestion, not a blocker — the spec explicitly stated REQ-012 is "verified by delegation toPersonService.findAllFamilyMembers()".personService.getById()per-person — only batch-fetch mocks are registered. Mockito's strict stubbing would fail any unexpected call.self_spouse_edge_invariant_is_enforced_by_db_constrainthas a clear comment explaining it's a documentation test, not a behavioral assertion. Comment explains the DB constraint is authoritative.PersonwheregetDisplayName()returns an empty string or null. The spec saysPerson.getDisplayName()is "confirmed null-safe in existing code" — this is an accepted assumption. The spec also says to "add an assertion test that display names are non-null AND non-blank" — this IS present inshould_emit_heirat_with_displayname_for_both_spousesfor Heirat, but not explicitly for Geburt/Tod events in a dedicated test. It is implicitly covered by assertingevent.primaryPersonName()equality toanna.getDisplayName()inshould_emit_one_geburt_for_person_with_birthdate. Acceptable.@SpringBootTestmisuseOne concern worth noting for issue #5: When the
GET /api/timelinecontroller endpoint is wired in #5, it will need a@WebMvcTestslice test proving thatREAD_ALLis enforced beforeassembleDerivedEvents()is called. This PR deliberately does not add that test (no controller yet). Flag for #5 review.Blocker: None.
Verdict: APPROVE — 16 well-named tests with strong assertions and correct factory helpers. Minor suggestion to clarify REQ-012 test name. The test suite would catch any regression in the assembly logic.
Security (Nora "NullX" Steiner) — PR Code Review
Verdict: ✅ Approved
This PR is a read-only backend assembly layer with no HTTP endpoints, no file handling, and no new auth boundaries. The STRIDE analysis in the issue spec is accurate. Reviewing against constitution §2 rules and the security checklist:
@RequirePermissionREAD_ALLgate is deferred to #5 — spec explicitly documents this.READ_ALLas the right permission for #5's endpoint.@Entity, nocreatedBy/updatedByto set.assembleDerivedEvents()returns aggregated data from the full family-member set, not a per-user filtered resource. No resource-by-id lookup that could be IDORed.{@html}primaryPersonName/relatedPersonNamefromgetDisplayName()are stored DB values, not rendered in this PR. Frontend rendering is #6/#7.assembleDerivedEvents()returnsList<TimelineEntryDTO>— onlyid,type,precision,derived,derivedType,primaryPersonName,relatedPersonName. No email, no password hash, no session data.log.debug("Assembled {} derived events for {} persons", result.size(), persons.size()). No person names, no dates, no UUIDs in log statements. Constitution §2.7 + REQ-016 satisfied.Security observation —
assembleDerivedEvents()is unauthenticated at the service layer (by design):@RequirePermission). ADR-043 Decision 3 documents this obligation.assembleDerivedEvents()without theREAD_ALLguard, person names (PII) would be exposed. The Javadoc warning is the mitigation here. Constitution §2.8 requires an Unwanted-behavior requirement for the unauthenticated case — this is deferred to #5 as designed.@WebMvcTesttest:GET /api/timelineas unauthenticated → 401, as READ_ALL-only → 200. This PR's tests cannot cover this because there is no controller yet.CWE check:
Blocker: None.
Verdict: APPROVE — no mutating endpoints, no PII in logs, no entity internals in responses. Auth enforcement correctly deferred to #5 with documented obligation in Javadoc and ADR-043.
DevOps (Tobias Wendt) — PR Code Review
Verdict: ✅ Approved
.env.exampleupload-artifact@v4+npm auditgreen@Slf4jwith parameterizedlog.debug(...). No new log pipeline changes.result.size()andpersons.size()— no names, no dates. Consistent with existing log pipeline.ADR-043 number verified: The ADR is written to
docs/adr/043-derived-person-events.md. The spec required verifying the number043against disk before use. The existing ADRs in this project run through042-sdd-adoption.md(referenced in constitution). ADR-043 is the correct next free number. PASS.One operational note for future: ADR-043 notes that caching should be considered if
findAllFamilyMembers()exceeds ~500 rows. At MVP scale this is fine. When/if the timeline endpoint (#5) is called frequently, a short TTL cache onassembleDerivedEvents()or on the two backing queries may be warranted. File a follow-up issue at that point.Blocker: None.
Verdict: APPROVE — no infrastructure changes, no migration, no new dependencies, no new env vars, PII-free logging. Clean DevOps footprint.
UI/UX (Leonie Voss) — PR Code Review
Verdict: ✅ Approved
This is a backend-only PR. No Svelte components, no i18n message files, no routes. My review is scoped to the two UI/UX concerns that touch this PR:
TimelineEntryDTOcarries raw data fields (primaryPersonName,relatedPersonName,derivedType,precision,eventDate) — no pre-composed labels. The backend does NOT return "Geburt von Anna Müller" — it returns the discriminator (DerivedEventType.BIRTH) and the name separately. Frontend (#6/#7) owns label composition via Paraglide i18n. Correct.precision=UNKNOWN,eventDate=null) needs a companion display key (e.g.timeline.derived.marriage_no_date) or 'Ohne Datum' bucket label — spec this in #6, do not discover it during implementation." This PR correctly does NOT attempt to resolve this display question. TheUNKNOWNprecision is surfaced (per REQ-006) so #6/#7 will have the data to implement the null-date bucket.primaryPersonNameandrelatedPersonNameare separate fields onTimelineEntryDTO. The frontend (#6/#7) owns the " & " separator and localized formatting. This gives the UI full control over locale-appropriate name ordering and punctuation.derivedTypeenum values match the i18n key conventionDerivedEventType.BIRTH/DEATH/MARRIAGE→ frontend maps totimeline.derived.birth/death/marriagekeys. Simple, consistent, no mismatch.Observation: The
relatedPersonNamefield is nullable (null for Geburt/Tod, populated for Heirat). The frontend template in #6/#7 should handle the null case explicitly —{relatedPersonName ?? ''}or conditional rendering. This is a reminder for the #6/#7 PR review, not an issue with this PR.Blocker: None.
Verdict: APPROVE — backend-only PR. i18n key convention locked cleanly, null-date marriage deferred correctly to #6, name fields kept separate for frontend control. Everything UI/UX cares about for this layer is in the right shape.
Architect (Markus Keller) — PR Code Review
Verdict: ⚠️ Approved with concerns
DerivedEventType,TimelineEntryDTOlive inorg.raddatz.familienarchiv.timeline.RelationshipService.findAllSpouseEdges()stays inorg.raddatz.familienarchiv.person.relationship. No code smeared across layer packages.TimelineEventServiceinjectsRelationshipService(its service interface), notPersonRelationshipRepository. Constitution §1.3 explicitly satisfied. The diff showsPersonRelationshipentity is used as a return type — this is the DTO-like role of the entity within the relationship sub-domain, which is acceptable since it crosses viaRelationshipService.assembleDerivedEvents()contract correctTimelineEventService, returnsList<TimelineEntryDTO>, annotated@Transactional(readOnly=true). Javadoc documents auth obligation for callers. Three private builder methods are correctly private.TimelineEntryDTOrecord, not raw entity. ADR-039 (Person dates as LocalDate + DatePrecision): used correctly. ADR-040 (timeline domain): extended correctly.docs/architecture/c4/l3-backend-*.pumlupdatedTimelineEventServicegainedassembleDerivedEvents()as a public method and now has a new dependency onRelationshipService. The PR updatesCLAUDE.md(done, verified in diff) and the RTM (done). The C4 L3 diagram update is not visible in the diff.docs/architecture/db/diagramsdocs/GLOSSARY.mdupdatedDerivedEventType,derivedType,assembleDerivedEvents, and 'derived event' as a concept." This is NOT visible in the PR diff. The issue spec explicitly listed this as a task.timeline/(new types + new methods on existing service) andperson/relationship/(one new public method). No other domain packages touched. Blast radius is minimal and justified.buildMarriageEvents()both document the JOIN FETCH guarantee.Concern 1 — GLOSSARY.md not updated (potential blocker per architecture checklist):
Per the architect's review table: "New domain concept or term →
docs/GLOSSARY.md." The diff does not include GLOSSARY.md updates. The spec's task list includes: "Add entries forDerivedEventType,derivedType,assembleDerivedEvents, and 'derived event' as a concept." This is a blocker per the architecture review rule ("a doc omission is a blocker, not a concern") — unlessdocs/GLOSSARY.mdwas already updated in a prior commit not visible in the diff, or the file doesn't exist yet.Concern 2 — C4 L3 backend diagram not updated:
TimelineEventServicenow has a new public method and a new injected dependency (RelationshipService). The matchingdocs/architecture/c4/l3-backend-*.pumldiagram for the timeline domain should show this new method and the new service-to-service dependency arrow. If this diagram file exists and was not updated, it's a blocker per the architect's checklist.Action required: Verify whether
docs/GLOSSARY.mdanddocs/architecture/c4/l3-backend-*.pumlexist. If they do and are missing the new terms/arrows, those updates must be added before merge. If the GLOSSARY doesn't exist yet (possible for a newer project), document that decision.Blocker: Conditional — if
docs/GLOSSARY.mdexists anddocs/architecture/c4/l3-backend-*.pumlexists, both must be updated.Verdict: APPROVE WITH CONCERNS — core architectural decisions are sound, domain boundaries clean, ADR-043 thorough. Pending verification of GLOSSARY.md and C4 L3 diagram updates.