> ## Documentation Index
> Fetch the complete documentation index at: https://internal.softcrum.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Identity Resolution and Merges

> After this ships, the same person arriving from a point of sale, an ecommerce and an email link becomes one contact instead of three.

## 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.merged` so 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)*

1. An identity is `(provider, identifier)` unique per tenant. `provider` names the source system —
   `email`, `phone`, `pos`, `ecommerce`, `national_id`.
2. Identities carry a `verified` flag. **Only verified identities drive automatic resolution.** An
   unverified email is a claim, not an identity.
3. Anonymous → known promotion **never loses history**: the anonymous identity stays attached to
   the same contact, so the events collected before login remain attributed.
4. Automatic merge happens only on an **exact match of a verified identifier**. Anything weaker
   produces a merge *suggestion*, never a merge.
5. 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.
6. The survivor **keeps every identity** of both sides. Losing an identifier in a merge would break
   the integration that supplied it.
7. `core.contact_merges` is **append-only and immutable**, and records the evidence that justified
   the merge. Without the evidence, a merge is unauditable.
8. The absorbed contact id keeps resolving — to the survivor. An integration holding the old id
   must not break.
9. 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.
10. FORBIDDEN: merging across tenants, under any circumstance.

## Data *(normative)*

| Table                     | Key invariants                                                                                                                                         |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `core.contact_identities` | unique (`tenant_id`, `provider`, `identifier`); FK `contact_id`; `verified` BOOL; `verified_at`; `first_seen_at`                                       |
| `core.contact_merges`     | append-only, immutable; `survivor_contact_id`, `absorbed_contact_id`; `evidence` JSONB; `discarded_values` JSONB; `merged_by` typed actor; `merged_at` |

## API *(normative)*

| Endpoint                                | Class      | Permission            | Budget    |
| --------------------------------------- | ---------- | --------------------- | --------- |
| `POST /v1/core/contacts/{id}/merge`     | Management | `core.contacts.merge` | p95 \<1 s |
| `GET /v1/core/contacts/{id}/identities` | Management | `core.contacts.read`  | p95 \<1 s |

## 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)*

1. An anonymous contact with 50 events, promoted through a verified email, keeps all 50 attributed.
2. An exact verified-identifier match merges automatically; an unverified match produces a
   suggestion and no merge.
3. After a merge the survivor holds the union of both identity sets, with none dropped.
4. The absorbed contact id still resolves to the survivor through every read path.
5. A merge with an induced failure at the event-repoint step rolls back entirely — no partial
   merge exists at any point.
6. `contact_merges` rows cannot be updated or deleted, proven by a test that attempts both.
7. Discarded conflicting values are recoverable from the merge row.
8. **Negative:** a merge attempt across two tenants is rejected before any write.

## Execution

Synchronous command. The resolution path is also called from the ingestion processor
(FS-CORE-0006), which is why the service must be safe to call concurrently for the same contact —
the merge takes a row lock on both sides.

## Open questions

| # | Question                                                                                   | Decides | By              |
| - | ------------------------------------------------------------------------------------------ | ------- | --------------- |
| 1 | Is a national ID match automatically a verified identity, or does it need a second signal? | Daniel  | before approval |
| 2 | Field conflict policy: most-recent wins, or survivor always wins?                          | Daniel  | before approval |

## Changelog

| Version | Date       | Change        | Why | Author                 |
| ------- | ---------- | ------------- | --- | ---------------------- |
| 0.1.0   | 2026-08-17 | Initial draft | —   | daniel + claude-opus-5 |

## Delivery record

*Not implemented yet.*
