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

FieldTypeDescription
idstringUnique identifier within the tenant.
namestringHuman-readable display name.
enabledbooleanWhether the rule is active.
priorityinteger (1–100)Evaluation order. Lower number = higher priority. Priority 1 is "most important," 100 is "fallback."
modestring"all" for AND logic across conditions, "either" for OR logic.

Optional fields

FieldTypeDescription
descriptionstringFree-form explanation of the rule's intent.
conditionsobjectMatching criteria. Empty / omitted = match all queries.
actionsobjectWhat happens when the rule matches.
respectSqlHintsbooleanHonor inline SQL comment hints (default true).
invalidateRulesstring[]Rule IDs to invalidate when this DML rule fires.
requireInvalidationbooleanIf 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

OperatorDescriptionExample
includesTable appears in the query"includes": "ORDERS"
notIncludesTable does not appear"notIncludes": "TEMP_DATA"
includesAnyAny of these tables appear"includesAny": ["ORDERS","SALES"]
includesAllAll of these tables appear"includesAll": ["ORDERS","CUSTOMERS"]

Schema / catalog

OperatorDescriptionExample
equalsExact match"equals": "finance"
matchesRegex pattern"matches": "^prod_.*"
inOne of these values"in": ["finance","accounting"]

Statement type

Common values: SELECT, INSERT, UPDATE, DELETE, SHOW, DESCRIBE, WITH.

OperatorDescriptionExample
equalsExact statement type"equals": "SELECT"
inOne of these types"in": ["SELECT","SHOW"]
notInNot 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*;?$" }
  }
}
OperatorDescriptionExample
matchesRegex on the normalized statement text"matches": "\\bJOIN\\b"
containsSubstring search"contains": "WHERE customer_id"
startsWithBegins 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.

OperatorDescriptionExample
includesColumn appears"includes": "email"
includesAnyAny of these columns"includesAny": ["ssn","credit_card"]
includesAllAll of these columns"includesAll": ["first_name","last_name"]

User

OperatorDescriptionExample
equalsSpecific user"equals": "admin@company.com"
matchesEmail pattern"matches": ".*@contractors\\.com"
inList 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

OperatorDescriptionExample
inRole is one of these"in": ["ANALYST","BI_SERVICE"]
equalsExact 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"]
    }
  }
}
OperatorDescriptionExample
equalsAddress, or CIDR containing it"equals": "203.0.113.7"
inWithin any of these addresses/blocks"in": ["10.0.0.0/8"]
notInWithin 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"] }
  }
}
ConditionOperatorAccepts
timeOfDaybetweenTwo strict "HH:MM" 24-hour UTC strings
dayOfWeekinDay 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 }
    }
  }
}
OperatorDescriptionExample
existsHeader is (or is not) present{ "exists": true }
equalsExact header value{ "equals": "premium" }
matchesRegex on the value{ "matches": "Chrome" }
inValue is one of these{ "in": ["premium","enterprise"] }
containsSubstring, 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
  }
}
TypeFunctions detected
dateCURRENT_DATE, CURDATE(), TODAY()
timeCURRENT_TIMESTAMP, NOW(), GETDATE(), SYSDATE
userCURRENT_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 }
    }
  }
}
ConditionDescription
hasParametersBoolean — query has parameter placeholders
parameters.<name>.equalsExact parameter value
parameters.<name>.inValue in list
parameters.<name>.matchesRegex on value
parameters.<name>.existsParameter is bound
parameters.<name>.greaterThanNumeric comparison
parameters.<name>.lessThanNumeric 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 }
  }
}
TTLMeaning
0Don't cache — bypass
601 minute
36001 hour
8640024 hours
6048001 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" }
  }
}
StrategyExpires at
until-midnight-utcThe next UTC midnight.
until-midnight-tz:<IANA zone>The next local midnight in that zone. Daylight-saving aware.
next-5min · next-15min · next-hourThe 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.

FieldTypeDescription
enabledbooleanWhether SWR applies to this rule. Default false; omitting the block has the same effect.
windowSecondsinteger or nullHow 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.

ElementDescriptionAvailable on
standardizedSqlNormalized SQL textAll adapters — recommended baseline
userIdUser identifierAll adapters — per-user isolation (RLS)
catalogCatalog nameAll adapters, when the query names one
schemaSchema nameAll adapters, when the query names one
tablesTable namesAll adapters
userRoleUser's roleSnowflake only. Fails closed on Databricks and PostgreSQL
warehouseTarget warehouseSnowflake, and the Databricks SQL Statement API. Fails closed on Databricks JDBC/ODBC (Thrift) and PostgreSQL
statementOriginal SQL textNot implemented. Always fails closed — use standardizedSql
userGroupsUser's groupsNot implemented. Always fails closed
warehouseSizeWarehouse size/tierNot implemented. Always fails closed
columnsColumn namesNot implemented. Always fails closed
tenantIdTenant identifierDo 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"
    }
  }
}
FieldTypeDescription
enabledbooleanWhether the rule enforces. There is no audit-only mode — see below.
messagestringShown verbatim in the user's own tool. Printable ASCII, length-capped. Write it as something the reader can act on.
sqlStatestringSQLSTATE returned to the client. Defaults to 42501 (insufficient_privilege).
codestringAirbrx error code. Defaults to AIRBRX_STATEMENT_BLOCKED.
severitystringSeverity 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:

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