Skip to content

kind: Entity — resolve

When two systems share no key, resolution: gives each real customer one identity. It matches rows rule by rule, keeps what it cannot decide for you to review, and takes your correction over any rule.

Kind Entity, with a resolution: block instead of match:
Apply lm entity apply — lm_admin
Resolve, correct lm entity resolve, lm entity assert-same, lm entity assert-distinct — lm_admin
Review lm entity ambiguous, lm entity history — lm_user, scope-bound
Edition Open

Use match: when every system carries the same customer code. Use resolution: when they don't: the ERP knows C-003, the CRM knows contact 9003, and only the email address ties them together.

Quickstart — land, apply, resolve

Two small exports. The ERP has four customers. Two of them share one mailbox (compta@groupe-rhone.example). The CRM has five contacts with no ERP code, and one of them has no email at all.

code,name,email
C-001,Hydraulique Lyon,contact@hydraulique-lyon.example
C-002,Fixations Milano,ventes@fixations-milano.example
C-003,Filtration Lyon,compta@groupe-rhone.example
C-004,Levage Lyon,compta@groupe-rhone.example
contact_id,company,mail
9001,HYDRAULIQUE LYON,Contact@Hydraulique-Lyon.example
9002,Fixations Milano,ventes@fixations-milano.example
9003,Groupe Rhône,compta@groupe-rhone.example
9004,Transmission Aarhus,
9005,Pneumatique Porto,geral@pneumatique-porto.example

The manifests read the files from /tmp/acme-resolution/ on the hub's filesystem; no lm command places a file there. Copy them in, then land both sources:

docker compose -f docker-compose.selfhost.yml cp docs/entities-resolution/. hub:/tmp/acme-resolution
lm apply -f docs/entities-resolution/10-erp.yaml
lm apply -f docs/entities-resolution/20-crm.yaml
lm connector run res-erp
lm connector run res-crm
$ lm run list --connector res-crm
ID        CONNECTOR  ENDPOINT  STATUS     IN  OUT  SKIPPED  DEAD-LETTERS  STARTED           ENDED
3139233c  res-crm    default   Completed  5   5    0        0             2026-10-19 01:47  2026-10-19 01:47

The entity declares one rule: same email address, compared in lower case.

# 30-customer.yaml — one Customer across the ERP and the CRM, which share no key.
# The two are matched on the email address, compared in lower case.

kind: Entity
name: Customer
scopes: [sales]
sources:
  - table: t_res_erp
    map:
      code: code
      name: name
      email: email
  - table: t_res_crm
    map:
      contact_id: crm_id
      company: name
      mail: email
resolution:
  identity:               # the column that identifies a row in each source
    t_res_erp: code
    t_res_crm: crm_id
  cascade:
    - id: email
      exact:
        fields: [email]
        normalize: [lower]

Apply it, then resolve it once. After that, every source run that ends Completed or Partial resolves the rows it wrote. A Failed or Cancelled run is covered by the next lm entity resolve.

$ lm entity apply -f docs/entities-resolution/30-customer.yaml
Applied entity "Customer" → connector."v_customer"
$ lm entity resolve Customer
rows seen: 9
resolved: 7
ambiguous: 1
unresolved (no rule): 1
unresolved (provider): 0
newly minted: 5
provider calls: 0
provider failures: 0
rule hits:
  customer:email: 8
$ lm entity get Customer
CODE   NAME                 EMAIL                             CRM_ID  _CONFLICTS
C-001  Hydraulique Lyon     contact@hydraulique-lyon.example  9001    {"name": [{"src": "t_res_erp", "value": "Hydraulique Lyon"}, {"src": "t_res_crm", "value": "HYDRAULIQUE LYON"}], "email": [{"src": "t_res_erp", "value": "contact@hydraulique-lyon.example"}, {"src": "t_res_crm", "value": "Contact@Hydraulique-Lyon.example"}]}
C-002  Fixations Milano     ventes@fixations-milano.example   9002    {}
C-003  Filtration Lyon      compta@groupe-rhone.example               {}
C-004  Levage Lyon          compta@groupe-rhone.example               {}
       Groupe Rhône         compta@groupe-rhone.example       9003    {}
       Pneumatique Porto    geral@pneumatique-porto.example   9005    {}
       Transmission Aarhus                                    9004    {}
  • 9001 joined C-001 even though the CRM writes the address in capitals: the rule compares in lower case. Where the two systems still disagree on a value, the row says so in _conflicts, exactly as with match:.
  • 9003 stayed on its own. Its address belongs to two ERP customers, and the rule cannot choose between them. It is ambiguous, and an ambiguous row is never merged.
  • 9004 stayed on its own. It has no email, so no rule could even try. Nothing is lost: the row is still there, waiting for a better rule or a correction.
  • 9005 is a new customer known only to the CRM. It gets an identity of its own.

