Skip to content

feat(flow): OpenRegister flow engine on symfony/workflow (ADR-065)#2064

Merged
rubenvdlinde merged 2 commits into
developmentfrom
feat/adr-065-or-flow-engine
Jul 24, 2026
Merged

feat(flow): OpenRegister flow engine on symfony/workflow (ADR-065)#2064
rubenvdlinde merged 2 commits into
developmentfrom
feat/adr-065-or-flow-engine

Conversation

@rubenvdlinde

@rubenvdlinde rubenvdlinde commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

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

  • Five unrelated systems use the word "flow" — the NC Flow leaf,
    x-openregister-flows, openconnector's flow, procest's workflowTemplate,
    and openbuild's Automation. Every app that wants richer flow editing is
    currently a candidate to grow its own engine: the exact defect ADR-022 exists
    to prevent.
  • No fleet engine can express parallel work. openconnector's model is a
    linear list where order is identity, sequence, and the implicit edge set at
    once; 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.
  • A Petri net is a superset of both models we already have, so this unifies
    execution without forcing a state machine and an action pipeline into one
    shape. Verified by running it, not from package metadata.
  • Nothing viable exists to buy for the rest. There is no PHP DMN engine (the
    only Packagist hit is an abandoned 2019 writer), no PHP FEEL parser at all,
    and no PHP CMMN anything. symfony/workflow is the one maintained,
    MIT, php >=8.1 component that does what the execution core needs.

What Changes

  • Add symfony/workflow: ^6.4. OpenRegister already requires symfony/* at
    ^6.4 and already vendors deprecation-contracts and event-dispatcher, so
    this 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-net
    Definition. Nodes become places; edges become transitions; multi-endpoint
    edges become splits and synchronising joins.
  • FlowEngine — runs a flow, providing everything Symfony does not: run
    lifecycle, append-only trace, per-step onError, and a loop ceiling.
  • FlowStepDispatcher — the seam between when a step runs (engine) and what
    it 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 on
    each" 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)

  • Relocating openconnector's flow schema and FlowRunnerService. The
    engine core lands first; the migration is its own change, and its sharpest
    edge — order is referenced by value by branches[].nextStepOrder, so it
    cannot survive a canvas unchanged — deserves separate treatment.
  • Persisting the marking to an OR object. The engine takes a
    MarkingStoreInterface; the OR-backed store lands with the relocation.
  • DMN/CMMN interchange — parked, openregister#466.
  • A conformant FEEL implementation — explicitly not planned.

Amended before merge: the data channel is an item list

The dispatcher contract originally took one object $subject and 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.

$subject stays: 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:

# Workstream
#2067 Flow logic — If/Switch, Merge, Loop, Wait, error branch, sub-flows
#2068 Nextcloud-native trigger set
#2069 JSONLogic expressions (reusing openconnector's existing dependency)
#2070 Execution tooling — history, retry, pin/mock data, partial runs
#2071 MCP surface at parity with n8n's
#2065 Shareable integration network, distributed via GitHub
#2066 Optional Node sidecar — the route to a real Code node
hermiq#35 Retire hermiq's GraphExecutor onto this engine

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. phpcs clean on all five files under lib/Service/Flow/.

🤖 Generated with Claude Code

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).
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openregister @ d6c4a79

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant