Architecture11 min read
Constrained actions instead of raw SQL
Have the model emit one JSON action over handles issued for this request, constrain decoding to that schema, and compile it yourself into parameterised SQL.
George Pullen
Have the model emit one JSON action over handles issued for this request, constrain decoding to that schema, and compile it yourself into parameterised SQL.
Where you can, do not let the model write SQL. On the Company Twin's governed path, the model asks for typed records or figures from approved data products and never writes a query. On the exploratory path, capable models still write SQL, behind a parser, a read-only transaction and a row limit, because open-ended analysis needs a full query language and a guard is a reasonable boundary for a model that writes SQL well. This article is about an experiment for the case in between: a small private model that must express open-ended queries but cannot be trusted to write SQL. There, the model emits one small JSON action in which every table, column and value is a short handle issued for this request, decoding is constrained to a schema built from those handles, and our own code compiles the action into parameterised SQL for a read-only broker. A malformed or unauthorised query then cannot be expressed, rather than being caught afterwards. The design is locked and tested on reference cases; it has not been trained at scale or deployed, and the work is paused.
A constrained action is a structured model output whose grammar, identifiers and values are fixed by the system for the current request, so that the model can only choose among things it has been allowed to use, and deterministic code decides what runs.
This is the second version of the query language we built for Project Venus, our open-weight model research, where the aim is a small private model that can answer questions over a company's data. The first version, RFQ1, is described in building a relation-first query language. This article covers why we replaced it, what RFQ2 looks like, how it would be enforced, what has and has not been proved, and where the work stands.
The common pattern is to let a model write SQL and then guard it: parse the statement, allow only SELECT, check the referenced tables, add a row limit and run it in a read-only transaction. We do exactly that on the Twin's exploratory path, and it is a reasonable boundary for a capable model. We describe it in letting an LLM write SQL safely.
Two problems remain. The first is that the model still chooses everything a guard has to understand: real table names, literal values, dialect features and any function SQL allows. Every guard is a list of things to reject from a very large language. The second matters more for small models: reliability. Small open models writing free-form SQL produce malformed queries, invent column names and copy long identifiers wrongly. Filtering catches those after the model has spent the tokens, and the model learns nothing from a rejection it never sees coming.
Constrained decoding attacks both at once. If the model can only produce tokens that continue a valid action, and the action can only name things the system issued, there is far less left to filter.
RFQ1 was a relation-first query language written as compact prefix text, with ten fixed clauses and a grammar that could, in principle, be enforced by masking tokens during generation. In use, three things went wrong.
rel_ or col_ followed by 24 hexadecimal characters. Models copied them wrongly.An audit of 30 real queries did not find a single remaining failure of the language itself. It found translator bugs (case-insensitive matching and aggregate filters), lost replay evidence, badly formulated historical questions, gaps in the data catalogue and presentation work. That told us to keep the typed relational core and change the contract around it: make it shorter, explicit and directly enforceable.
| RFQ1 | RFQ2 | |
|---|---|---|
| What the model writes | Handwritten prefix text | One canonical JSON action |
| What training and runtime use | A different internal form | The same RFQ2 object |
| Handles | rel_ or col_ plus 24 hex characters | Session-local r1, c1, v1 |
| Values | Bounded literal strings in the text | Typed, issued value handles |
| Constrained decoding | Not available on the teacher path | A JSON Schema built for each turn |
| Semantics | Some conventions hidden in the generator | Explicit aliases, grouping, ordering and page bound |
| Execution | Compiler and read-only broker | Compiler and read-only broker |
Every completion is exactly one of four actions:
A query object always has the same 13 fields, in a fixed order, with empty clauses written out rather than omitted: common table expressions, the source, joins, filter, grouping, having, the selected expressions, distinct, set operations, ordering and a page. Expressions are short tagged arrays, which keeps the output small without creating a second text language to parse. Here is a complete query, with the contract version omitted:
{ "action": "warehouse_query", "arguments": { "query": { "with": [], "from": { "scan": "r1", "as": "o" }, "joins": [], "where": ["op", "eq", ["col", "o", "c3"], ["param", "v1"]], "group_by": { "mode": "plain", "keys": [["col", "o", "c5"]] }, "having": null, "select": [ { "as": "currency", "expr": ["col", "o", "c5"] }, { "as": "order_count", "expr": ["agg", "count", [], { "distinct": false, "filter": null, "order_by": [] }] }, { "as": "total_value", "expr": ["agg", "sum", [["col", "o", "c4"]], { "distinct": false, "filter": null, "order_by": [] }] } ], "distinct": { "mode": "none" }, "set_ops": [], "order_by": [{ "expr": ["col", "o", "c5"], "direction": "asc", "nulls": null }], "page": { "limit": 200, "offset": 0 } } }}Earlier receipts in the same turn told the model that r1 is the relevant table of orders, c3 is its status, c4 an amount, c5 the currency, and v1 the value "completed" taken from the request. The model never sees the real, mixed-case table and column names, and it never types the value itself. It also groups by currency, because the contract asks monetary totals to stay partitioned by currency rather than summed across them.
In the design, the system builds a JSON Schema for each turn from the handles issued so far. Relation, column and value handles are enumerations of exactly those strings. Every object is closed, so no extra field can appear. Expressions are fixed-length tuples whose first element is one of a finite set of tags. The page limit must be an integer from 1 to 200.
A grammar engine compiles that schema against the model's tokeniser. At each decoding step it computes which tokens could still lead to a valid action and sets every other token's score to negative infinity before sampling. The design uses XGrammar, which the serving engines we run support for structured outputs.
The point of the figure is not that the model was about to misbehave. It is that a plausible but wrong continuation, such as a real table name it has seen in training, is not available to choose. Neither is a handle that was never issued this turn. For the first seed example, the schema was 16,169 bytes and compiled to a native grammar with the Qwen tokeniser's 248,320-token vocabulary. That check confirmed the grammar builds; it loaded no model weights and makes no claim about generation quality.
A JSON Schema constrains shape. It cannot say that a column handle belongs to the relation an alias refers to, or that an aggregate is not nested inside another. Those rules live in an owned compiler that runs before anything reaches the database.
DISTINCT ON must agree with the ordering.$1::text. No literal is ever spliced into the SQL text.In the design, the compiled statement then goes to the same read-only warehouse broker as every other query in the Twin, which applies the organisation's scope, a row limit, a statement timeout and a read-only transaction. The result returns to the model as a receipt with a fixed size cap, in the same turn, ready for the next action.
A small language is only safe if its omissions are deliberate. RFQ2 excludes raw SQL, any data-changing statement, writable or recursive common table expressions, locking clauses, volatile functions and JSON-path access. Some exclusions are less obvious:
WITH TIES. It would let the requested page size stop being a hard bound on rows returned.WITH across a set operation. The compiler refuses this combination until it can hoist the definitions correctly. It fails closed rather than guessing.The contract was locked on 13 August 2026, with hashes of the compiler, tests and specification recorded so that any incompatible change needs a new version, a fresh replay against PostgreSQL and a new lock.
| Check | Result |
|---|---|
| Exact replay against PostgreSQL | 8 RFQ2 queries return exactly the same rows as hand-written reference SQL, covering mixed-case names, typed parameters, case-insensitive matching, aggregate filters, joins, currency partitioning, grouping sets, DISTINCT ON, windows, set operations and an unbounded intermediate result |
| Invalid queries rejected | 19 cases, including unissued handles, raw SQL fields, missing or oversized pages, incompatible types and wrong arity |
| Schema compiles for constrained decoding | Yes, under a pinned grammar engine and tokeniser; no model loaded |
| Independent reviews | 2 |
| Seed training examples | 5 records, reviewed as a format; scale generation not authorised |
Three things are not yet established. We have not measured how much of a real, weighted workload RFQ2 can express; the honest next measurement is a replay set that reports source, discovery and presentation failures separately from language failures. We have not trained a model on it at scale. And the runtime admission step for return-to-base is not deployed.
RFQ2 is paused. Two weeks after the lock, we made serving our own open-weight models the main aim of Project Venus, so that a pilot could run an unmodified base model without waiting for RFQ2 training data. The adapter training that followed in September used a compact action format over the Twin's existing guarded warehouse tools, in which the model writes SQL, rather than RFQ2. In late October we moved the small model to a minimal reuse of an existing agent loop, as a candidate rather than a proven choice. RFQ2 training data has not gone beyond the five seed examples.
What we keep from it: the audit showed that the failures were in translation and data, not in the language; short handles issued per request and a schema built per turn are cheap to enforce; and a later architecture review still preferred governed programs with typed slots, in the style of RFQ2, over free SQL for runtime retrieval. If we train a small model for open-ended queries again, this contract is where we would start.
For figures, the Twin now answers through a deterministic graph engine: the agent plans structured queries, and an engine that calls no model computes them and attaches a certificate tracing each number to its records. We describe it in certified answers from a Rust graph engine. On the governed path, the model asks for typed records or aggregates from an approved product and never supplies SQL at all.
RFQ2 is the same principle applied where a model does need to express an arbitrary relational query: the model proposes a structured program from a closed vocabulary, and deterministic code holds the names, the values and the authority to run it. It is the contract we would start from to train a small private model for exploratory analysis, so that the model's output and the system's guard are the same object.
If you are putting an agent in front of a database, we would first ask whether the questions can be answered from approved, typed products, because that removes model-written queries from the main path. Where open-ended analysis is needed, we would design the action space before choosing the model, and let a schema and a compiler carry the rules that a prompt cannot enforce. Our comparison of RAG and agentic retrieval covers when an agent loop is worth it at all, and our guide to effective prompting covers structured outputs on hosted models.
06 stages
List the actions the model may take, including how it stops and how it says it cannot answer
Watchpoint
Without an explicit stop action, models loop or invent an answer
Replace real names and literal values with short handles issued per request
Watchpoint
Long opaque IDs get truncated; real names invite guessing
Build the output schema per turn from the handles issued so far, and close every object
Watchpoint
A static schema allows handles the person was never given
Compile actions yourself into parameterised SQL and run them through a read-only broker
Watchpoint
Splicing values into SQL text reopens injection
Write down every exclusion and why, and fail closed on unsupported combinations
Watchpoint
Silent workarounds change results without anyone noticing
Prove the language with exact replays against hand-written reference queries
Watchpoint
A query that runs is not the same as a query that returns the right rows
In the Company Twin the model proposes and deterministic code decides: governed queries, certified figures and read-only access by construction. See how the Company Twin answers from your data →