Skip to main content

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 session and state_machine without 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

  1. Compare the two composition patterns: session-outermost and state-machine-outermost.
  2. Decide which runtime owns lifecycle, signalling, recovery, and timeout semantics in a nested flow.
  3. Trace output paths through nested orchestration without flattening away important structure.
  4. Understand the current asymmetric constraints so you do not design a composition the runtime cannot support.
  5. Choose the smallest composition boundary that keeps the business model obvious.

Prerequisites

Source Examples

FileWhat it shows
ch15/order-session-with-state-machine.blogeSession-outermost composition: a phase embeds a full order lifecycle state machine
ch15/review-state-machine-with-session.blogeState-machine-outermost composition: a state embeds a short review session
ch15/ivr-customer-service.blogeA longer-lived session that could later grow nested lifecycle behavior
OrderSessionWithStateMachineExample.javaJava builder version of session → nested state machine
ReviewStateMachineWithSessionExample.javaJava builder version of state machine → nested session
OrderSessionWithStateMachineExampleTest.java / ReviewStateMachineWithSessionExampleTest.javaRuntime 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:

PatternOuter ownerInner ownerBest fit
session → nested state_machineSessionExecutorStateMachineExecutor logic wrapped as a synthetic nodeA long-lived interaction whose one phase needs explicit state transitions
state_machine → nested sessionStateMachineExecutorSessionExecutor logic wrapped as a synthetic nodeA named lifecycle state that needs a short, self-contained multi-step interaction

The patterns are both useful, but their runtime constraints differ.

Diagram: 16-composing-sessions-and-state-machines figure 1


Choose the Outer Owner Before Choosing Syntax

Composition begins with one question: which runtime must still own the case after the inner activity ends?

Diagram: lifecycle owner chooser

Dominant factOuter ownerInner activity example
A customer continues a multi-turn conversationSessionOrder state machine inside the ordering phase
An order continues through durable business statesState machineReview Session inside the review state
Work is one-shot and dependency-drivenGraphNo 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.

Diagram: bidirectional signal and output sequence

Trace one order through both owners

DirectionContract crossing the boundaryWrong shortcut
Inwardcustomer submit → Session input → state-machine submit signalAddress an inner node id from the webhook
Inwardpayment callback → owning runtime → payment_confirmed eventMutate currentState directly
Outwardstate-machine terminal output contains shipped and shipmentIdRead arbitrary inner workspace from the caller
OutwardSession fulfillment phase calls notifyCustomerLet 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 SessionExecutor is 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_machine must 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 session inside a state_machine cannot 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 raises SM_NESTED_SESSION_AWAITS_SIGNAL and refuses to publish the graph.

The reverse — a nested state_machine inside a session phase — 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:

QuestionIf the answer is “session”If the answer is “state machine”
What is the main business interaction?Human or multi-turn interactionObject lifecycle or status progression
Who receives external signals?The sessionThe state machine
Who owns idle timeout / access guard?The session runtimeThe state-machine runtime
What should a dashboard show first?Current phase / roundCurrent state
Can the inner flow wait across external turns?Yes, if the outer owner is the sessionNo, 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 session outermost
  • if humans think in named states, make state_machine outermost

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.

  1. Start with the business question. Are you mainly tracking a conversation, or a lifecycle state?
  2. Choose the outer runtime. Session for conversation, state machine for lifecycle.
  3. 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.
  4. Write one downstream expression that proves the output path is understandable.

If the path expression is unreadable, your boundary is probably wrong.


Brain Check

  1. In the session-outermost pattern, who receives external signals?

    (The outer SessionExecutor / SessionHandle.)

  2. In the state-machine-outermost pattern, can the nested session suspend for a later external signal?

    (No. It must complete synchronously today.)

  3. Why is ctx.ordering.output.orderFlow.stateMachine.currentStateId a good thing, not a bad thing?

    (Because it preserves ownership and provenance across the nested boundary.)

  4. What should you decide first when designing a nested flow?

    (Which runtime owns the main business lifecycle.)

  5. 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.)

  6. 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.

  1. Model a customer order flow where the customer may respond hours later to payment prompts.
  2. Model an internal approval flow where the review interaction completes immediately from existing data.
  3. For each workflow, write down:
    • outer runtime
    • inner runtime (if any)
    • who receives signals
    • one sample downstream output path
  4. 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 session and state_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 (from bloge-session-durable) for crash-safe session lifecycle, and DurableStateMachineManager (from bloge-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.


Coding Agent: Open the versioned task guide.