# Merge playbook

Rules for turning reviewed duplicate groups into a merge plan a person can apply. The skill writes the plan; a person with merge rights applies it in the CRM. Template: [../assets/merge-plan-template.csv](../assets/merge-plan-template.csv).

## Checking the plan, and the sign-off stop

- The agent fills every column except approver, approved_at and consent_reviewed, which only the named owners fill.
- At hand-over, run `scripts/check_merge_plan.py` with `--draft` and `--profile`: missing sign-offs print as PENDING, everything else that blocks prints as BLOCK. Hand over only at 0 BLOCK.
- After the CRM owner (and the privacy owner, for opted-out or bounced records) sign, run it again without `--draft`. It must print 0 BLOCK before anyone merges.
- `--profile` adds the unique keys the export holds, so two records with different keys are blocked even when the plan's unique_key_value column was left blank, and it blocks any converted Salesforce lead in a lead merge.

## Survivor selection, in this order

- 1. The record an integration is paired with or keyed on: the HubSpot contact already syncing to the intended Salesforce record, the holder of the billing or product key. Choosing the other one makes the next sync recreate the loser.
- 2. The record with more associations and logged activities: fewer moves, less association-limit risk.
- 3. The record whose owner is an active person and matches the routing policy.
- 4. The oldest create date as the tie-breaker (HubSpot and Salesforce keep the oldest create date anyway).
- Never choose by most recently updated (syncs and imports make incomplete records look new; HubSpot's bulk "Most recently updated" criterion also evaluates hidden internal properties), lowest ID, or fill rate alone. duplicates.csv labels its survivor as a suggestion with the deciding reason.

## Field survivorship and consent

- A field with a system of record in `data-dictionary.md` keeps the authoritative value, whichever record holds it.
- Otherwise keep the survivor's value and take the loser's only where the survivor is blank (HubSpot's default).
- Email: in HubSpot the survivor's primary stays primary and the loser's becomes an additional address. Marketing email goes to the primary, and email filters in segments, workflows and reports read the primary.
- Never let a merge widen permission. HubSpot keeps the most marketable marketing status and the most recent legal basis values; bounces and unsubscribes attach to addresses. When either record is opted out or bounced, the privacy owner decides whether the restriction applies to the person, and the plan records consent_reviewed = yes on every row of the group.
- Lifecycle: HubSpot keeps the stage furthest down the funnel, so a false Customer on either record becomes the merged value. Fix the false Customer in the change list, or accept it only when the survivor really is a customer.
- Original Traffic Source: HubSpot keeps the oldest unless it was set manually.

## Merge blocks (do not merge)

- Different non-blank unique keys: these are different source records.
- Any row in the possible band. Re-band to probable only after review, with the new evidence written in the evidence column.
- A role or shared mailbox with different person names.
- A shared domain without the same unique key (business units, subsidiaries, resellers) until the account boundary decision exists.
- Salesforce: a person account with a business account; a chosen master that sits below another selected record in the account hierarchy; opportunities (the merge covers leads, contacts and accounts, person accounts included, not opportunities: move contact roles and activities, then close the duplicate through the change list).
- Salesforce: a converted lead in any merge group, whatever the UI allows. Convert the open lead into the existing contact instead (D07); folding a fresh inquiry into a converted lead parks it on a record nobody works. check_merge_plan.py blocks it when given `--profile`.
- HubSpot: records already in 250 combined merges; a result that would exceed association limits; company merges while the old version of the HubSpot-Salesforce integration is installed.
- HubSpot connected to Salesforce: keep the HubSpot contact already syncing to the intended Salesforce record as survivor. A Salesforce merge can delete the paired HubSpot contact when deletion sync is on for the losing record, so check the integration's deletion settings first and never run unrelated bulk merges in both systems at once.
- Attio: deals, workspaces, users and custom objects cannot be merged in the UI.
- Pending workflow or sequence steps the merge would cancel: outside the Primary ID Preservation beta, HubSpot unenrolls both records from active workflows, and "Automatically enroll merged records" is off by default.

## Mechanics and ID effects

- HubSpot: two records of one object at a time (contacts, companies, deals, tickets, custom objects and other supported objects). The result gets a new Record ID by default; the Primary ID Preservation beta keeps the primary's ID. "Merged [record] IDs" keeps the old IDs. Merges cannot be undone. Merge history export covers 90 days and only merges made in the data quality tool; activities can take up to 30 minutes to appear.
- Salesforce: up to three records of one object per merge (accounts, person accounts, contacts, leads). The survivor keeps its ID; losing records go to the Recycle Bin with MasterRecordId pointing at the survivor (readable through Export All or queryAll). Created By and Created Date come from the oldest record. Child records reparented by the merge do not fire their own Apex triggers. Restoring a loser from the Recycle Bin does not restore lookups the merge already replaced.
- Attio: two records at a time, people and companies in the UI. The result is a new record ID and URL; the record on the right wins conflicts; a `record.merged` webhook fires once per original with the new ID; deleted records cannot be recovered and can be recreated by later email or calendar activity.

## Batch protocol and read-back

- 1. Fix or pause the writer that created the duplicates first (change list item with an owner). Merging while the integration still creates duplicates is a treadmill.
- 2. Groups are disjoint: each record in one group only.
- 3. First batch: one group per creation path, merged by a person, then read back.
- 4. Read back the resulting ID, lineage ("Merged IDs" or MasterRecordId), selected field values, required associations and activities, marketing status and subscriptions, and workflow state. Fill result_record_id and executed_at.
- 5. Update the crosswalk and every external system that stores a losing ID. A missing lookup must never trigger "create", or the first sync after the merge recreates the duplicate.
- 6. Expand in batches of up to 25 groups only after the first batch reconciles; stop at the first mismatch.
- 7. Rerun the profiler: merged groups gone, no new group from the same creation path, losing IDs listed under "records missing since the previous profile".

## Crosswalk format

Keep one CSV per merge run, outside the CRM and outside any repository:

- old_record_id, resulting_record_id, object, crm, merge_date, group_id, approver, systems_updated (billing, product, warehouse, marketing tool), verified_at.

## Recovery limits

- No unmerge in HubSpot or Attio. Recreating a record later is not an undo.
- HubSpot "Restore CRM changes" rolls back imports, integrations and workflows for 14 days (Super Admin, Starter or above). It does not reverse merges.
- Salesforce losers sit in the Recycle Bin for 15 days, then are scheduled for permanent deletion with no guaranteed exact time.
- Keep a before-export of every record in the plan. It documents what changed; it does not restore relationships or activity history.

## Two entries from the Acme example

- G1 contacts, certain: survivor 1001, merge 1002. Both carry Product User ID PU-001. 1001 holds deal 7001 and an active owner; 1002 was created by Billing Sync and is a false Customer (L01). external_ids_to_update on 1002: the product user directory maps PU-001 to the resulting ID. HubSpot keeps Customer, which is correct for the person once merged with 1001.
- G2 contacts, probable: survivor 1003, merge 1004 (the plus-tag address, unsubscribed from all email). The privacy owner decided the unsubscribe applies to the person, so consent_reviewed = yes on both rows. Unsubscribes attach to addresses and the merged record keeps the most marketable status, so after the merge the survivor's primary address would still be subscribed and billed: change list item CL-04 unsubscribes that address from all email and sets the record non-marketing.
- check_merge_plan.py on the full Acme plan, the version the CRM owner signed on 2026-11-03, prints 0 BLOCK, 0 WARN, 0 NOTE and a six-line sequence.
- Non-merge fixes (owners, lifecycle, suppression, associations) go in change lists: [cleanup-plan.md](cleanup-plan.md).
