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(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:
Every decision on the legal policy shows the check passing:
and the closing lines report both outcomes:
The step-by-step example shows only the rejection:
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):
- 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. - If \(h > \texttt{max\_star\_height}\) (default 3), raise
StarHeightError. - Build the intermediate machine (an NFA, a non-deterministic finite
automaton). If it has more than
max_nfa_states(default 512) states, raiseCompileError. - 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, raiseCompileError.
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
certificatepassed. 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 inclinical_triageandfraud_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=leanhere 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 noact(...)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 raisemax_dfa_states/max_nfa_statesdeliberately 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¶
- The mathematics, the precise definition, and why the cap is 3: Policies that stay simple (star height).
- The other build-time check: Can the workflow keep going?
- All six checks side by side: The six checks.
- Terms: Glossary.