WOW demo — one Customer, fused from two legacy systems, alive over REST¶
The 60-second integrator story:
Take Customer (the hero entity), fuse it from two realistic legacy sources — a SOAP/Siebel CRM and a nightly CSV export — and serve one canonical, conflict-aware Customer over REST. No ETL job, no warehouse. A declarative manifest over the live hub.
You don't have a Siebel to point at — so this folder ships a 40-line fake-siebel.py that
speaks just enough of the CustomerQueryPage dialect. Everything else is real lumnik.
┌─ fake-siebel.py (SOAP QueryPage) ─→ siebel-customers ─→ connector.cust_siebel ┐
│ (tenant_id + RLS) │
│ ├─→ Customer entity
└─ customer.csv (flat-file export) ─→ csv-customers ──→ connector.t_cust_csv ──┘ (fuse on customer_code)
(tenant_id + RLS) │
▼
GET /api/entities/customer → fused JSON + _conflicts ledger
What's in this folder¶
| File | Role |
|---|---|
fake-siebel.py |
Stand-in Siebel — POST → a CustomerQueryPage SOAP envelope (stdlib only). |
customer.csv |
The second legacy source: a flat-file customer export. |
siebel-connector.yaml |
kind: soap connector → lands connector.cust_siebel. |
csv-connector.yaml |
csv-file connector → lands connector.t_cust_csv. |
customer-entity.yaml |
The kind: Entity manifest — fuses both on customer_code. |
clients-view.yaml |
kind: View — the first app surface: a list over the fused Customer. |
lyon-process.yaml |
kind: Process — emits a decision when a Lyon customer lands (Act 2). |
fake-n8n.py |
Stand-in webhook receiver for Act 2 (stdlib only). |
commandes.csv |
Act 3's source: a dirty statuses export, EN LITIGE lurking undeclared. |
commandes-connector.yaml |
csv-file connector → lands connector.t_commandes_csv (Act 3). |
commande-workflow.yaml |
kind: Workflow — the declared commande lifecycle (Act 3). |
docker-compose.wow.yml |
Overlay for the self-host stack: mounts this folder at /demo, adds host-gateway. |
The proof (headless, CI-guarded)¶
The exact same fil runs as an integration test — start there if you just want certainty:
export JAVA_HOME=/path/to/jdk-21
mvn -pl lumnik-hub verify -Dit.test='CustomerWowFusionIT' \
-DfailIfNoTests=false -Dsurefire.failIfNoSpecifiedTests=false -Dtest=NoSuchUnitTest
CustomerWowFusionIT stands up a WireMock fake-Siebel, runs both connectors for real, applies the
manifest, and asserts the fused result over REST — agreement, gap-fill, and a cross-source conflict.
Replay it by hand (on the self-host stack)¶
This replays the WOW against the deployed stack — real OIDC, no dev-bypass — the one
deploy/selfhost/up.sh brings up. Every command runs from the repo root (all paths are
relative to it). Prerequisites:
- The self-host stack running with the demo folder mounted and the host Ollama reachable:
(Ollama + models: see
./deploy/selfhost/up.sh --build # hub + postgres + keycloak (real OIDC) # recreate the hub with the WOW overlay so it can read /demo/customer.csv: docker compose -f docker-compose.selfhost.yml \ -f docs/demo/wow-customer/docker-compose.wow.yml up -d hub # recreating RESTARTS the hub — wait until healthy before any lm call (an early # call gets 401 while the hub is still establishing OIDC with Keycloak): until [ "$(docker inspect -f '{{.State.Health.Status}}' \ "$(docker compose -f docker-compose.selfhost.yml ps -q hub)")" = healthy ]; do sleep 3; donedeploy/selfhost/README.md— the "ask" step needs them.) - The
lmbinary on PATH (needs Go 1.24+):(cd lm && go build -o lm ./cmd/lm) && sudo mv lm/lm /usr/local/bin/lm - The fake Siebel, on the host:
python3 docs/demo/wow-customer/fake-siebel.py(leave it running; if it says "address already in use", it's already up — skip this).
Point lm at the stack and log in. The bundled realm ships a demo integrator
(integrator / integrator, tenant 1) — change it for anything real.
lm config set-context selfhost --server http://localhost:8080 \
--oidc-issuer http://localhost:8180/realms/lumnik
lm config use-context selfhost
lm login # prints a URL + device code — open it, sign in as integrator / integrator
Then run the full flow — every call is a real Bearer-authenticated request:
# 1. Source 1 — the SOAP/Siebel CRM
lm apply -f docs/demo/wow-customer/siebel-connector.yaml
lm connector run siebel-customers # → run scheduled: <runId>
# lands connector.cust_siebel (C-100, C-200)
# 2. Source 2 — the CSV export (read from /demo inside the hub container)
lm apply -f docs/demo/wow-customer/csv-connector.yaml
lm connector run cust-csv # → run scheduled: <runId>
# lands connector.t_cust_csv (C-100, C-200, C-300)
# (verify either run: lm run list)
# 3. Fuse them into the hero entity
lm entity apply -f docs/demo/wow-customer/customer-entity.yaml
# → Applied entity "customer" → connector.v_customer
# 4. Ask the hub for the fused Customer over REST (read-only, tenant-scoped by your JWT).
# curl can't share lm's login session, so grab a password-grant token from Keycloak —
# it is the SAME Bearer identity (integrator, tenant 1) by another road:
TOKEN=$(curl -s -X POST http://localhost:8180/realms/lumnik/protocol/openid-connect/token \
-d grant_type=password -d client_id=lm-cli \
-d username=integrator -d password=integrator | jq -r .access_token)
curl -s 'http://localhost:8080/api/entities/customer' -H "Authorization: Bearer $TOKEN" | jq
# 5. Ask the fused Customer a question in natural language (per-métier text-to-SQL)
lm ask --scope clients "combien de clients à Lyon ?"
What you see¶
Three customers, fused from two systems that never agreed:
- C-100 — Siebel and the CSV agree on the name;
email/cityare gap-filled from the CSV._conflictsis empty. - C-200 — the systems disagree on the name (Siebel
Marievs CSVMARIE A.). The hub keeps Siebel as the representative (it's listed first) and preserves both in the_conflictsledger — nothing is silently dropped. - C-300 — exists only in the CSV. Still fused, no conflict.
[
{ "customer_code": "C-100", "name": "Jean Dupont", "email": "jd@x.fr", "city": "Paris", "_conflicts": "{}" },
{ "customer_code": "C-200", "name": "Marie", "email": "m@x.fr", "city": "Lyon",
"_conflicts": "{\"name\":[{\"src\":\"cust_siebel\",\"value\":\"Marie\"},{\"src\":\"t_cust_csv\",\"value\":\"MARIE A.\"}]}" },
{ "customer_code": "C-300", "name": "Solo", "email": "solo@x.fr", "city": "Nice", "_conflicts": "{}" }
]
The fused view is security_invoker, so it inherits each base table's row-level security for free —
each tenant sees only its own customers through the fusion (proven by EntityFusionRlsIT).
Ask it (the last 20 seconds of the WOW)¶
Because customer-entity.yaml declares scopes: [clients], applying it makes the fused Customer
discoverable to the per-métier chat — lm entity apply registers the view and rebuilds the
clients schema card, so the ask-to-SQL engine can translate a natural-language question into a
read-only SELECT over connector.v_customer:
lm ask --scope clients "combien de clients à Lyon ?"
# → an answer computed over a fused truth that lived in neither Siebel nor the CSV alone.
The chat reads through the same security_invoker view, so the answer is tenant-scoped for free.
Honesty: it refuses the deceptive "none"¶
The cardinal sin of an enterprise data chat isn't a wrong query — it's a confident "there are none"
that gets the user blamed by their boss. Because customer-entity.yaml declares
segment.values: [GOLD, SILVER, BRONZE], the chat knows the domain. Ask for a value that isn't in
it and the system refuses before running anything — it never returns 0 rows phrased as a fact:
lm ask --scope clients "combien de clients Platinum ?"
# → I won't run this: 'PLATINUM' is not a declared value of segment (known: GOLD, SILVER, BRONZE).
# If it should exist, add it to the entity manifest.
The refusal is honest in both cases: if PLATINUM is a hallucination, it's caught; if it's a real
segment the model just doesn't know about, the message tells you exactly how to teach it (declare it).
Abstention over a confident-wrong answer — always.
See it — the first app surface (kind:View)¶
Before reacting to the data, look at it: clients-view.yaml declares a read-only list over the
fused Customer — no code, one YAML (reference).
lm view -f docs/demo/wow-customer/clients-view.yaml --filter city=Lyon
# Clients — city=Lyon
# CUSTOMER_CODE NAME CITY SEGMENT
# C-200 Marie Lyon SILVER
lm view C-200 -f docs/demo/wow-customer/clients-view.yaml
# the record's detail — including the _conflicts ledger (Siebel 'Marie' vs CSV 'MARIE A.')
A filter is refused honestly, never silently ignored: an unknown column, a malformed comparison,
or a segment=PLATINUM (out of the declared domain) each name the problem instead of returning
a lying empty table.
Thresholds work too — --filter "montant>1000", --filter "echeance<2026-08-01"
(> < >= <=; the value must be a number or an ISO date, refused loudly otherwise).
And you don't have to write the YAML yourself — describe the view, the AI writes the manifest, the same card-backed gate validates every column and filter before anything is suggested (reference):
lm view generate clients "les clients gold avec leur ville et segment" -o gold.yaml
cat gold.yaml
# first line = the ready-to-run render command, e.g.:
# lm view -f gold.yaml --filter 'segment=GOLD'
lm view generate clients "les commandes du mois de mars"
# → generation abstained: no order data in this scope — the model refuses
# to invent columns rather than fake an answer.
Quote the filter — an unquoted > is a shell redirect that truncates a file named 1000.
Act 2 — the process (kind:Process, notify)¶
The dragon's first rung — proven live 2026-07-05: a declared rule watches the CSV and emits a decision when a new customer from Lyon arrives (Marie's city).
Why
villeon scopedemo. A process triggers ondata.row.changed, born at the raw write path — soon.tableis the raw ingest tableconnector.t_cust_csv, whose columns are the CSV's own (code, cname, email, ville).segmentis a Siebel field, absent from the CSV. The raw table lives in thedemoscope card (the csv connector is taggeddemo); the fusedclientscard names onlyconnector.v_customer, so a process'son.tablemust name a raw table that itsscope:card actually carries — otherwise apply honestly 400s.
# a fake n8n on the host (stdlib only)
python3 docs/demo/wow-customer/fake-n8n.py
# subscribe deliveries to decision.* — the real admin API is
# POST /api/tenants/{tid}/integrations (IntegrationResource, @RequiresPermission("lm_admin")).
# There is no `lm` CLI wrapper for it yet, so call it straight, with the same Bearer
# token used for the fused-Customer call above (the `1` in the path = the demo
# integrator's tenant — use your own tid otherwise):
curl -s -X POST http://localhost:8080/api/tenants/1/integrations \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"name":"fake-n8n","targetUrl":"http://host.docker.internal:9099/hook",
"authMode":"NONE","eventTypes":["decision.*"],"enabled":true}'
# declare the process (this also gates data.row.changed ON for t_cust_csv):
lm process apply -f docs/demo/wow-customer/lyon-process.yaml
lm process list
# a process fires only on a GENUINELY-NEW row (content-hash dedup: re-running the same CSV
# emits nothing). Add a new Lyon customer, then re-run the connector:
echo 'C-500,Claire Martin,claire@x.fr,Lyon' >> docs/demo/wow-customer/customer.csv
lm connector run cust-csv
# ~10-15s later (two 5s consumer ticks), fake-n8n prints:
# {"message":"Nouveau client Claire Martin à Lyon","process":"lyon-client-arrive",
# "entity":"connector.t_cust_csv","entity_key":"<row hash>",
# "fields":{"cname":"Claire Martin","ville":"Lyon"}}
# the {cname}/{ville} placeholders rendered from the row — and `fields` carries the
# referenced columns as data, so n8n consumes values, not text-parsing.
core.webhook_endpoint normally takes an HMAC secret for signed delivery (authMode: HMAC); the
demo passes authMode: NONE so fake-n8n.py doesn't have to verify a signature — real n8n/Make/Zapier
subscriptions should use HMAC and check it.
And the same decision, on the human side — no n8n required. The human disposes; the ack is
itself an event (annotation.decision.acked), so a webhook subscribed to annotation.*
reacts when someone marks it done — and app state stays a fold over the log, never a state row:
lm inbox
# ID QUAND PROCESS MESSAGE ENTITY_KEY
# 4212 2min lyon-client-arrive Nouveau client Claire Martin à Lyon e71717a1…
lm inbox ack 4212
# acked. (idempotent — re-acking emits nothing twice)
lm inbox # the fold: only what still needs you
lm inbox --all # everything, disposed entries marked [traité]
The inbox reads the event log, so history is bounded by the outbox retention purge
(lumnik.events.retention-days, default 30 days) — it shows what retention keeps, and an
ack expires together with its decision.
Act 3 — the lifecycle (kind:Workflow)¶
Replayed live 2026-08-04. Not on the self-host stack: on an isolated quarkus:dev hub
against a throwaway pgvector and a local Ollama (llama3.2), with dev-bypass headers over
curl instead of lm. So the OIDC path and the CLI itself are still unexercised here — the
hub behaviour below is captured output, the two lm invocations are the equivalent calls.
The first replay is also what caught the bug fixed in #200: applying a workflow answered 200 while the chunk never reached the corpus, so the chat stayed ungrounded and only a WARN said so. Every output below is from the run after that fix.
Twenty years of Excel don't produce clean statuses. commandes.csv carries En cours,
fini, TERMINE (trailing space), BROUILLON, encours — and twice, EN LITIGE, a
value nobody ever told the system about. commande-workflow.yaml declares the lifecycle
Madame Martin kept in her head — three states, two transitions, provenance: declared —
and applying it doesn't hide the mess: it names it.
# land the raw commandes table
lm apply -f docs/demo/wow-customer/commandes-connector.yaml
lm connector run commandes-csv # → run scheduled: <runId>
# lands connector.t_commandes_csv — the run
# recorded 7 in / 7 out / 0 skipped, dirty
# statuses and all
# declare the lifecycle
lm workflow apply -f docs/demo/wow-customer/commande-workflow.yaml
lm workflow apply prints the hub's response verbatim — the data-truth confrontation
between the declared graph and what's actually in the column. Captured from the run:
{"name":"commande-lifecycle","scope":"demo","table":"connector.t_commandes_csv","column":"statut",
"dataTruth":{"undeclared":[{"value":"EN LITIGE","rows":2}],"unobserved":[],"nullOrBlank":0,
"tableMissing":false,"keyColumnMissing":false,
"checkUnavailable":false,"unavailableReason":null}}
200, not blocked. EN LITIGE is reported, never refused — it's reality, and reality
doesn't ask the manifest's permission. unobserved is empty (every declared state was
seen at least once); had the CSV never carried a TERMINE/fini row, TERMINE would
show up there instead — a declared code that's never actually reached.
The manifest applied above also declares two fields this walkthrough hasn't touched yet:
on.key: num and dormancy.after: 30d. Together they turn the lifecycle from a static
taxonomy into a watched one — a scheduled sweep reads every commande's current state
and alerts when it has sat, unmoved, in a non-terminal state past the threshold. Expect
nothing on the sweep tick right after this apply: cold start is honest, the clock starts
at lumnik's own first observation of each commande, never at whatever happened in the ERP
before lumnik looked. Alerts show up in lm inbox only once 30d has actually elapsed
from that first observation, tagged workflow:commande-lifecycle. Full vocabulary, the
alert's exact wording, and the honesty rules behind it live in
Dormancy — not repeated
here.
Applying the workflow also indexes it into the demo-scope corpus as one chunk — states,
aliases, terminal marks and the transition graph, spelled out with declared by the
integrator so the chat can cite how this lifecycle is known, never as observed fact.
Now the magasinier can ask the obvious question instead of guessing:
lm ask --scope demo "pourquoi je ne peux pas clôturer la commande CMD-001 ?"
Captured, llama3.2, abridged:
« D'après le workflow, il est possible que la commande CMD-001 soit en cours de traitement (EN_COURS) et qu'elle nécessite encore certaines étapes pour être clôturée. Vous pouvez vérifier son statut actuel en consultant la table
connector.t_commandes_csv. Vous pouvez également vérifier si les transitions autorisées sont correctement configurées entre les états BROUILLON, EN_COURS et TERMINE. »
Worth reading closely, because it is less than the aspirational answer this act used to show — and that's the honest result. The chunk carries the lifecycle, not the rows, so the model grounds itself in the declared states and transitions and then abstains on CMD-001's actual status rather than guessing it. Naming the table to look in is the correct move when you know the shape of the answer but not the value.
The contrast is the whole point. Asked the same question with the chunk missing — the state this demo was in before #200 — the same model had nothing to stand on and read commande as a shell command:
« je n'ai pas accès aux informations spécifiques sur la commande CMD-001 […] Quel est l'environnement dans lequel vous utilisez la commande (par exemple, un système Windows, Linux ou macOS) ? »
The exact wording is the model's, run to run — what's fixed is the ground truth it must agree
with: the states, aliases and transitions declared in commande-workflow.yaml, never an
invented lifecycle.