Skip to content

fix(api): reject stale same-target merge reversals via active merge event pointer - #383

Open
detail-app[bot] wants to merge 1 commit into
mainfrom
detail/bug-fix/fix-api-reject-stale-same-target-merge-reversals-v-b91d2b
Open

detail-app[bot] wants to merge 1 commit into
mainfrom
detail/bug-fix/fix-api-reject-stale-same-target-merge-reversals-v-b91d2b

Conversation

@detail-app

@detail-app detail-app Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Detail bug report: View on Detail

Bug

unmergeContacts is the correction path that reverses a contact_merge_events row, and its stated invariant is that only the merge currently in effect may be reversed. The supersede guard compared source.merged_into_contact_id (a surviving contact id) against mergeEvent.target_contact_id (also a surviving contact id), so it could not distinguish two merge events that shared the same (source, target) pair.

This made the corrupting sequence reachable: merge S→T (event A) → unmerge A → re-merge S→T (event B, now in effect) → unmergeContacts(A). Because S.merged_into_contact_id was still T, the guard for A passed, A's endpoint reassignment rows were replayed back onto S, and S was revived — leaving B orphaned (contact_merge_events + contact_endpoint_reassignment_events rows contradicting live state) and un-correctable (unmergeContacts(B) then threw superseded). The audit log recorded contact.unmerged for the wrong event id (A), which is the fingerprint of the bug.

Fix

Track which merge event is in effect with an explicit pointer, and validate against that — not against the surviving contact id:

  • Added contacts.active_merge_event_id UUID (FK → contact_merge_events(id), ON DELETE RESTRICT, NOT VALID) plus a partial unique index …_c_ame_uidx on (active_merge_event_id) WHERE active_merge_event_id IS NOT NULL (at most one active merge pointer per contact). Provisioned for new tenants in ensureChannelSpineTenantSchema; mirrored in TenantDatabase / TENANT_SCHEMA_CONTRACT.
  • mergeContacts sets active_merge_event_id = mergeEvent.id when archiving the source; unmergeContacts clears it on revival and the supersede guard now compares source.active_merge_event_id !== mergeEvent.id. A forUpdate() lock on the source row makes the check race-safe (two concurrent unmerges of the in-effect event can't both pass).
  • Migration 104_track_active_merge_event adds the column/FK/index to every existing tenant and backfills the pointer for contacts merged before the column existed (matching the in-effect event by target, deterministic via ORDER BY created_at DESC, id DESC LIMIT 1), so legacy in-effect merges remain correctable.

A localised "newest event for source" query was deliberately avoided: contact_merge_events.id is gen_random_uuid() (not time-ordered), so ORDER BY created_at DESC can't break created_at ties deterministically, and the table is unindexed for that lookup. The column is a single source of truth indexed by the existing PK.

Testing

  • New integration test (unmergeContacts same-target re-merge): merge A → unmerge A → merge B (same pair) → unmerge A must throw superseded → B stays in effect and is still correctable → a second reversal of either A or B also throws. Reproduces the exact gap the original tests missed (same-target re-merge then reversing the stale event).
  • Existing integration tests (mergeContacts, unmergeContacts restores/skips/no-such-event, suggestContactMerges placeholder addresses) still pass — no regression to the legitimate unmerge, the moved-on skip, or the merge invariants.
  • Tenant-schema provisioning test: a freshly-created tenant schema gets the new column + FK + partial unique index; the satisfies TenantDatabase contract still compiles.
  • Migration backfill verified against a live tenant: a pre-fix merged contact (active_merge_event_id null) is backfilled to the correct in-effect event id, so it does not become falsely un-correctable.
  • DB-level objects verified via catalog queries: the nullable uuid column, the contacts_active_merge_event_fk FK, and the partial unique index are all present; deleting the active merge event is blocked by the FK RESTRICT.
  • Concurrent-unmerge safety: two simultaneous unmergeContacts(B) calls on the same in-effect event → exactly one succeeds, the other throws superseded (the forUpdate() lock serializes them).
  • Route-level end-to-end against a running API: POST /contacts/:target/merge/merges/A/unmerge → re-merge → /merges/A/unmerge returns 400 { "error": "…already been superseded…" } (not 200); the in-effect event B then unmerges successfully, and the audit_logs table contains exactly two contact.unmerged rows (A then B) — no audit row is written for the rejected stale reversal, confirming the audit fingerprint is fixed.
  • Unit tests, typecheck (tsc -p apps/api/tsconfig.json --noEmit), @wateaminbox/database build, biome lint, and bun run --filter @wateaminbox/database db:migrate (incl. an idempotent re-run) all pass.

Closes #378


Automatic Fixes PRs can be configured here.

@detail-app
detail-app Bot requested a review from setkyar September 11, 2026 21:56
@setkyar
setkyar force-pushed the detail/bug-fix/fix-api-reject-stale-same-target-merge-reversals-v-b91d2b branch from 9c1b36a to 2daf746 Compare September 12, 2026 14:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant