Skip to content

Write a policy

A policy is the part of your decision you can write down in advance: which events lead to which proposed actions. "A suspicious authorisation proposes blocking the card." "An access request needs a submit and then an approval before anything is provisioned." The library compiles that policy once, checks it, and then on every event says what the policy proposes and whether the evidence is strong enough to act on it. This page shows how to write a policy, in policy text or as an explicit state chart, how to test it, and how to change it without corrupting the state your service has saved.

Who this is for: the engineer turning a run-book or a rule into code, with the domain owner checking that it says what they mean.

What you need: a development checkout (Install) and, ideally, Your first decision done. Terms used here: an event is something that happened, named the way the policy names it (submit); a state is where one entity (a card, a request, a patient) currently is in the policy; a proposed action is what the policy would do next. The glossary has the rest.

Step 1: list the events and the actions

Start from the run-book, not from the syntax. Write two lists and one table:

  • Events: what your systems already emit that should move a case forward (submit, approve, reject; suspicious, clean).
  • Actions: what software may do on its own (provision, close; block, pass).
  • Which event, in which situation, proposes which action.

Two rules save trouble later:

  1. Give each action its own event. The policy decides what to propose from the event alone; the evidence checks only decide whether to act. If approve and deny both follow the same event, the policy cannot say which to propose, and building it fails (see Each action needs its own event).
  2. The event carries the direction. The code that chooses suspicious or clean (your model, your rule) is where direction is decided. The support check can hold an action the related cases contradict, but only if your evidence carries the support share (see Troubleshoot).

For an access-request service the table is:

In this situation Event Proposes
a new request submit nothing yet
after submit approve provision
after submit reject close

Step 2: choose policy text or an explicit chart

There are two ways to write the same policy. Both compile to a state chart and go through the same checks.

Policy text (parse + Module.from_expr) Explicit chart (Statechart + Module)
Looks like test(submit) ; test(approve) ; act(provision) .state(...) and .on(source, event, target) calls
State names generated: s0, s1, … renumbered when you edit the text yours: new, submitted, provisioned
An event arriving out of order not handled: the entity stays where it is, nothing is proposed not handled: the entity stays where it is, nothing is proposed
Priorities for the progress check set for you: states with an action or no way out are progress, others are not yours to set, plus payoffs and environment-owned states
Fallback (degrade) states not available available
Best for short, one-shot sequences; quick prototypes anything that lives long, gets edited, or saves state between requests

If your service saves state between requests, prefer the explicit chart: its state names survive edits.

Step 3a: write it as policy text

The language has five pieces: test(event) waits for an event, act(action) marks the state reached with a proposed action, ; is "then", | is "or", and * is "repeat any number of times". not(...) and eps (the empty step) exist for advanced uses; # starts a comment. Names are letters, digits and _; quote anything else (test("low-risk")). The full grammar is in the grammar reference.

from grounded_decision import Event, Module, parse
from grounded_decision.examples.common import well_corroborated_evidence

policy = Module.from_expr(
    "access_request",
    parse(
        "test(submit) ; (test(approve) ; act(provision) | test(reject) ; act(close))"
    ),
)
evidence = well_corroborated_evidence()
for name in ("submit", "approve"):
    record = policy.decide(Event(name), evidence)
    print(
        name, record.verdict.value, record.action, record.extras["state"], record.reason
    )
submit abstain None s2 no action marked on current state
approve act provision s3 all channels passed

submit moves the request to s2 and proposes nothing, so the verdict is abstain with no failed check: the policy is waiting, nothing is held. approve moves it to s3, which proposes provision, and with strong evidence the module acts. (well_corroborated_evidence() is a ready-made snapshot that passes every data check; see Build evidence for real ones.)

In Python code you can build the same policy without text: seq(evt("submit"), union(seq(evt("approve"), act("provision")), ...)). The builder is called evt, not test, because pytest would collect a function named test. In policy text it is always test(...).

Out-of-order events are not handled

An event the policy knows but that arrives out of order, such as approve before submit, has no arrow from the current state. The entity stays where it is and nothing is proposed:

early abstain s0 event 'approve' is not handled in state 's0'; nothing proposed

