Skip to content

Flows (the plan builder) ​

The plan package is a typed flow builder: a Go-embedded builder you author as typed handles, compiled to the same journal-backed runtime as plain Go. It adds a way to author, never a way to execute. Every node lowers to a memoized Do step, so a flow inherits at-most-once side effects, halt-on-ambiguity, and durable resume for free, and it can be checked against its declared shape (conformance).

Requires Go 1.27 (the builder uses generic methods).

When to reach for it ​

Plain Go plus the durable primitives (durable steps) already gives you the guarantees and full type safety. Reach for plan when you want the flow to be a value: a declared topology you can render, diff, version, hand to a visual builder, and, above all, prove a run followed (did the run do what the diagram said?). If you do not need a declared topology, plain Go is simpler and wins on every other axis.

A flow, end to end ​

go
import (
    "github.com/bide-ai/bide/agent"
    "github.com/bide-ai/bide/plan"
)

f := plan.New[Order, Receipt]("triage")                  // input and output pinned here

classify := f.Step("classify", classifyOrder)            // Order -> Assessment (types inferred from the func)
reserve  := f.Step("reserve", reserveInventory)          // Assessment -> Reservation (a non-idempotent effect)
finalize := f.Step("finalize", finalizeReceipt)          // Reservation -> Receipt
decline  := f.Step("decline", declineReceipt)            // Assessment -> Receipt

f.Switch(classify,                                       // route on classify's output
    plan.When(func(a Assessment) bool { return a.Rush }, reserve),
    plan.Else(decline),
)
f.Edge(reserve, finalize)

flow, err := f.Build()                                   // validates the whole graph; returns *plan.Flow
if err != nil {
    return err
}

out, err := flow.Run(ctx, store, runID, order)           // Order in, Receipt out

store is any agent.Durable (an in-memory store for tests; store/sqlite or store/postgres for production). runID is the durable identity: re-running the same runID resumes from the journal.

