Skip to main content
Version: Next

Guardrails

Guardrails are a configurable chain of safety checks that run on every query before it reaches a backend engine. They are designed primarily for agentic workloads — where an AI agent generates SQL dynamically — but apply equally to human clients.

Each guard inspects the translated SQL (after dialect translation, before engine dispatch) and returns one of three verdicts:

VerdictEffect
allowQuery proceeds.
warnQuery proceeds; a warning is recorded in the audit log.
denyQuery is blocked. A machine-readable error code is returned so agents can react programmatically.

Every verdict is recorded in guard_actions on the query record, alongside a was_guard_blocked flag, making the full guard history queryable from Studio and the Admin API.


How the chain works

Guards are evaluated in order. The chain stops at the first deny — subsequent guards are skipped.

Two layers of guards compose per query:

  1. Global guards — defined once, run for every query regardless of cluster group.
  2. Per-group guards — appended after the global chain for queries routed to that group.

This lets you apply baseline safety globally (e.g. read-only for all agents) while tightening or relaxing rules for specific groups (e.g. a stricter row limit for an analytics group).


Built-in guards

read_only

Blocks any statement that is not a SELECT, WITH, SHOW, DESCRIBE, or EXPLAIN. Guards against agents issuing accidental INSERT, UPDATE, DELETE, or DDL.

guardrails:
global:
- kind: built_in
name: read_only

Error code on deny: READ_ONLY_VIOLATION


row_limit

Requires the outermost query to have a LIMIT clause. Optionally enforces a maximum.

  • No LIMIT presentwarn (query still runs, but the warning is recorded).
  • LIMIT present but exceeds max_rowsdeny.
guardrails:
global:
- kind: built_in
name: row_limit
max_rows: 10000

Error code on deny: ROW_LIMIT_EXCEEDED

The check is applied to the outermost query only — a subquery with LIMIT 9999 inside a SELECT … LIMIT 10 outer query correctly passes a max_rows: 1000 guard.


require_predicate

Rejects SELECT statements that have no WHERE clause. Prevents full table scans that can scan billions of rows and generate large cloud bills.

Use applies_to to restrict the check to specific table name patterns (glob syntax, * matches any sequence):

guardrails:
global:
- kind: built_in
name: require_predicate
applies_to:
- "fct_*"
- "events.*"

With an empty applies_to list (or omitted), the guard applies to all tables.

Error code on deny: MISSING_PREDICATE


Per-group overrides

Per-group guards are appended after the global chain. This is useful for giving different agent pools different safety profiles:

guardrails:
global:
- kind: built_in
name: read_only
groups:
agents:
- kind: built_in
name: row_limit
max_rows: 5000
- kind: built_in
name: require_predicate
analysts:
- kind: built_in
name: row_limit
max_rows: 100000

Queries routed to the agents group run: read_onlyrow_limit(5000)require_predicate. Queries routed to the analysts group run: read_onlyrow_limit(100000).


Python script guards

Note: Python script guards are not yet executed at runtime. The guard kind is accepted in configuration and stored, but the script body is skipped during dispatch. Use built-in guards or HTTP webhook guards for production safety rules.

For logic that can't be expressed as a built-in rule, Python script guards can be authored through the QueryFlux Studio Guardrails page. The script receives a ctx dict with sql, translated_sql, engine_type, cluster_group, user, and agent_context fields, and must return:

# allow
return {"action": "allow"}

# warn
return {"action": "warn", "reason": "large join detected"}

# deny
return {"action": "deny", "reason": "cross-region query blocked", "code": "CROSS_REGION"}

HTTP webhook guards

Note: HTTP webhook guards are not yet executed at runtime. The guard kind is accepted in configuration, but the webhook is not called during dispatch.

Delegate guard decisions to an external service:

guardrails:
global:
- kind: http_webhook
url: "https://hooks.example.com/guard"
timeout_ms: 5000
fail_behavior: deny # or: allow

QueryFlux POSTs the query context as JSON and expects the same {action, reason?, code?} response shape. fail_behavior controls what happens if the webhook is unreachable or times out — deny (default) is the safer choice for production.


Agentic context

Every query record can carry agentic metadata when the caller is an AI agent:

FieldDescription
agent_idStable identifier for the agent instance.
conversation_idGroups all queries from one agent session.
step_indexPosition of this query within the conversation.
tool_call_idThe specific tool-call that triggered the query.
query_intentFree-text description of what the agent was trying to do.

These fields are indexed in Postgres, so you can replay an agent's full session — every query it ran, in order, and every guard decision — directly from Studio or the Admin API.

-- All queries from a single agent conversation, in order
SELECT sql, query_intent, was_guard_blocked, guard_actions
FROM query_records
WHERE conversation_id = 'conv-abc123'
ORDER BY step_index;

Configuring guardrails in Studio

The Guardrails page in QueryFlux Studio provides a live editor for the guard chain. Changes are applied without a proxy restart. Built-in guards can be toggled and parameterized; Python script guards can be written and tested in the browser.


Observability

Guard decisions are recorded in guard_actions (JSONB array) on every query_records row. Each element has guard, action, reason, and code fields. The was_guard_blocked boolean column is indexed for fast filtering:

-- Recent blocked queries
SELECT created_at, sql, guard_actions
FROM query_records
WHERE was_guard_blocked = TRUE
ORDER BY created_at DESC
LIMIT 50;