Skip to content
Go back

What I Got Wrong About Letting an AI Agent Write SQL

Published: at 11:00 AM (13 min read)

On October 10 I published a post about giving an AI agent SQL access to a multi-tenant ERP. It ended with a design: a restrictive policy per table, a validator, and a lint for the fan-out trap.

The same day, I accepted a decision that closed that design.

This is the post I owe the first one. Not because the first one was dishonest: it said, in as many words, that the restrictive-policy part was “a hypothesis I have not yet tested”. It is because a reader would still take its conclusion as the plan. It was not. Here is what changed, what I measured, and what I would do differently.

Table of contents

Open Table of contents

What the first post got right

One idea held up, and it is the one I would keep if I could keep only one: “may this session see this row?” and “does this number mean what it claims?” are two different failures with two different owners. The database owns the first, below the model. Something that knows grain and relationships owns the second. A security control cannot answer the second one.

Everything else in that post survived only in a smaller form, or not at all.

The mistake: I asked “how” before “should”

The first design started from a given: the agent writes SQL over the physical tables. All the effort went into making that safe. Reading the chain back, it is almost embarrassing:

StepWhy it was neededWhat it still could not do
A separate read-only loginthe agent must not reach the application role
A signed tenant tokenrow-level security trusts a variable the session can forge
A view per entity carrying the tokencarry the signed predicatedoes not scale to hundreds of tables
Restrictive policies and column grantsreplace the viewsevery table and column must be classified
A catalog of every table and a set of deny rulesdecide what is safecannot say what “revenue” means, or what a given user may see

Each step closed one hole and exposed the next. When every fix needs the next fix, the design is answering a question it should have rejected.

I looked at that chain and said what it was: an usine à gaz. I had already said it about the views, and I was right about the views. This time I did not stop at one step. I stopped the whole direction. I wrote a critique, checked what the vendors actually do, and wrote down a different design the same day. That call was mine. It came from the shape of the chain, not from a test.

An assembly line of increasingly elaborate database defenses ends at a stop sign asking whether the design is answering the wrong question.

Every new defense closed a hole — and made it clearer that I was defending the wrong path.

The test that showed where the problem really was

By then the isolation work was finished and attacked. A script played an injected agent against the role, and every attack failed. That part worked, and it still does.

The decision to stop did not come from a test. The next day I ran the first end-to-end evaluation, this time of the replacement: real questions a small-business owner would ask, answered by the agent, scored against SQL ground truth computed on a test company.

Two answers out of eight were correct. Zero rows from another company.

That result did not change the direction. It showed what the new direction would have to be good at. The isolation gate had passed from day one, and it had told me nothing about whether the answers were right. The part of the system I had engineered was not the part that fails.

What actually moved the score

Not the database layer. Over the following days the score climbed because of work on meaning:

The rollup bug was the classic shape. Summing a tree by joining every account to all of its ancestors counts a leaf once per level, unless each row is pinned to exactly one parent:

-- wrong: a leaf appears once for every ancestor it is joined to
SELECT a.id, SUM(e.amount)
FROM accounts a
JOIN account_ancestors t ON t.ancestor_id = a.id   -- one row per (node, ancestor) pair
JOIN entries e           ON e.account_id = t.node_id
GROUP BY a.id;

-- right: sum each entry once at its own account, then roll up the totals
WITH own AS (
  SELECT account_id, SUM(amount) AS total
  FROM entries
  GROUP BY account_id
)
SELECT t.ancestor_id, SUM(own.total)
FROM account_ancestors t
JOIN own ON own.account_id = t.node_id
GROUP BY t.ancestor_id;

That last one is the argument for the second half of the first post, in its strongest form. The silent failure was not the model’s SQL. It was a number everyone trusted.

Two evaluation paths lead to one release gate: tenant isolation prevents cross-company rows while separate business-meaning checks distinguish a wrong answer from a correct one.

Isolation proved the agent could not cross a company boundary. It did not prove that its numbers meant anything.

What the market actually does

The first post cited vendor practice as support. I checked it against primary documentation only after designing, which is the wrong order. What the documentation says is different from what I assumed:

That was the opposite of a separate database path. It also exposed the point I had missed: authorization lives in the service layer. Today my application has no read-side permissions beyond the tenant, so the agent could not leak inside a company. When read rules arrive (payroll, margins), an agent that reads through the application’s own path inherits them. An agent with its own database path would need every rule re-implemented in PostgreSQL.

What I built instead

ConcernFirst designNow
What the agent writesSQLa structured request: entity, fields, filters, grouping, aggregates
Who enforces the tenanta database role and a signed tokenthe compiler injects it; the query runs as the user inside a company-scoped transaction; row-level security stays a second guard
Meaningleft to the model, plus a lintentities with grain and state meanings; governed metrics for any number that has a definition; many-to-one joins only
New infrastructureroles, secrets, a policy stepnone
Covering hundreds of tablesclassify each by handa few curated entities, the rest derived from the schema and discovered on demand

Concretely, the agent no longer emits a statement. It emits something like this, and nothing else:

{
  "entity": "order",
  "fields": ["customer.name"],
  "filters": [{ "field": "status", "op": "=", "value": "open" }],
  "groupBy": ["customer.name"],
  "aggregates": [{ "metric": "order_total", "fn": "sum" }]
}

The application compiles it. The tenant predicate is added there, by code the model never sees, and the field list is checked against what the entity declares:

function compile(req: EntityRequest, ctx: { tenantId: string }): Query {
  const entity = catalog.get(req.entity); // unknown entity -> rejected
  const fields = req.fields.map(f => entity.resolve(f)); // undeclared field -> rejected

  return select(fields)
    .from(entity.table)
    .where(
      and(
        eq(entity.table.tenantId, ctx.tenantId),
        ...toFilters(req.filters, entity)
      )
    )
    .groupBy(...req.groupBy.map(f => entity.resolve(f)));
}
// executed as the signed-in user, inside a transaction scoped to ctx.tenantId

A request the catalog cannot express fails with a clear error. It never degrades into a looser query.

The fan-out trap from the first post is gone by construction, not by lint: joins are many-to-one only, and amounts that have a governed definition go through a metric, never a sum over records. The hostile-query problem disappears too, because the model never writes a statement. It writes a typed request.

A governed query path: an AI agent creates a structured request, the application applies business meaning and user authorization, and the database remains within a company-scoped boundary.

The replacement is deliberately boring: a bounded request gains meaning and authorization before it reaches data.

I kept one door open. If real usage measures more than 15% of questions unsupported over 60 days, entity SQL (the agent writes a SELECT against the entities, parsed and rewritten by the application) opens. It is designed in, and it waits for evidence instead of for my enthusiasm.

The numbers, and what they do not prove

The release gate is 99 questions, scored against SQL ground truth, with a bar of 95% on supported questions and zero rows from another company. By model tier:

Model tierSupported questionsRows from another company
Small, previous generation (five runs)59% to 95.6%, last run 90.5%0
Large, previous generation97.5%0
Smallest, current generation (two runs)100%, then 96.2%0

Three things I take from this:

  1. The tenant boundary was never the weak point, in either design.
  2. Price per token does not predict the result. A tier about twenty-five times cheaper than the large one passed twice. The previous small tier did not pass reliably. Only the evaluation tells you.
  3. The harness lies too. Two of the three failures in one run were bugs in my scorer, not mistakes by the model. A check that only reads the last query penalised an agent that had grouped correctly and then double-checked a zero. A gate you cannot trust to fail correctly is worse than none.

The scorer bug fit in one line:

// before: judged only the final tool call
const grouped = calls.at(-1)?.groupBy.length > 0;

// after: the agent may verify a result with a later, simpler query
const grouped = calls.some(c => c.groupBy.length > 0);

What is still unproven

I would rather say this than have it found:

The rules I will use next time

  1. Ask “should” before “how.” List the options, including “do not give the agent this capability.” Prefer removing an attack surface to defending it.

  2. Measure on real questions before building infrastructure. Ten or twenty questions with ground truth would have shown on day one that the failing layer was meaning.

  3. Gate correctness as hard as isolation. An isolation gate that passes says nothing about whether the answers are right.

  4. Check the market in primary sources before copying a pattern, and label secondary numbers as such.

  5. Treat “this is becoming a factory” as a stop signal. Look for the question being answered wrongly, not for the next hole to plug. Here it was the cheapest check I had: I said the words out loud, a day before any test could have told me.

  6. Prove a test can fail. Remove the protection and watch the test go red. Do the same for the scorer.

    For the tenant gate that means dropping the policy on a scratch table and checking that the attack now returns rows:

    ALTER TABLE scratch_orders DISABLE ROW LEVEL SECURITY;
    -- rerun the attack: it must now FAIL, otherwise the test proves nothing
  7. Put a price on an experiment before running it. A week of repeated live runs is a bill, and most of mine were repeats nobody needed.

The lesson

“Can the agent write SQL?” was never one question. The first post said so, and it was right. Where I went wrong was in believing that the answer to the security half could carry the design.

It could not. The part I had finished, and attacked, and was proud of, was the part that was not failing. The part that was failing was the part I had not measured yet.