Ask honesty — the five guards
Which builds can run this
The five guards below run in every edition, the open one included — they are not a Pro
feature. What an open-edition build lacks is the doors and the model behind them:
analytic and semantic chat (lm ask/TUI :ask and POST /api/platform/rag/chat; the PWA
chat and POST /api/rag/chat) need two extra modules. Check GET /api/platform/health: an
open-edition build lists only core and identity; a build with chat also lists rag and
llm.
When you ask your data an analytic question, lumnik translates it to SQL and runs it
read-only over the hub. Every door that asks in analytic mode rides the same pipeline —
lm ask, TUI :ask, the PWA chat, or POST /api/rag/chat with mode: analytic — so the
guards below apply to all of them. (mode: semantic is retrieval-only: it cites ingested
chunks and never generates SQL, so these guards don't come into play there.) Before and after that run, a pipeline of deterministic guards enforces
one principle — the asymmetry of harm:
A refusal costs you a retry. A confident wrong answer — especially a confident "there are none" when there are some — costs you a business decision. lumnik always prefers the refusal.
Every refusal below is deterministic (no LLM judges the veto), and each message tells you what to
do next. The messages are quoted from the guard sources (the same strings the test suite asserts);
the <bracketed> parts and … are editorial — at runtime they carry your actual value, column,
table, and the scope's real lists.
The pipeline, in order
The diagram below numbers seven stages, one more than "five guards" in the title: abstention stands at two doors (floor and exit) and counts once, and SQL safety is a layer beneath the guards, not one of them.
flowchart TB
q(["your question"]) --> g1
g1["1 · abstention floor"] --> gen["SQL generation"]
gen --> g2["2 · abstention exit"]
g2 --> g3["3 · SQL safety"]
g3 --> g4["4 · leak guard"]
g4 --> g5["5 · undocumented-column guard"]
g5 --> g6["6 · domain guard"]
g6 --> exec["read-only execution"]
exec --> g7["7 · zero-row honesty"]
g7 --> ans(["the answer — always with the SQL that ran"])
g1 -.-> ref(["honest refusal,<br/>with what to do next"])
g2 -.-> ref
g3 -.-> ref
g4 -.-> ref
g5 -.-> ref
g6 -.-> ref
Stages 1–6 each eject to a refusal when they trigger. Stage 7 never refuses — an empty result is delivered with a caveat, never phrased as a confident "there are none".
1. Abstention floor — nothing declared yet
Trigger: the scope has no schema at all (no tables, or tables without columns).
I have no data declared for this scope yet, so I can't answer that. Ingest a source for this scope first.
Do next: apply a connector with metadata.scopes: [<your-scope>] and run it.
A stored schema that cannot be read is refused the same way and says so, rather than failing somewhere later with an error id: the name of a table or a column is what every part of the ask dereferences, so a card carrying one that names nothing is unusable rather than degraded.
The stored schema card for scope '
<scope>' cannot be read (…) — re-run discovery, or apply the entity again, to replace it.
2. Abstention exit — the model's honest "I can't"
Trigger: the SQL-generation model concludes the question doesn't map to the declared schema
and emits its sentinel (NO_ANSWER) instead of guessing a query.
I can't answer that from the data available for this scope — the question doesn't map to the declared schema.
Do next: rephrase using the columns the scope actually declares (lm view shows them), or
ingest/declare the missing data.
When the scope holds something text-to-SQL structurally cannot read, the refusal says so. It names
the fact, never a command — the same answer string is served to lm ask, to the PWA (which
renders it as plain text) and to anything else on the API, so the gesture is the client's to offer
and each already has one. Below, what the refusal adds and where you go next are therefore two
different things.
Two questions abstain for the same reason — the schema card advertises an entity's canonical columns and nothing else — and for both the answer exists one step away:
- "why can't this document move on?" — a declared
kind:Workflowlifecycle lives in the scope's RAG corpus, which text-to-SQL never reads.- The refusal adds: this scope does declare a lifecycle, and the question is answered from that declaration rather than from the data.
- Where you go next: the semantic chat door —
lm chat, orPOST /api/rag/chatwithoutmode: analytic.
- "where do the ERP and the CRM disagree?" — a fused entity's
_conflictsledger is a jsonb column that is deliberately not on the card.- The refusal adds: the disagreement is recorded, row by row, on each fused record.
- Where you go next:
lm conflicts list <entity>, or the record's detail view in the PWA.
Neither is answered by the analytic door itself, on purpose. Reading lifecycle text would have the
SQL generator reasoning over prose, and advertising a jsonb column on a card that types every
column text would have it writing _conflicts->>… against a column declared as text — the
confident-wrong these guards exist to prevent, once in each direction.
Sometimes the abstention fires before a more specific guard
If you ask about a value that is outside a declared domain (e.g. a segment that doesn't
exist), the model may abstain before the domain guard gets a chance to name the exact
mismatch. You get an honest refusal either way, but a generic one — re-ask with a declared
value to see the specific hint.
3. SQL safety — read-only, bounded
Trigger: the generated SQL is anything but one clean read: not a single SELECT/WITH
statement, multiple statements, or a banned token (DML/DDL keywords, pg_sleep, pg_catalog,
information_schema, dblink, …).
I couldn't translate that into a safe query.
Accepted queries are also capped with LIMIT 500 (an existing larger LIMIT is lowered).
Execution runs on a read-only connection with an empty search_path — every table must be
schema-qualified, so an unqualified (potentially ambiguous) name cannot resolve.
4. Leak guard — the métier boundary
Trigger: the query reads a schema-qualified table that is not declared in this scope's card.
Row-Level Security already isolates tenants; this guard is the scope boundary within a tenant.
(Choosing the scope itself can also be access-controlled per user — see
binding users to scopes: a scope-bound user
gets 403 scope not granted before any SQL is even generated.)
I won't run this: '
<schema.table>' is not a table in this scope (declared: …). The query may not read data outside this métier's declared scope.
Do next: switch to the scope that owns the table (lm scopes), or add this scope to the
connector's metadata.scopes and re-run it.
5. Undocumented-column guard
Trigger: the query filters a column that is declared in no table of the scope — an
invented column, or an internal one (tenant_id, _row_hash) the chat was never meant to
reason over.
I won't run this: '
<column>' is not a declared column of this scope (known: …). If it should exist, add it to the entity manifest.
(The "known" list is capped at 12 names to keep the message readable.)
6. Domain guard — the declared-values contract
Trigger: the query filters a column on a literal that is not in that column's declared
value-domain (the entity manifest's values: list). This is the direct counter to the cardinal
sin: a query that returns 0 rows for the wrong reason, then gets phrased as a confident
"there are none".
I won't run this: '
<value>' is not a declared value of<column>(known: …). If it should exist, add it to the entity manifest.
Do next: if the value is legitimate, add it to the entity manifest's declared values and re-apply; if it was a typo, re-ask with a declared value.
7. Zero-row honesty — never a confident negative
Trigger: the query ran fine and returned 0 rows.
The query returned no rows. That can mean there are genuinely none, or that the query did not capture your question — verify the SQL before reporting that there are none.
The SQL that ran is always shown with the answer (CLI prints it; TUI lets you yank it with y) —
verification is one glance away.
Two more things can happen once the query actually runs — after every guard above has already let it through, so neither is a guard veto:
- A filter matches more than one row — e.g. a customer name that isn't unique. lumnik re-runs just that filter and tells you what it matched instead of failing flat:
That filter matches
<N>rows, so the question has no single answer. Matching<column>:<id-1>,<id-2>, …. Ask again naming one of them — for example by filtering on<column>instead of the name.
If the probe hit its own row cap, <N> reads "at least <N>" — a floor, never presented as
exact. The id list is deduplicated and stops at 10 distinct values, appending ", and <k> more"
when there were more.
- Any other read failure (a genuine syntax error, a permission or timeout issue) gets a refusal naming the SQL and, when it's safe to, the database's own reason:
I couldn't run that query — the database refused it:
<reason>The SQL is below; if it looks wrong for this scope, re-run discovery.
<reason> is the database's own first line when its SQLSTATE class describes pure structure
(21, 42, 53, 54, 57); for any other class it becomes SQLSTATE <code> (the message is
withheld — that class can carry stored values; the server log has it). instead of the raw
message.
Fail-open by design
The value/column/table guards inspect only clean, unambiguous SQL shapes (bare identifiers
compared to string literals). A function-wrapped column (lower(col) = …), a qualified alias, or
an ambiguous name across tables is deliberately not refused — better to miss one check than to
block a legitimate query on a parsing glitch. The guards are a floor of honesty, not a cage.
Names the source chose
A column keeps the name its source gave it: a JSON field named c_bpartner.id, a CSV header
Partner Name, an ERP column MONTANT_€. The schema card names every column quoted, so all
three are ordinary askable columns — the generated SQL quotes them, and a refusal about one names
it in full, never a fragment (montant_€_ht stays montant_€_ht). Use the name exactly as the
card lists it; none of the three would resolve correctly left unquoted.
This matters most where there is no rename. A kafka manifest carries no mapping hooks, so the producer's field name is the column name; a CSV or JDBC source can also rename on the way in, but nothing has to.
The same holds one rung up the ladder: a
kind:Workflow declares its lifecycle over the
column's real name too, so the graph the chat answers from and the column it answers about are
spelled the same way.
When an answer looks wrong
The guards lower the odds; they do not abolish them. The circuit:
- Detect — the SQL travels with every answer: reading it is the verification gesture, and the reason a wrong number is catchable at all.
- Report — there is no product channel today: no button, no endpoint (the security contact is for vulnerabilities only). The circuit is your integrator.
- Fix — a value or column the guards should have known: add it to the entity
manifest, then
POST /api/platform/rag/rediscover(schema admin). - Re-verify — the faithfulness judge exists (Models → Judge) but only as an offline eval harness in the test suite; there is no operator procedure "after a model update, run X" yet. Until there is, re-run the questions that went wrong — the SQL in each answer makes them replayable.
See also
- Ingestion honesty — the same principle on the way in (DLQ, quarantine, run transparency).
- Scopes — what a scope is and what the leak guard guarantees.
- View generation contract — the same abstention ethos when
the AI writes a
kind: View: unanswerable ask → refusal with the reason, never invented columns.