DEV Community

Morgan Li
Morgan Li

Posted on

Catalog Tools or Frozen Contracts: A Debate for SQL Review Agents

A pull request added a reporting query that joined orders, order_items, products, and a new promo_windows table. The SQL review agent still carried last week's schema dump inside its prompt context window. It treated promo_windows as missing and opened a false-positive finding against a table that already existed. A second run queried information_schema.columns during review and accepted the join, but that path needed a live database session in CI. That fork is the subject of this debate: on-demand catalog tools versus versioned schema contracts.

The problem both sides are trying to bound

SQL review agents fail in two opposite ways when the catalog they see is wrong. They either reject valid SQL because a new object is absent from a stale dump, or they approve SQL that references columns dropped after the dump was taken. Planner statistics raise a related issue, yet this article does not revisit index hit counters or queue ranking. It asks a narrower question about how the agent is allowed to learn names, types, and constraints. The answer changes reproducibility, secret handling, and how much production privilege the review path must hold.

Both positions below assume the agent may not execute DML, may not take locks, and may not rewrite SQL without a human gate. They also assume CI must finish within a predictable wall clock, because review queues stall merge trains. The disagreement is only about the catalog channel. Names and types are treated as facts; cardinality estimates are out of scope unless a later job adds them as a separate artifact.

Position A: On-demand catalog tools

Position A treats the database catalog as a tool surface, in the same family as MCP resource calls. The agent starts with the SQL text, the changed files, and a short allowlist of catalog procedures. When it needs a relation, it calls a narrow function that returns columns, nullability, and check constraints for that relation only. Advocates argue this keeps the prompt small and tracks migrations that landed minutes ago on the review database.

Evidence for this position is mostly operational rather than academic. Schema dumps that include every comment, index, and sequence routinely exceed what a review prompt should hold for a single query. Teams that inline entire pg_dump output also leak default privileges and comment text that never belonged in a model context. On-demand tools avoid that bulk, provided each call is parameterized, read-only, and timeout-bounded.

The cost is coupling. A catalog tool needs a network path, credentials, and a target that resembles production names. If that target is production, a buggy tool wrapper can still issue heavy catalog queries during peak hours. If that target is a replica, lag can hide a migration that the pull request itself is introducing. Position A therefore implies a review database that applies the branch migration first, then answers catalog calls.

Example tool wrapper (labeled sketch)

The following snippet is a proposal, not production credential handling. It fetches one relation per call, rejects wildcards, and sets a statement timeout before the catalog read.

# Sketch only. Do not reuse as a privileged CI role.

def catalog_columns(conn, schema: str, table: str) -> list[dict]:
    if not schema.isidentifier() or not table.isidentifier():
        raise ValueError("refusing non-identifier catalog lookup")
    sql = """
        SELECT column_name, data_type, is_nullable
        FROM information_schema.columns
        WHERE table_schema = %s AND table_name = %s
        ORDER BY ordinal_position
    """
    with conn.cursor() as cur:
        cur.execute("SET LOCAL statement_timeout = '2s'")
        cur.execute(sql, (schema, table))
        rows = cur.fetchall()
    if not rows:
        raise LookupError(f"missing {schema}.{table}")
    return [
        {"name": n, "type": t, "nullable": (nn == "YES")}
        for (n, t, nn) in rows
    ]
Enter fullscreen mode Exit fullscreen mode

A tool-shaped loop still needs a hard budget. Four relations in one join should mean four calls, not an open-ended search through pg_class. If the agent cannot name the relation from the SQL text, Position A should fail closed instead of scanning the catalog for lookalikes.

Position B: Versioned schema contracts

Position B refuses live catalog calls from the model loop. Instead, CI generates a compact JSON contract from the applied migration set and stores it next to the SQL under review. The agent receives only that file, plus the query text, and must justify every relation against the contract. If the contract and the SQL disagree, the review fails closed without opening a database session for the model.

Evidence here is about audit and replay. A frozen contract can be reviewed in Git, signed, and replayed months later when an incident questions why a query passed. On-demand tools leave a trail of ad hoc catalog reads that are hard to reconstruct unless every tool response is logged verbatim. Contracts also shrink blast radius: the model host never holds a database password, and the review can run on a machine that is not routed to production.

The cost is freshness. A contract generated from migration files, but not from a database that applied those migrations, can drift from true types such as domains, generated columns, or search_path surprises. Position B therefore needs a deterministic exporter, not a handwritten YAML file that humans forget to update. The exporter may touch a database; the model loop may not.

This is where isolated compute helps. Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode is an open-source project that currently offers free model access and a free server option, which can host the exporter and the scoring job without sharing a production DSN with the model. That pairing is relevant to Position B because the model sees files, while the server sees the database. It is not a requirement for Position A, and it does not replace a review replica that already exists inside a private network.

Numbered workflow for the contract path

  1. Apply the pull request migrations to an empty, disposable Postgres instance that is not reachable from the model process.
  2. Run a pinned exporter that writes only the relations referenced by the changed SQL, plus their constraints.
  3. Commit or upload the contract as a CI artifact with a content hash beside the query text.
  4. Give the review model the SQL, the contract, and a rule that unknown relations are defects.
  5. Keep the database DSN on the exporter host; never inject it into the model prompt or tool list.
