skip to content

Why does a Rego policy shaped as a deny set fail open while default allow := false fails closed?

level: middleimportance: must knowfreq 57%

answer

  1. what does silence mean here
  2. empty set versus explicit false
  3. default covers a failing body only
  4. a policy that never loaded is undefined
  5. known-bad fixture proves the gate fires

basics

~20 s

A deny set treats absence of messages as approval, so anything that stops the rule from matching produces a pass. A complete allow rule with default false has an explicit value of false whenever its body does not succeed, so absence of evidence becomes a denial.

solid answer

~50 s

The two shapes disagree about what silence means. `deny` is a partial set: no matching body means an empty set, and the runner reads no messages as no problem — so a renamed package, a namespace the runner never selected, or a rule whose condition no longer matches the document all come back green. A complete rule with `default allow := false` inverts that: when the body does not succeed the rule is not undefined, it is `false`, and the caller that requires `allow == true` blocks. The important caveat is that `default` only covers the body failing — it cannot save you if the *policy never loaded*, because then the query for `data.ci.retention.allow` is undefined rather than false. Fail-closed is therefore a property of the shape **plus** a consumer that treats undefined as a denial, and neither shape removes the need for a known-bad fixture that the gate must reject.

go deeper

for a junior

Know the one-line contrast: an empty deny set passes, while an allow rule with a false default blocks whenever its condition is not met.

for a middle

Explain the mechanics precisely — partial versus complete rules, what default actually substitutes for, and concrete causes that make a rule stop matching.

for a senior

Demonstrate the operational instinct: a gate that has been green for weeks is a hypothesis, and you can name the fixture test and namespace pinning that verify it.

for a principal

Frame it as a trade the organisation is making — contribution cost and message quality against failure direction — and be able to say where you would pay for fail-closed.

## The asymmetry Both shapes express the same policy. They disagree about the default answer when the policy has nothing to say. **Deny set (the ecosystem default).** `deny` is a partial set of messages. Zero matching evaluations means an empty set. The runner turns an empty set into exit code zero. *Silence is approval.* **Boolean allow.** `allow` is a complete rule. Written on its own it is *undefined* when its body fails; adding `default allow := false` gives it the value `false` in exactly that case. The caller admits the change only on an explicit `true`. *Silence is denial.* ```rego package ci.retention min_days := 90 default allow := false allow if { every job in input.jobs { job.artifacts.retention_days >= min_days } } ``` The consequence shows up not when the policy is right, but when it is *absent*. ## What actually goes wrong in practice The failure mode is almost never "the rule said the wrong thing". It is "the rule was never consulted": - the package was renamed while refactoring the library, so `data.main.deny` no longer exists; - the runner was pointed at one namespace and the rule lives in another, with no `--all-namespaces`; - the policy directory was not mounted or copied into the job at all; - the rule matched a field path the input document no longer uses. With a deny set, every one of those yields zero messages and a green check. The gate has been decorative for three weeks and no signal was ever emitted, because *the absence of a complaint is exactly what success looks like*. With `default allow := false`, the first three still leave the whole document undefined, but the fourth — the body simply failing to hold — produces `false` and blocks immediately. Someone notices within minutes, because a fail-closed policy that stops working is loud: it blocks everything. ## The caveat that separates a good answer from a slogan `default` is a property of a **rule**, not of the query. It supplies a value when the rule's body does not succeed. It cannot supply a value for a rule that is not in the compiled policy at all. If the package path is wrong, `data.ci.retention.allow` is undefined, and undefined is not `false`. So the fail-closed property is really two things: 1. `default allow := false` inside the policy, covering the body failing; and 2. a **consumer** that treats an undefined or missing result as a denial rather than as an approval. A harness that does `if result == false: block` and otherwise proceeds has thrown the property away. It must be `unless result is exactly true: block`. ## Why the ecosystem still defaults to deny sets Given all of that, why is nearly every published policy library written as deny sets? Because the runner looks rules up **by name**. conftest resolves `deny`, `violation` and `warn` (optionally suffixed) inside a namespace and needs no configuration to know what question to ask. A boolean decision has no such convention — you have to write the query and the harness yourself. Deny sets also give you per-violation messages for free, which is most of what a developer needs from the gate, whereas a bare boolean tells them only that something is wrong. That is a real engineering trade, not laziness: cheap contribution and good messages, bought with a failure direction that hides its own absence. ## How you buy the safety back Since you cannot get fail-closed behaviour out of a deny set by writing better Rego, you get it from the harness around it: - keep a deliberately non-compliant fixture — a pipeline file whose log retention is below the floor — and assert in the policy repository's own CI that the gate rejects it. If a rename silences the rule, this test goes red. - pin the package path and the namespace in the gate invocation rather than relying on discovery, so a mismatch is a configuration error rather than a quiet no-op. - have the gate report how many rules it evaluated, and treat *zero* as a failure of the gate rather than a clean bill of health. ## What to say in an interview State the asymmetry in one sentence (empty set reads as approval; `default allow := false` reads as denial), give a concrete cause that triggers it (a renamed package), then add the caveat that `default` covers a failing body and not a policy that never loaded, so fail-closed is shape plus consumer. Finish with the fixture test, because that is what you would actually do on Monday.

  • If default allow := false does not cover a policy that failed to load, what does?
    Only the caller. The harness must demand an explicit `true` and treat undefined, missing or error results as a denial. Pinning the package and namespace in the invocation turns a rename into a hard configuration error rather than a silent empty result.
  • You inherited a deny-set library and cannot rewrite it. What is the cheapest way to detect a rule that has gone silent?
    Add a non-compliant fixture to the policy repository and assert that the gate rejects it on every change. It costs one file and one CI step, and it fails loudly the moment a rename, a namespace change or a schema drift stops the rule from matching.
  • Does a boolean allow rule give developers the same feedback as a deny set?
    Not by itself — it yields one bit. To match the deny set's usefulness you have to collect reasons alongside the boolean, at which point you have rebuilt the message set and are maintaining both. That duplication is part of the cost of choosing the fail-closed shape.

A smoke alarm that reports by staying silent when all is well is indistinguishable from one with no battery. The test button exists because silence carries no information on its own.

saying these in an interview costs you the question

  • Claims default allow := false protects against a policy not loading
  • Says undefined is treated as false by the caller
  • Thinks deny sets fail open because of a bug rather than by design
  • Believes a boolean allow rule needs no harness of its own
  • Suggests the fix is writing more careful Rego

context