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

# Lead identity

> Keep a permanent lead reference while preserving capture, conversion and historical tracking identifiers.

# Lead identity

A lead's contact details can change. `canonical_lead_id` is the permanent
reference for a lead in an enrolled dataset; email and phone are evidence used to
find that lead. Lead attributes stay in the dataset row.

Turning identity on for an existing dataset is a platform migration. Adding a
pipeline option alone does not turn it on or backfill anything.

## Lead IDs and conversion IDs

These references mean different things:

| Reference                  | Meaning                                                                        |
| -------------------------- | ------------------------------------------------------------------------------ |
| `canonical_lead_id`        | The permanent lead identity within the organization and dataset.               |
| `lid`                      | A legacy contact-derived tracking reference, retained for compatibility.       |
| Conversion transaction ID  | One acquisition conversion, reused when delivering that same conversion again. |
| Source event or booking ID | One interaction or booking, associated with the lead.                          |

Do not replace an already-reported conversion transaction ID with the permanent
lead UUID. Historical `id`, `lead_id` and `lid` values keep their original
meaning. Their presence alone does not establish permanent person identity.

## Matching

The platform resolves contact evidence against raw dataset rows, including rows
hidden by exclusions. This stops a hidden shared phone number from looking like a
unique match.

* A unique, consistent email or phone match reuses the existing identity.
* Missing contact information can be filled when it does not conflict with another lead.
* When email and phone point to different leads, or contact evidence is ambiguous,
  the capture records the unresolved result instead of guessing or creating a third lead.
* A new eligible contact with no match receives a new permanent ID.

Matching does not remove exclusions or authorize communications. Publicly
supplied identifiers are not authorization to read or update another lead.

## Capture responses

An adopted capture pipeline separates the two identities in its response:

```json theme={null}
{
  "ok": true,
  "canonicalLeadId": "8a610ecd-1388-47b0-bc74-2694996f6d88",
  "leadId": "conversion-transaction-reference",
  "conversionEligible": true
}
```

`leadId` remains the conversion reference for compatible existing clients.
`canonicalLeadId` identifies the lead. A client must honor `conversionEligible`
and must not report a conversion merely because capture succeeded. Historical
leads without established conversion evidence do not become newly eligible
because identity storage changed.

Only the operation that creates the acquisition may report it. A later capture
for the same lead reuses the conversion reference but is not a new acquisition,
so identity resolution alone prevents a repeat submission from double-reporting.
The receipt records the platform's decision; it does not prove an external
analytics service received the event.

A pipeline can carry the producer contract before its dataset is migrated. It
keeps its existing response behavior until the IDs exist, so adoption is safe to
apply first. Once IDs are populated, the dataset refuses legacy writes.

## Historical migration

ID backfill assigns a distinct permanent ID to each existing source row. It does
not merge rows, rewrite historical conversion IDs, or replay capture pipelines.
Existing contacts, timestamps and exclusions are preserved.

Migration is one operator call per dataset.

Start with a preview. It mutates nothing and reports the rows that would be
assigned, the pipeline writers that will take the producer contract, and any
consumer that blocks the migration.

Apply gives every pipeline writer of the dataset the producer contract, then
backfills the IDs and publishes the new dataset revision together with the
identity metadata in one transaction. No new-row arrival event is emitted.

Managed automations need no preparation. `canonical_lead_id` is reserved for
every dataset, generated scripts hide it from their row inputs and content
fingerprints whether or not the dataset is migrated, and each script is re-derived
from its declaration before it runs. Adding the column therefore cannot change
what an automation sees or make `on_update: "act"` replay rows whose business
content did not change.

Apply refuses while any consumer is queued, running, or cannot be re-derived from
a platform-owned declaration, because the backfill rewrites the file all of them
read. A hand-written script is the usual case: it must be adapted or retired
first. The refusal names what blocked it.

### Running a migration

Dataset and automation edit permissions apply. Preview is the default; `apply`
performs the migration.

```bash theme={null}
# preview — mutates nothing, reports readiness and what blocks it
erdo datasets lead-identity maintain acme.acme-leads

# apply
erdo datasets lead-identity maintain acme.acme-leads \
  --apply --operation-key acme-leads-migration-2026-09-11
```

| Endpoint                                                            | Purpose                                                                                                                     |
| ------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `POST /v1/datasets/:datasetSlug/lead-identity/maintain`             | Preview by default; `apply` performs the migration. Addressable by slug or UUID, and callable with an organization API key. |
| `POST /event-pipelines/canonical-lead-adoption/:datasetID/maintain` | The same operation on the first-party session surface.                                                                      |

