← back to the log

$ cat ~/log/same-email-two-different-people.md

same email, two different people.

aug 04 2026 · 6 min read #typescript#identity#llm#data
Two Roman bronze portrait busts of young men side by side, hollow-eyed, near-identical at a glance but clearly two different people.

Prism is a Slack agent I’ve been building that writes a pre-call brief on every prospect before a rep walks into a meeting. Before it can write anything it has to answer one question: who is this person? A calendar invite gives us an email address and close to nothing else, so we hand that email to a paid people-data provider and get a person back.

Then we handed one email to two providers and got two different humans. In this article we’ll go through what that broke, why the obvious fix is the worst available option, and the clustering we ended up with instead.

The disagreement

Same email, run through two paid providers:

  • RocketReach gave us Satyanarayana Lokam, a researcher.
  • MixRank gave us Satya Nadella.

Both providers paid, both flagged verified, neither of them hedging in the slightest.

That wasn’t the first sign either. Earlier we’d tried the obvious disambiguation — pass name plus company rather than just the email, name=Satya Nadella, current_employer=Microsoft — and RocketReach came back with Sravya Nadella, an SWE2 in Hyderabad. A namesake. it returned her without a flinch, at full confidence.

Why not just merge them

The first instinct is to reconcile. Pick a winner provider, or merge both records into one profile and keep the union of the fields. I’d argue against both, and merging is much the worse of the two.

Merge those two records and you get Satya Nadella’s job title stapled onto a researcher’s publication history. Then the LLM does exactly what it’s supposed to do with the context it’s given, and writes a fluent, specific, well-sourced brief about a person who does not exist. The rep reads it on the way in and opens with something the other person has never done.

that’s the failure mode worth designing against. A missing brief is annoying, and the rep walks in cold — which is what they did before Prism existed anyway.

A missing brief is annoying. A confidently wrong one is what a rep can’t recover from mid-call.

Clustering instead of reconciling

So we don’t reconcile. We cluster, and when the clusters disagree we stop and ask a human.

The clustering is anchored on LinkedIn and it is deliberately conservative:

  • Same normalized LinkedIn URL means same person. That’s authoritative.
  • A doc that has a LinkedIn never merges into a cluster carrying a different one.
  • Docs with no LinkedIn fall back to matching on normalized name, but only when that merge won’t quietly glue two different LinkedIns together.

That last condition is the one that matters, and it’s the (!c.li || !li) in the name fallback:

// src/enrichment/identity.ts
export function clusterByIdentity(results: SourceResult[]): IdentityCluster[] {
  const clusters: IdentityCluster[] = []
  for (const result of results) {
    if (!result.success) continue
    const doc = docOf(result)
    if (!doc) continue
    const rawLi = str(doc.linkedin_url)
    const li = isAnchoringLinkedin(rawLi) ? normalizeLinkedin(rawLi) : ''
    const nm = normalizeName(str(doc.name))

    let cluster = li ? clusters.find((c) => c.li === li) : undefined
    if (!cluster && nm) {
      // name fallback — merge only when we won't be fusing two DIFFERENT LinkedIns
      cluster = clusters.find((c) => c.nm === nm && (!c.li || !li))
    }

    if (cluster) {
      if (li && !cluster.li) {
        cluster.li = li // a LinkedIn-less cluster gets upgraded to a LinkedIn identity
        cluster.key = li
        cluster.candidate.clusterKey = li
      }
      if (!cluster.candidate.backingSources.includes(result.sourceName)) {
        cluster.candidate.backingSources.push(result.sourceName)
      }
      cluster.results.push(result)
    } else {
      const key = li || (nm ? `name:${nm}` : `unknown:${clusters.length}`)
      const candidate = candidateFrom(doc, key)
      candidate.backingSources.push(result.sourceName)
      clusters.push({ key, candidate, results: [result], li, nm })
    }
  }
  return clusters
}

The conflict test afterwards is just clusters.length >= 2. Two surviving clusters means two verified providers are describing different people, and at that point enrichAttendee doesn’t return a profile at all. It returns a question.

// src/enrichment/enrich.ts
if (clusters.length >= 2) {
  const confirmed = person.confirmedLinkedinUrl ? normalizeLinkedin(person.confirmedLinkedinUrl) : ''
  chosen = confirmed ? clusters.find((c) => c.key === confirmed) ?? null : null
  if (!chosen) {
    await applyEnrichment(person.id, { idUpdates })
    return { status: 'needs_choice', personId: person.id, candidates: clusters.map((c) => c.candidate) }
  }
}

In Slack that surfaces as a card asking which of these people you’re meeting. The answer is stored as confirmedLinkedinUrl on the shared person row, so the next time anyone in the workspace preps a meeting with them the whole block short-circuits on the first line above. You get asked once, ever.

Two things that bit us on the way

Legacy LinkedIn URLs faked a conflict. Our normalization drops the query string, which is correct for modern /in/slug URLs. But a legacy linkedin.com/profile/view?id=12345 URL normalizes down to a bare .../profile/view, which identifies nobody. Two records for the same person, one modern and one legacy, looked like two different people and fired a completely pointless “pick a person” card at the rep. The fix is to mark those URLs as non-anchoring, so they’re treated as if the doc had no LinkedIn at all:

// src/enrichment/identity.ts
/** Legacy `linkedin.com/profile/view?id=…` URLs lose their query in normalization, so they
 *  can neither distinguish nor identify a person — treat them as if the doc had no LinkedIn. */
export function isAnchoringLinkedin(url: string | null | undefined): boolean {
  const n = normalizeLinkedin(url)
  return n !== '' && !n.endsWith('/profile/view')
}

What you persist when you park matters. When we return needs_choice we do save the provider IDs we discovered, so that when the rep picks, the resume path is a pure cache read and we don’t pay for the same lookups twice. IDs only, though — merging both candidates’ identity arrays at that moment is precisely the pollution this whole mechanism exists to prevent.

We also deliberately do not stamp the negative cache at park time, because the soft tier hasn’t run yet at that point. Stamping there would make the resume skip web research on every single conflict, which is a story of its own — over in the cache that buried people for a week.

What actually changed

The clustering code isn’t the interesting part of this. What changed for me was realising I’d been asking the wrong question for weeks. I kept asking which source to trust. The question that actually pays is when are the sources describing different realities, and what do we do about it.

For nearly every field, the answer is boring: gather everything, keep it side by side, let the model see all of it and cite what it uses. Identity is the exception — there a blend isn’t noisy, it’s wrong. so that’s the one place a human gets asked.

Clay, Apollo and ZoomInfo all resolve this silently, and I understand why — a card asking the user a question is friction, and friction is the enemy of a smooth demo. we deliberately don’t, because a silently wrong identity is the single most expensive error this system can make.

Further reading

  • Splink — probabilistic record linkage at a scale where asking a human isn’t on the table. Useful contrast to the conservative approach here.
  • RFC 2308 — negative caching, which is where the second half of this story goes.
gurprit
full-stack engineer · ai-focused
work with me