Online edition for BLOGE
0.9.8-RC1· facts verified 2026-09-15 · 中文
Chapter 16 — Composing Sessions and State Machines
Promise: By the end of this chapter you will know how to combine
sessionandstate_machinewithout losing track of signal ownership, timeout behavior, or output paths — and you will understand why the two nesting directions are useful but not symmetric.
Learning Goals
- Compare the two composition patterns: session-outermost and state-machine-outermost.
- Decide which runtime owns lifecycle, signalling, recovery, and timeout semantics in a nested flow.
- Trace output paths through nested orchestration without flattening away important structure.
- Understand the current asymmetric constraints so you do not design a composition the runtime cannot support.
- Choose the smallest composition boundary that keeps the business model obvious.
Prerequisites
- Chapter 14 — Multi-Turn Sessions — you need the session lifecycle first
- Chapter 15 — State Machines — you need explicit state transitions first
- Chapter 13 — Durable Execution — nested runtimes still sit on top of the same durability foundation
Source Examples
| File | What it shows |
|---|---|
ch15/order-session-with-state-machine.bloge | Session-outermost composition: a phase embeds a full order lifecycle state machine |
ch15/review-state-machine-with-session.bloge | State-machine-outermost composition: a state embeds a short review session |
ch15/ivr-customer-service.bloge | A longer-lived session that could later grow nested lifecycle behavior |
OrderSessionWithStateMachineExample.java | Java builder version of session → nested state machine |
ReviewStateMachineWithSessionExample.java | Java builder version of state machine → nested session |
OrderSessionWithStateMachineExampleTest.java / ReviewStateMachineWithSessionExampleTest.java | Runtime tests proving the nested shapes compile and execute |
Why This Matters
Real business flows often need both abstractions:
- a customer conversation that contains a multi-step order lifecycle
- a lifecycle state that needs a short, self-contained review interaction before deciding where to go next
If you flatten everything into one graph, the model becomes muddy. If you split everything into separate services, the ownership boundaries become muddy.
Composition lets you keep the business model visible:
- the outer runtime says what the main lifecycle is
- the nested runtime handles one smaller orchestration problem inside it
The crucial design question is not “can these be nested?” It is: which runtime owns the lifecycle that humans or other systems care about most?
Mental Model
There are two supported directions:
| Pattern | Outer owner | Inner owner | Best fit |
|---|---|---|---|
session → nested state_machine | SessionExecutor | StateMachineExecutor logic wrapped as a synthetic node | A long-lived interaction whose one phase needs explicit state transitions |
state_machine → nested session | StateMachineExecutor | SessionExecutor logic wrapped as a synthetic node | A named lifecycle state that needs a short, self-contained multi-step interaction |
The patterns are both useful, but their runtime constraints differ.
Choose the Outer Owner Before Choosing Syntax
Composition begins with one question: which runtime must still own the case after the inner activity ends?
| Dominant fact | Outer owner | Inner activity example |
|---|---|---|
| A customer continues a multi-turn conversation | Session | Order state machine inside the ordering phase |
| An order continues through durable business states | State machine | Review Session inside the review state |
| Work is one-shot and dependency-driven | Graph | No second lifecycle model needed |
For orderWorkflow, the caller returns to the conversation after order state
reaches shipped, so Session is outermost. For reviewWorkflow, the case must
become approved or rejected after interactive review, so the state machine
is outermost. Nesting direction is an ownership decision, not formatting.
First Working Example
The more flexible direction is session outermost, state machine nested inside one phase:
session orderWorkflow {
idle_timeout = 72h
max_rounds = 10
phase ordering {
state_machine orderFlow {
max_transitions = 20
max_state_visits = 5
state draft [initial] {
graph {
node collectInfo : CollectOrderInfoOperator { }
}
on submit -> pendingPayment
}
state pendingPayment {
graph {
node chargePayment : ChargePaymentOperator { }
}
on payment_confirmed -> processing
on payment_failed -> draft
}
state processing {
graph {
node shipOrder : ShipOrderOperator { }
}
on * -> shipped
}
state shipped [terminal] { }
}
then -> fulfillment
}
}
Read it as:
- the outer business interaction is an order session
- one phase of that interaction is a nested order lifecycle
- when the nested machine finishes, the session moves to fulfillment
The outer session still owns persistence, signalling, and the overall interaction identity.
Signals Travel Inward; Terminal Outputs Travel Outward
The nested runtime owns its current state, so an external event cannot jump
directly to chargePayment or shipOrder. It enters through the outer Session,
is translated to a state-machine signal, and only then selects a transition.
Trace one order through both owners
| Direction | Contract crossing the boundary | Wrong shortcut |
|---|---|---|
| Inward | customer submit → Session input → state-machine submit signal | Address an inner node id from the webhook |
| Inward | payment callback → owning runtime → payment_confirmed event | Mutate currentState directly |
| Outward | state-machine terminal output contains shipped and shipmentId | Read arbitrary inner workspace from the caller |
| Outward | Session fulfillment phase calls notifyCustomer | Let inner Operator send ungoverned conversation messages |
This gives both directions a receipt: accepted signal and state transition on the way in; namespaced terminal output and Session result on the way out. When a signal is rejected, inspect the owner, instance id, and allowed transition before inspecting business node code.
Break It Apart
Pattern A — session outermost, state machine inside a phase
This is the shape used by order-session-with-state-machine.bloge.
Important behaviors:
- the enclosing
SessionExecutoris still the runtime entry point - external
signal(...)calls go to the session handle, not directly to the nested state machine - while the nested state machine is waiting for an event, the outer session surfaces as
SUSPENDED - when the nested machine resumes, the session returns to
ACTIVE
The output path makes the ownership visible:
finalState = ctx.ordering.output.orderFlow.stateMachine.currentStateId
shipmentId = ctx.ordering.output.orderFlow.processing.output.shipOrder.shipmentId
That path is long on purpose. It tells you the data came from:
- session phase
ordering - nested state machine node
orderFlow - nested state
processing - nested graph node
shipOrder
Pattern B — state machine outermost, session inside a state
This is the shape used by review-state-machine-with-session.bloge.
The review state contains a nested session:
state review [initial] {
session reviewSession {
phase collect { ... then -> finalize }
phase finalize { ... }
}
on * when ctx.review.output.reviewSession.finalize.output.decideReview.decision == "approved" -> approved
on * -> rejected
}
This is where the asymmetry appears.
A nested session inside a state machine must complete synchronously.
If that inner session suspends for an external signal, the SessionOperator fails fast instead of leaving the enclosing state machine half-paused.
So this pattern is good for:
- a short two-phase review
- a compact enrich → decide interaction
- an inner lifecycle that stays self-contained inside one outer state visit
It is not good for a long-lived human conversation with repeated external turns.
Current composition constraints
Today the nested forms are intentionally narrow:
- a phase may contain at most one top-level nested
state_machine - that nested
state_machinemust be the only executable member in the phase - a state may embed a nested
session, but that session must complete synchronously
These constraints keep lifecycle ownership unambiguous.
Constraint — avoid signal deadlock
A nested
sessioninside astate_machinecannot wait for an external signal, only complete synchronously. The reason is ownership: the outer state-machine executor holds the lifecycle lock and will not release control to another signal recipient. If the nested session tries to suspend on a signal, the compiler raisesSM_NESTED_SESSION_AWAITS_SIGNALand refuses to publish the graph.The reverse — a nested
state_machineinside asessionphase — can suspend freely, because the outer session is the signal owner. Reach for Pattern A (session outermost) whenever you might need the inner lifecycle to pause for the world.
Ownership checklist
Ask these questions before you nest anything:
| Question | If the answer is “session” | If the answer is “state machine” |
|---|---|---|
| What is the main business interaction? | Human or multi-turn interaction | Object lifecycle or status progression |
| Who receives external signals? | The session | The state machine |
| Who owns idle timeout / access guard? | The session runtime | The state-machine runtime |
| What should a dashboard show first? | Current phase / round | Current state |
| Can the inner flow wait across external turns? | Yes, if the outer owner is the session | No, not for a nested session inside a state machine |
Choose the outer owner first
The easiest way to design nested orchestration is to choose the outer owner first:
- if humans think in conversation turns, make
sessionoutermost - if humans think in named states, make
state_machineoutermost
Only after that should you decide whether one inner portion deserves its own orchestration model.
Common Trap
❌ Assuming the two nesting directions are symmetric
They are not.
- A nested state machine inside a session can wait and resume through the outer session's lifecycle.
- A nested session inside a state machine must finish synchronously today.
If you design as though both inner runtimes can suspend freely, you will discover the constraint late — after you already modeled the business flow the wrong way.
What Goes Wrong
A signal is sent to the wrong runtime boundary
In the session-outermost pattern, the nested state machine may be the thing logically waiting — but the signal still has to enter through the session runtime.
So this is wrong:
// Wrong mental model: trying to signal the nested machine directly
stateMachineExecutor.signal(...)
and this is the correct shape:
sessionHandle.signal(Map.of("event", "payment_confirmed"));
Route the signal to the outer owner.
Guided Rewrite
Compare the two companion examples and practice choosing the outer owner before you write any DSL.
- Start with the business question. Are you mainly tracking a conversation, or a lifecycle state?
- Choose the outer runtime. Session for conversation, state machine for lifecycle.
- Nest only the portion that has a different shape.
- Order flow: the outer interaction is a customer/order session, but one phase is best expressed as states.
- Review flow: the outer lifecycle is a review workflow, but one state needs a short collect → finalize interaction.
- Write one downstream expression that proves the output path is understandable.
If the path expression is unreadable, your boundary is probably wrong.
Brain Check
-
In the session-outermost pattern, who receives external signals?
(The outer
SessionExecutor/SessionHandle.) -
In the state-machine-outermost pattern, can the nested session suspend for a later external signal?
(No. It must complete synchronously today.)
-
Why is
ctx.ordering.output.orderFlow.stateMachine.currentStateIda good thing, not a bad thing?(Because it preserves ownership and provenance across the nested boundary.)
-
What should you decide first when designing a nested flow?
(Which runtime owns the main business lifecycle.)
-
When is session-outermost usually the safer default?
(When the flow is long-lived, signal-driven, and centered on a human or multi-turn interaction.)
-
What is the key risk of treating nested composition as symmetric?
(You may design an inner session that needs to suspend, even though the state-machine-outermost pattern does not support that today.)
Lab
Goal: choose and implement the right outer owner for two similar-looking workflows.
- Model a customer order flow where the customer may respond hours later to payment prompts.
- Model an internal approval flow where the review interaction completes immediately from existing data.
- For each workflow, write down:
- outer runtime
- inner runtime (if any)
- who receives signals
- one sample downstream output path
- Implement one DSL file for each shape.
Stretch: add a test that proves you can signal the session-outermost example through the outer session handle and still reach the nested state-machine terminal state.
Experiment acceptance card
- Expected and observed: The outer owner controls lifecycle; signals move inward and terminal outputs outward.
- Failure and recovery: Make Session and state machine co-own the outside; recover one owner.
- Proof boundary: Proves composition ownership and signal direction, not deadlock-free arbitrary nesting.
- Exercise contract: Support composition; swap only the outer owner; deliver a choice card and sequence; stop with one owner.
Recap
- You can compose
sessionandstate_machine, but the two directions have different constraints. - The outer runtime owns signalling, recovery, and the business identity that matters most.
- Session-outermost is the right fit for long-lived, externally driven interactions with an inner lifecycle.
- State-machine-outermost is the right fit for named lifecycle states that contain a short synchronous inner interaction.
- Long output paths are a feature: they preserve provenance across nested boundaries.
- Choose the outer owner first, then justify whether any inner slice really needs its own orchestration model.
Durable variants for production. When composing sessions and state machines in production, wire the corresponding durable managers: use
DurableSessionManager(frombloge-session-durable) for crash-safe session lifecycle, andDurableStateMachineManager(frombloge-state-durable) for crash-safe state-machine lifecycle. The composition patterns in this chapter remain the same — the durable wrappers add checkpoint persistence under the same API surface.
Next Step
In Chapter 17 — Putting a Nondeterministic Agent Inside a Deterministic Lifecycle, the same ownership discipline contains a nondeterministic Agent inside explicit tool, turn, and lifecycle boundaries. Chapter 18 then returns to testing discipline.
Reference Links
order-session-with-state-machine.bloge— session-outermost examplereview-state-machine-with-session.bloge— state-machine-outermost exampleivr-customer-service.bloge— longer-lived session exampleOrderSessionWithStateMachineExample.java— Java builder implementationReviewStateMachineWithSessionExample.java— Java builder implementationOrderSessionWithStateMachineExampleTest.java— nested state-machine runtime testReviewStateMachineWithSessionExampleTest.java— nested session runtime test
Coding Agent: Open the versioned task guide.