A preview reports `ready`, the `blockers` holding it up, and how many rows would
be assigned an identity. **Apply refuses outright while any consumer is
incompatible, unverifiable, queued or running**, because the backfill rewrites
the file every one of them reads — and a *disabled* automation still blocks,
since it can be re-enabled later and would then read a file whose shape had
changed.

Scoped API keys — the per-end-user credentials — cannot run a migration, for the
reason they cannot merge: it rewrites the dataset every other lead lives in.

Apply takes a stable `operation_key`. Retry an uncertain apply with the same key
and the committed migration settles it, so a lost response never applies twice.
A different key on an already-migrated dataset is refused rather than migrating a
second time. Nothing else is carried between calls: everything the migration
needs is derived under the dataset write lock at apply time.

## Merging two leads

Some people cannot be connected by contact evidence alone, and the platform
deliberately refuses to guess:

* Two leads share an email address or a phone number, so a capture resolves
  `ambiguous`.
* A submission's email points at one lead and its phone at another, so it
  resolves `conflict`.
* A lead forked before migration, because the old writers matched on email *and*
  phone together and one submission had no phone.
* A phone-only lead returns email-only, with nothing in common.

Merging is how a person settles those. It is an operator action: nothing merges
automatically.

### What a merge does

The absorbed lead stops being its own lead. In one transaction, under the same
per-dataset write lock a capture takes:

* The absorbed identity points at the survivor.
* Both leads' email and phone evidence becomes the survivor's, so a capture that
  would have been refused now resolves to the survivor.
* The two dataset rows become **one row**. An identity table saying "these are
  one person" while the dataset still holds two rows is worse than not merging,
  because every count, export and automation reads the rows.

Rows combine the same way a merge-mode write does. The survivor's row is the
base: a value the absorbed row carries fills a blank in the survivor's row, and
never replaces a value the survivor already holds unless you name that column in
`overwrite_columns`. The combined row keeps the **earlier** of the two arrival
times, because the absorbed row is being destroyed and the later stamp would date
the person after they actually arrived.

### A merge cannot be undone

The pointer survives, but the absorbed row's values are combined away. There is
no unmerge. Read both leads first.

The absorbed identity keeps existing rather than being deleted: capture receipts
reference it, and they remain accurate about what happened at the time. An id you
already hold — from a capture response or an outbound link — keeps working, and
resolves to whatever that lead is now, following a chain of merges to its end.

A merge takes no idempotency key. It is identified by the pair of leads it joins,
so retrying it after an uncertain response either finds the two already one lead
and answers `already_merged`, or performs the merge that did not commit.

### Addressing a lead

A lead is addressed by its `canonical_lead_id` UUID or by a 22-character lead
reference, which is the same value in a form short enough for a link or an SMS.
Both forms are accepted everywhere a lead is named, and every response carries
both.

### Endpoints

| Endpoint                                          | Purpose                                                                                        |
| ------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| `GET /v1/datasets/{slug}/leads/{lead}`            | One lead: contact evidence, dataset row, capture history, and the leads merged into it.        |
| `GET /v1/datasets/{slug}/lead-candidates`         | The captures the platform refused to resolve, each with the leads it could not choose between. |
| `POST /v1/datasets/{slug}/leads/{survivor}/merge` | Merge `absorb_lead_id` into the lead named in the path.                                        |

`lead-candidates` also returns `lead_identity_enabled`. An empty list means two
very different things — the platform looked and found nothing, or this dataset
has no lead identity at all — so branch on that flag rather than reporting "no
duplicates" for a dataset nothing has checked. Reading one lead on such a dataset
answers a precondition failure naming the missing migration, not a 404.

Reading takes dataset view permission; merging takes dataset edit permission.
Scoped API keys — the per-end-user credentials — cannot merge: one lead's holder
must never be able to fold another lead into it.

From the CLI:

```bash theme={null}
erdo leads candidates acme-leads
erdo leads get acme-leads <lead>
erdo leads merge acme-leads <survivor> --absorb <lead>
```

Reading a lead is also available to MCP clients as `erdo_get_lead` and
`erdo_list_lead_merge_candidates`. Merging deliberately is not: it cannot be
undone, and the captures it settles are exactly the ones where the evidence
contradicts itself, so a person decides.

## Related

* [Event pipelines](/event-pipelines)
* [Dataset row actions](/dataset-row-actions)
* [Offline conversions](/offline-conversions)
