A checkout migration sat in review for eleven minutes while a SQL agent rewrote the same UPDATE four times. Each pass looked cleaner in the comment thread, yet the agent never measured lock scope against a realistic row estimate. The final suggestion dropped a WHERE clause that still compiled, and a parser would have flagged the missing predicate immediately. This opening is a labeled composite of review-queue failures, not a first-person production claim.
SQL review agents fail in a specific way that generic coding agents often hide behind fluent comments. They can emit valid SQL that still expands lock scope, invalidates indexes, or rewrites predicates during a supposedly helpful repair. Multi-turn loops amplify that risk because each iteration treats the last model output as a trusted new source of truth. The practical question is whether the agent should speak once, or keep repairing until a budget expires.
Why this is not a routing or catalog debate
Isolation answers where a candidate statement may execute, and catalog policy answers which schema objects the model may observe. Auto-rewrite gates answer whether the agent may patch a file at all, which is a different permission than iteration count. Those questions remain open on this account, and they should not collapse into one policy knob. This article isolates a narrower control: how many times a SQL review agent may rewrite after the first finding.
Recent public discussion around agent loops is noisy, and most of it is not grounded in statement-class risk. A SQL agent is not a general coding loop with a compiler sitting at the end of the turn. The “compiler” for SQL is a combination of parser, planner, permissions, and data-dependent locks, and those signals do not arrive together. A repair loop that only watches message text will optimize for comment aesthetics instead of lock and cardinality behavior.
Position A: single-pass critique behind a parser gate
The first position treats the model as a reviewer, not as an author with an unbounded edit loop. A deterministic parser extracts statement type, tables, predicates, and join keys before any model token is spent. If the parse fails, or a policy rule fires, the agent posts a finding and stops without proposing a patched file. The model may explain residual risk, but it does not receive its own previous SQL as the next user message.
This position is strongest when the failure mode is structural rather than planner-specific. Missing WHERE clauses, unqualified DELETE, cross-schema DROP, and INSERT that omits a NOT NULL column are parser-visible. Sending those defects into a repair loop often produces a fluent rewrite that still fails the same static rule. Single-pass critique also keeps the review comment stable for humans, because the thread does not accumulate four competing variants of one migration.
A labeled example gate looks like the following Python. Treat it as a review-time sketch, not as a measured benchmark from production traffic.
# Example only: parser gate for a SQL review agent. Not executed against live traffic.
from dataclasses import dataclass
import sqlglot
from sqlglot import exp
FORBIDDEN_ROOTS = (exp.Drop, exp.Truncate, exp.Alter)
@dataclass
class GateResult:
allow_model: bool
halt_loop: bool
reasons: list[str]
def parser_gate(sql: str) -> GateResult:
reasons: list[str] = []
try:
trees = sqlglot.parse(sql, read="postgres")
except sqlglot.errors.ParseError as err:
return GateResult(allow_model=False, halt_loop=True, reasons=[f"parse_error:{err}"])
for tree in trees:
if tree is None:
continue
if isinstance(tree, FORBIDDEN_ROOTS):
reasons.append(f"forbidden_root:{type(tree).__name__}")
for node in tree.walk():
if isinstance(node, (exp.Update, exp.Delete)) and node.args.get("where") is None:
reasons.append(f"dml_without_where:{type(node).__name__}")
if isinstance(node, exp.Select) and not list(node.find_all(exp.Limit)):
if node.find(exp.Join):
reasons.append("unbounded_join_select")
halt = bool(reasons)
return GateResult(allow_model=not halt, halt_loop=halt, reasons=reasons)
Under Position A, halt_loop=True is terminal. The agent may still write a diagnostic comment that quotes reasons, but it must not call the model again with a “please fix this SQL” instruction. That rule sounds severe until you inspect what repair loops optimize for in practice. They optimize for making the linter quiet, which is not the same as making the statement safe under concurrency.
Position B: a bounded repair loop on an isolated server
The second position accepts that first drafts miss bind-parameter types, partial indexes, and trigger side effects. A small loop may rewrite the statement, run it in a sandbox, and feed errors back into the next prompt. That loop only stays honest if execution happens off the production endpoint and the iteration budget is explicit. Without a hard stop, the agent will spend the entire review window arguing with itself about alias names.
Disclosure: This article was prepared as part of MonkeyCode's product outreach. Teams evaluating MonkeyCode can use its free model access and free server option to keep those repair attempts off production capacity. The claim here is availability of free model access and a free server option, not a quota, hardware profile, model name, or permanence guarantee. If your change set includes customer data, the sandbox still needs a scrubbed schema clone rather than a logical replica of production rows.
Position B is strongest when the defect is runtime-shaped and parser-invisible. Search path mistakes, missing grants, IMMUTABLE function misuse, and trigger recursion often survive sqlglot and still fail when a statement hits a real engine. A single isolated execution can return a definite error string, which is higher-quality evidence than another paragraph of model speculation. The loop should consume that error, not a request to “make it more elegant.”
A labeled loop budget can be this small. The important part is the halt conditions, not the client library you happen to wrap.
# Example only: bounded repair loop. Do not point this at production.
MAX_REPAIR_TURNS = 2 # initial critique + at most one error-driven repair
STYLE_FEEDBACK_FORBIDDEN = True
class LoopHalt(Exception):
pass
def repair_loop(sql: str, schema_ddl: str, sandbox_exec, model_complete) -> str:
gate = parser_gate(sql)
if gate.halt_loop:
raise LoopHalt("parser_gate:" + ",".join(gate.reasons))
current = sql
last_error = None
for turn in range(MAX_REPAIR_TURNS):
if turn == 0:
prompt = critique_prompt(current, schema_ddl)
else:
if last_error is None:
raise LoopHalt("no_runtime_error_for_repair")
prompt = repair_prompt(current, schema_ddl, last_error)
proposal = extract_sql(model_complete(prompt))
gate = parser_gate(proposal)
if gate.halt_loop:
raise LoopHalt("repair_failed_parser:" + ",".join(gate.reasons))
err = sandbox_exec(proposal)
if err is None:
return proposal if turn > 0 else current
last_error = err
raise LoopHalt("repair_budget_exhausted")
Notice the loop does not repair on style comments, unused aliases, or formatting nits. If the sandbox returns success, Position B still should not rewrite for taste, because taste rewrites are how WHERE clauses disappear. The sandbox is evidence for errors, not a license to keep iterating until the model gets bored.
Evidence that actually changes the decision
Parser-first evidence is cheap and repeatable, which matters more than rhetorical confidence from a model. If sqlglot.parse fails, or a DML node lacks where, you already have a ship-blocking finding with a stable identifier. Repairing that class of defect with a model is optional work, and it is often worse work, because the model can satisfy the parser by adding WHERE true or a tautology. Single-pass critique should treat tautology predicates as a halt condition as well.
Sandbox evidence is slower and more complete for engine-true failures. A free isolated server can answer questions the parser cannot, including missing relations, wrong types in RETURNING lists, and volatile functions in index expressions. That evidence is only trustworthy when the schema clone matches the branch under review, including migrations that have not reached production. A stale clone will reject valid statements or, worse, accept invalid ones that depend on columns added later.
Loop evidence is mostly negative, and that is still useful. Each extra turn re-sends schema text, prior SQL, and the latest error, which crowds out the original reviewer intent. Agents then “fix” timeouts by deleting joins, and they “fix” permission errors by switching tables. If you cannot write a halt condition that names those regressions, you do not have a repair loop. You have an unsupervised author with a retry button.
Artifact: decision table, tests, and a command path
The original artifact is a decision table plus a test plan you can run without production credentials. Rows are statement classes, not vibes about model quality. Apply the table before the first model call, then again after any proposed repair.
| Statement class | Parser gate | Repair loop allowed | Required evidence to continue | Halt immediately when |
|---|---|---|---|---|
| UPDATE/DELETE without WHERE | Fail closed | No | None | Missing predicate, tautology predicate |
| UPDATE/DELETE with WHERE | Fail open to critique | One turn, error only | Sandbox error string | Predicate removed or weakened |
| SELECT with JOIN, no LIMIT | Warn | No | Static join list | Model adds DISTINCT as a substitute for LIMIT |
| DDL (DROP/ALTER/TRUNCATE) | Fail closed for apply | No | Human approval | Any auto-applied rewrite |
| INSERT missing NOT NULL column | Fail closed | No | Parser or information_schema | Model supplies dummy literals |
| Utility SQL (VACUUM, GRANT) | Fail closed | No | Named operator runbook | Agent invents a substitute command |
Labeled tests below encode the table. They are intended for a local pytest run against fixtures, not as reported production metrics.
# Example tests for the debate rule. Fixtures only.
import pytest
def test_update_without_where_never_enters_loop():
sql = "UPDATE orders SET status = 'paid';"
gate = parser_gate(sql)
assert gate.halt_loop is True
assert any("dml_without_where" in r for r in gate.reasons)
def test_tautology_where_is_not_a_successful_repair():
original = "UPDATE orders SET status = 'paid' WHERE id = %s;"
repaired = "UPDATE orders SET status = 'paid' WHERE 1 = 1;"
assert parser_gate(original).halt_loop is False
# Extension point: compare predicates, do not accept tautologies.
assert "1 = 1" in repaired # document the failure mode under review
def test_repair_budget_is_two_turns_maximum():
assert MAX_REPAIR_TURNS == 2
A command path keeps the same rule visible outside Python. The following is a local dry-run sketch against a disposable database, not a production runbook.
# Example only: parse, then optionally exec on a disposable database.
python -c 'from gate import parser_gate; print(parser_gate(open("stmt.sql").read()))'
# If the gate fails, stop. Do not call a model.
# If the gate passes and statement class allows one repair, exec in a clone:
psql "postgres://review:review@sandbox-host:5432/review_clone" \
-v ON_ERROR_STOP=1 \
-c "BEGIN; $(cat stmt.sql); ROLLBACK;"
Rollback wrapping is part of the evidence story, not an optional flourish. Repair loops that auto-commit on a shared clone will contaminate the next pull request’s findings. If you cannot wrap the candidate in BEGIN and ROLLBACK, Position B is unavailable for that statement class, and you should fall back to Position A.
A concrete workflow in numbered steps
Classify the statement with a parser before any model call, and store the statement class next to the review comment identifier. Classification must be deterministic so later turns cannot relabel a DROP as a SELECT after a rewrite. If classification fails, post the parse error and halt without asking the model to guess intent from broken SQL. This step is mandatory for both positions, because a loop without a class cannot apply the decision table.
Apply the decision table row for that class, including the halt conditions that mention weakened predicates and dummy literals. Do not skip this row because a model previously “did well” on a neighboring file in the same pull request. Neighboring files do not share lock scope, and they do not share trigger bodies. Record the chosen position in the comment so humans can see whether they are reading a critique or a repaired proposal.
If Position A applies, call the model at most once with schema that is frozen for the branch, then post findings without a patch file. The prompt should ask for residual risk, not for a rewritten statement, which removes the incentive to invent WHERE clauses. If the model still returns SQL, discard the SQL and keep only the diagnostic sentences that cite objects the parser already extracted. Single-pass critique fails closed when the model cannot point at a parsed object.
If Position B applies, run the current SQL in an isolated clone, and allow a repair turn only when the engine returns an error string. Feed that error back without style instructions, then parse the proposal and diff it against the original predicates and target tables. Halt if tables change, if predicates weaken, or if the repair budget is already spent. Success in the sandbox is not permission to iterate again for naming or formatting.
Close the review with one artifact humans can replay: parser reasons, sandbox error or
None, turn count, and the halt rule that fired. Replayability matters more than confidence language, because the next reviewer needs to know why the agent stopped. If you cannot replay the halt, you cannot defend the comment in an incident review. That close-out is the difference between a SQL review agent and a chat window attached to git.
Limitations, and who should not use this approach
This approach does not replace query-plan review, because neither sqlglot nor a rolled-back execute is a substitute for cardinality on production-like statistics. It also does not certify that a free server clone matches production extensions, collations, or autovacuum settings. If your risk sits in planner choices rather than parse structure or hard engine errors, you need captured plans or a statistics fixture, which is a different debate. Mixing those signals into this loop will hide the halt conditions that make the loop defensible.
Do not use Position B on a shared free server when the SQL or the clone would carry customer data, secrets, or uncleansed dumps. Do not use a repair loop for non-idempotent DDL, because a failed ALTER in a clone is not a safe teaching signal for a model. Do not use either position as an apply mechanism; the artifact is a review comment and a halt reason, not a merge. Teams without a parser in the path should not enable loops at all, because they cannot detect a WHERE clause vanishing between turns.
Free model access and a free server option also do not imply unbounded capacity, reserved hardware, or a stable model identity across days. Treat both as an evaluation path for isolated review work, then re-check current product terms before you automate a queue. If terms are unclear, keep the parser gate and disable repair until the execution target is named. Unnamed execution targets are how review agents wander into the primary.
Decision rule
Use Position A, single-pass critique, when the parser can name a structural defect or the statement class is DDL, GRANT, or unbounded DML. Use Position B, one error-driven repair turn, only when the parser is clean, the statement is DML or SELECT, a scrubbed clone exists, and the sandbox returns an engine error. Never repair on style, never repair after a successful sandbox run, and never allow a repair that changes tables or weakens predicates. If those halt checks cannot be coded as tests, the agent does not get a loop.
That rule is intentionally conservative relative to general coding agents that retry until tests go green. SQL “tests” that only check executability will go green after deleting the constraint that made the statement interesting. The review agent’s job is to keep that constraint visible, even when a model is available at no listed cost. Cheap tokens do not change the cost of a lock on the orders table.
If you already have a disposable clone and want to exercise the bounded loop on synthetic migrations, MonkeyCode’s free model access and free server option are a reasonable evaluation path. Keep the parser gate in front, keep the decision table in source control, and keep production endpoints out of the client configuration.
Top comments (0)