kind: Entity — fuse
Fuse N legacy tables into one canonical, queryable thing. Each source keeps landing in its own table; the manifest says how their columns map onto one shape and which column identifies the same real-world row across systems. The hub compiles that into a SQL view — and every disagreement between sources is kept in a
_conflictsledger on the row, never silently resolved.
| Kind | Entity |
| Compiles to | connector.v_<name> — a view; each tenant sees only its own rows |
| Sources | any connector's table: CSV, JDBC, REST or Kafka |
| Lookups | optional joins[] — a table that enriches the fused rows without adding one |
| Apply | lm entity apply — lm_admin; a manifest that cannot compile is not saved |
| Read | lm entity get NAME — lm_user, scope-bound; also lm view, and lm ask (Pro) |
| Edition | Open; asking it in plain language is Pro |
The ERP has a customer table, the CRM has another. An entity declares which one wins, exposes one row per customer, and records per row and per column where the systems disagree.
That ledger is what a kind: View shows under Ambiguïtés et divergences, and
what lm conflicts list lists across every entity you can read.
Features
- Join without collapsing. A lookup table enriches each fused row — one row per product with its category — and an ambiguous lookup key is shown, never guessed.
- Priority is the source order, per column. The first source that has a value wins that column; a source that has none lets the next one fill the gap, which is never a conflict.
- Nothing is dropped. Two distinct non-null values for one column land
in the row's
_conflictsledger with the source that said each. - Grounding for the surfaces above. Labels a view renders,
descriptions the chat reads, and a
valuesdomain the filters enforce — refusing what is outside it rather than returning an empty table. - Askable immediately (Pro). The fused entity can be asked about as soon as it is applied.
- Refused before compiled. A manifest that cannot compile is not saved. Every refusal names what to change.
- The delete interlock. A connector whose table feeds an entity cannot be deleted
until the entity is — a
409that names both.
Quickstart
Every screen on this page reads the demo desk that ships with the repo
Two customer exports that disagree and an order book, under docs/apps/demo/. Three
commands land them: The demo desk.
The two exports, on two customers:
acme-erp.csv code,name,city,segment,credit_limit
P-1000,Hydraulique Lyon 1000,Lyon,GOLD,500
P-1008,Filtration Lyon 1008,Lyon,GOLD,1596
acme-crm.csv customer,company,town,email
P-1000,HYDRAULIQUE LYON,Lyon,contact@hydraulique-lyon.example
P-1008,Filtration Lyon 1008,Villeurbanne,contact@filtration-lyon.example
# 10-customer.yaml — one Customer fused from the ERP and the CRM on the customer code.
#
# The ERP is listed first, so on a disagreement its value is the representative and the
# CRM's is kept in the row's _conflicts ledger — nothing is silently dropped. The `columns`
# block grounds the canonical shape for the surfaces above: labels a kind:View renders,
# descriptions the chat reads, and a `values` domain the filters enforce.
#
# Expect: 14 rows (12 ERP + 2 CRM-only; the CRM's blank-code row is excluded), and exactly
# three in dispute — P-1000 (name), P-1002 (city), P-1008 (city). P-1005 is not one:
# the ERP had no city and the CRM filled it, and a gap is not a disagreement.
kind: Entity
name: AcmeCustomer
description: "A customer, fused from the ERP export and the CRM export."
match: [code]
scopes: [desk]
columns:
code:
label: "Code client"
name:
label: "Raison sociale"
city:
label: "Ville"
description: "Billing city, free text."
example: "Lyon"
segment:
label: "Segment"
description: "Commercial segment."
values: [GOLD, SILVER, BRONZE]
credit_limit:
label: "Encours autorisé"
description: "Credit limit in euros."
email:
label: "Email"
sources:
- table: t_acme_desk_erp
map:
code: code
name: name
city: city
segment: segment
credit_limit: credit_limit
- table: t_acme_desk_crm
map:
customer: code
company: name
town: city
email: email
$ lm entity apply -f docs/apps/demo/entities/10-customer.yaml
Applied entity "AcmeCustomer" → connector."v_acmecustomer"
There is no lm entity validate: apply refuses a manifest that would not compile, before
anything is written. Read the result with lm entity get. The name works in any case;
--filter is an exact match on a column:
$ lm entity get acmecustomer --filter city=Lyon -o json
[
{
"code": "P-1000",
"name": "Hydraulique Lyon 1000",
"city": "Lyon",
"segment": "GOLD",
"credit_limit": "500",
"email": "contact@hydraulique-lyon.example",
"_conflicts": "{\"name\": [{\"src\": \"t_acme_desk_erp\", \"value\": \"Hydraulique Lyon 1000\"}, {\"src\": \"t_acme_desk_crm\", \"value\": \"HYDRAULIQUE LYON\"}]}"
},
{
"code": "P-1008",
"name": "Filtration Lyon 1008",
"city": "Lyon",
"segment": "GOLD",
"credit_limit": "1596",
"email": "contact@filtration-lyon.example",
"_conflicts": "{\"city\": [{\"src\": \"t_acme_desk_erp\", \"value\": \"Lyon\"}, {\"src\": \"t_acme_desk_crm\", \"value\": \"Villeurbanne\"}]}"
}
]
Two customers in Lyon, and on each the CRM disagrees with the ERP. The ERP is first in
sources[], so its value is in the column; the CRM's is in the ledger.
Fourteen rows in all: twelve from the ERP, two the CRM alone knows. The CRM's row with no customer
code is absent: a NULL match key matches nothing.
Where the systems disagree, across every entity at once:
$ lm conflicts list
ENTITY KEY COLUMNS SOURCES
AcmeCustomer P-1000 name t_acme_desk_erp,t_acme_desk_crm
AcmeCustomer P-1002 city t_acme_desk_erp,t_acme_desk_crm
AcmeCustomer P-1008 city t_acme_desk_erp,t_acme_desk_crm
Three rows in dispute, exactly the three the hand count predicted. P-1005 is not one of them: the ERP had no city for it, the CRM did, and the fused row simply carries Aarhus.
The manifest
| Field | Required | Meaning |
|---|---|---|
kind |
yes | must be Entity |
name |
yes | a simple identifier (letters, digits, _), at most 61 characters. It names the view connector.v_<name> (lower-cased) and the entity in the chat |
label |
no | the entity's human name — a kind: View that declares no title: is headed with it, and the schema card shows it beside the view name. Without one, both fall back to name |
description |
no | free text for humans |
match |
yes (≥ 1), unless resolution |
the canonical column(s) that identify the same real-world row across sources; several make a composite key. Every source must map every match column, in the same case |
resolution |
no | instead of match, when the systems share no key — see kind: Entity — resolve |
sources[] |
yes (≥ 1) | ordered — the order is the merge priority; the first source wins a disagreement, unless the column names its own (columns.<col>.prefer) |
sources[].table |
yes | the source's dynamic table in the connector schema, without the schema prefix (t_acme_desk_erp) |
sources[].map |
yes (≥ 1) | source column: canonical column, per source |
joins[] |
no | relational lookups that enrich the fused rows — see Fuse or join. Never fused, never add a row |
joins[].table |
yes | the lookup's dynamic table in the connector schema; it must not also be a source |
joins[].from |
yes | a canonical column of the fused entity (it may be a match column) — the value looked up |
joins[].to |
yes | a physical column of the lookup table — the key it is matched on (it need not appear in map) |
joins[].map |
yes (≥ 1) | lookup column: canonical column; the new canonical names must not collide with any source column, any other join, or a reserved name, and none may be a match column |
zone |
no | the business's time zone, a region such as Europe/Paris: a date meets a time on the day it has there. Without it the day is the UTC one — see The business's day |
scopes |
no | the business scopes whose chat and views may read this entity — an entity with none is not discoverable by anyone |
columns |
no | per-canonical-column grounding |
- Column order: every
sources[].mapvalue in source order, then everyjoins[].mapvalue in declared order, then_conflicts. - Reserved names:
_conflicts,_src,_ord,_entity_id,_resolution,_source_id,_handle,_canonical_key. - Case:
emailin one source andEmailin another is refused, not split into two columns. - Every value reads as a string:
credit_limitreads"500", not500. - A typed column keeps its type when every source holds it with that type: a date or a timestamp stays one, a number stays a number and orders as one. See Dates and numbers across sources. Any other column is text: cast where you consume it.
The key set is closed: an unknown key such as scope: for scopes: is refused, not dropped:
$ lm entity apply -f docs/apps/demo/entities/63-refused-unknown-key.yaml
Error: API error 400: unknown key(s) in the manifest: scope — kind:Entity honors root {apiVersion, columns, description, joins, kind, label, match, name, resolution, scopes, sources, zone}, sources[] {map, table}, joins[] {from, map, table, to}, resolution {cascade, identity}, resolution.cascade[] {exact, id, propose}, resolution.cascade[].exact {fields, normalize}, resolution.cascade[].propose {acceptAbove, provider, reviewAbove}
Fusion semantics
flowchart LR
s1[("t_acme_desk_erp<br/>P-1000 · name: Hydraulique Lyon 1000")] --> f
s2[("t_acme_desk_crm<br/>P-1000 · company: HYDRAULIQUE LYON")] --> f
f["fusion — union of all sources,<br/>grouped by the match column"] --> c[("v_acmecustomer<br/>P-1000 · name: Hydraulique Lyon 1000<br/>first non-null, source order")]
f -.-> k["_conflicts<br/>name: Hydraulique Lyon 1000 ↔ HYDRAULIQUE LYON<br/>nothing silently dropped"]
The compiled view unions every source and groups by the match column(s):
- A source row whose match column is
NULLis excluded: it cannot be matched to anything. - Every other column takes the first non-null value, in source order.
- The choice is made per column: a source that wins
namehas no claim oncity. - A
NULLis not an opinion. A source with no value lets the next one fill the gap. That is never a conflict: only two or more distinct non-null values are. - When sources disagree, the column still follows source order, and all the values land in
the
_conflictsledger. - A column may name the source that goes first for it alone:
prefer:in itscolumns:entry. The other columns keep the declared order.
Flip the order and nothing but the representatives changes. 20-crm-first.yaml lists the CRM
first over the same two tables:
$ lm entity apply -f docs/apps/demo/entities/20-crm-first.yaml
Applied entity "AcmeCustomerCrmFirst" → connector."v_acmecustomercrmfirst"
$ lm entity get acmecustomercrmfirst --filter code=P-1000 -o json
[
{
"code": "P-1000",
"name": "HYDRAULIQUE LYON",
"city": "Lyon",
"email": "contact@hydraulique-lyon.example",
"segment": "GOLD",
"credit_limit": "500",
"_conflicts": "{\"name\": [{\"src\": \"t_acme_desk_crm\", \"value\": \"HYDRAULIQUE LYON\"}, {\"src\": \"t_acme_desk_erp\", \"value\": \"Hydraulique Lyon 1000\"}]}"
}
]
Same fourteen rows, same three disputes; P-1000 now reads HYDRAULIQUE LYON, and segment —
which the CRM does not carry — still comes from the ERP. Each tenant sees only its own rows
through the fusion.
When a source row changes
Run the connector again after the source changed, and the entity shows the new value. On the demo desk, the ERP said Milano for P-1002 and the CRM said Milan — one of the three disputes above:
$ lm entity get acmecustomer --filter code=P-1002 -o json
[
{
"code": "P-1002",
"name": "Fixations Milano 1002",
"city": "Milano",
"segment": "BRONZE",
"credit_limit": "774",
"email": "ventes@fixations-milano.example",
"_conflicts": "{\"city\": [{\"src\": \"t_acme_desk_erp\", \"value\": \"Milano\"}, {\"src\": \"t_acme_desk_crm\", \"value\": \"Milan\"}]}"
}
]
The ERP corrects its export to Milan. Change the line in a copy of the file, put it in place, run the connector:
sed 's/^P-1002,Fixations Milano 1002,Milano,/P-1002,Fixations Milano 1002,Milan,/' \
docs/apps/demo/data/acme-erp.csv > acme-erp.csv
docker compose -f docker-compose.selfhost.yml cp acme-erp.csv hub:/tmp/acme-desk/acme-erp.csv
lm connector run acme-desk-erp
$ lm entity get acmecustomer --filter code=P-1002 -o json
[
{
"code": "P-1002",
"name": "Fixations Milano 1002",
"city": "Milan",
"segment": "BRONZE",
"credit_limit": "774",
"email": "ventes@fixations-milano.example",
"_conflicts": "{}"
}
]
$ lm conflicts list
ENTITY KEY COLUMNS SOURCES
AcmeCustomer P-1000 name t_acme_desk_erp,t_acme_desk_crm
AcmeCustomer P-1008 city t_acme_desk_erp,t_acme_desk_crm
AcmeCustomerCrmFirst P-1000 name t_acme_desk_crm,t_acme_desk_erp
AcmeCustomerCrmFirst P-1008 city t_acme_desk_crm,t_acme_desk_erp
The ERP's old value is gone from the row and from the ledger. The same holds for a joins[]
lookup (a renamed category shows its new name) and for a resolution: entity. If one export lists
the same customer twice with two different values, both stay in _conflicts.
The demo's ERP connector reads one local file, so each run is a
full refresh. Remove P-1011 from the file
(grep -v '^P-1011,'), put it in place and run again: the customer leaves the source table and the
entity.
$ lm run get 630cee80
ID CONNECTOR ENDPOINT STATUS IN OUT SKIPPED DEAD-LETTERS STARTED ENDED
630cee80 acme-desk-erp default Completed 11 11 0 0 2026-10-19 07:28 2026-10-19 07:28
• 1 row(s) removed — the source no longer holds them
$ lm entity get acmecustomer --filter code=P-1011
no rows
Put Milano back in the file and run again: the dispute with the CRM returns.
$ lm entity get acmecustomer --filter code=P-1002 -o json
[
{
"code": "P-1002",
"name": "Fixations Milano 1002",
"city": "Milano",
"segment": "BRONZE",
"credit_limit": "774",
"email": "ventes@fixations-milano.example",
"_conflicts": "{\"city\": [{\"src\": \"t_acme_desk_erp\", \"value\": \"Milano\"}, {\"src\": \"t_acme_desk_crm\", \"value\": \"Milan\"}]}"
}
]
What happens to a row deleted at the source:
| Source | A row deleted at the source |
|---|---|
csv-file, one local file |
removed at the next run (above) |
csv-file drop folder, sftp, s3 |
stays — see CSV & files |
jdbc-generic |
stays — see Deletes are never propagated |
kafka |
stays — a tombstone goes to the dead letters, see Tombstones |
Dates and numbers across sources
2026-07-01 14:30 and 01/07/2026, 1500 and 1500.00: two systems write the same value
differently. Fused as written, they disagree on every row. Typed at the door with parse-date
and parse-number, only the real disagreements stay:
Entities — dates and numbers.
The _conflicts ledger
One JSON object per row, one key per disputed column, {} — never null — when everyone
agrees:
{
"city": [
{ "src": "t_acme_desk_erp", "value": "Lyon" },
{ "src": "t_acme_desk_crm", "value": "Villeurbanne" }
]
}
Where it shows:
- on every read of the entity (
lm entity get); - at the foot of a record's detail in a
kind: View; - across every entity in
lm conflicts list— see Aggregated conflicts.
The chat cannot query it: "where do the ERP and the CRM disagree?" is a question for
lm conflicts list, not lm ask. An lm ask answer that filtered on a disputed value names the
record in a disputed block (Disputed rows).
Fuse or join
Two different things happen between legacy tables:
- Fuse (
match+sources[]): several systems describe the same object — the ERP's customer and the CRM's customer. Merged into one row; disagreements kept. - Join (
joins[]): one object references another — a product and its category. One row per product, the category's fields brought along.
Joins have their own page: Entities — joins.
The columns: grounding block
This is where you teach the surfaces what a column means — the input the ask guards enforce and the labels a view renders:
| Key | Meaning |
|---|---|
label |
the human name: the column header and field name in a kind: View, the field name on Search results, and beside the raw name on the schema card |
description |
the business meaning. The model that writes Ask's SQL reads it, so it does not have to guess |
example |
an example value, for the same model |
values |
the allowed domain — a filter on any other value is refused, not run (the domain guard) |
prefer |
the source table that goes first for this column: each system is the reference for its own fields |
conflicts |
false: the sources progress rather than disagree — the value still follows the source order, the column is never in _conflicts |
A terse question such as status SO-000004 needs an example on the identifier column, or the
model abstains: nothing ties that shape to a column. Declare a synthetic one
(example: SO-000001); the schema card carries only what you declare.
The rows a question returns are sent to the configured model to phrase the answer — see Models.
After the quickstart's manifest, the scope's card carries the labels:
$ lm source schema --scope desk
…
connector.v_acmecustomer
code text — Code client
name text — Raison sociale
city text — Ville
segment text — Segment
credit_limit text — Encours autorisé
email text — Email
and a filter outside the declared domain is refused wherever the entity is read:
$ lm view -f docs/apps/demo/views/10-customers.yaml --filter segment=PLATINUM
Error: API error 400: 'PLATINUM' is not an allowed value for segment (allowed: GOLD, SILVER, BRONZE)
Apply checks the block:
- A grounded column must exist in the entity: a typo is refused.
- A declared value may not be blank, or carry leading or trailing spaces. It would never match, and every filter on it would return nothing:
$ lm entity apply -f docs/apps/demo/entities/64-refused-domain-whitespace.yaml
Error: API error 400: column 'segment' has an allowed value with leading/trailing whitespace (would never exact-match): ' SILVER'
Each system for its own fields — prefer
The ERP is the reference for the name and the segment; the CRM, where sales update addresses, for the city. One source order cannot say both, so the city names its source:
# 40-customer-crm-city.yaml — each system for its own fields: the CRM is the reference for the city.
#
# Same two tables as 10-customer.yaml, the ERP still first. `prefer` puts the CRM first for the
# city alone: the ERP keeps every other column.
#
# Expect: P-1008 reads Villeurbanne (the CRM's), its name and segment still the ERP's, and the
# disagreement still in _conflicts, the CRM's value first.
kind: Entity
name: AcmeCustomerCrmCity
match: [code]
scopes: [desk]
columns:
city: { prefer: t_acme_desk_crm }
sources:
- table: t_acme_desk_erp
map: { code: code, name: name, city: city, segment: segment, credit_limit: credit_limit }
- table: t_acme_desk_crm
map: { customer: code, company: name, town: city, email: email }
$ lm entity get acmecustomercrmcity --filter code=P-1008 -o json
[
{
"code": "P-1008",
"name": "Filtration Lyon 1008",
"city": "Villeurbanne",
"segment": "GOLD",
"credit_limit": "1596",
"email": "contact@filtration-lyon.example",
"_conflicts": "{\"city\": [{\"src\": \"t_acme_desk_crm\", \"value\": \"Villeurbanne\"}, {\"src\": \"t_acme_desk_erp\", \"value\": \"Lyon\"}]}"
}
]
- The city is the CRM's; the name and the segment are still the ERP's.
- Nothing is hidden: the disagreement stays in
_conflicts, the preferred value first. - Still first non-empty: a customer with no city in the CRM gets the ERP's.
- Each column on its own: preferring two columns does not tie them together.
lm entity applyrefuses a table that is not a source, a source that does not map the column, and amatchcolumn.
A stage several flows stamp — conflicts: false
When each flow says how far a line got, the flows do not contradict each other: they progress.
The order book stamps ORDERED, the delivery notes DELIVERED. The furthest flow goes first, and
the stage needs no ledger:
# 50-order-stage.yaml — a stage several flows stamp: progress, not dispute.
#
# The delivery notes go first: the furthest flow is the answer. `conflicts: false` keeps the
# stage out of _conflicts; the article is still compared.
#
# Expect: L1 and L2 read DELIVERED, L3 ORDERED; lm conflicts list shows L2 only, for its article.
kind: Entity
name: OrderStage
match: [line_id]
scopes: [desk]
columns:
stage: { conflicts: false }
sources:
- table: t_stage_deliveries
map: { line_id: line_id, stage: stage, article: article }
- table: t_stage_orders
map: { line_id: line_id, stage: stage, article: article }
Without conflicts: false, every delivered line is a dispute:
$ lm conflicts list
ENTITY KEY COLUMNS SOURCES
OrderStage L1 stage t_stage_deliveries,t_stage_orders
OrderStage L2 stage,article t_stage_deliveries,t_stage_orders
With it, only the real one stays — L2's article:
$ lm entity get orderstage
LINE_ID STAGE ARTICLE _CONFLICTS
L1 DELIVERED PMP-100 {}
L2 DELIVERED VAN-210 {"article": [{"src": "t_stage_deliveries", "value": "VAN-210"}, {"src": "t_stage_orders", "value": "VAN-200"}]}
L3 ORDERED FLT-300 {}
$ lm conflicts list
ENTITY KEY COLUMNS SOURCES
OrderStage L2 article t_stage_deliveries,t_stage_orders
- The stage still follows the source order. The other columns are still compared.
- Refused on a
matchcolumn, and on a joined column. - Files:
sources/55-stage-orders.yaml,sources/56-stage-deliveries.yaml, their CSVs underdata/, landed like the demo desk.
Askable immediately
lumnik Pro. If the entity declares scopes, it can be asked about as soon as it is applied:
$ lm ask --scope desk "Combien de clients GOLD sont à Lyon, et quel est leur encours autorisé total ?"
Il y a 2 clients GOLD à Lyon et leur encours autorisé total est de 2096.
data as of: 2026-10-16T08:43:46.474408Z (last completed run)
disputed — a source disagrees on a value this answer filtered on:
P-1008 city = 'Lyon' counted t_acme_desk_erp: Lyon · t_acme_desk_crm: Villeurbanne
GOLD_CUSTOMERS_COUNT TOTAL_CREDIT_LIMIT
2 2096
1 row returned
SQL: SELECT COUNT(*) AS gold_customers_count, SUM(CAST(credit_limit AS INTEGER)) AS total_credit_limit
FROM connector."v_acmecustomer"
WHERE city = 'Lyon' AND segment = 'GOLD'
LIMIT 500
try: lm conflicts list — where this scope has source conflicts or lookup ambiguities, row by row
And when the filter matches nothing, a record the fused view filtered out is named too:
$ lm ask --scope desk "Quels clients sont à Villeurbanne ?"
La requête n'a renvoyé aucune ligne. Il peut n'y en avoir réellement aucune, ou la requête n'a pas saisi votre question — vérifiez le SQL avant d'affirmer qu'il n'y en a pas.
data as of: 2026-10-16T08:43:46.474408Z (last completed run)
disputed — a source disagrees on a value this answer filtered on:
P-1008 city = 'Villeurbanne' not counted t_acme_desk_erp: Lyon · t_acme_desk_crm: Villeurbanne
no rows returned
SQL: SELECT v.code, v.name, v.city, v.segment, v.credit_limit, v.email
FROM connector."v_acmecustomer" v
WHERE v.city = 'Villeurbanne'
LIMIT 500
try: lm conflicts list — where this scope has source conflicts or lookup ambiguities, row by row
- The model answers in the question's language.
- The hub's own sentences (the zero-row warning above) follow
--lang, else your locale (fr_FRhere). - The SQL and the lines
lmwrites (data as of, the table, the row count) stay in English. - A re-apply updates what the chat knows of the entity, in every tenant. A scope it left, or a column it dropped, is no longer offered to the chat.
- If a scope is missing from the chat,
lm source rediscover --scope <tag>refreshes it.
Refusals
Apply refuses with the exact problem. The ones an integrator meets first, each with a file under
docs/apps/demo/entities/:
$ lm entity apply -f docs/apps/demo/entities/60-refused-no-match-column.yaml
Error: API error 400: source 't_acme_desk_crm' does not map the match column: code
$ lm entity apply -f docs/apps/demo/entities/61-refused-case-collision.yaml
Error: API error 400: canonical column 'email' conflicts with 'Email' across sources (case-insensitive collision); use one consistent case
$ lm entity apply -f docs/apps/demo/entities/62-refused-reserved.yaml
Error: API error 400: source 't_acme_desk_erp': canonical column '_src' is reserved
$ lm entity apply -f docs/apps/demo/entities/65-refused-missing-table.yaml
Error: API error 409: source relation "connector.t_acme_desk_nowhere" does not exist — the entity fuses tables its source connectors create; run the source connectors first, then apply again
The last one is a 409, not a 400: the manifest is fine, the world is not ready for it yet.
| Refusal | Meaning | Fix |
|---|---|---|
kind must be 'Entity' |
wrong or missing kind |
kind: Entity |
name must be a simple identifier (letters, digits, _) — it becomes the SQL view name v_<name> advertised to the chat: … |
the name flows into SQL and the chat | rename |
name has 62 characters; at most 61 — its view v_<name> must fit PostgreSQL's 63-byte names |
PostgreSQL would cut the view's name short | shorten the name |
match must declare at least one column |
v1 needs one match rule | declare the join key |
match declares a duplicate column (case-insensitive): … |
the same key listed twice | remove the duplicate |
sources must have at least one source / source '…' map must have at least one column |
an entity fuses real tables | declare table + map |
source '…': canonical column '…' is reserved |
the name is one of the reserved names | pick another canonical name |
source '…' maps duplicate canonical column (case-insensitive): … |
two source columns feeding one canonical column in the same source | keep one |
canonical column '…' conflicts with '…' across sources (case-insensitive collision); use one consistent case |
email vs Email would silently become two SQL columns |
one consistent spelling |
source '…' does not map the match column: … |
every source must produce the match column, same case — else its rows could never be matched | add it to that source's map |
scope must be lowercase, start with a letter, then letters/digits/_/- (max 41): … |
a scope is a Keycloak role name — the same rule as connector scopes, see Scopes | rename the scope |
'zone' is not a time zone: "…" / 'zone' must name a region, not an offset: "…" |
a zone: the hub does not read, or an offset such as +02:00 |
a region name: Europe/Paris, America/New_York, UTC |
unknown key(s) in the manifest: … |
a key kind:Entity does not honor — scope: for scopes: |
the message lists what it honors |
columns references unknown canonical column '…' (known: …) |
grounding for a column that does not exist — almost always a typo | use a canonical name |
column '…' has a blank allowed value / … has an allowed value with leading/trailing whitespace … |
a domain value that can never match makes the guard lie | trim or remove it |
409 source relation "…" does not exist — … run the source connectors first |
a sources[].table no connector has created yet |
run the source connector, then re-apply |
400 source table …: column "…" does not exist — check the sources[].map keys … |
a sources[].map key the physical table does not have: a typo, or a column renamed upstream since the last apply |
fix the key to the column the connector creates, then re-apply |
Read API
lm entity get NAME [--filter COLUMN=VALUE]… [--limit N] [--offset N] [-o table|json|yaml]
lm_user. The fused rows, each with its _conflicts ledger ({} when no conflict). The name is
case-insensitive; an unknown entity is a 404, an empty result prints no rows.
| Option | Effect |
|---|---|
--filter COLUMN=VALUE |
exact match on a column; repeatable (AND). A name that is not a column is refused: Error: API error 400: filter _conflicts is not a column of AcmeCustomer; filterable columns: code, name, city, segment, credit_limit, email |
--limit N |
1..1000, default 100; a full page prints note: result hit the limit on stderr |
--offset N |
skip N rows, to page further |
How a value reads and filters depends on the column's type:
- A date column reads as its UTC instant,
2026-07-01T14:30:00Z, or as its day,2026-07-02. --filter due=2026-07-01on a date column is equal as an instant: the day's midnight in the entity's zone, UTC without one. A view's filter is always UTC.- A number column is equal by value:
--filter amount=1500.00finds1500. - Every other column is text, and
--filtermatches it exactly. -
A timestamp held as text reads in UTC,
2026-07-01 00:00:00+00, whatever zone the hub runs in. -
A scope-bound user who holds none of the entity's scopes gets
403 scope not granted(see Scopes). lmrefuses a column filtered twice.- A
resolution:entity also filters by_entity_id, the key a search hit links to. - For a
resolution:entity, the history behind a row is on kind: Entity — resolve. - From a script or another system:
GET /api/entities/{name}, with?<column>=value,?limit=,?offset=.
Aggregated conflicts
Where do my entities hold a source conflict or a lookup ambiguity, across every entity I may read?
$ lm conflicts list
ENTITY KEY COLUMNS SOURCES
AcmeCustomer P-1000 name t_acme_desk_erp,t_acme_desk_crm
AcmeCustomer P-1002 city t_acme_desk_erp,t_acme_desk_crm
AcmeCustomer P-1008 city t_acme_desk_erp,t_acme_desk_crm
With several entities, --entity narrows the list to one, and the whole --limit is spent on it:
$ lm conflicts list --entity member_a
ENTITY KEY COLUMNS SOURCES
member_a M-1001 full_name t_scenario_member_gestion_a,t_scenario_member_portail_a
member_a M-1003 plan t_scenario_member_gestion_a,t_scenario_member_portail_a
$ lm conflicts list --entity no_such_entity
Error: API error 404: entity no_such_entity not found
An entity outside your scopes answers 403 scope not granted, as lm entity get does.
lm_user. One row per record still in dispute, by entity then match key; --limit N (default 200,
up to 1000). The TUI's :conflicts page shows the full ledger under d.
- A
resolution:entity's key is the record's entity id, or<source table>:<source id>while nothing has resolved it. - A scope-bound user's list omits the entities they hold no scope for, without a
403. - An entity whose view cannot be read is left out; the hub log names it. The rest still answers.
- Over HTTP:
GET /api/entities/conflicts?limit=N&entity=NAME. Withoutlimit, the hub answers 100 rows.
Remove
$ lm connector delete acme-desk-erp
Error: API error 409: table connector.t_acme_desk_erp feeds entity acmecustomer — remove the entity first (lm entity delete acmecustomer)
$ lm entity delete acmecustomer
Type 'delete' to delete entity acmecustomer — its view, for every tenant: delete
deleted: acmecustomer
$ lm connector delete acme-desk-erp
Error: API error 409: table connector.t_acme_desk_erp feeds entity acmecustomercrmfirst — remove the entity first (lm entity delete acmecustomercrmfirst)
$ lm entity delete acmecustomercrmfirst
Type 'delete' to delete entity acmecustomercrmfirst — its view, for every tenant: delete
deleted: acmecustomercrmfirst
$ lm connector delete acme-desk-erp
Type 'delete' to delete connector acme-desk-erp (csv-file) — 11 rows of connector.t_acme_desk_erp, its run history and RAG chunks: delete
deleted: acme-desk-erp
The 409 names one entity at a time: delete each entity that reads the table, then the connector.
lm entity delete(lm_admin) drops the view, the manifest and every search index over the entity. It removes the entity from every card that lists it, in every tenant. The prompt names the search indexes when there are some.- An entity is declared once for the whole hub: deleting it removes it for every tenant.
- In a script, pass
--force; without it and without a keyboard nothing is deleted:aborted: no confirmation read (EOF) — pass --force in a script. - A scope-bound integrator must hold all the entity's scopes. An unknown name is a
404.
Several hub nodes
You can run the hub on more than one machine. Apply or delete an entity on any of them: the others follow, with no restart.
- It takes a few seconds (3 by default). In that time another machine still shows the old entity.
- Add a source to an entity: processes on the other machines react to that source's changes from then on. A change that arrives during those seconds, on a machine that has not caught up, does not trigger them.
- If an entity cannot be read, the machine keeps the last one it could read, and the log says which.
- To change the delay, set
lumnik.entity.registry.refresh-every, for example1s.
How to…
Fuse two systems' customer tables
lm entity apply -f docs/apps/demo/entities/10-customer.yaml
lm entity get acmecustomer -o json
Result: one canonical row per match key, unioned from every declared source — the first
source in sources[] wins a disagreement, and every disagreement is kept in that row's
_conflicts ledger.
See which source said what, on one record
$ lm view P-1008 -f docs/apps/demo/views/10-customers.yaml
Clients ACME / P-1008
Code client P-1008
Raison sociale Filtration Lyon 1008
Ville Lyon
Segment GOLD
Encours autorisé 1596
(!) Ambiguïtés et divergences
Ville t_acme_desk_erp «Lyon» · t_acme_desk_crm «Villeurbanne»
Result: the record's ledger, rendered under Ambiguïtés et divergences ("ambiguities and
divergences"); a record with neither ends with ✓ aucune ambiguïté ni divergence instead. The
view is kind: View, one file over the entity.
Give the fusion a third opinion
Add a third entry to sources[] — a legacy table, a Kafka topic's t_* — mapping the match
column and whatever else it knows. Result: columns only that source has appear in the shape;
a three-way disagreement lists three entries in the ledger; the source that wins one column has
no claim on another.
Troubleshooting
| Symptom | Cause | Gesture |
|---|---|---|
404 on lm entity get NAME (or GET /api/entities/{name}) |
no entity by that name has been applied (lookup is case-insensitive, so casing is not it) | check the name against what apply reported, or apply the manifest |
Error: access refused (403): scope not granted on a read |
you hold none of the entity's scopes |
ask an integrator to grant one of those scope: roles |
apply refused 409: source relation … does not exist |
a sources[].table does not exist yet — that source connector never ran |
run the source connector, then re-apply |
lm connector delete refused 409: table … feeds entity … |
the interlock — the entity still fuses that table | lm entity delete <name> first |
| a row you expected is missing from the fusion | its match column is NULL in that source — excluded by construction |
give it a key at the source, or accept that it cannot be matched |
| the wrong system's value shows | source order is the priority, per column | reorder sources[]; the other value is in _conflicts either way |
a number is a string ("500") |
every value reads as a string; the column orders as a number only when every source holds a number | type the CSV column with parse-number (its table is created again, as for a date), then lm entity apply — see Numbers across sources |
ids or amounts sort as text (100 before 12) |
one source holds the column as text | the same: every source must hold a number |
| a date is a string, or two sources "disagree" on the same day | one source holds the date as text (a CSV left as written) | type that column with parse-date: four steps, its table is created again |
an entity is missing from lm conflicts list |
you hold none of its scopes, or its view is gone or fails when read |
check your grants, or the hub log for "compiled view missing" / "compiled view unreadable" |
| entity applied fine, but chat cannot discuss it | no scopes declared |
add scopes: and re-apply; apply rediscovers the cards for you |
403 with an empty body |
you do not hold the role the command requires (lm_admin for apply and delete, lm_user for reads) |
no lm command lists your roles: GET /api/bff/v1/me returns them under roles |
See also
- kind: Entity — resolve — when the systems share no key: rules, review, corrections
- The demo desk — the sources every screen here reads, and the hand count
- kind: View — see the fused entity; the detail shows one record's ledger
- WOW demo — the full fusion walkthrough, two legacy sources to one Customer
- Ask honesty — the guards — what the
columns:grounding feeds - Scopes — the boundary the read API answers within
- CLI & TUI reference —
lm conflicts listand the:conflictsscreen