Skip to main content
A rule makes two decisions. It decides what it MATCHES, and it decides what happens on a match. The first decision is a type and a list. The second is the action field: leave it unset and a match denies, set action: defer and the match becomes a finding that a Rego policy rules on.
Deferring moves the DETERMINATION and leaves the matching where it is. The local engine still runs the detector, the regex and the word list, in microseconds with no network. Rego gets the answer and decides what it means for this actor, this table and this hour.
action: defer on a lane with no policy.opa.url is refused at startup. A finding nobody reads is a rule that matches and then allows, which looks like enforcement from the config file.
Config File carries the exhaustive schema for every field named below.

The eight rule types

Every rule also takes name and message. message reaches the user in the protocol’s own error frame, so write one: the fallback names the rule and the operation and nothing else. Each block below is one entry in a lane’s policy.rules list.

operation

Reach for it first. It costs nothing, it survives every outage, and it reads the scanner’s classification rather than the text, so SELECT 'DROP TABLE customers' stays a select. Operation is the most consequential effect the statement has, whatever verb it opens with. A data-modifying CTE is a delete: Statement.Effects carries the full set when a rule needs to tell them apart, and it reaches Rego as input.effects. The SQL vocabulary: select, insert, update, delete, merge, create, drop, alter, truncate, grant, revoke, call, copy, show, set, begin, commit, rollback, explain, plus other and unknown. HTTP verbs are distinct values: get, post, put, patch, head, options, connect, trace.
CALL and EXECUTE now report unknown, so a rule written as operations: [call] stops matching. Both hide their body from any parser, and reporting a verb for a statement nobody can read is the fail-open this design removes. call survives in input.effects, which is where a Rego policy still sees it; a rule wanting both writes operations: [call, unknown]. Read Fail closed on what the scanner could not read.

table

access takes write, read, or nothing at all. Unset matches either, so every rule written before the split means what it always meant. Set it when the rule guards a table’s contents against change. Without access: write, a rule meaning “nothing writes to customers” also fires on a statement that only reads it:
The scanner reports staging as a write and customers as a read. That false positive is how operators learn to widen a rule until it protects nothing. An unknown access value is refused at startup. Table matching is best effort. A bare name matches any schema qualification, so customers covers public.customers, and an empty relation list means “could not determine” rather than “touches nothing”. Add require_table_match: true on a rule guarding something critical and accept the false positives.

deny_words_list

A case-insensitive substring search over the raw text. Use it for identifiers the scanner has no concept of, such as a function name. Do not use it for verbs: it denies SELECT 'DROP TABLE customers', and an operation rule does not.

pattern_match

RE2, compiled at startup, so a bad pattern names the lane and the rule and stops the process rather than failing on the first request that hits it. Reach for it when the shape you object to is textual and nothing else expresses it.

pii

The guardrail half of PII detection, and it runs on the REQUEST. Masking rewrites a response, and a taxpayer ID in a WHERE clause has already landed in the database’s own query log, its slow-query log and its EXPLAIN output. A rule naming an entity absent from pii.entities is refused at startup, because it would load, evaluate and match nothing. The denial message never quotes the value it found.

http_resource

