lumnik. / scenarios All scenarios · Français

One customer in two systems with no shared id

Goal Match ERP customers and CRM contacts, without ever merging by chance.

An ERP, a CRM, no shared id. Email matches the records. A shared mailbox points at two customers.

erp-accounts.csv
account_code,company_name,email,city
AC-100,Atelier Boréal,commandes@atelier-boreal.example,Lille
AC-200,Métal Azur,achats@metal-azur.example,Nice
AC-300,Rhône Filtration,compta@groupe-rhone.example,Lyon
AC-301,Rhône Levage,compta@groupe-rhone.example,Lyon
AC-400,Capteurs Armor,contact@capteurs-armor.example,Rennes
crm-contacts.csv
contact_code,display_name,email,phone
CT-9001,ATELIER BORÉAL,Commandes@Atelier-Boreal.example,+33 3 20 00 00 01
CT-9002,Métal Azur,achats@metal-azur.example,+33 4 93 00 00 02
CT-9003,Groupe Rhône,compta@groupe-rhone.example,+33 4 72 00 00 03
CT-9004,Transmission Nord,,+33 3 28 00 00 04
CT-9005,Pneumatique Atlas,ventes@pneumatique-atlas.example,+33 5 61 00 00 05
manifests/30-customer.yaml
kind: Entityname: CustomerOfRecorddescription: "One customer across an ERP account and a CRM contact that share no identifier."scopes: [entity-resolution]sources:  - table: t_scenario_resolution_erp    map:      account_code: account_code      company_name: name      email: email      city: city  - table: t_scenario_resolution_crm    map:      contact_code: crm_id      display_name: name      email: email      phone: phoneresolution:  identity:    t_scenario_resolution_erp: account_code    t_scenario_resolution_crm: crm_id  cascade:    - id: email      exact:        fields: [email]        normalize: [lower]
$ lm entity resolve CustomerOfRecord
rows seen: 10
resolved: 8
ambiguous: 1
unresolved (no rule): 1
unresolved (provider): 0
newly minted: 6
provider calls: 0
provider failures: 0
rule hits:
  customerofrecord:email: 9

$ lm entity ambiguous CustomerOfRecord -o json
[
  {
    "identityRef": "2f7844dc-3739-457a-968a-e4a7e8c4b5f0",
    "source": "t_scenario_resolution_crm",
    "sourceId": "CT-9003",
    "candidates": [
      "2f0553b1-1829-4350-98d5-e4234266135a",
      "59727d7b-3b4d-4b2b-8a45-5bb8fe51a910"
    ],
    "discardedCandidates": [],
    "setAside": [],
    "decidedBy": "automatic",
    "occurredAt": "2026-11-03T15:24:34.823862Z"
  }
]

$ lm entity get CustomerOfRecord --filter crm_id=CT-9004 -o json
[
  {
    "account_code": null,
    "name": "Transmission Nord",
    "email": null,
    "city": null,
    "crm_id": "CT-9004",
    "phone": "+33 3 28 00 00 04",
    "_conflicts": "{}"
  }
]

$ lm entity get CustomerOfRecord --filter crm_id=CT-9001 -o json
[
  {
    "account_code": "AC-100",
    "name": "Atelier Boréal",
    "email": "commandes@atelier-boreal.example",
    "city": "Lille",
    "crm_id": "CT-9001",
    "phone": "+33 3 20 00 00 01",
    "_conflicts": "{\"name\": [{\"src\": \"t_scenario_resolution_erp\", \"value\": \"Atelier Boréal\"}, {\"src\": \"t_scenario_resolution_crm\", \"value\": \"ATELIER BORÉAL\"}], \"email\": [{\"src\": \"t_scenario_resolution_erp\", \"value\": \"commandes@atelier-boreal.example\"}, {\"src\": \"t_scenario_resolution_crm\", \"value\": \"Commandes@Atelier-Boreal.example\"}]}"
  }
]

$ lm entity history CustomerOfRecord t_scenario_resolution_crm CT-9003 -o json
[
  {
    "ruleDeclaredId": "email",
    "ruleVersion": "ce968f5902d73417@engine-v1@hmac-k1@lower:1.0.0",
    "provider": "",
    "candidates": [
      "2f0553b1-1829-4350-98d5-e4234266135a",
      "59727d7b-3b4d-4b2b-8a45-5bb8fe51a910"
    ],
    "discardedCandidates": [],
    "setAside": [],
    "outcome": "AMBIGUOUS",
    "decidedBy": "automatic",
    "runId": "",
    "occurredAt": "2026-11-03T15:24:34.823862Z",
    "assertedBy": null,
    "assertedByLogin": null,
    "reason": null
  }
]
$ lm entity assert-same CustomerOfRecord --left t_scenario_resolution_crm:CT-9003 --right t_scenario_resolution_erp:AC-300 --reason "The CRM billing contact belongs to Rhône Filtration"
recorded: t_scenario_resolution_crm:CT-9003 and t_scenario_resolution_erp:AC-300 are same

