Context
A company’s customer exists in several systems under different keys, and an anonymous visitor becomes a known customer at some point in their journey. Resolving that is the reason a customer core exists at all. It is also the most dangerous operation in the platform. A false merge combines two real people — their purchase history, their points, their consent — and unwinding it is close to impossible because the merged state has already been read, billed against and acted on. Every decision here is therefore biased toward refusing to merge when uncertain, which is why bulk imports produce a review queue rather than a resolution (DEC-A6).Scope (normative)
core.contact_identities:(provider, identifier)pairs pointing at a contact.- Anonymous → known promotion: an anonymous id acquires a known identity without losing history.
- Deterministic resolution on verified identifiers.
core.contact_merges: immutable record of survivor, absorbed and evidence.- Merge execution: identities and events move to the survivor; the absorbed contact becomes a tombstone.
core.contact.mergedso consumers can repoint their own references.
Non-scope (normative)
- Probabilistic or fuzzy matching. F1 merges on verified identifiers only. Name similarity matching is how false merges happen, and it is not worth the recall.
- Un-merging. Deliberately not built: it is the operation whose existence encourages careless merging. Recovery is a restore.
- The import review queue UI — FS-CORE-0013 owns the queue; this owns the merge it executes.
Behaviour (normative)
- An identity is
(provider, identifier)unique per tenant.providernames the source system —email,phone,pos,ecommerce,national_id. - Identities carry a
verifiedflag. Only verified identities drive automatic resolution. An unverified email is a claim, not an identity. - Anonymous → known promotion never loses history: the anonymous identity stays attached to the same contact, so the events collected before login remain attributed.
- Automatic merge happens only on an exact match of a verified identifier. Anything weaker produces a merge suggestion, never a merge.
- A merge is one transaction: identities repoint, events repoint, the absorbed contact becomes a tombstone, the merge row is written, the event is emitted, the audit row is written.
- The survivor keeps every identity of both sides. Losing an identifier in a merge would break the integration that supplied it.
core.contact_mergesis append-only and immutable, and records the evidence that justified the merge. Without the evidence, a merge is unauditable.- The absorbed contact id keeps resolving — to the survivor. An integration holding the old id must not break.
- Conflicting field values resolve by an explicit, documented policy (most recently updated non-null wins), and the discarded values are recorded in the merge row rather than lost.
- FORBIDDEN: merging across tenants, under any circumstance.
Data (normative)
API (normative)
Events (normative)
core.contact.merged, available as an outgoing webhook, carrying survivor and absorbed ids so a
consumer can repoint its own references.
Acceptance criteria (normative)
- An anonymous contact with 50 events, promoted through a verified email, keeps all 50 attributed.
- An exact verified-identifier match merges automatically; an unverified match produces a suggestion and no merge.
- After a merge the survivor holds the union of both identity sets, with none dropped.
- The absorbed contact id still resolves to the survivor through every read path.
- A merge with an induced failure at the event-repoint step rolls back entirely — no partial merge exists at any point.
contact_mergesrows cannot be updated or deleted, proven by a test that attempts both.- Discarded conflicting values are recoverable from the merge row.
- Negative: a merge attempt across two tenants is rejected before any write.