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.
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.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
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.
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:
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
SELECT 'DROP TABLE customers', and an operation rule does not.
pattern_match
pii
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
/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
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.
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
Apii 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
opa eval against that file:Test the status, never the value
Onlyok 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
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 withhoop start sidecar --validate --config config.yaml.
- Read-only replica
- One table, no writes
- Defer to Rego
- AI analysis, gated
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:
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.