13. Passing data across boundaries¶
A region or a submachine doesn’t just reach a verdict — it usually produces data the caller
needs. A fraud screen yields a risk score; a payment yields an auth code. harel moves that
data across execution boundaries with carry, and exposes it to the joining parent as
region_results.
carry: a region’s return value¶
By default a finishing region reports only its outcome. List extra context keys with carry
and they ride along too. Here the Fraud region computes a score and carries it:
event FraudCleared {}
event StockReserved {}
machine checkout {
initial Verifying
orthogonal Verifying {
state Fraud {
carry score
initial Screening
state Screening { on enter set_score }
final Cleared success {}
from Screening to Cleared on FraudCleared
}
state Stock {
initial Checking
state Checking {}
final Reserved success {}
from Checking to Reserved on StockReserved
}
}
final Approved success {}
from Verifying to Approved
}
region_results at the join¶
When the parent joins, it finds context["region_results"] — a map from each region’s path to
its outcome plus whatever it carried:
import json
from harel import definition_from_dsl, DurableRunner, DictStore, Event
SOURCE = """
event FraudCleared {}
event StockReserved {}
machine checkout {
initial Verifying
orthogonal Verifying {
state Fraud {
carry score
initial Screening
state Screening { on enter set_score }
final Cleared success {}
from Screening to Cleared on FraudCleared
}
state Stock {
initial Checking
state Checking {}
final Reserved success {}
from Checking to Reserved on StockReserved
}
}
final Approved success {}
from Verifying to Approved
}
"""
def set_score(stm, event, **inputs):
stm.execution_ctx["score"] = 87
defn = definition_from_dsl(SOURCE, "checkout", actions={"set_score": set_score})
runner = DurableRunner(DictStore(), {defn.id: defn})
exe = runner.create(defn.id)
exe = runner.process(exe.id, Event(kind="FraudCleared"))
exe = runner.process(exe.id, Event(kind="StockReserved"))
print("final:", exe.active_path, "/", exe.outcome)
print("region_results:", json.dumps(exe.context["region_results"], sort_keys=True))
final: Approved / success
region_results: {"Verifying.Fraud": {"outcome": "success", "score": 87}, "Verifying.Stock": {"outcome": "success"}}
The Fraud region carried its score; Stock carried nothing, so only its outcome shows.
A select on the join can read region_results to route — e.g. all cleared with score ≥ 80 →
fast-track, otherwise → manual review. The same outcome-plus-carry payload is what a single
invoke hands back on its Returned event.
Note
The engine does not aggregate a verdict for you. After a join the parent’s outcome is
whatever terminal it routes to — there is no hidden “all regions succeeded ⇒ success” default.
Policy stays in the model (a select/join you write), which is why a validation
rule makes you declare the verdict rather than the engine guessing.
Data going in¶
Two ways to seed an execution’s context:
At creation —
runner.create(defn.id, context={...}), which we used for the fan-out’sparcelsand the submachine’samount.On a
Startevent with data —Event(kind="Start", data={...})seeds the context as the machine starts.
And on the way out under cancellation, cancel(reason={...}) attaches an opaque payload to the
cooperative Cancel event, readable by the machine’s own on Cancel cleanup — that’s the
control plane.
One capability remains before we leave modelling: making the compiler catch your mistakes. Static validation is the final step.