Let the agent write SQL inside guards

About 32 minutes to read, with 11 examples in 12 sections.

Edition 2026.11.3 Examples passed Nov 6, 2026 78 of 78 examples pass What changed

Written with AI. Verified by people. How we make books

Anyone can check this book or correct it. Report an error · How to fix one

Labs

Learn this chapter by doing it

Run every example exactly as the book did, change the code to see what happens, and try your own ideas on the book's code and data. Nothing to install, and your work is saved. Opens in about 30 seconds.

Run every example exactly as the book did, change the code to see what happens, and try your own ideas on the book's code and data. Nothing to install, and your work is saved. Included with membership: 20 hours a month with Member, or 60 with Member Plus.

Open in Lab, in a new tab

Checking your hours…

Checking for your Lab…

Every chapter is read aloud for members. Chapter 1 of every book is free for everyone. Become a member, or sign in.

Read by an AI voice, word for word from this edition's text. Code, output and tables are on the page.

Pocket TTS by Kyutai, voice alba, from Alba MacKenna's recordings, CC BY 4.0.Pocket TTS by Kyutai, voice eve, from VCTK corpus speaker p361, CSTR, University of Edinburgh, CC BY 4.0, adapted by Kyutai.

By the end of this chapter, you can handle the questions no metric covers by letting the agent write SQL that runs read-only on a database holding only the rows and columns its role may read, and that is parsed, checked against an allowlist of tables, bounded in rows, size, cost and time, repaired once from an error, and transpiled to your warehouse's dialect. You can also tell from the golden questions when such a question should become a metric instead.

Chapter 6 routed the shop's questions to governed metrics first. The shop is the online outdoor-gear shop in three regions that this book follows throughout, and its semantic layer holds ten metrics. That covers the questions the finance team asks every month, but not the rest: how much revenue came from orders that included a tent, or what share of sessions converted. Chapter 6's agent declines those, which is honest and frustrating. We call these questions the long tail, and ten of the shop's golden questions are in it, listed in the section on promoting them.

For the long tail, the agent has to write SQL. Chapter 4 already had it do so, and ran the SQL as written on a connection that can't write. This chapter puts guards around it: guarded SQL runs on a database holding only what the agent may read, checked first and inside limits. DuckDB's own documentation says to "Treat SQL in DuckDB like code in Bash or Python. Do not execute SQL from untrusted sources without proper sandboxing" (DuckDB). A model's SQL is SQL from an untrusted source in that sense, whoever typed the question.

So the security boundary is the database: the agent connects read-only, to a database that holds only views limiting the rows and columns its role may see. The rest of the chapter, the parser's checks in lib/sql_guard.py, the timer and the cost check, is a teaching layer and defence in depth. It catches mistakes, gives the model refusals to repair from, and bounds cost and time, but it doesn't keep data from a role. Drafts of the parser were bypassed, as the replay section shows, and any parser can be.

Give the agent a database with only what it may read

Least privilege means that the agent's SQL runs on a connection that can reach what the agent may query and nothing else. It comes before any check on the SQL, because a check is a judgement about text, and when the judgement is wrong, the database decides what the query reaches.

Chapter 1's warehouse.connect() is read-only, but it sees everything: the raw schema, the legacy mart and the semantic views. So agent_warehouse() in lib/warehouse.py copies the seven marts the agent may read, named one by one in AGENT_TABLES, into a second database file, and opens it read-only. File access and extensions are off, memory is limited to 1 GB and each query to two threads, and the settings are locked so that a query can't switch any of it back on. The example sends the agent's database six attempts to reach past it, with no guard in front, and reads what DuckDB's catalog still shows.

agent-warehouse.py Python 3.12.15 · duckdb 2.0.0 Passing
"""The agent's own database: copies of the allowed marts, read-only, with files, extensions and settings locked."""import duckdbfrom lib.warehouse import agent_warehouse, connectshop = connect()agent = agent_warehouse(shop)                 # a second file: the seven allowed marts, copied, and nothing else(shop_tables,), = shop.execute("SELECT count(*) FROM duckdb_tables()").fetchall()print("tables in the shop's warehouse:", shop_tables)tables = agent.execute("SELECT schema_name || '.' || table_name FROM duckdb_tables() ORDER BY 1").fetchall()print("tables in the agent's database:", len(tables))print("the agent's tables:", ", ".join(name for (name,) in tables))(source,), = shop.execute("SELECT path FROM duckdb_databases() WHERE database_name = 'shop'").fetchall()ATTEMPTS = {                                  # sent straight to the agent's database, with no guard in front    "raw table": "SELECT count(*) FROM raw.customers",    "legacy mart": "SELECT sum(revenue) FROM analytics.orders_legacy",    "attach the shop's file": f"ATTACH '{source}' AS shop (READ_ONLY)",    "read a file": "SELECT * FROM read_csv('data/golden/questions.toml')",    "load an extension": "LOAD httpfs",    "turn file access back on": "SET enable_external_access = true",}for label, sql in ATTEMPTS.items():    try:        agent.execute(sql).fetchall()        print(f"{label}: allowed")    except duckdb.Error as e:        kind, message = str(e).splitlines()[0].split(": ", 1)        print(f"{label}: {kind}, {message.split(' - ')[-1]}")   # the file's path is left out of the output# What no setting hides: DuckDB's own catalog, which every database has.(listed,), = agent.execute("SELECT count(*) FROM information_schema.tables WHERE table_schema = 'analytics'").fetchall()print("catalog, tables it lists:", listed)(settings,), = agent.execute("SELECT count(*) FROM duckdb_settings()").fetchall()print("catalog, settings it lists:", settings)(paths,), = agent.execute("SELECT count(*) FROM duckdb_databases() WHERE path IS NOT NULL").fetchall()print("catalog, database files it names:", paths)
Outputpython agent-warehouse.py
tables in the shop's warehouse: 16
tables in the agent's database: 7
the agent's tables: analytics.dim_customers, analytics.dim_date, analytics.dim_products, analytics.fct_order_lines, analytics.fct_orders, analytics.fct_refunds, analytics.fct_sessions
raw table: Catalog Error, Table with name "raw.customers" does not exist because schema "raw" does not exist.
legacy mart: Catalog Error, Table with name orders_legacy does not exist!
attach the shop's file: Permission Error, file system operations are disabled by configuration
read a file: Permission Error, file system operations are disabled by configuration
load an extension: Permission Error, Loading external extensions is disabled through configuration
turn file access back on: Invalid Input Error, the configuration has been locked
catalog, tables it lists: 7
catalog, settings it lists: 170
catalog, database files it names: 1

The shop's warehouse has 16 tables, and the agent's database has 7. A query on raw.customers gets Catalog Error, Table with name "raw.customers" does not exist because schema "raw" does not exist. Nothing refused it; the schema isn't there. The legacy mart isn't there either. Attaching the shop's file or reading any other file fails with Permission Error, file system operations are disabled by configuration, loading an extension fails too, and a SET that would turn file access back on fails with Invalid Input Error, the configuration has been locked. The DuckDB documentation calls such settings "defense-in-depth" and adds that "they are not a substitute for proper sandboxing" (DuckDB). Here the sandbox is the database that holds only the allowed marts, and the settings keep it closed.

The last three lines show what no setting hides: DuckDB's catalog, such as information_schema and duckdb_settings(), which describes the database itself. In the agent's database it lists 7 tables, all of them ones the agent may read. It also lists 170 settings and names 1 database file with its path on disk. The database can't refuse those reads, so the allowlist section refuses them in the parser.

Working rule: Run the agent's SQL on a connection that can reach only what the agent may query: a role with only those grants, or, in DuckDB, a read-only database file that holds only the allowed tables, with file access and extension loading off and its settings locked. Name the allowed tables one by one. Then list what the database can't hide, such as its catalog, and refuse those reads in the guard.

Give each role a database of views

The agent's database leaves out whole tables, but the shop's people don't all see the same rows and columns of the rest. Five roles ask the agent questions, written down in examples/data/shop/roles.toml, which chapter 9 explains. EMEA's regional lead sees EMEA's rows only, finance sees customers with names and emails masked, and support sees orders without revenue. A role's view of a table returns those rows, with a masked column holding a constant and a denied column left out. The view, not the parser, should decide what the role reads.

Real warehouses do this with grants. PostgreSQL's documentation shows a view that hides a column, granted to a role that can't read the table under it: "because of the GRANT, the assistant can run a SELECT on the phone_number view" (PostgreSQL). It warns that such views "cannot be used to reliably conceal the data in unseen rows unless the security_barrier flag has been set". Snowflake's secure views are meant "to limit access to sensitive data that should not be exposed to all users of the underlying table(s)" (Snowflake). Row access policies (Snowflake) and masking policies attach the same rules to the table itself, and a masking policy "is applied to the column at every location where the column appears" (Snowflake). So in production, the agent's warehouse user has a read-only role with SELECT on the role's secure views and nothing else.