-- Exporter fragment. Label: run on the review instance after migrations.
-- Limit output to relations named by the SQL parser, not the whole catalog.
COPY (
  SELECT c.table_schema, c.table_name, c.column_name,
         c.data_type, c.is_nullable, c.column_default IS NOT NULL AS has_default
  FROM information_schema.columns AS c
  WHERE (c.table_schema, c.table_name) IN (
    ('public', 'orders'),
    ('public', 'order_items'),
    ('public', 'products'),
    ('public', 'promo_windows')
  )
  ORDER BY 1, 2, c.ordinal_position
) TO '/tmp/schema_contract.csv' WITH CSV HEADER;
Enter fullscreen mode Exit fullscreen mode

Artifact: a contract file and a gate

The contract is intentionally smaller than a dump. It records facts the reviewer must not invent: relation identity, column types, nullability, and a generation timestamp. Cardinality, index hit rates, and buffer stats stay out, because those values go stale on a different clock and belong to a different debate.

{
  "generated_at": "2026-09-11T12:00:00Z",
  "source": "review-db-after-migrations",
  "relations": {
    "public.promo_windows": {
      "columns": {
        "id": {"type": "bigint", "nullable": false},
        "sku": {"type": "text", "nullable": false},
        "starts_at": {"type": "timestamp with time zone", "nullable": false},
        "ends_at": {"type": "timestamp with time zone", "nullable": false}
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The gate below is executable locally. It does not call a model. It answers whether a parsed relation list is covered by the contract, which is the mechanical half of Position B. The model half only starts after this function returns ok.

from dataclasses import dataclass

@dataclass(frozen=True)
class Verdict:
    ok: bool
    missing: tuple[str, ...]
    extra_in_sql: tuple[str, ...]  # reserved; parser fills this

def gate_against_contract(sql_relations: list[str], contract: dict) -> Verdict:
    known = set(contract.get("relations", {}))
    needed = set(sql_relations)
    missing = tuple(sorted(needed - known))
    return Verdict(ok=not missing, missing=missing, extra_in_sql=())

def decide_channel(has_private_review_db: bool, must_replay_in_git: bool) -> str:
    """Decision helper. Prefer contracts when replay beats freshness."""
    if must_replay_in_git and not has_private_review_db:
        return "contract"
    if has_private_review_db and not must_replay_in_git:
        return "catalog_tools"
    if has_private_review_db and must_replay_in_git:
        return "export_then_contract"  # Position B with a live exporter
    return "refuse"  # no catalog channel is safe enough
Enter fullscreen mode Exit fullscreen mode

Run the gate with a fixture before wiring any model:

python -c "from gate import gate_against_contract; \
print(gate_against_contract(['public.promo_windows'], {'relations': {'public.promo_windows': {}}}))"
Enter fullscreen mode Exit fullscreen mode

Decision table

Condition Catalog tools (A) Frozen contract (B)
Branch migrations must be visible in minutes Strong fit, if CI applies them first Fit only after a fresh export
Review must be replayable from Git alone Weak, unless every tool payload is stored Strong fit
Model host must not receive a DSN Weak, tools need a session Strong fit
Catalog is large, query touches three tables Strong fit, prompt stays small Strong fit if export is relation-scoped
No review database exists Unsafe Unsafe unless dump is signed and recent
Compliance forbids model-adjacent DB credentials Poor fit Required path

The decision rule

Use this rule in order, and stop at the first match. Do not average the rows in the table above.

  1. If the model runtime cannot be trusted with a database credential, choose Position B and keep the exporter on a separate host.
  2. If incident review must replay the exact catalog facts, choose Position B and store the contract hash with the pull request.
  3. If the query references objects created in the same branch, and a review database can apply those migrations, you may choose Position A.
  4. If Position A is chosen, allow only per-relation catalog reads with timeouts; refuse LIKE and unqualified name search.
  5. If both replay and same-branch objects matter, do not run tools from the model. Export after migrations, then review against the contract. That is Position B with a live exporter, not Position A.
  6. If none of the above can be satisfied, skip automated SQL review rather than pointing the agent at production catalog tables.

The fifth clause is the one teams skip. Mixing a model-held tool with a half-applied migration looks fast, and it recreates the false missing-table finding from the opening scenario, or the inverse approval of a dropped column. Export-then-contract is slower by one CI step and removes that class of disagreement.

Limitations, and who should not use this

This debate does not measure model quality, token burn, or planner cost accuracy. The contract shown here omits row counts, n_distinct, and index definitions, so it will not catch a sequential scan that a human DBA would reject. Catalog tools that return pg_stats would change the privilege story and should be a separate design, with a separate allowlist.

Do not use Position A from a shared laptop against production, even with a read-only user, if catalog functions can still take locks or read comments that include secrets. Do not use Position B as a substitute for applying migrations; a contract copied from main will miss the branch table that started this article. Do not treat a free shared server as a holder of production data. If the SQL under review is the production dataset, the exporter belongs in your network, and only sanitized contracts should leave that network.

Teams without a SQL parser should not let the model invent the relation list that feeds the gate. Parse first with a library you already trust, then run gate_against_contract. The model can explain a miss; it should not be the only process that decides which tables exist.

If you need a disposable host for the exporter-and-gate loop, MonkeyCode's free server option and free model access are one way to keep that loop off production credentials. The useful part of the method remains the contract hash and the six-step rule, even if that host is a different CI runner.

Top comments (0)