fix(api): reject stale same-target merge reversals via active merge event pointer - #383
Open
detail-app[bot] wants to merge 1 commit into
Conversation
setkyar
force-pushed
the
detail/bug-fix/fix-api-reject-stale-same-target-merge-reversals-v-b91d2b
branch
from
September 12, 2026 14:03
9c1b36a to
2daf746
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Detail bug report: View on Detail
Bug
unmergeContactsis the correction path that reverses acontact_merge_eventsrow, and its stated invariant is that only the merge currently in effect may be reversed. The supersede guard comparedsource.merged_into_contact_id(a surviving contact id) againstmergeEvent.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-mergeS→T(event B, now in effect) →unmergeContacts(A). BecauseS.merged_into_contact_idwas stillT, the guard for A passed, A's endpoint reassignment rows were replayed back ontoS, andSwas revived — leaving B orphaned (contact_merge_events+contact_endpoint_reassignment_eventsrows contradicting live state) and un-correctable (unmergeContacts(B)then threw superseded). The audit log recordedcontact.unmergedfor 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:
contacts.active_merge_event_id UUID(FK →contact_merge_events(id),ON DELETE RESTRICT,NOT VALID) plus a partial unique index…_c_ame_uidxon(active_merge_event_id) WHERE active_merge_event_id IS NOT NULL(at most one active merge pointer per contact). Provisioned for new tenants inensureChannelSpineTenantSchema; mirrored inTenantDatabase/TENANT_SCHEMA_CONTRACT.mergeContactssetsactive_merge_event_id = mergeEvent.idwhen archiving the source;unmergeContactsclears it on revival and the supersede guard now comparessource.active_merge_event_id !== mergeEvent.id. AforUpdate()lock on the source row makes the check race-safe (two concurrent unmerges of the in-effect event can't both pass).104_track_active_merge_eventadds 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 viaORDER 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.idisgen_random_uuid()(not time-ordered), soORDER BY created_at DESCcan't breakcreated_atties deterministically, and the table is unindexed for that lookup. The column is a single source of truth indexed by the existing PK.Testing
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).mergeContacts,unmergeContactsrestores/skips/no-such-event,suggestContactMergesplaceholder addresses) still pass — no regression to the legitimate unmerge, the moved-on skip, or the merge invariants.satisfies TenantDatabasecontract still compiles.active_merge_event_idnull) is backfilled to the correct in-effect event id, so it does not become falsely un-correctable.uuidcolumn, thecontacts_active_merge_event_fkFK, and the partial unique index are all present; deleting the active merge event is blocked by the FKRESTRICT.unmergeContacts(B)calls on the same in-effect event → exactly one succeeds, the other throws superseded (theforUpdate()lock serializes them).POST /contacts/:target/merge→/merges/A/unmerge→ re-merge →/merges/A/unmergereturns400 { "error": "…already been superseded…" }(not200); the in-effect eventBthen unmerges successfully, and theaudit_logstable contains exactly twocontact.unmergedrows (A then B) — no audit row is written for the rejected stale reversal, confirming the audit fingerprint is fixed.tsc -p apps/api/tsconfig.json --noEmit),@wateaminbox/databasebuild, biome lint, andbun run --filter @wateaminbox/database db:migrate(incl. an idempotent re-run) all pass.Closes #378
Automatic Fixes PRs can be configured here.