DuckDB has no roles or GRANT, and a view can be read only by a connection that can read the tables under it. So agent_warehouse(con, policy=...) builds the role its own database: each table in it is the role's view of a mart, written out as a table of the same name, and opened read-only with the same locked settings. The views come from chapter 9's Policy.subquery(), so each rule is written once. The example sends three queries that this chapter's guard accepts, since it knows tables and columns but not roles, straight to each role's database, then replays a catalog bypass a draft of the guard let through.

role-database.py Python 3.12.15 · duckdb 2.0.0 · pyyaml 6.0.3 · sqlglot 30.29.0 Passing
"""Each role's own database: SQL that chapter 7's guard accepts, sent with no guard, reaches only the role's views."""import duckdbfrom lib.guard_attacks import ATTEMPTSfrom lib.policies import policy_forfrom lib.sql_guard import SqlGuardfrom lib.warehouse import agent_warehouse, connectshop = connect()marts = agent_warehouse(shop)                 # every allowed mart, whole: no role's limitsguard = SqlGuard(marts)                       # chapter 7's guard knows tables and columns, not rolesroles = {"EMEA's regional lead": policy_for("regional_lead", "EMEA"), "finance": policy_for("finance"),         "support": policy_for("support")}QUERIES = {"EMEA's regional lead": "SELECT count(DISTINCT region) AS regions FROM analytics.fct_orders",           "finance": "SELECT count(*) AS n FROM analytics.dim_customers WHERE email LIKE 'customer-0004%'",           "support": "SELECT sum(net_revenue_eur) AS revenue FROM analytics.fct_orders"}def first_value(con, sql):    try:        return con.execute(sql).fetchall()[0][0]    except duckdb.Error as e:        return str(e).splitlines()[0]for role, sql in QUERIES.items():    database = agent_warehouse(shop, policy=roles[role])     # the role's database: its views of the marts only    guard.check(sql)                                         # raises if the guard refuses; it doesn't    print(f"{role}, guard accepts: yes")    print(f"{role}, every mart: {first_value(marts, sql)}")    print(f"{role}, role's database: {first_value(database, sql)}")# A parser bypass a draft of the guard let through: a CTE named like DuckDB's catalog, which lists every table's SQL.support = agent_warehouse(shop, policy=roles["support"])listed = " ".join(str(row) for row in support.execute(ATTEMPTS["CTE named duckdb_tables"]).fetchall())print(f"support, catalog bypass, tables listed: {listed.count('CREATE TABLE')}")print(f"support, catalog bypass, names a revenue column: {'net_revenue_eur' in listed}")print(f"support, a write: {first_value(support, 'DELETE FROM analytics.fct_orders')}")
Outputpython role-database.py
EMEA's regional lead, guard accepts: yes
EMEA's regional lead, every mart: 3
EMEA's regional lead, role's database: 1
finance, guard accepts: yes
finance, every mart: 10
finance, role's database: 0
support, guard accepts: yes
support, every mart: 4019333.37
support, role's database: Binder Error: Referenced column "net_revenue_eur" not found in FROM clause!
support, catalog bypass, tables listed: 6
support, catalog bypass, names a revenue column: False
support, a write: Invalid Input Error: Cannot execute statement of type "DELETE" on database "agent" which is attached in read-only mode!

Updated from sqlglot 30.27.0 to 30.29.0 in Edition 2026.11.3.

Each query passed the guard, and each got only what the role's views hold. Counting regions in the orders table gives 3 on every mart and 1 on the lead's database. Finance's search for emails that start customer-0004 matches 10 customers on every mart and 0 on finance's, because every email there is (masked). Support's sum of net revenue fails with Binder Error: Referenced column "net_revenue_eur" not found in FROM clause!, because the column isn't there. The bypass, a CTE named like DuckDB's catalog, lists 6 tables in support's database, and whether any names a revenue column is False. A write fails as read-only.

Working rule: Make the database the boundary: connect the agent read-only, as a role that can read only views limiting the rows and columns that role may see, through secure views, row access policies and masking policies where the warehouse has them. In DuckDB, give each role its own read-only database of those views. Generate the views from the same policy the rest of your stack uses, and test them with queries the parser accepts.

Parse the SQL before it runs

Last changed in Edition 2026.11.3

A guard can only check what it can see, and SQL as text shows almost nothing: a check for the word DELETE misses a comment, a quoted name and a dozen ways to write the same statement. Parsing turns the text into a tree, with one node for each statement, table, column and function, so every check that follows asks a question about nodes instead of characters. sqlglot, at 30.29.0, parses SQL in DuckDB's dialect and many others, and the same tree later becomes SQL again. Its README warns about what parsing doesn't do: "SQLGlot is a transpiler, not a validator. A query that parses successfully may still fail at execution time" (sqlglot). So the guard parses first, then checks the tree against the warehouse it will run on.

The SQL to check is SQL the model already wrote. For gq-017, net revenue in each of the shop's countries in 2025, chapter 4's recorded reply with all four layers of context grouped by a country column of the calendar table. The example parses that reply, lists what the tree holds, and runs the guard's check() on it. Then it shows that one string can hold two statements, and where a broken query stops parsing.

parse-agent-sql.py Python 3.12.15 · duckdb 2.0.0 · pyyaml 6.0.3 · sqlglot 30.29.0 Passing
"""Parse the SQL a model wrote before it runs: one statement, the tables it reads, and a column that doesn't exist."""import duckdbimport sqlglotfrom sqlglot import expfrom sqlglot.errors import ParseErrorfrom lib.context import LAYERS, build_contextfrom lib.golden import load_goldenfrom lib.model import RecordedModelfrom lib.prompts import layered_sql_messagesfrom lib.sql_guard import GuardError, allowed_schema, checkfrom lib.tools import extract_sqlfrom lib.warehouse import agent_warehouse, connectcon = connect()question = next(q for q in load_golden() if q.question_id == "gq-017")context = build_context(con, question.question, LAYERS)          # chapter 4's context, every layersql = extract_sql(RecordedModel("context-layers").complete(layered_sql_messages(context.text, question.question)))print(f"question: {question.question}\n{sql}")tree = sqlglot.parse_one(sql, read="duckdb")print("root:", type(tree).__name__)print("tables read:", ", ".join(f"{t.db}.{t.name} AS {t.alias}" for t in tree.find_all(exp.Table)))print("columns named:", ", ".join(sorted({f"{c.table}.{c.name}" for c in tree.find_all(exp.Column) if c.table})))try:    check(sql, allowed_schema(agent_warehouse(con)))       # the allowlist: the agent's database's tablesexcept GuardError as e:    print("guard refuses:", e.kind)    message, columns = str(e).split(". The tables it reads have only these columns: ")    print("guard message:", message)    print("message then lists the columns of:", ", ".join(part.split(" has ")[0] for part in columns.split("; ")))try:    con.execute(sql)except duckdb.Error as e:    print("DuckDB, had it run:", str(e).splitlines()[0])statements = sqlglot.parse("SELECT 1; DROP TABLE analytics.fct_orders", read="duckdb")print("statements in one string:", ", ".join(type(s).__name__ for s in statements))try:    sqlglot.parse_one("SELECT region, sum(net_revenue_eur FROM analytics.fct_orders GROUP BY region", read="duckdb")except ParseError as e:    error = e.errors[0]    print("parse error fields:", ", ".join(sorted(error)))    print(f"parse error: {error['description']}\nparse error line: {error['line']}\nparse error column: {error['col']}")
Outputpython parse-agent-sql.py
question: What was net revenue in each of our countries in 2025?
SELECT d.country, round(sum(o.net_revenue_eur), 2) AS net_revenue
FROM analytics.fct_orders AS o
JOIN analytics.dim_date AS d ON d.date_day = o.order_date_local
WHERE o.status <> 'cancelled'
  AND d.calendar_year = 2025
GROUP BY d.country
ORDER BY net_revenue DESC
root: Select
tables read: analytics.fct_orders AS o, analytics.dim_date AS d
columns named: d.calendar_year, d.country, d.date_day, o.net_revenue_eur, o.order_date_local, o.status
guard refuses: column
guard message: Unknown column: country
message then lists the columns of: analytics.fct_orders, analytics.dim_date
DuckDB, had it run: Binder Error: Table "d" does not have a column named "country"
statements in one string: Select, Drop
parse error fields: col, description, end_context, highlight, into_expression, line, start_context
parse error: Expecting )
parse error line: 1
parse error column: 39

Updated from sqlglot 30.27.0 to 30.29.0 in Edition 2026.11.3.

