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_accessandvouch_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
abstaincan 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
Statechartbetween modules. Build one per module. - Do not replace
compose_parentwith 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¶
- Encapsulation rules: what a parent may and may not touch on a module.
- Combinations: more worked combinations, including the payout example.
- Act, abstain, degrade: routing the parent verdict.