Files
lda-wf/docs/wf_authoring_control_flow.md
T

7.2 KiB

wf_authoring Control Flow

Use this document when choosing how to wire branches with WorkflowBuilder.

The authoring API intentionally separates different control-flow ideas instead of putting them all behind one overloaded method.

Method Use when Creates condition nodes?
branch an existing step already returned an outcome label no
handle several source/outcome pairs should go to one target no
match one state/input/context value should equal one of several values yes
when one boolean expression chooses between two targets yes
choose ordered boolean expressions choose the first matching target yes

route() still exists only as deprecated compatibility sugar. New code should use match() or when() directly.

branch: Route Node Outcomes

Use branch() when a node already decides its own outcome.

router = g.use(classify_message)
send = g.use(send_email)
skip = g.use(skip_email)
fail = g.use(runtime_error)

branches = g.branch(
    router,
    {
        "send": send,
        "skip": skip,
        "error": fail,
    },
)

This only adds edges:

classify_message.send -> send_email
classify_message.skip -> skip_email
classify_message.error -> runtime_error

The return value is a BranchResult. It exposes the resolved source and lets tests or later code retrieve targets by outcome:

assert branches.source is router
assert branches["send"] is send

handle: Shared Outcome Target

Use handle() when several steps should route the same kind of outcome to one target.

fail = g.use(runtime_error)

errors = g.handle(
    (lookup_user, "error"),
    (charge_card, "error"),
    (send_receipt, "error"),
    to=fail,
)

This is not a join and it does not wait for multiple branches. It only writes edges:

lookup_user.error -> fail
charge_card.error -> fail
send_receipt.error -> fail

The return value is a HandleResult with the shared target and the resolved source/outcome pairs.

match: Equality Dispatch

Use match() when one graph value chooses a target by equality.

decision = g.match(
    state("status"),
    {
        "approved": approve,
        "rejected": reject,
        "pending": wait,
    },
    default=fail,
)

This lowers to an ordered chain of generated condition nodes:

if state.status == "approved": approve
elif state.status == "rejected": reject
elif state.status == "pending": wait
else: fail

Condition ids are source-derived by default, such as state_status, state_status_2, and so on. Pass id="status_choice" when stable generated ids matter.

The return value is a DecisionResult:

g.set_entry_point(decision.entry)
assert decision["approved"] is approve
assert decision["default"] is fail

when: Boolean Dispatch

Use when() when one boolean expression chooses between two targets.

decision = g.when(
    state("retry_count").lt(3),
    then=retry,
    otherwise=fail,
)

This lowers to one condition node with true and false edges.

The return value is also a DecisionResult:

assert decision[True] is retry
assert decision[False] is fail

choose: Ordered Predicate Chain

Use choose() when the graph should try several boolean expressions in order and route to the first true target.

decision = g.choose(
    (state("score").ge(90), gold),
    (state("score").ge(70), silver),
    (state("score").ge(50), bronze),
    default=fail,
    id="score_tier",
)

This lowers to:

if state.score >= 90: gold
elif state.score >= 70: silver
elif state.score >= 50: bronze
else: fail

choose() is still one explicit call. Fluent or operator-heavy syntax can be built on top later, but should delegate to this API rather than rebuilding edge logic itself.

Defaults

match(), when(), and choose() default their fallback path to the standard runtime_error node. This makes missing cases fail loudly instead of silently ending or continuing with unclear state.

Pass an explicit default= or otherwise= when the fallback is valid business logic.

NodeSpec Targets

connect(), branch(), handle(), match(), when(), and choose() accept either existing step refs or NodeSpec objects as targets. Passing a NodeSpec creates a fresh use() step with auto-mapping and an auto id.

Use existing step refs when the same node use should be shared. Pass a NodeSpec when you want a new use at that point in the graph.

Concurrent foreach

Use foreach(mode="concurrent") when item lineages may make progress independently but should still commit their state writes at one deterministic barrier.

each = g.foreach(
    id="each",
    over=state_path("items"),
    as_="item",
    mode="concurrent",
    concurrent={"max_active": 2, "max_outstanding": 2},
    item_error={
        "action": "collect",
        "collect_to": state_path("errors"),
    },
)

item_error is the canonical policy field. It accepts:

  • "fail" or "skip" when no extra policy fields are needed;
  • a mapping when fields such as collect_to are needed;
  • the core ForeachItemErrorPolicy object.

item_error="collect" is intentionally incomplete and fails validation because collect must say where error records should be written. on_item_error is deprecated compatibility shorthand and should not appear in new examples.

Concurrent foreach is not a general fork/gather node. It is still one foreach step with item-local child lineages:

  • each item sees its own buffered writes while it runs;
  • sibling item writes do not leak into each other before the barrier;
  • final barrier commits happen in item-index order;
  • same-path sibling writes require a mergeable reducer on that exact state path;
  • item_error.action="collect" requires collect_to to point at a declared array state field.

In async execution, admitted async item node handlers may run at the same time. Run-state mutation, tracing, and barrier commits remain deterministic.

An iteration body returns through its immediate owning foreach:

g.connect(each, "loop", record)
g.connect(record, "ok", each)
g.connect(each, "done", END)

Region conflicts, unreachable nodes, body terminals, non-local returns, empty bodies, and bodies without possible returns fail validation.

See examples/authoring_concurrent_foreach.py for a runnable example covering:

  • sync concurrent foreach with item_error={"action": "collect", ...};
  • async item-node batching with deterministic output order;
  • item_error as a string, mapping, or ForeachItemErrorPolicy object.
  • the replace-conflict case when sibling item writes target a non-mergeable state path.

Deprecated route

route() is a compatibility shim:

  • route(state("x"), {"a": step}) forwards to match(...).
  • route(state("x").exists(), {True: step}) forwards to when(...).

It emits a DeprecationWarning and should not appear in new examples.

Drafts

Workflow drafts currently expose only explicit outcome routes:

{
  "routes": {
    "classify": {
      "send": "send_email",
      "skip": "__end__"
    }
  }
}

Draft JSON does not yet have match, when, or choose sugar. Add that only after the Python authoring surface stays stable enough to be mirrored.