The tree's root is a Select, and it reads analytics.fct_orders AS o, analytics.dim_date AS d. A parser alone would let d.country through. check() qualifies every column against the columns of the tables the query is allowed to read, with sqlglot's qualify, which resolves each column to its table and raises an error for a column it can't place. Here it refuses with column: Unknown column: country. The message then lists the columns of analytics.fct_orders, analytics.dim_date, which is what the model needs to fix the query. Run as written, the query would have failed in DuckDB with Binder Error: Table "d" does not have a column named "country".

The last lines of the output are the parser's other reports. SELECT 1; DROP TABLE analytics.fct_orders is two statements, Select, Drop, so the guard accepts exactly one. A query missing a closing parenthesis raises sqlglot's ParseError, whose errors list gives each problem's col, description, end_context, highlight, into_expression, line, start_context. For the example's broken query, the description is Expecting ), at line 1, column 39. The guard passes the description, line and column to the model, as the repair section uses.

Working rule: Parse the agent's SQL into a tree before anything runs, in the dialect of the warehouse that will run it, and accept exactly one statement. Check nodes, never text. Treat a query that parses as a query whose shape you can now inspect, not as one that is valid, and resolve its columns against the tables it may read before you send it to the warehouse.

Allow only read-only queries on known tables

With the agent's SQL parsed into a tree, the guard can ask two questions of it: does it only read, and does it read only what it may? Checking the statement at the root isn't enough: in DuckDB a common table expression, a CTE, can hold a DELETE with RETURNING inside a SELECT. So the guard accepts only a query at the root, and then walks every node of the tree and refuses any statement that writes, changes settings or reaches outside the warehouse.

The example sends one hostile query, whose CTE deletes every refund over 50 euros and whose outer SELECT counts them, through the guard, straight to the agent's read-only database, and to a writable copy of the shop in a temporary folder.

block-a-delete.py Python 3.12.15 · duckdb 2.0.0 · pyyaml 6.0.3 · sqlglot 30.29.0 Passing
"""A DELETE hidden in a CTE: the root says SELECT, the tree walk finds the DELETE, and the read-only warehouse refuses it.The attack runs for real only on a throwaway copy of the shop, built from the same seed in a temporary folder."""import tempfilefrom pathlib import Pathimport duckdbimport sqlglotfrom sqlglot import expfrom lib.sql_guard import GuardError, SqlGuardfrom lib.warehouse import agent_warehouse, build, connectATTACK = """WITH gone AS (DELETE FROM analytics.fct_refunds WHERE amount_eur > 50 RETURNING refund_id)SELECT count(*) AS refunds_over_50 FROM gone"""tree = sqlglot.parse_one(ATTACK, read="duckdb")print("root:", type(tree).__name__)print("statements in the tree:", ", ".join(type(n).__name__ for n in tree.walk() if isinstance(n, (exp.Query, exp.DML))))agent = agent_warehouse(connect())                      # the agent's read-only databasetry:    SqlGuard(agent).run(ATTACK)except GuardError as e:    print(f"guard refuses ({e.kind}): {e}")try:    agent.execute(ATTACK)                                 # past the guard, straight to the read-only databaseexcept duckdb.Error as e:    print("read-only database:", str(e).splitlines()[0])with tempfile.TemporaryDirectory() as folder:             # a writable copy, deleted when the block ends    path = Path(folder) / "shop.duckdb"    build(path)    with duckdb.connect(str(path)) as writable:        (before,), = writable.execute("SELECT count(*) FROM analytics.fct_refunds").fetchall()        (answer,), = writable.execute(ATTACK).fetchall()        (after,), = writable.execute("SELECT count(*) FROM analytics.fct_refunds").fetchall()print("writable copy, refunds before:", before)print("writable copy, the query's answer:", answer)print("writable copy, refunds after:", after)
Outputpython block-a-delete.py
root: Select
statements in the tree: Select, Delete
guard refuses (write): the query contains a Delete statement; only reads may run
read-only database: Invalid Input Error: Cannot execute statement of type "SELECT" on database "agent" which is attached in read-only mode!
writable copy, refunds before: 1068
writable copy, the query's answer: 931
writable copy, refunds after: 137

Updated from sqlglot 30.27.0 to 30.29.0 in Edition 2026.11.3.

The root is a Select, so a root check would pass the query. The walk finds Select, Delete, and the guard refuses it. The read-only database refuses it too, and its message names the statement it saw as a SELECT. On the writable copy, the query returned 931, a plausible count of refunds, and analytics.fct_refunds went from 1068 rows to 137. Nothing in the answer says rows were deleted. The read-only database is what makes that impossible; the parse check turns it into a refusal the model can read.

The second question is which tables. The agent's database already leaves out raw and analytics.orders_legacy, the old definition of revenue that chapter 2 warned about. The guard checks the same list in the parser all the same, because its refusal names the tables a repair needs, and because it must refuse DuckDB's catalog. The allowlist is every table in the agent's database, read by allowed_schema() with each table's columns, and the same mapping is the schema qualify resolves columns against. The allowlist is its own check, because qualify doesn't check that a table exists.

A name without a schema can name a CTE, which is visible only in the query whose WITH defines it, in that query's subqueries, and to the CTEs after it in the same WITH. The guard's refers_to_cte() follows those rules, and refuses any other name without a schema. This example tries six ways around the allowlist, the last of them a CTE named like DuckDB's catalog, and then runs the guard over every golden question's golden SQL.

block-raw-tables.py Python 3.12.15 · duckdb 2.0.0 · pyyaml 6.0.3 · sqlglot 30.29.0 Passing
"""The table allowlist: the marts the agent may read, the ways around it the guard refuses, and what it costs."""from lib.golden import load_goldenfrom lib.sql_guard import GuardError, SqlGuardfrom lib.warehouse import agent_warehouse, connectcon = agent_warehouse(connect())          # the agent's database: copies of the allowed marts, nothing elseguard = SqlGuard(con)                     # allowlist: the tables in it, read with their columnsprint("allowed tables:", ", ".join(f"{db}.{t}" for db, tables in guard.schema.items() for t in tables))ATTEMPTS = {    "raw table": "SELECT count(*) AS customers FROM raw.customers",    "legacy mart": "SELECT sum(revenue) AS revenue FROM analytics.orders_legacy",    "no schema": "SELECT count(*) AS orders FROM fct_orders",    "file as a table": "SELECT * FROM 'data/golden/questions.toml'",    "table function": "SELECT * FROM read_csv('data/golden/questions.toml')",    "CTE named like the catalog": """SELECT * FROM (WITH duckdb_tables AS (SELECT 1 AS x) SELECT x FROM duckdb_tables) AS a,duckdb_tables""",}for label, sql in ATTEMPTS.items():    try:        guard.check(sql)        print(f"{label}: allowed")    except GuardError as e:        print(f"{label}: refused ({e.kind}), {str(e).split(';')[0]}")golden = [q for q in load_golden() if q.golden_sql]outside = []for q in golden:    try:        guard.check(q.golden_sql)    except GuardError as e:        outside.append(f"{q.question_id} ({str(e).split(' ')[0]})")print(f"golden queries the allowlist refuses: {len(outside)} of {len(golden)}")print("refused golden queries:", ", ".join(outside))
Outputpython block-raw-tables.py
allowed tables: analytics.dim_customers, analytics.dim_date, analytics.dim_products, analytics.fct_order_lines, analytics.fct_orders, analytics.fct_refunds, analytics.fct_sessions
raw table: refused (table), raw.customers isn't a table you may read
legacy mart: refused (table), analytics.orders_legacy isn't a table you may read
no schema: refused (table), fct_orders isn't a table you may read
file as a table: refused (table), data/golden/questions.toml isn't a table you may read
table function: refused (table), READ_CSV('data/golden/questions.toml') reads data through a function
CTE named like the catalog: refused (table), duckdb_tables is DuckDB's catalog, not a table you may read
golden queries the allowlist refuses: 2 of 34
refused golden queries: gq-034 (raw.marketing_spend), gq-037 (raw.customers)

Updated from sqlglot 30.27.0 to 30.29.0 in Edition 2026.11.3.

All six are refused, and three of them aren't table names at all. A file path in the FROM clause parses as a table named after the path, so the allowlist refuses it. read_csv() parses as a function in the table's place, and the guard refuses anything in FROM other than a table, a subquery or a VALUES list, which covers range(), unnest() and query_table() too. The CTE named duckdb_tables is defined inside a subquery, so the duckdb_tables after it names DuckDB's catalog view, and the guard says so: refused (table), duckdb_tables is DuckDB's catalog, not a table you may read. It refuses every catalog name, such as those starting duckdb_ or pg_, and every function that reads the instance, such as current_setting(). Any other function must be on ALLOWED_FUNCTIONS, the functions the shop's analytics need.