$ lm entity resolve CustomerOfRecord
rows seen: 10
resolved: 9
ambiguous: 0
unresolved (no rule): 1
unresolved (provider): 0
newly minted: 0
provider calls: 0
provider failures: 0
rule hits:
  customerofrecord:email: 8

$ lm entity history CustomerOfRecord t_scenario_resolution_crm CT-9003 -o json
[
  {
    "ruleDeclaredId": "email",
    "ruleVersion": "ce968f5902d73417@engine-v1@hmac-k1@lower:1.0.0",
    "provider": "",
    "candidates": [
      "2f0553b1-1829-4350-98d5-e4234266135a",
      "59727d7b-3b4d-4b2b-8a45-5bb8fe51a910"
    ],
    "discardedCandidates": [],
    "setAside": [],
    "outcome": "AMBIGUOUS",
    "decidedBy": "automatic",
    "runId": "",
    "occurredAt": "2026-11-03T15:24:34.823862Z",
    "assertedBy": null,
    "assertedByLogin": null,
    "reason": null
  },
  {
    "ruleDeclaredId": "",
    "ruleVersion": "",
    "provider": "",
    "candidates": [
      "59727d7b-3b4d-4b2b-8a45-5bb8fe51a910"
    ],
    "discardedCandidates": [],
    "setAside": [],
    "outcome": "RESOLVED",
    "decidedBy": "asserted",
    "runId": "",
    "occurredAt": "2026-11-03T15:24:35.05687Z",
    "assertedBy": "1",
    "assertedByLogin": "integrator",
    "reason": "The CRM billing contact belongs to Rhône Filtration"
  }
]

$ lm entity ambiguous CustomerOfRecord -o json
[]

$ lm entity get CustomerOfRecord --filter crm_id=CT-9003 -o json
[
  {
    "account_code": "AC-300",
    "name": "Rhône Filtration",
    "email": "compta@groupe-rhone.example",
    "city": "Lyon",
    "crm_id": "CT-9003",
    "phone": "+33 4 72 00 00 03",
    "_conflicts": "{\"name\": [{\"src\": \"t_scenario_resolution_erp\", \"value\": \"Rhône Filtration\"}, {\"src\": \"t_scenario_resolution_crm\", \"value\": \"Groupe Rhône\"}]}"
  }
]
$ lm entity resolve CustomerOfRecord
rows seen: 10
resolved: 9
ambiguous: 0
unresolved (no rule): 1
unresolved (provider): 0
newly minted: 0
provider calls: 0
provider failures: 0
rule hits:
  customerofrecord:email: 8

$ lm entity get CustomerOfRecord --filter crm_id=CT-9003 -o json
[
  {
    "account_code": "AC-300",
    "name": "Rhône Filtration",
    "email": "compta@groupe-rhone.example",
    "city": "Lyon",
    "crm_id": "CT-9003",
    "phone": "+33 4 72 00 00 03",
    "_conflicts": "{\"name\": [{\"src\": \"t_scenario_resolution_erp\", \"value\": \"Rhône Filtration\"}, {\"src\": \"t_scenario_resolution_crm\", \"value\": \"Groupe Rhône\"}]}"
  }
]
$ lm entity assert-same CustomerOfRecord --left t_scenario_resolution_crm:CT-9999 --right t_scenario_resolution_erp:AC-300
Error: API error 404: entity 'CustomerOfRecord' has no row t_scenario_resolution_crm:CT-9999 — check it with lm entity history CustomerOfRecord t_scenario_resolution_crm CT-9999, or run lm entity resolve CustomerOfRecord if the row is new

Limits

  • Email is a declared signal, not proof of identity.
  • Exact rules only; no candidate provider.
  • Synthetic data; the company names are French.

Customers · ERP and CRM

The same customer lives in two systems.

With no shared id.

Five accounts, five contacts

The problem

Who is who?

  • AC-300 in the ERP, CT-9003 in the CRM.
  • Neither knows the other.

What is not changed

The ERP and the CRM stay as they are.

  • Two exports, read as they are.
  • No key to add at the source.

The trap

A shared mailbox.

  • compta@groupe-rhone points at two accounts.
  • Merging by chance is getting it wrong.

What had to be written

normalize: [lower]

One line.

  • Email matches, in lower case.
  • Two candidates: an ambiguity.
  • A 52-line scenario, three manifests.

The decision

A human decides.

  • CT-9003 joins Rhône Filtration.
  • The reason is written, it holds on replay.

The limit

Email is a signal, not a proof.

  • Exact rules only.

Try it

The scenario replays locally.

Every step is published with its output.

This scenario runs on lumnik Open, free and open source.

Try lumnik Open →