State and policy versions¶
A card, a support ticket or a patient usually receives more than one event, and the policy's answer to the next event depends on where that entity is now. Your service has to remember each entity's position between requests, keep two requests from mixing up their positions, and make sure a saved position still means the same thing after someone edits the policy. This page explains all three.
It is for the engineer running the library inside a service with many
entities, several workers and a policy that will change. It assumes you have
read Anatomy of a decision and know what a
decision record contains. Examples use the fraud_review policy
(uv run grounded-decision examples fraud_review) and the reference service in
src/grounded_decision/examples/reference_service.py.
An entity's state over time¶
The state is the entity's position in the policy: intake, review,
hold for the fraud policy. Each decision reads the state, moves along one
arrow, and returns the new state in record.extras["state"].
One card's history in the reference service looks like this:
sequenceDiagram
participant S as Your service
participant DB as State table
participant M as Module
S->>DB: load state for acct_900
DB-->>S: none (new card)
S->>M: reset(); decide(suspicious, thin evidence)
M-->>S: abstain, proposed block, state=review
S->>DB: save review
Note over S: reviewer confirms fraud
S->>DB: save intake (case closed)
S->>DB: load state for acct_900
DB-->>S: intake
S->>M: restore(intake); decide(...)
Three facts drive everything else on this page:
- The state moves even when the action is held. The thin-chain card
moves to
reviewand the block waits for a person. The checks decide whether the proposal is carried out, not whether the policy advances. - Some states have no way forward for some events. The fraud chart has no
suspiciousarrow out ofreview, so a second flag on a card already in review proposes nothing. The reference service resets a card tointakewhen a reviewer closes its case. Design your policy so every state an entity can rest in has arrows for the events it will receive, or reset it when the case closes. - One
Moduleholds one state at a time. It is a compiled policy plus a single "current state" slot. To serve many entities, you load each entity's state into that slot before deciding, and save it afterwards.
Stateless workers: restore, decide, save¶
Build the module once when the process starts (building runs the policy analysis; deciding is cheap). Then, for every request:
saved = state_table.load(entity_id) # None for a new entity
if saved is None:
module.reset() # back to the policy's start state
else:
module.restore(saved) # KeyError if the name is not in this policy
record = module.decide(event, evidence)
state_table.save(entity_id, record.extras["state"])
handle_request(module, body) in grounded_decision.integrate does the
first four lines for a JSON body of the form {"state", "event",
"evidence"}: it restores body["state"] when present and resets otherwise.
Two other patterns are valid:
- Every decision independent. If each event is a separate case (each
card authorisation judged alone), call
reset()before everydecideand store nothing. The example printouts do this ("fresh cursor"). - One long stream in one process.
DecisionSession(module)feeds a sequence of events to one module and keeps its state between them. Use it for batch replays and tests, not for a service handling many entities.
Never pickle a Module to save state, and never read the private _state
attribute. The state name in the record is the whole of what you need.
Threads and concurrent requests¶
A Module is not thread-safe
restore and decide share the module's single state slot. If two
threads interleave them, one entity is decided from the other's state.
Here is that failure with real records. Card A is in review; on its own, a
second suspicious event does nothing:
Now a second thread starts a new card B between card A's restore and its
decide:
thread A: restore("review")
thread B: reset() # card B is new
thread A: decide(suspicious) → act, action=block, state=review
Card A was decided from intake, and its card is blocked a second time.
There are three standard fixes. The reference service's DecisionEngine
uses the first:
- One lock around restore and decide.
decidetakes about 7 µs for the fraud policy (see Limits and performance), so the lock is not the bottleneck. Keep evidence reads and database writes outside it.
with self._lock:
if saved_state is None:
self.module.reset()
else:
self.module.restore(saved_state)
return self.module.decide(event, evidence)
- A pool of modules, one checked out per request (a
queue.Queuefilled at start-up). - One module per worker process, with single-threaded workers.
A lock around the module does not stop a different problem: two events for
the same entity arriving at once. Both load intake, both decide, and
the second save overwrites the first. The reference service prevents this
in two layers: a lock per entity within a process (EntityLocks), and an
optimistic version column in the state table across processes, so the
slower writer's save fails and it retries.
Editing a policy renumbers its states¶
A policy written as text is compiled to states named s0, s1, s2 and so
on. The numbering follows the compiled structure, so an edit can give an
existing name a new meaning.
A loan policy:
compiles to:
| State | Action |
|---|---|
s0 |
none (start) |
s1 |
approve |
s2 |
decline |
Add a fraud branch:
and the states become:
| State | Action |
|---|---|
s0 |
none (start) |
s1 |
approve |
s2 |
block |
s3 |
decline |
An application saved in s2 under the old policy had been declined.
module.restore("s2") on the new policy succeeds without complaint, and the
application is now in the state that means "blocked as fraud". Nothing raises.
(restore raises KeyError only when a name does not exist at all, such as
review in this policy.) An event the new policy does not expect in the
restored state is not handled: the entity stays put and nothing is
proposed.
Charts you build by hand with Statechart(...).state("review", ...) keep the
names you give them, which is one reason to prefer them for long-lived
workflows. They can still change meaning if you change a state's action,
verdict or arrows.
Version the policy and store the version with the state¶
Treat the compiled policy as versioned data. The reference service computes a
fingerprint with policy_fingerprint(module): a hash of the module name,
its hardness label, the chart (every state with its verdict, action, payoff
and priority, every arrow, the start state) and the policy text. That is
exactly what gives a saved state its meaning. The settings that change what
the checks conclude (the Limits, the evidence settings, the learned
parameters) are versioned separately by parameters_fingerprint, because
they change often and do not change what a saved state means. For the two
loan policies above:
Compiling the same text again gives the same fingerprint, so restarting or adding workers with unchanged code is seamless.
The reference service stores the fingerprint in every saved state row and
every decision row. Before restoring, it compares the stored fingerprint with
the running one and refuses a mismatch with HTTP 409, error code
policy_version_mismatch, and this message:
Entity 'acct_900' is in state 'review' saved by policy fraud_review@<old>,
but this process runs fraud_review@<new>. Migrate saved states (map old state
names to new ones) before restoring across a policy change.
Do the same in your service. A refused request is visible and fixable; a silently reinterpreted state is not.
Migrating saved states¶
When the fingerprint changes, choose one of these for the entities that are mid-way through the old policy:
| Strategy | How | Use when |
|---|---|---|
| Map the names | For each old state, find the new state with the same meaning (same action and verdict, same arrows to equivalent states). Rewrite saved rows and their fingerprint in one migration. | The edit is small and you can check every state. Unmarked states (such as s0 above) need care: several may look alike. |
| Drain the old version | Keep a module built from the old policy, keyed by its fingerprint. Entities saved under the old fingerprint finish on it; new entities start on the new one. | Workflows are short-lived and you can run two versions for a while. |
| Reset | Set saved states back to the start state under the new fingerprint. | Losing progress is harmless, for example when every decision is independent anyway. |
| Identity mapping | Keep every state name, change only the fingerprint. | The edit changed something the fingerprint covers without changing what a state means, such as a payoff or a priority. Check this by comparing the two charts, not by assumption. (Changing only Limits or evidence settings does not change the policy fingerprint at all.) |
Whatever you choose, keep the old fingerprint on the old decision rows. An auditor reading a two-year-old record needs to know which policy made it.
What this page does not cover¶
- Persisting evidence. The record does not contain the full evidence snapshot; store it beside the record (The decision record).
- Combining several modules. Each module has its own state and its own fingerprint; see Composing modules.
- Running the service. Build a decision service runs the reference service end to end, and Integrate into a service shows the same pattern with other frameworks.
Where to go next¶
- Architecture: what is built once and what runs per request.
- Limits and performance: build and decide costs, and the numbers behind "the lock is not the bottleneck".
- Roll out safely: ship a new policy version in shadow mode before it acts.