The last two lines show what the allowlist costs. It refuses 2 of 34 golden queries: gq-034 (raw.marketing_spend), gq-037 (raw.customers). Marketing spend has no mart, and duplicate sign-ups exist only in raw.customers, because the mart removed them. The last section returns to those two questions.

Working rule: Accept only a query at the root, walk every node for a statement that writes, and allowlist tables by schema and name, with nothing but tables and subqueries in FROM. Resolve a name without a schema as a CTE only where a CTE of that name is visible, refuse the database's catalog by name, and allow only the functions your analytics need. Check the same list the agent's database holds. When a golden question needs a table off the list, give it a mart or a metric, not a wider list.

Bound the rows and the cost

A query that only reads the allowed tables can still hurt: it can return every row of a fact table into the agent's context, or join three fact tables with no join condition and run for hours. The guard bounds both, and the time. For rows, it adds a LIMIT, and after the run it refuses a result longer than 50,000 characters, since one row can hold a whole column. For cost, it asks DuckDB's EXPLAIN for the plan, which plans the query without running it and gives each step an estimated number of rows (DuckDB). For time, it needs its own timer, because DuckDB has no statement timeout, and its documentation says to "Implement strict query timeouts at the application level" (DuckDB).

The costly mistake is chapter 2's fan-out taken to its end: orders, lines and refunds in one FROM clause with no keys. The example compares that plan with the same sum joined on order_id, checks the limit against the golden queries, and then shows a LIMIT the agent wrote, too many rows, and the join without keys stopped by the timer.

explain-before-running.py Python 3.12.15 · duckdb 2.0.0 · pyyaml 6.0.3 · sqlglot 30.29.0 Passing
"""Bound the rows and the cost: EXPLAIN a join that forgot its keys, keep the agent's LIMIT, and stop a slow query."""import sqlglotfrom lib.golden import load_goldenfrom lib.sql_guard import GuardError, SqlGuard, check, explain, run_with_timeoutfrom lib.warehouse import agent_warehouse, connectguard = SqlGuard(con := agent_warehouse(connect()))   # the agent's database, where the guard runs its checksNO_KEYS = """SELECT sum(o.net_revenue_eur) AS net_revenue FROM analytics.fct_orders AS o,analytics.fct_order_lines AS l, analytics.fct_refunds AS r"""KEYED = """SELECT sum(o.net_revenue_eur) AS net_revenue FROM analytics.fct_orders AS oJOIN analytics.fct_order_lines AS l USING (order_id)"""for label, sql in (("no join keys", NO_KEYS), ("joined on order_id", KEYED)):    cost = explain(con, check(sql, guard.schema).sql)    print(f"{label}, largest step: {cost.largest_step}\n{label}, estimated rows: {cost.largest_estimate:,}")try:    guard.check(NO_KEYS)except GuardError as e:    print(f"guard refuses ({e.kind}): {e}")costs = {q.question_id: explain(con, check(q.golden_sql, guard.schema).sql)         for q in load_golden() if q.golden_sql and q.question_id not in ("gq-034", "gq-037")}print("golden queries estimated:", len(costs))print(f"largest estimate among golden queries: {max(c.largest_estimate for c in costs.values()):,} rows")print("golden plans with a cross product:", ", ".join(i for i, c in costs.items() if c.cross_products))print("golden plans with a step that has no estimate:", sum(1 for c in costs.values() if c.unestimated))print(f"limit on estimated rows: {guard.max_estimated_rows:,}")TOP_FIVE = """SELECT product_id, sum(net_amount_eur) AS net_revenue FROM analytics.fct_order_linesGROUP BY product_id ORDER BY net_revenue DESC LIMIT 5"""print("sqlglot's .limit(1001):", sqlglot.parse_one(TOP_FIVE, read="duckdb").limit(1001).sql("duckdb").split("ORDER BY")[1].strip())print("guard's limit on the top five:", check(TOP_FIVE, guard.schema).limit)try:    guard.run("SELECT order_id, net_revenue_eur FROM analytics.fct_orders")except GuardError as e:    print(f"every order ({e.kind}): {e}")# The join without keys, through the time limit alone, as if the estimate had let it through. It reads its rows in# batches, and DuckDB looks for the timer's interrupt between them.try:    run_with_timeout(con, check(NO_KEYS, guard.schema).sql, seconds=1.0)except GuardError as e:    print(f"no join keys, timer alone ({e.kind}): {e}")print("connection still answers:", con.execute("SELECT count(*) FROM analytics.fct_orders").fetchall()[0][0])
Outputpython explain-before-running.py
no join keys, largest step: CROSS_PRODUCT
no join keys, estimated rows: 604,042,404,768
joined on order_id, largest step: SEQ_SCAN
joined on order_id, estimated rows: 38,071
guard refuses (cost): one step of the plan (CROSS_PRODUCT) is estimated to produce 604,042,404,768 rows, over the limit of 10,000,000; join each table on its key
golden queries estimated: 32
largest estimate among golden queries: 38,071 rows
golden plans with a cross product: gq-031
golden plans with a step that has no estimate: 0
limit on estimated rows: 10,000,000
sqlglot's .limit(1001): net_revenue DESC LIMIT 1001
guard's limit on the top five: 5
every order (rows): the query returns more than 1000 rows; aggregate or filter it
no join keys, timer alone (timeout): the query ran past the 1-second time limit and was stopped
connection still answers: 14856

Updated from sqlglot 30.27.0 to 30.29.0 in Edition 2026.11.3.

The keyed join's largest step is a SEQ_SCAN of 38,071 rows. Without keys, the largest step is a CROSS_PRODUCT of 604,042,404,768 rows, and the guard refuses it with a message that tells the model to join on keys. Any other step without an estimate, such as an UNNEST that makes rows from a list, makes the plan's cost unknown, and the guard refuses the plan rather than guess; 0 golden plans have such a step. A cross product isn't always a mistake, either: DuckDB's plan for the golden query of gq-031, units per order, has one, and that query is correct. The 32 golden queries the allowlist accepts peak at 38,071 rows, far under the limit of 10,000,000, a rule of thumb for the shop's size.

The LIMIT needs care. sqlglot's .limit() replaces the LIMIT a query already has, so a top-five query given .limit(1001) ends in net_revenue DESC LIMIT 1001 and stops being a top five. The guard keeps the smaller limit, 5. Its own limit is one row more than the 1,000 it allows, so it can tell a result that was cut from one that fits. A query for every order is refused: the query returns more than 1000 rows; aggregate or filter it. The first 1,000 rows would look like an answer; a refusal makes the model aggregate.

The time limit is the backstop for estimates that are wrong. The example runs the join without keys under the timer alone. run_with_timeout() calls DuckDB's interrupt(), which DuckDB acts on between the batches of rows a query reads, so this query stops: the query ran past the 1-second time limit and was stopped. The connection then answers the next query, 14856 orders.

Working rule: Bound every query four ways: a row limit that keeps a smaller one the agent wrote, a limit on the result's size, a plan-size limit from EXPLAIN checked before the query runs, and one deadline over every step of the guard, planning included. Refuse a result over either limit rather than cut it, and refuse a plan with a step that has no estimate. Set the plan limit from your own known-good queries, never use EXPLAIN ANALYZE, which runs the query, and rely on the deadline and the result limits, not the estimate.

Enforce the deadline by killing a process

The bounds section stopped a slow query with an interrupt, but DuckDB acts on interrupt() only where it checks for one, and it can compute a constant while planning, before any such check. So the timer can't be relied on; a killed process stops wherever it is.

So the guard plans and runs the agent's query in a worker, a separate process started by Worker in lib/sql_guard.py that opens the agent's database file read-only with the guard's settings, and kills it when the 5-second deadline passes. At most 1,001 rows come back through a pipe, and an oversized result is refused inside the worker.

The example's query compares each order ID with a constant sum over 30 million generated numbers, its list functions let past the guard's list. The example plans it with a small constant, 18, on the shop's connection, which folds constants, and on the agent's database, which doesn't; then a worker plans the full query under a half-second deadline.

