Skip to content

Is the policy well-formed? (policy shape)

A policy is only useful if a reviewer can read it and say which events lead to which actions. Policies written as text can nest "repeat this" inside "repeat this" until nobody can follow them, or grow so many branches that the compiled machine becomes huge. This check refuses to build such a policy at all, so the problem surfaces in your test suite or deploy step, not in a live decision.

This page is for anyone who writes or reviews policies. It assumes you have seen a policy written as text (see Write a policy); the other four checks are introduced on The six checks. The name of this check in decision records is certificate.

The question

Is the policy simple enough to audit, and small enough to compile?

Picture an access-provisioning workflow. A request is submitted; an approver approves it (the policy proposes provision) or rejects it (the policy proposes close); then the next request may arrive. In the policy language:

(test(submit) ; (test(approve) ; act(provision) | test(reject) ; act(close)))*

test(x) means "event x arrives", act(y) marks the action proposed at that point, ; means "then", | means "or", and * means "repeat zero or more times".

Under deadline pressure someone adds "retry the approval", then "retry the retry", each as another * wrapped around part of the text. Every version still compiles in principle, but a reviewer facing four nested repeats can no longer say what the policy does. This check stops that.

The intuition: count loops inside loops

The nesting depth of a policy (mathematicians call it its star height) is the largest number of * you pass through going from the outside of the text to any single event. Loops side by side do not add up; only loops inside loops do.

test(a) ; act(b)                      depth 0   no repeat
(test(a))* ; (test(b))*               depth 1   two loops side by side
((test(a))* ; (test(b))*)*            depth 2   a loop around loops
(((test(a))*)*)*                      depth 3   the deepest allowed
((((test(a))*)*)*)*                   depth 4   rejected

The provisioning policy above has one * around everything, so its depth is 1.

Depth is about readability, not size. (((test(a))*)*)* has depth 3 but compiles to a tiny machine. A flat policy with fifty alternatives has depth 0 and can still be large. Size has its own two limits, on the number of states the compiler may create.

Worked example

The workflow_liveness example compiles the provisioning policy, runs requests through it, then tries to compile a four-deep policy:

uv run grounded-decision examples workflow_liveness

Every decision on the legal policy shows the check passing:

         certificate      PASS  can we compile this policy (star-height ≤ 3)?

and the closing lines report both outcomes:

    policy star-height 1 (max 3); the four-loop policy was rejected at compile time

The step-by-step example shows only the rejection:

uv run grounded-decision examples step_star_height_reject
  OUTPUT star-height 4 exceeds max 3 (catalog 134)
  NEXT   fix the policy text; there is no state to persist

There is no decision record for the rejected policy. Module.from_expr raised StarHeightError, so no module was built and decide was never called.

The size limits fail the same way. Compiling the provisioning policy (depth 1, 5 automaton states, 4 of them kept in the chart; the fifth is the internal "no continuation" state) with deliberately tiny caps, using Module.from_expr("access", parse(policy), limits=Limits(...)), gives:

Limits passed Result
defaults builds; record shows star_height: 1, dfa_states: 4
Limits(max_dfa_states=4) CompileError: DFA exceeded 4 states
Limits(max_nfa_states=10) CompileError: NFA has 14 states > 10

The exact rule

At build time (Module.from_expr, or compile_expr):

  1. Compute the depth \(h\) of the policy as written: an event, an action or the empty step has depth 0; "then" and "or" take the largest depth of their parts; not(...) keeps the depth of its inside; each * adds 1.
  2. If \(h > \texttt{max\_star\_height}\) (default 3), raise StarHeightError.
  3. Build the intermediate machine (an NFA, a non-deterministic finite automaton). If it has more than max_nfa_states (default 512) states, raise CompileError.
  4. Turn it into the final machine (a DFA, deterministic finite automaton: one state per situation the policy can be in). If that exceeds max_dfa_states (default 256) states, raise CompileError.

All three caps are fields of Limits; see Defaults and limits.

Every decision record then carries a certificate entry:

Field Meaning
passed true when the depth is within max_star_height and the state count within max_dfa_states (always, inside a record)
trust always lean (see below)
metrics.star_height the depth \(h\) computed at build time
metrics.dfa_states the number of states in the compiled chart
metrics.max_dfa_states the cap it was checked against (256)
summary for example star-height 0 ≤ 3; 4 states ≤ 256

What it does not tell you

  • It never fails inside a decision record. A policy that breaks a limit is never built, so every record you will ever see has certificate passed. The check's real work happens at build time, as an exception.
  • Policies built as charts are not measured. A module built directly from a Statechart (as in clinical_triage and fraud_review) has no policy text, so its depth is reported as 0.
  • It says nothing about correctness. A depth-1 policy can still propose the wrong action or have an action that can never be reached. (Two actions after the same event are refused at build time with a CompileError, and an out-of-order event is simply not handled.) Test the policy's behaviour separately.
  • It measures your text, not the best possible text. The library does not look for a shallower way to write the same policy, and does not rewrite yours.
  • It looks at no data. Whether the evidence supports the proposed action is the job of the four data checks; see The six checks.
  • trust=lean here names the mathematical result the cap of 3 comes from, not a proof about your policy. See Trust levels and honest limits.

What to do when it fails

You will see an exception at build time, not an abstain:

  • StarHeightError: star-height 4 exceeds max 3. Remove nesting that changes nothing: a * directly inside a * adds nothing, so ((((test(a))*)*)*)* is the same as (test(a))*. A repeat of repeats such as ((test(retry))* ; (test(escalate))*)* can usually be written (test(retry) | test(escalate))* (depth 1), as long as no act(...) sits between the inner loops. If the nesting reflects real structure (a case lifecycle with a retry loop inside it), split it into two modules and combine their verdicts; see Composing modules.
  • CompileError: ... states. Split the policy into smaller modules, or raise max_dfa_states / max_nfa_states deliberately for this tenant. The caps exist to keep build time and memory bounded.

Raising max_star_height above 3 is possible but defeats the purpose of the check; lowering it to 1 or 2 for teams that write simple policies is a reasonable restriction. These caps are engineering budgets, not settings learned from data: nothing in your outcomes says how deep a policy may nest (Where every number comes from). More guidance is in Write a policy.

Where next