- ADR-044 extends ADR-039 to the relationship edge: LocalDate+DatePrecision, update re-validation of create invariants, no @Version (last-write-wins), DELETE→404 anti-enumeration alignment, precise derived marriage date, and the relationshipDates.ts location reusing the existing person→shared boundary. - db-orm.puml: person_relationships now carries from_date/from_date_precision/ to_date/to_date_precision; db-relationships.puml gets a V78 columns-only note. - DEPLOYMENT.md §5: V78 deploy note — no maintenance window, stop-old-then-start ordering (not rolling-deploy-safe), targeted pg_restore rollback. - CLAUDE.md error-code list gains INVALID_RELATIONSHIP_DATES. - rtm.md: REQ-001..REQ-019 for #837 mapped to impl + tests, all Done. Refs #837 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
5.1 KiB
ADR-044 — Relationship dates become LocalDate + DatePrecision; relationships become editable
Status: Accepted Date: 2026-06-14 Issue: #837 (Zeitstrahl milestone; deferred follow-up to #773 / ADR-039)
Context
PersonRelationship stored its span as Integer fromYear/toYear. A wedding could
never be more precise than 1923, while Person (ADR-039), Document, and
TimelineEvent already carry full DatePrecision. Relationships also supported only
create + delete: fixing a wrong type, a wrong person, or adding a date learned later
meant deleting and re-creating the edge — losing createdAt. A notes column existed
that no form set and nothing displayed.
V78 replaces the two integer columns with from_date/to_date (DATE, nullable) plus
from_date_precision/to_date_precision (VARCHAR(16) NOT NULL DEFAULT 'UNKNOWN'),
backfilling existing years as YYYY-01-01 at YEAR precision — exactly the V76 / ADR-039
pattern applied to the relationship edge. A new PUT /api/persons/{id}/relationships/{relId}
makes relationships editable, and notes is activated end to end.
Decisions
1. Mirror ADR-039 verbatim for the relationship edge
DatePrecision is imported cross-domain from document/ (ADR-039 §1 — value-type
sharing, not a layering breach). The precision columns are NOT NULL default UNKNOWN,
guarded by five named CHECK constraints (chk_relationship_from_coherence,
chk_relationship_to_coherence, chk_relationship_date_order,
chk_relationship_{from,to}_precision_values). RelationshipService.validateRelationshipDates
enforces the same rules first, so the user gets a structured 400
(INVALID_DATE_PRECISION for coherence, the new INVALID_RELATIONSHIP_DATES for a
toDate < fromDate order violation) instead of a constraint-violation 500. The form
offers only DAY / MONTH / YEAR; storage still accepts all seven values, and a
stored non-offered precision seeds the edit select as YEAR (ADR-039 §2).
2. Update re-runs every create invariant
An edit can violate the same invariants as a create, so updateRelationship re-runs
all of them: self-relation (VALIDATION_ERROR), date coherence + order, reverse
PARENT_OF (CIRCULAR_RELATIONSHIP), and the (person, relatedPerson, type) unique
constraint via saveAndFlush (DUPLICATE_RELATIONSHIP). Editing into a family type
flags both endpoints as family members (additive; never auto-unflags). The directed
orientation is preserved per viewpoint — whichever endpoint {personId} already holds
on the row stays put — so a PARENT_OF edge remains parent→child whether edited from
either person's page.
3. No optimistic locking (@Version)
PersonRelationship gains no @Version; the edit is last-write-wins, matching the
person edit form. This is a single-writer family archive, and it avoids the managed-
setVersion pitfall (a setVersion on a managed entity is silently ignored by
Hibernate — see the integration-test note in #496-era work). If concurrent curation
ever becomes real, add @Version plus an explicit client-version compare then.
4. IDOR / anti-enumeration: ownership mismatch is 404, for PUT and DELETE
A {relId} that does not belong to {personId} returns 404 RELATIONSHIP_NOT_FOUND
(a shared loadOwnedRelationship helper), so a curator cannot probe relationship ids
belonging to people they cannot see. This aligns deleteRelationship from its
former 403 to 404 in the same change, so the two mutating endpoints behave identically
on the same mismatch.
5. Derived marriage events gain precision for free
TimelineEventService.buildMarriageEvents now sources the Heirat date from the
SPOUSE_OF row's from_date + from_date_precision (previously
LocalDate.of(fromYear, 1, 1) at hard-coded YEAR). A DAY-precision wedding now
surfaces the exact day on the Zeitstrahl. RelationshipInferenceService is unchanged
— it is time-ignorant and never read the year fields.
6. relationshipDates.ts lives in $lib/person/, no new boundary
formatRelationshipDateRange mirrors personLifeDates.ts and delegates entirely to the
already-tested formatDocumentDate (zero new precision logic). It sits in $lib/person/
next to personLifeDates.ts; its only cross-domain import is formatDocumentDate from
$lib/shared/utils/, which the existing person → shared rule in eslint.config.js
already permits — no new eslint boundary rule is added.
Consequences
- V78 is one-way (columns dropped) and is not rolling-deploy-safe — the running JAR
maps
from_yearuntil redeploy. Deploy order: stop old JAR → run Flyway V78 → start new JAR. Rollback = targetedpg_restore -t person_relationshipsfrom the pre-deploy dump (seedocs/DEPLOYMENT.md§8). No maintenance window needed (single-writer archive). - Relationships are fully editable (type, related person, dates, notes) and the read view shows the date range + notes.
RelationshipDTOdropsfromYear/toYearforfromDate/fromDatePrecision/toDate/toDatePrecision; thepersonBirthYear/relatedPersonBirthYearderived fields are unaffected (ADR-039 §3).