stop-a-slow-plan.py Python 3.12.15 · duckdb 2.0.0 · pyyaml 6.0.3 · sqlglot 30.29.0 Passing
"""Stop a slow plan: DuckDB computes a constant while it plans, before any row is read, so a timer's interrupt hasnothing to stop it at; a killed worker stops wherever it is."""import jsonfrom lib.sql_guard import GuardError, SqlGuard, Worker, checkfrom lib.warehouse import agent_warehouse, connectshop = connect()                                      # chapter 1's connection: constant folding onagent = agent_warehouse(shop)                         # the agent's database: constant folding offguard = SqlGuard(agent, timeout=0.5)SLOW_PLAN = """SELECT count(*) AS n FROM analytics.fct_ordersWHERE order_id < list_sum(list_transform(range(30000000), x -> list_sum(range(40))))"""# Checked as if the function list allowed the list functions, as sqlglot names them (range() is GENERATE_SERIES and# list_transform() is TRANSFORM), so the plan reaches DuckDB.LIST_FUNCTIONS = {"list_sum", "transform", "generate_series"}planned = check(SLOW_PLAN, guard.schema, functions=guard.functions | LIST_FUNCTIONS).sql# The same query with a small constant, 3 lists summing to 6 each: 18, under every order ID. A plan with no rows to# read means DuckDB computed the constant while planning.small = planned.replace("30000000", "3").replace("RANGE(0, 40)", "RANGE(0, 4)")for label, connection in (("shop's connection, folding on", shop), ("agent's database, folding off", agent)):    (_, plan), = connection.execute(f"EXPLAIN (FORMAT json) {small}").fetchall()    steps, nodes = [], json.loads(plan)    while nodes:        steps.append(nodes[0]["name"])        nodes = nodes[0]["children"]    print(f"EXPLAIN with a small constant, {label}: {' > '.join(steps)}")worker = Worker(shop)                                 # a process with its own connection to the shop's filetry:    worker.explain(planned, 0.5)    outcome = "planned"except GuardError as e:    outcome = e.kind                                  # timeout, memory or worker_failed: the machine decides whichprint("EXPLAIN in a worker with a half-second deadline, shop's connection, plan returned:", outcome == "planned")print("EXPLAIN in a worker, still running afterwards:", worker.running)try:    guard.run(SLOW_PLAN)except GuardError as e:    print(f"slow plan through the guard ({e.kind}): {e}")
Outputpython stop-a-slow-plan.py
EXPLAIN with a small constant, shop's connection, folding on: UNGROUPED_AGGREGATE > EMPTY_RESULT
EXPLAIN with a small constant, agent's database, folding off: UNGROUPED_AGGREGATE > EMPTY_RESULT
EXPLAIN in a worker with a half-second deadline, shop's connection, plan returned: False
EXPLAIN in a worker, still running afterwards: False
slow plan through the guard (function): list_sum() isn't a function the guard allows; use aggregates, window functions, casts, and date, number and text functions

Updated from sqlglot 30.27.0 to 30.29.0 in Edition 2026.11.3.

The small plans read UNGROUPED_AGGREGATE > EMPTY_RESULT with folding on and UNGROUPED_AGGREGATE > EMPTY_RESULT with it off (DuckDB): DuckDB computed the constant while planning and found no order ID under it. For the full constant that is the slow step, so folding off is only a second line; the guard relies on the kill. Plan returned: False. Worker still running: False.

Working rule: Start the deadline when the agent's SQL arrives, and run EXPLAIN under it as well as the query. Enforce it by killing a separate process with its own read-only connection, not with an interrupt. Check on your own database that a stopped plan and a stopped query really stop.

Replay the attacks on the guard

A first draft of the guard trusted its parse checks and bounds, on chapter 1's connection, which could see every schema, and a critic got past the parser three ways. A CTE named like a catalog view returned the CREATE statement of every table, raw included. A constant folded while planning outran the timer. A cross product with unnest(range(200000)) passed the plan-size limit, because a step with no estimate counted as small. Each was a place where the guard's model of SQL and the database disagreed, and the database's reading is the one that runs. Two security reviews found more ways past the hardened guard. That is why the parser is not the boundary.

The example replays the reviews' attempts and three smaller ones, some straight to the agent's database, and each through the guard with its 5-second limit.

replay-guard-bypasses.py Python 3.12.15 · duckdb 2.0.0 · pyyaml 6.0.3 · sqlglot 30.29.0 Passing
"""The critics' attempts to get past the guard (lib/guard_attacks.py), replayed on the agent's database, a temporarycopy of the marts: what the database alone allows, and what the guard does."""import duckdbfrom lib.guard_attacks import ALONE, ATTEMPTS, alone, attempt, macro_then_sumfrom lib.sql_guard import SqlGuard, explainfrom lib.warehouse import agent_warehouse, connectagent = agent_warehouse(connect())guard = SqlGuard(agent)                       # the 5-second limit: the queries that run here finish far inside itfor label, sql in ATTEMPTS.items():    if label in ALONE:                        # straight to the agent's database, with no guard in front        print(f"{label}, database alone: {alone(agent, label)}")    print(f"{label}, guard: {attempt(guard.run, sql)[0]}")print("upper-case names, guard:", attempt(guard.run, "SELECT count(*) AS n FROM ANALYTICS.FCT_ORDERS")[0])# A temporary macro named sum, on a cursor of the database alone, then sent straight to the guard's worker.alone_sum, real = macro_then_sum(agent, guard.worker)print("temp macro named sum, database alone, the next sum:", alone_sum)print("temp macro named sum, guard's worker, the next sum is the real one:", real)# The regex attempt. In-process, a timer's interrupt waits for the end of a batch of rows, and one batch is this much# text. With repeat() and regexp_matches() (sqlglot's REGEXP_LIKE) allowed, the guard's worker runs it under the# review's 2-second limit, and stops it by the deadline or the memory cap, whichever the machine reaches first.REGEX, BATCH = ATTEMPTS["regex on a repeated email"], duckdb.__standard_vector_size__(text,), = agent.execute(f"SELECT sum(length(email)) * 200000 FROM (SELECT email FROM analytics.dim_customers "                         f"ORDER BY customer_id LIMIT {BATCH})").fetchall()print(f"regex, characters to match in one batch of {BATCH:,} rows: {text:,}")wider = SqlGuard(agent, timeout=2.0, functions=guard.functions | {"repeat", "regexp_like"})print("regex, guard without the function list:", attempt(wider.run, REGEX)[1])print("regex, worker still running afterwards:", wider.worker_running)print("regex, the guard's next query:", attempt(wider.run, "SELECT count(*) AS n FROM analytics.fct_orders")[0])# The inequality join ran. Its estimate varies a little between runs, so only its size is printed.JOIN = ATTEMPTS["join on inequality"]estimate, joined = explain(agent, JOIN).largest_estimate, guard.run(JOIN).rows[0][0]print("join on inequality, estimate under the limit:", estimate < guard.max_estimated_rows)print(f"join on inequality, rows it joined: {joined:,}")print("join on inequality, rows joined more than 100 times the estimate:", joined > 100 * estimate)
Outputpython replay-guard-bypasses.py
CTE named duckdb_tables, database alone: 7 rows, one per table, all in analytics: True
CTE named duckdb_tables, guard: refused (table), duckdb_tables is DuckDB's catalog, not a table you may read
CTE named pg_settings, database alone: 170 rows of settings
CTE named pg_settings, guard: refused (table), pg_settings is DuckDB's catalog, not a table you may read
slow plan, guard: refused (function), list_sum() isn't a function the guard allows
unnest in FROM, guard: refused (table), UNNEST(RANGE(0, 200000)) AS t(x) makes rows from a function, not a table
unnest in a subquery, guard: refused (function), unnest() isn't a function the guard allows
every email in one row, database alone: 1 row of 106,670 characters
every email in one row, guard: refused (size), the result is 106,670 characters long, over the limit of 50,000
a setting read by a function, database alone: 1 row, a folder's path
a setting read by a function, guard: refused (table), current_setting() reads the database's settings or catalog
a table named with its database, database alone: 14,856 orders
a table named with its database, guard: refused (table), agent.analytics.fct_orders names a database
regex on a repeated email, guard: refused (function), regexp_matches() isn't a function the guard allows
database name and version, database alone: agent, v2.0.0
database name and version, guard: refused (table), current_database() reads the database's settings or catalog
600 nested subqueries, guard: refused (parse), the SQL is nested too deeply to check (the limit is 200 levels)
SIMILAR TO on an email, guard: refused (function), SIMILAR TO isn't an operator the guard allows
temp macro named sum, guard: refused (statement), only queries may run, and this is a Create statement
join on inequality, guard: ran
upper-case names, guard: ran
temp macro named sum, database alone, the next sum: 42
temp macro named sum, guard's worker, the next sum is the real one: True
regex, characters to match in one batch of 2,048 rows: 10,649,600,000
regex, guard without the function list: stopped, no answer
regex, worker still running afterwards: False
regex, the guard's next query: ran
join on inequality, estimate under the limit: True
join on inequality, rows it joined: 45,147,384
join on inequality, rows joined more than 100 times the estimate: True

Updated from sqlglot 30.27.0 to 30.29.0 in Edition 2026.11.3.

