Cache rule schema
Rules in Airbrx are JSON objects stored on a tenant's configuration. The App's rules workshop is the usual edit surface; this page is the underlying shape — for scripting bulk changes, importing rules from another tenant, or generating rules from an external system. For the conceptual model, start with Rules as the differentiator.
Top-level shape
{
"id": "cache_customer_data",
"name": "Cache customer queries for 1 hour",
"description": "Per-user cache on the CUSTOMER table",
"enabled": true,
"priority": 10,
"mode": "all",
"conditions": { /* what queries match */ },
"actions": { /* what happens on match */ },
"respectSqlHints": true,
"invalidateRules": [],
"requireInvalidation": false
}
Required fields
| Field | Type | Description |
|---|---|---|
id | string | Unique identifier within the tenant. |
name | string | Human-readable display name. |
enabled | boolean | Whether the rule is active. |
priority | integer (1–100) | Evaluation order. Lower number = higher priority. Priority 1 is "most important," 100 is "fallback." |
mode | string | "all" for AND logic across conditions, "either" for OR logic. |
Optional fields
| Field | Type | Description |
|---|---|---|
description | string | Free-form explanation of the rule's intent. |
conditions | object | Matching criteria. Empty / omitted = match all queries. |
actions | object | What happens when the rule matches. |
respectSqlHints | boolean | Honor inline SQL comment hints (default true). |
invalidateRules | string[] | Rule IDs to invalidate when this DML rule fires. |
requireInvalidation | boolean | If true, the DML statement fails when invalidation can't be recorded (default false). |
Conditions
Conditions match query metadata. The rule's mode controls how
condition types combine.
Within a single condition type, operators are first-match-wins, not
AND. The matcher checks the operators in a fixed order and returns on the
first one present, so a second operator on the same condition is silently ignored
— { "includes": "ORDERS", "notIncludes": "TEMP_DATA" } evaluates
includes and never looks at notIncludes. To require two
things, use two condition types or two rules. clientIp and
httpHeaders are the exceptions, and say so in their own sections.
Every matches operator compiles to a case-insensitive
regular expression, and it is unanchored — "matches": "prod_"
matches anywhere in the value, not just at the start. Anchor it yourself when you
mean the whole string.
Tables
| Operator | Description | Example |
|---|---|---|
includes | Table appears in the query | "includes": "ORDERS" |
notIncludes | Table does not appear | "notIncludes": "TEMP_DATA" |
includesAny | Any of these tables appear | "includesAny": ["ORDERS","SALES"] |
includesAll | All of these tables appear | "includesAll": ["ORDERS","CUSTOMERS"] |
Schema / catalog
| Operator | Description | Example |
|---|---|---|
equals | Exact match | "equals": "finance" |
matches | Regex pattern | "matches": "^prod_.*" |
in | One of these values | "in": ["finance","accounting"] |
Statement type
Common values: SELECT, INSERT, UPDATE, DELETE, SHOW, DESCRIBE, WITH.
| Operator | Description | Example |
|---|---|---|
equals | Exact statement type | "equals": "SELECT" |
in | One of these types | "in": ["SELECT","SHOW"] |
notIn | Not one of these types | "notIn": ["INSERT","UPDATE","DELETE"] |
SQL pattern
The condition key is standardizedSql, and it matches against the
normalized SQL, not the text the client sent. Normalization strips
-- and /* */ comments, collapses all whitespace to single
spaces, uppercases SQL keywords, and lowercases fully-qualified table names. Write
patterns against that shape — a regex anchored on lowercase select, or
one that expects a newline, will not match.
{
"conditions": {
"standardizedSql": { "matches": "^SELECT 1\\s*;?$" }
}
}
| Operator | Description | Example |
|---|---|---|
matches | Regex on the normalized statement text | "matches": "\\bJOIN\\b" |
contains | Substring search | "contains": "WHERE customer_id" |
startsWith | Begins with | "startsWith": "SELECT COUNT" |
Do not use statement as a condition key. The Gateway
accepts it without complaint, but nothing populates the raw statement text in the
evaluation context, so a statement condition never matches and the rule
that carries it silently never fires. Use standardizedSql.
Columns
Not currently evaluated. Column extraction is not wired into the
evaluation context on any adapter, so a columns condition never matches.
The operators below are the intended shape; scope by tables in the
meantime.
| Operator | Description | Example |
|---|---|---|
includes | Column appears | "includes": "email" |
includesAny | Any of these columns | "includesAny": ["ssn","credit_card"] |
includesAll | All of these columns | "includesAll": ["first_name","last_name"] |
User
| Operator | Description | Example |
|---|---|---|
equals | Specific user | "equals": "admin@company.com" |
matches | Email pattern | "matches": ".*@contractors\\.com" |
in | List of users | "in": ["user1","user2"] |
These operators are first-match-wins, not AND: equals wins if present,
then matches, then in. There is no notIn on
userId — express an exclusion with a matches regex
and a negative lookahead, e.g. "^(?!svc-etl@)".
User role
| Operator | Description | Example |
|---|---|---|
in | Role is one of these | "in": ["ANALYST","BI_SERVICE"] |
equals | Exact role | "equals": "ANALYST" |
Where the role comes from differs by adapter, and one of them will surprise
you. On Snowflake it is the session role. On the Databricks JSON path it is
the caller's user info, falling back to the literal string user when
none is supplied — so { "equals": "user" } there matches every
caller rather than a role anyone was granted. PostgreSQL and the Databricks Thrift
path supply no role at all, and a missing role fails closed, so on those a
userRole condition never matches and the rule carrying it never fires.
There is no notIn and no matches here; an unimplemented
operator falls through to “no match” rather than erroring, so a rule that
uses one looks enabled and does nothing.
Client IP
Matches the client address the Gateway resolved for the connection. Each value is a
bare IPv4/IPv6 address or a CIDR block — equals accepts a CIDR
too, so it means containment, not just an exact host. Entry lists are compiled into
sorted range sets at rule load, so a large allowlist costs a binary search per
request, not a re-parse.
{
"conditions": {
"clientIp": {
"in": ["10.0.0.0/8", "203.0.113.7"],
"notIn": ["10.5.0.0/16"]
}
}
}
| Operator | Description | Example |
|---|---|---|
equals | Address, or CIDR containing it | "equals": "203.0.113.7" |
in | Within any of these addresses/blocks | "in": ["10.0.0.0/8"] |
notIn | Within none of these | "notIn": ["10.5.0.0/16"] |
Unlike every other condition type, operators here combine with AND when several are present — an allowlist with a carve-out is one condition, not two rules.
An unresolvable client IP is always a mismatch. That is the right
polarity for an allowlist (equals/in): the grant is
withheld. It is the wrong polarity for a denylist — a deny rule written as
notIn alone will not fire when the IP cannot be resolved.
Write IP-based deny rules as an allowlist of permitted ranges plus a deny on
everything else, not as a bare notIn.
Time of day and day of week
Both read a single instant stamped once per request, and both are
UTC only. between is start-inclusive, end-exclusive,
and wraps midnight if the start is later than the end.
{
"conditions": {
"timeOfDay": { "between": ["13:00", "22:00"] },
"dayOfWeek": { "in": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"] }
}
}
| Condition | Operator | Accepts |
|---|---|---|
timeOfDay | between | Two strict "HH:MM" 24-hour UTC strings |
dayOfWeek | in | Day names (case-insensitive) or numbers 0–6, where 0 = Sunday |
The two are evaluated independently against the same instant — they are not
correlated. A window like ["22:00", "13:00"] runs past midnight, and
its post-midnight half evaluates as the next UTC day. Pair them with that
in mind.
A wrong-shaped value fails closed rather than matching everything. Writing
"timeOfDay": "13:00-22:00" instead of the between object
matches nothing, rather than silently turning a bounded window into an unbounded one.
HTTP headers
Matches request headers on the HTTP-based paths. Header names are matched case-insensitively. Every header named must satisfy its condition — they combine with AND.
{
"conditions": {
"httpHeaders": {
"x-tenant-tier": { "equals": "premium" },
"user-agent": { "matches": "Chrome" },
"x-batch-job": { "exists": false }
}
}
}
| Operator | Description | Example |
|---|---|---|
exists | Header is (or is not) present | { "exists": true } |
equals | Exact header value | { "equals": "premium" } |
matches | Regex on the value | { "matches": "Chrome" } |
in | Value is one of these | { "in": ["premium","enterprise"] } |
contains | Substring, case-insensitive | { "contains": "chrome" } |
A condition naming a header that isn't present is a mismatch, except for
"exists": false, which is how you match its absence deliberately.
The AND is across header names. The operators on any one header are first-match-wins, in the order listed above, like every other condition type.
Non-deterministic functions
Matches on whether the statement calls a function whose value depends on
when or who — the functions that quietly make a result
uncacheable. Detection runs after string literals are blanked, so a column value
that happens to spell 'now' is not mistaken for the call.
{
"conditions": {
"nonDeterministic": { "types": { "includesAny": ["time", "user"] } }
},
"actions": {
"deleteCache": true
}
}
| Type | Functions detected |
|---|---|
date | CURRENT_DATE, CURDATE(), TODAY() |
time | CURRENT_TIMESTAMP, NOW(), GETDATE(), SYSDATE |
user | CURRENT_USER, CURRENT_ROLE, SESSION_USER |
The types operand takes includes, includesAny,
or includesAll. The common use is the pairing above: let a
date-only query cache to the end of the UTC day, and send anything
touching time or user straight through.
Parameterized queries
Match queries with parameter placeholders — :name, ?, $1:
{
"conditions": {
"hasParameters": true,
"parameters": {
"customer_id": { "equals": "VIP123" },
"region": { "in": ["US","CA","MX"] },
"ssn": { "exists": true }
}
}
}
| Condition | Description |
|---|---|
hasParameters | Boolean — query has parameter placeholders |
parameters.<name>.equals | Exact parameter value |
parameters.<name>.in | Value in list |
parameters.<name>.matches | Regex on value |
parameters.<name>.exists | Parameter is bound |
parameters.<name>.greaterThan | Numeric comparison |
parameters.<name>.lessThan | Numeric comparison |
Parameter values are automatically included in cache keys when present —
you don't need to specify them in cacheKeyElements.
Modes
"mode": "all" (default) — every condition must match.
"mode": "either" — any condition matching is enough.
Actions
Exactly one cache rule's actions apply. Rules are sorted by
priority ascending, and evaluation stops at the first enabled rule whose
conditions match — its actions are the whole decision. Lower-precedence rules do not
contribute, override, or veto anything, deleteCache included. The one
exception is deny, which runs in its own pass over every rule (see
Deny below).
Cache TTL
{
"actions": {
"cache": { "ttlSeconds": 3600 }
}
}
| TTL | Meaning |
|---|---|
0 | Don't cache — bypass |
60 | 1 minute |
3600 | 1 hour |
86400 | 24 hours |
604800 | 1 week |
TTL strategies
Instead of a fixed duration, a cache rule can expire on a clock boundary.
Use ttlStrategy in place of ttlSeconds — the two are
mutually exclusive, and setting both is a load-time error.
{
"actions": {
"cache": { "ttlStrategy": "until-midnight-tz:America/New_York" }
}
}
| Strategy | Expires at |
|---|---|
until-midnight-utc | The next UTC midnight. |
until-midnight-tz:<IANA zone> | The next local midnight in that zone. Daylight-saving aware. |
next-5min · next-15min · next-hour | The next aligned UTC clock boundary. |
fixed:<seconds> | A literal duration — the escape hatch. |
This is what makes caching a CURRENT_DATE query safe: the entry expires
at the instant the function's own answer changes, rather than an arbitrary duration
after it was computed. The boundary is also shared — with
next-15min, queries at 10:41 and 10:44 both expire at 10:45 instead of
starting independent countdowns, so they converge on the same cached entry rather
than drifting apart.
Validation. Strategy strings are checked at rule load, including the IANA zone name — a typo fails loudly rather than silently falling back to a default. A query landing exactly on a boundary gets a full interval, never a zero TTL (which would quietly mean "don't cache").
Not combinable with SWR. Serving a stale result past a strategy's boundary defeats the point of the boundary, so the pair is rejected at load.
Stale-while-revalidate
Opt a cache rule into stale-while-revalidate by adding a
staleWhileRevalidate block on the cache action. When a cached entry is
past its TTL but within the SWR window, the Gateway returns the cached value
immediately and fires a background refresh against the warehouse. See
the cookbook recipe
for when to reach for it.
{
"actions": {
"cache": {
"ttlSeconds": 3600,
"staleWhileRevalidate": {
"enabled": true,
"windowSeconds": 86400
}
}
}
}
Deny conditions are allowlist-shaped, not exclusions.
userRole implements only in and equals, and
userId only equals, matches and
in — there is no notIn on either. A condition
carrying an operator the matcher does not implement falls through to “no
match,” so a rule written as "userRole": { "notIn": [...] } loads
cleanly, reports as enabled, and never fires. Name the identities you are
refusing, as above. To express an exclusion, use a userId regex with a
negative lookahead — { "matches": "^(?!svc-governance@)" } —
which is evaluated as a real pattern.
userRole also resolves unevenly across adapters, and a deny rule is the
worst place to discover that. PostgreSQL and the Databricks Thrift path supply no
role, and userRole fails closed on a missing one, so a role-scoped deny
rule never fires there. The Databricks JSON path does supply one, but defaults it to
the literal user — so the same rule refuses far more traffic than
its author described. Both directions are costly here: scope deny rules by
userId, clientIp or tables, and treat
userRole as a Snowflake-only refinement on top of one of those.
| Field | Type | Description |
|---|---|---|
enabled | boolean | Whether SWR applies to this rule. Default false; omitting the block has the same effect. |
windowSeconds | integer or null | How far past TTL the Gateway will serve a stale result before falling through to a synchronous miss. null means "serve stale until a refresh succeeds" — unbounded; rare. |
Validation. enabled: true with no windowSeconds
is a load-time error — tenants must explicitly choose a stale ceiling (including
null for forever). Negative or non-finite values are also rejected.
Invalidation precedence. An invalidation marker against a cache rule always suppresses its stale-cached response, even inside the SWR window. SWR bounds latency; invalidation bounds correctness, and correctness wins.
Cache key elements
What makes a cached entry unique. The Gateway hashes these inputs to compute the cache key.
{
"actions": {
"cacheKeyElements": ["userId", "standardizedSql"]
}
}
["userId", "standardizedSql"] is the safe baseline: both elements
resolve on every adapter. Anything else depends on what the connecting protocol
actually gives the Gateway — check the availability column before adding it.
| Element | Description | Available on |
|---|---|---|
standardizedSql | Normalized SQL text | All adapters — recommended baseline |
userId | User identifier | All adapters — per-user isolation (RLS) |
catalog | Catalog name | All adapters, when the query names one |
schema | Schema name | All adapters, when the query names one |
tables | Table names | All adapters |
userRole | User's role | Snowflake only. Fails closed on Databricks and PostgreSQL |
warehouse | Target warehouse | Snowflake, and the Databricks SQL Statement API. Fails closed on Databricks JDBC/ODBC (Thrift) and PostgreSQL |
statement | Original SQL text | Not implemented. Always fails closed — use standardizedSql |
userGroups | User's groups | Not implemented. Always fails closed |
warehouseSize | Warehouse size/tier | Not implemented. Always fails closed |
columns | Column names | Not implemented. Always fails closed |
tenantId | Tenant identifier | Do not list. Tenant isolation is structural — cache paths are already per-tenant |
Unresolvable elements fail closed
Every element a rule lists must resolve for this request. If any one of them
doesn't — an element the adapter never supplies (warehouse over
JDBC), one that isn't implemented at all (userGroups), or a typo
(userid, standardisedSql) — the Gateway refuses to
build a key and the request bypasses the cache entirely. It
does not fall back to a shorter key, because a coarser key would collide
across whatever the dropped element was there to isolate.
This is quiet by design and easy to misread as a broken rule. The rule still
matches, so X-Airbrx-Cache-Rule names it on the response — but
X-Airbrx-Cache-Status is BYPASS and there is no
X-Airbrx-Cache-Key header at all. The missing key header is the
tell. Gateway logs carry the reason and the offending element:
Cache key element could not be resolved -- failing closed (request will not be cached)
{ "element": "warehouse", "elements": ["userId","warehouse","standardizedSql"] }
A rule that matches a lot of traffic and caches none of it is almost always this, not a TTL or condition problem.
These are included automatically and don't need to be listed:
proxyVersion (cache invalidates on Gateway upgrades),
sessionState (session vars and catalog/schema context),
describeOnly (separates schema queries from data queries),
parameterValues (parameter values for parameterized queries).
Delete cache
{
"actions": {
"deleteCache": true
}
}
deleteCache: true forces a bypass for statements this rule matches:
no cache key, no TTL, straight to the warehouse. Any cache or
cacheKeyElements configured on the same rule is discarded, and a
__AIRBRX_CACHE__ hint cannot override it.
It obeys ordinary precedence — it is an action on one rule, not a global switch.
A deleteCache rule at priority 11 has no effect on a request that a
caching rule at priority 10 already matched. If you intend it as a fallthrough
(“serve cache inside the window, go live outside it”), the pair only
works when the windowed rule sits at a lower priority number than the
fallthrough and is enabled — disable the windowed rule and the
fallthrough starts applying to everything it matches.
Deny
A deny action turns a rule into a statement firewall: a matching
statement is refused at the Gateway, in the client's native protocol, before the
warehouse is contacted. See
Deny rules for the evaluation model and
the reasoning behind it.
{
"id": "block-pii-exports",
"name": "Block unapproved PII exports",
"priority": 10,
"conditions": {
"tables": { "includes": "CUSTOMER_PII" },
"userRole": { "in": ["ANALYST", "BI_SERVICE"] }
},
"actions": {
"deny": {
"enabled": true,
"message": "Exports from CUSTOMER_PII need an approved extract — ask #data-gov.",
"sqlState": "42501",
"code": "AIRBRX_STATEMENT_BLOCKED",
"severity": "critical"
}
}
}
| Field | Type | Description |
|---|---|---|
enabled | boolean | Whether the rule enforces. There is no audit-only mode — see below. |
message | string | Shown verbatim in the user's own tool. Printable ASCII, length-capped. Write it as something the reader can act on. |
sqlState | string | SQLSTATE returned to the client. Defaults to 42501 (insufficient_privilege). |
code | string | Airbrx error code. Defaults to AIRBRX_STATEMENT_BLOCKED. |
severity | string | Severity of the security event a denial emits. critical routes to alerting. |
Validation is strict, and a bad deny rule fails the whole rule set at load. A cache rule that degrades to "don't cache" costs money; a deny rule that degrades to "don't deny" disarms a control someone is relying on. Rejected:
- An empty
conditionsobject — it would deny every statement the tenant sends. - Any caching action,
invalidateRules, orrequireInvalidationon the same rule. A rule cannot both cache and deny. - A malformed SQLSTATE, code, or severity; an over-long or non-ASCII message.
auditOnly— explicitly rejected rather than ignored, so an author expecting log-but-allow never silently gets enforced blocking.
Evaluation differs from cache rules. Deny rules are not
first-match-wins: every enabled deny rule is evaluated on every statement, so a cache
rule with a higher precedence can never shadow one. Priority only decides whose
message the client sees. The denial is attached after SQL hints, so
__AIRBRX_CACHE__ cannot re-enable caching for a refused statement.
Unknown action keys are alarmed, not ignored. The implemented
actions are cache, cacheKeyElements, version,
deleteCache, and deny. Anything else logs a critical event
at rule load. There is no routing, masking, redaction, rate-limit, or callout action.
Invalidation rules (DML)
A rule that matches DML statements (INSERT/UPDATE/DELETE) can name cache rules to invalidate when it fires:
{
"id": "orders-dml",
"name": "Orders Table Changes",
"priority": 5,
"conditions": {
"statementType": { "in": ["INSERT","UPDATE","DELETE"] },
"tables": { "includes": "ORDERS" }
},
"actions": {},
"invalidateRules": ["cache-orders-aggregates","cache-orders-recent"]
}
Pair with "requireInvalidation": true on a cache rule to
refuse cache hits if recent invalidation activity can't be confirmed —
right default for compliance-critical caches.
Worked example
{
"id": "cache_customer_data",
"name": "Cache customer queries per user for 1 hour",
"description": "Aggregate reads on CUSTOMER, isolated per user (RLS).",
"enabled": true,
"priority": 10,
"mode": "all",
"conditions": {
"tables": { "includes": "CUSTOMER" },
"statementType": { "equals": "SELECT" }
},
"actions": {
"cache": { "ttlSeconds": 3600 },
"cacheKeyElements": ["userId", "standardizedSql"]
}
}
On Snowflake you can add "warehouse" to the key elements to isolate
results per warehouse. Don't add it on a Databricks JDBC/ODBC or PostgreSQL
tenant — it doesn't resolve there, and the rule would match every SELECT and
cache none of them.
See also
- Rules as the differentiator — the conceptual model for cache + invalidation rules.
- Markers API — fire invalidation markers from outside the SQL stream.
- API reference — the endpoints that PUT rules onto a tenant.