Encapsulation rules¶
Code outside a module talks to it through three messages: an event and the evidence go in, a decision record comes out. If every caller keeps to that, you can store one JSON record per decision and rebuild nothing, combine modules that run in different services, and answer an auditor without a live process. This page lists what you may use on a module, what you must leave alone, and why each rule exists.
It is for the engineer integrating the library and the contributor changing it. It assumes you have read Architecture. Rules about combining several modules are on Composing modules; rules about saving state between requests are on State and policy versions.
The contract in one picture¶
flowchart LR
E["Event<br/>name + payload"] --> M
V["EvidenceSnapshot<br/>numbers about related cases"] --> M
subgraph M["Module"]
direction TB
PUB["public: decide, reset, restore, state,<br/>name, limits, envelope"]
PRIV["private: current state slot,<br/>compiled chart, cached policy-check results"]
end
M --> R["DecisionRecord<br/>verdict, action, proposed action,<br/>six check reports, health, new state"]
Everything a caller needs to know about a decision is in the record. Anything the caller would have to read from inside the module is a sign the record is missing something, and that is a bug to report rather than a reason to reach in.
What you may use¶
On a Module |
Use it to |
|---|---|
Module(name, chart, limits=..., envelope=...) |
Build a module from a hand-built Statechart |
Module.from_expr(name, expr, limits=..., envelope=...) |
Build a module from policy text (parse("...")) |
decide(event, evidence) |
Take one step and get a DecisionRecord |
reset() |
Put this module back in its policy's start state |
restore(state_name) |
Load a saved state before deciding; raises KeyError for a name this policy does not have |
state |
Read the current state name (the same value the last record put in extras["state"]) |
name, limits, envelope, chart, expr, star_h |
Read for logging, fingerprinting and tests |
handle_request(module, body) wraps restore/reset and decide for a
JSON body. Pipeline and compose_parent combine modules through their
records.
What is private, and what to use instead¶
| Do not | Why | Instead |
|---|---|---|
Read or set _state |
It is one slot shared by every request that uses this module object; it says nothing about which entity it belongs to | Read record.extras["state"]; call restore with a saved name |
Read _game_ok, _mean_ok, _game_note |
They are cached build-time results | Read the game report in record.channels |
Add states or arrows to module.chart after building |
The progress check was solved for the old chart; the cached result would describe a different policy | Build a new Module from a new chart |
Assign a new module.expr |
Nothing recompiles; the chart stays as it was | Build a new module with Module.from_expr |
Share one Statechart object between two modules |
Both would step one table of arrows; editing it for one changes the other | Build one chart per module |
Change DEFAULT_LIMITS |
It is frozen and raises; a global setting would let one module change another's caps | Pass limits=Limits(...) to the module that needs it |
| Pickle a module to save progress | It ties stored data to a Python object layout | Store the record JSON and extras["state"] |
These are rules, not locks. The chart is an ordinary Python object after
validate(), so code can still append to it. Reviews and tests enforce the
boundary.
The three messages¶
Event. Only event.name moves the policy. The payload is copied into
the record for the audit trail and is never read by the policy or the checks.
Put the details a reviewer will want (amount, merchant, vital signs) in the
payload; keep secrets out of it, because every record will contain them.
EvidenceSnapshot. The numbers the four evidence checks read: n,
p_hat and p_threshold for the knife-edge check; degree and
lambda_hat for neighbour agreement; scores and label_score for the
unusual-cases check; support for the support check. Each check is explained under
The six checks. The snapshot is frozen, so no module can
change what another module sees. To give a second module different evidence,
build a new snapshot (dataclasses.replace(evidence, label_score=0.4) works).
The observations mapping is ignored by the checks; use it to carry context
into your audit log.
DecisionRecord. The only output. It is frozen, and it is designed to be enough on its own. An auditor who never sees the running process can answer:
| Question | Field |
|---|---|
| What did the policy want? | proposed_action |
| What was carried out? | action |
| Why, or why not? | reason, channels[].passed |
| How strong was the evidence? | health, channels[].metrics |
| How much mathematics stands behind each check? | channels[].trust, channels[].catalog |
| Where is the entity now? | extras["state"] |
The record does not contain the full evidence or the policy version. Store those beside it; see The decision record.
Building is the boundary¶
Everything about the policy is settled when the module is built:
- Policy text is parsed, its nesting depth checked, and it is compiled to
states. In the compiled chart, actions sit on states:
act(provision)marks the state the entity reaches, and the nextdecidethat lands there proposesprovision. Actions are never events the policy consumes. (In automata terms the result is a Moore machine: outputs belong to states, not to arrows.) - The two policy checks are computed once. Is the policy well-formed? and Can the workflow keep going? are solved at build time and their results are copied onto every record. Nothing is re-solved per decision.
- Errors happen here, not later. A policy that nests repetition too deeply or compiles to too many states raises at build time and no module exists. A progress game that is too large to solve does not raise: the module is built and every decision abstains. Architecture lists every case.
The policy text keyword for an event is test(name). The Python builder is
evt(name), because a top-level function called test would be collected
by pytest. Both produce the same policy.
Priorities and payoffs on hand-built charts¶
A hand-built Statechart lets you give each state a priority and a
payoff, which only the build-time progress check reads. Policies compiled
from text set every priority and payoff to 0.
The progress check treats "an event with no arrow leaves the state unchanged" as a loop on every state. As a result the policy can always stay in its start state, and the check passes whenever the start state's priority is even (0 by default) and its payoff is not negative, whatever the other states look like. Verified with small charts:
| Chart | Progress check |
|---|---|
start (priority 0) → page (priority 1, no exit) |
passes |
start (priority 1) → page (priority 1) |
fails: parity_win=False |
start (payoff −1) → page (payoff −1) |
fails: nonnegative_mean=False |
So an odd-priority state you cannot leave does not fail the check if the start state is ordinary. The check does not detect a workflow that can get stuck in general. Unless you have a specific reason, leave priorities at 0 and payoffs non-negative; Can the workflow keep going? explains what the check does and does not catch.
The library and the math catalog¶
The package does not import the companion math catalog or any proof files.
Each check report carries catalog entries (a result number, a title, a flag
for whether a machine-checked statement exists, and a note) and a trust
tag. They are labels copied onto the record; nothing is proved or checked
when you call decide. If the catalog moves or changes, the library still
runs. Trust levels and honest limits explains how to read the
tags.
Where to go next¶
- Composing modules: combining modules through their records.
- State and policy versions: saving and restoring state, threads, policy edits.
- Package structure: which file owns each object on this page.