Skip to content

Composing modules

Some decisions need more than one policy to agree. A marketplace should release an instant payout only if the compliance team's identity check and the fraud team's device check are both confident. This page shows how to combine several modules into one decision, which of three patterns to use, and how to store the result.

It is for the engineer whose decision spans more than one policy or team. It assumes you know what a decision record is and how act, abstain and degrade are routed. The worked example is uv run grounded-decision examples combo_parent_and_session.

The rule: combine records, not policies

Each module decides on its own, from its own policy, its own saved state and its own evidence, and returns its own record. A parent decision is then computed from those records with compose_parent:

Child verdicts (checked in this order) Parent verdict
none at all abstain
at least one abstain abstain
otherwise, at least one degrade degrade
every child act act

It is an AND, not a vote. Ten children that act and one that abstains give abstain. A hold beats a fallback: if one child abstains and another degrades, the parent abstains, because part of the decision has no support at all, and a hold must never become an automatic action, not even a reduced one.

The library does not merge two policies into one bigger policy. Merging would multiply their states together, exceed the size limits quickly, and leave a record that no longer says which team's policy held the decision.

Worked example: an instant payout needs two services

In combo_parent_and_session, a seller requests an instant payout each day for six days. Two services, run by two teams, each keep their own module and their own saved state for the seller:

  • Compliance: (test(kyc_current) ; act(release) | test(kyc_expired) ; act(request_documents))*, with evidence from sellers who share a bank account or address.
  • Fraud: (test(low_risk) ; act(release) | test(high_risk) ; act(hold))*, with evidence from the seller's device neighbourhood.

On days 2 and 3 a wave of brand-new accounts with no review history joins the seller's device neighbourhood, and linked accounts stop predicting each other's outcomes:

Day Fraud: agreement (λ) Fraud: evidence strength (\(d\lambda^2\), \(d=8\)) Compliance Fraud Parent Payout
0 0.55 2.42 act act act instant
1 0.52 2.163 act act act instant
2 0.30 0.72 act abstain abstain standard
3 0.28 0.6272 act abstain abstain standard
4 0.48 1.843 act act act instant
5 0.55 2.42 act act act instant

Evidence strength must be above 1.05 for the related cases to count as informative, so the fraud service holds on days 2 and 3. A rule that released whenever either service said "go" would pay out all six days. The combined decision pays out instantly on four and falls back to the standard next-day payout on the other two.

Each service runs the stateless pattern from State and policy versions and returns JSON. The orchestrator rebuilds the two records and combines them:

from grounded_decision import compose_parent
from grounded_decision.integrate import handle_request
from grounded_decision.types import DecisionRecord, Event, Verdict

compliance_json = handle_request(
    compliance_module,
    {
        "state": saved_compliance_state,
        "event": Event("kyc_current", payout_request).to_dict(),
        "evidence": compliance_evidence.to_dict(),
    },
)
fraud_json = handle_request(
    fraud_module,
    {
        "state": saved_fraud_state,
        "event": Event("low_risk", payout_request).to_dict(),
        "evidence": fraud_evidence.to_dict(),
    },
)
parent = compose_parent(
    "instant_payout",
    [
        DecisionRecord.from_dict(compliance_json),
        DecisionRecord.from_dict(fraud_json),
    ],
)
if parent is Verdict.ACT:
    release_instant_payout()
else:
    schedule_standard_payout(reasons=[compliance_json["reason"], fraud_json["reason"]])

Each service saves its own extras["state"]; neither reads the other's.

Three patterns

flowchart LR
    subgraph A["Separate evidence (usual)"]
        E1["event"] --> C1["compliance.decide<br/>(own evidence)"]
        E1 --> F1["fraud.decide<br/>(own evidence)"]
        C1 --> P1["compose_parent"]
        F1 --> P1
    end
    subgraph B["Pipeline (shared evidence)"]
        E2["event + one evidence"] --> M1["module 1"]
        E2 --> M2["module 2"]
        M1 --> P2["compose_parent"]
        M2 --> P2
    end

Separate evidence, combined with compose_parent

The pattern above. Use it whenever the modules look at different related cases, which is nearly always: identity links and device links are different graphs. The modules can live in different services.

Pipeline: one event, one evidence snapshot

Pipeline(name, modules) is shorthand for calling several modules in the same process with the same event and the same evidence, then calling compose_parent:

from grounded_decision import Pipeline

pipe = Pipeline("grant_access", modules=[peer_access_check, device_trust_check])
verdict, records = pipe.decide(request_event, evidence)
pipe.reset()  # resets every module to its start state

The modules run one after another in list order, not in parallel. Each moves its own policy on the event name; a module whose policy does not know that event stays where it is and proposes nothing, which makes it abstain and the pipeline abstain. For example, a pipeline of fraud_review and sensor_fusion given suspicious returns abstain: fraud acts, but sensor fusion has no suspicious arrow and abstains with "event 'suspicious' is not handled in state 'intake'; nothing proposed". uv run grounded-decision examples step_pipeline_and shows a pipeline whose modules share an event and evidence, followed by the separate-evidence version of the same decision.

Use Pipeline only when one snapshot really describes the evidence for every module. If module B should see module A's result, call A, then build B's evidence yourself (you can put A's record in observations, which the checks ignore but your audit log keeps), then call B.

A supervisor module

If the combined decision has its own sequence of steps (for example, "release after both checks pass, then wait for settlement before the next payout"), write that sequence as a third policy. Its events are names you derive from the child results, such as both_cleared when compose_parent returns act. It runs the six checks on whatever evidence you give it, so give it evidence about the parent's own action. If you have none, you do not need a supervisor: compose_parent plus your own routing is enough.

Things compose_parent does not do

  • It returns a verdict, not an action. When every child acts, they may carry out different actions (vouch_for_access and vouch_for_device). Your code chooses what the combined action is.
  • It does not combine the checks. A parent does not see ten check results; it sees two verdicts. Keep each child's record, with its own channels, for the audit.
  • It does not tell you which child held. A parent abstain can come from any child, including one that abstained with nothing proposed (waiting for its next event). Look at the child records to decide where to route it.
  • It does not look at envelopes or states. Those stay on the child records.

Storing a combined decision

Store each child record as usual, with its own module's policy fingerprint (see State and policy versions). Then store one row for the parent: its name, the parent verdict, the ids of the child records, and the action your code chose. There is no parent record type in the library. When an auditor asks why a payout was not instant, the parent row points to the fraud record whose reason reads d λ² = 0.7200 vs 1+0.050 (at or below KS; abstain).

What not to do

  • Do not put the same module object in a pipeline twice. Both entries share one state slot, so one event moves it twice.
  • Do not share a Statechart between modules. Build one per module.
  • Do not replace compose_parent with a vote and keep the name. If you need "two of three", write a separately named function and say so in your runbook.
  • Do not reach into a child. Read its record, not its state attribute or its chart. Encapsulation rules explains why.

Where to go next