Closes #837 Makes `PersonRelationship` fully editable (type, related person, dates, notes), migrates its dates from `Integer fromYear/toYear` to `LocalDate + DatePrecision` (mirroring the #773 person pattern, ADR-039 / V76), activates the previously-dead `notes` column, and gives the Zeitstrahl's derived **Heirat** events full date precision for free. Both Open Decisions confirmed as adopted: **no `@Version`** (last-write-wins, single-writer archive) and **`DELETE` ownership-mismatch aligned 403 → 404** (anti-enumeration, matching the new `PUT`). ## What's in it - **V78** migrates `person_relationships.from_year/to_year` → `from_date`/`to_date` + NOT-NULL `*_date_precision` (default `UNKNOWN`); pre-check abort on corrupt years, `YYYY-01-01`/`YEAR` backfill, 5 named CHECK constraints, year columns dropped. - **`PUT /api/persons/{id}/relationships/{relId}`** (`@RequirePermission(WRITE_ALL)`) re-runs every create invariant (self / coherence / order / reverse-PARENT_OF / duplicate) and re-flags family membership; orientation preserved per viewpoint. - New `ErrorCode.INVALID_RELATIONSHIP_DATES` registered in all four sites (§3.6). - `TimelineEventService` sources the derived marriage date from `SPOUSE_OF.fromDate` + precision. - Frontend: `RelationshipDateField` (DAY/MONTH/YEAR), upsert-capable `AddRelationshipForm` (pre-fill + notes + in-flight submit lock), `RelationshipChip` Edit affordance, `updateRelationship` server action, read-view date range + notes, `formatRelationshipDateRange` helper. `api.ts` regenerated. - Docs: ADR-044, db-orm/db-relationships diagrams, DEPLOYMENT §5 deploy note, RTM REQ-001…REQ-019. ## Requirements All 19 EARS requirements implemented red/green and marked `Done` in `.specify/rtm.md`. ## Test plan - **Backend** (targeted, green): `RelationshipMigrationTest` (Testcontainers pg16, 8), `RelationshipServiceTest` (22), `RelationshipControllerTest` (15), `RelationshipServiceIntegrationTest` (real DB, 10), `DerivedEventsAssemblyTest` (17), `ArchitectureTest` (14); `clean package` builds. - **Frontend** (green): `relationshipDates.spec.ts`, `AddRelationshipForm.svelte.spec.ts`, `RelationshipChip.svelte.spec.ts`, `PersonRelationshipsCard.svelte.test.ts`, `page.server.spec.ts`, `messages.spec.ts`. `npm run check` = 798 (below the ~834 baseline); `npm run lint` clean. ## Notes for reviewers - **Spec deviation:** the edit form was built by making `AddRelationshipForm` upsert-capable rather than a duplicate `EditRelationshipForm` (DRY); RTM rows reference `AddRelationshipForm.svelte.spec.ts`. - `api.ts` regenerated from the live spec; only relationship-relevant hunks remain (one springdoc `PageableObject` field-reorder pruned). - **Deploy:** V78 is one-way and not rolling-deploy-safe — stop old JAR → start new JAR (Flyway runs first); targeted `pg_restore -t person_relationships` for rollback. No maintenance window. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Marcel <marcel@familienarchiv> Reviewed-on: #841
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).