Skip to content

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

parrant policy init --manifest target/manifest.json --catalog target/catalog.json
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:

parrant policy test --last 20 --policy parrant.policy.yml
This shows what each rule would have done and, the headline column, how many firings were driven by a fail-safe 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_truenever 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_prodpii_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 warnblock

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.