Skip to content

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 next decide that lands there proposes provision. 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