The query gate¶
The agent answers questions about the one connected database and never writes SQL. It emits
Cube JSON queries (modelstore/schema.py → CubeQuery, extra="forbid"), and every query passes
a four-tier gate in modelstore/gate.py.
| Tier | What | Where | Cost |
|---|---|---|---|
| 1 | Pydantic schema | gate.parse |
free |
| 2 | Member existence, kind (measure/dimension/segment), operator vs type, WHERE-vs-HAVING mixing, granularity, cross-entity joinability | gate.semantic_check |
free |
| 3 | POST /refs/{ref}/sql — Cube compiles, executes nothing |
gate.check |
one HTTP call |
| 4 | POST /refs/{ref}/query — execute with row and time caps |
gate.execute / gate.pages |
warehouse |
Failures are text the agent can act on, never exceptions. A tier-2 rejection carries the
nearest member names ('orders.cnt' does not exist. Closest matches: orders.count).
Entry points¶
| Function | Tiers | Used by |
|---|---|---|
gate.run |
1–4 | a chat turn, MCP run_query — one query, executed or explained |
gate.check |
1–3 | dashboards (know a query is sound without running it), Flow check_query / validate_flow |
gate.execute |
4 | after check. Takes the GateResult, so SQL and warnings survive rather than being recompiled |
gate.pages |
4, paged | Flow extract. Reads size rows at a time by offset, up to most |
check always sets a limit: an absent limit becomes max_rows. A ceiling is not optional.
Why offset paging is safe¶
pages appends every grouped time dimension and every dimension to the query's order, ascending,
after whatever order was asked for. There is no ungrouped, so no two rows share every dimension
value, and that order is total. Without it, ties on a measure could move a row between pages —
one page repeats it and the next skips it.
Why not SQL¶
Tier 2 can check operators and granularities precisely because it inspects a CubeQuery, and
none of that survives parsing SQL. The expressiveness gap — no ratio or period delta in a
CubeQuery — is closed downstream, by post-processing rows in the sandbox, not by widening
the gate.
How Cube is reached¶
Only through the model store, at /refs/{ref}/{query,sql,meta}. The store mints the JWT for each
ref and revision, so nothing on this side handles a Cube token. deployment.store_url is the only
address; ModelStore.for_deployment builds the session; store.at(ref) returns a Bound — a
store and one ref — which is what everything that queries takes. Chats, dashboards and Flow runs
are pinned to main.
The established exception pair is (StoreError, httpx.RequestError): httpx transport errors do
not subclass OSError.
Results¶
tool_end carries rows[:MAX_POINTS] and a result_id; the full rows are their own results
row. Column headers and charts are pure functions of (query, catalog, rows) — shape.columns_of
and charts.chart_for. A grouped time dimension comes back under two keys
(sales.invoiced_at and sales.invoiced_at.month), which is why headers are computed, never
Object.keys(rows[0]).