Notes

One client, one record: email-first resolution

AI assistants split contacts because names vary between captures. Historis resolves people by email first: one client, one record, enforced by the database.

Ask your assistant to log a call and it will name the caller: "Jean Dupont" today, "J. Dupont" tomorrow, just "Dupont" next week. Names are how people and models refer to each other; they are terrible identity keys. A CRM that creates a contact for every spelling ends up with three copies of the same client, each holding a slice of the history. Historis treats this as an identity problem. On the MCP capture path, a person reference resolves by email first; a name match can enrich a record but never override one; and a name that looks like an existing contact becomes a question instead of a blind create. One client, one record, enforced below the model like every other write guarantee.

A name is a reference, not an identity

The failure mode is invisible at write time. Your agent captures "call with Dupont about the trade-in"; no contact carries exactly that name, so it politely creates one. No error, nothing to review. Just a silent fork. Two weeks later half the story lives on "Dupont", the other half on "Jean Dupont", and the timeline that was supposed to be this client's memory reads like two strangers.

So a capture reference in Historis is { name, email? }, and the two fields do different jobs: the name is display material; the email, when you have it, is the identity.

Email decides, name enriches

// person_refs: [{ name: "J. Dupont", email: "jean@..." }]
// 1. The address decides, whatever the name variant says
const byEmail = await findVisiblePersonByEmail(org, email)
if (byEmail) return link(byEmail)

// 2. A name match may only ENRICH a contact with no address yet
const sameName = await findPersonByName(org, name)
if (sameName && !sameName.email) {
  await db.from('persons').update({ email })
    .eq('id', sameName.id)
    .is('email', null) // guards the check-then-set race
  return link(sameName)
}

// 3. Same name, different address? Another person: create.

Step 2 is deliberately narrow. An email-less "Jean Dupont", created from an earlier name-only capture, is almost certainly the same client, so the address is backfilled onto that record and every future capture resolves by email directly. But a "Jean Dupont" who already owns a different address is presumed to be someone else. Email outranks name in both directions: an address match wins regardless of the name, and a name match never overrides an address. Every lookup here is also visibility-scoped, like everything on the agent surface. A reference can only ever resolve to a contact you could see yourself.

Can two agents create the same contact twice?

No. And resolution has to survive that test, because capture is exactly the workload that races: two assistants processing the same inbox, a retried sync, a bulk import next to a live capture. The email key is a unique index per organization, so the database closes the race the same way it makes event writes idempotent. One insert wins; the other recovers the winner's row and links to it. When that contact sits outside the caller's visibility, the losing capture skips the link rather than mislink. Either way, a second row for the same address cannot exist.

The same holds within a single call, where two references carrying the same address are the same person whatever their names say, and a resolved contact is also remembered under its real display name, so a payload about a client you already track mixing { "name": "J. Dupont", "email": "jean@..." } and a bare { "name": "Jean Dupont" } links that one contact, in either order.

What happens when there is no email?

Email is not always there. At the counter it is "Dupont came by, picked up the bike". No address, maybe not even a first name. This is where name-keyed CRMs quietly fork, so here Historis refuses to create blind: before auto-creating a name-only contact, the server fuzzy-matches the name against the contacts you can see.

-- keep candidates scoring >= 0.7
GREATEST(
  similarity(display_name, ref_name),      -- spelling variants
  word_similarity(ref_name, display_name)  -- 'Dupont' inside 'Jean Dupont'
)

The two operators cover the two real-world forks. Trigram similarity catches the spelling drift because a one-character typo on a full name still scores about 0.73 and gets flagged. Word similarity catches the partial name: "Dupont" aligns to whole words inside "Jean Dupont" and scores near 1.0, while a short name merely embedded mid-word does not ("Ann" inside "Joanne" stays around 0.25, so an existing "Joanne" never blocks creating "Ann"). "Jean Boulanger" next to "Marie Boulanger" scores about 0.63, below the floor, so a family of regulars does not turn every capture into a quiz.

On a hit, nothing is created and nothing is auto-linked. Instead the reference is held back and returned as ambiguous_persons with the candidates, and your assistant picks, or asks you. Bulk imports get the same guard, skipping possible duplicates instead of manufacturing them. And if the similarity check itself is down, the guard fails open: the capture goes through and the reply says the duplicate check was unavailable, because silently blocking your writes would be the worse failure.

The full resolution order, in one place:

Incoming referenceExisting contactOutcome
Carries an emailSame address on a visible recordLinks to that record, whatever the name says
Carries an emailSame name, no address on recordBackfills the address and links
Carries an emailSame name, different addressCreates a new contact (presumed homonym)
Name onlyClose match (score >= 0.7)Held back in ambiguous_persons; your assistant asks
Name onlyNo plausible matchCreates a new contact

What can still split

Honest boundaries. A name-only capture whose spelling drifts past the threshold still forks; the floor is calibrated to ask about probable duplicates, not to interrogate you about every neighbour of every name. Two genuinely different clients who share a full name are two records by design; give each an address and they can never be confused again. And a fork that does slip through is repaired by hand today: relink the stray events, delete the empty duplicate. Every agent write is attributed, which makes it obvious which captures to move.

Why it matters

A client history is only worth keeping if it accrues to the right client. The spreadsheet fails this quietly. A new row costs nothing, so every clerk and every agent adds one. Most CRMs fail it politely, with a merge screen you visit once a quarter. Historis keys identity on email and guards the name path; the database closes the races. The timeline your team and its AI build together lands where it belongs: one client, one record.

Related: why letting an AI write to your CRM is safe, the write-side guarantees this identity work sits on.

Frequently asked questions

Why does my AI assistant keep creating duplicate contacts?
Because it refers to people by name, and names vary between captures: "Jean Dupont", "J. Dupont" and "Dupont" read as three people to a CRM keyed on names. Historis resolves references by email first, so variants carrying the same address land on the same record, and a capture under a bare name that resembles an existing contact comes back as a question, not a new record.
What happens when I log a client by name only, with no email?
The server fuzzy-matches the name against the contacts you can see before creating anything. A close match (a partial name, a one-letter typo) holds the reference back and returns the candidates for your assistant to disambiguate; nothing is auto-linked. A name with no plausible match creates a new contact as before.
Can two agents writing at the same time create the same contact twice?
No. Email is enforced unique per organization by a database index, so a duplicate row is impossible. When two captures race, one insert wins and the other recovers the winner's row and links to it; if that row sits outside the losing agent's visibility, no link is made at all. Convergence comes from the database, not from agent coordination.
How does Historis tell apart two clients who share a name?
By email. A same-name contact that already owns a different address is treated as a different person, and a reference carrying an address never links to a name-match holding another one. Two homonyms are two records by design; only a name-only reference that closely matches an existing contact triggers a disambiguation question.

Related posts