Files
familienarchiv/docs/adr/044-relationship-dates-localdate-precision.md
marcel 8558567688
All checks were successful
CI / Unit & Component Tests (push) Successful in 5m10s
CI / OCR Service Tests (push) Successful in 24s
CI / Backend Unit Tests (push) Successful in 5m14s
CI / fail2ban Regex (push) Successful in 50s
CI / Semgrep Security Scan (push) Successful in 27s
CI / Compose Bucket Idempotency (push) Successful in 1m5s
feat(relationship): editable relationships with LocalDate+DatePrecision dates and notes (#841)
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
2026-06-14 21:17:36 +02:00

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_year until redeploy. Deploy order: stop old JAR → run Flyway V78 → start new JAR. Rollback = targeted pg_restore -t person_relationships from the pre-deploy dump (see docs/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.
  • RelationshipDTO drops fromYear/toYear for fromDate/fromDatePrecision/ toDate/toDatePrecision; the personBirthYear/relatedPersonBirthYear derived fields are unaffected (ADR-039 §3).