A later submit then moves it to s2 as usual. Event names the policy does not know at all behave the same way. (State s1 is missing from the compiled chart: it is the compiler's internal "no continuation" state, which is left out.)

Step 3b: write it as an explicit chart

from grounded_decision import Module, Statechart, Verdict

chart = (
    Statechart("access_request")
    .state("new", initial=True)
    .state("submitted")
    .state("provisioned", verdict=Verdict.ACT, action="provision")
    .state("closed", verdict=Verdict.ACT, action="close")
    .on("new", "submit", "submitted")
    .on("submitted", "approve", "provisioned")
    .on("submitted", "reject", "closed")
    .on("provisioned", "submit", "submitted")  # a new request after a finished one
    .on("closed", "submit", "submitted")
    .validate()
)
policy = Module(name="access_request", chart=chart)
stateDiagram-v2
    [*] --> new
    new --> submitted: submit
    submitted --> provisioned: approve
    submitted --> closed: reject
    provisioned --> submitted: submit
    closed --> submitted: submit
    note right of provisioned: proposes provision
    note right of closed: proposes close

Each .state(...) names a state and optionally gives it a verdict (Verdict.ACT to propose its action, Verdict.DEGRADE for a fallback, Verdict.ABSTAIN for an explicit hold) and an action. Each .on(source, event, target) is one transition; it can carry its own action, which takes precedence over the target state's. An event with no transition from the current state leaves the entity where it is and proposes nothing.

two_path_module in grounded_decision.examples.common builds the most common chart (one intake state, a routine path and an escalation path); most product examples, including the fraud template, use it.

Fallback states

A state with verdict=Verdict.DEGRADE proposes a safer fallback: an imaging service that publishes a full map from a smooth model, but only a rough image from an unconstrained one.

from grounded_decision import Statechart, Verdict

imaging = (
    Statechart("imaging")
    .state("waiting", initial=True)
    .state("full_map", verdict=Verdict.ACT, action="publish_map")
    .state("rough", verdict=Verdict.DEGRADE, action="publish_qualitative_image")
    .on("waiting", "smooth_model", "full_map")
    .on("waiting", "unconstrained_model", "rough")
    .validate()
)

With strong evidence, unconstrained_model returns degrade publish_qualitative_image ("statechart degrade"). With thin evidence it returns abstain: a fallback state still needs every check to pass. The sensor fusion case uses this shape.

Priorities and payoffs

States take a priority (an integer), a payoff (a number) and an environment flag. They feed the progress check, which is computed once when the module is built. Leave them at their defaults (0, 0, False) unless you need them. If you set them:

  • Priority: an even number means "it is fine for a case to keep coming back here"; an odd number means "a case must not end up circling here forever". If, from the start, every way the workflow can run ends up circling among states whose largest priority is odd, every decision abstains with parity_win=False.
  • Payoff: a reward for reaching a state. If the best average payoff over a cycle reachable from the start is negative, every decision abstains with nonnegative_mean=False.
  • environment=True: someone else (a customer, a pager) picks the next event in this state. The progress check assumes the worst choice there; in other states it assumes your service sends the event that keeps the workflow moving.

What the progress check can and cannot catch

For policy text, the check fails a policy that can loop forever without proposing anything ((test(ping))*). For a chart, it fails one that can only circle among odd-priority states, such as a retry loop with no exit. A chart left at priority 0 everywhere passes whatever its shape, and the check does not test that every action is reachable. The progress check page shows examples of each.

Each action needs its own event

Writing two actions after one event looks natural, but the policy then cannot say which to propose, and building it fails:

from grounded_decision import Event, Module, parse
from grounded_decision.examples.common import well_corroborated_evidence
from grounded_decision.grammar import CompileError

try:
    Module.from_expr("loan", parse("test(eligible) ; (act(approve) | act(deny))"))
except CompileError as error:
    print(f"CompileError: {error}")

right = Module.from_expr(
    "loan", parse("test(eligible) ; act(approve) | test(ineligible) ; act(decline)")
)
for name in ("eligible", "ineligible"):
    right.reset()
    print(name, right.decide(Event(name), well_corroborated_evidence()).action)
CompileError: Policy is ambiguous: after event 'eligible' (state s1) it could propose 'approve' or 'deny'; give each action its own event, for example test(ok) ; act(approve) | test(bad) ; act(deny).
eligible approve
ineligible decline

The second policy gives each action its own event, and the code that sends the event decides which. The same error catches two actions in a row after one event (test(x) ; act(a) ; act(b)) and two actions at the start (act(a) | act(b), reported as at the start (state s0)).

Step 4: fix compile errors

Errors in the policy surface when you build the module, before any decision. Catch them where your service loads policies, at start-up. These are the real messages:

You wrote Error Meaning and fix
"" ParseError: empty policy Nothing to compile.
test(a ; act(b) ParseError: line 1 col 8: unexpected ';'; expected one of: ')' Missing ). The caret in the message points at the column.
evt(a) ; act(b) ParseError: line 1 col 1: unexpected 'evt'; expected one of: '(', 'act', 'eps', 'not', 'test' evt is the Python builder; in text write test(a).
test(a) ; ParseError: line 1 col 9: unexpected end of input; expected one of: '(', 'act', 'eps', 'not', 'test' Trailing ;.
test(low-risk) ; act(b) ParseError: line 1 col 9: unexpected '-'; expected one of: ')' Quote names with other characters: test("low-risk").
((((test(a))*)*)*)* StarHeightError: star-height 4 exceeds max 3 (catalog 134) Repeats nested four deep. Flatten the policy; three levels is the limit.
a policy that compiles to many states CompileError: DFA exceeded 256 states (or NFA has … states > 512) The policy is larger than Limits.max_dfa_states / max_nfa_states. Simplify, or raise the limit after measuring compile time.
test(e) ; (act(a) \| act(b)) CompileError: Policy is ambiguous: after event 'e' (state s1) it could propose 'a' or 'b'; … Two actions after one event. Give each action its own event (see above).
.on("a", "go", "b") before .state("b") KeyError: "unknown state in 'a' -go-> 'b'" Declare every state before its transitions.
Statechart("c", initial="zzz") with no zzz ValueError: initial state 'zzz' is missing The initial state must be declared.

A stray symbol such as test(a) & act(b) gives ParseError: line 1 col 9: unexpected '&'; expected one of: ')', '*', ';', '|': only ;, | and * join steps.

Two problems do not raise; the module builds and every decision then abstains, because the progress check failed:

  • more states than Limits.max_game_vertices (200): reason 250 vertices exceed limit 200;
  • a policy that can loop forever without progress (in text, without ever proposing an action; in a chart, only through odd-priority states), or negative payoffs on every cycle: reason parity_win=False nonnegative_mean=True or parity_win=True nonnegative_mean=False.

Make one test decision per policy at start-up and fail the deploy if the certificate or game check fails. Policy size limits are listed in Limits and performance; star height is explained in Is the policy well-formed?.

Step 5: test it

Test the policy with the ready-made evidence snapshots so failures point at the policy, not at your data. This file runs as it is (uv run pytest test_access_policy.py) and covers the cases that matter:

import pytest

from grounded_decision import Event, Module, Statechart, Verdict
from grounded_decision.examples.common import (
    thinly_corroborated_evidence,
    well_corroborated_evidence,
)


def build() -> Module:
    chart = (
        Statechart("access_request")
        .state("new", initial=True)
        .state("submitted")
        .state("provisioned", verdict=Verdict.ACT, action="provision")
        .state("closed", verdict=Verdict.ACT, action="close")
        .on("new", "submit", "submitted")
        .on("submitted", "approve", "provisioned")
        .on("submitted", "reject", "closed")
        .on("provisioned", "submit", "submitted")
        .on("closed", "submit", "submitted")
        .validate()
    )
    return Module(name="access_request", chart=chart)


def run(module: Module, *events: str, evidence=None):
    evidence = evidence or well_corroborated_evidence()
    return [module.decide(Event(name), evidence) for name in events]


def test_strong_evidence_acts_after_the_full_sequence():
    submit, approve = run(build(), "submit", "approve")
    assert submit.verdict is Verdict.ABSTAIN and submit.proposed_action is None
    assert approve.verdict is Verdict.ACT and approve.action == "provision"


def test_thin_evidence_holds_and_keeps_the_proposal():
    _, approve = run(
        build(), "submit", "approve", evidence=thinly_corroborated_evidence()
    )
    assert approve.verdict is Verdict.ABSTAIN
    assert approve.action is None and approve.proposed_action == "provision"
    failed = {c.name.value for c in approve.channels if not c.passed}
    assert failed == {"reconstruction", "threshold"}


def test_state_moves_even_when_held():
    _, approve = run(
        build(), "submit", "approve", evidence=thinly_corroborated_evidence()
    )
    assert approve.extras["state"] == "provisioned"


def test_out_of_order_and_unknown_events_propose_nothing():
    module = build()
    (early,) = run(module, "approve")
    (typo,) = run(module, "Submit")
    assert early.proposed_action is None and typo.proposed_action is None
    assert module.state == "new"


def test_policy_checks_pass():
    record = run(build(), "submit")[0]
    assert all(
        c.passed for c in record.channels if c.name.value in {"certificate", "game"}
    )


def test_saved_state_round_trips():
    module = build()
    run(module, "submit")
    saved = module.state
    fresh = build()
    fresh.restore(saved)
    assert run(fresh, "approve")[0].action == "provision"
    with pytest.raises(KeyError):
        fresh.restore("s7")
6 passed in 0.04s

Note the thin-evidence test asserts the failed checks by name: on that snapshot the unusual-case (conformal) check passes, so a test that expects every data check to fail would be wrong. Add one test per row of your table from step 1, and one for every pitfall you have hit.

Two runnable examples show the same ideas in isolation:

uv run grounded-decision examples step_grammar_sequence
uv run grounded-decision examples step_unknown_event_stutter

Step 6: change it without corrupting saved state

A saved state is a position in one compiled policy. Change the policy and a saved name may vanish (then Module.restore raises KeyError: "unknown state 's7' for module 'access_request'") or, worse, survive with a different meaning. Policy text makes the second case easy to hit, because its states are numbered in the order the compiler meets them. Three versions of the access-request text, and what each state proposes:

v1 test(submit) ; (test(approve) ; act(provision) | test(reject) ; act(close))
   {'s0': '-', 's2': '-', 's3': 'provision', 's4': 'close'}         after submit -> s2
v2 ... | test(escalate) ; act(page_manager))
   {'s0': '-', 's2': '-', 's3': 'provision', 's4': 'page_manager', 's5': 'close'}