A row already resolved is never moved silently. If a later pass would join it to a different customer (the data changed, or so did a rule), the row becomes ambiguous and waits for you. Only a correction moves it.

Review what it could not decide

$ lm entity ambiguous Customer
IDENTITY_REF                          SOURCE     SOURCE_ID  CANDIDATES                                                                 DECIDED_BY  OCCURRED_AT
410ca39f-f788-4696-b9c4-82ef9a21e774  t_res_crm  9003       8b2d2706-be5c-44d3-a70f-14523d9a68df,a653a6a7-6d2f-488e-99f0-772d5b462d7b  automatic   2026-10-24 23:12

The two candidates are the identities of C-003 (8b2d2706…) and C-004 (a653a6a7…).

lm entity history NAME SOURCE ID explains any decision, one line per pass:

$ lm entity history Customer t_res_crm 9003
OCCURRED_AT          OUTCOME    RULE   PROVIDER  CANDIDATES                                                                 SET_ASIDE  DECIDED_BY  RUN_ID  ASSERTED_BY  REASON
2026-10-24 23:12:05  AMBIGUOUS  email            8b2d2706-be5c-44d3-a70f-14523d9a68df,a653a6a7-6d2f-488e-99f0-772d5b462d7b             automatic

C-004 shares C-003's mailbox but comes from the same ERP. One customer never holds two rows of the same source, so C-004 got its own identity, and its history shows the one it was set aside from:

$ lm entity history Customer t_res_erp C-004
OCCURRED_AT          OUTCOME   RULE  PROVIDER  CANDIDATES                            SET_ASIDE                             DECIDED_BY  RUN_ID  ASSERTED_BY  REASON
2026-10-24 23:12:05  RESOLVED                  a653a6a7-6d2f-488e-99f0-772d5b462d7b  8b2d2706-be5c-44d3-a70f-14523d9a68df  automatic

Correct it

You know that Groupe Rhône is the billing name of Filtration Lyon. Say so, naming each row as SOURCE:ID, then resolve:

$ lm entity assert-same Customer --left t_res_crm:9003 --right t_res_erp:C-003 --reason "Groupe Rhône is Filtration Lyon's billing name"
recorded: t_res_crm:9003 and t_res_erp:C-003 are same
$ lm entity resolve Customer
rows seen: 9
resolved: 8
ambiguous: 0
unresolved (no rule): 1
unresolved (provider): 0
newly minted: 0
provider calls: 0
provider failures: 0
rule hits:
  customer:email: 7
$ lm entity get Customer --filter crm_id=9003
CODE   NAME             EMAIL                        CRM_ID  _CONFLICTS
C-003  Filtration Lyon  compta@groupe-rhone.example  9003    {"name": [{"src": "t_res_erp", "value": "Filtration Lyon"}, {"src": "t_res_crm", "value": "Groupe Rhône"}]}

The history records the correction as asserted, with who made it and why:

$ lm entity history Customer t_res_crm 9003
OCCURRED_AT          OUTCOME    RULE   PROVIDER  CANDIDATES                                                                 SET_ASIDE  DECIDED_BY  RUN_ID  ASSERTED_BY  REASON
2026-10-24 23:12:05  AMBIGUOUS  email            8b2d2706-be5c-44d3-a70f-14523d9a68df,a653a6a7-6d2f-488e-99f0-772d5b462d7b             automatic
2026-10-24 23:12:05  RESOLVED                    8b2d2706-be5c-44d3-a70f-14523d9a68df                                                  asserted            integrator   Groupe Rhône is Filtration Lyon's billing name
  • A correction outranks every rule and survives every later run and resolve.
  • It takes effect at the next resolve: a run of one of the sources, or lm entity resolve.
  • Two rows of the same source can't be the same customer: such an assertion is refused (400).

To undo it, assert the opposite, then resolve. C-003 stops being a candidate for 9003; only C-004 is left, so the pass joins 9003 to C-004:

$ lm entity assert-distinct Customer --left t_res_crm:9003 --right t_res_erp:C-003
recorded: t_res_crm:9003 and t_res_erp:C-003 are distinct
$ lm entity resolve Customer
…
$ lm entity get Customer --filter crm_id=9003
CODE   NAME         EMAIL                        CRM_ID  _CONFLICTS
C-004  Levage Lyon  compta@groupe-rhone.example  9003    {"name": [{"src": "t_res_erp", "value": "Levage Lyon"}, {"src": "t_res_crm", "value": "Groupe Rhône"}]}

If that is wrong too, assert it distinct as well.

A row is named as the history names it. A mistyped one is refused:

$ lm entity assert-same Customer --left t_res_crm:9030 --right t_res_erp:C-003
Error: API error 404: entity 'Customer' has no row t_res_crm:9030 — check it with lm entity history Customer t_res_crm 9030, or run lm entity resolve Customer if the row is new

Every run resolves on its own

Add C-005,Pneumatique Porto,geral@pneumatique-porto.example to the ERP file and run the ERP again. The new row joins the CRM's 9005 with no other gesture:

$ lm connector run res-erp
run scheduled: ada9937e-9dc1-4444-adae-fda4c29b9afb
$ lm entity get Customer --filter crm_id=9005
CODE   NAME               EMAIL                            CRM_ID  _CONFLICTS
C-005  Pneumatique Porto  geral@pneumatique-porto.example  9005    {}
$ lm entity history Customer t_res_erp C-005
OCCURRED_AT          OUTCOME   RULE   PROVIDER  CANDIDATES                            SET_ASIDE  DECIDED_BY  RUN_ID                                ASSERTED_BY  REASON
2026-10-24 23:12:13  RESOLVED  email            643566ff-d862-4748-95bf-61dca428bcd1             automatic   ada9937e-9dc1-4444-adae-fda4c29b9afb

Run lm entity resolve NAME yourself after applying the entity, after changing its rules, and after a correction.

In the TUI

Type :ambiguous, then the entity name and Enter: the screen lists the same rows as lm entity ambiguous.

  • Enter on a row loads its history.
  • Ctrl-R resolves the entity again.
  • r refreshes the list.

Corrections are made in :entities: Enter on the entity, m marks a row, = (same) or ! (distinct) on another, then Ctrl-R to resolve — see The TUI.

  • joins[] work on a resolution: entity too. They are read on top of the resolved customers: each row gets the lookup's value. A key that the lookup holds more than once leaves the joined column empty, and lists those rows in _conflicts. It never picks one.
  • A SearchIndex finds a customer once it is resolved. A row still ambiguous, or with no rule, is not searchable yet.

The manifest

Key What it says
resolution.identity for each source table, the column that identifies one of its rows. Every source is listed, once. Write the column's name in the entity (the right side of the source's map): t_res_crm: crm_id, although the CRM file calls it contact_id
resolution.cascade the rules, tried in order — at least one
cascade[].id the rule's name, shown in the history
cascade[].exact.fields canonical columns that must be equal — several fields must all be equal
cascade[].exact.normalize how values are compared: lower, upper, remove-diacritics, strip-invisible, fix-mojibake, replace-smart-quotes, decode-html-entities, titlecase, clean. Here clean only removes a leading byte-order mark: it does not trim spaces
cascade[].propose reserved: no candidate provider ships today. Such a rule leaves its rows unresolved (provider)

Rules run in order, and a later rule can settle what an earlier one left ambiguous. Add a second rule on the name: a CRM contact named Levage Lyon at the shared mailbox is ambiguous by email, then resolved to C-004 by name. Its history then shows two lines: AMBIGUOUS under email, then RESOLVED under name.

cascade:
  - id: email
    exact: { fields: [email], normalize: [lower] }
  - id: name
    exact: { fields: [name], normalize: [lower] }
  • match: and resolution: are two ways to fuse; an entity declares one or the other.
  • _entity_id, _resolution, _source_id, _handle and _canonical_key are reserved column names.
  • A resolved entity is read like any other: lm entity get, lm view, lm ask, an export, a SearchIndex.

Operating it

  • LUMNIK_ENTITY_RESOLUTION_HMAC_SECRET is required: the hub does not start without it. It is at least 16 characters.
    • deploy/selfhost/up.sh generates it. With Helm, set secrets.entityResolutionHmacSecret.
    • Keep it stable, and back it up with your other secrets. Changing it invalidates every resolution fingerprint, and every open view page link answers this page link is no longer valid — reload the view from its first page.
  • After upgrading the hub, apply each resolution: entity again with the same manifest, then run lm entity resolve NAME (Upgrade).

See also