Skip to content

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 NULL is 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 _conflicts ledger — 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). Unlike kind: View filters, an unknown parameter is ignored, not refused; _conflicts is 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 _conflicts ledger.
  • Scopes — the boundary the read API answers within.