Policy gate guide
The policy gate is a metadata-agnostic rule engine. You write rules — predicate → action —
over any dbt meta, the change kind, the semantic breaking signal, and the lineage reach; the
engine evaluates them against a pull request's changeset and returns a verdict:
block, warn, or allow, plus a selective build/test set and any notifications to route.
A block is always a block-until: it states
how it clears and lifts itself on the next push, so the gate is an exit path, not a wall.
Every term below is defined once in the Glossary — the predicate axes, actions, gate decisions, reach kinds/mechanisms, operators, and the fail-safe knobs, with the code enum ↔ UI ↔ docs alignment pinned.
The tool ships the engine — you ship the rules
No metadata key is privileged. critical, pii, readable_by, tier are example
consumer configs, not built-ins. The worked examples below are exactly that: real,
shipped example policies you copy and adapt to your own meta conventions.
Quick start
Scaffold the policy — don't start from a blank file
parrant policy init reads your manifest + catalog and writes a heavily-commented,
safe-by-construction ./parrant.policy.yml keyed only to signals it confirmed exist,
so it runs green on day one. Start there rather than authoring from scratch — see
policy init below and the
Policy recipes page for the blessed copy-paste rules it emits.
Author (or scaffold) a policy.yml, then gate on it:
parrant impact \
--manifest target/manifest.json --catalog target/catalog.json \
--base-manifest base/manifest.json --base-catalog base/catalog.json \
--policy policy.yml \
--fail-on policy
--policy policy.ymlresolves the rules and attaches apolicy_verdictto the report.--fail-on policymakes the check exit 1 when the verdict isblock(and onlyblock).
Resolution order (first found): the explicit --policy PATH, then a
./parrant.policy.yml in the working directory. No policy found → the engine is inert
and the tool falls back to the legacy safe/review/block verdict (fully backward compatible).
A broken policy fails loudly
A present-but-invalid policy (unknown version, malformed predicate) raises an error and
fails the run — it is never silently treated as "no policy". A governance gate must not
disappear because someone fat-fingered the YAML.
The rule model
A policy is a version header, optional defaults, and a list of rules. Each rule is a
predicate (when does it apply?) mapped to one or more actions (what happens when it does?).
version: 1 # schema version; the engine rejects unknown majors
defaults:
on_missing_meta: fail_closed # fail_closed | fail_open | skip (default fail_closed)
on_error: fail_closed # how operator/type mismatches resolve
on_meaning_changed: block # block | warn | allow — gate a proven meaning change (optional)
on_indeterminate: warn # block | warn | allow — gate an unprovable change (optional)
rules:
- id: my-rule # required, unique — appears in the verdict
description: What this rule protects.
scope: change # change (per changed column) | aggregate (once for the PR)
predicate: <predicate-tree>
action: <action-or-list>
on_missing_meta: fail_open # optional per-rule override of the default
Each rule is evaluated against every changed column (scope: change, the default), or once
against the whole changeset (scope: aggregate, for project-wide rules). The changed column a
rule fired on is recorded in the verdict, so the report can say which change tripped it.
Semantic-severity defaults (no rules needed)
on_meaning_changed and on_indeterminate are built-in knobs that gate the semantic axis
directly — no hand-written rule required. Each maps a column's classification straight to a gate
contribution:
| Knob | Fires on | Values |
|---|---|---|
on_meaning_changed |
a column the AST diff proved changed meaning (semantic: meaning_changed) |
block · warn · allow |
on_indeterminate |
a column that could not be proven safe (semantic: indeterminate) |
block · warn · allow |
They exist to be tuned independently: a proven-breaking change need not carry the same weight as could-not-prove-safe. The common shape is block the proven one, warn the unprovable one:
version: 1
defaults:
on_meaning_changed: block
on_indeterminate: warn
# no rules: — the gate is driven entirely by the two knobs
Both default to unset, so a policy that omits them behaves exactly as before. allow (or leaving
a knob unset) contributes nothing. Structural add/remove/type changes carry no semantic and are
untouched by these knobs — they're governed by provable breaks and your rules. A knob contribution
folds into the same most-severe-wins combination as
every rule (appearing in the verdict as builtin:on_meaning_changed / builtin:on_indeterminate),
so a rule can only ever raise the decision, never lower one.
Why not block meaning_changed by default?
The no-policy gate deliberately keeps block reserved for provable test breaks, because the
conservative canonicalizer can over-classify a redundant-paren or commutative-reorder rewrite as
meaning_changed. These knobs are the opt-in for teams that have decided a meaning change should
hard-block in their project.
Predicates
A predicate is a tree of leaf conditions combined with all (AND), any (OR), and not.
all/any take a list; not takes a single node. A bare leaf is itself a valid predicate.
predicate:
all: # AND — every child must hold
- meta: { key: pii, op: is_true }
- reach:
kind: model
where:
any: # OR — inside the reach's inner predicate
- meta: { key: audience, op: eq, value: public }
- meta: { key: readable_by, op: not_subset_of, value: [COMPLIANCE, PAYMENT_OPS] }
- not:
change: { field: kind, op: eq, value: added }
A leaf condition matches on exactly one of six axes:
change — facts about the changed column itself
| Field | Values |
|---|---|
kind |
added, removed, type_changed, logic_changed |
semantic |
equivalent, meaning_changed, indeterminate |
breaking |
boolean — true for anything not equivalent (folds in indeterminate/absent) |
model, column |
string match (eq / in / matches) |
meta — a dbt meta key on the changed model or column
meta: { key: pii, op: is_true }
meta: { key: governance.tier, op: eq, value: gold } # dotted path into nested meta
Keys are dotted paths resolved against the node's merged meta (dbt's config.meta over
top-level meta). Missing-key handling is governed by on_missing_meta.
inferred_meta — a meta key resolved by folding UPSTREAM lineage
Same shape as meta (key / op / value), but the value is inferred from the column's
lineage rather than read only from its own declared meta — so a classification declared
once (e.g. pii: true on a staging column) is inherited by every downstream column that
derives from it, without re-tagging each one.
inferred_meta: { key: pii, op: is_true } # true if PII anywhere upstream (unless declassified)
inferred_meta: { key: secret, op: is_true }
Resolution, per column:
- Own meta wins. If the column declares its own value for the key, that value is used
verbatim — this is the seed, the override, and the declassification point (a downstream
pii: falseon a hashed/masked column stops propagation). This is a column-level notion: only a column's own meta seeds or declassifiesinferred_meta.*— a model-level tag does not. (Seed your sources at column grain.) - Otherwise fold the upstream source columns, combining most-restrictively per a
key-specific strategy:
pii— orderedtrue > unknown > false: any upstreamtrue⇒true; else any unresolved upstream ⇒unknown; elsefalse.secret— boolean OR over upstream (any upstreamtrue⇒true); an ownfalsestill wins by rule 1.- Any other key falls back to the most-restrictive (
pii-style) fold.
- Otherwise — no own meta and no resolvable upstream (a root/source column that was never seeded, or a chain that never reaches a declared value) — the value is UNKNOWN.
An UNKNOWN inferred value is treated exactly like a missing plain meta key: it is routed
to on_missing_meta. This differs from meta.*, where is_true /
is_false on an absent key read as False: for inferred_meta.*, an unprovable classification is
UNKNOWN for every operator (including is_true), so "we could not prove this column is not
PII" is never silently read as passing. inferred_meta is evaluated at column grain, so in a
reach.where only a reach.kind: column object resolves; a reached model/exposure is UNKNOWN.
inferred_meta.* is purely additive — meta.* is unchanged and still reads only the node's own
declared meta.
Rolling inferred_meta.* out — advisory by default
Introduce inferred_meta.* rules with on_missing_meta: skip (advisory) — not to block.
Because an un-seeded column folds to UNKNOWN, a rule under skip simply drops for the columns
whose classification cannot yet be proven (reported in skipped_missing_meta) while still
firing on the ones it can prove. That lets a rule roll out across teams that have not finished
documenting their meta without failing their builds — it warns/advises where it has signal
and stays quiet where it doesn't.
Making an inferred_meta.pii is_true rule blocking is a deliberate opt-in, and it only behaves
sensibly once the sources are seeded at column grain. Without seeds nearly every column
folds to UNKNOWN, and under fail_closed a blocking rule fires on almost all of them — which is
the fail-safe direction, but not a useful gate. So: seed your pii / secret sources first,
run the rule advisory (skip) to see where the signal lands, and only then promote it to
blocking. UNKNOWN always resolves via the fail-safe knob — there is no separate "block
everything" mode.
config — a dbt node.config key on the changed model
Same shape as meta (key / op / value), but the value is resolved against the model's
resolved dbt config — grants, materialized, tags, enabled, schema … — rather than
user meta. Keys are dotted paths into that config, and values are surfaced raw (exactly as
dbt resolved them; the engine never normalizes). This makes any generic dbt config attribute
referenceable — most importantly config.grants.select, the roles a model grants SELECT to.
config: { key: grants.select, op: not_subset_of, value: [pii_reader] }
config: { key: materialized, op: eq, value: incremental } # scalar
config: { key: tags, op: intersects, value: [finance] } # list
config is a model-grained notion (dbt config is a model-level concept), so there is no
column-level fallback like meta has — the key resolves against the changed column's model
config. It is purely additive: meta.* and inferred_meta.* are unchanged.
Missing-path semantics — set vs scalar (the load-bearing rule):
| Operator class | A missing dotted path resolves to… | Why |
|---|---|---|
set (subset_of, not_subset_of, intersects, superset_of) |
the empty set [] — present, not unknown |
"no grants.select declared = the empty reader set". So config.grants.select not_subset_of [allowlist] on a model with no grants is [] ⊄ X == FALSE and does not fire — a model that grants to nobody cannot over-expose. This is the generic, correct default. |
scalar (eq, ne, matches, numeric …) |
UNKNOWN → on_missing_meta |
exactly like a missing plain meta key. The presence/boolean operators (exists / absent / is_true / is_false) stay total, also like meta. |
A key present but explicitly null (e.g. grants.select: null) is treated like absent for
set operators — it collapses to the empty set (null readers = no readers = not exposed), staying
consistent with the [] case rather than fail-closed-blocking. For scalar operators a null value
is left as-is (evaluated as None by the operator). A value present but of the wrong shape for
the operator — a scalar in a set slot, e.g. grants.select: "pii_reader" (a bare string) fed to
not_subset_of — is a genuine evaluation error (UNKNOWN_ERROR) routed to
on_error, so under the default fail_closed a blocking rule fires
(fails safe, never a silent pass).
This differs deliberately from meta.*, where a set operator on a missing key resolves to
UNKNOWN. For config a set-operator miss is a proven empty set — the right default for the
"grants must not exceed an allowlist" gate, so the rule fires only on models that actually grant to
a role outside it, never on models with no grants at all.
config is usable in a reach.where too: it resolves against the reached model's config (a reached
column resolves against its model; a reached exposure has no dbt config → present=False).
reach — a quantified condition over the change's downstream reach
"Does this change reach a downstream object whose own meta satisfies an inner predicate?"
reach:
kind: model # model | column | exposure — what to scan
mechanism: [derived_recompute, rowset_filter] # optional: restrict by how it propagates
where: # inner predicate, evaluated against each reached object's meta
meta: { key: critical, op: is_true }
min_count: 1 # require at least N matches (default 1)
reach.kindpicks the downstream object type. BI dashboards surface askind: exposure(Metabase is the first supported connector) — see the cross-boundary guide.reach.mechanismfilters by how the change propagates, using the tool's existing taxonomy:derived_recompute(the value is recomputed),rowset_filter(used in a filter/join),renamed_passthrough,direct_passthrough. This is the "recompute vs pass-through" distinction that powers selective rebuilds.reach.wherematches on the reached object'smeta.*(and, for exposures, itstype/owner/name).
structural — booleans the pipeline already computes
| Fact | True when… |
|---|---|
provable_test_break |
the change removes/renames a column a dbt test still targets |
touches_exposure |
the change reaches ≥1 exposure |
reaches_anything |
the change reaches ≥1 downstream node |
Operators
meta and change string conditions take an op:
| Operator | Meaning |
|---|---|
exists / absent |
key present / absent |
is_true / is_false |
truthy / falsy |
eq / ne |
scalar equality |
in / not_in |
scalar ∈ / ∉ a list |
matches |
regex full-match (strings) |
intersects |
list shares ≥1 element with the given list |
subset_of / not_subset_of / superset_of |
list containment |
gt / ge / lt / le |
numeric comparison |
Actions
A rule emits one or more actions:
| Action | Effect on the verdict |
|---|---|
block |
contributes block to the gate (most-severe-wins). |
warn |
contributes warn — advisory; never causes a non-zero exit. |
add-to-build-set |
adds the reached (or subject) models to the selective build set. |
add-to-test-set |
adds the reached models (or their tests) to the selective test set. |
notify |
appends a notification intent (channel, target, message) for your CI to route. |
Actions can carry parameters:
action:
- type: add-to-build-set
include: reached # reached | subject | both
mechanism: [derived_recompute] # optional: only nodes reached this way
- type: notify
channel: slack
target: "#data-governance"
message: "PII {change.model}.{change.column} reaches a non-allowlisted reader"
message supports a small, safe interpolation vocabulary — {change.model}, {change.column},
{reach.count}, {rule.id} — no arbitrary code.
How multiple rules combine into one verdict
- Gate decision: most-severe-wins.
block > warn > allow. Anyblock→ the verdict blocks. - Build/test sets: union. Every fired
add-to-build-set/add-to-test-setcontributes; the engine dedups across rules. - Notifications: accumulate (deduped by
channel/target/message). - No short-circuit. All rules evaluate (so the build/test sets are complete), and there is
no priority/override in v1 — a
warncan never cancel ablock.
Worked examples (the shipped example policies)
These live in the repo under tests/resources/policies/ and are the canonical starting points.
1. PII must not reach a reader outside the allowlist
The offline analogue of a PII-exposure gate, expressed as pure config: a change to a PII-tagged column that reaches a downstream model whose declared readers are not a subset of the allowlist blocks the PR and notifies governance.
version: 1
rules:
- id: pii-outside-allowlist
description: >
A change to a PII-tagged column that reaches a consumer readable by a role
outside the compliance allowlist must block (offline PII-exposure check).
scope: change
predicate:
all:
- meta: { key: pii, op: is_true } # subject column is PII
- reach:
kind: model # scan reached downstream models
where:
meta:
key: readable_by # roles the mart exposes to
op: not_subset_of
value: [COMPLIANCE, PAYMENT_OPS]
action:
- type: block
- type: notify
channel: slack
target: "#data-governance"
message: "PII {change.model}.{change.column} reaches a non-allowlisted reader"
Note the fail-safe subtlety: because the reach uses a value operator (not_subset_of) on
readable_by, a mart that forgot to declare its readers resolves to unknown → risk present
under fail_closed, so it blocks — a mart with no declared readers is treated as if it
exposes to everyone. (See fail-safe defaults.)
2. A breaking change reaching a critical mart
Expresses critical:true gating — and it is breaking-aware: a proven-equivalent refactor
that reaches a critical mart does not block. When it does block, it also schedules a selective
rebuild of only the descendants that actually recompute.
version: 1
rules:
- id: breaking-reaches-critical
description: A breaking change that reaches a critical mart must block.
scope: change
predicate:
all:
- change: { field: breaking, op: is_true } # semantic != equivalent (fail-safe)
- reach:
kind: model
where:
meta: { key: critical, op: is_true }
action:
- type: block
- type: add-to-build-set
include: reached
mechanism: [derived_recompute, rowset_filter] # rebuild only what recomputes
3. Reproduce the legacy --fail-on tests gate
The provable-break signal is a built-in structural fact, not a hardcoded verdict. The old
--fail-on tests behaviour is one rule:
version: 1
rules:
- id: provable-break-blocks
description: A change that provably breaks a dbt test must block (legacy --fail-on tests).
scope: change
predicate:
structural: { fact: provable_test_break }
action:
- type: block
4. PII must not be granted to a role outside the allowlist
The config axis makes "sensitive data must not be granted to a role outside
an allowlist" expressible directly — reading dbt's own grants config, with no manifest-patch
bridge. It composes the inferred_meta axis (PII inherited over
lineage) with config.grants.select (the roles the model grants SELECT to):
version: 1
rules:
- id: pii-not-over-granted
description: >
A change to a PII column (inferred over lineage) on a model that grants SELECT to a
role outside the compliance allowlist must block (offline PII-exposure check on grants).
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]
action:
- type: block
- type: notify
channel: slack
target: "#data-governance"
message: "PII {change.model}.{change.column} is granted to a non-allowlisted role"
Sensitive data may only be granted to pii_reader: a model that grants select to reporter /
analyst trips not_subset_of and blocks; one that grants only to pii_reader passes. Because
not_subset_of is a set operator, a PII model with no grants declared resolves to the
empty set → FALSE and does not fire — only a model that actually grants
SELECT to a role outside the allowlist blocks. (Env-suffix normalization — e.g. pii_reader_prod
→ pii_reader — is a consumer concern: the engine surfaces grant roles raw, so list the exact
role names your project grants.) Ships as tests/resources/policies/pii_grants_allowlist.yml.
For the warn-first, policy test-before-you-arm version, see the
PII over-grant guard recipe.
Fail-safe defaults
The engine is fail-closed by default: an undecidable input biases toward blocking so a governance gate never silently passes a risky change.
| Situation | fail_closed (default) |
fail_open |
skip |
|---|---|---|---|
| Missing meta key on the node a rule inspects | for a blocking rule, resolves toward "unknown = risk = fire" | resolves False (rule can't fire on absence) |
the rule is skipped for that subject |
Operator/type mismatch (e.g. subset_of on a scalar) |
governed by on_error — True for blocking rules, False otherwise |
— | — |
| Unresolved reach (a removed column with no base catalog) | reach resolves True (can't prove it doesn't reach) |
reach resolves False |
— |
The asymmetry is deliberate: blocking rules bias toward firing; non-blocking rules bias toward staying silent — the safety mechanism over-blocks but never manufactures spurious warnings.
See what fail-safe would actually do before you arm it — policy test
Fail-safe semantics are impossible to verify by reading the YAML: the same naive
meta.pii is_true guard can block every changed column under fail_closed and silently
allow everything under skip. policy test
replays a candidate policy over your git history and reports, per rule, how many firings were
driven by a fail-safe UNKNOWN rather than a proven match — so you never arm a rage-blocking or
silently-dead rule across a large repo.
Gate on change.breaking, not change.semantic == …
change.semantic is always a present value, so change.semantic eq meaning_changed
resolves to a plain False for an indeterminate/absent semantic — no fail-closed bias.
A consumer who wants "treat anything possibly-breaking as breaking" must gate on
change.breaking is_true, which folds indeterminate and absent into breaking. This is
the single most important fail-safe rule to internalize.
Absent-boolean is by design, not a hole
is_true / exists on a missing key resolves False — a model that forgot critical:true
is not blocked. A metadata gate can't police metadata it wasn't given; that "miss = blind"
risk is one the consumer accepts. To get fail-closed-on-missing instead, use a value
operator on the reached object (like readable_by not_subset_of … in example 1) so a missing
key becomes unknown → risk → block. Opposite behaviours from one engine, purely by operator
choice.
Isolating a subset of exposures (the fail_open + guard pattern)
To write a rule that fires only on a specific class of reached exposure — e.g. only Metabase
dashboards, not dbt-native exposures — combine on_missing_meta: fail_open with a meta guard
in the reach where. Under fail_closed, dbt-native exposures (which lack the guard meta) would
be treated as risk and block regardless; fail_open lets the guard do its job. See the
cross-boundary guide for the worked example.
Scaffold a starter policy — policy init
Authoring three-valued fail-safe YAML from a blank file is the adoption cliff, and a naive starter
makes it worse (a value-comparison block rule on a meta key most of your models lack
rage-blocks every PR under fail_closed). policy init removes the blank
page: it introspects your manifest + catalog and writes a heavily-commented,
safe-by-construction starter policy keyed only to signals the scan confirmed exist.
parrant policy init \
--manifest target/manifest.json --catalog target/catalog.json
# → wrote ./parrant.policy.yml
The default output path is ./parrant.policy.yml — exactly the path
impact and policy test auto-resolve, so the generated file is picked up with no
--policy flag. The file is yours — it is code scaffolded into your repo, not a tool-managed
preset that can change under you. Edit it freely.
What it emits
| Section | Behaviour |
|---|---|
| Header | Ownership note, a pointer to run policy test before arming anything, and the two footguns (presence vs. value operators; the fail-safe glossary) stated plainly. |
defaults |
on_missing_meta: fail_closed, on_error: fail_closed — the safe closed posture, emitted with the explanatory comment. |
provable-break-block |
Emitted enabled iff the scan found column-targeted dbt tests. This is a pure structural fact (never UNKNOWN), so it is safe to block on day one — it can only fire on a real breakage. Emitted commented, with the reason, when no tests are found. |
exposure-guard |
Emitted enabled (warn) iff the scan found exposures. Emitted commented otherwise. |
| Meta templates | For every dbt-meta key the scan actually found, a commented warn template using the presence operator is_true, prefixed with the key's real coverage (present on N/total models (P%)). Never enabled, never a block, never a value comparison — so uncommenting one can never rage-block. |
| Footer | Next steps: run policy test, uncomment a template once its coverage is useful, and promote warn → block only after the backtest proves it fires on real matches. |
The enabled rules are tool-owned structural signals only — facts the pipeline computes that can
never silently no-op. Meta-keyed rules always ship commented, because the tool never guesses your
governance intent. If the manifest has neither tests nor exposures, policy init emits a valid file
with rules: [] and an honest note that nothing could be safely auto-enabled — an honest empty
policy, never a fake guard.
The scaffold never emits a fail-open default
policy init only ever writes the safe fail_closed posture — a gate that opens whenever it is
unsure is not a gate. The permissive fail-safe mode is described in the header glossary but is
deliberately never written as a copy-pasteable value.
Options
| Option | Default | Purpose |
|---|---|---|
--manifest <path> |
target/manifest.json |
the dbt manifest to scan (run dbt first). |
--catalog <path> |
target/catalog.json |
the dbt catalog to scan. |
--adapter <dialect> |
auto-detected | override the sqlglot dialect (e.g. tsql, snowflake, bigquery). |
-o, --output <path> |
./parrant.policy.yml |
where to write the generated policy (the path load_policy auto-resolves). |
--force |
off | overwrite an existing file at --output instead of refusing. |
--stdout |
off | print the generated policy to stdout instead of writing a file (never touches disk). |
policy init refuses to overwrite an existing policy without --force, so a hand-edited file is
never silently clobbered; --stdout prints without touching the filesystem at all.
The generated file arrives green — the enabled rules are safe-by-construction — but the meta
templates are yours to arm. Once you have it, replay it with
policy test and reach for the blessed
Policy recipes when you want to graft in a rule the scan couldn't auto-enable.
Backtest a policy before you arm it — policy test
A policy's fail-safe behaviour is unverifiable by inspection (see the tip above),
and a rule that never fires is a silent no-op. policy test makes both visible offline: it
replays a candidate policy over your recent history and reports, per rule, what the gate would
have ruled — before you make it a required check.
parrant policy test \
--manifest target/manifest.json --catalog target/catalog.json \
--policy policy.yml \
--last 30 # replay the last 30 commits
It is deterministic, offline, and sequential — no dbt run, no warehouse, no credentials. The
head registry is built once (the one meaningful cost) and reused for every point, so replaying
30–50 commits is cheap; per-point progress (replaying i/N <ref>) streams to stderr so a long run
is observably alive.
policy test is a subcommand, not a flag
parrant policy test … is dispatched on the leading policy word. It is entirely
separate from the --policy option on parrant impact (which gates a single
live PR) — the two never shadow each other.
Choosing what to replay
Supply exactly one change source:
| Source | Meaning |
|---|---|
--git-range <base>..<head> |
replay each commit in the range as one changeset (default head = HEAD). On squash-merge repos one commit ≈ one PR. |
--last <N> |
sugar for --git-range HEAD~N..HEAD — the last N commits. |
--changesets <dir> |
replay a directory of saved changeset JSONs (fixtures / a CI corpus). |
Other options:
| Option | Default | Purpose |
|---|---|---|
--policy <path> |
(required) | the candidate policy to backtest. A present-but-invalid file fails loudly — never treated as "no policy". |
--manifest / --catalog |
target/manifest.json / target/catalog.json |
the head artifacts — the registry replayed against every point. |
--repo-dir <dir> |
current directory | the git repo for --git-range / --last. Independent of the manifest/catalog paths (which may live under target/). |
--adapter <dialect> |
auto-detected | override the sqlglot dialect. |
--format table\|json\|markdown |
table |
table for terminals; json / markdown for a CI artifact or an agent over MCP. |
--fail-on none\|any-block\|regression |
none |
exit-code gate — see below. |
--baseline <path> |
— | a saved prior backtest JSON to diff against (required by --fail-on regression). |
The report — the trust instrument
The per-rule aggregate is the artifact you arm the gate on. Every table / markdown run leads
with a coverage-honest totals line — N PR(s) replayed (E evaluated, K skipped) — B would BLOCK,
W would WARN; avg blast radius … — so a low-coverage run is never mistaken for a clean pass, then
one row per rule:
| rule_id | would-BLOCK PRs | would-WARN PRs | fired-total | fail-safe UNKNOWN | flag |
|---|---|---|---|---|---|
| provable-break-block | 2 | – | 2 | 0 | |
| exposure-guard | – | 14 | 1315 | 0 | |
| pii-guard (naive) | 18 | – | 3169 | 3169 | FAIL-SAFE ONLY (never a proven match) |
| tier-guard | – | – | 0 | 0 | DEAD (never fired) |
Two columns carry the whole point:
- fail-safe UNKNOWN — firings that resolved via a fail-safe
UNKNOWN(a blocking rule underfail_closedfiring on an undecidable predicate) rather than a provenTRUEmatch. A rule that blocks mostly via fail-safe defaults is the rage-block footgun: it looks armed but is really just blocking everything it can't decide. The flag column calls it out —FAIL-SAFE ONLY (never a proven match)when every firing was fail-safe, orpartly fail-safewhen some were. - DEAD (never fired) — a guard that never fired across the whole range is a silent no-op. This
is usually the honest signal that the
metakey it inspects isn't populated on your models ("yourpii-guardmatched 0 subjects across your last 50 PRs" → go tag your columns).
--format json emits the full typed BacktestReport: the totals, the rule_stats above, and a
per-point drill-down (points[]) carrying each commit's decision, blast radius, the rules it
fired, a sample_reach, plus unmapped_changes and any per-point parse_failures — coverage
honesty, never hidden.
What a git-diff backtest does not prove (the fidelity note)
Every run prints a fidelity note, and it is deliberately blunt. In the default git-diff
mode the tool diffs whole .sql files, so every change is classified indeterminate by
construction and the head registry is HEAD's replayed against older diffs (models added or
renamed since a commit surface as unmapped_changes). Rules keyed on reach / meta /
change.kind / on_indeterminate fire normally and can block. What this version does
not exercise: the provable-break block tier and the semantic meaning-change block
tier — both need a per-commit before-state. The note says so plainly; per-commit base-manifest
validation of those tiers ships in a later release. In --changesets mode the note is
branched too: the provable-break tier is still unexercised, and a meaning_changed block fires
only if a saved changeset already carried that classification.
Gating a policy-change PR in CI
Because the backtest itself has an exit code, policy test can gate the PR that changes the
policy — a policy that would newly block N historical PRs is a regression worth a human look.
--fail-on |
Exit 1 when… |
|---|---|
none (default) |
never — report only. |
any-block |
any replayed PR would block under the candidate policy. |
regression |
any rule's would-BLOCK count rose versus --baseline. |
--fail-on regression requires --baseline; running it without one exits 1 with a clear
message rather than silently passing (a regression gate with no baseline validates nothing), and a
malformed --baseline file is rejected loudly. Capture a baseline by saving a --format json run,
then compare later runs against it:
parrant policy test --policy policy.yml --last 50 --format json > baseline.json
# … on the policy-change PR:
parrant policy test --policy policy.yml --last 50 \
--fail-on regression --baseline baseline.json
Reading the verdict
A block is a block-until, not a dead end
The point of the gate is action-driven awareness, so a block answers not just "you may not
merge" but "blocked until when?". Every block is a block-until: the gate is stateless
and re-runs on every push, so a block clears itself the moment the change stops tripping
the rule that fired it — no manual override, no ticket, no re-approval. The Markdown verdict says
exactly this, so the person hitting it sees the exit path.
| Blocked until… | How it clears | Cost |
|---|---|---|
| the change is no longer breaking | revert it, or make it a proven-equivalent refactor → the change.breaking predicate goes false on the next push |
free (self-clearing) |
| it no longer reaches the flagged object | narrow the change, or the reached model/dashboard is retired/re-pointed → the reach predicate goes false |
free (self-clearing) |
| the downstream / schema absorbs it | evolve the consumer (add the column, widen the type, update the test) → the structural/reach predicate goes false |
free (self-clearing) |
All three are self-clearing — they need no new tool feature, because the predicate simply evaluates to false on the next run. That is the whole release model today.
The audited release path: an in-code override
Beyond self-clearing, there is one supported way to acknowledge a fired verdict without
changing the code that tripped it: an override pragma
(-- lineage:allow-change / -- lineage:allow-break). It lives in the PR's own SQL, so it
stays offline / zero-credential and is diffed and reviewed like any code — and it only ever
lowers severity (it is a post-evaluation cap, not a rule; a warn action still can never
cancel a block action inside the engine — see
how rules combine).
Two external-input release paths remain deliberately unbuilt: block-until-acknowledged
(an owner signs off via a PR label the gate honors) and block-until-proven (a /data-diff
result). Both would require the gate to consume a new external input, cutting against the
offline / zero-credential guarantee — noted as possible future work, not available today.
Exit code
| Invocation | Exit 0 | Exit 1 |
|---|---|---|
--fail-on policy |
verdict is warn or allow |
verdict is block |
any other --fail-on value |
policy runs but doesn't gate | (the chosen gate decides) |
Only block fails the check under --fail-on policy; warn and allow never do. If you pass
--fail-on policy with no resolvable policy, the tool warns that the gate can never fire.
JSON (--format json)
When a policy resolved, the report gains a policy_verdict block — the flagship machine-facing
surface:
{
"policy_verdict": {
"decision": "block",
"hits": [
{ "rule_id": "pii-outside-allowlist", "decision": "block",
"change_model": "dim_account_holders", "change_column": "email",
"matched_reach": ["account_holders"], "actions": ["block", "notify"],
"fired_on_unknown": false, "unknown_cause": null }
],
"build_set": ["dim_accounts"],
"test_set": ["fact_revenue"],
"notifications": [
{ "channel": "slack", "target": "#data-governance", "message": "PII …" }
],
"evaluated_rules": 4,
"fired_rules": 2,
"unresolved_reach_count": 0,
"skipped_missing_meta": 0
}
}
Each hit carries fired_on_unknown (with unknown_cause — missing / error): true means the
rule fired because a fail-safe knob resolved an UNKNOWN, not because it
proved a match. The verdict-level unresolved_reach_count / skipped_missing_meta count what the
policy left undecided. Both are surfaced in the PR comment (below) so a fail-safe block never
reads as a proven one.
Markdown / PR comment
When a policy is present, the report adds a Policy verdict section — a "Why this verdict" breakdown of the rules that fired, grouped by decision (block first). Each row names the subject change, a capped sample of the reach it matched, and — the load-bearing bit — an honesty marker:
✓ proven match— the rule proved its predicate against real data.⚠️ **fired on a fail-safe default** (meta missing / evaluation error)— the rule fired only because a fail-safe knob resolved an UNKNOWN. A fail-safe block reads very differently from a proven one, on purpose: a confident-looking ruling that was actually undecided is exactly the trust this gate is sold on.
When an override capped a hit, the row shows the
original → effective (overridden: reason) delta so the audit trail is visible. Below the rules
come the selective build set / test set and the notify intents.
Whenever the policy left anything undecided, a LOUD Coverage footer states it plainly —
Coverage: N columns undecided (missing meta), M reaches unresolved — these did NOT count as
safe. — so a fail-closed block driven by unknowns is never folded into a clean pass.
The section deliberately references node names only; it never re-lists the downstream blast radius
(the impact section above it owns that). A block verdict leads with a one-line "blocked until…"
note stating how the block clears (see above), so the PR
comment carries the release path, not just the obstacle.
The default gate (no policy) explains itself too: each flagged column carries the compact
semantic reason it was flagged, alongside the provable-break diagnostics. impact --explain
expands the human comment with the base → head expression trace; the JSON always carries the
full explanation regardless of the flag.
Overriding a verdict — the in-code escape hatch
A gate with no escape hatch gets turned off, not tuned. The moment the verdict flags a change the
author knows is fine ("yes, this recomputes downstream — it's intended, and downstream was updated
in the same PR"), the tempting move is to disarm the whole gate (--fail-on none). An override
pragma is the alternative: an inline SQL comment in the head model that acknowledges one specific
change, with a mandatory reason. It is explicit, in the diff, reviewed like any other code, and
logged — never a silent global off-switch.
select
-- lineage:allow-change reason="renamed from amount_cents; all downstream refs updated in this PR"
amount as amount_eur,
...
Two verbs, deliberately distinct so a soft acknowledgement can never silence a hard break:
| Pragma | What it downgrades | Can it touch a provable break? |
|---|---|---|
-- lineage:allow-change [column=<col>] reason="…" |
a REVIEW / WARN contribution for that column to allow (the common case). | No — never. |
-- lineage:allow-break [column=<col>] reason="…" |
a provable BLOCK — the only verb that can — down to REVIEW (no-policy gate) or WARN (policy gate). | Yes, and only that far — never to safe. A provably-broken test always leaves a visible mark + audit record. |
reason= is mandatory: an override with no non-empty reason is dropped and warned (an override
that isn't justified has no audit value).
Scope resolution
Which changed column a pragma excuses, in priority order:
- an explicit
column=<name>argument, else - the column whose SELECT expression the comment is attached to / immediately precedes, else
- model scope — a pragma placed above the model's first statement with no
column=arg excuses every changed column in that model (logged as a model-level override, so the broader reach is visible).
Pragmas are head-only by design: the override lives in the PR's own SQL, so it is diffable and reviewed like any code. Source lines in the report are relative to the compiled SQL.
Fail-safe invariants (non-negotiable — this is a gate)
- An override only ever lowers severity, never raises it.
allow-changecannot touch a provable BLOCK — onlyallow-breakcan, and only BLOCK→REVIEW/WARN, never →safe.- On the same column,
allow-breakwins overallow-change(hard beats soft). - A malformed / reasonless / unknown-verb pragma is dropped with a loud warning and never changes the ruling silently.
- On a rename, adjacency resolves to the new column name, so a break stays armed unless you
name the old column explicitly (
column=<old_name>) or use model scope. Overriding the wrong side is a no-op, not an accidental unblock.
It moves --fail-on tests (the whole point)
An allow-break that acknowledges a provable break excuses that break from the gate: the
excused break drops out of provable_breaks / provable_break_count, so --fail-on tests (and
--fail-on policy on a provable-break rule) clears — exit 0 — while the acknowledgement stays
visible in the report. allow-change never excuses a break. Run with
--no-overrides to see the raw gate the override cleared.
Every honored override is logged
Nothing is ever silently suppressed. A honored override is surfaced on every surface:
-
JSON — the impact report gains four blocks:
Block Contents overrideshonored overrides: {model, column, verb, reason, downgraded_from, downgraded_to, source_line, scope}ineffective_overridespragmas that landed on a real changed column but changed nothing, with a fix hint(e.g.allow-breakon an added column)stale_overridespragmas with no matching change at all — dead excuses to prune override_warningsdropped malformed / reasonless / unknown-verb pragmas (strings) Under the policy engine, a capped
RuleHitalso carriesoverridden: true,original_decision, andoverride_reason, so the verdict says exactly which rule the override suppressed. -
Markdown / PR comment — an override section renders, in order: ⚠️ Override pragmas IGNORED first and unfolded (a dropped pragma must be noticed), then ⚠️ Overrides applied (each with its
from → todelta and reason), ⚠️ Overrides with no effect (ineffective, with the hint), and a folded Stale overrides list to prune. A capped policy hit reads "override suppressed WARN (reason: …)" inline. -
Action — the composite action emits an
overrides_appliedoutput (count of honored overrides). See In CI.
Auditing override reliance
impact --no-overrides evaluates the changeset as if no pragma were present — the raw gate.
Use it to answer "what would the gate say without the excuses?" and to measure override reliance
over history (a rising allow-break volume is a signal the gate is mis-tuned, not merely escaped).
With no pragma present, the output is byte-identical to the default run.
parrant impact \
--manifest target/manifest.json --catalog target/catalog.json \
--base-manifest base/manifest.json --base-catalog base/catalog.json \
--fail-on tests --no-overrides # ignore every -- lineage:allow-* pragma
In CI (GitHub Action)
The composite action exposes a policy input and policy outputs:
- uses: Fszta/parrant@v0
with:
manifest: artifacts/head/manifest.json
catalog: artifacts/head/catalog.json
base-manifest: artifacts/base/manifest.json
base-catalog: artifacts/base/catalog.json
policy: policy.yml
fail-on: policy # block the PR when the verdict is block
Outputs for downstream steps: policy_decision (block/warn/allow), build_set_size,
test_set_size — for example, feed the build set into a selective dbt build. The action also
emits overrides_applied: the number of honored override pragmas that lowered the ruling on this
run (always present; 0 when none fired).
To evaluate the raw gate in CI — ignoring every -- lineage:allow-* pragma, e.g. to backtest
override reliance — set the no-overrides input to true (threads through to --no-overrides).