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
                
Two phases with a publish step between them. Everything an auditor cares about happens on the lower path, where nothing is generated.

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

Build
The build tab of a data API showing the SQL template with named placeholders and the parameter editor beneath it with name, type and default
The template refers to :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 timeWhy there
A placeholder with no matching parameterA consumer API is the wrong place to discover a malformed query
A declared parameter no placeholder usesDead parameters become misleading documentation
A table outside the indexed setThe endpoint cannot reach data it was never granted
A statement that is not read-onlySelf-evident
It runs, once, with its defaultsProves 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.

ChangeTreated as
A column added at the endCompatible — publishes
A column's format changedCompatible — publishes, noted in the history
A column removedBreaking — refused unless the publish declares it
A column's type changedBreaking — refused unless declared
A column renamedBreaking. 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.

Try it
The try-it tab showing the declared filters as form fields with their defaults and help text, a row limit, and a choice of which published version to run
The same call a consumer makes, with the version pinned. A reviewer can run the published version and the draft side by side and compare the numbers before anything moves.

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:

  1. Is the measure right? Read the SQL. It is one statement, it is visible, and it names real columns in your warehouse.
  2. Are the filters the right ones? They are declared, typed and bounded in a list on the same screen.
  3. Does it return what consumers expect? The columns are pinned and a breaking change is refused.
  4. 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.

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.