Jump to content
All posts
specificationopen-sourcearchitectureinteroperability

100% test coverage, and still wrong

We've published the FlowDrop Workflow Specification: 399 rules for how a workflow is written, stored, validated and run, independent of any one implementation. Here's why we wrote it down, and what it changes for you.

A workflow engine with 100% test coverage has proven it agrees with itself. It hasn’t proven what your workflow means.

A FlowDrop workflow is a document: nodes, edges and their configuration. You draw it in the editor, it’s stored on a server, and it runs on a backend. That might be a Drupal site today and a Rust service tomorrow, with an AI agent editing it in between. Every one of those has to agree on what the document means. What does a gateway do when no branch matches? What does an unfinished run report as its finish time? What does a refused import leave behind?

Test suites can’t settle that. Each engine’s tests check that engine against its own assumptions, so two engines can each pass every test and still run the same workflow differently. The meaning of a workflow has to live somewhere outside any one engine. The FlowDrop Workflow Specification is that place.

Here’s what’s at stake, from a bug we found while writing it.

A gateway node in a FlowDrop workflow routes execution down one of several branches: approved goes one way, rejected another. Now suppose the value it produces matches none of the branches you configured. What should happen?

In our own Drupal implementation, the answer was: the gateway emitted a branch name that no port carried, and everything downstream of it quietly switched off. No error, no failed step, nothing in the log that said this is where your run stopped doing anything useful. The workflow looked like it had worked.

Nobody had decided it should behave that way. It was simply what the code did. It had also never been written down anywhere, so nobody could say it was wrong. The engine’s own tests couldn’t catch it: they checked the engine against itself, and there was no definition of the workflow to check it against.

That definition is what the FlowDrop Workflow Specification provides.

What we built

The specification is the set of rules a FlowDrop workflow obeys: how it is written, stored, validated and executed. Those rules are stated independently of any one implementation. It is live at flowdrop.io/spec as version 1.0-draft.

  • 399 rules in 30 families, covering storage, schema, the expression language, the runtime, memory, error handling, orchestration, the HTTP API and more.
  • One page per rule, each with a permanent identifier: STORE-2 (a workflow must be named), BR-4 (an outcome matching no branch is loud, never a silent gate-off), and so on.
  • Rulings that record the decisions of principle behind the rules, so the reasoning is kept alongside the rules it produced.
  • Open licensing: the text is CC BY 4.0, the tooling is MIT, and the source is on GitHub.

The rules weren’t invented from nothing. They started life as a registry inside FlowDrop for Drupal, the implementation where most of FlowDrop’s behaviour was first worked out. That was also the problem with them.

Why a specification, and why now

A spec that lives inside one implementation is just that implementation’s documentation

The original registry was written in the voice of the Drupal module: its class names, its storage, its habits. That was fine while there was one backend. It stopped being fine once there was more than one.

FlowDrop is backend-agnostic by design. The editor is a standalone library, and it has more than one backend: the Drupal module and a Rust implementation among them. We’ve argued before that your workflow tool shouldn’t lock you into one vendor’s server. That argument doesn’t hold up unless “a FlowDrop workflow” means the same thing on every server that claims to run one. A second implementation can’t conform to rules that describe the first one’s internals, not even in principle.

So we pulled the rules out into their own repository, rewrote them so a conforming server could be built in any language on any stack, and made them the thing implementations aim at rather than a description of one of them.

Writing things down is how you find out what you actually do

Once every rule had to be stated plainly, the accidents started to show. There were limits borrowed from database conventions nobody here uses, and behaviours that were really a host language’s quirks mistaken for decisions. Then there was the gateway above.

We reviewed all 399 rules against the Drupal implementation, one by one. That review turned up fourteen places where the code disagreed with what the rule should say. Thirteen of them are fixed and released, and the last is in progress. Every one of them was a real defect a user could have hit, and none of them would have been found by reading the code alone, because the code was the only definition there was.

