WritingCompliance
State machine or DAG for a compliance workflow builder
How to choose between a state machine and a DAG when ops staff design their own review workflows, and what each costs when a reviewer sends a case back.
A vendor onboarding workflow in a compliance tool has five steps: collect the company's details and director list, upload an insurance certificate, screen the directors against sanctions lists, have a reviewer check the certificate, and have a manager approve. On Tuesday at 16:42 the reviewer opens case 4471, sees that the certificate expired on 31 August, and clicks "send back to vendor". The vendor uploads a new certificate on Thursday morning and, while in the form, adds a director who joined in September. What "send back" means in the data model is decided by one earlier choice: whether the workflow builder is a state machine or a directed acyclic graph.
You face this choice the week you stop hard-coding each review flow and let operations staff design their own. My position is that a compliance workflow builder should be a state machine, because reviewers send cases backwards and a DAG has no truthful way to express that; the DAG belongs inside a single automated state, running the checks that fan out and join. Plenty of good engineers choose the DAG first because the automated steps are the ones that look like a graph, and that is the order in which this decision usually goes wrong.
What decides it
Both models run the same five steps forward and nobody can tell them apart on the happy path. They part company on three questions: what happens when a step is revisited, who owns a step while it waits, and what the record of the case looks like a year later when an auditor asks why vendor 4471 was approved with an unscreened director.
| Criterion | State machine | DAG |
|---|---|---|
| A reviewer sends a case back | A transition to an earlier state, recorded like any other | A re-run of one node while downstream nodes stay marked complete |
| Checks that run in parallel | Needs a composite state, or a small DAG inside one state | Native: nodes fan out and a join node waits for all of them |
| Who owns a waiting step | A named actor per state, so each state is a work queue | The engine, with humans modelled as blocking nodes |
| The question ops ask | Where is case 4471 and who has it | What is still running for case 4471 |
| Adding a step to a live workflow | A new state and new transitions; open cases stay valid | A new node; open runs finish on the old graph or need migrating |
| The audit trail | Transitions with actor, time and input version | Node completions, with re-runs overwriting or duplicating |
I have built a Form Builder and a Workflow Builder for a compliance product, and the question I put to every proposed step is whether a person can send the case somewhere earlier from it. If the answer is yes for even one step, the whole workflow is a state machine, however many automated nodes it also contains.
State machine
Best for Review workflows where a person owns steps and cases go backwards
A case is in exactly one state, and the workflow is the list of allowed transitions, each with an actor and a guard.
- Strengths: a send-back is a transition from review to collecting, stored beside every other transition, so the case's history reads in order without special cases.
- Strengths: every state is a queue with an owner, so "show me what is waiting on the vendor" is a filter on one column.
- Costs: two checks running at once have no single state to sit in, so you either invent a composite state or run a graph inside the state.
- Costs: the builder must let ops draw transitions, not just list steps, and a list is what they will ask for first.
DAG
Best for Automated pipelines where each node runs once per input and the output is a verdict
A run is a set of nodes with edges; a node starts when every edge into it has completed, and a run ends when the sink node completes.
- Strengths: screening directors, screening the company and checking the certificate run concurrently with no extra modelling.
- Strengths: the engine can be generic; a node is a function of its inputs and the graph says nothing about people.
- Costs: there is no edge back to an earlier node by definition, so a send-back becomes a re-run with downstream results left in place.
- Costs: a waiting human is a node that blocks for days, and the run's status says "running" rather than "with the vendor".
flowchart TD
A{Can a person send a case back} -->|Yes| B[State machine]:::accent
A -->|No| C{Do checks fan out and join}
C -->|Yes| D[DAG]
C -->|No| E{Does a person own a step}
E -->|Yes| B
E -->|No| D
B --> F[DAG inside automated states]How the DAG approves an unscreened director
Walk case 4471 through a DAG built from the five steps. Node 2, the certificate upload, is re-run on Tuesday because the reviewer clicked "send back". Node 3, the sanctions screen, completed on Tuesday at 09:10 against director list version 1 and is still marked complete; nothing in the graph connects node 3 to a re-run of node 2. On Thursday the vendor uploads the certificate and adds the new director, so the case's inputs are now version 2.
The join node checks its inputs: node 3 complete, node 4 (certificate review) pending. The reviewer looks at the new certificate, which expires next July, and approves. The join completes, the approval node opens, and the manager sees four green ticks and a screening badge dated Tuesday. The manager approves at 11:20. What the system sees is a run where every node completed successfully. What the business sees, months later, is an approved vendor whose newest director was never screened, and an audit record that supports that outcome at every step.
What contains it is a version number on the case's inputs that every node result carries, and a join that refuses when any result's version is older than the case's current one. That guard is not a DAG feature; it is the same guard a state machine puts on the transition into approval, which is why the model that makes the guard natural is the one to pick.
// Simplified: a transition table with guards on input version.
type CaseState =
| 'collecting' | 'screening' | 'review' | 'approval' | 'approved' | 'rejected';
type Actor = 'vendor' | 'reviewer' | 'manager' | 'system';
interface Case {
state: CaseState;
inputVersion: number; // bumped on every edit to vendor data
screening?: { inputVersion: number; clear: boolean };
history: { from: CaseState; to: CaseState; actor: Actor; inputVersion: number }[];
}
interface Transition {
from: CaseState; to: CaseState; actor: Actor; guard?: (c: Case) => boolean;
}
const fresh = (c: Case) => c.screening?.inputVersion === c.inputVersion;
const transitions: Transition[] = [
{ from: 'collecting', to: 'screening', actor: 'vendor' },
{ from: 'screening', to: 'review', actor: 'system', guard: fresh },
{ from: 'review', to: 'collecting', actor: 'reviewer' }, // send back
{ from: 'review', to: 'approval', actor: 'reviewer', guard: fresh },
{ from: 'approval', to: 'approved', actor: 'manager', guard: fresh },
{ from: 'approval', to: 'rejected', actor: 'manager' },
];
function apply(c: Case, to: CaseState, actor: Actor): Case {
const t = transitions.find(
(x) => x.from === c.state && x.to === to && x.actor === actor,
);
if (!t) throw new Error(`no transition ${c.state} to ${to} for ${actor}`);
if (t.guard && !t.guard(c)) throw new Error(`stale inputs for ${to}`);
const step = { from: c.state, to, actor, inputVersion: c.inputVersion };
return { ...c, state: to, history: [...c.history, step] };
}In this table the Thursday edit bumps inputVersion to 2 while the case sits in collecting. When the vendor resubmits, the case enters screening, the screen runs against version 2, and only then does fresh allow the move to review. The reviewer cannot skip to approval on Tuesday's screen, because the guard reads the version, not the tick.
stateDiagram-v2 [*] --> collecting collecting --> screening : vendor submits screening --> review : screen is fresh screening --> rejected : screen hits review --> collecting : reviewer sends back review --> approval : reviewer accepts approval --> approved : manager approves approval --> rejected : manager rejects review:::accent
Where the state machine costs you
The price shows up at the screening state. Screening the directors, screening the company and validating the certificate's issuer are three independent calls, and a state machine wants the case in one state while they run. The clean answer is to make screening an automated state that owns a three-node DAG: two screen nodes and a join, all stamped with the input version, with the join raising the screen is fresh or screen hits event. Ops never see that graph; they see one box called Screening with two exits.
The other cost is the builder itself. Operations staff will describe a workflow as a numbered list, and a list of steps is a chain with no send-back edges. The builder has to ask, for each step, who owns it and where it can go, and refuse to save a workflow where a human-owned step has no exit to an earlier state. A review step with no send-back is a review step the reviewer will route around by email.
Whichever model wins, build the input version first: one counter on the case that increments on every edit to the submitted data, stamped on every check result and every transition, with a guard that refuses approval on a stale version. The builder UI, the queues and the parallel checks all sit on top of that number, and none of them can be trusted without it.