You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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 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.
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(andLinkIdentityDialogin the web) joins two participants into one identity cluster, andmergePersonBindingsTxcarries the person binding across when at most one side is a promoted person. But when both sides are promoted — twopersonsrows, each with its own vCard UID, attributes, names, contact points, relationships, employment, categories, and contact state — the link is refused with 409person_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.
personscascades 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_relationshipsand 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:DeletePersonrefuses withperson_referencedwhen 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}/mergewith the absorbed person andIf-Matchon 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.POST /api/v1/persons/{id}/split(orunmergeby 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.person_binding_conflictand instead offer the merge, with the two profiles side by side. Same for the CLI:msgvault person merge <survivor> <absorbed>andmsgvault person split.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.