Target, not description

That review settled the most important decision in the whole specification: it is a target.

Where an implementation disagrees with a rule, the rule is what is intended and the implementation has a defect. The only exception is when the rule itself is wrong, and then that’s a bug you can report against the specification. We made this a formal ruling: where a rule had recorded a behaviour nobody chose, the rule states the intended behaviour. Fixing the accident then becomes a planned change, not a break of contract.

A specification that describes whatever the code happens to do can never catch the code being wrong. One that states intent can.

What it gives you

If you build with FlowDrop

Portability you can check. A workflow you design today isn’t tied to the server it was designed on. When you move it, the rules that govern it are public, and you can check the new server against them.

Answers instead of archaeology. What happens when a condition’s regex is matched case-insensitively? When a run hasn’t finished, what does its lastExecuted field say? (It says null. PIPE-9: a timestamp is never fabricated.) You shouldn’t have to read PHP or Rust to find out.

A contract for things that fail. Many of the rules are about refusals: what gets rejected, with which status, and what is guaranteed not to have happened as a result. An import that is refused leaves nothing behind (STORE-9), and a workflow that fails validation is never partly stored (STORE-5). When you run AI workflows against real content, these guarantees matter more than the happy path.

If you build a FlowDrop backend

A target you can aim at. Every rule is marked with its level and the profiles it belongs to, so you can see what a storage API needs and what a full runtime needs. The rules are written to be neutral about stack. Anywhere the text still presumes a particular platform, that counts as a bug.

Identifiers you can cite from tests. Identifiers are permanent. STORE-2 means one thing forever: never renumbered, never reused, still resolvable after the rule is withdrawn. Even identifiers we’ve declined are reserved, so nobody can reuse them for something else. A test named after a rule stays accurate for as long as the test exists.

If you review, audit or govern AI systems

Something you can point to. “The system behaves correctly” isn’t a claim anyone can check. “The system conforms to BR-4” is. The rules state what must happen, not how, which is the level a compliance conversation actually works at. This fits with the audit-trail and human-oversight work we’ve written about in earlier posts.

If you work with AI agents

The specification gets quoted into issues, code reviews and model context about as often as people read it in a browser, so we built it to be read by machines too. Every rule is also published as markdown at its own URL (append .md). There’s an llms.txt index, the whole corpus in one file at llms-full.txt, and every field as structured data in rules.json. An agent building or reviewing a FlowDrop workflow can cite the rule it relied on, and a person can check that citation in one click.

What it deliberately is not

Two things were left out on purpose.

It is not a certification programme. The specification confers no status, grants no mark, and issues no badge. We considered one, and decided against it the same day. The spec is informative. Its job is to say clearly what a workflow means, not to sell anyone a sticker.

It carries no implementation status. You won’t find a conformance matrix on the specification site, or any note saying “the Drupal module does X”. Status belongs next to the code it describes, so each implementation publishes its own standing against the rules in its own documentation. That keeps the specification timeless: a rule states the target, and never where somebody currently is on the way to it.

How to get involved

The specification is a draft, and we mean that. The rules came out of one implementation, and in places the wording still shows it. Fixing that is the ongoing editorial work, and you can help.

  • Read it: start at flowdrop.io/spec, or browse all rules filtered by family, profile or level.
  • Tell us a rule is wrong. That is the most useful thing you can file. Editorial fixes are reviewed on their merits. A change to what implementations must do needs a short ruling first, which then becomes part of the specification. The contributing guide explains the difference.
  • Build against it. If you’re writing a FlowDrop backend, or thinking about one, the specification is the contract. Cite the identifiers in your tests and tell us where the text gets in your way.

Back to the gateway. In the specification it is now BR-4: when an outcome matches no branch, the gateway either sends it to a declared default branch or fails with an error that an error edge can route. It never goes quiet. That behaviour is now written down, it has an identifier, and every FlowDrop implementation is held to it, including ours.