The pieces ​

  • New[In, Out](name) pins the flow's input and output types at construction, so the boundary is checked there rather than by an afterthought.
  • Nodes are builder methods that return a typed Handle:
    • Step[I, O](name, func(I) (O, error)) wraps arbitrary Go. I and O are inferred from the func.
    • Tool[I, O](name, agent.Tool) runs a tool; give I/O explicitly (they say how to JSON-encode the input and decode the result).
    • Model[I, O](name, prompt) is a model turn: bind a model with Builder.WithModel(m) (or, in a declarative config, Load's WithLoadedModel). The node renders prompt as a text/template over the typed input I, calls the bound model, and decodes the structured response into O (so O must be JSON-shaped and the prompt should ask for matching JSON). Build errors if a Model node has no bound model, naming it.
    • The string name is the node's durable journal key: it must be unique (Build enforces it) and stable across code edits, because resume finds a step by this name. It is not just a label.
  • Wiring takes handles, so a miswired connection does not compile:
    • Edge[M](from, to) connects a producer to a consumer, unifying the connecting type M.
    • Switch[M](over, When(pred, to)..., Else(to)) routes on a node's output to exactly one arm. Switch arms do not reconverge (each arm runs to a terminal producing Out); use Join2/Join3 for fan-in of branches that both run, and a When arm with a loopMax back-edge for a bounded loop.
  • Build() validates whole-graph coherence (entry consumes In, every terminal path produces Out, names unique, no unreachable node, at most one Else per switch) and freezes the spec into a *Flow. Errors name the offending node.
  • Run(ctx, store, runID, in) drives the flow sequentially on the runtime and returns the typed output. It adds no executor.
  • RenderMermaid() renders the declared topology (contrast agent.RenderMermaid, which renders the actual-ran path from the journal).
  • Conform(ctx, store, runID) checks a run's journal against the declared topology and returns whether it followed the graph (ok), the divergences, and an error.

What you get, and the semantics to know ​

Every node lowers to a memoized Do step under a two-phase attempt/result guard, so:

  • At-most-once with halt, automatic. Before a node's body runs, Run journals an attempt:<name> marker; after it succeeds, it journals the result <name>. On resume: a recorded result replays (the body does not re-run); an attempt with no result means the outcome is unknown, so Run halts with a *plan.HaltAmbiguous rather than re-firing the body. A Switch choice is journaled as its own step switch:<over> and replayed, so a resumed run takes the branch it originally took; predicates must therefore be pure functions of the node's output.
  • Halt is per node, and conservative by default. Because the attempt marker is written before the body, any crash inside a node halts on resume unless the node is classified safe to repeat. The default (no classification) never double-fires, but completing after a mid-node crash then requires resolving the halt out of band (record the halted node's result, then continue), which is only safe when that node has no side effect. A node may instead declare a Safety (read-only, idempotent, or retryable, via ReadOnly()/Idempotent()/Retryable() in Go, or safety in a declarative config) so it re-runs on resume instead of halting.
  • Conformance. Because the flow is authored and the actual path is derived from the journal, a run can be proven to have followed the declared topology, at node-visitation granularity plus the journaled branch choice. The blind spot: conformance sees that a node ran, not what its Go body did inside, so a node whose interior must be checked should be split into smaller nodes.

Cryptographic conformance ​

Conform proves the run followed the declared graph against the same process's copy of the flow. Cryptographic conformance goes one step further: it makes that claim offline-verifiable by an auditor who never trusts your process, your database, or your logs. The property proven is precise: this run committed to THIS declared topology.

Two pieces make it work:

  • A topology digest. flow.Digest() returns a deterministic SHA-256 (hex) of the frozen spec: the flow name, each node's name + kind + input type + output type, every edge, and each Switch with its ordered arms. It is computed by walking the insertion-ordered spec (never a map), so it is stable across builds and processes and changes whenever the topology changes (a renamed or retyped node, an added or reordered edge, a changed arm). It commits to topology, not to node bodies.
  • A journaled record the audit layer covers. The first thing Run records is the digest, as a durable step under the reserved name flow:digest (memoized on resume). Because it lives in the journal, the audit package's Merkle tree and signed tree head commit to it like any other record.

The flow, end to end:

  1. flow.Run(ctx, store, runID, in) executes the flow. Its first journal record is flow:digest.
  2. audit.NewTreeHead(ctx, store, runID, ts) then audit.SignTreeHead(th, priv) commit to the run's journal with a signed tree head (STH). Anchor the STH and its key in a separate trust domain; that is what makes it tamper-evident (see the audit package security model).
  3. audit.ProveRecord(ctx, store, runID, i, sth) builds an RFC 6962 inclusion proof for the flow:digest record (index i), bundled with the STH.
  4. An auditor holding only the bundle and the signer's public key (obtained out of band) checks bundle.Verify(pub) (the STH signature is authentic and the record is included under the signed root) and that the proven digest equals the declared flow's flow.Digest(). Together: the run committed to this signed diagram.

Conform closes the loop on the path: it recognizes flow:digest as an internal record (never a divergence) and verifies the journaled digest equals the current flow's Digest(). A mismatch is reported as a divergence, "ran against a different topology": the run executed under a different declared graph than the flow now describes. So Conform covers node-visitation and branch choices, and the digest + inclusion proof cover which topology the run committed to, verifiable offline.

The examples/plan demo prints this after a clean run (Cryptographic conformance: the run committed to the declared topology under the signed tree head), and TestCryptographicConformance there proves it in-process, including that a tampered flow:digest record no longer verifies under the signed root.

Declarative config ​

The builder authors a flow as typed Go. You can also author the same flow as data: a declarative config that describes the topology (nodes, edges, switch arms) and references behavior by name. A config places and wires nodes; it cannot write a Step's body or a Switch's predicate. Those stay in Go and are referenced by name through a registry. A config loads into the same builder and produces the same *Flow, so it inherits RenderMermaid, Conform, and the topology Digest unchanged.

The registry and Load ​

Registration maps config names to typed Go blocks, capturing each block's I/O types via reflect.TypeFor, so the config never restates types; they flow from the registered block.

go
reg := plan.NewRegistry()
plan.RegisterStep(reg, "classify", classifyOrder)              // infers Order -> Assessment
plan.RegisterStep(reg, "reserve", reserveInventory)            // Assessment -> Reservation
plan.RegisterStep(reg, "finalize", finalizeReceipt)            // Reservation -> Receipt
plan.RegisterStep(reg, "decline", declineReceipt)              // Assessment -> Receipt
plan.RegisterPredicate(reg, "rush", func(a Assessment) bool { return a.Rush })
  • NewRegistry() returns a fresh, explicit, per-Load registry. There is no global mutable default, and a duplicate registration is an error, never a silent overwrite.
  • RegisterStep[I, O] infers I/O from the func. RegisterTool[I, O] and RegisterModel[I, O] take them explicitly (an agent.Tool and a prompt carry no I/O types). A Model node needs a bound model, supplied to the loaded flow via Load(..., WithLoadedModel(m)). RegisterPredicate[M] captures the switched type M; RegisterJoin2/RegisterJoin3 register a merge block for a join.

Loading supplies the boundary types at the Go call site (the caller knows them); the loader fills the middle from data:

go
flow, err := plan.Load[Order, Receipt](configBytes, reg)   // *Flow[Order, Receipt], or a load error

LoadReader[In, Out] is the same reading from an io.Reader (an *os.File or an HTTP body).

The config schema ​

A config is pure topology plus block references: a top-level flow name, optional in/out documentation, an explicit entry (else nodes[0]), the nodes, and an ordered wiring list. Each wiring element is EITHER an edge {"edge": [from, to]} OR a switch {"switch": over, "when": [{"pred": p, "to": t}], "else": t}. Illustrative YAML:

yaml
flow: order-triage
in: main.Order            # optional, cross-checked against Load's In
out: main.Receipt         # optional, cross-checked against Load's Out
entry: classify
nodes:
  - {name: classify, block: classify}
  - {name: reserve,  block: reserve}
  - {name: finalize, block: finalize}
  - {name: decline,  block: decline}
wiring:
  - switch: classify
    when: [{pred: rush, to: reserve}]
    else: decline
  - edge: [reserve, finalize]

The equivalent JSON (the loader format) is what examples/plan/declarative.go embeds and loads.

Fan-in: the join wiring element ​

A third wiring form fans several producers back into one node: {"join": name, "inputs": [...], "merge": mergeBlock}. The merge names a merge block registered with RegisterJoin2[A, B, O] (or RegisterJoin3), whose arity and input types Load checks against the join's declared inputs. As with steps and predicates, only the shape is data; the merge body stays registered Go referenced by name. A canonical fan-out-then-fan-in diamond as data:

yaml
nodes:
  - {name: split, block: split}   # int -> int, fans out
  - {name: y,     block: y}        # int -> int
  - {name: z,     block: z}        # int -> string
wiring:
  - edge: [split, y]
  - edge: [split, z]
  - join: merge
    inputs: [y, z]
    merge: mergeBlock              # RegisterJoin2(reg, "mergeBlock", func(int, string) (string, error))
    safety: readonly               # optional; see "Node and join safety" below

The merge block:

go
plan.RegisterJoin2(reg, "mergeBlock", func(a int, s string) (string, error) {
    return fmt.Sprintf("%s+%d", s, a), nil
})

Bounded loops: the loopMax back-edge arm ​

A switch when arm may carry a loopMax: {"pred": p, "to": head, "loopMax": n}. This is a bounded back-edge that routes to an ancestor of the switched node (the loop head) while pred holds, up to n iterations, so the graph stays finite. The arm's pred is an ordinary registered predicate referenced by name; loopMax is the only new field. A bounded countdown loop as data:

yaml
nodes:
  - {name: seed,   block: seed}     # int -> LoopState (entry)
  - {name: refine, block: refine}   # LoopState -> LoopState (the loop head)
  - {name: check,  block: check}    # LoopState -> LoopState (the loop switch)
  - {name: done,   block: done}     # LoopState -> string (the exit terminal)
wiring:
  - edge: [seed, refine]
  - edge: [refine, check]
  - switch: check
    when: [{pred: again, to: refine, loopMax: 10}]   # loop back to refine while N>0
    else: done                                        # exit

The same load-time validation Build gives a hand-built loop applies: the back-edge target must be an ancestor of the switch, the bound must be positive, and the routed type must equal the head's input type.

Node and join safety ​

A node (or a join) may carry a safety classifying how Run treats it on the ambiguous-crash window (an attempt recorded, its result lost to a crash): "readonly", "idempotent", or "retryable". A node with a safety re-runs its body on resume rather than halting, because a read-only or idempotent body is safe to repeat. The default (no safety) is the conservative halt. The config safety overrides the registered block's default, so the classification is authorable as data:

yaml
nodes:
  - {name: read, block: read, safety: readonly}   # re-run on resume, do not halt

The join wiring element takes the same optional safety (shown in the diamond above), since a merge block carries no safety of its own.

Load-time validation ​

Moving topology from Go to data trades compile-time type checking for load-time validation: a miswired config does not fail at go build, it fails at Load, with a worded error that names the offending nodes and types. Load runs, by reflect.Type identity:

  • Predicate typing: every switch arm's registered predicate M equals the switched node's output type. This is a strict improvement over the Go builder, which only checks a predicate at its compile-time call site.
  • Edge typing: every edge's from.outType equals to.inType exactly (nominal identity, not assignability, matching the Go builder's Edge[M]).
  • Boundary typing: the entry consumes In, every terminal produces Out, and any present in/out documentation matches In/Out.
  • Join typing: a join's merge block must be registered, and its arity and input types must match the join's declared inputs; an unknown or mis-arity merge is a load error naming the join.
  • Structural checks Build does not give: a node cannot be both switched-over and have an outgoing edge, and a wiring[] element must set exactly one of edge/switch/join.

Load reports all failures at once (collect-all drift): it names every unresolved block or predicate with a near-miss suggestion where one exists, and flags registered blocks the config never uses. For CI, Validate(data, reg) error runs every check that does not need In/Out, so config-vs-registry drift is catchable in a unit test rather than only at process start.

Inherited for free, and conformable to its config ​

A config-loaded *Flow is an ordinary flow, so it inherits Run (sequential, at-most-once, halt-on-ambiguity), RenderMermaid (the declared topology, now sourced from the config), Conform, and Digest + the flow:digest record. Because the digest now commits to the config-derived topology, a signed tree head over the run proves offline that the run followed this config, the same way cryptographic conformance proves it followed the diagram. This is the headline: a config-loaded flow is cryptographically conformable to its config. examples/plan demonstrates it by asserting the config-loaded flow's Digest() equals the code-built flow's Digest(): the config and the Go describe the same topology. The same holds for a config-built fan-in or bounded loop: examples/plan loads a join diamond and a loopMax loop, runs and conforms each, and asserts each config-loaded flow's Digest() equals its code-built counterpart, so a config-built join or loop is cryptographically conformable to its config just like the linear case.

JSON is the loader (and tool-emit / interchange) format; the core loader stays stdlib-JSON and dependency-free. YAML is a thin authoring front-end that decodes into the same config struct, not a core dependency, so the two formats are just front-ends to one loader.

Limits ​

  • Fan-in is fixed-arity (Join2/Join3); unbounded or ragged fan-in is not supported.
  • Model decodes the response as JSON into O (no derived response schema yet), so O must be JSON-shaped and the prompt should instruct JSON output.
  • Requires Go 1.27.

A runnable example ​

examples/plan is a self-contained module: it builds an order-triage flow, prints the declared diagram, runs it against a sqlite-backed store, conforms the run, and proves cryptographic conformance (a signed tree head over the run plus an inclusion proof that the journaled topology digest equals flow.Digest()). Its cross-process test crashes mid-run and resumes in a fresh process, proving the side effect fires at most once and the run either completes or halts. Run the demo with cd examples/plan && GOWORK=off go run ..

Apache-2.0 licensed.