Architecture12 min read
Certified answers from a Rust graph engine
An agent plans the query; a Rust engine with no model computes each figure and returns a certificate tracing it to the records, checking access on every read.
Mercury Labs
An agent plans the query; a Rust engine with no model computes each figure and returns a certificate tracing it to the records, checking access on every read.
To stop an AI agent inventing numbers, take the arithmetic away from it. In the Company Twin, the agent plans a structured query and writes the answer, but every figure is computed by a deterministic graph engine, written in Rust, that calls no model. Each result comes back with a certificate that pins the query, the model of the business and the data it ran against, and any figure can be traced to the records behind it.
A certified answer is a figure returned with a machine-checkable record of exactly how it was computed: the query, the definitions used, the versions of the model and data, and a hash of the result, so anyone can run it again and get the same table.
This article explains why we split the work this way, what the engine holds, how it enforces access, what it does when it cannot answer, and what we measured when we made it faster.
The usual way to let a language model answer "what did we invoice in Q3?" is to give it the schema and let it write SQL. That works in demos and fails in quiet ways in production, because the model is making two kinds of decision at once. It decides what to ask, which is a language problem it is good at. It also decides how to compute the answer: which table holds the truth, whether a credit note nets against an invoice, whether two currencies may be added, whether a balance is summed or read at a date. Those are business rules, and a model that gets them right nine times in ten is wrong often enough to lose a finance team's trust.
We have written before about guarding model-written SQL. Guards help, but they are a fence around the arithmetic, not a replacement for it. The graph engine moves the rules out of the prompt and into code that is tested once and applied every time.
The task agent reads a catalogue of what the organisation's graph can answer: entities, their dimensions and links, and named measures with descriptions. For a question it writes up to six structured queries, each naming measures, the dimensions to group by, filters and a time window. It never writes SQL and never adds up a column itself. The engine runs the queries and returns tables, and the agent writes the answer around them, citing the certificates.
The planning step never reads the whole graph. For each question the engine writes a short brief: the names the question mentions and the 16 most relevant measures, ranked by BM25 (a standard keyword-relevance score that favours rarer words shared with the question) over their names and descriptions. We tried ranking by meaning with embeddings and dropped it, because plain words did this job as well and cost less to run (the trade-off is the one in our guide to embeddings). Over 446 measures the brief takes about 8 ms.
A query, as the agent writes it, looks like this:
{ "measures": ["invoice_amount_total"], "by": ["currency"], "where": [{ "field": "status", "op": "in", "value": ["PAID", "AUTHORISED"] }], "time": { "from": "2026-07-01", "to": "2026-09-30" }, "totals": true}Each organisation has one declared semantic model, loaded into memory. Its baseline is built from the governed warehouse views our connectors maintain: one entity per view, with typed fields, keys, relationships as links, dates, and money columns as totals. An organisation can also have its own model with richer entities, matching rules and measures, kept under version control and reported by hash, so we always know which definitions answered. On top of either, every model gains:
The service itself is small, conventional Rust: axum for HTTP, tokio-postgres and tiberius for the two database dialects, zstd for snapshots on disk. It listens only on loopback and answers only the tenant API, which holds its credential. The executor that runs agents holds no address or secret for it.
The rules that decide a figure are where most of the value lies, and they are the rules a model is most likely to get wrong:
| Rule | What the engine does |
|---|---|
| Currencies are never added | A sum over amounts in several currencies is refused, naming them, until the query groups or filters by currency. |
| A balance is read at a date | A snapshot measure such as closing cash reads the latest date in each period; adding a balance across months is refused or flagged. |
| A period needs a time rule | A window on a measure with no date to apply it to is refused, naming the dates it could use, instead of answering the all-time figure. |
| A count of nothing is zero, with a reason | An empty answer says why: which filter or window removed the rows. |
| Sums never cross many-to-one links | A sum that would repeat a linked value once per row is refused when the model loads. |
| Typing errors are not the latest date | A date fifty years ahead is never treated as the most recent; the answer says it was set aside. |
Every query result carries a certificate. It is not a citation that points loosely at sources; it is the information needed to reproduce the exact table. For identical inputs the hashes are identical, so a figure in last month's answer can be checked against the same model and data today.
{ "schemaVersion": "lazy-warehouse.query-certificate.v1", "certificateId": "qc_5f0c9a41d2e8b7c6a3f91e04", "modelSha256": "9b1e…", "dataAsOf": "2026-11-20T09:14:03Z", "query": { "measures": ["invoice_amount_total"], "by": ["currency"], "…": "…" }, "querySha256": "c4a7…", "measures": [{ "name": "invoice_amount_total", "agg": "sum", "currency_field": "currency", "…": "…" }], "entities": [ { "name": "invoice", "definitionSha256": "1d0f…", "dataSha256": "77ab…", "rows": 41210, "relations": ["xero.invoices"] } ], "result": { "sha256": "e930…", "columns": ["currency", "invoice_amount_total"], "rows": 3, "complete": true }}(The schema name comes from the research engine this one grew out of.) A trace goes one step further: for a single cell it returns the records the figure was computed from, recomputes the figure from them and reports whether it reconciles, with each record pointing at its source record and, where the model says how, a link to open it in the source system. The agent keeps the certificates it cites with its run, so a reviewer can see which tables an answer rests on. Our earlier piece on why every AI answer needs a receipt sets out the principle; this is the mechanism.
A certificate also states freshness twice: dataAsOf, when the engine last read changed rows, and a separate check time, when it last looked for changes. An hour-old data time beside a current check time means nothing changed, not that syncing stalled.
Access is not a filter applied to the answer. It is an input to the read. Every request names the organisation, the person the control plane verified, and the warehouse views, channel data sets, channels and database relations that person may read. For that read, the engine uses only the entities those sources provide, anything built from them alone, and the links between them. An empty list reads nothing.
There are two layers. The organisation's engine reads the governed views as no person, so it holds the organisation's shared rows. A person's slice reads the per-person views (their own mail, for example) again as that person, and shares everything else with the organisation's engine rather than copying it. A slice is built in the background, kept on disk for fast restarts, and closed and rebuilt when an erasure removes someone's rows.
Every read the engine answers is written to the organisation's access ledger before the caller receives it: who read, which run or public call, a hash of the input and counts, never the values. A read that cannot be recorded is not returned. The same rule applies whether the reader is our own agent, a person in the CLI, a tenant API key or an outside model connected over MCP.
A query that asks for something the model lacks (a measure, a field, a link or an entity) is answered with a typed refusal, not_covered, listing the missing pieces. The agent is told to say in its answer that the organisation's graph does not cover that part, naming what is missing, rather than estimate it. The run records the hand-off, so we can measure coverage directly and add the measures people actually ask for. Refusals are also counted on the engine's log by measure, with no reader or value, so the measures agents hit most are the first to get a time rule or a currency split.
"Our graph does not hold contract end dates yet" is a useful sentence. A plausible number built from the wrong column is not.
In November we reviewed the whole engine, about 25,000 lines of Rust, and measured before claiming any speed-up. Because we do not load-test on customers' data, the measurements use a made-up organisation the size of a production model: 224 entities, 446 measures, a ledger of one million rows over 2,000 accounts, two guarded feed entities of 150,000 rows, and 220 small entities.
The biggest single cost was in the query loop. Every filter, dimension and measure cloned its cell for every row, a text comparison folded both sides to lower case on every row, and a test for a blank cell rendered the whole cell, which for a long document column meant copying 100 KB to learn it was not empty. The change reads a field in place by its column index, folds the filter's target once, compares ASCII text without copying and finds a measure's time columns once per query. It is on main with the review's measurement harness beside it, so every later change is measured against the same fixture.
Two other measurements shaped the plan. A search over the engine's word index takes 0.5 ms; scanning every cell takes 299 ms. A person's own tables were not indexed, so personal searches paid that difference every time; they are now indexed in the background within a memory budget. And explaining an empty answer used to run the query again once per filter, close to a second on a million rows; it now takes one pass.
Speed matters less for its own sake than for what it allows. A refresh used to hold reads on one large organisation for close to a minute. The engine now builds each refresh beside the state that reads see and swaps it in whole, so a read takes the state before the refresh or after it, never part of each. A test races reads against 400 generations of refreshes to hold it to that.
Two standing suites ask the live graph through the command line with no model involved. An answer suite grades answers against truth worked out per question. Invariants need no truth at all: over every measure in every catalogue they check properties any correct engine must keep, such as groups adding up to the ungrouped figure, a filter and its negation partitioning the total, and the top five being the first five of the full ordering. Because they are generated from each catalogue, a new measure is covered without anyone writing a test. The approach is the one in our guide to evaluating AI systems at scale, applied to a part of the system that should never be probabilistic.
Most organisations asking us about AI over their finance or operations data want answers people can repeat in a board meeting. Our approach follows the pattern above.
06 stages
Name the twenty questions people actually ask, and the measures behind them
Watchpoint
A model of everything is never finished; a model of the questions is useful in weeks
Define each measure once, with its time rule and currency field
Watchpoint
Leaving a balance to be summed by whoever asks is how two reports come to disagree
Put access in the request, not in a filter on the answer
Watchpoint
A filter applied afterwards leaks through every path it forgot
Return a certificate with every figure and keep it with the answer
Watchpoint
Without one, a wrong number cannot be traced or reproduced
Refuse what the model lacks, and count the refusals
Watchpoint
An approximation looks like an answer; a refusal tells you what to model next
Run invariants over every measure after every release
Watchpoint
Known-answer tests only cover the questions someone thought to ask
None of this needs a new warehouse. It sits on the governed views you already have, and the model of the business it uses is a short, reviewable file. For more on why agreed definitions beat ever more retrieval, see approved meaning, not more context, and for how retrieval and agents divide the rest of the work, our guide to RAG and agentic retrieval.
The graph engine computes the figures the Company Twin gives about your data, each with a certificate you can check. See the Company Twin →