kind: Entity — canonical fusion¶
An entity fuses N legacy sources into one canonical, queryable thing. Each source keeps
landing in its own dynamic table; the manifest declares how their columns map onto one
canonical shape and which column matches rows across systems — the hub compiles that into a
SQL view, and every disagreement between sources is preserved in a _conflicts ledger
instead of being silently resolved.
The real manifest (from the WOW demo)¶
# The hero entity: one Customer fused from the two legacy sources on customer_code.
# Siebel is listed first, so on a disagreement Siebel is the representative and the CSV
# value is preserved in the _conflicts ledger (nothing is silently dropped).
kind: Entity
name: Customer
description: "A customer, fused from the Siebel CRM and the nightly CSV export."
match: [customer_code]
scopes: [clients] # the métier(s) whose chat can ask about this entity
columns: # grounding fed to the chat + human labels a kind:View renders
customer_code:
label: "Code client" # shown by kind:View instead of the raw canonical name
name:
label: "Nom"
segment:
label: "Segment"
description: "Commercial segment."
values: [GOLD, SILVER, BRONZE] # the domain — a filter on any other value is REFUSED, not run
city:
label: "Ville"
description: "Billing city (free text, French city names)."
example: "Lyon"
sources:
- table: cust_siebel # source 1 (SOAP/Siebel) — promoted columns
map:
customer_code: customer_code
name: name
segment: segment
- table: t_cust_csv # source 2 (CSV export) — header columns
map:
code: customer_code
cname: name
email: email
ville: city
Fields¶
| Field | Required | Meaning |
|---|---|---|
kind |
yes | must be Entity |
name |
yes | simple identifier (letters, digits, _) — becomes the SQL view connector.v_<name> (lower-cased) advertised to the chat |
description |
no | free text for humans |
match |
yes (≥1) | canonical column(s) that identify the same real-world row across sources (composite if >1) — every source must map every match column, same case |
sources[] |
yes (≥1) | ordered list — the order is the merge priority (first source wins a disagreement) |
sources[].table |
yes | the connector dynamic table in the connector schema |
sources[].map |
yes (≥1) | source column → canonical column, per source |
scopes |
no | métier tags whose chat can ask about this entity — an entity with no scopes is simply not discoverable |
columns |
no | per-canonical-column grounding |
The canonical shape is the ordered union of every source's map values. The names
_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, not silently split into two columns.
Fusion semantics¶
The compiled view unions all sources 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 value is the first non-null by source order — source 1 wins over source 2, and a source that has no value simply lets the next one fill the gap;
- when sources genuinely disagree (more than one distinct non-null value), the
representative still follows source order, and all values land in the
_conflictsledger — nothing is silently dropped.
_conflicts is a JSON object, one key per disputed column, empty ({}) when everyone
agrees. From the demo, C-200's name disputed between Siebel and the CSV:
{
"name": [
{ "src": "cust_siebel", "value": "Marie" },
{ "src": "t_cust_csv", "value": "MARIE A." }
]
}
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.
The columns: grounding block¶
This is where you teach the surfaces what a column means — the input the ask guards enforce:
| Key | Meaning |
|---|---|
label |
human display name — shown by kind: View and the schema card instead of the raw canonical name |
description |
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) |
A grounded column must exist in the canonical shape (typos are refused at apply), and a declared value may not be blank or carry leading/trailing whitespace — it would never exact-match and would silently zero-row.
Apply¶
lm entity apply -f customer-entity.yaml
# → Applied entity "Customer" → connector."v_customer"
The CLI posts the YAML to POST /api/entities/apply (lm_admin, body
application/yaml) and receives {name, view}. Apply is validate-then-compile: the
manifest is parsed and refused with a 400 on any problem below, then the view is
(re)created and the manifest upserted atomically — a manifest that cannot compile is
not saved.
If the entity declares scopes, apply also rediscovers each scope's schema card for
the calling tenant, so the fused entity is askable in its métiers immediately. This
refresh is best-effort: a discovery failure never fails the apply (the card can be rebuilt
via rediscovery).
Read API¶
GET /api/entities/{name}
lm_user. Returns the fused rows as JSON, every row carrying its _conflicts ledger
(as JSON text, "{}" when no conflict). Unknown entity → 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). Unlikekind: Viewfilters, 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.
curl -s 'http://localhost:8080/api/entities/customer?city=Lyon&limit=10' \
-H "Authorization: Bearer $TOKEN" | jq
Reads go through the RLS-enforced datasource and the security_invoker view — the
response is tenant-scoped by your JWT, nothing else to configure.
Validation refusals¶
Every refusal is a 400 from apply, quoting the exact problem:
| 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 a simple identifier (letters, digits, _ or -): … |
scopes are tags, not expressions | rename the tag |
columns references unknown canonical column '…' (known: …) |
grounding for a column that doesn't exist — almost always a typo | use a canonical name |
column '…' has a blank allowed value / column '…' has an allowed value with leading/trailing whitespace (would never exact-match): '…' |
a domain value that can never match makes the guard lie | trim or remove it |
See also¶
- WOW demo — the full fusion walkthrough, two legacy sources to one Customer.
- Ask honesty — the guards — what the
columns:grounding feeds. - kind: View — see the fused entity; the detail shows the
_conflictsledger. - Scopes — the boundary the read API answers within.