fix(crm): bind merge application to the proposal that was reviewed - #718
Open
kmonsoe wants to merge 1 commit into
Open
Conversation
crm_apply_merge_proposal took only { id }, and crm_propose_merge does not
always create a proposal: on a pair that already has a pending one it
updates that row in place, overwriting confidence, evidence,
recommendedKeeperId and recommendedPatch under the same id. Merge
proposals have none of the review tracking outreach has, so the rewrite
was completely silent. A curator pass re-filing a pair with the keeper
flipped changed the card an operator was reading, and their click would
retire the contact they meant to keep.
Applying is not a small write: it copies the patch onto the keeper,
repoints activities, deals and relationships, archives the duplicate with
doNotContact and a cleared endUserId, and auto-dismisses both the pending
outreach proposals for the duplicate and other pending merge proposals
touching it.
Proposals now carry a mergeFingerprint over (contactAId, contactBId,
recommendedKeeperId, confidence, recommendedPatch) and apply requires it.
A mismatch is a 409 crm_conflict: nothing is merged and the proposal
stays pending. The Slack apply button carries the digest in its action
value; the panel and dashboard pass what they rendered.
The digest covers the proposal row, not the contact rows behind it.
crm_update_contact can change a displayed name, but not which record
survives or what patch lands, and digesting live contact fields would
invalidate queued cards on ordinary CRM activity. evidence is excluded
too, so the weekly hygiene pass can refresh its reasoning on a pending
pair without invalidating a queue the operator is working through.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
Stacked on #717, which is stacked on #716. Land order is 716, 717, 718, retargeting each child before its parent merges.
Third and last of the propose-approve binding fixes. This is the worst of the three.
Why it is worse than the other two
crm_apply_merge_proposaltook only{ id }, same as the others. The difference is the mutation path and the absence of any tell.crm_propose_mergedoes not always create a proposal. On a pair that already has a pending one it updates that row in place (crm.service.ts:1046), overwritingconfidence,evidence,recommendedKeeperIdandrecommendedPatchunder the same id. Andcrm_merge_proposalshas none of the review trackingoutreach_proposalshas: norevisionCount, norevisedAfterReviewAt, nofirstViewedAt(schema.ts:1807shows those columns are outreach-only). Outreach at least rendered "revised 2 times" on the card. Here there is nothing.So: agent files merge(Ada, A. Lovelace) keeper Ada. Operator reads it. Agent re-files the same pair with the keeper flipped. Same id, same card, no counter. Operator clicks Apply and Ada is the one retired.
Apply is also not a small write. It copies the patch onto the keeper, repoints activities, deals and relationships on both sides, archives the duplicate with
doNotContact: trueand a clearedendUserId, auto-dismisses every pending outreach proposal for the duplicate, and auto-dismisses other pending merge proposals touching it. Not a hard delete, so not strictly unrecoverable, but unwinding it by hand is not realistic and the dismissed outreach followups do not come back (a dismissed followup permanently ends that sequence).What changed
mergeFingerprintover(contactAId, contactBId, recommendedKeeperId, confidence, recommendedPatch), computed on read, no column and no migration. Required bycrm_apply_merge_proposal({ id, fingerprint })and byPOST /v1/crm/merge-proposals/:id/apply. A mismatch is a409withcrm_conflict; nothing is merged and the proposal stays pending.The patch is canonicalised before hashing (object keys sorted recursively, array order preserved) so jsonb round-trips do not produce spurious conflicts.
The two scoping calls
Contact rows are excluded. The card renders contact names and emails, and
crm_update_contactcan change them. Including them would catch that, but contact rows move for ordinary reasons and a merge queue that sits overnight would start throwing conflicts on untampered work. Operators who see conflicts on legitimate work learn to click through them, which costs more than the case it catches. And the harm is asymmetric: an edited email makes the card stale, an flipped keeper retires the wrong person.evidenceis excluded. This is what makes the weekly hygiene pass compatible with a queue in progress: re-proposing a pending pair with refreshed reasoning leaves the fingerprint alone, so only a changed decision invalidates a review. There is a test for exactly this.Tests
Backend-core green (1363). New coverage: apply refused after a re-propose flips the keeper, asserting the proposal stays pending and neither contact was archived; the fingerprint moves on a changed patch or confidence; a re-propose that only refreshes evidence keeps it stable and still applies; unit tests for patch key-order insensitivity and array-order sensitivity; the Slack apply button carries the digest; a stale Slack button is refused with
crm_conflictand leaves the proposal pending.skill://crm/clean-contact-dataupdated, including a new "don't re-propose a pair with a different keeper while the operator is reviewing it" entry, and fixtures regenerated.Still open
Option 3 from the discussion:
revisionCount/lastRevisedAtcolumns oncrm_merge_proposalsso the card can show what changed rather than just refusing. Needs a migration, no new RLS policy. Worth doing, deliberately not bundled here.🤖 Generated with Claude Code