As a family curator I want to edit a relationship's type, people, precise dates and notes after creating it, so I can fix mistakes and add dates I learn later without deleting and re-adding #837

Closed
opened 2026-06-14 14:25:54 +02:00 by marcel · 1 comment
Owner

Milestone: Zeitstrahl — Family Timeline · Follow-up to #773
Spec: issue body is the source of truth (SDD; no committed spec.md)

Follow-up to #773, which migrated Person birth/death to LocalDate + DatePrecision and explicitly left "PersonRelationship.fromYear (marriage) stays Integer/YEAR for now" out of scope. This is that deferred work, bundled with the missing edit capability.

Context

Constitution principles this feature depends on:

  • §1.2 — the new PUT controller method calls RelationshipService, never the repository (RelationshipController is not exempt from ArchUnit).
  • §1.3 — the cross-domain timeline read (deriving Heirat from findAllSpouseEdges()) goes through the relationship/person service, never another domain's repository.
  • §2.1 — the new PUT carries @RequirePermission(Permission.WRITE_ALL); there is no unguarded mutating endpoint.
  • §2.8 — the PUT is covered by Unwanted-behavior (EARS If) requirements for both the unauthenticated (401, REQ-018) and unauthorized (403, REQ-005) cases.
  • §2.5notes is user-supplied text; it renders through Svelte's default {...} escaping and never {@html}.
  • §3.5 — the new precision fields carry @Schema(requiredMode = REQUIRED), and npm run generate:api is run after the model change.
  • §3.6 — the new ErrorCode.INVALID_RELATIONSHIP_DATES is added in all four sites (ErrorCode.java, frontend/src/lib/shared/errors.ts, getErrorMessage(), messages/{de,en,es}.json).

A PersonRelationship (marriage SPOUSE_OF, SIBLING_OF, PARENT_OF, plus FRIEND/COLLEAGUE/EMPLOYER/DOCTOR/NEIGHBOR/OTHER) today supports only create (POST /api/persons/{id}/relationships) and delete (DELETE .../{relId}). There is no update endpoint and no edit UI: to fix a wrong type, a wrong person, or to add a date learned later, you must delete the relationship and re-create it — losing the original createdAt and risking the whole edge.

Two adjacent gaps compound it:

  • Relationship dates are stored as Integer fromYear/toYear. A wedding can never be more precise than 1923, while Person, Document, and TimelineEvent already carry full DatePrecision.
  • A notes column exists on the entity (@Column(length = 2000)) that no form can set and nothing displays — a dead feature.

This issue makes relationships fully editable (type, related person, dates, notes), migrates their dates to LocalDate + DatePrecision mirroring #773's ADR-039 person pattern, activates notes, and surfaces dates on the read view. As a bonus, the Zeitstrahl's derived Heirat (marriage) events — currently sourced from SPOUSE_OF.fromYear via RelationshipService.findAllSpouseEdges() — gain full date precision for free.

User Journey (happy path)

  1. A curator opens a person's edit page and sees their relationships, each with an Edit button (next to the existing Delete).
  2. They click Edit on a marriage. A form opens pre-filled with the current type, the related person, both dates with precision selectors, and any notes.
  3. They change the from-date from 1923 (YEAR) to 12.05.1923 (DAY), add a note, and save.
  4. The system validates and persists; the page shows the updated relationship, and the read (detail) page now shows 12. Mai 1923 to any reader.
  5. The Zeitstrahl's derived marriage event for that couple now reads 12. Mai 1923 instead of 1923.

Scope — Data Model Change

Replace the two integer year columns on person_relationships with two date + precision pairs, mirroring Person (ADR-039):

Field Type Notes
fromDate LocalDate (nullable) start of the relationship (wedding, employment start, …)
fromDatePrecision DatePrecision NOT NULL default UNKNOWN; UNKNOWN ⇔ no date
toDate LocalDate (nullable) end (divorce, death, employment end, …)
toDatePrecision DatePrecision NOT NULL default UNKNOWN

Reuse the DatePrecision enum from document/DatePrecision.java (cross-domain value-type import, already blessed by ADR-039 — no common/ package). The form exposes DAY / MONTH / YEAR only (resolved — consistent with the person form and the 60+ author audience); storage accepts all 7 values, and SEASON/RANGE/APPROX render correctly if present from any source but are not offered in the relationship form.

Out of scope:

  • Reciprocal-row writes — relationships stay single directed rows; the reverse is inferred by RelationshipInferenceService at query time (unchanged).
  • Optimistic locking / @Version — last-write-wins, matching person edit.
  • Bulk relationship editing.
  • The family-tree side panel (StammbaumSidePanel) edit affordance — stays create-only for now.

Data Model — Flyway V78__relationship_years_to_localdate.sql

