Files
lda-wf/docs/superpowers/specs/2026-05-19-authoring-control-flow-design.md
T
2026-07-30 01:18:53 +07:00

5.1 KiB

Authoring Control Flow Design

Goal

Make wf_authoring control flow precise enough that future sugar layers can delegate to it instead of reimplementing graph semantics.

The current problem is route(): it accepts both PathExpr and Expr, then dispatches to two different behaviors. Both "route" something, but the caller must understand different case semantics depending on the argument type.

That is too blurry for the core authoring API.

Design Rule

Each public control-flow method should name one decision mechanism.

Method Decision source Meaning
branch a node's declared outcome wire outcome labels to targets
match one graph value compare that value against equality cases
when one boolean condition route through true / false
choose ordered boolean conditions first true condition wins
handle several source/outcome pairs send shared outcomes to one target

Future fluent builders or operator sugar must call these methods rather than constructing edges/conditions independently.

Keep branch

branch() is already good:

g.branch(
    tool,
    {
        "ok": next_step,
        "error": fail_step,
    },
)

It has one clear meaning:

route an existing node outcome

It should remain named branch. Alternatives such as on_outcome are more literal but worse to use, and the current method does not suffer from the semantic overloading that route() does.

Replace route

match

g.match(
    state("status"),
    {
        "done": finish,
        "retry": retry,
    },
    default=fail,
)

Meaning:

if state.status == "done": finish
elif state.status == "retry": retry
else: fail

This is the existing PathExpr branch of route(), renamed to say what it actually does.

when

g.when(
    state("count").ge(1),
    then=positive,
    otherwise=zero,
)

Meaning:

if state.count >= 1: positive
else: zero

This is the existing Expr branch of route(), made explicit and easier to call correctly.

choose

g.choose(
    (state("x").gt(10), big),
    (state("y").exists(), has_y),
    default=fail,
)

Meaning:

if state.x > 10: big
elif state.y exists: has_y
else: fail

This is an ordered predicate chain. It should lower through the same condition construction/connect machinery as when, repeated for each clause.

choose is intentionally one call. Multiple-call fluent syntax can be built later on top of it if it proves useful.

Lowering

These methods may generate core condition nodes, but callers should not need to know the exact node construction details to choose the right API.

Canonical lowering:

  • branch
    • no new condition nodes
    • wires declared outcome strings from one source
  • match
    • ordered equality-check condition chain
    • one generated condition per case
  • when
    • one condition node
    • true and false edges
  • choose
    • ordered condition chain
    • one generated condition per clause

Trace behavior should document that match and choose expand to generated condition nodes.

Outcome Names

Outcome names are strings at the core wire level, but Python authoring should eventually avoid handwritten strings when a NodeSpec already declares them.

Future improvement:

tool.outcomes.ok
tool.outcomes.error

derived from NodeSpec.outcomes, not duplicated constants that can drift from the contract.

This is orthogonal to the control-flow rename, but it belongs in the same authoring quality bar.

Migration

route() should become deprecated compatibility sugar for one release window, then be removed.

Recommended behavior during compatibility:

  • mark route() with @deprecated so IDEs surface the replacement path
  • route(PathExpr, cases, ...)
    • warns and forwards to match(...)
  • route(Expr, {True: a, False: b}, ...)
    • warns and forwards to when(...)

The public docs should prefer only:

  • branch
  • handle
  • match
  • when
  • choose

Shared Outcome Handlers

handle() is the reverse-shaped companion to branch(): it connects several source/outcome pairs to one shared target.

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

Meaning:

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

It does not create a join, wait for multiple branches, or inspect state. It is just outcome-edge sugar for the common "several things fail the same way" case.

Not In This Pass

  • fluent/cursor builder APIs
  • operator overloading
  • graph-as-node/subgraph support
  • JSON draft match / when / choose shapes

Those later layers should depend on this API once it is stable.

Testing

Tests should prove:

  1. branch still wires outcome labels only
  2. match reproduces current value-route behavior
  3. when reproduces current boolean-route behavior
  4. choose lowers ordered predicates correctly
  5. route emits deprecation warnings while preserving old behavior
  6. later sugar can delegate to these without needing private builder internals