Policy recipes
Blessed, copy-paste starter policies for the policy gate. Each is
safe-by-construction: it either uses a presence operator (is_true / exists), so it never
rage-blocks on a meta key your models don't carry, or — for the config axis
recipe below — a set operator whose missing path resolves to the empty set (present, not
UNKNOWN), so it fires only on models that actually declare the config it checks.
Start with policy init, not a blank file
policy init reads your manifest + catalog and writes a heavily-commented
./parrant.policy.yml keyed only to signals it confirmed exist — the structural
rules below arrive enabled, and every dbt-meta key you actually use arrives as a commented
template prefixed with its real coverage. This page is the reference for the recipes it emits
and a few more you can graft in. The generated file is yours — edit it freely.
Run policy test before you arm a block
Before you flip any rule from warn to block — or uncomment a meta-keyed template —
replay it over your recent history:
UNKNOWN rather than a proven match. A rule that blocks mostly via
fail-safe defaults is the rage-block footgun — catch it here, not in CI.
The footgun, in one paragraph
Under the default on_missing_meta: fail_closed, a value comparison (eq, in, gt,
matches, …) on a key a model doesn't declare resolves UNKNOWN, and a blocking rule then
fires on everything that lacks the key. A presence operator (is_true, is_false,
exists, absent) is total: it resolves FALSE on a missing key, so the rule simply doesn't
match. Every recipe here uses a presence operator. Reach for a value comparison only after
policy test proves your key coverage.
1. Block provable test breaks
The day-1 governance win, and the only tier objective enough to block on sight: a change that
removes or renames a column a dbt test targets will fail the next dbt build. This is a
proven structural fact (never UNKNOWN), so blocking on it can only ever fire on a real breakage.
version: 1
defaults:
on_missing_meta: fail_closed
on_error: fail_closed
rules:
- id: provable-break-block
scope: aggregate
predicate: { structural: { fact: provable_test_break } }
action: [{ type: block }]
2. Warn (and notify) when a change reaches an exposure
Flag any change that reaches a dbt exposure (a dashboard / downstream consumer) for human review —
without gating the merge. warn, not block: touches_exposure can be UNKNOWN on unresolved
reach, and a non-blocking rule never fires on UNKNOWN, so this never manufactures a spurious
warning.
- id: exposure-guard
predicate: { structural: { fact: touches_exposure } }
action:
- { type: warn }
- type: notify
channel: slack
target: "#analytics-eng"
message: "{change.model}.{change.column} reaches {reach.count} exposure(s) — please review."
3. Critical-mart review
Warn when a change reaches a model your team flagged critical. This assumes a boolean flag
meta: { critical: true } on the model, so it uses is_true:
- id: critical-mart-guard
predicate:
reach:
kind: model
where: { meta: { key: critical, op: is_true } }
action: [{ type: warn }]
is_true vs exists
is_true matches only a model where critical is present AND truthy. A model without the
key — or one that set critical: false — does not match. That is what you want for a
flag: exists would also match critical: false, wrongly reporting the change as reaching a
critical model. Use exists only for a key whose mere presence is the signal.
4. PII guard (the correctly-written one)
The rule most likely to be mis-written into a rage-block. A change to a column your team tagged
pii should get extra scrutiny. Tag PII columns with meta: { pii: true } and gate with the
presence operator is_true — never eq true:
- id: pii-change-guard
predicate: { meta: { key: pii, op: is_true } }
action:
- { type: warn }
- type: notify
channel: slack
target: "#data-governance"
message: "PII column {change.model}.{change.column} changed — governance review required."
Why is_true, and why policy test first
meta: { key: pii, op: eq, value: true } looks equivalent but is not: on the ~92% of
columns that never declared pii (see above), eq resolves UNKNOWN, and under
fail_closed a block action then fires on every untagged column — it rage-blocks every
PR. is_true resolves FALSE on those columns, so the rule targets only real PII. Even so,
run policy test --last 20 before promoting this to block, to confirm it fires only on the
columns you expect.
5. PII over-grant guard (the config axis)
The flagship offline governance rule: sensitive data must not be granted SELECT to a role
outside the allowlist — checked straight against dbt's own grants config, with no
manifest-patch bridge. It composes two axes: inferred_meta.pii
(PII declared once upstream and inherited down the lineage graph) and
config.grants.select (the roles the model grants SELECT to):
- id: pii-over-grant-guard
scope: change
predicate:
all:
- inferred_meta: { key: pii, op: is_true } # PII anywhere upstream, unless declassified
- config:
key: grants.select # roles the model grants SELECT to
op: not_subset_of
value: [pii_reader] # your sensitive-data allowlist
action:
- { type: warn }
- type: notify
channel: slack
target: "#data-governance"
message: "PII {change.model}.{change.column} is granted to a non-allowlisted role."
Why this is still safe-by-construction
not_subset_of is a set operator, and on the config axis a missing grants.select
path resolves to the empty set — present, not UNKNOWN. So [] not_subset_of [pii_reader]
is FALSE: a model that grants to nobody cannot over-expose and simply doesn't match. The rule
fires only on a model that actually grants SELECT to a role outside your allowlist — no
rage-block on ungranted models. (This is the one config-specific fail-safe: set-operator misses
are the empty set, scalar misses route to on_missing_meta. See the
config conditions table.)
Grant role names are surfaced raw
The engine lists grant roles exactly as dbt resolved them — env-suffix normalization
(e.g. pii_reader_prod → pii_reader) is a consumer concern. List the exact role names your
project grants (or every environment-suffixed variant) in the allowlist. As always, run
policy test --last 20 before promoting this to block. The shipped example
tests/resources/policies/pii_grants_allowlist.yml arms it as block once backtested.
Promoting warn → block
Every meta-keyed recipe here ships as warn. Promote it to block only once
policy test shows, over your real history, that it fires exclusively on proven matches and
never via a fail-safe UNKNOWN. That evidence — not intuition — is the bar for arming a gate.
See the policy gate guide and the glossary for the full
operator / fail-safe reference.