Skip to content
Back to Projects

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.

what this page claims2025 → ongoing · live in production
  1. Answers arrive with the SQL that produced them
  2. It can answer about the whole estate, not one table at a time
  3. Generated SQL can only ever read
  4. A 39,041-row export streams instead of filling memory
  5. A conversation can become a dashboard
  6. Fabrication is at zerono receipt
how hard is text-to-sql, really?

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.

clarity · chat
Clarity answering how many sites are in the estate, with the two SQL statements that produced the answer shown underneath it
receipt · 01518 sites, and underneath it the two statements that counted them. These users read SQL. Showing the query costs screen space, but it means they can check the answer instead of taking my word for it.
where's the vector store?

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:

compiled nightly, injected as cached contextclick a highlighted line
## Schema Directory
Every 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?
```sql
SELECT s.name, count(*) AS open_jobs
FROM work_order w JOIN site s ON s.id = w.site_id
WHERE w.closed_at IS NULL GROUP BY s.name ORDER BY 2 DESC
```
freshness marks

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:

clarity · estate briefing
Generated estate briefing showing site and device counts, a disconnected HVAC unit, onboarding status and an empty maintenance backlog
receipt · 02The briefing compiles the same grounded context into a readout. A smaller tenant: nine sites, 170 devices, one HVAC unit offline for over 24 hours. Each card names the site it came from rather than summarising the estate in prose, so anything surprising can be checked against the thing it describes.
which sites are running hottest?

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.

drop table sites;

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.

receipt · 03run it yourself, below
try one, or write your own
model-generated SQL
what the validator actually sees
SELECT tablename FROM pg_tables

comments stripped · literals masked · identifiers unquoted and lower-cased

blockedclarity.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.

identifiers: select, tablename, from, pg_tables
calls: none
schema refs: none

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.

how do you know it works?

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.

grounding layerONwhat users get
question

What are the freezer temperatures across my northern sites?

what actually ran
! runQuery → join across operational + telemetry stores
! error → cross-store join, relation not found
answer shown to the user
I couldn't answer that one. The operational and telemetry data live in separate stores and I can't join across them, so I need to query them one at a time. Want me to pull the telemetry side on its own?
fabricated_resulttriggers a correction round

Every query this turn failed, yet the answer presents data. Nothing produced those numbers.

what stops it, though?

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.

  1. An answer with a number in it ran a query

    canary evals, post-deploy

    The canary evals from the section above: canned questions after every deploy, asserted against the audit record rather than the prose.

  2. Generated SQL can only ever read

    a Postgres role, not the prompt

    A 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.

  3. The two data stores can never be joined

    routing, by construction

    Operational 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.

  4. A conversation can't become runaway spend

    around the agent loop

    Every 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.

clarity · exports
Reports page showing a generated CSV of telemetry readings, 39,041 rows and 2.6MB, ready to download
receipt · 0439,041 rows against a 50,000-row ceiling, 2.6MB, retained 30 days. Exports stream row by row and never assemble the result set in heap, so memory stays flat whether the answer is forty rows or forty thousand.
is it working?

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.

clarity · a dashboard the AI built
A dashboard built from a Clarity conversation, with the tenant's name and figures redacted, showing widgets and a note about filters
receipt · 05A dashboard assembled from a conversation. The tenant's name and their trading figures are blacked out. They're not mine to publish. The note above the widgets is real: widgets built before dashboard filters existed can't be reached by them, so the UI says so rather than pretending otherwise.
no receipt · 06left open in the ledger

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.