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 security_invoker view, RLS inherited from every source |
| Sources | any table in the connector schema: a CSV, JDBC or Kafka connector's t_* |
| Apply | lm entity apply — lm_admin; validate-then-compile, atomic |
| Read | GET /api/entities/{name} — lm_user, scope-bound; lm view, lm ask |
| Edition | Open |
The ERP has a customer table, the CRM has another, and the two have never agreed on a single
name. An entity does not pick a winner and forget the loser: it declares the priority, exposes
one row per customer, and writes down — per row, per column — where the systems still disagree.
That ledger is what a kind: View shows under Où les sources divergent, and
what lm conflicts list aggregates across every entity you can read.
Features
- 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. Applying an entity rediscovers its scopes' schema cards; the fused view is in the chat's vocabulary before the next question.
- Refused before compiled. A manifest that cannot compile is not saved, and every
refusal is a
400that says exactly 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 hand count of what the fusion must give is on
The demo desk.
# 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 is validate-then-compile, and a manifest that would not
compile is refused before anything is written. Read the result through the API — the view is
named after the entity, lower-cased:
$ curl -s 'http://localhost:8080/api/entities/acmecustomer?city=Lyon' -H "Authorization: Bearer $TOKEN" | jq
[
{
"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 about something. The ERP
is listed first in sources[], so its value is the one in the column; the CRM's is in the
ledger. Fourteen rows in all: twelve from the ERP, two the CRM alone knows, and the CRM's row
with no customer code is absent, because a NULL match key can be matched to 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, _) — becomes the view connector.v_<name>, lower-cased, and the name the chat advertises |
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) | 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 |
sources[] |
yes (≥ 1) | ordered — the order is the merge priority; the first source wins a disagreement |
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 |
scopes |
no | the métier scopes whose chat and views may read this entity — an entity with none is not discoverable by anyone |
columns |
no | per-canonical-column grounding |
The canonical shape is the ordered union of every source's map values. _conflicts, _src
and _ord are reserved. Canonical names are case-sensitive SQL identifiers, so email in one
source and Email in another is refused as an accidental collision rather than silently split
into two columns. Every value is text in the compiled view — credit_limit reads "500",
not 500 — which is what lets sources of different types fuse at all; cast where you consume.
The key set is closed: scope: for scopes: is named, 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, kind, match, name, scopes, sources}, sources[] {map, table}
— because without that guard the entity would apply with an empty scope list and a clean 200, visible to no métier and askable by nobody.
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; - for every other column, the representative is the first non-null by source order. The
choice is made per column, independently: 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, and that is never recorded as a conflict. Only two or more distinct non-null values are; - when sources genuinely disagree, the representative still follows source order, and all
the values land in the
_conflictsledger.
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"
$ curl -s 'http://localhost:8080/api/entities/acmecustomercrmfirst?code=P-1000' -H "Authorization: Bearer $TOKEN" | jq
[
{
"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.
The view is compiled security_invoker, so it inherits each base table's row-level security:
every tenant sees only its own rows through the fusion, with no per-entity policy to maintain.
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" }
]
}
It rides on every read of the entity, it is what a kind: View renders at the
foot of a record's detail, and GET /api/entities/conflicts aggregates it — see
Aggregated conflicts. It is not a column the chat can query: the
schema card advertises the canonical columns and nothing else, so "where do the ERP and the CRM
disagree?" is a question for lm conflicts list, not for lm ask.
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 — a kind: View prints it as the column header and the detail's field name; the schema card shows it beside the raw name |
description |
the business meaning, fed to the SQL-generation prompt so the model stops guessing |
example |
an example value, same purpose |
values |
the allowed domain — a filter on any other value is refused, not run (the domain guard) |
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)
A grounded column must exist in the canonical shape (a typo is refused at apply), and a declared value may not be blank or carry leading or trailing whitespace — it would never exact-match and would silently zero-row:
$ 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'
Askable immediately
If the entity declares scopes, apply rediscovers each scope's schema card for the calling
tenant, so the fused view is in the chat's vocabulary at once:
$ lm ask --scope desk "Combien de clients GOLD sont à Lyon, et quel est leur encours autorisé total ?"
There are 2 GOLD customers in Lyon with a total encours autorisé of 2096.
data as of: 2026-09-13T00:39:29.573902Z (last completed run)
SQL: SELECT COUNT(*) AS customer_count, SUM(credit_limit::numeric) AS total_credit_limit
FROM "connector"."v_acmecustomer"
WHERE city = 'Lyon' AND segment = 'GOLD'
The model read the label Encours autorisé from the grounding, cast the text column itself,
and answered over the fused view rather than either source. That refresh is best-effort: a
discovery failure never fails the apply, and lm source rediscover --scope <tag> rebuilds a card
by hand.
Refusals
Every refusal is a 400 from apply, quoting the exact problem. The ones an integrator meets
first, each pinned by 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 |
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 |
_conflicts, _src, _ord belong to the fusion machinery |
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 |
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 |
Read API
GET /api/entities/{name}
lm_user. The fused rows as JSON, every row carrying its _conflicts ledger as JSON text
("{}" when no conflict). The lookup is case-insensitive; an unknown entity is a 404.
- Scope boundary — the read is scope-bound like the rest of the app surface: a scope-bound
user who holds none of the entity's scopes gets the uniform
403 {"error":"scope not granted"}(see Scopes). - Filters —
?<canonical-col>=value, exact match, repeatable across columns (AND). Unlike a view's filters, an unknown parameter is ignored, not refused;_conflictsis not filterable. - Paging —
?limit=(default 100, clamped to 1…1000) and?offset=(default 0). Rows are ordered by the canonical columns, so pages are deterministic.
Reads go through the RLS-enforced datasource and the security_invoker view — the response is
tenant-scoped by your JWT, nothing else to configure.
Aggregated conflicts
The per-entity read answers "show me this entity"; this one answers the question the ledger raises but a per-row read cannot: where do my sources still disagree, across every entity I may read?
GET /api/entities/conflicts
lm_user. One row per fused record still in dispute, ordered by entity then match key —
lm conflicts list prints it, and the TUI's :conflicts screen opens the full ledger under d.
- Scope boundary — silently narrowed, never refused. A scope-bound user's aggregate simply
omits the entities they hold no scope for. This is the one place the uniform
403does not apply: an aggregate shows what you are allowed to see, and a non-granted entity is indistinguishable from a conflict-free one. - Paging —
?limit=(default 100, clamped 1…1000), spread across entities in order. - An entity whose compiled view is missing (its source table dropped by a direct DB edit —
lm connector deletecannot do this, it is refused while the entity stands) is logged and skipped; the rest of the aggregate still answers.
Remove
$ lm connector delete acme-desk-crm
Error: API error 409: table connector.t_acme_desk_crm feeds entity acmecustomer — remove the entity first (lm entity delete acmecustomer)
$ lm entity delete AcmeCustomerCrmFirst
deleted: AcmeCustomerCrmFirst
DELETE /api/entities/{name} (lm_admin) is the inverse of apply: it drops the view and deletes
the manifest in one transaction, then rediscovers its scopes so the cards drop it — best-effort,
like apply's refresh. A scope-bound
integrator must hold all the entity's scopes to remove it, the same asymmetry as apply. An
unknown name is a 404.
An entity is shared across tenants: the manifest and its view are deployment-global, only the rows they expose are tenant-scoped by RLS. Delete, like apply, acts for the whole deployment, and a second hub instance picks the change up on its next rediscover.
How to…
Fuse two systems' customer tables
lm entity apply -f docs/apps/demo/entities/10-customer.yaml
curl -s 'http://localhost:8080/api/entities/acmecustomer' -H "Authorization: Bearer $TOKEN" | jq
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
(!) Où les sources divergent
Ville t_acme_desk_erp «Lyon» · t_acme_desk_crm «Villeurbanne»
Result: the record's ledger, rendered under Où les sources divergent ("where the sources
diverge"); a record everyone agrees on ends with ✓ toutes les sources concordent 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 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 |
403 {"error":"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 fused value is text |
cast where you consume; lm ask does it by itself |
an entity is missing from lm conflicts list |
you hold none of its scopes (silently narrowed), or its compiled view is gone — both logged, neither refused |
check your grants, or the hub log for "compiled view missing" |
| 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 endpoint requires (lm_admin for apply and delete, lm_user for reads) |
GET /api/bff/v1/me returns your roles under roles |
See also
- 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