Skip to content

[Detail Bug] Contacts: Unmerging a stale merge event succeeds after re-merging the same source/target pair, orphaning the newer merge event #378

Description

@detail-app

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

  • 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) {
  throw new ValidationError(
    "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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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