# FlowDrop Workflow Specification 1.0: Why a Workflow Engine Needs a Spec

> FlowDrop now has a published, implementation-independent workflow specification: 386 rules with permanent identifiers, conformance fixtures, and a clear answer to absent versus null in JSON. Why we pulled the rules out of the Drupal module, and what a written spec fixes that a test suite cannot.

By Shibin Das · FlowDrop · Published 25 Oct 2026. Source: https://www.d34dman.com/posts/flowdrop/flowdrop-workflow-specification-1-0/
Tags: flowdrop, specification, workflows, json, json-schema, api-design, orchestration, drupal, rust, open-source

**Scope note:** This post is about the [FlowDrop Workflow Specification](https://flowdrop.io/spec/), which reached version 1.0 this week. It covers why it exists, what it changed, and the handful of design decisions in it that I think are worth stealing for your own API, whether or not you ever touch FlowDrop. It does not cover the editor, the AI nodes or the engine internals.

---

### TL;DR

- **What it is:** 386 rules saying how a FlowDrop workflow is written, stored, validated and executed, stated without reference to any one implementation. 217 core, 167 extended, 2 optional.
- **Why:** the rules used to live inside FlowDrop for Drupal, extracted from its code. They described one PHP implementation very well and could not be used to build a second one.
- **What it fixes:** a workflow should mean the same thing on every engine that runs it, and when two engines disagree, there should be one place that says which one is wrong.
- **What you can take from it even if you never use FlowDrop:** a strict definition of absent, `null` and empty in JSON, open lists with a name register instead of `X-` prefixes, floors instead of ceilings, and error codes instead of error messages.
- **Licence:** spec text CC BY 4.0, code and test fixtures MIT. Source on [GitHub](https://github.com/flowdrop-io/flowdrop-spec).

## Where the rules used to live

[FlowDrop](https://www.drupal.org/project/flowdrop) ([why I built it](https://www.d34dman.com/posts/flowdrop/configurable-workflows-future-flowdrop/)) started as a Drupal module with a visual editor in front of it. Early on we made one decision that paid off more than any other: the editor talks to its backend over an API and knows nothing about Drupal. That is why a FlowDrop workflow can run on a backend that is not Drupal at all.

For a long time, though, the rules about *what a workflow means* lived in the Drupal module. There was a specification registry in its docs, every rule had an identifier, and the test suite was bound to those identifiers, with CI failing if a rule lost its test. As a regression suite it was excellent. Over 370 rules, all covered.

Then we tried to read it as a specification, and asked one question of every rule: could a team build this in TypeScript, or Rust, from the text alone, without reading the PHP?

A lot of rules failed. Not because they were badly written, but because they were extracted from code, and code records its accidents as faithfully as its intentions. A few examples of what the registry was guarding with tests:

- **A workflow named `0` counted as having no name**, because PHP's `empty('0')` is true. A JavaScript or Rust implementation would never produce that behaviour by accident, and should not be asked to produce it on purpose.
- **Case-insensitive regex matching worked by lowercasing the pattern.** That turns `\D` (not a digit) into `\d` (a digit), so a condition written to match non-digits started matching digits.
- **Clients told one refusal from another by matching the error message.** Rewording a sentence was a breaking change.
- **A `null` on an input meant different things depending on which code path built the inputs**, because one used `array_key_exists()` and another used `isset()`.

Each of these had a test, and each test was defending the bug. The registry described today's system brilliantly. You could not build tomorrow's system from it.

## Why that matters now

FlowDrop is no longer one implementation. The spec's own drafting guide puts it plainly: *PHP, JavaScript and Rust today, Python, Go or any other tomorrow.* There is the Drupal module, the JavaScript editor that has to agree with whichever backend it talks to, and [flowdrop-rs](https://github.com/flowdrop-io/flowdrop-rs), a Rust backend.

The moment a workflow is saved by one implementation and read by another, every rule that quietly depended on a host language becomes a bug that only shows up in production, on somebody else's server. And the first question anyone asks when two engines disagree is: *which one is right?* A test suite cannot answer that. It can only tell you which implementation changed.

There is a second reader I had in mind the whole time: the agent debugging the workflow. More and more of the "is this a bug?" questions about FlowDrop are asked by a model reading logs at two in the morning. A spec written so that one rule decides one observed behaviour is as useful to that model as it is to a person. More on that below.

## What 1.0 actually is

| | |
|---|---|
| Rules in force | 386 (217 core, 167 extended, 2 optional) |
| Rule families | 30, from `GR-STORE` (storing workflows) to `RT-GATE` (human approval gates) |
| Rulings | 43, each recording what was decided and why |
| Withdrawn rules | 21, kept on record, numbers never reused |
| Deferred rules | 22 that stayed in the Drupal module because they were about Drupal |
| Conformance fixtures | 53, in the first suite |

Every rule declares three things:

- **Profile:** who it binds. `runtime` (executes workflows), `storage-api` (stores and serves them) or `editor-client` (authors them). An editor does not have to care about retry semantics; an engine does not have to care about what an editor records.
- **Level:** `core` is what you meet before calling yourself a FlowDrop implementation. `extended` is commonly expected. `optional` is genuinely optional.
- **Posture:** almost every rule is a `normative-target`: it says what a conforming implementation must do, even if no implementation does it yet.

That last word matters. The spec is a **target**, not a description. If an implementation disagrees with a rule, the implementation has a defect, unless the rule is wrong, and then that is a bug in the spec, reported against the spec repository. It also deliberately carries **no implementation status**. Nothing in it says which implementation passes which rule. Each implementation publishes that itself.

### Rule identifiers are forever

`STORE-2` means one thing, forever. Identifiers are cited from tests, issues and other people's documents, so they are never renumbered and never reused. A withdrawn rule stays on record as withdrawn.

There is one subtler case. When a rule was moved out of the Drupal registry, its number was already in use there for a rule the spec declined. Issuing that number for something else would make one identifier mean two things in two documents that cite each other. So those numbers are **reserved**: recorded in an append-only `REGISTRY.lock`, and CI refuses any rule file that claims one.

## Design decisions worth stealing

These are the parts I would copy into any JSON API spec, FlowDrop or not.

### 1. Absent, null and empty are four different states

This was the single largest source of disagreement between implementations, and the ruling that settled it ([OPEN-26](https://flowdrop.io/spec/rulings/open-26)) touched more rules than any other. The spec now defines it once, for every payload:

| State | JSON | Meaning |
|---|---|---|
| Not provided | the member is absent | A fallback applies: a lower priority, a default. |
| Provided, no value | `null` | A choice. It replaces any fallback. In a partial update it clears the stored value. |
| Provided, empty | `""`, `[]`, `{}`, `false`, `0` | A value, like any other. |
| Provided, with content | anything else | A value. |

No rule reads an empty value as absent. No implementation may drop a `null` or fill in an absent member on its way through. The reasoning is short: a value that one implementation reads as "absent" changes meaning when it is serialised and read back by another.

It also agrees with JSON Schema with no exceptions, so an off-the-shelf validator in any language gives the spec's verdict. Here is one of the fixtures that checks it:

```json
{
  "title": "A null on the wire beats the author's saved value",
  "rules": ["CFG-4", "CFG-7", "CFG-14"],
  "operation": "resolve-parameters",
  "given": {
    "parameters": {
      "x": {
        "schema": { "type": ["string", "null"] },
        "connectable": true, "exposed": true, "configurable": true
      }
    },
    "config": { "x": "saved" },
    "inputs": { "x": null }
  },
  "expect": { "resolved": { "x": null } }
}
```

A wired-in `null` wins over the author's saved `"saved"`, because sending `null` was a choice. Had the input been absent, the saved value would apply.

### 2. Lists are open unless a rule says they are closed

Many rules used to list the members of an object with the word "exactly". Read strictly, every extra member was a breach, including ones clients depend on. Read loosely, nothing stopped two implementations giving the same added name two meanings.

The spec now says lists are **open by default**. An implementation may add members; a consumer must ignore what it does not recognise. A rule that needs a closed list says *closed* and gives the reason. Added names go in an [extensions register](https://github.com/flowdrop-io/flowdrop-spec/blob/main/extensions.md), **without a required prefix**, because a prefix turns into a rename the day the name is standardised. HTTP learned that with `X-` headers ([RFC 6648](https://www.rfc-editor.org/rfc/rfc6648)).

The condition node's operators are an example of a list that *is* closed, with the reason written into the rule ([LANG-28](https://flowdrop.io/spec/rules/gr-lang/lang-28)): a condition is stored in the workflow, so an operator only one engine understands would make that workflow fail to save on every other.

### 3. Floors, not ceilings

"A name may be at most 255 characters" freezes one system's database column into every other system. The spec writes it as a floor instead: an implementation **must accept at least** 255, and may accept more ([STORE-2](https://flowdrop.io/spec/rules/gr-store/store-2)). A caller that stays within the floor works everywhere.

And "characters" is pinned down: text length counts **Unicode code points**. Not bytes, which would make a limit depend on the alphabet you write in. Not grapheme clusters, which are closer to what a person calls a character but much harder for two implementations to agree on.

### 4. Equality is defined, not inherited

When a rule compares two values, it compares them the way JSON Schema's `const` does. `1` equals `1.0`. `"1"` does not equal `1`. `true` does not equal `1`. Object members compare regardless of order. Your language's `==` gets some of these right and some wrong, so the spec says to compare by its definition, not by the operator.

### 5. Error codes, not error messages

Every refusal carries a stable, machine-readable `error_code` next to the human-readable message ([API-8](https://flowdrop.io/spec/rules/gr-api/api-8)). A client must not classify a refusal by matching its text. A published code never changes meaning and is never reused. It is a boring rule. It is also the one that lets you fix a typo in an error message without a major release.

### 6. The spec carries no history

The spec states what is required now. It does not say what used to be required, and it has no legacy appendix telling implementations to tolerate old forms ([OPEN-36](https://flowdrop.io/spec/rulings/open-36)). Why something changed lives in the ruling that changed it. How an implementation migrates its own stored workflows lives in that implementation's release notes. A new implementation should not have to carry somebody else's migration history.

## Two tests every rule has to pass

The [drafting guide](https://github.com/flowdrop-io/flowdrop-spec/blob/main/drafting.md) has 21 checks, and a review cites them by number. Two of them carry most of the weight:

- **The outsider test:** could someone implement this rule correctly from its text, the conventions and the glossary alone, in a language and framework unlike any existing implementation's?
- **The debugger test:** could someone debugging a workflow, *a person or an agent*, decide from this rule alone whether an observed behaviour is correct, and so whether to file a bug and against whom?

If two readings of a rule are defensible, the rule is ambiguous, and that is a defect in the rule.

## Written to be read by machines too

The spec gets quoted into issues, code reviews and model context at least as often as it gets read in a browser, so it is published for both:

- Every rule page has a **markdown twin** at the same URL with `.md` appended, for example [`/spec/rules/gr-store/store-2.md`](https://flowdrop.io/spec/rules/gr-store/store-2.md). Each one carries its identifier and a link back to its source, so a quotation stays traceable.
- [`llms.txt`](https://flowdrop.io/spec/llms.txt) is an index, one line per rule. [`llms-full.txt`](https://flowdrop.io/spec/llms-full.txt) is the whole spec as one document.
- [`rules.json`](https://flowdrop.io/spec/rules.json) is the corpus as data, including the identifiers the spec has *declined*, so a tool resolving a number can tell "not ours" from "unknown".
- Crawlers are allowed everywhere, deliberately. A rule nobody can find is a rule nobody conforms to. (Whether anything actually reads those files is a separate question, and [your analytics probably cannot tell you](https://www.d34dman.com/posts/engineering/llms-txt-analytics-blind-spot/).)

If you are pointing a coding agent at a FlowDrop implementation, hand it `llms-full.txt` and ask it to cite rules by identifier.

## Conformance fixtures

Rules are for reading. Fixtures are for running. Each fixture is a JSON file naming the rules that decide it, an operation, an input and the expected outcome, like the one above. An implementation writes a small adapter that maps `given` onto its own API and its result back onto `expect`, and can copy the fixtures straight into its test suite, since they are MIT licensed.

The comparison is strict on purpose. A member present in `expect` must be present with an equal value. A member absent from `expect` must be absent from the outcome. There is no "don't care". Without that, absent versus `null` would be untestable, and that was the whole point.

## How it is governed, honestly

The spec is maintained by Factorial, who also build two of its implementations. It is a **vendor-led specification with a public target**, not the output of a neutral standards body, and the [governance document](https://github.com/flowdrop-io/flowdrop-spec/blob/main/GOVERNANCE.md) says so in its first paragraph. Pretending to a neutrality we do not have would be worse for everyone relying on it.

What constrains us instead is the record. Any change to what an implementation must do needs a **ruling**: a short, permanent statement of the question, the decision and the reasoning, carried in the spec and linked from every rule it affects. A rule that changes without a ruling is a process defect, and pointing that out is a legitimate issue to file.

Contributions come in two kinds, and the pull request has to say which:

- **Editorial:** the rule requires exactly what it did before and now says so more clearly. Usually merged.
- **Normative:** an implementation that conformed yesterday might fail today, or the reverse. Needs a ruling, so open an issue first.

The test for which one you have: could an existing implementation pass before your change and fail after it? If you are not sure, it is normative.

The most useful thing you can file is "this rule is wrong".

## What it is not

- It is **not a certification**. It grants no mark and no status.
- It does **not track implementations**. No scoreboard, no ranking.
- It is **not finished**. The first fixture suite covers absent, `null` and empty through parameter resolution, inputs and outputs. The other 29 families have rules but not yet fixtures.

## Where to start

- **Building an implementation?** Read the [conventions](https://flowdrop.io/spec/conventions) first, then filter the [rules](https://flowdrop.io/spec/rules) by your profile and `core`.
- **Authoring workflows?** The [glossary](https://flowdrop.io/spec/glossary) and the `GR-` families are the part that decides what your workflow means.
- **Designing your own JSON API?** Steal the [Payloads](https://flowdrop.io/spec/conventions#payloads) and [Extensibility](https://flowdrop.io/spec/conventions#extensibility) sections. CC BY 4.0 says you can, as long as you say where you got them.
- **Found a rule that is wrong?** [Open an issue](https://github.com/flowdrop-io/flowdrop-spec/issues).

The short version of why we did this: a test suite tells you what your code does. A specification tells you what it should do. For a long time FlowDrop only had the first one. Now it has both, and the second one does not care which language you write in.
