Ali Rathore.

July 2026

The number carries its own receipt

An assistant answers business questions in plain English and every number it reports arrives with the exact SQL that produced it. The real boundary is not the prompt telling it to behave, it is a Postgres role that cannot write and cannot cross tenants.

Someone asks the store chain’s assistant which sites are bleeding sales this week, and it answers: three of them, named, ranked, with dollar figures. Below the sentence sits a small card. On the card is the SQL it ran, select site_id, store_name, weekly_sales from v_store_rankings order by sales_rank limit 3, and a line reading “Grounded · 3 rows returned.” You can read the query. You can run it yourself. The number in the prose and the number in the card came from the same execution, or the card would not be there.

That last clause is the whole point, and it is worth being suspicious of, because the assistant’s system prompt contains exactly the instruction you’d expect an engineer to roll their eyes at. Verbatim: “NEVER state a number you did not get from run_sql.” A capitalized NEVER in a prompt is a confession that you have no other mechanism. Prompts are not enforcement; they are hope with good grammar. A model told never to invent a number will, under the right question, invent one anyway and apologize later. So the interesting question is not whether the prompt is well-written. It is what happens when the prompt fails, when the model hallucinates a query, or hallucinates a number, or writes SQL that reaches for a table it should never see.

The design answer is to stop treating the SQL as text to be trusted and start treating it as an intermediate representation to be executed under constraint. The model proposes a query. The runtime disposes of it. Everything that matters happens between “proposes” and the rows coming back.

Here is the path a query actually takes, in platform/ai/sqltool. First a cheap text check: is this a single statement, does it start with select or with, does it avoid set_config and the system catalogs. That check exists, and the package’s own doc comment is unusually honest about what it is worth: “The SQL text checks are for clean errors, NOT the security boundary.” The comment goes on to name its own defeat: Postgres U&"…" unicode-escape identifiers decode only after the string match runs, so a determined string can slip a catalog reference past the regex. The authors wrote the check and then wrote down how to beat it. That is the correct posture. A validator you believe in is a validator that will eventually be the only thing between a hallucination and your data, and it is not strong enough for that job.

The job belongs one layer down. The query runs inside a BEGIN READ ONLY transaction, so a write is refused by the transaction’s access mode regardless of what the SQL says. Then, still on the privileged pool connection, the app pins its tenancy: set_config('app.active_owner', …) with the caller’s real owner id, transaction-local, parameterized so nothing the model wrote can reach the value. Only then does the connection SET LOCAL ROLE to a non-BYPASSRLS, SELECT-only role and hand the model’s query to Postgres. The views the query reads are security_invoker, so they self-filter against app.active_owner under that stripped role’s RLS. A 5-second statement_timeout bounds the pathological case. The row cap is pushed into the plan as a wrapping subquery, so a cartesian product is stopped by the planner rather than materialized and trimmed afterward.

Read that ordering carefully, because the order is the security argument. The GUC that decides which tenant’s rows exist is set while the code is still privileged and before the role drops, so the model never holds both the ability to run SQL and the ability to change whose data it sees. By the time the model’s query executes, the connection cannot write and cannot cross tenants, and neither fact depends on the SQL being well-formed. Forced RLS on the base tables decides them. The comment in the runner is blunt about where the line is: the READ ONLY tx, the non-BYPASSRLS role, and the app’s fail-closed GUCs “hold even if a check is evaded.”

This is the capability-security move, worn as plumbing rather than announced. The model is handed a connection whose authority has already been reduced to exactly “read this tenant’s rows.” It cannot escalate by being clever, because nothing in the SQL grammar grants privilege: privilege was decided by a Postgres role grant, out of band, before the query was seen. You can hallucinate a query. You cannot hallucinate past a GRANT. The failure mode of a confused model is a query that errors, or returns zero rows, or returns fewer than it hoped, never a query that returns someone else’s data or changes yours.

Which brings the card back into focus. When run_sql returns, a withProvenance wrapper marshals a Provenance record onto the chat stream: the exact SQL string, the row count, a truncation flag, and the rows themselves. The UI renders it as the verifiable card under the answer, “Grounded · N row(s) returned.” The provenance is not a nicety bolted on for user trust; it is the same execution’s output, emitted from inside the tool handler, so the card cannot show a query other than the one that ran. The number you read and the query you can re-run are the same event. That closes the loop the prompt could only gesture at: the prompt says never state an ungrounded number, and the architecture makes an ungrounded number have nowhere to come from and no card to sit under.

I said one war story, so here it is, from the runner’s own test suite. There is a test that feeds the executor with s as (select set_config('app.active_owner','00000000-0000-0000-0000-000000000000',true)) select * from v_transactions, s, a CTE that tries to re-pin the tenancy GUC to a different owner mid-query and then read transactions under it. It is precisely the attack the text-level set_config block is meant to catch. But the test’s value is not that the string check catches it. The test also exercises the executor path that skips the text validation entirely, to prove the boundary holds without it: the query runs, the GUC re-pin does nothing because the scope was pinned transaction-locally before the role drop and the stripped role has no authority to matter, and the rows that come back are the caller’s, filtered by RLS, or none. The check that names the attack is not the thing that stops the attack. The database is.

So it is worth being exact about where that boundary sits, because it is narrower than the card makes it look. What provenance and the stripped role guarantee is bounded and mechanical: no number the model reports is a black box, because every number came from an execution you can read and re-run, and no query it runs, however wrong, can write a row or cross a tenant, because the role grant and forced RLS decided that before the SQL was seen.

What they do not guarantee is that the answer is right. The model can write a perfectly grounded query against the wrong view: sum a rate column as if it were dollars, join on the wrong grain, read chain-wide product totals and narrate them as per-store. Every number it states will be real, executed, and re-runnable, and still answer a question nobody asked. A grounded query against the wrong view is a wrong answer with a card under it that certifies its provenance and says nothing about its meaning. The card proves the number came from the database. It does not prove the number is the number you wanted. So the wall is this: the mechanism moves the failure from “the model invented data or reached into the wrong tenant,” which it cannot do, to “the model misread the schema,” which it can do freely, and which arrives wearing the same “Grounded” badge as a correct answer. The badge tells you the number is real. It cannot tell you it is true.