Skip to content

Ask honesty — the five guards

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

question → abstention floor → SQL generation → abstention exit → SQL safety
        → leak guard → undocumented-column guard → domain guard
        → read-only execution → zero-row honesty → answer

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 target.tags: [<your-scope>] and run 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.

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: '' 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 tag the connector into this scope 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: '' 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: '' is not a declared value of (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.

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.

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 honesty — the same abstention ethos when the AI writes a kind: View: unanswerable ask → refusal with the reason, never invented columns.