Governed SQL analyst agent
A Slack-native BI agent that answers business questions with SQL over an analytical warehouse. The model writes the query and the sentence. Deterministic code decides whether the answer is allowed to leave.
Someone asks a business question in Slack, in plain words. The agent turns it into a database query. The query is checked before it runs, and the answer is checked before it is sent: the number is recomputed from the report the company already uses, and if the two disagree, nothing is sent. Everything the agent did is written down, so a person can replay it later.
The part that is actually hard
Getting a model to write SQL is not the interesting problem. In a warehouse where a wrong number moves money, the interesting problem is everything that decides whether the number it produced is allowed to reach a human, and whether anyone can reconstruct that decision afterwards.
So this agent is built around a boundary: the model scores, suggests and drafts, governed code decides. It cannot widen its own access, it cannot skip its own checks, and every turn it takes is replayable from the log.
One question, end to end
The ordering is the point. Validation is static and cheap, so it runs before execution and an unsafe query never reaches the database. The cross-check runs after the answer is written, because the number being checked is the one the agent is about to state.
question
-> inbound screening instruction-shaped input is refused and logged
-> plan the model writes SQL and declares which canonical
metric its headline number is supposed to equal
-> validate AST: one statement, read-only, allowlisted relations,
no restricted columns, no file or network functions,
hard row cap injected
rejected -> the rule is fed back to the model, up to 2 repairs
-> execute read-only connection, 15 second timeout
-> narrate the model writes the answer strictly from the rows
-> checks empty result, truncation, source freshness,
negative counts, canonical cross-check
any failure -> the number is not sent, the turn escalates
-> answer + cited evidence + audit recordThe query governance layer
Rules are enforced against the parsed syntax tree, not against the query text, which is why comment tricks and string mangling do not get through. The whole policy sits in one readable file, so a change to it is a single clear diff.
| Rule | Rejects |
|---|---|
| not_read_only | anything that is not a SELECT: DROP, UPDATE, COPY, ATTACH, PRAGMA |
| multi_statement | statement chaining, the usual way an injected clause smuggles in a second query |
| relation_not_allowed | any table outside the allowlist, including CTEs that shadow allowlisted names |
| denied_column | restricted personal data columns, qualified or not |
| star_over_restricted_relation | SELECT * on a table that holds restricted columns |
| denied_function | file, network and extension functions, including in the FROM clause |
| row cap | injected, not requested: no LIMIT becomes 1000, a larger one is clamped |
Defence is layered rather than stacked on the input screen. A prompt injection that slips past the screen still cannot produce a query that leaves the allowlist, and a model that decides to be helpful about email addresses still cannot select the column.
Checks before a number is sent
| Check | What it catches |
|---|---|
| non_empty | an empty result the model would otherwise narrate around |
| row_cap | a truncated result presented as a total |
| freshness | a number computed over a table that has not finished loading |
| non_negative_counts | a count column that came back negative |
| canonical_crosscheck | the headline number recomputed from the trusted reporting view, blocked above 0.5% drift |
A failed check does not produce a hedged answer. It produces no number at all, plus a trace id for a human to open. And the model does not get the last word on whether the cross-check runs: when the query returns a single number whose alias names a canonical metric, the check is derived from the query itself. A control that only fires when the model remembers to ask for it is not a control.
What a reviewer sees
Every turn appends a full record to a decision audit log, and the supervisor console is generated from that log: rejected queries shown next to the rule that rejected them, and grading buttons whose output exports as evaluation data.



The dashboard the team steers by
The agent writes down every decision it makes, so its own operating numbers are a query away. This dashboard is generated from that log plus the evaluation history: decision volume, why turns did not produce a number, how each check behaved, cross-check drift against the 0.5% threshold, latency, suite history, and the queue of turns a human should still look at.
Both surfaces are live, open them and click around:
performance dashboardsupervisor console
The console is the review surface: expand any turn to see the SQL that was proposed, the rule that rejected it, the rows, the checks, and grade it. The grades export as JSONL, which is the format the evaluation cases are written in.
Evaluation
For a non-deterministic system a single green run is not evidence, so the suite reports per-case stability across repeated runs. Every run is appended to a history file, so the number below can be re-derived rather than taken on trust.
All five misses are the same case: an average per active player per day, which the model still gets wrong about a third of the time. It either cannot write the query within its three attempts or produces a number a check rejects. It has never sent a wrong number for it: it sends no number. The case stays in the suite at its real pass rate rather than being quietly reworded until it goes green. The other fifteen have not missed once in sixteen runs.
What the suite caught during development, each of which became a fix rather than a note:
- The agent filtered a timestamp column against a bare end date, silently dropping the last day of every range. Caught by the cross-check at 10.9% drift.
- It summed all withdrawals where the canonical view counts settled ones only. That is how a metric definition drifts in production. Caught at 29.6% drift and kept as a permanent regression case.
- It refused a perfectly answerable question because an instruction to prefer the reporting views read as a prohibition on base tables. Over-refusal is a failure and the suite grades it as one.
- Once the suite started grading whether the cross-check actually ran, rather than only whether the answer looked right, one case dropped to 1 out of 5: the agent answered correctly but skipped the declaration, so the number went out uncorroborated. The fix was not a sterner prompt. The derivation moved into deterministic code and the case went back to 5 out of 5.
- Then the cross-check itself became the bug. Running a bank of 40 real questions through the agent surfaced four answers blocked for nonsense reasons: a bonus figure compared against game payouts, a count of rejected withdrawals compared against a euro total, the largest single withdrawal reported as 1862% away from the monthly figure. The check was matching on column names. It now also requires that the metric comes from a relation it is defined on, that the aggregate is the same kind (a maximum is not a total, an average per player is not a count), and that the query is not answering a filtered slice. All four are permanent regression cases.
- Net deposits came back inverted, withdrawals minus deposits, across a whole batch of runs. The fix was not a prompt patch: the metric definitions moved into the warehouse catalog the agent reads, next to the columns they describe, because that is where a definition belongs.
- A query that passed validation and then failed inside the database used to end the turn. A database error is a repairable mistake like any other, so it now goes back to the model the same way a rejected query does.
- Two of the graded cases still missed at an earlier stage. Once the agent's net deposit figure disagreed with the canonical view and the cross-check blocked it, so the user got no number rather than a wrong one. Once a correct answer failed a grading assertion and did not reproduce in twelve further runs. Both are on the record, because a suite that only ever reports green is a suite nobody is learning from.
What is real and what is simplified
Real
- SQL validation is genuine AST inspection, not regex on the query string.
- Execution is a read-only connection with a timeout and an injected row cap.
- The cross-check recomputes the number from an independent view and blocks on drift.
- The audit log reconstructs a turn completely, and the console is generated from it.
- The eval suite runs against the live model and fails on a regression.
Simplified, deliberately
- DuckDB stands in for ClickHouse: columnar and close in SQL, but no cluster and no query cost management.
- Output is built as Slack Block Kit blocks, but there is no Slack workspace behind it.
- Every tool is read-only. Confirmation workflows and the autonomy ladder are out of scope.
- No ML models: churn, lifetime value and risk scoring are not part of this project.
- gpt-4o-mini throughout, chosen as a cost guard. A stronger model raises answer quality and does not move the governance boundary, which is the part that matters.
- Outbound calls are off by default. The whole pipeline, checks, audit log, console and dashboard included, runs with no network access at all, and a test replaces the socket layer with one that raises on any connection to prove it. This page itself is static: it makes no request to any third party.
All data in this project is synthetic and generated from a fixed seed. No real company, customer or transaction data is used anywhere in it. Built in August 2026. Source code is available on request.