skip to content

In a Sentinel policy, which rule decides the verdict, and why might a rule you wrote never run?

level: middleimportance: should knowfreq 45%

answer

  1. the file is a set of named rules
  2. only one of them is the verdict
  3. the rest are inert until reached
  4. not evaluated is not enforced
  5. the rule called main

basics

~20 s

The rule named main decides it: the policy passes only when main evaluates to true. Rules are evaluated lazily, so a rule that main never reaches is never evaluated at all and silently enforces nothing.

solid answer

~50 s

A Sentinel policy is a set of named rules, and exactly one of them is the verdict: `main`. The policy passes only when `main` comes out `true` — `false` fails it, and so does `undefined`, because undefined is not a falsy value. Every other rule in the file is inert unless `main` reaches it, directly or through another rule it references, since rules are evaluated lazily and at most once. That is how a perfectly correct check ships and enforces nothing: nobody wired it into `main`. The model also shapes what you can express. There is no accumulating list of denials each carrying its own message; `main` hands back one boolean. If the blocked developer is to see which address offended, you filter the offenders into a named collection and emit them yourself with `print`, or they get a bare failed rule.

code

sentinel · 19 lines
sentinel
import "tfplan/v2" as tfplan

created = filter tfplan.resource_changes as _, rc {
    rc.change.actions contains "create"
}

# Referenced by main below, so it is evaluated.
under_limit = rule {
    length(created) <= 25
}

# Nothing reaches this rule, so it never runs at all.
naming_ok = rule {
    all created as _, rc {
        rc.name matches "^[a-z0-9-]+$"
    }
}

main = rule { under_limit }

go deeper

for a junior

Remember that a policy has one entry point: the rule called main, and the policy passes only if it is true. Being able to point at that rule and say why it is special is the expectation here.

for a middle

Be ready to explain lazy evaluation and its consequence — a rule main never reaches is never evaluated — and to say what a policy failure does and does not tell the developer on its own.

for a senior

Demonstrate that you verify a new rule fires against a fixture it should reject, and that you compose named rules and print offending values so the person blocked by the gate can act without asking you.

for a principal

Own the operability of the gate: a boolean verdict with no message is a support cost paid by every blocked team, so the authoring conventions that make failures legible are a standard you set, not a personal habit.

## One rule is the verdict A Sentinel policy file is a small program: a few imports, some assignments, and a set of **rules**. A rule is a named, boolean-valued expression, written `name = rule { ... }`. Nothing in that set is privileged except by name — the framework looks for the rule called `main`, evaluates it, and that value is the policy's result. `main` must evaluate to `true` for the policy to pass. `false` fails it, and so does `undefined`, the value Sentinel produces when an expression cannot be evaluated: undefined is not a falsy value, so a policy that collapses into undefined has failed rather than quietly passed. The rule's job ends at that boolean. What the platform then does about a failed policy is configured outside the rule and is not something the rule expresses. ### Lazy evaluation, and the rule that never runs Rules are evaluated **lazily**. A rule is evaluated when something asks for its value, and at most once per policy execution — the result is remembered rather than recomputed at each reference. That is stated as a performance property, and it has a correctness consequence that catches most new authors. If `main` does not reference a rule, and nothing `main` reaches references it either, that rule is never evaluated. It has no effect whatsoever. The file reads as though it enforces three things; it enforces the one `main` names. This failure is unusually hard to notice. Reading the file does not reveal it, because each rule is individually correct. The policy passing does not reveal it, because a rule that never runs cannot fail. The only reliable check is to evaluate the policy against a fixture the new rule *should* reject and confirm the policy comes back failed. An unwired rule cannot change `main`, so a fixture that ought to be denied and is not is the signal. ### What the model lets you express Some engines are built around an accumulating set: every violation appends a message, and the engine reports the set. Sentinel is not shaped that way. `main` returns one boolean, and a boolean carries no addresses, no attribute values and no explanation. Two affordances follow, and using them is the difference between a policy that is operable and one that produces a bare `false` at five in the afternoon. The first is **naming intermediate rules**. Write small predicates — one per constraint — and make `main` their conjunction: ``` main = rule { regions_ok and retention_ok } ``` The policy result now comes with a per-rule breakdown, so the reader can see which named sub-check was false instead of only that the policy failed. It also documents intent: a rule name is the only place the constraint is stated in English. The second is **emitting the offenders yourself**. Filter the changes down to the ones that violate the constraint, keep that filtered collection in a named variable, and use the `print` built-in to write the offending addresses and values into the policy's output, which the platform shows alongside the result. Nothing in the framework does this for you; a rule that only says `false` leaves the developer to reconstruct why from the plan. ### The vacuously true rule One edge deserves naming because it interacts badly with a boolean verdict. `all` over an empty collection is true — vacuously, in the ordinary logical sense. If the filter that built the collection matched nothing, because a type string or an action name was wrong, then `all` over it is true, `main` is true, and the policy passes. Since the verdict is a single boolean, `true` cannot distinguish "checked eleven resources and all were fine" from "checked nothing at all". If that distinction matters — and for a rule you have just written it always does — make the size of the filtered collection visible: print it, or assert on it in a test fixture where you know how many offenders should be present. Do not encode a non-empty requirement into the production rule unless you genuinely mean that an empty plan is a violation. ### Testing the shape, not only the logic Because `main` is what the platform evaluates, the unit worth testing is the policy as a whole. The Sentinel CLI evaluates a policy against mock data reproducing a run's plan, state and configuration, and its test configuration can assert the expected value of `main` as well as of individual named rules. A single fixture that should be rejected, asserted to make `main` false, catches both classes of mistake at once: a predicate that is wrong, and a predicate that is right but unreachable. The habit this all points at is simple. After writing a rule, ask two questions in order: does `main` reach it, and does it fire on something? Neither is answered by the policy passing.

  • How do you make a failed policy tell the developer which resource offended?
    Do it deliberately. Filter the changes down to the violating ones, hold them in a named collection, and use `print` to write their addresses and the offending values into the policy's output, which appears with the result. Splitting the check into named rules helps too, since the per-rule breakdown then names which constraint was false rather than only that the policy failed.
  • You add a new rule to a policy that already passes. How do you prove it is actually running?
    Evaluate the policy against a fixture that the new rule should reject, and require the policy to come back failed. A rule nothing references cannot change `main`, so a case that ought to be denied and is not tells you the rule is unreachable rather than wrong. Confirming a rule fires is a separate check from confirming it is correct.
  • Why split a check into several named rules instead of writing one large main?
    Because the name is the only place the constraint is stated in English, and the per-rule results tell the reader which sub-check was false rather than that the whole policy was. Composition costs nothing at evaluation time: rules are evaluated at most once, so a predicate referenced from two places is still computed once.

saying these in an interview costs you the question

  • Thinks every rule in the file gets evaluated
  • Expects a failed policy to list offenders by itself
  • Believes an undefined rule still lets the policy pass
  • Treats a passing policy as proof a rule ran
  • Cannot say why an empty filter makes a rule true

context