Each attempt but the last is refused. The CTE named duckdb_tables, sent to the agent's database alone, returns 7 rows, one per table, all in analytics: True: the catalog can't show the agent a table it may not read, because none is there. The slow plan and unnest() are now refused as functions off the list, as in refused (function), list_sum() isn't a function the guard allows. One row holding every customer's name and email, 1 row of 106,670 characters, is refused as too large.

The second review's first attempt matches a pattern against each customer's email repeated 200,000 times. It passed the parser and the plan's limit, and outran an interrupt, which DuckDB checks for only between batches of rows; one batch here holds 10,649,600,000 characters. The function list now refuses it: refused (function), regexp_matches() isn't a function the guard allows. Run with repeat() and regexp_matches() allowed, under the review's 2-second limit, it is stopped, no answer, whether the deadline or the worker's memory cap came first, and the worker is still running: False. The guard's next query ran.

current_database() and version(), which alone return agent, v2.0.0, had passed; the guard now checks every function. Six hundred nested subqueries, which crashed sqlglot's parser, are now refused: refused (parse), the SQL is nested too deeply to check (the limit is 200 levels).

A final review added two. The guard now refuses SIMILAR TO, a regular expression as an operator: refused (function), SIMILAR TO isn't an operator the guard allows. A temporary macro named sum, accepted by the database alone, makes the next sum 42. The parser refuses it, and the worker, starting each query on a fresh cursor, would drop it, so the next sum is real: True.

The last attempt, a join of orders to every customer who didn't place them, ran, as the rules allow. Its estimate was under the limit, True, and it joined 45,147,384 rows, more than 100 times the estimate: True. So the plan's limit catches only the mistakes it was set for, and the kill, the result limits and the memory limit bound the rest, and the production section lists what remains.

Working rule: Rely on the database's limits, and treat the parser as defence in depth. Keep every attack anyone finds as a test: replay it against the agent's database alone and through the guard, and check that each is refused by the layer you expect. When an attack gets past the parser, ask first why the database let it reach anything, and then fix the parser.

Repair once from the error

Repair means giving the model the guard's refusal or the warehouse's error, once, and running what it writes next. The research on repair loops says when it helps. MAC-SQL's Refiner, which corrects SQL from "the error message feedback", added 4.6 points on BIRD's dev set with GPT-4 (Wang et al., Table 3). Repair without an outside signal is weaker: models "struggle to self-correct their responses without external feedback" (Huang et al.), and in DIN-SQL a prompt that told GPT-4 its SQL was buggy lowered its Spider score from 73.3 to 70.0 (Pourreza and Rafiei, Table 5). So the guard repairs only on an outside signal, its own refusal or DuckDB's error, and the prompt asks for a correction only "if the message shows how".

The repair prompt is GUARD_REPAIR in lib/prompts.py: the model's own reply, the message, and the instruction to reply with corrected SQL or a line that starts cannot answer:. answer_with_repair() in lib/sql_guard.py runs it at most once per question. The recording guarded-sql-runs holds only the repair calls, made with the book's pinned model, Qwen3-4B-Instruct-2507 in a 4-bit file, chosen as chapter 1 explains. Each first reply is replayed from chapter 1's schema-only recording or chapter 4's layered one, and the model was called only where the guard refused that reply or DuckDB failed: 6 times for schema-only SQL and 1 for layered SQL on the dev split. The same run repaired the test split's replies, which the long-tail section uses. The example scores both on the dev split's 17 answer questions, and shows two repairs: the layered run for gq-017 from the parse section, and the schema-only run for gq-001, net revenue in fiscal year 2026.

repair-from-error.py Python 3.12.15 · duckdb 2.0.0 · pyyaml 6.0.3 · sqlglot 30.29.0 Passing
"""Repair once from the guard's message: one repaired run, then how often repair acts at all, on the dev split."""from collections import Counterfrom lib.context import LAYERS, build_contextfrom lib.golden import golden_result, grade, load_goldenfrom lib.model import RecordedModelsfrom lib.prompts import layered_sql_messages, schema_only_messagesfrom lib.sql_guard import SqlGuard, answer_with_repairfrom lib.warehouse import agent_warehouse, connect, describe_schemacon = connect()guard, schema = SqlGuard(agent_warehouse(con)), describe_schema(con)# First replies from chapters 1 and 4's recordings; each repair reply from this chapter's.model = RecordedModels("schema-only-sql", "context-layers", "guarded-sql-runs")MESSAGES = {"schema-only": lambda q: schema_only_messages(schema, q.question),            "layered": lambda q: layered_sql_messages(build_context(con, q.question, LAYERS).text, q.question)}dev = [q for q in load_golden() if q.split == "dev" and q.kind == "answer"]shown = {}for context, messages in MESSAGES.items():    counts = Counter()    for q in dev:        run = answer_with_repair(guard, model, messages(q), q.question, role=q.role, repairs=1)        outcome = grade(q, run.answer, golden_result(con, q))        when = "after repair" if run.repaired else "first time"        counts[f"ran {when}"] += run.attempts[-1].outcome == "ran"        counts[f"correct {when}"] += outcome == "correct"        counts[f"wrong {when}"] += outcome == "wrong"        counts["refused or failed first"] += run.repaired        counts["declined after repair"] += run.attempts[-1].outcome == "declined"        shown[context, q.question_id] = run    print(f"{context}, questions: {len(dev)}")    for key in ("ran first time", "correct first time", "wrong first time", "refused or failed first",                "ran after repair", "correct after repair", "wrong after repair", "declined after repair"):        print(f"{context}, {key}: {counts[key]}")for context, question_id in (("layered", "gq-017"), ("schema-only", "gq-001")):    run = shown[context, question_id]    print(f"{question_id} {context}, refused: {run.attempts[0].message.split('. The tables')[0]}")    print(run.attempts[1].sql)    rows = len(run.answer.rows)    print(f"{question_id} {context}, after repair: {run.attempts[1].outcome} and returned {rows} row{'s' * (rows != 1)}")
Outputpython repair-from-error.py
schema-only, questions: 17
schema-only, ran first time: 11
schema-only, correct first time: 2
schema-only, wrong first time: 9
schema-only, refused or failed first: 6
schema-only, ran after repair: 5
schema-only, correct after repair: 0
schema-only, wrong after repair: 5
schema-only, declined after repair: 0
layered, questions: 17
layered, ran first time: 16
layered, correct first time: 14
layered, wrong first time: 2
layered, refused or failed first: 1
layered, ran after repair: 1
layered, correct after repair: 0
layered, wrong after repair: 1
layered, declined after repair: 0
gq-017 layered, refused: Unknown column: country
SELECT o.country, round(sum(o.net_revenue_eur), 2) AS net_revenue
FROM analytics.fct_orders AS o
JOIN analytics.dim_date AS d ON d.date_day = o.order_date_local
WHERE o.status <> 'cancelled'
  AND d.calendar_year = 2025
GROUP BY o.country
ORDER BY net_revenue DESC
gq-017 layered, after repair: ran and returned 10 rows
gq-001 schema-only, refused: Column 'fiscal_year' could not be resolved. Line: 3, Col: 17
SELECT SUM(net_revenue_eur) AS net_revenue
FROM analytics.fct_orders
WHERE EXTRACT(YEAR FROM placed_at) = 2026
gq-001 schema-only, after repair: ran and returned 1 row

Updated from sqlglot 30.27.0 to 30.29.0 in Edition 2026.11.3.

Repair worked as a mechanism. For gq-017, the guard's message named the unknown column and listed the orders table's columns, and the model moved country from the calendar to the orders table. The repaired query ran and returned 10 rows. For schema-only SQL, 5 of the 6 refused or failed queries ran after one repair.

It didn't make answers right. Schema-only SQL got 0 repaired answers correct, and layered SQL 0. The repaired gq-017 returns every country, and the golden question is asked by EMEA's regional lead, who should see EMEA's countries only: a rule that the lead's own database enforces, and that no SQL error reveals. For gq-001, the guard refused fiscal_year, which the model had looked for on the orders table, and the model's fix was to filter on the calendar year of the UTC timestamp instead. The query ran and returned 1 row, for a different question. Meanwhile layered SQL ran first time on 16 questions, with 2 of them wrong, and repair never sees those, because nothing failed. On the shop's dev split, repair turned visible errors into answers that run and are wrong, which is harder to catch than an error.

Working rule: Repair at most once, and only from an outside signal: the guard's refusal or the warehouse's error, passed to the model with its own SQL and a prompt that doesn't assume the SQL is wrong. Let the model decline in the repair. Score repaired answers on their own, apart from first-time answers, and if your golden questions show they are rarely right, decline on a refusal instead of repairing.

Transpile to the warehouse's dialect

