Skip to content

Merge two person profiles into one, and split them back #638

Description

@salmonumbrella

The situation

One human, two email addresses, and msgvault shows two people. That is the normal case, not the edge case: a work address and a personal one, an old address and a new one, a phone number on WhatsApp and an email on Gmail. The archive has to be able to say "these are the same person" and from then on treat them as one, and it has to be able to do that after the fact, once both halves have already been curated separately.

Today that works only up to a point. POST /identity/links (and LinkIdentityDialog in the web) joins two participants into one identity cluster, and mergePersonBindingsTx carries the person binding across when at most one side is a promoted person. But when both sides are promoted — two persons rows, each with its own vCard UID, attributes, names, contact points, relationships, employment, categories, and contact state — the link is refused with 409 person_binding_conflict, "The identity clusters belong to different person profiles" (internal/api/identity_links.go:116). Accepting a #596 importer candidate that would join two persons hits the same wall (internal/api/identity_match_candidates.go:325).

The only path through is to delete one profile and then link. persons cascades on delete, so that throws away every curated row the absorbed profile had — person_attribute_values, person_names, person_contact_points, person_addresses, person_dates, person_categories, person_media, person_relationships and their reviews, employments, person_contact_state, activity_event_persons, and daily-note links — and retires its UID with nothing pointing from the old UID to the survivor. And even that path closes for a well-connected contact: DeletePerson refuses with person_referenced when another profile's relationship or an organization points at the person (internal/store/persons.go:200-211), so the two profiles simply cannot be joined.

The schema already states the rule a merge must follow: "a future person-merge must keep the surviving person's UID and retire the other", person IDs are never recycled, UIDs are never reused. #532 lists person merge as deferred follow-up work from #531. #621 adds UID-alias tables. #627 will publish these people to an external address book, where a duplicated or vanished UID is a duplicated or vanished contact.

What works today, and why it is not enough

Linking before either side is promoted works, and the web's link flow covers it. Nobody curates in that order. People get promoted as they show up, attributes and relationships get filled in over weeks, and only later does it become clear that two of them are one human. At that moment the archive offers delete-and-relink, which is a data-loss operation dressed up as the answer, and #532 already notes the web renders the 409 through the generic error path.

There is also no way back. If two people were joined by mistake, unlink splits the identity graph but the schema comment says it "may leave one person spanning the split clusters until the user re-links or deletes the profile" — so the curated profile stays fused.

Ask

Add a user-initiated person merge, and a split that reverses it, on both backends.

  • POST /api/v1/persons/{id}/merge with the absorbed person and If-Match on both revisions. The named person survives with its ID and vCard UID; the absorbed person's participants are rebound to the survivor all-or-none; every person-owned row moves with its provenance intact. Multi-value facts union. Where a single-value definition conflicts, the survivor's current value stays current and the absorbed value is kept as history and surfaced as a reviewable candidate, never dropped. Relationships and employments re-point and dedupe; contact state and derived attributes recompute from the combined activity; categories union.
  • The absorbed UID is retired and recorded as an alias of the survivor's UID in the feat(vcard): add lossless native resource envelopes #621 alias table, so vCard export and the Add a CardDAV client that syncs curated people into an external address book #627 CardDAV client resolve the old UID to the surviving contact instead of creating a second one.
  • The merge is recorded: who, when, absorbed snapshot, and every row moved, so it can be inspected and reversed.
  • POST /api/v1/persons/{id}/split (or unmerge by merge id) that carves a chosen set of participants back out into a new person under a new UID — UIDs are never reused — and restores the rows whose provenance points at those participants from the merge record. Message attribution follows the participants; nothing about the archive itself changes.
  • The link flow and feat(beeper): add reviewable identity candidates #596 candidate acceptance stop failing on person_binding_conflict and instead offer the merge, with the two profiles side by side. Same for the CLI: msgvault person merge <survivor> <absorbed> and msgvault person split.
  • Merge is a human decision. This issue adds no similarity scoring and no automatic merge; Roadmap: richer people profiles, relationships, and CardDAV #534 keeps auto-merge as bonus research. What it guarantees is that when a person says "same human", the archive can comply without losing anything, and can undo it.

Design constraints: SQLite and PostgreSQL stay behaviorally equivalent; ETag/If-Match concurrency as on the rest of the person API; idempotent on retry; published UIDs are stable and never silently reassigned; the operation is atomic — a partial merge must not be observable.

Delivery boundary

Depends on the person model already merged (#531, #535, #565, #592, #593, #599) and on the UID-alias table from #621. It should land before #627 so the CardDAV client never has to publish two contacts for one human. The web entry points belong in the Directory work under #534; this issue owns the API, store, and CLI, and the conflict-path change in the link and candidate flows.

Refs #534, #531, #532, #596, #621, #627.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions