The objection is reasonable and you should expect it from your risk function: you are proposing to store SQL that an AI wrote, expose it as an endpoint, and let applications pass filters into it. Every part of that sentence sounds like an incident waiting to happen.
It is worth working through properly, because the answers are specific and they are the difference between a demo and something that can sit under a regulatory filing.
First: when does the model actually run?
This is the question that settles most of the others, and the answer is a line drawn hard through the product.
The model authors. It never invokes.
A model may write a query, draft a dashboard or read a screenshot. Once a person has reviewed and published that work, it is an ordinary artefact. Serving it binds typed values, validates the statement and runs it. No model is consulted.
So the thing under governance is not "an AI answering questions". It is a saved query that happens to have been drafted by one — reviewable in the same way, and behaving identically on the thousandth call as on the first.
flowchart TB
subgraph AUTH ["Authoring — a model may take part"]
direction LR
A1["Draft SQL"] --> A2["A person reviews"] --> A3["Publish a version"]
end
subgraph SERVE ["Serving — deterministic, always"]
direction LR
S1["Declared filters"] --> S2["Typed + bound"] --> S3["Statement validated"] --> S4["Warehouse"] --> S5["Rows + provenance"]
end
AUTH --> SERVE
style AUTH fill:#fdf6ec,stroke:#e6c79c
style SERVE fill:#e6f7f2,stroke:#9fd8c9
Second: can a caller inject SQL through a filter?
The whole answer is one sentence, and it is deliberately short enough to be enforced without judgement:
Values can be parameters. Identifiers cannot.
A caller-supplied value is rendered as a typed literal. A caller-supplied identifier — a column name, table name, sort expression or aggregate function — never reaches the SQL at all.
Where a caller genuinely needs to choose a column, it chooses among declared values and the template maps that choice onto an identifier it already contains. A request asking to sort by an arbitrary expression is refused rather than accommodated.
After substitution the statement is parsed again and put through the same checks the platform applies everywhere — reads only, tables inside the indexed set, a row cap — and re-rendered from the syntax tree. Anything that tried to arrive as syntax rather than as a value does not survive that step, because the tree has no node for it.
Every parameter is declared, and undeclared ones are refused

:as_of, :segment, :region — and nothing else is substituted. Each parameter has a type, bounds and a default. A request carrying anything undeclared is a 400, not something quietly ignored.The refusal matters as much as the binding. A caller who sends as_off and
receives a cheerful page of unfiltered rows has been misled — the request succeeded
and the answer is wrong.
Validation happens when the query is saved, not when it is called
| Checked at save time | Why there |
|---|---|
| A placeholder with no matching parameter | A consumer API is the wrong place to discover a malformed query |
| A declared parameter no placeholder uses | Dead parameters become misleading documentation |
| A table outside the indexed set | The endpoint cannot reach data it was never granted |
| A statement that is not read-only | Self-evident |
| It runs, once, with its defaults | Proves it works and resolves the response shape |
By the time anything is invoked, the only failures left should be a bad parameter value, a timeout, or the warehouse being down.
Third: how do you stop the number moving under people?
For an insight, the response shape is incidental — a person reads the narrative. For a data API the columns are the product, because somebody has written code against them.
So the columns are pinned when a version is published, and a later publish is checked against the version it replaces.
| Change | Treated as |
|---|---|
| A column added at the end | Compatible — publishes |
| A column's format changed | Compatible — publishes, noted in the history |
| A column removed | Breaking — refused unless the publish declares it |
| A column's type changed | Breaking — refused unless declared |
| A column renamed | Breaking. It is a removal and an addition; guessing it is a rename is how a consumer silently starts receiving nulls |
This catches the ordinary accident, not the malicious one. Someone edits the SQL to add a
filter, drops a column from the SELECT without noticing, and publishing refuses
— rather than a dashboard in another system going blank the next morning.
Fourth: who is allowed to call it?
A key that authenticates is not thereby entitled. That is the opposite of the usual default, and it is the right way round: the failure mode of "authenticated means allowed" is silent, and the failure mode of this one is a 403 that somebody reports.
Each API key carries a grant naming the queries it may call and the scope it is confined to. Any parameter declared against a scope axis is intersected with that confinement — so a key limited to one region cannot widen itself by passing a different value. It is refused, not silently narrowed, because a narrowed answer to a question nobody asked is indistinguishable from the truth.
And every call is recorded
The caller, the filters as applied after defaults and entitlement, the row count, the duration, whether it was cached, and whether it was refused. Successes and refusals alike. "Why does the figure in the board pack differ from the operational screen" becomes answerable rather than archaeological.
Fifth: can a person actually check the work?
This is the part most AI analytics tools skip, and it is the one that decides whether a risk function will sign. The query is visible, editable and runnable by hand, with exactly the filters a consumer would send.

What a review actually consists of
The useful consequence of all this is that reviewing an AI-written query is a bounded task rather than an open-ended one. There are four things to look at:
- Is the measure right? Read the SQL. It is one statement, it is visible, and it names real columns in your warehouse.
- Are the filters the right ones? They are declared, typed and bounded in a list on the same screen.
- Does it return what consumers expect? The columns are pinned and a breaking change is refused.
- Who can call it? The grant says so, and the log says who did.
None of those four require anyone to reason about what a model might do. That is the point. The model's output has been reduced to an artefact a person can hold still and examine.
The honest limitation
A data API over hand-written SQL is not semantically verified. The platform can prove the
statement is read-only, reaches only permitted tables, binds its filters safely and
returns a stable shape. It cannot prove that SUM(net_revenue) is what your
finance team means by recognised revenue. That remains a review, by a person who knows
the business — which is why the review is made as cheap as possible rather than
pretended away.
Update, October 2026
Grants now name insights, collections and dashboards as well as data APIs, and attach to a directory group as well as to a key; a grant also carries a daily quota and whether masked columns are shown in clear. Column masking (redact, hash, last four, null) is applied on the way out, after the cache. Members request a publish and a curator approves it with the same checks. All of it is on one screen: A catalog with a ledger.
Read next
Embedding insight in the apps people already use — the two calls a host application makes, and why it receives a specification rather than components.