The shop's warehouse is DuckDB, and most readers' isn't. The same tree can be written in another dialect, as chapter 5 did for the semantic layer's SQL. What is transpiled is the guard's SQL, with every identifier quoted. sqlglot's transpiler translates what it knows and, by default, warns and makes a "best-effort translation" of what it can't; with unsupported_level=ErrorLevel.RAISE, which transpile() in lib/sql_guard.py uses, it raises instead (sqlglot). Neither tells you that the result means the same thing on the target.

The example checks a long-tail query, net revenue by weekday and month in 2025, and transpiles it to Snowflake, first keeping the guard's quotes and then without them. Then it transpiles a query that uses DuckDB's histogram() aggregate, which is on the guard's function list.

transpile-to-snowflake.py Python 3.12.15 · duckdb 2.0.0 · pyyaml 6.0.3 · sqlglot 30.29.0 Passing
"""Transpile checked SQL to Snowflake: the quotes the guard added, a function it can't translate, a name that differs."""from lib.sql_guard import SqlGuard, transpilefrom lib.warehouse import agent_warehouse, connectcon = connect()guard = SqlGuard(agent_warehouse(con))WEEKDAYS = """SELECT dayname(order_date_local) AS weekday, strftime(order_date_local, '%Y-%m') AS month,       round(sum(net_revenue_eur), 2) AS net_revenueFROM analytics.fct_ordersWHERE status <> 'cancelled' AND year(order_date_local) = 2025GROUP BY ALL ORDER BY month, weekday"""checked = guard.check(WEEKDAYS)print("kept quoted, Snowflake reads:", transpile(checked.sql, "snowflake", quoted=True).sql.split(" FROM ")[1].split(" WHERE")[0])snowflake = transpile(checked.sql, "snowflake", pretty=True)print(snowflake.sql)print("functions passed through unchanged:", ", ".join(snowflake.passed_through) or "none")print("DuckDB's first weekday:", guard.run(WEEKDAYS).rows[0][0])MIX = """SELECT region, histogram(channel) AS orders_by_channel FROM analytics.fct_ordersWHERE year(order_date_local) = 2025 GROUP BY region"""mix = transpile(guard.check(MIX).sql, "snowflake")print("channel mix in Snowflake:", mix.sql.split(" FROM")[0])print("functions passed through in the channel mix:", ", ".join(mix.passed_through) or "none")
Outputpython transpile-to-snowflake.py
kept quoted, Snowflake reads: "analytics"."fct_orders" AS "fct_orders"
SELECT
  DAYNAME(fct_orders.order_date_local) AS weekday,
  TO_CHAR(fct_orders.order_date_local, 'yyyy-mm') AS month,
  ROUND(SUM(fct_orders.net_revenue_eur), 2) AS net_revenue
FROM analytics.fct_orders AS fct_orders
WHERE
  fct_orders.status <> 'cancelled' AND YEAR(fct_orders.order_date_local) = 2025
GROUP BY ALL
ORDER BY
  month,
  weekday
LIMIT 1001
functions passed through unchanged: none
DuckDB's first weekday: Friday
channel mix in Snowflake: SELECT fct_orders.region AS region, HISTOGRAM(fct_orders.channel) AS orders_by_channel
functions passed through in the channel mix: histogram

Updated from sqlglot 30.27.0 to 30.29.0 in Edition 2026.11.3.

Kept quoted, the table becomes "analytics"."fct_orders" AS "fct_orders". In Snowflake that names a different table: "Unquoted identifiers are stored and resolved in uppercase", while "Delimited identifiers (i.e. identifiers enclosed in double quotes) are case-sensitive" (Snowflake). So transpile() drops the quotes unless asked to keep them, and the second version reads analytics.fct_orders. sqlglot rewrote strftime() as TO_CHAR() with Snowflake's format, and knew every function: none passed through.

That clean result still changes the answer. dayname() came out as DAYNAME(), and the two names don't mean the same: DuckDB's gives "The (English) name of the weekday" (DuckDB), here Friday, while Snowflake's "Extracts the three-letter day-of-week name" (Snowflake). The same rows get different labels, so a golden result that says Friday fails on Snowflake. The channel mix is the other failure: histogram is a function sqlglot doesn't know, so it was written out unchanged, SELECT fct_orders.region AS region, HISTOGRAM(fct_orders.channel) AS orders_by_channel, with no error. transpile() lists such functions, here histogram, so you can refuse them or check them by hand.

Working rule: Let the agent write the dialect your golden questions are checked in, guard that SQL, and transpile the guard's output, not the agent's text, with errors raised for what the transpiler knows it can't translate. Drop the guard's quotes for warehouses that fold unquoted names to upper case, refuse functions the transpiler passed through, and run the golden questions on the target warehouse before you trust a transpiled answer, because a function with the same name can return different values.

When to prefer a new metric over SQL

The database and the guards make the agent's SQL safe to run: limited to its role's views, bounded and repaired once. None of them makes it right. A metric is right by construction, because a person defined it once. So guarded SQL is for questions asked once, and a question that keeps coming back, or whose number goes into a report, should become a metric. To tell them apart, score the guarded path on the long tail of golden questions and read every wrong answer.

The shop's long tail is the ten answer questions that no metric or dimension in examples/data/semantic/ covers, such as orders that include a tent, session conversion and discount. 2 of the ten are on the dev split and 8 on the held-out test split. Two questions are too few to score, so the long-tail number comes from test. The guard and the repair prompt were fixed on dev, and nothing changed after we read test once; chapter 4 already scored the first replies there. Then the example promotes one question to the semantic layer, in a copy of its YAML.

