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
Context: unmergeContacts is the correction path that reverses a contact_merge_events row; its stated invariant (the guard's own comment) is that only the merge currently in effect may be reversed. The unmerge route, and unmergeContacts, and the supersede check were all introduced by commit ca38064 ("feat: converge the channel spine and unblock linked-device writes (feat: converge the channel spine and unblock linked-device writes #335)").
Bug: The supersede check compares source.merged_into_contact_id against mergeEvent.target_contact_id (a target id) instead of against the merge-event id currently in effect, so an already-corrected older merge event that reuses the same target passes the guard.
Actual vs. expected: Reversing the older event after the same (source, target) pair was re-merged succeeds and revives/displaces endpoints, instead of being rejected as superseded.
Impact: A merge-history/audit bookkeeping inconsistency. The newer in-effect merge event is orphaned: its contact_merge_events row and its contact_endpoint_reassignment_events rows remain while the source contact is revived, and unmergeContacts for the newer event subsequently throws. No user-facing production read path consumes the orphaned rows (verified), but the audit log records the wrong event id and the “only reverse the in-effect merge” invariant is broken.
Code with Bug
apps/api/src/services/contact-merge.service.ts:
if(source.merged_into_contact_id!==mergeEvent.target_contact_id){thrownewValidationError("This merge has already been superseded and cannot be reversed",);}// <-- BUG 🔴 compares against target contact id, not the active merge event id; old and new events with same (source,target) both pass
if(!endpoint||endpoint.contact_id!==mergeEvent.target_contact_id){skippedEndpoints++;continue;}// <-- BUG 🔴 after re-merge into the same target, endpoints sit on target again, so an old event's reassignment rows get replayed/restored
Explanation
Two separate merge events can exist for the same (source, target) pair (e.g., merge A → unmerge A → merge B). When B is in effect, source.merged_into_contact_id is still target.id, so the guard in unmergeContacts(A) cannot distinguish A vs. B and incorrectly allows unmerging A again. Because unmergeContacts replays contact_endpoint_reassignment_events for the specified merge_event_id, it will move endpoints back to the source and clear merged_into_contact_id, leaving B’s rows contradicting live state. After that, unmergeContacts(B) fails the same guard (source is no longer merged into target), making B un-correctable.
Codebase Inconsistency
POST /contacts/merges/:mergeEventId/unmerge (in apps/api/src/routes/contacts/merge.ts) passes the URL’s mergeEventId directly to unmergeContacts with no “is this the latest/in-effect event for this source?” validation, implying the service-level guard is expected to enforce the invariant but currently does not for same-target re-merges.
Recommended Fix
Track which merge event is in effect and validate against that, not against merged_into_contact_id. Add an active_merge_event_id UUID column on contacts (FK to contact_merge_events(id)), set it in mergeContacts, clear it in unmergeContacts, and change the supersede check to compare source.active_merge_event_id !== mergeEvent.id. (Optionally add a DB constraint/index to ensure only one active merge per source.)
History
This bug was introduced in commit ca38064, which added unmergeContacts and the supersede guard comparing source.merged_into_contact_id to mergeEvent.target_contact_id rather than to an in-effect event identifier.
Detail Bug Report
https://app.detail.dev/org_ee14aa77-b24a-40b2-b22d-66bd31931f4a/bugs/bug_753524ae-f8ab-4053-8942-8aa83b8bc49f
Introduced in #335 by @setkyar on Sep 9, 2026
Summary
unmergeContactsis the correction path that reverses acontact_merge_eventsrow; its stated invariant (the guard's own comment) is that only the merge currently in effect may be reversed. The unmerge route, andunmergeContacts, and the supersede check were all introduced by commitca38064("feat: converge the channel spine and unblock linked-device writes (feat: converge the channel spine and unblock linked-device writes #335)").source.merged_into_contact_idagainstmergeEvent.target_contact_id(a target id) instead of against the merge-event id currently in effect, so an already-corrected older merge event that reuses the same target passes the guard.(source, target)pair was re-merged succeeds and revives/displaces endpoints, instead of being rejected as superseded.contact_merge_eventsrow and itscontact_endpoint_reassignment_eventsrows remain while the source contact is revived, andunmergeContactsfor the newer event subsequently throws. No user-facing production read path consumes the orphaned rows (verified), but the audit log records the wrong event id and the “only reverse the in-effect merge” invariant is broken.Code with Bug
apps/api/src/services/contact-merge.service.ts:Explanation
Two separate merge events can exist for the same
(source, target)pair (e.g., merge A → unmerge A → merge B). When B is in effect,source.merged_into_contact_idis stilltarget.id, so the guard inunmergeContacts(A)cannot distinguish A vs. B and incorrectly allows unmerging A again. BecauseunmergeContactsreplayscontact_endpoint_reassignment_eventsfor the specifiedmerge_event_id, it will move endpoints back to the source and clearmerged_into_contact_id, leaving B’s rows contradicting live state. After that,unmergeContacts(B)fails the same guard (source is no longer merged into target), making B un-correctable.Codebase Inconsistency
POST /contacts/merges/:mergeEventId/unmerge(inapps/api/src/routes/contacts/merge.ts) passes the URL’smergeEventIddirectly tounmergeContactswith no “is this the latest/in-effect event for this source?” validation, implying the service-level guard is expected to enforce the invariant but currently does not for same-target re-merges.Recommended Fix
Track which merge event is in effect and validate against that, not against
merged_into_contact_id. Add anactive_merge_event_id UUIDcolumn oncontacts(FK tocontact_merge_events(id)), set it inmergeContacts, clear it inunmergeContacts, and change the supersede check to comparesource.active_merge_event_id !== mergeEvent.id. (Optionally add a DB constraint/index to ensure only one active merge per source.)History
This bug was introduced in commit
ca38064, which addedunmergeContactsand the supersede guard comparingsource.merged_into_contact_idtomergeEvent.target_contact_idrather than to an in-effect event identifier.