feat(flow): OpenRegister flow engine on symfony/workflow (ADR-065)#2064
Merged
Conversation
Five unrelated systems in this fleet use the word "flow", and every app that
wants richer flow editing is currently a candidate to grow its own engine — the
exact defect ADR-022 exists to prevent. This gives OpenRegister the one engine
the fleet consumes.
Execution core is symfony/workflow 6.4 (MIT, php >=8.1, one hard dep). A Petri
net is chosen because it is a SUPERSET of both models the fleet already has: a
single-token marking is a state machine (procest's case.status), a multi-token
marking gives parallel splits and synchronising joins that NO current fleet
engine can express — openconnector's order-indexed step list explicitly cannot,
which is why a canvas could not ship against it.
Verified rather than assumed:
- OR already requires symfony/* at ^6.4 and already vendors deprecation-contracts
and event-dispatcher; NC core ships Symfony 6.4.x and no symfony/workflow. The
vendor-shadowing risk is empirically nil: this adds exactly ONE package with
zero version churn, and composer audit reports no advisories.
- Tests run on the CONTAINER's PHP 8.4.22, not the host's 8.2 (OR declares ^8.3,
so the host cannot run them at all). Running there caught a real defect the
host never would have: a private run() helper colliding with PHPUnit's final
TestCase::run().
What symfony/workflow does NOT give us, and therefore lives here — ported from
openconnector's FlowRunnerService, which had it right:
- run lifecycle: completed | stopped | dead_letter | failed
- append-only run log, one entry per step
- per-step onError: stop | continue | dead_letter, where an UNKNOWN policy stops
rather than continues, so a typo fails safe
- a loop ceiling, reported as a failure rather than truncating silently (a Petri
net can express cycles, so a drawn loop can run forever)
FlowStepDispatcher is the seam between when a step runs (engine) and what it does
(app), so the engine is container-free testable and a consumer adds step types
without an engine change — the whole point of the engine living in OR rather than
in the app that needed it first.
The builder treats the document as untrusted and rejects malformed graphs BY NAME
(dangling edge, duplicate node id, missing endpoint, unknown initial) rather than
letting symfony fail later and less legibly. Unlike x-openregister-flows — which
swallows by design to protect the save path — this engine's failures are visible.
It accepts both {from,to} and the {source,target} dialect CnGraphCanvas emits, so
a canvas payload is directly runnable.
26 new unit tests (all green on PHP 8.4), including the claim the engine choice
rests on: a join REFUSES to fire until every inbound branch arrives. Pre-existing
flow tests unaffected (42/42). PHPCS clean; openspec validate --strict valid;
hydra gates: my files trigger none.
Scope: engine core only. Relocating openconnector's flow schema/runner, the
OR-backed marking store, and moving openbuild's DecisionTableEvaluator are
separate changes — see tasks.md §4. DMN/CMMN interchange is parked (#466).
Contributor
Quality Report — ConductionNL/openregister @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ❌ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ❌ | ||||
| stylelint | ❌ | ||||
| composer | ❌ | ✅ 173/173 | |||
| npm | ❌ | ❌ | |||
| PHPUnit | ⏭️ | ||||
| Newman | ⏭️ | ||||
| Playwright | ⏭️ |
Quality workflow — 2026-07-24 12:04 UTC
Download the full PDF report from the workflow artifacts.
…bject
Settled before this engine merges, because changing it afterwards would break
every dispatcher in the fleet at once.
A step now receives its input items and returns its output items. That is what
lets a flow express "fetch 200 rows and act on each" without the author drawing
a loop, lets a filter step return fewer items than it got, and lets a fan-out
stay explainable.
- `FlowItems` — an item is `{json, binary, pairedItem}`. `pairedItem` is the
chain back to the input that caused an output, which is the first thing asked
of any failed run.
- The engine normalises what a dispatcher returns (full list, single item, or a
bare record) rather than rejecting the looser shapes, which would push the
same boilerplate into every consuming app.
- An empty returned list ends that branch's data. It is not "no change".
- `context` is demoted to run-level metadata and documented as NOT the data
channel: anything per-record put there stops being per-record the moment a
step fans out.
- A run with no seed starts from exactly one item built from the subject, so a
flow that never fans out behaves as it did before.
- Each run-log entry now carries the item count in and out — the first piece of
the execution tooling this engine still needs.
`$subject` stays: it carries the Petri-net marking and names what the run is
about. It was never the data.
41 tests green, including fan-out with surviving provenance, filtering to
empty, threading between steps, and single-item seeding. phpcs clean.
This was referenced Jul 24, 2026
This was referenced Jul 24, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Proposal: or-flow-engine
Summary
Give OpenRegister a real flow engine, so the fleet has one instead of five
things called "flow". The execution core is
symfony/workflow(a Petri net);openconnector's run lifecycle is ported on top. Leaf apps become consumers.
Governed by ADR-065 (hydra:
openspec/architecture/adr-065-flow-engine-and-canvas.md).Why
x-openregister-flows, openconnector'sflow, procest'sworkflowTemplate,and openbuild's
Automation. Every app that wants richer flow editing iscurrently a candidate to grow its own engine: the exact defect ADR-022 exists
to prevent.
linear list where
orderis identity, sequence, and the implicit edge set atonce; its spec rules out parallel/fan-out explicitly. procest's engine never
walks a graph at all. A canvas invites users to draw a fork the moment they
see one.
execution without forcing a state machine and an action pipeline into one
shape. Verified by running it, not from package metadata.
only Packagist hit is an abandoned 2019 writer), no PHP FEEL parser at all,
and no PHP CMMN anything.
symfony/workflowis the one maintained,MIT,
php >=8.1component that does what the execution core needs.What Changes
symfony/workflow: ^6.4. OpenRegister already requiressymfony/*at^6.4and already vendorsdeprecation-contractsandevent-dispatcher, sothis adds no new class of vendor-shadowing risk; NC core ships Symfony 6.4.x
and no
symfony/workflow, so there is nothing to shadow.FlowDefinitionBuilder— translates a stored flow document into a Petri-netDefinition. Nodes become places; edges become transitions; multi-endpointedges become splits and synchronising joins.
FlowEngine— runs a flow, providing everything Symfony does not: runlifecycle, append-only trace, per-step
onError, and a loop ceiling.FlowStepDispatcher— the seam between when a step runs (engine) and whatit does (app), so the engine is container-free testable and consumers add
step types without touching it.
FlowItems— the data channel. Steps exchange a LIST of items(
{json, binary, pairedItem}), not one object, so "fetch 200 rows and act oneach" needs no author-drawn loop and a fan-out stays explainable. This shape
is settled here, before any consumer exists, precisely because changing it
later would break every dispatcher in the fleet at once.
Out of scope (this change)
flowschema andFlowRunnerService. Theengine core lands first; the migration is its own change, and its sharpest
edge —
orderis referenced by value bybranches[].nextStepOrder, so itcannot survive a canvas unchanged — deserves separate treatment.
MarkingStoreInterface; the OR-backed store lands with the relocation.Amended before merge: the data channel is an item list
The dispatcher contract originally took one
object $subjectand returned context to merge. It now takes and returns an item list —{json, binary, pairedItem}— settled here rather than in a follow-up, because changing it after consumers exist would break every dispatcher in the fleet at once.$subjectstays: it carries the Petri-net marking and names what the run is about. It was never the data.This is the foundation of the flow-parity programme:
Verification
41 unit tests green, including four new ones covering item threading, fan-out with surviving provenance, filtering to an empty list, and single-item seeding from the subject.
phpcsclean on all five files underlib/Service/Flow/.🤖 Generated with Claude Code