11. Composing machines: submachine invoke

Sometimes a step is itself a whole machine you’d rather keep separate — payment authorization, a KYC check, a credit decision. invoke runs another machine as a child, treats it as a black box, and routes on the verdict it returns.

A machine that authorizes payment

The payment machine decides on its projected input and ends with a verdict. It self-drives — an automatic selector routes it the moment it starts (we’ll see why that matters below):

# payment.stm
machine payment {
  carry authcode
  initial Authorizing
  state Authorizing {}
  final Authorized success {}
  final Declined failed {}

  from Authorizing select paylib.decide {
    "ok" to Authorized
    "no" to Declined
  }
}

The order invokes it, projects input in with with { }, and routes on the reserved Returned event:

# order.stm
import "payment.stm" as pay

machine order {
  initial Charge
  state Charge {
    invoke pay.payment
    with { amount: amount }      # child's context <- parent's context
  }
  final Paid success
  final Cancelled cancelled

  from Charge to Paid      on Returned where outcome == "success"
  from Charge to Cancelled on Returned where outcome == "failed"
}
        stateDiagram-v2
    [*] --> Charge
    state Charge {
        [*] --> payment
        note right of payment : runs payment.stm\nas a black box
    }
    Charge --> Paid : Returned [success]
    Charge --> Cancelled : Returned [failed]
    Paid --> [*]
    Cancelled --> [*]
    

Run it

import sys, tempfile
from pathlib import Path

from harel import definition_from_dsl_file, DurableRunner, DictStore

project = Path(tempfile.mkdtemp())
sys.path.insert(0, str(project))

# the submachine's decision logic, referenced as a literal dotted path
(project / "paylib.py").write_text(
    "def decide(stm, event, **inputs):\n"
    "    return 'ok' if stm.execution_ctx.get('amount', 0) <= 100 else 'no'\n"
)
(project / "payment.stm").write_text("""
machine payment {
  carry authcode
  initial Authorizing
  state Authorizing {}
  final Authorized success {}
  final Declined failed {}

  from Authorizing select paylib.decide {
    "ok" to Authorized
    "no" to Declined
  }
}
""")
(project / "order.stm").write_text("""
import "payment.stm" as pay

machine order {
  initial Charge
  state Charge {
    invoke pay.payment
    with { amount: amount }
  }
  final Paid success
  final Cancelled cancelled

  from Charge to Paid      on Returned where outcome == "success"
  from Charge to Cancelled on Returned where outcome == "failed"
}
""")

defn = definition_from_dsl_file(project / "order.stm", "order")
runner = DurableRunner(DictStore(), {defn.id: defn})

for amount in (50, 500):
    exe = runner.create(defn.id, context={"amount": amount})
    print(f"amount={amount:<4} -> {exe.active_path} / {exe.outcome}")
amount=50   -> Paid / success
amount=500  -> Cancelled / cancelled

Entering Charge forks the payment machine as a child execution seeded with amount. The child runs to its own terminal, and on finishing emits a Returned event carrying its outcome (and anything it declared with carry). The order routes on that event: a small amount is AuthorizedPaid; a large one is DeclinedCancelled.

Black box, in and out

Important

A submachine is a black box. The parent’s domain events are not delivered into it — so a submachine must drive itself to completion (automatic transitions and selectors), which is why payment uses an automatic select. Communication is narrow and explicit:

  • inwith { child_key: parent_key } projects parent context into the child;

  • out — the child’s terminal outcome, plus any context keys it lists with carry, arrive on the Returned event.

A single invoke state must be a leaf with no automatic exit — it leaves only by routing on Returned. (Lifecycle Cancel still reaches a running submachine; that’s the control plane, in its own guide.)

Invoking one submachine is composition. Invoking the same machine once per item in a list — ship N parcels, each its own run — is the data-parallel sibling: fan-out.