v3 test(open) ; test(submit) ; ...
   {'s0': '-', 's2': '-', 's3': '-', 's4': 'provision', 's5': 'close'}  after open, submit -> s3

(Printed by compiling each text with Module.from_expr and listing chart.action_of for every state.) A request saved in s4 under v1 was "closed"; restored under v2 it is "manager paged". Nothing raises.

So:

  1. Version the policy. Store a fingerprint of the compiled policy next to every saved state and refuse to restore across versions; the reference service's policy_fingerprint does this and returns 409 policy_version_mismatch (Integrate).
  2. Prefer named states (an explicit chart) for anything that saves state. Adding a state then leaves the other names and meanings alone.
  3. Migrate deliberately. When you ship a new version, map each old state to a new one (an identity map if the names did not change) and rewrite saved states, or let in-flight cases finish on the old version. Roll out safely has the procedure, and State and policy versions the background.

Result

A policy that compiles at start-up, proposes one action per event, keeps cases where they are when stray events arrive, has a test per rule, and carries a version with every saved state.

What can go wrong

  • Actions are not events. act(provision) marks a state; you never send Event("provision"). Sending it proposes nothing (it is an unknown event).
  • Resetting in the middle of a sequence. Multi-step policies need the saved state from the previous event; reset() (or sending no state to handle_request) starts over at the initial state.
  • One entity, many independent decisions. If every authorisation on a card needs its own decision, key the state by authorisation, not by card, or add a transition back to the start. Otherwise the second event finds the card already in review and proposes nothing.
  • Changing Limits per tenant by editing DEFAULT_LIMITS. It is frozen; build a new Limits(...) and pass it to the module.

Next