Duplicate leads: how should merging actually work?

A decision on the architecture for handling one human who shows up as two lead records — prompted by the second production incident of the same shape.

2026-08-30 · Status: Proposed — pending Fede's review · Full research: 90 sources across CRM, healthcare, and master-data-management practice

The pointer isn't the problem. Where the rule lives is. Today the "she's really over there" note is something every piece of code must independently remember to read. That's the classic soft-delete trap, and it fails every time a new writer is added — which, with AI agents writing much of our code, is constantly. The research is unanimous: either put resolution behind one door nothing can bypass, or restructure so there is no note to forget.

What happened (twice)

A prospect reached out on two channels, so two records existed. We correctly spotted the duplicate and closed one, leaving a note pointing at the survivor. Days later his rental application arrived from AppFolio. The sync looked for "which record holds this AppFolio link," found the closed one, wrote the application to it, and brought it back from the dead — two rows in the pipeline again, and the real record never learned he applied. The same shape hit us in June (application spawned a duplicate) and in August (application landed under a new person and the toured guarantor kept getting "did you apply?" texts). Each fix patched one door; each incident came through a different door.

What the research found

The decision — pick one

Option 1 — Harden what we have

~1 week · kills the write-path bug · still one bypass away from a repeat

Keep the closed-record-plus-pointer design, but make the pointer a real field instead of prose, route every lookup through one resolver function, and — the key piece — attach a storage-level condition to every write so that writing to a merged-away record fails loudly instead of reviving it. Precedent: Salesforce's lead conversion + Stripe's "deleted customers still read, never write."

Risk: it's a discipline-preserving fix, and discipline is what keeps failing. Reads of dead records still quietly succeed unless we also add the event and read-resolver.

Option 2 — One identity answer: "who is this?" RECOMMENDED

~2–3 weeks incl. migration · removes the reason the bug is possible · reversible merges

Give every real person one stable internal ID. Every other identifier that has ever referred to them — old duplicate records, the AppFolio guest-card link, phone numbers, emails — becomes an entry in a lookup index that resolves to that one live identity. The AppFolio sync stops asking "which record holds this link?" (the question that found the corpse) and starts asking "who is this?" — and can only ever get the live answer. Writes to a merged-away record fail loudly, enforced by the database itself, not by convention. Each merge is stored as its own small record (who decided, when, on what evidence), so a wrong merge can actually be undone — nothing was moved, so nothing needs reconstructing.

This is the pattern every surveyed industry independently converged on (CRM integrators' "external ID" practice, master-data "crosswalks," healthcare's deprecated-but-still-resolvable old identifiers). Cheap add-on worth including: publish a "these two records were merged" event so anything we integrate later can react — the one thing that saved Zendesk's pointerless design and the one thing whose absence sank Microsoft's.

Risk: the migration of existing merged records must be complete and verified — a half-migrated index is worse than none.

Option 3 — Never merge at all: group records like households

~4–6 weeks · the best long-term model · touches every consumer

Stop merging records entirely. The phone lead, the web form, the AppFolio card each stay exactly as they arrived — and a grouping (same shape as our existing household concept) says "these are one person." Everything reads the group. Undoing a wrong match becomes trivial and lossless; "these are definitely NOT the same person" becomes expressible (no other option can say that); the AppFolio sync writing to "its" record stops being a bug at all. This is where the entity-resolution field has landed.

Risk: every consumer (UI, Clara's context, reporting, agent tools) must learn to read groups; a half-migration is worse than either endpoint. Note: Option 2 is a strict subset of this — choosing 2 now keeps 3 open, and the alias index becomes the group's membership edges.

Option 4 — Move everything to the survivor at merge time (Salesforce-style)

not recommended

Physically move every application, tour and message onto the survivor and leave an empty shell. After the merge there's nothing to forget — but it doesn't fix our incident (AppFolio still holds a key we can't rewrite, so we'd need most of Option 2 anyway), a crash mid-move leaves records silently split across two rows, and it destroys the history that makes un-merge and fair-housing reconstruction possible. Wrong choice for a domain where an AI does the matching and will sometimes be wrong.

Do these regardless of the option (the incident-class killers)

Immediate state (already handled)

The live incident was hand-repaired on Aug 30: the application was moved to the surviving record (which now correctly shows Application Review), the resurrected duplicate was re-closed and its follow-up cadence stopped, and a later sync pass was observed landing on the right record without reviving the duplicate. Both records were backed up before the repair. The systemic fix — this decision — is what prevents the fourth recurrence.

Open questions to decide alongside

Full research report (90 sources: Salesforce, HubSpot, Zendesk, Dynamics, Intercom, Segment, Stripe, HL7/FHIR, IHE, AHIMA, Epic/Cerner/Oracle patient-index practice, Reltio, Informatica, Senzing, Splink, AWS Entity Resolution, Wikidata/MediaWiki, Stack Overflow, Cassandra) available on request — session agents-003, 2026-08-30. Two effort caveats from the report: estimates assume a codebase audit confirms the prospect-writer count is single-digit, and several vendor claims are search-excerpt-sourced where doc sites blocked fetching.

PropFlow Docs