long-tail-score.py Python 3.12.15 · duckdb 2.0.0 · pyyaml 6.0.3 · sqlglot 30.29.0 Passing
"""Guarded SQL on the long tail, the answer questions no metric in the semantic layer covers; then one promoted."""import shutilimport tempfilefrom collections import Counterfrom pathlib import Pathfrom lib.answers import format_valuefrom lib.context import LAYERS, build_contextfrom lib.golden import golden_result, grade, load_golden, results_matchfrom lib.model import RecordedModelsfrom lib.prompts import layered_sql_messagesfrom lib.semantic import SEMANTIC_DIR, MetricRequest, loadfrom lib.sql_guard import SqlGuard, answer_with_repairfrom lib.warehouse import agent_warehouse, connectLONG_TAIL = {"gq-007": "a filter on what an order contains", "gq-016": "customers with an order",             "gq-031": "units per order", "gq-032": "gross margin", "gq-033": "session conversion",             "gq-034": "marketing spend", "gq-036": "one order's refunds", "gq-037": "duplicate sign-ups",             "gq-038": "a weekday dimension", "gq-041": "discount"}con, golden = connect(), {q.question_id: q for q in load_golden()}guard, model = SqlGuard(agent_warehouse(con)), RecordedModels("context-layers", "context-layers-test", "guarded-sql-runs")counts = Counter()for question_id, needs in LONG_TAIL.items():    q = golden[question_id]    run = answer_with_repair(guard, model, layered_sql_messages(build_context(con, q.question, LAYERS).text,                                                                 q.question), q.question, role=q.role)    outcome = grade(q, run.answer, gold := golden_result(con, q))    counts[q.split, outcome] += 1    print(f"{question_id} ({q.split}, {needs}): {outcome}, after {' then '.join(a.outcome for a in run.attempts)}")    if outcome == "wrong":        row = ", ".join(map(format_value, min(run.answer.rows))) if run.answer.rows else "no rows"        print(f"{question_id} first row: {row} (golden: {', '.join(map(format_value, min(gold)))})")print(*(f"long tail questions on {s}: {sum(golden[i].split == s for i in LONG_TAIL)}" for s in ("dev", "test")), sep="\n")for split, outcome in [(s, o) for s in ("dev", "test") for o in ("correct", "wrong", "declined")]:    print(f"long tail {split} {outcome}: {counts[split, outcome]} of {sum(n for (s, _), n in counts.items() if s == split)}")with tempfile.TemporaryDirectory() as folder:        # promote gq-038: one dimension added to a copy of the YAML    shutil.copytree(SEMANTIC_DIR, copy := Path(folder) / "semantic")    with open(copy / "orders.yml", "a") as orders:        orders.write("    - name: weekday\n      type: categorical\n      expr: dayname(order_date_local)\n")    layer = load(copy)answer = layer.query(con, MetricRequest(("net_revenue", "orders"), ("order__weekday",), {"metric_time__year": 2025}))print("gq-038 as a metric request matches golden:", results_match(answer.rows, golden_result(con, golden["gq-038"])))
Outputpython long-tail-score.py
gq-007 (dev, a filter on what an order contains): wrong, after ran
gq-007 first row: NULL (golden: 377,497.40)
gq-016 (dev, customers with an order): correct, after ran
gq-031 (test, units per order): correct, after ran
gq-032 (test, gross margin): wrong, after ran
gq-032 first row: 773,723.34 (golden: 847,864.71)
gq-033 (test, session conversion): wrong, after ran
gq-033 first row: (none), 13.46 (golden: (none), 0.13)
gq-034 (test, marketing spend): declined, after table then declined
gq-036 (test, one order's refunds): correct, after ran
gq-037 (test, duplicate sign-ups): wrong, after ran
gq-037 first row: no rows (golden: 54)
gq-038 (test, a weekday dimension): wrong, after ran
gq-038 first row: Friday, 221,172.11, 822 (golden: Friday, 221,172.11, 869)
gq-041 (test, discount): correct, after ran
long tail questions on dev: 2
long tail questions on test: 8
long tail dev correct: 1 of 2
long tail dev wrong: 1 of 2
long tail dev declined: 0 of 2
long tail test correct: 3 of 8
long tail test wrong: 4 of 8
long tail test declined: 1 of 8
gq-038 as a metric request matches golden: True

Updated from sqlglot 30.27.0 to 30.29.0 in Edition 2026.11.3.

On dev, guarded SQL gets 1 of 2 right; on test, 3 of 8, with 1 of 8 declined. The decline is gq-034, return on marketing spend: the guard refused raw.marketing_spend, and in its repair the model answered that no allowed table holds spend, which is right; the fix is a mart.

Every wrong answer ran cleanly through every guard. For gq-007, the model made both mistakes chapter 4 found: it wrote the category tents where the shop stores Tents, so the sum came back NULL (golden: 377,497.40), and it sums only the tent lines, where the question asks for the whole revenue of orders that included one. For gq-032, gross margin, the model subtracted net revenue from gross revenue, which gives the tax, and joined the order lines on order_id, so each order counts once per line, chapter 2's fan-out: 773,723.34 (golden: 847,864.71). The plan-size limit doesn't catch a fan-out on a key. For gq-033, it gave conversion as a percentage where the shop reports a fraction: (none), 13.46 (golden: (none), 0.13). For gq-038, net revenue and orders by weekday, revenue is right and the order count isn't: Friday, 221,172.11, 822 (golden: Friday, 221,172.11, 869). The model left cancelled orders out of the count, where the shop's orders metric keeps them.

gq-038 is the one to promote. The shop defines both its metrics and lacks only a weekday dimension: three lines of YAML on the orders semantic model, with the expression dayname(order_date_local). With it, the question becomes a metric request grouped by order__weekday in 2025, and it matches the golden result: True. That isn't a held-out result: we chose it from gq-038's failure on test. The book's layer is unchanged, so gq-038 stays in test; in your own layer, move it to dev. gq-034 and gq-037 need a mart first, because the agent rightly can't read raw. Lookups such as gq-036, one order's refunds and their reasons, are what guarded SQL answers well.

Working rule: Send a question to guarded SQL only when no metric covers it, and score that path on its own long tail of golden questions. Promote a question to the semantic layer when it recurs, when its answer goes into a report, or when guarded SQL gets it wrong on a definition, as with what counts as an order. Leave one-off lookups on the guarded path. When a question needs a table off the allowlist, build the mart and its metric rather than widening the list.

What goes wrong in production

These are the ways guarded SQL fails once real people use it, and how you'd notice each one.

A wrong answer passes every guard. The database and the guards limit what a query reads and costs, not what its number means. Execution-based correction can't fix SQL that runs and is wrong, which is why Hong et al. add error detection before repair (Hong et al.), and Chaturvedi et al. guide the correction with a taxonomy of errors (Chaturvedi et al.). You notice only by scoring the guarded path on golden questions, and by reading answers whose result is empty, NULL or a percentage where you expected a fraction.

The agent's connection sits above the views. The boundary holds only if the agent's warehouse user can't read the tables under its views. Check its grants whenever the warehouse's roles change.

A role's view is wrong or out of date. A view returns what its definition says, so a missing row filter or mask leaks on every path at once. In DuckDB, each role's database is also a copy made when it is built, so it must be rebuilt when the marts are. Generate the views from one policy, test each role with queries the parser accepts, as the views section does, and name every table one by one, so a new mart stays out until someone adds it.

The function and catalog lists are judgements. A function the analysts need can be missing, one on the list can still be slow, and a new DuckDB catalog function can outgrow the list. Log every function refusal, and review both lists with each DuckDB release.

The guard refuses good SQL. The guard refuses DuckDB syntax that sqlglot can't model. Log every refusal with its kind, and run every golden query through the guard in CI, as the allowlist section does.

An estimate is not a cost. The replay shows a join that produced more than 100 times its estimate. Log each query's estimate beside its run time.

A read-only database still keeps state. A read-only DuckDB connection accepts temporary tables, views, macros and variables, so a statement past the parser could change later answers, as the replay's macro shows. The worker gives each query a fresh cursor; do the same in any shared connection pool.

DuckDB's memory limit doesn't cover every value. It "only applies to the buffer manager" (DuckDB), so the strings an expression builds escape its 1 GB. The worker caps its own process at 1.5 GB over its size once the database is open. A worker that runs out of memory, dies or never starts raises GuardError (memory or worker_failed) and returns no rows: the guard fails closed. The parse checks run in the guard's own process, bounded only by the 20,000-character limit and the depth cap. Give each agent run its own guard, because a worker answers one query at a time.

The warehouse's own limit is far away. Snowflake's STATEMENT_TIMEOUT_IN_SECONDS defaults to "172800 (2 days)" (Snowflake). Set the agent's time limit in the guard, and a lower limit on the agent's warehouse role as well.

Repair hides errors. On the shop's dev split, every repaired query that ran was wrong. Record the attempts with each answer, as answer_with_repair() does in its GuardedRun, and count repaired answers in every score.

The guard's messages describe the database. A refusal lists the allowed tables and their columns. Build the guard on the role's own database, so it lists only what the role may see.

Working rule: Treat the role's read-only database of views as the control, and the guard as a layer that turns mistakes into refusals and bounds the cost. Check the agent's grants and test each role's views, run every golden query through the guard in CI, set the time limit in the guard and on the warehouse, log every refusal, estimate and repair with its answer, and score the path on its own long tail of golden questions after every change.

Decisions this chapter made

  • Guarded SQL is the fallback, not the default, for questions no metric covers, written with chapter 4's four layers of context.
  • The database is the security boundary. The agent's SQL runs read-only on agent_warehouse(): a file holding the seven allowed marts, or, given a role's policy, that role's views of them, with file access, extensions and constant folding off, memory and threads limited, and its settings locked. In a warehouse with roles, that is a read-only role with SELECT on secure views, or row access and masking policies.
  • The parser, the timer and the cost check are a teaching layer and defence in depth, not the boundary.
  • What runs is the guard's SQL, parsed and regenerated by sqlglot, with every column qualified and a row limit added: one statement, a query at the root, and no writing statement anywhere in the tree.
  • The allowlist is the database's tables, named one by one, with CTE names resolved by scope, DuckDB's catalog refused, only listed functions allowed, SIMILAR TO and GLOB refused, nesting capped, and nothing but tables and subqueries in FROM. Two golden questions can't be answered inside it.
  • Four bounds on every query: at most 1,000 rows and 50,000 characters, refused rather than cut; a plan whose largest step EXPLAIN estimates at no more than 10 million rows, a rule of thumb, with no step left unestimated; and one 5-second deadline over the checks, the plan and the run, enforced by killing the worker process, which runs each query on a fresh cursor.
  • One repair, from the guard's refusal or DuckDB's error, with a prompt that lets the model decline. Repaired answers are scored apart.
  • Transpile the guard's output, without its quotes, raising on what sqlglot can't translate and listing the functions it passed through.
  • Promote a question to a metric when it recurs, when its number is reported, or when guarded SQL gets its definition wrong.

This chapter, in this edition

Edition 2026.11.3Examples passed Nov 6, 202611 of 11 examples in this chapter pass

Anyone can check this book or correct it. Found something wrong? Report it with the section's ID, the last part of the link beside each heading, or fix it yourself with a pull request.

Cite this section

Format

Every format names this edition. Its link opens the current edition, where a section’s heading says when its text last changed. Accessed and retrieved dates are the day you open this.

On this page

Chapter 7 of 11: Let the agent write SQL inside guards

  1. Give the agent a database with only what it may read
  2. Give each role a database of views
  3. Parse the SQL before it runs
  4. Allow only read-only queries on known tables
  5. Bound the rows and the cost
  6. Enforce the deadline by killing a process
  7. Replay the attacks on the guard
  8. Repair once from the error
  9. Transpile to the warehouse's dialect
  10. When to prefer a new metric over SQL
  11. What goes wrong in production
  12. Decisions this chapter made

Back to the topAll chapters