Single atomic file (next free number confirmed on disk: latest is V77__add_timeline_events.sql). Steps:

  1. Pre-check gate (mirrors #773 V76): abort with RAISE EXCEPTION if any row has from_year > to_year, or from_year = 0 / to_year = 0, naming the offending count.
  2. Add from_date, from_date_precision NOT NULL DEFAULT 'UNKNOWN', to_date, to_date_precision NOT NULL DEFAULT 'UNKNOWN'.
  3. Backfill: UPDATE person_relationships SET from_date = make_date(from_year,1,1), from_date_precision = 'YEAR' WHERE from_year IS NOT NULL (same for to_year).
  4. Add named CHECK constraints:
    • chk_relationship_from_coherence CHECK ((from_date IS NULL) = (from_date_precision = 'UNKNOWN'))
    • chk_relationship_to_coherence CHECK ((to_date IS NULL) = (to_date_precision = 'UNKNOWN'))
    • chk_relationship_date_order CHECK (from_date IS NULL OR to_date IS NULL OR from_date <= to_date)
    • chk_relationship_from_precision_values / chk_relationship_to_precision_valuesIN ('DAY','MONTH','SEASON','YEAR','RANGE','APPROX','UNKNOWN')
  5. Drop from_year, to_year.

One-way migration; rollback via targeted pg_restore -t person_relationships from the pre-deploy backup. No maintenance window (single-writer archive). Note in the deploy runbook.

Entity PersonRelationship.java — replace Integer fromYear/toYear with:

private LocalDate fromDate;

@Enumerated(EnumType.STRING)
@Column(name = "from_date_precision", nullable = false, length = 16)
@Builder.Default
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private DatePrecision fromDatePrecision = DatePrecision.UNKNOWN;

private LocalDate toDate;

@Enumerated(EnumType.STRING)
@Column(name = "to_date_precision", nullable = false, length = 16)
@Builder.Default
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private DatePrecision toDatePrecision = DatePrecision.UNKNOWN;

(PersonRelationship has no @Version and gains none — see Decisions Resolved.)

API

New endpointPUT /api/persons/{id}/relationships/{relId} · @RequirePermission(Permission.WRITE_ALL) · returns RelationshipDTO (200). RelationshipController is not exempt from ArchUnit rules; the controller must call the service, not the repository.

Request DTO — replace CreateRelationshipRequest's fromYear/toYear and reuse the same record for create and update:

public record RelationshipUpsertRequest(
        @NotNull UUID relatedPersonId,
        @NotNull RelationType relationType,
        LocalDate fromDate, DatePrecision fromDatePrecision,
        LocalDate toDate,   DatePrecision toDatePrecision,
        @Size(max = 2000) String notes) {}

Response RelationshipDTO — replace Integer fromYear/toYear with fromDate, fromDatePrecision, toDate, toDatePrecision (the personBirthYear/relatedPersonBirthYear derived fields are unaffected — they come from Person). After the entity/DTO change, run npm run generate:api; TypeScript compile errors then reveal every caller (StammbaumCard.svelte, RelationshipChip.svelte, PersonRelationshipsCard.svelte, and the timeline path).

Service Logic

  • validateRelationshipDates (replaces validateYears): coherence (date present ⇔ precision ≠ UNKNOWN) for both ends → INVALID_DATE_PRECISION (400, reused from #773); order (toDate.isAfter(fromDate)) → new INVALID_RELATIONSHIP_DATES (400). Use DomainException.badRequest/conflict — never raw exceptions.
  • updateRelationship(personId, relId, dto) (@Transactional): load by relId, respond 404 RELATIONSHIP_NOT_FOUND if it does not belong to personId; then re-run the same invariants as create — self-relation (VALIDATION_ERROR), validateRelationshipDates, reverse PARENT_OF (CIRCULAR_RELATIONSHIP), and the (person, relatedPerson, type) unique constraint via saveAndFlush (DUPLICATE_RELATIONSHIP); the row's own identity must not self-conflict. If the new relationType is a family type, flag both endpoints as family members (additive, mirrors addRelationship; never auto-unflags).
  • addRelationship: switch to the new date fields; notes is already persisted (blankToNull).
  • Cross-domain (timeline): TimelineEventService.assembleDerivedEvents() derives Heirat from findAllSpouseEdges() and currently reads fromYear; update it to source fromDate + fromDatePrecision. Derived marriage events then render at full precision.

Frontend

  • relationshipDates.ts (new, mirrors #773's personLifeDates.ts): formatRelationshipDateRange(fromDate, fromPrec, toDate, toPrec, locale) delegating entirely to the already-tested formatDocumentDate ($lib/shared/utils/documentDate.ts) — zero new precision logic. Lives in $lib/person/ (where personLifeDates.ts already sits); its only cross-domain import is formatDocumentDate from $lib/shared/utils/documentDate.ts, which the existing person → shared rule in eslint.config.js already permits — no boundary change needed.
  • Edit form: make AddRelationshipForm.svelte upsert-capable (pre-fill from an optional relationship prop) or add a sibling EditRelationshipForm.svelte. Replace the two <input name="fromYear/toYear"> with two date + precision controls per end, reusing the PersonLifeDateField.svelte pattern with a RELATIONSHIP_DATE_PRECISIONS = ['DAY','MONTH','YEAR'] filter. Add a notes <textarea> (≤2000). German date entry via the existing handleGermanDateInput from $lib/shared/utils/date.ts. min-h-[44px] on the precision <select> (WCAG 2.2 touch target). Every date input, the precision <select>, and the notes <textarea> has an associated <label for>; while the request is in flight the submit control is disabled and shows a progress indicator (REQ-019), preventing a double-submit on a slow PUT. The notes <textarea> and the Edit button use the semantic tokens (bg-surface, text-ink-3, border-line) so dark mode is guaranteed, not inherited by assumption from PersonLifeDateField.svelte.
  • RelationshipChip.svelte: add an Edit button next to Delete, shown only when canWrite, opening the pre-filled form. If the affordance is icon-only it carries an accessible name (aria-label={m.relation_edit()}).
  • persons/[id]/edit/+page.server.ts: add an updateRelationship action (PUT via the typed API client), parsing German dates + precision + notes; surface backend 400s as localized form errors (getErrorMessage(extractErrorCode(result.error))). Update the addRelationship action to send the date+precision+notes shape.
  • Read view PersonRelationshipsCard.svelte: render the date range via formatRelationshipDateRange and show notes. StammbaumCard.svelte: replace the integer yearRange() helper with formatRelationshipDateRange.

i18n Key Inventory (messages/{de,en,es}.json)

Key de en es
relation_label_from_date Beginn (Datum) Start date Fecha de inicio
relation_label_to_date Ende (Datum) End date Fecha de fin
relation_label_date_precision Genauigkeit Precision Precisión
relation_precision_day Genaues Datum (Tag) Exact date (day) Fecha exacta (día)
relation_precision_month Monat bekannt Month known Mes conocido
relation_precision_year Nur Jahreszahl Year only Solo año
relation_label_notes Notizen Notes Notas
relation_notes_placeholder Optionaler Hinweis zu dieser Beziehung Optional note about this relationship Nota opcional sobre esta relación
relation_date_placeholder_hint Leer lassen, wenn unbekannt Leave empty if unknown Dejar vacío si es desconocido
relation_edit Beziehung bearbeiten Edit relationship Editar relación
error_invalid_relationship_dates Das Ende-Datum darf nicht vor dem Beginn-Datum liegen. The end date must not be before the start date. La fecha de fin no puede ser anterior a la de inicio.

(error_invalid_date_precision is reused from #773 — verify the key already exists before re-adding.)

Security Considerations (STRIDE)

  • Spoofing / authentication — an unauthenticated PUT is rejected with 401 and mutates nothing (REQ-018; CWE-306; constitution §2.8).
  • Elevation of privilege — a caller without WRITE_ALL is rejected with 403 on both create and update (REQ-005; §2.1).
  • Tampering / IDOR — a {relId} that does not belong to {id} is rejected with 404 RELATIONSHIP_NOT_FOUND (REQ-006); the service loads by relId and verifies ownership before any write, so one curator cannot edit another person's relationship by guessing ids. The 404 (not 403) on the PUT is a deliberate anti-enumeration choice; the existing deleteRelationship returns 403 for the same mismatch, so a task aligns DELETE to 404 for consistency (see Tasks / Open Decisions).
  • Logging / PII — the updateRelationship log lines and the V78 pre-check RAISE EXCEPTION carry only stable UUIDs and counts — never notes, person names, or other PII (constitution §2.7).
  • Information disclosure (XSS)notes is user-supplied free text, rendered only through Svelte's default {...} escaping, never {@html} (CWE-79; §2.5). Error bodies carry only a typed ErrorCode, no entity internals.
  • RepudiationcreatedAt is preserved across edits (the point of update-vs-recreate); audit fields are set from the session principal inside the service, never bound from the request body (§2.4).

Requirements (EARS)

  • REQ-001 (Ubiquitous): The system shall store each relationship's start and end as a nullable LocalDate plus a NOT-NULL DatePrecision (default UNKNOWN), replacing fromYear/toYear.
  • REQ-002 (Event-driven): When migration V78 runs, the system shall convert each non-null fromYear/toYear to {year}-01-01 at YEAR precision and leave null years as null + UNKNOWN, preserving every existing row.
  • REQ-003 (Ubiquitous): The system shall enforce via named DB CHECK constraints that (date IS NULL) = (precision = UNKNOWN) for both ends and that fromDate <= toDate when both are present.
  • REQ-004 (Event-driven): When a curator submits PUT /api/persons/{id}/relationships/{relId} with a valid body, the system shall update the relationship and return the updated RelationshipDTO with status 200.
  • REQ-005 (Optional-feature): Where the caller lacks WRITE_ALL, the system shall reject create and update with 403.
  • REQ-006 (Unwanted): If {relId} does not exist or does not belong to person {id}, then the system shall respond 404 RELATIONSHIP_NOT_FOUND.
  • REQ-007 (Unwanted): If the update sets relatedPersonId == {id}, then the system shall respond 400 VALIDATION_ERROR.
  • REQ-008 (Unwanted): If the resulting (person, relatedPerson, relationType) already exists on another row, then the system shall respond 409 DUPLICATE_RELATIONSHIP.
  • REQ-009 (Unwanted): If the update sets relationType = PARENT_OF while the reverse PARENT_OF exists, then the system shall respond 409 CIRCULAR_RELATIONSHIP.
  • REQ-010 (Unwanted): If toDate is before fromDate, then the system shall respond 400 INVALID_RELATIONSHIP_DATES.
  • REQ-011 (Unwanted): If a date is present with UNKNOWN precision, or a non-UNKNOWN precision is set without a date, then the system shall respond 400 INVALID_DATE_PRECISION.
  • REQ-012 (Unwanted): If notes exceeds 2000 characters, or relationType/relatedPersonId is missing or carries an invalid enum value, then the system shall respond 400 VALIDATION_ERROR (Bean Validation at the controller boundary).
  • REQ-013 (State-driven): While the (updated) relationType is a family type, the system shall ensure both endpoints are flagged family members.
  • REQ-014 (Ubiquitous): The system shall persist and display notes on create, update, and both the read and edit views.
  • REQ-015 (Ubiquitous): The person detail (read) view shall display each relationship's date range formatted at its stored precision; a relationship with no dates shall render no date line.
  • REQ-016 (Event-driven): When a curator opens the relationship edit affordance, the system shall present a form pre-filled with the current type, related person, both dates + precision, and notes, with the precision select limited to DAY/MONTH/YEAR.
  • REQ-017 (Event-driven): When the timeline assembles derived marriage (Heirat) events, it shall source the date from the SPOUSE_OF relationship's fromDate + fromDatePrecision.
  • REQ-018 (Unwanted): If the request to PUT /api/persons/{id}/relationships/{relId} is unauthenticated, then the system shall respond 401 and modify no row.
  • REQ-019 (State-driven): While a relationship create or update request is in flight, the edit form shall disable its submit control and show a progress indicator, so a slow PUT cannot be double-submitted.

Acceptance Criteria (measurable)

  1. REQ-002: after V78, a relationship that had fromYear=1923, toYear=1958 has from_date='1923-01-01'/YEAR, to_date='1958-01-01'/YEAR; the from_year/to_year columns no longer exist; row count before == row count after.
  2. REQ-004/016: editing a marriage's start from 1923 (YEAR) to 12.05.1923 (DAY) in the form persists from_date='1923-05-12'/DAY, and the detail page renders 12. Mai 1923 (de) / May 12, 1923 (en).
  3. REQ-006: PUT with an unknown relId (or a relId belonging to a different person) → 404 RELATIONSHIP_NOT_FOUND with a structured ErrorCode in the body.
  4. REQ-008/009: editing a relationship into an existing (person, relatedPerson, type) → 409 DUPLICATE_RELATIONSHIP; into a reverse PARENT_OF → 409 CIRCULAR_RELATIONSHIP.
  5. REQ-010: toDate before fromDate → 400 INVALID_RELATIONSHIP_DATES; equal dates allowed.
  6. REQ-011: fromDate set with fromDatePrecision=UNKNOWN → 400 INVALID_DATE_PRECISION; precision DAY with null fromDate → 400.
  7. REQ-014: a note entered on edit is stored (≤2000) and shown on both the read and edit views; a note >2000 chars → 400.
  8. REQ-015: a relationship with no dates renders no date line (not an empty ); a from-only relationship renders just the start, no trailing dash.
  9. REQ-017: a couple with a DAY-precision SPOUSE_OF.fromDate shows that exact date on the Zeitstrahl marriage event, not just the year.
  10. Regression: StammbaumCard, the family network, and RelationshipInferenceService views render unchanged for YEAR-precision (backfilled) relationships; the inference service is untouched and its tests pass.
  11. REQ-001/003: a person_relationships row stores from_date/from_date_precision/to_date/to_date_precision; inserting a row with a date present but UNKNOWN precision is rejected by chk_relationship_*_coherence, and a to_date < from_date insert is rejected by chk_relationship_date_order.
  12. REQ-005: a POST or PUT from a caller without WRITE_ALL → 403, and the row is unchanged.
  13. REQ-007: a PUT with relatedPersonId == {id} → 400 VALIDATION_ERROR.
  14. REQ-012: a PUT with an unknown relationType enum value, or a missing relatedPersonId/relationType, → 400 VALIDATION_ERROR; notes of exactly 2000 chars is accepted and 2001 → 400.
  15. REQ-013: editing a non-family relationship (e.g. OTHER) into a family type (e.g. SIBLING_OF) flags both endpoints as family members; an already-flagged endpoint is never unflagged.
  16. REQ-018: an unauthenticated PUT → 401, and the row is unchanged.
  17. REQ-019: while a PUT is in flight the submit control is disabled and shows a progress indicator; a second click issues no second request.

Tasks

  • PersonRelationship.java: replace fromYear/toYear with fromDate/fromDatePrecision/toDate/toDatePrecision.
  • V78__relationship_years_to_localdate.sql: pre-check → add columns → backfill YYYY-01-01/YEAR → 5 named CHECK constraints → drop from_year/to_year.
  • RelationshipUpsertRequest (replace CreateRelationshipRequest, shared by create + update); RelationshipDTO date fields.
  • RelationshipController: add PUT /api/persons/{id}/relationships/{relId} (@RequirePermission(WRITE_ALL)).
  • RelationshipService/RelationshipController: align deleteRelationship's ownership-mismatch response from 403 to 404 RELATIONSHIP_NOT_FOUND, matching the new PUT (anti-enumeration consistency — see Open Decisions).
  • RelationshipService: validateRelationshipDates, updateRelationship, update addRelationship; re-run self/circular/duplicate/family-flag on update.
  • ErrorCode.INVALID_RELATIONSHIP_DATES → mirror to frontend/src/lib/shared/errors.ts + a case in getErrorMessage() + i18n keys.
  • TimelineEventService: derive Heirat date from fromDate/fromDatePrecision.
  • npm run generate:api; fix every revealed caller.
  • Frontend: relationshipDates.ts; upsert-capable form with date+precision+notes; RelationshipChip Edit button; updateRelationship server action; read-view date + notes display; StammbaumCard helper swap.
  • i18n keys (table above) in messages/{de,en,es}.json.
  • ADR-044 (next free on disk; latest is 043-derived-person-events.md): extends ADR-039 to the relationship edge; documents update re-validation of create invariants, the precise derived marriage date on the Zeitstrahl, and the no-@Version decision. Also records that relationshipDates.ts lives in $lib/person/ and reuses the existing person → shared boundary (no new eslint rule).
  • Update DB diagrams docs/architecture/db/db-orm.puml and db-relationships.puml (merge blocker).
  • Deploy runbook: note no maintenance window; spell out deploy ordering — stop old JAR → run Flyway V78 → start new JAR (the running JAR still maps from_year until redeploy, so the from_year/to_year column drop is not rolling-deploy-safe); include pg_restore -t person_relationships for targeted rollback.
  • Tests (below).

Tests

Migration (Testcontainers postgres:16-alpine — NOT H2; H2 won't honor CHECK constraints)

  • Pre-check aborts on from_year > to_year; aborts on from_year = 0.
  • from_year=1923from_date='1923-01-01'/YEAR; both-null → null/UNKNOWN (no spurious 0001-01-01).
  • Order CHECK rejects a to_date < from_date insert; coherence CHECK rejects date present + UNKNOWN precision.
  • Column drop verified: querying from_year after migration errors.

Service / controller

  • validateRelationshipDates: order rejected; equal allowed; null sides allowed; coherence both directions.
  • updateRelationship: happy path; 404 on wrong person; DUPLICATE_RELATIONSHIP; reverse PARENT_OFCIRCULAR_RELATIONSHIP; family-flag set on update to a family type.
  • RelationshipControllerTest: PUT 200; 401 when unauthenticated (row unchanged); 403 without WRITE_ALL; invalid enum value → 400 with structured ErrorCode; invalid date string → 400.

Frontend (*.spec.ts, browser mode)

  • relationshipDates.spec.ts: DAY/MONTH/YEAR/UNKNOWN × {from-only, to-only, both}, with en/es for at least DAY/MONTH (catch German-month leak).
  • Edit form: pre-fills existing DAY-precision date as dd.mm.yyyy; precision select shows only DAY/MONTH/YEAR; notes round-trips; malformed date → localized error (not a Jackson trace); 320px no-overflow; submit control disabled + progress shown while in flight (REQ-019); every input has a <label for> and the icon-only Edit affordance has an accessible name.
  • PersonRelationshipsCard.svelte.spec.ts: read view shows the date range + notes; empty-state shows no date line.

Timeline

  • TimelineEventServiceTest: a DAY-precision SPOUSE_OF.fromDate produces a derived Heirat event rendering the exact date.

Decisions Resolved

Decision Resolution Rationale
Date model for relationship from/to LocalDate + DatePrecision per end (mirror Person ADR-039) Two independent precise endpoints; consistent with the rest of the app
Form precision values DAY / MONTH / YEAR only (storage keeps all 7) Matches the person form & 60+ author audience; SEASON/APPROX render but aren't offered
Milestone Zeitstrahl — Family Timeline Deferred follow-up to #773; also improves derived marriage-event precision
Date-order error code new INVALID_RELATIONSHIP_DATES; coherence reuses INVALID_DATE_PRECISION Distinct from person BIRTH_AFTER_DEATH and document INVALID_DATE_RANGE
Update request shape reuse one RelationshipUpsertRequest for create + update DRY; identical fields
Re-validation on update re-run self / circular PARENT_OF / duplicate / family-flag An edit can violate the same invariants as a create
Optimistic locking none (no @Version); last-write-wins Single-writer family archive; matches person edit; avoids the managed-setVersion pitfall
Reciprocal rows unchanged — single directed row, reverse inferred RelationshipInferenceService already derives reverse/sibling edges at query time
notes activate the existing column (≤2000): entry + display Dead feature already paid for
Flyway / ADR numbers V78 / ADR-044 Verified on disk: latest V77__add_timeline_events.sql, 043-derived-person-events.md

Open Decisions

These carry a concrete proposal the spec already adopts; they are buildable as written and listed only for human confirm/override before or at /implement:

  • No optimistic locking (@Version) on PersonRelationship — last-write-wins, matching person edit and avoiding the managed-setVersion pitfall. Adopted (see Decisions Resolved + ADR-044); confirm it is acceptable for the single-writer family archive. (alt: add @Version + an explicit client-version compare.)
  • DELETE ownership-mismatch → 404 — a task aligns the existing deleteRelationship from 403 to 404 RELATIONSHIP_NOT_FOUND so it matches the new PUT's anti-enumeration behavior. Confirm, or drop the alignment and instead document PUT's 404 as an intentional divergence. (alt: leave DELETE at 403.)

Traceability (RTM rows — added on the feature branch at /implement, not on main now)

| REQ-001 | #<this> | RelationshipMigrationTest                       | Planned |
| REQ-002 | #<this> | RelationshipMigrationTest#backfill              | Planned |
| REQ-003 | #<this> | RelationshipMigrationTest#checkConstraints      | Planned |
| REQ-004 | #<this> | RelationshipControllerTest#update               | Planned |
| REQ-005 | #<this> | RelationshipControllerTest#updateForbidden      | Planned |
| REQ-006 | #<this> | RelationshipServiceTest#updateWrongPerson       | Planned |
| REQ-007 | #<this> | RelationshipServiceTest#selfRelation            | Planned |
| REQ-008 | #<this> | RelationshipServiceTest#duplicateOnUpdate       | Planned |
| REQ-009 | #<this> | RelationshipServiceTest#circularParentOnUpdate  | Planned |
| REQ-010 | #<this> | RelationshipServiceTest#toBeforeFrom            | Planned |
| REQ-011 | #<this> | RelationshipServiceTest#precisionCoherence      | Planned |
| REQ-012 | #<this> | RelationshipControllerTest#invalidBody          | Planned |
| REQ-013 | #<this> | RelationshipServiceTest#familyFlagOnUpdate      | Planned |
| REQ-014 | #<this> | PersonRelationshipsCard.svelte.spec.ts          | Planned |
| REQ-015 | #<this> | PersonRelationshipsCard.svelte.spec.ts          | Planned |
| REQ-016 | #<this> | EditRelationshipForm.svelte.spec.ts             | Planned |
| REQ-017 | #<this> | TimelineEventServiceTest#derivedMarriagePrecise | Planned |
| REQ-018 | #<this> | RelationshipControllerTest#updateUnauthenticated | Planned |
| REQ-019 | #<this> | EditRelationshipForm.svelte.spec.ts             | Planned |

Open Questions

None — OQ-1 (milestone → Zeitstrahl) and OQ-2 (precision set → DAY/MONTH/YEAR only) are resolved (see Decisions Resolved).

Persona Review Results

Converged after 2 rounds of /review-issue (six-persona SDD spec gate). Round 2: Requirements Engineer · Developer · Security · DevOps · UI/UX · Architect — all APPROVE, no blocking FAILs. Round 1 folds: constitution-principle list, REQ-018 (401 unauthenticated), REQ-019 (in-flight submit lock), Security Considerations (STRIDE), six added acceptance criteria, deploy ordering, helper location/boundary. Round 2 polish: IDOR 404-vs-403 note + DELETE alignment task, PII-log discipline, semantic dark-mode tokens. Items left for human sign-off are in ## Open Decisions.

**Milestone:** Zeitstrahl — Family Timeline · **Follow-up to #773** **Spec:** issue body is the source of truth (SDD; no committed spec.md) Follow-up to #773, which migrated `Person` birth/death to `LocalDate + DatePrecision` and explicitly left *"`PersonRelationship.fromYear` (marriage) stays `Integer`/`YEAR` for now"* out of scope. This is that deferred work, bundled with the missing edit capability. ## Context **Constitution principles this feature depends on:** - **§1.2** — the new `PUT` controller method calls `RelationshipService`, never the repository (`RelationshipController` is not exempt from ArchUnit). - **§1.3** — the cross-domain timeline read (deriving **Heirat** from `findAllSpouseEdges()`) goes through the relationship/person service, never another domain's repository. - **§2.1** — the new `PUT` carries `@RequirePermission(Permission.WRITE_ALL)`; there is no unguarded mutating endpoint. - **§2.8** — the `PUT` is covered by Unwanted-behavior (EARS `If`) requirements for both the unauthenticated (401, REQ-018) and unauthorized (403, REQ-005) cases. - **§2.5** — `notes` is user-supplied text; it renders through Svelte's default `{...}` escaping and never `{@html}`. - **§3.5** — the new precision fields carry `@Schema(requiredMode = REQUIRED)`, and `npm run generate:api` is run after the model change. - **§3.6** — the new `ErrorCode.INVALID_RELATIONSHIP_DATES` is added in all four sites (`ErrorCode.java`, `frontend/src/lib/shared/errors.ts`, `getErrorMessage()`, `messages/{de,en,es}.json`). A `PersonRelationship` (marriage `SPOUSE_OF`, `SIBLING_OF`, `PARENT_OF`, plus `FRIEND`/`COLLEAGUE`/`EMPLOYER`/`DOCTOR`/`NEIGHBOR`/`OTHER`) today supports only **create** (`POST /api/persons/{id}/relationships`) and **delete** (`DELETE .../{relId}`). There is no update endpoint and no edit UI: to fix a wrong type, a wrong person, or to add a date learned later, you must delete the relationship and re-create it — losing the original `createdAt` and risking the whole edge. Two adjacent gaps compound it: - Relationship dates are stored as `Integer fromYear`/`toYear`. A wedding can never be more precise than `1923`, while `Person`, `Document`, and `TimelineEvent` already carry full `DatePrecision`. - A `notes` column exists on the entity (`@Column(length = 2000)`) that **no form can set and nothing displays** — a dead feature. This issue makes relationships fully editable (type, related person, dates, notes), migrates their dates to `LocalDate + DatePrecision` mirroring #773's ADR-039 person pattern, activates `notes`, and surfaces dates on the read view. As a bonus, the Zeitstrahl's derived **Heirat** (marriage) events — currently sourced from `SPOUSE_OF.fromYear` via `RelationshipService.findAllSpouseEdges()` — gain full date precision for free. ## User Journey (happy path) 1. A curator opens a person's **edit** page and sees their relationships, each with an **Edit** button (next to the existing Delete). 2. They click **Edit** on a marriage. A form opens **pre-filled** with the current type, the related person, both dates with precision selectors, and any notes. 3. They change the from-date from `1923` (YEAR) to `12.05.1923` (DAY), add a note, and save. 4. The system validates and persists; the page shows the updated relationship, and the **read** (detail) page now shows `12. Mai 1923` to any reader. 5. The Zeitstrahl's derived marriage event for that couple now reads `12. Mai 1923` instead of `1923`. ## Scope — Data Model Change Replace the two integer year columns on `person_relationships` with two date + precision pairs, mirroring `Person` (ADR-039): | Field | Type | Notes | |---|---|---| | `fromDate` | `LocalDate` (nullable) | start of the relationship (wedding, employment start, …) | | `fromDatePrecision` | `DatePrecision` NOT NULL | default `UNKNOWN`; `UNKNOWN` ⇔ no date | | `toDate` | `LocalDate` (nullable) | end (divorce, death, employment end, …) | | `toDatePrecision` | `DatePrecision` NOT NULL | default `UNKNOWN` | Reuse the `DatePrecision` enum from `document/DatePrecision.java` (cross-domain value-type import, already blessed by ADR-039 — no `common/` package). **The form exposes DAY / MONTH / YEAR only** (resolved — consistent with the person form and the 60+ author audience); storage accepts all 7 values, and `SEASON`/`RANGE`/`APPROX` render correctly if present from any source but are not offered in the relationship form. **Out of scope:** - Reciprocal-row writes — relationships stay single directed rows; the reverse is inferred by `RelationshipInferenceService` at query time (unchanged). - Optimistic locking / `@Version` — last-write-wins, matching person edit. - Bulk relationship editing. - The family-tree side panel (`StammbaumSidePanel`) edit affordance — stays create-only for now. ## Data Model — Flyway `V78__relationship_years_to_localdate.sql` Single atomic file (next free number confirmed on disk: latest is `V77__add_timeline_events.sql`). Steps: 1. **Pre-check gate** (mirrors #773 V76): abort with `RAISE EXCEPTION` if any row has `from_year > to_year`, or `from_year = 0` / `to_year = 0`, naming the offending count. 2. Add `from_date`, `from_date_precision NOT NULL DEFAULT 'UNKNOWN'`, `to_date`, `to_date_precision NOT NULL DEFAULT 'UNKNOWN'`. 3. Backfill: `UPDATE person_relationships SET from_date = make_date(from_year,1,1), from_date_precision = 'YEAR' WHERE from_year IS NOT NULL` (same for `to_year`). 4. Add **named** CHECK constraints: - `chk_relationship_from_coherence CHECK ((from_date IS NULL) = (from_date_precision = 'UNKNOWN'))` - `chk_relationship_to_coherence CHECK ((to_date IS NULL) = (to_date_precision = 'UNKNOWN'))` - `chk_relationship_date_order CHECK (from_date IS NULL OR to_date IS NULL OR from_date <= to_date)` - `chk_relationship_from_precision_values` / `chk_relationship_to_precision_values` — `IN ('DAY','MONTH','SEASON','YEAR','RANGE','APPROX','UNKNOWN')` 5. Drop `from_year`, `to_year`. One-way migration; rollback via targeted `pg_restore -t person_relationships` from the pre-deploy backup. No maintenance window (single-writer archive). Note in the deploy runbook. **Entity `PersonRelationship.java`** — replace `Integer fromYear`/`toYear` with: ```java private LocalDate fromDate; @Enumerated(EnumType.STRING) @Column(name = "from_date_precision", nullable = false, length = 16) @Builder.Default @Schema(requiredMode = Schema.RequiredMode.REQUIRED) private DatePrecision fromDatePrecision = DatePrecision.UNKNOWN; private LocalDate toDate; @Enumerated(EnumType.STRING) @Column(name = "to_date_precision", nullable = false, length = 16) @Builder.Default @Schema(requiredMode = Schema.RequiredMode.REQUIRED) private DatePrecision toDatePrecision = DatePrecision.UNKNOWN; ``` (`PersonRelationship` has no `@Version` and gains none — see Decisions Resolved.) ## API **New endpoint** — `PUT /api/persons/{id}/relationships/{relId}` · `@RequirePermission(Permission.WRITE_ALL)` · returns `RelationshipDTO` (200). `RelationshipController` is not exempt from ArchUnit rules; the controller must call the service, not the repository. **Request DTO** — replace `CreateRelationshipRequest`'s `fromYear`/`toYear` and reuse the same record for **create and update**: ```java public record RelationshipUpsertRequest( @NotNull UUID relatedPersonId, @NotNull RelationType relationType, LocalDate fromDate, DatePrecision fromDatePrecision, LocalDate toDate, DatePrecision toDatePrecision, @Size(max = 2000) String notes) {} ``` **Response `RelationshipDTO`** — replace `Integer fromYear`/`toYear` with `fromDate`, `fromDatePrecision`, `toDate`, `toDatePrecision` (the `personBirthYear`/`relatedPersonBirthYear` derived fields are unaffected — they come from `Person`). After the entity/DTO change, run `npm run generate:api`; TypeScript compile errors then reveal every caller (`StammbaumCard.svelte`, `RelationshipChip.svelte`, `PersonRelationshipsCard.svelte`, and the timeline path). ## Service Logic - **`validateRelationshipDates`** (replaces `validateYears`): coherence (`date present ⇔ precision ≠ UNKNOWN`) for both ends → `INVALID_DATE_PRECISION` (400, reused from #773); order (`toDate.isAfter(fromDate)`) → new `INVALID_RELATIONSHIP_DATES` (400). Use `DomainException.badRequest/conflict` — never raw exceptions. - **`updateRelationship(personId, relId, dto)`** (`@Transactional`): load by `relId`, respond 404 `RELATIONSHIP_NOT_FOUND` if it does not belong to `personId`; then re-run the **same invariants as create** — self-relation (`VALIDATION_ERROR`), `validateRelationshipDates`, reverse `PARENT_OF` (`CIRCULAR_RELATIONSHIP`), and the `(person, relatedPerson, type)` unique constraint via `saveAndFlush` (`DUPLICATE_RELATIONSHIP`); the row's own identity must not self-conflict. If the new `relationType` is a family type, flag both endpoints as family members (additive, mirrors `addRelationship`; never auto-unflags). - **`addRelationship`**: switch to the new date fields; `notes` is already persisted (`blankToNull`). - **Cross-domain (timeline):** `TimelineEventService.assembleDerivedEvents()` derives **Heirat** from `findAllSpouseEdges()` and currently reads `fromYear`; update it to source `fromDate` + `fromDatePrecision`. Derived marriage events then render at full precision. ## Frontend - **`relationshipDates.ts`** (new, mirrors #773's `personLifeDates.ts`): `formatRelationshipDateRange(fromDate, fromPrec, toDate, toPrec, locale)` delegating entirely to the already-tested `formatDocumentDate` (`$lib/shared/utils/documentDate.ts`) — zero new precision logic. Lives in `$lib/person/` (where `personLifeDates.ts` already sits); its only cross-domain import is `formatDocumentDate` from `$lib/shared/utils/documentDate.ts`, which the existing `person → shared` rule in `eslint.config.js` already permits — **no boundary change needed**. - **Edit form**: make `AddRelationshipForm.svelte` upsert-capable (pre-fill from an optional `relationship` prop) or add a sibling `EditRelationshipForm.svelte`. Replace the two `<input name="fromYear/toYear">` with two **date + precision** controls per end, reusing the `PersonLifeDateField.svelte` pattern with a `RELATIONSHIP_DATE_PRECISIONS = ['DAY','MONTH','YEAR']` filter. Add a **notes** `<textarea>` (≤2000). German date entry via the existing `handleGermanDateInput` from `$lib/shared/utils/date.ts`. `min-h-[44px]` on the precision `<select>` (WCAG 2.2 touch target). Every date input, the precision `<select>`, and the notes `<textarea>` has an associated `<label for>`; while the request is in flight the submit control is disabled and shows a progress indicator (REQ-019), preventing a double-submit on a slow `PUT`. The notes `<textarea>` and the Edit button use the semantic tokens (`bg-surface`, `text-ink-3`, `border-line`) so dark mode is guaranteed, not inherited by assumption from `PersonLifeDateField.svelte`. - **`RelationshipChip.svelte`**: add an **Edit** button next to Delete, shown only when `canWrite`, opening the pre-filled form. If the affordance is icon-only it carries an accessible name (`aria-label={m.relation_edit()}`). - **`persons/[id]/edit/+page.server.ts`**: add an `updateRelationship` action (`PUT` via the typed API client), parsing German dates + precision + notes; surface backend 400s as localized form errors (`getErrorMessage(extractErrorCode(result.error))`). Update the `addRelationship` action to send the date+precision+notes shape. - **Read view `PersonRelationshipsCard.svelte`**: render the date range via `formatRelationshipDateRange` and show `notes`. **`StammbaumCard.svelte`**: replace the integer `yearRange()` helper with `formatRelationshipDateRange`. ## i18n Key Inventory (`messages/{de,en,es}.json`) | Key | de | en | es | |---|---|---|---| | `relation_label_from_date` | Beginn (Datum) | Start date | Fecha de inicio | | `relation_label_to_date` | Ende (Datum) | End date | Fecha de fin | | `relation_label_date_precision` | Genauigkeit | Precision | Precisión | | `relation_precision_day` | Genaues Datum (Tag) | Exact date (day) | Fecha exacta (día) | | `relation_precision_month` | Monat bekannt | Month known | Mes conocido | | `relation_precision_year` | Nur Jahreszahl | Year only | Solo año | | `relation_label_notes` | Notizen | Notes | Notas | | `relation_notes_placeholder` | Optionaler Hinweis zu dieser Beziehung | Optional note about this relationship | Nota opcional sobre esta relación | | `relation_date_placeholder_hint` | Leer lassen, wenn unbekannt | Leave empty if unknown | Dejar vacío si es desconocido | | `relation_edit` | Beziehung bearbeiten | Edit relationship | Editar relación | | `error_invalid_relationship_dates` | Das Ende-Datum darf nicht vor dem Beginn-Datum liegen. | The end date must not be before the start date. | La fecha de fin no puede ser anterior a la de inicio. | (`error_invalid_date_precision` is reused from #773 — verify the key already exists before re-adding.) ## Security Considerations (STRIDE) - **Spoofing / authentication** — an unauthenticated `PUT` is rejected with 401 and mutates nothing (REQ-018; CWE-306; constitution §2.8). - **Elevation of privilege** — a caller without `WRITE_ALL` is rejected with 403 on both create and update (REQ-005; §2.1). - **Tampering / IDOR** — a `{relId}` that does not belong to `{id}` is rejected with 404 `RELATIONSHIP_NOT_FOUND` (REQ-006); the service loads by `relId` and verifies ownership **before** any write, so one curator cannot edit another person's relationship by guessing ids. The 404 (not 403) on the `PUT` is a deliberate anti-enumeration choice; the existing `deleteRelationship` returns 403 for the same mismatch, so a task aligns `DELETE` to 404 for consistency (see Tasks / Open Decisions). - **Logging / PII** — the `updateRelationship` log lines and the `V78` pre-check `RAISE EXCEPTION` carry only stable UUIDs and counts — never `notes`, person names, or other PII (constitution §2.7). - **Information disclosure (XSS)** — `notes` is user-supplied free text, rendered only through Svelte's default `{...}` escaping, never `{@html}` (CWE-79; §2.5). Error bodies carry only a typed `ErrorCode`, no entity internals. - **Repudiation** — `createdAt` is preserved across edits (the point of update-vs-recreate); audit fields are set from the session principal inside the service, never bound from the request body (§2.4). ## Requirements (EARS) - **REQ-001** (Ubiquitous): The system shall store each relationship's start and end as a nullable `LocalDate` plus a NOT-NULL `DatePrecision` (default `UNKNOWN`), replacing `fromYear`/`toYear`. - **REQ-002** (Event-driven): When migration `V78` runs, the system shall convert each non-null `fromYear`/`toYear` to `{year}-01-01` at `YEAR` precision and leave null years as `null` + `UNKNOWN`, preserving every existing row. - **REQ-003** (Ubiquitous): The system shall enforce via named DB CHECK constraints that `(date IS NULL) = (precision = UNKNOWN)` for both ends and that `fromDate <= toDate` when both are present. - **REQ-004** (Event-driven): When a curator submits `PUT /api/persons/{id}/relationships/{relId}` with a valid body, the system shall update the relationship and return the updated `RelationshipDTO` with status 200. - **REQ-005** (Optional-feature): Where the caller lacks `WRITE_ALL`, the system shall reject create and update with 403. - **REQ-006** (Unwanted): If `{relId}` does not exist or does not belong to person `{id}`, then the system shall respond 404 `RELATIONSHIP_NOT_FOUND`. - **REQ-007** (Unwanted): If the update sets `relatedPersonId == {id}`, then the system shall respond 400 `VALIDATION_ERROR`. - **REQ-008** (Unwanted): If the resulting `(person, relatedPerson, relationType)` already exists on another row, then the system shall respond 409 `DUPLICATE_RELATIONSHIP`. - **REQ-009** (Unwanted): If the update sets `relationType = PARENT_OF` while the reverse `PARENT_OF` exists, then the system shall respond 409 `CIRCULAR_RELATIONSHIP`. - **REQ-010** (Unwanted): If `toDate` is before `fromDate`, then the system shall respond 400 `INVALID_RELATIONSHIP_DATES`. - **REQ-011** (Unwanted): If a date is present with `UNKNOWN` precision, or a non-`UNKNOWN` precision is set without a date, then the system shall respond 400 `INVALID_DATE_PRECISION`. - **REQ-012** (Unwanted): If `notes` exceeds 2000 characters, or `relationType`/`relatedPersonId` is missing or carries an invalid enum value, then the system shall respond 400 `VALIDATION_ERROR` (Bean Validation at the controller boundary). - **REQ-013** (State-driven): While the (updated) `relationType` is a family type, the system shall ensure both endpoints are flagged family members. - **REQ-014** (Ubiquitous): The system shall persist and display `notes` on create, update, and both the read and edit views. - **REQ-015** (Ubiquitous): The person **detail** (read) view shall display each relationship's date range formatted at its stored precision; a relationship with no dates shall render no date line. - **REQ-016** (Event-driven): When a curator opens the relationship edit affordance, the system shall present a form pre-filled with the current type, related person, both dates + precision, and notes, with the precision select limited to DAY/MONTH/YEAR. - **REQ-017** (Event-driven): When the timeline assembles derived marriage (Heirat) events, it shall source the date from the `SPOUSE_OF` relationship's `fromDate` + `fromDatePrecision`. - **REQ-018** (Unwanted): If the request to `PUT /api/persons/{id}/relationships/{relId}` is unauthenticated, then the system shall respond 401 and modify no row. - **REQ-019** (State-driven): While a relationship create or update request is in flight, the edit form shall disable its submit control and show a progress indicator, so a slow `PUT` cannot be double-submitted. ## Acceptance Criteria (measurable) 1. **REQ-002:** after `V78`, a relationship that had `fromYear=1923, toYear=1958` has `from_date='1923-01-01'/YEAR`, `to_date='1958-01-01'/YEAR`; the `from_year`/`to_year` columns no longer exist; row count before == row count after. 2. **REQ-004/016:** editing a marriage's start from `1923` (YEAR) to `12.05.1923` (DAY) in the form persists `from_date='1923-05-12'/DAY`, and the detail page renders `12. Mai 1923` (de) / `May 12, 1923` (en). 3. **REQ-006:** `PUT` with an unknown `relId` (or a `relId` belonging to a different person) → 404 `RELATIONSHIP_NOT_FOUND` with a structured `ErrorCode` in the body. 4. **REQ-008/009:** editing a relationship into an existing `(person, relatedPerson, type)` → 409 `DUPLICATE_RELATIONSHIP`; into a reverse `PARENT_OF` → 409 `CIRCULAR_RELATIONSHIP`. 5. **REQ-010:** `toDate` before `fromDate` → 400 `INVALID_RELATIONSHIP_DATES`; equal dates allowed. 6. **REQ-011:** `fromDate` set with `fromDatePrecision=UNKNOWN` → 400 `INVALID_DATE_PRECISION`; precision `DAY` with null `fromDate` → 400. 7. **REQ-014:** a note entered on edit is stored (≤2000) and shown on both the read and edit views; a note >2000 chars → 400. 8. **REQ-015:** a relationship with no dates renders no date line (not an empty `–`); a from-only relationship renders just the start, no trailing dash. 9. **REQ-017:** a couple with a DAY-precision `SPOUSE_OF.fromDate` shows that exact date on the Zeitstrahl marriage event, not just the year. 10. **Regression:** `StammbaumCard`, the family network, and `RelationshipInferenceService` views render unchanged for YEAR-precision (backfilled) relationships; the inference service is untouched and its tests pass. 11. **REQ-001/003:** a `person_relationships` row stores `from_date`/`from_date_precision`/`to_date`/`to_date_precision`; inserting a row with a date present but `UNKNOWN` precision is rejected by `chk_relationship_*_coherence`, and a `to_date < from_date` insert is rejected by `chk_relationship_date_order`. 12. **REQ-005:** a `POST` or `PUT` from a caller without `WRITE_ALL` → 403, and the row is unchanged. 13. **REQ-007:** a `PUT` with `relatedPersonId == {id}` → 400 `VALIDATION_ERROR`. 14. **REQ-012:** a `PUT` with an unknown `relationType` enum value, or a missing `relatedPersonId`/`relationType`, → 400 `VALIDATION_ERROR`; `notes` of exactly 2000 chars is accepted and 2001 → 400. 15. **REQ-013:** editing a non-family relationship (e.g. `OTHER`) into a family type (e.g. `SIBLING_OF`) flags both endpoints as family members; an already-flagged endpoint is never unflagged. 16. **REQ-018:** an unauthenticated `PUT` → 401, and the row is unchanged. 17. **REQ-019:** while a `PUT` is in flight the submit control is disabled and shows a progress indicator; a second click issues no second request. ## Tasks - [ ] `PersonRelationship.java`: replace `fromYear`/`toYear` with `fromDate`/`fromDatePrecision`/`toDate`/`toDatePrecision`. - [ ] `V78__relationship_years_to_localdate.sql`: pre-check → add columns → backfill `YYYY-01-01`/`YEAR` → 5 named CHECK constraints → drop `from_year`/`to_year`. - [ ] `RelationshipUpsertRequest` (replace `CreateRelationshipRequest`, shared by create + update); `RelationshipDTO` date fields. - [ ] `RelationshipController`: add `PUT /api/persons/{id}/relationships/{relId}` (`@RequirePermission(WRITE_ALL)`). - [ ] `RelationshipService`/`RelationshipController`: align `deleteRelationship`'s ownership-mismatch response from 403 to 404 `RELATIONSHIP_NOT_FOUND`, matching the new `PUT` (anti-enumeration consistency — see Open Decisions). - [ ] `RelationshipService`: `validateRelationshipDates`, `updateRelationship`, update `addRelationship`; re-run self/circular/duplicate/family-flag on update. - [ ] `ErrorCode.INVALID_RELATIONSHIP_DATES` → mirror to `frontend/src/lib/shared/errors.ts` + a `case` in `getErrorMessage()` + i18n keys. - [ ] `TimelineEventService`: derive Heirat date from `fromDate`/`fromDatePrecision`. - [ ] `npm run generate:api`; fix every revealed caller. - [ ] Frontend: `relationshipDates.ts`; upsert-capable form with date+precision+notes; `RelationshipChip` Edit button; `updateRelationship` server action; read-view date + notes display; `StammbaumCard` helper swap. - [ ] i18n keys (table above) in `messages/{de,en,es}.json`. - [ ] **ADR-044** (next free on disk; latest is `043-derived-person-events.md`): extends ADR-039 to the relationship edge; documents update re-validation of create invariants, the precise derived marriage date on the Zeitstrahl, and the no-`@Version` decision. Also records that `relationshipDates.ts` lives in `$lib/person/` and reuses the existing `person → shared` boundary (no new eslint rule). - [ ] Update DB diagrams `docs/architecture/db/db-orm.puml` and `db-relationships.puml` (merge blocker). - [ ] Deploy runbook: note no maintenance window; spell out deploy ordering — **stop old JAR → run Flyway V78 → start new JAR** (the running JAR still maps `from_year` until redeploy, so the `from_year`/`to_year` column drop is **not** rolling-deploy-safe); include `pg_restore -t person_relationships` for targeted rollback. - [ ] Tests (below). ## Tests ### Migration (Testcontainers `postgres:16-alpine` — NOT H2; H2 won't honor CHECK constraints) - Pre-check aborts on `from_year > to_year`; aborts on `from_year = 0`. - `from_year=1923` → `from_date='1923-01-01'/YEAR`; both-null → null/UNKNOWN (no spurious `0001-01-01`). - Order CHECK rejects a `to_date < from_date` insert; coherence CHECK rejects `date present + UNKNOWN precision`. - Column drop verified: querying `from_year` after migration errors. ### Service / controller - `validateRelationshipDates`: order rejected; equal allowed; null sides allowed; coherence both directions. - `updateRelationship`: happy path; 404 on wrong person; `DUPLICATE_RELATIONSHIP`; reverse `PARENT_OF` → `CIRCULAR_RELATIONSHIP`; family-flag set on update to a family type. - `RelationshipControllerTest`: `PUT` 200; **401 when unauthenticated (row unchanged)**; 403 without `WRITE_ALL`; invalid enum value → 400 with structured `ErrorCode`; invalid date string → 400. ### Frontend (`*.spec.ts`, browser mode) - `relationshipDates.spec.ts`: DAY/MONTH/YEAR/UNKNOWN × {from-only, to-only, both}, with `en`/`es` for at least DAY/MONTH (catch German-month leak). - Edit form: pre-fills existing DAY-precision date as `dd.mm.yyyy`; precision select shows only DAY/MONTH/YEAR; notes round-trips; malformed date → localized error (not a Jackson trace); 320px no-overflow; submit control disabled + progress shown while in flight (REQ-019); every input has a `<label for>` and the icon-only Edit affordance has an accessible name. - `PersonRelationshipsCard.svelte.spec.ts`: read view shows the date range + notes; empty-state shows no date line. ### Timeline - `TimelineEventServiceTest`: a DAY-precision `SPOUSE_OF.fromDate` produces a derived Heirat event rendering the exact date. ## Decisions Resolved | Decision | Resolution | Rationale | |---|---|---| | Date model for relationship from/to | `LocalDate` + `DatePrecision` per end (mirror Person ADR-039) | Two independent precise endpoints; consistent with the rest of the app | | Form precision values | **DAY / MONTH / YEAR only** (storage keeps all 7) | Matches the person form & 60+ author audience; `SEASON`/`APPROX` render but aren't offered | | Milestone | **Zeitstrahl — Family Timeline** | Deferred follow-up to #773; also improves derived marriage-event precision | | Date-order error code | new `INVALID_RELATIONSHIP_DATES`; coherence reuses `INVALID_DATE_PRECISION` | Distinct from person `BIRTH_AFTER_DEATH` and document `INVALID_DATE_RANGE` | | Update request shape | reuse one `RelationshipUpsertRequest` for create + update | DRY; identical fields | | Re-validation on update | re-run self / circular `PARENT_OF` / duplicate / family-flag | An edit can violate the same invariants as a create | | Optimistic locking | none (no `@Version`); last-write-wins | Single-writer family archive; matches person edit; avoids the managed-`setVersion` pitfall | | Reciprocal rows | unchanged — single directed row, reverse inferred | `RelationshipInferenceService` already derives reverse/sibling edges at query time | | `notes` | activate the existing column (≤2000): entry + display | Dead feature already paid for | | Flyway / ADR numbers | **V78 / ADR-044** | Verified on disk: latest `V77__add_timeline_events.sql`, `043-derived-person-events.md` | ## Open Decisions These carry a concrete proposal the spec already adopts; they are buildable as written and listed only for human **confirm/override** before or at `/implement`: - **No optimistic locking (`@Version`) on `PersonRelationship`** — last-write-wins, matching person edit and avoiding the managed-`setVersion` pitfall. Adopted (see Decisions Resolved + ADR-044); confirm it is acceptable for the single-writer family archive. _(alt: add `@Version` + an explicit client-version compare.)_ - **`DELETE` ownership-mismatch → 404** — a task aligns the existing `deleteRelationship` from 403 to 404 `RELATIONSHIP_NOT_FOUND` so it matches the new `PUT`'s anti-enumeration behavior. Confirm, or drop the alignment and instead document `PUT`'s 404 as an intentional divergence. _(alt: leave `DELETE` at 403.)_ ## Traceability (RTM rows — added on the feature branch at `/implement`, not on main now) ``` | REQ-001 | #<this> | RelationshipMigrationTest | Planned | | REQ-002 | #<this> | RelationshipMigrationTest#backfill | Planned | | REQ-003 | #<this> | RelationshipMigrationTest#checkConstraints | Planned | | REQ-004 | #<this> | RelationshipControllerTest#update | Planned | | REQ-005 | #<this> | RelationshipControllerTest#updateForbidden | Planned | | REQ-006 | #<this> | RelationshipServiceTest#updateWrongPerson | Planned | | REQ-007 | #<this> | RelationshipServiceTest#selfRelation | Planned | | REQ-008 | #<this> | RelationshipServiceTest#duplicateOnUpdate | Planned | | REQ-009 | #<this> | RelationshipServiceTest#circularParentOnUpdate | Planned | | REQ-010 | #<this> | RelationshipServiceTest#toBeforeFrom | Planned | | REQ-011 | #<this> | RelationshipServiceTest#precisionCoherence | Planned | | REQ-012 | #<this> | RelationshipControllerTest#invalidBody | Planned | | REQ-013 | #<this> | RelationshipServiceTest#familyFlagOnUpdate | Planned | | REQ-014 | #<this> | PersonRelationshipsCard.svelte.spec.ts | Planned | | REQ-015 | #<this> | PersonRelationshipsCard.svelte.spec.ts | Planned | | REQ-016 | #<this> | EditRelationshipForm.svelte.spec.ts | Planned | | REQ-017 | #<this> | TimelineEventServiceTest#derivedMarriagePrecise | Planned | | REQ-018 | #<this> | RelationshipControllerTest#updateUnauthenticated | Planned | | REQ-019 | #<this> | EditRelationshipForm.svelte.spec.ts | Planned | ``` ## Open Questions None — OQ-1 (milestone → Zeitstrahl) and OQ-2 (precision set → DAY/MONTH/YEAR only) are resolved (see Decisions Resolved). ## Persona Review Results **Converged after 2 rounds** of `/review-issue` (six-persona SDD spec gate). Round 2: Requirements Engineer · Developer · Security · DevOps · UI/UX · Architect — all **APPROVE**, no blocking FAILs. Round 1 folds: constitution-principle list, REQ-018 (401 unauthenticated), REQ-019 (in-flight submit lock), Security Considerations (STRIDE), six added acceptance criteria, deploy ordering, helper location/boundary. Round 2 polish: IDOR 404-vs-403 note + `DELETE` alignment task, PII-log discipline, semantic dark-mode tokens. Items left for human sign-off are in `## Open Decisions`.
marcel added this to the Zeitstrahl — Family Timeline milestone 2026-06-14 14:25:54 +02:00
marcel added the P2-mediumfeatureperson labels 2026-06-14 14:25:59 +02:00
Author
Owner

Implemented on feat/issue-837-relationship-edit-dates

All 19 requirements implemented with red/green TDD. Both Open Decisions confirmed as adopted: no @Version (last-write-wins) and DELETE ownership-mismatch aligned 403 → 404.

Commits

# Commit Covers
1 feat(relationship): add INVALID_RELATIONSHIP_DATES error code new ErrorCode, all 4 sites (§3.6)
2 feat(relationship): store from/to as LocalDate + DatePrecision V78, entity, DTOs, validation, timeline — REQ-001/002/003/010/011/014/017
3 feat(relationship): add PUT update endpoint, align DELETE mismatch to 404 update endpoint + 404 alignment — REQ-004/005/006/007/008/009/012/013/018
4 feat(relationship): add formatRelationshipDateRange helper REQ-015
5 feat(relationship): date+precision edit UI, notes, and read-view display api.ts regen + form/chip/views + actions — REQ-004/014/015/016/019
6 docs(relationship): ADR-044, DB diagrams, deploy runbook, RTM rows ADR-044, db-*.puml, DEPLOYMENT §5, CLAUDE.md, RTM

REQ → test

  • REQ-001/002/003 (LocalDate+precision storage, V78 backfill, CHECK constraints) → RelationshipMigrationTest (Testcontainers pg16, 8 tests)
  • REQ-004/006/007/008/009/013 (update endpoint + re-validated invariants) → RelationshipServiceTest#updateRelationship_*, RelationshipServiceIntegrationTest (real DB)
  • REQ-005/012/018 (403 / 400 invalid body / 401 unauthenticated) → RelationshipControllerTest#updateRelationship_*
  • REQ-010/011 (date order + coherence) → RelationshipServiceTest#addRelationship_throws_INVALID_RELATIONSHIP_DATES…, …INVALID_DATE_PRECISION…
  • REQ-014/015 (notes + read-view date range) → PersonRelationshipsCard.svelte.test.ts, relationshipDates.spec.ts
  • REQ-016/019 (pre-filled edit form, DAY/MONTH/YEAR, in-flight lock) → AddRelationshipForm.svelte.spec.ts, RelationshipChip.svelte.spec.ts
  • REQ-017 (precise derived Heirat) → DerivedEventsAssemblyTest#should_emit_day_precision_heirat_from_spouse_fromDate

Verification

  • Backend: RelationshipMigrationTest (8), RelationshipServiceTest (22), RelationshipControllerTest (15), RelationshipServiceIntegrationTest (10, real DB), DerivedEventsAssemblyTest (17), ArchitectureTest (14) — all green; clean package builds.
  • Frontend: all affected *.spec.ts/*.test.ts green (form, chip, card, helper, server actions, message parity); npm run check 798 errors (below the ~834 baseline — net-negative); npm run lint clean.
  • api.ts regenerated from the live spec (springdoc reorder noise pruned).
  • One deviation from the spec's Traceability: the edit form was built by extending AddRelationshipForm (upsert-capable) rather than a duplicate EditRelationshipForm; RTM rows reference AddRelationshipForm.svelte.spec.ts accordingly.

RTM rows REQ-001…REQ-019 added for #837, all Done. Ready for /review-pr.

## ✅ Implemented on `feat/issue-837-relationship-edit-dates` All 19 requirements implemented with red/green TDD. Both Open Decisions confirmed as adopted: **no `@Version`** (last-write-wins) and **`DELETE` ownership-mismatch aligned 403 → 404**. ### Commits | # | Commit | Covers | |---|---|---| | 1 | `feat(relationship): add INVALID_RELATIONSHIP_DATES error code` | new ErrorCode, all 4 sites (§3.6) | | 2 | `feat(relationship): store from/to as LocalDate + DatePrecision` | V78, entity, DTOs, validation, timeline — REQ-001/002/003/010/011/014/017 | | 3 | `feat(relationship): add PUT update endpoint, align DELETE mismatch to 404` | update endpoint + 404 alignment — REQ-004/005/006/007/008/009/012/013/018 | | 4 | `feat(relationship): add formatRelationshipDateRange helper` | REQ-015 | | 5 | `feat(relationship): date+precision edit UI, notes, and read-view display` | api.ts regen + form/chip/views + actions — REQ-004/014/015/016/019 | | 6 | `docs(relationship): ADR-044, DB diagrams, deploy runbook, RTM rows` | ADR-044, db-*.puml, DEPLOYMENT §5, CLAUDE.md, RTM | ### REQ → test - **REQ-001/002/003** (LocalDate+precision storage, V78 backfill, CHECK constraints) → `RelationshipMigrationTest` (Testcontainers pg16, 8 tests) - **REQ-004/006/007/008/009/013** (update endpoint + re-validated invariants) → `RelationshipServiceTest#updateRelationship_*`, `RelationshipServiceIntegrationTest` (real DB) - **REQ-005/012/018** (403 / 400 invalid body / 401 unauthenticated) → `RelationshipControllerTest#updateRelationship_*` - **REQ-010/011** (date order + coherence) → `RelationshipServiceTest#addRelationship_throws_INVALID_RELATIONSHIP_DATES…`, `…INVALID_DATE_PRECISION…` - **REQ-014/015** (notes + read-view date range) → `PersonRelationshipsCard.svelte.test.ts`, `relationshipDates.spec.ts` - **REQ-016/019** (pre-filled edit form, DAY/MONTH/YEAR, in-flight lock) → `AddRelationshipForm.svelte.spec.ts`, `RelationshipChip.svelte.spec.ts` - **REQ-017** (precise derived Heirat) → `DerivedEventsAssemblyTest#should_emit_day_precision_heirat_from_spouse_fromDate` ### Verification - Backend: `RelationshipMigrationTest` (8), `RelationshipServiceTest` (22), `RelationshipControllerTest` (15), `RelationshipServiceIntegrationTest` (10, real DB), `DerivedEventsAssemblyTest` (17), `ArchitectureTest` (14) — all green; `clean package` builds. - Frontend: all affected `*.spec.ts`/`*.test.ts` green (form, chip, card, helper, server actions, message parity); `npm run check` **798** errors (below the ~834 baseline — net-negative); `npm run lint` clean. - `api.ts` regenerated from the live spec (springdoc reorder noise pruned). - One deviation from the spec's Traceability: the edit form was built by extending `AddRelationshipForm` (upsert-capable) rather than a duplicate `EditRelationshipForm`; RTM rows reference `AddRelationshipForm.svelte.spec.ts` accordingly. RTM rows REQ-001…REQ-019 added for #837, all `Done`. Ready for `/review-pr`.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#837