Skip to content

fix(crm): bind merge application to the proposal that was reviewed - #718

Open
kmonsoe wants to merge 1 commit into
fix/kb-publish-bound-to-reviewed-versionfrom
fix/crm-merge-bound-to-reviewed-proposal
Open

fix(crm): bind merge application to the proposal that was reviewed#718
kmonsoe wants to merge 1 commit into
fix/kb-publish-bound-to-reviewed-versionfrom
fix/crm-merge-bound-to-reviewed-proposal

Conversation

@kmonsoe

@kmonsoe kmonsoe commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

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_proposal took only { id }, same as the others. The difference is the mutation path and the absence of any tell.

crm_propose_merge does not always create a proposal. On a pair that already has a pending one it updates that row in place (crm.service.ts:1046), overwriting confidence, evidence, recommendedKeeperId and recommendedPatch under the same id. And crm_merge_proposals has none of the review tracking outreach_proposals has: no revisionCount, no revisedAfterReviewAt, no firstViewedAt (schema.ts:1807 shows 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: true and a cleared endUserId, 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

mergeFingerprint over (contactAId, contactBId, recommendedKeeperId, confidence, recommendedPatch), computed on read, no column and no migration. Required by crm_apply_merge_proposal({ id, fingerprint }) and by POST /v1/crm/merge-proposals/:id/apply. A mismatch is a 409 with crm_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_contact can 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.

evidence is 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_conflict and leaves the proposal pending.

skill://crm/clean-contact-data updated, 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 / lastRevisedAt columns on crm_merge_proposals so 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

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>
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.

1 participant