Clarity
Natural-language database interface
Built the infrastructure and trust layer · Loweconex, a UK IoT platform business
Before Clarity, a customer wanting a number out of their estate raised a ticket and waited for an analyst to run the query. Now about twenty people a day ask directly, across roughly thirty tenants.
Those customers run hundreds of physical sites, supermarkets and warehouses full of sensors and HVAC kit, and Clarity lets them ask about that estate in English and get an answer back with the SQL that produced it. Generating the SQL was the easy bit. Proving the answers took the other eight months.
- Answers arrive with the SQL that produced them
- It can answer about the whole estate, not one table at a time
- Generated SQL can only ever read
- A 39,041-row export streams instead of filling memory
- A conversation can become a dashboard
- Fabrication is at zerono receipt
Text-to-SQL demos in an afternoon
Point a decent model at a schema and you'll have something working the same day.
The problems start after that. It answers from a table that died a year ago, quotes a number without running a query, or says an export is ready when nothing was written. These are worse than errors, because nobody can tell they happened.
When I read the actual user transcripts, the issue wasn't missing features. People didn't trust the answers.
So the design rule became: Clarity doesn't assert a number without showing the query behind it.

No vector store
The default move is embeddings: chunk the schema, load it into a vector database, retrieve per question. I didn't do that. A schema isn't an unbounded corpus. It's a few hundred tables you can describe directly.
A nightly job compiles a knowledge document per tenant. Business summary per table, a glossary, SQL recipes known to work. It goes into every conversation as cached context, byte-identical so the provider can cache it.
The model turns up already knowing the estate. Live discovery stays as a fallback, so a table added this morning works this morning. This is what it gets handed:
## Schema DirectoryEvery table you can query is listed below with a one-line description.This is the complete set.### operational- `work_order` — jobs raised against a site, with status and due date- `site` — every physical location, with region and commissioning date- `asset` — equipment installed at a site, keyed to its site- `site_daily_rollup` (no new data since 2025-03) — legacy daily aggregates- `alarm_event_archive` (no rows) — superseded, retained for audit### telemetry- `reading` — time-series sensor readings, one row per probe per interval- `probe` — sensor metadata: type, unit, the asset it monitors## Glossary- **overdue**: a work order past its due date and not yet closed- **estate**: every site belonging to one tenant## Verified Query Recipes- **open work by site** — how much outstanding work does each site have?```sqlSELECT s.name, count(*) AS open_jobsFROM work_order w JOIN site s ON s.id = w.site_idWHERE w.closed_at IS NULL GROUP BY s.name ORDER BY 2 DESC```
The compile probes each table's newest timestamp and stamps the result. A dead table announces itself, so the model routes around it instead of confidently reporting no data. This is the mark that exists because of a real bug.
Everything above is sanitised before it reaches the prompt. Table comments and sample values are attacker-influenceable, so headings, code fences and template tokens are defused at the rendering boundary. Injected knowledge is data, never instructions.
Compiled knowledge is what lets it answer about the whole estate at once rather than one table at a time:

The bug that changed the design
Someone asked which sites were running hottest. Clarity said there was no data. There was loads of data. It had found a promisingly named table, dead for months, while the live telemetry sat somewhere less obvious.
A wrong "no data" is the worst failure mode, because it never gets escalated. People just stop using the tool, and no metric tells you why.
Now the compile probes each table for its newest timestamp and marks the directory: (no rows), (no new data since …). The model routes around dead tables because it can see they're dead.
The compile only rewrites a table's summary when the table itself has changed, which it works out by hashing the structure. Freshness timestamps are deliberately left out of that hash. A clock ticking forward every night would otherwise rebuild the whole estate to tell us nothing had changed.
Try to get something past it
Generated SQL is untrusted input that happens to be executable. The system prompt says read-only, but a prompt isn't enforcement, so the restriction lives in the database.
Queries run as a dedicated read-only role, provisioned on every tenant database at startup. It fails closed: no pool, no query. There is no code path that falls back to the admin connection.
comments stripped · literals masked · identifiers unquoted and lower-cased
clarity.sql.blocked{reason=system_table}Access to system table 'pg_tables' is not permitted.
offending token: pg_tables
Beats naive string matching. Comments are stripped before any comparison happens.
These are the real rule sets and reason codes, ported to run in your browser. Nothing is sent anywhere. In production this is the outermost layer: underneath it the query still runs as a SELECT-only role that fails closed.
Validation lexes SQL to canonical tokens before checking anything. String matching loses to quoting and comments, so FROM/**/pg_tables would walk straight past it. Failures return as data, not exceptions, so the model reads its own error and corrects itself.
I won't use a model to grade a model
LLM-as-judge is the obvious approach; I didn't use it. A grader that hallucinates can't certify a system whose defining failure is hallucination. Two models can agree in the same wrong direction.
So the evals are canary questions instead. Canned questions replay after every deploy, asserted against the audit record rather than the prose. Answer contains a number, SQL must have run. Fabricated-names list must be empty. Export claimed, report row must have completed.
Five ways an answer can lie
These are the five classes the grounding layer checks on every turn, all of them simplified from cases the detectors caught in production. The toggle switches the grounding layer off and on. Off is what the model produced. On is what a user sees.
What are the freezer temperatures across my northern sites?
Every query this turn failed, yet the answer presents data. Nothing produced those numbers.
Where each guarantee is enforced
A system prompt is a request. Everything below is a property of the system, and each entry names the place it holds.
An answer with a number in it ran a query
canary evals, post-deployThe canary evals from the section above: canned questions after every deploy, asserted against the audit record rather than the prose.
Generated SQL can only ever read
a Postgres role, not the promptA dedicated read-only role, provisioned on every tenant database at startup. It fails closed — no pool, no query — and there is no code path that falls back to the admin connection.
The two data stores can never be joined
routing, by constructionOperational data lives on a per-tenant database, telemetry in a shared time-series store, and generated SQL is routed to exactly one of them. A cross-store join can't be expressed at all, which removes a whole category of wrong answer.
A conversation can't become runaway spend
around the agent loopEvery turn carries a tool-call cap enforced around the loop, not requested inside it. Blow it and the model is told to summarise what it found and stop. A per-tenant rate limit sits on top.

An honest answer
The questions that used to become a ticket now get asked directly. Some of those conversations end as a dashboard the tenant keeps rather than a one-off answer.

One claim I can't back yet: the trust layer is built and running, but I haven't re-run the transcript analysis against the current build, so I can't show that fabrication is at zero. I'm not claiming it until I can.