Patterns match the NORMALIZED path, so /users/12345/orders/98765 arrives as /users/*/orders/* and one rule replaces a regex per endpoint. A trailing /** matches any deeper path. methods narrows the rule; leave it out to cover every method.

http_status

ext_authz cannot express this rule. Envoy decides before it calls the upstream, so no Envoy config reads a response status. This one is response-side: a request carries status 0 and never matches. Exact codes ("404") and classes ("4xx") both work.

ai_analysis

The local engine does not evaluate this type. The sidecar lifts it out of the rule set and runs it after the local rules and OPA, so its position in the list changes nothing and a statement a free rule already denied never reaches a paid classifier. Actions are allow, warn, block and defer; an unnamed risk level allows. It leaves the process and costs money per statement. Read Risk Analysis before enabling it anywhere real.
ai_analysis expresses deferral per risk level: high: defer. An action field on this rule type is refused at startup, and so are require_review and a defer on a lane with no policy.opa.url.

Fail closed on what the scanner could not read

unknown means the scanner did not understand the statement, and the reason travels in metadata["sql.incomplete"], which reaches Rego as input.metadata["sql.incomplete"]: Those three are a permanent ceiling, and no parser lifts it, PostgreSQL’s own grammar included. The body is not in the statement: it is in the catalog, in the client’s earlier PREPARE, or in a string a PL interpreter reads at runtime. A fourth case sits beside them and does NOT set the flag: a function call inside a SELECT list can do anything the function does, and flagging every SELECT count(*) would make the flag mean nothing. other is the statement that parsed into something the scanner does not classify. REFRESH MATERIALIZED VIEW mv is one, and it writes mv, which is why a lane refusing writes names other beside unknown.
operations: [unknown, other] is a permanent posture. No future parser removes the need for it, because the body of a DO block or a CALL is somewhere the statement does not carry. Leave it off a lane and you have accepted that risk in writing, which is the point of making it a rule you type.
The scanner is measured against PostgreSQL’s own grammar. On the differential oracle it agrees exactly on 72 of 74 statements, concedes 2 (DO and CALL) and gets 0 wrong. Run over PostgreSQL 17.5’s own regression suite, 20,391 statements, it reads 99.3% end to end; all 151 concessions are PREPARE, CALL, DO and three unbalanced-paren statements, and every one of them fails closed.

Deferring to OPA

A pii rule knows which entity classes a statement carries. Whether a statement carrying one may run depends on who is asking, and that belongs in one policy rather than scattered across YAML. Defer hands the second half to Rego.
1

The rule defers

config.yaml
2

The producer writes a finding

A rule that defers becomes a producer: it establishes a fact and writes it to the evaluation context, keyed by its source.Findings key by TYPE rather than by name, so several pii rules fold into one finding: values.rules is the union of the names that matched and every list value unions. pii adds values.entities, deny_words_list adds values.words, and a matched pattern’s TEXT never travels, because OPA’s decision log is a copy of everything sent to it.
3

The policy sees it in input.findings

phase, findings, effects and relations are additive. A single-call lane with no producers sends a byte-identical document to the one it sent before any of this existed.The Sidecar fills input.context from the session: principal, session_id and connection always, plus subject, email, groups, peer_addr, upstream and correlation_id where the identity carries them.
4

Rego rules on it

policy.rego
Four inputs, four verdicts, from opa eval against that file:

Test the status, never the value

Only ok and cached mean the producer answered. The other three carry no values, so a policy testing count(entities) == 0 reads “found nothing”, “never ran”, “budget spent” and “provider down” as one answer. Every status outside those two MUST fail closed.

The phase idiom

Write it in every policy. A single-call lane sends no phase field, so a policy testing input.phase == "decide" is undefined there, and fail_open: false turns undefined into a denial for every statement on that lane. The default makes one policy answer a gated lane, a deferring lane and a plain one.

The gate phase

A lane that defers calls its producers first and OPA second, so a statement Rego would have refused for free has already cost a model call. gate: true buys the cost control back by consulting OPA on both sides of the producers:
Both calls hit the same URL and carry input.phase, so a policy that ignores the field answers both identically and turning the gate on costs one round trip rather than a rewrite. The gate answers request beside its decision, keyed by source:
An UNDEFINED gate decision allows and requests nothing, even under fail_open: false. A gate is an optimization over a policy someone already wrote, so switching it on must not deny every statement until a second Rego rule exists. The decide phase keeps the normal fail-closed reading of undefined.
gate: true on a lane with no ai_analysis rule is refused at startup: the extra round trip would gate nothing.

Rule ordering and inheritance

First match wins, for DENIALS only. A deferring rule records its finding and evaluation continues, so one statement can report several findings and still be denied by a hard rule further down. Order decides which name and message a user reads, never whether the statement runs. Top-level rules concatenate with a listener’s, listener first. Every rule denies and evaluation is first-match-wins, so concatenating cannot change the outcome, and putting the listener’s rules in front lets a lane’s specific message beat a generic default for the same statement.
config.yaml
policy.opa and policy.enforce REPLACE instead of merging: two decision endpoints cannot become one, and a lane saying enforce: false means it. Reading the file will not tell you what a lane ended up with, because the merge happens at startup. Ask the running process, which reports rule names per resolved lane:

Recipes

Four complete lane configs. Each one validates with hoop start sidecar --validate --config config.yaml.
config.yaml
other earns its place: REFRESH MATERIALIZED VIEW lands there and writes.copy is the judgement call. COPY t FROM STDIN writes and COPY (SELECT …) TO STDOUT reads, and both classify as copy, so naming it costs your analysts their exports. Add it where nobody exports, and guard the writable tables with access: write rules where they do.

What startup refuses

Each of these would otherwise load, evaluate and do nothing: Check before you deploy. Nothing needs to be running:
The line reports the RESOLVED lane, so the count includes everything it inherited. The full refusal list is in Config File.

Next

Config File

Every field of every section, inheritance between lanes, and the full list of what startup refuses.

Risk Analysis

Cost controls, providers, prompts and what leaves the process before you enable ai_analysis.