Visual workflow editors are sold on a simple promise: the people who own a process can change it without waiting for engineering. The promise is real, and it is usually delivered with a gap underneath it. The editor treats a published flow as configuration — a setting that was updated — when what actually happened is a release of software that decides outcomes for people.

The difference shows up the first time someone asks which rule a case was judged under. If the answer requires knowing what the flow looked like on a Tuesday four months ago, and the system only stores what the flow looks like now, there is no answer.

This is about the small number of design decisions that close that gap, and one conversion bug that quietly breaks conditional routing in a lot of flow engines.

Draft, publish, version

A workflow definition needs three states, and they are not interchangeable.

  • Draft — editable, incomplete, never executed. Saving a draft has no effect on any running case.
  • Published — immutable, numbered, executable. Publishing produces a new version rather than mutating the previous one.
  • Superseded — a version that is no longer the default for new runs but is still the authority for the runs it started.

The immutability is the whole point. If publishing overwrites, then every historical run points at a definition that has since changed, and the audit answer degrades to “here is the current flow, which may or may not be what ran”. Numbering the version and pinning each run to the version that started it costs a column and buys the ability to reconstruct any decision.

It also changes how teams behave. Once publishing produces a numbered artifact, people start writing down why they published, the same way they write commit messages. A flow change that adjusts a confidence threshold becomes a reviewable event rather than a field somebody edited.

The graph has to survive publication

Here is the failure mode worth the most attention, because it is common and it fails silently.

An editor represents the flow as a graph: nodes with identifiers, edges between them, and branches leaving a conditional node through distinct handles. The runtime, historically, executes a list of steps in order. So publication converts the graph into an ordered list — and the conversion is lossy exactly where the flow is most interesting.

A linear flow converts cleanly. A conditional does not. Once the graph is flattened to phase_order = 1, 2, 3, 4, the information that step 3 should only run on the true branch has nowhere to live. The engine runs every step in sequence, the branch condition becomes decorative, and nothing errors.

The minimum contract that prevents this has three parts:

  • Stable node identity — the identifier the editor assigned to a node is carried through publication into the executable definition, so the runtime can route by node rather than by position.
  • Persisted outgoing edges — each node stores its outgoing edges, and each edge records which branch it represents, so a conditional's true and false paths remain distinguishable.
  • An explicit legacy path — definitions published before the graph was captured have no node identity and no edges; the engine must fall back to linear order for those rather than treating an absent graph as an empty graph.

That third point is the one teams skip. A migration that assumes every definition has a graph will route older runs into a flow with no reachable next step, and the symptom is runs that stop without an error.

{
  "node_id": "review-gate",
  "phase_type": "review",
  "phase_order": 3,
  "outgoing_edges": [
    { "to": "generate-contract", "handle": "true"  },
    { "to": "reject-notice",     "handle": "false" }
  ]
}

phase_order stays for the legacy path. node_id and the edges are what the graph-aware engine actually routes on.

A run is a sequence of claimed steps

Execution has its own set of requirements that visual editors rarely surface, because they only become visible under load or failure.

Each step in a run should be an addressable record that can be claimed by exactly one worker, retried with backoff, and left in a terminal state that says what happened. Not a position in a loop. The reasons are practical: work is dispatched across processes, a step that calls an external service will time out, and a retry must not re-execute a side effect that already succeeded.

Idempotency is the part that bites. A step that starts a collection request, renders a document or sends a signature envelope has an external effect. If the claim is lost and another worker picks the step up, the reservation has to be keyed on something stable enough that the second attempt recognises the first one's work instead of duplicating it. This is unglamorous and it is the difference between a flow engine that survives a deploy and one that sends two contracts.

What a phase is allowed to be

The value of a dynamic flow comes from the phases being real capabilities rather than generic script blocks. A flow that composes collection, classification, extraction, redaction, review, document generation, signature and webhook delivery is expressive because each phase already knows how to do something hard.

PhaseWhat it contributes to the run
CollectionThe requirement checklist and the expected class per item
ClassificationThe route, and a mismatch signal against the expected class
ExtractionTyped fields with confidence and evidence references
ReviewAn assignment, a reason for entry and a measurable queue
GenerationA document rendered from the fields the run already holds
SignatureAn envelope, signer authentication and a sealed artifact
WebhookSigned delivery with retry and a delivery record

The conditional node is what makes this a flow rather than a pipeline. Routing on an extracted value, a confidence band or a classification mismatch is where a business rule stops living in a reviewer's head and becomes something the system can be measured against.

Instrument the flow, not the model

Once a run is a sequence of versioned steps, a set of questions becomes answerable that no model metric can reach.

  • Straight-through rate — what fraction of runs reached a terminal state with no human step, by definition version.
  • Where runs stop — which node absorbs the most elapsed time, which is almost always a human queue rather than a model call.
  • Branch distribution — how often each conditional took each path, which is how you discover a rule that never fires.
  • Version deltas — whether the flow published last week moved completion, cost or review load relative to the one before it.

That last one only exists because runs are pinned to versions. Without pinning, a change in outcomes is uncorrelated with the change that caused it, and process tuning becomes folklore.

The audit question, answered

Return to the question this started with. A regulator, an auditor or a customer's lawyer asks why a specific case was declined in March.

With versioned definitions and pinned runs, the answer is mechanical: this run executed definition version 7, entered the conditional at this node with this extracted value, took the false branch, landed in the review queue, and a named reviewer closed it with this reason. Every element is a record, not a reconstruction.

Without it, the honest answer is that the flow was changed some number of times since and nobody knows which shape was live. That answer is survivable exactly once. Our audit trail for non-deterministic systems covers the model side of the same problem; the flow side is cheaper to fix and gets skipped more often.

Letting the business own the flow is the right call. Treating each publication as a release — numbered, immutable, pinned to the runs it produced — is what makes it a call you can defend afterwards.