ADR-0004: A derived family graph over stored parent and partner edges
Metadata
- Status: Accepted
- Date: 2026-07-23 (decided); implemented 2026-07-25
- Deciders: EagraΓ Clainne Team (decisions taken in a design interview, 2026-07-23)
- Context: Implemented 2026-07-25 β see "Implementation notes" for the concrete choices and deviations
- Related:
fix(domain): own the reference graph and restrict user deletion on live references; ADR-0002 (centralised RPC authorization); the Family page (relations UI writes both directions today)
Context
The relation model today is three enum values (PARENT, CHILD, OWNER) on
UserRef, stored redundantly: linking Γine to Luke writes a PARENT ref on
Γine and a CHILD ref on Luke, in two independent RPCs issued by the UI.
Nothing is derived β siblings, grandparents, and partners simply don't exist β
and the two stored directions can drift apart because nothing reconciles them.
Two requirements drove this redesign:
- Derive everything derivable. Stored edges should be the minimum from which the rest of the family follows.
- Real families aren't tidy. Partners change, so partnership must be assigned (not inferred); and remarriage means a child may gain a third or fourth parent β parent count must never be capped at two.
Decision
Canonical stored edges: PARENT and PARTNER (and demoted EX_PARTNER)
The graph stores exactly two live edge kinds:
PARENTβ directed, child β parent, stored once on the child. Any number of parent edges per person: a remarried parent's new partner becomes a full parent by being granted a parent edge, deliberately.PARTNERβ symmetric, one edge, at most one living partnership per person.
CHILD is no longer stored β it is the inverse of PARENT. The UI's
double-write (and its drift risk) disappears.
Parents are parents. There is no step/biological/adoptive distinction in the model and none in the labels; if someone should count as a parent, they get a parent edge, and re-partnering alone confers nothing parental.
Partnerships change; the graph remembers
Assigning a new partner auto-demotes the existing partner edge to
EX_PARTNER; an explicit end partnership action does the same without a
replacement, and a partnership that ended before it was ever recorded can be
written as an EX_PARTNER edge directly. Ex edges are inert β no derivation ever walks through them β
because anything parental that survived the partnership is carried by parent
edges, which a breakup does not touch. Ex edges accumulate (several are fine),
an admin may delete an ex edge outright when the family would rather
forget, and any stored edge β ex included β still blocks member deletion under
the live-reference rule.
The backend derives; clients render
Derivation is server-side, in one walk module the web app, CLI, and the delete-restriction all share. The derived set is the extended family:
| Derived | Walk |
|---|---|
| Child | inverse of a parent edge |
| Sibling | shares β₯ 1 parent |
| Grandparent / Grandchild | two parent hops (either direction) |
| Aunt/Uncle | parent's sibling |
| Cousin | parent's sibling's child |
| Parent-in-law / Child-in-law | partner's parent / child's partner |
Family is family: labels carry no nuance β no "half", no "step". A blended household's children are Siblings, full stop; the topology is there for whoever wants to look, but labels don't editorialise. And because the model stores no gender, every label is neutral: Sibling, Grandparent, Grandchild, Cousin, Parent-in-law β with Aunt/Uncle as the one compound. (A future per-person "call me Grandma" display-name override was noted and deliberately left out of scope.)
Wire shape and migration
User.relationscontinues to carry stored edges only (PARENT,PARTNER,EX_PARTNER).- Responses gain a read-only
derived_relationslist carrying the derived refs with new enum values (CHILDreused there, plusSIBLING,GRANDPARENT,GRANDCHILD,AUNT_UNCLE,COUSIN,PARENT_IN_LAW,CHILD_IN_LAW). Derived refs are never writable. ReferenceUserauto-inverts aCHILDwrite into a parent edge stored on the other member, so today's UI, CLI, and seed script keep working unmodified during the transition.- A one-time migration drops the stored
CHILDrows; from then on the inverse is always derived.
Validation keeps the graph derivable
ReferenceUser rejects impossible edges: self-edges, duplicate edges, and
any parent edge that would create an ancestry cycle (nobody is their own
grandparent). There is no cap on parent count. The walk code carries
visited-sets regardless, as defence in depth against pre-validation data.
Consequences
Positive
- One stored edge where there were two: no reciprocal drift, one RPC per link.
- Remarriage works: third and fourth parents are ordinary parent edges, and siblinghood across the blended family falls out of the shared-parent walk with zero extra bookkeeping.
- Partner changes are one action with history: the audit trail plus the demoted ex edge record what happened, while derivations only ever reflect the present.
- One derivation module serves every client and the deletion rule β the web app and CLI cannot disagree about what a cousin is.
- Gender-neutral by construction, because the data never knew gender.
Negative / trade-offs
- The backend grows a graph-walk module and
ReferenceUsergrows cycle detection β meaningfully more logic than today's append. - Extended-family walks (aunts/uncles, cousins, in-laws) triple the label set and the walk code relative to a nuclear-only derivation, for relations the Family page shows rarely.
- "Family is family" means the UI cannot distinguish a half-sibling even when a family wishes it would; the model would need a deliberate amendment.
- Ex edges block deletion like any stored edge, so removing a member may first require an admin to delete an old ex edge β a step that needs honest UI copy.
CHILDauto-inversion keeps old writers alive but means the write path silently stores something other than what was sent β worth a log line and eventual client cleanup.
Implementation notes (2026-07-25)
Shipped as designed, with the following concrete choices and deviations recorded honestly:
-
Partner-edge storage convention. The single PARTNER edge is stored on the member the
ReferenceUsercall targeted (request.uidholds the edge pointing atref.uid). The derivation module (internal/domain/graph.go) applies the symmetric closure at read time, so which side holds the edge is invisible to every reader; writers that need "is X partnered?" consult the full user set, since the edge may sit on either side. -
End-partnership mechanism. No new RPC: writing
ReferenceUserwith anEX_PARTNERref targeting the current partner demotes the live edge β on whichever side it is stored β without writing a replacement. When no live partnership exists between the two, the same write instead records a new inertEX_PARTNERedge on the targeted member β a past partnership that was never stored live, such as a divorced co-parent joining the graph. Writing an ex-partnership the graph already knows about (on either side) is a duplicate. -
Partner auto-demotion is one transaction. Assigning a partner locks (in sorted-uid order) every row holding a live partner edge involving either member, demotes them, appends the new edge and audits each changed row β all in a single transaction (
mutateRelationRows). -
CHILD auto-inversion, and its duplicate case. A
CHILDwrite stores aPARENTedge on the referenced member and logs the inversion. Because the web UI still double-writes PARENT+CHILD for one link, a duplicate arriving via inversion is treated as an idempotent no-op (commit without writing) rather than an error; a duplicate on a direct write is rejected with already-exists. The response to a CHILD write carries the member actually mutated β the child β not the member named inrequest.uid. -
Validation.
ReferenceUserrejects self-edges, duplicates, ancestry cycles (checked server-side against the full user table), derived-only relation kinds (they are read-only), and β new relative to the old append-anything behaviour β PARENT/PARTNER references to users that do not exist. -
Derived list plumbing.
ReadandListUserspopulateUser.derived_relations(field 10, response-only) via one shared walk module. The field is stripped at the database write choke point (internal/database/rows.go), so no mutation path β present or future β can persist it. -
Migration.
internal/database/migrations/0004-drop-stored-child-relations.sqldrops stored CHILD rows. It is idempotent (a second run matches nothing) and is run once withpsql -f, the same way the schema ConfigMap is applied (seedeploy/k8s/database/schema-job.yaml); auto-inversion keeps old writers correct before and after it runs. -
Closer-kin dominance in derivations. Where degenerate data would let one person qualify twice across a two-hop walk (for example a stored parent who is also reachable as a grandparent), the visited-set keeps the closer relation and drops the further one; a parent is likewise never also emitted as an aunt/uncle.
-
2026-07-25 (post-seed fix):
derived_relationsnow also mirrors partnerships to the side that does not store the edge β a livePARTNERref for the non-storing partner, and anEX_PARTNERref for the member an ex edge points at. Exes remain inert in every walk; mirroring the fact is not walking through it, and without the mirror one partner's sheet showed nothing about an edge that still blocks their deletion. The web member sheet renders stored plus derived relations together.
Alternatives considered
- Keep reciprocal stored pairs β least migration, but preserves the double-write and drift; rejected as the opposite of "derive everything".
- Store all relations explicitly β every step-edge hand-written on every remarriage; rejected outright.
- Step-parents derived from partner edges (parent's partner = step-parent) β zero upkeep and automatically current, but rejected in favour of "parents are parents": parental standing should be granted deliberately, not implied by someone's relationship status.
- Parent-edge subtypes (biological/step/adoptive/guardian) β expressive but stored data that goes stale on breakup; rejected.
- Single partner, replace-and-forget β simplest, but the family's shape (divorced co-parents) vanishes from the graph; rejected for EX_PARTNER.
- Multiple concurrent partners β the graph shouldn't police households, but derivations and the sheet multiply; rejected for now.
- Exes participating in derivations β continuity for children, but if the ex mattered parentally they should hold a parent edge, which survives the breakup anyway; rejected.
- Frontend derivation β no backend change, but web and CLI each reimplement the walk and can disagree; rejected.
- Derived flag on the one relations list β fewer fields, but every reader must filter before counting or editing; rejected for a separate read-only list.
- Rejecting CHILD writes immediately β purest contract, but breaks the current UI, CLI, and seed script until all update; rejected for auto-inversion plus migration.
- Descriptive path labels ("parent's sibling") β perfectly neutral but reads like a database explaining itself; rejected for neutral kind labels.
- Skipping cycle detection β cheaper writes and the walks are cycle-safe anyway, but nonsense ancestry would live in the data forever; rejected.