diff --git a/appinfo/info.xml b/appinfo/info.xml
index 6942e5ccf..d24f35cc5 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -229,6 +229,17 @@
Last in the block, and post-migration only: it removes rows and
creates nothing, so no step depends on it, and a fresh install
has nothing to remove. -->
+
+ OCA\Integriq\Repair\MigrateFlowStepsToGraph
OCA\Integriq\Repair\RemoveRetiredCronJobs
OCA\Integriq\Command\RuleToFlow
+
+ OCA\Integriq\Command\FlowStepsToGraph
diff --git a/docs/features/README.md b/docs/features/README.md
index 3cbd7cea2..70cb12cbf 100644
--- a/docs/features/README.md
+++ b/docs/features/README.md
@@ -13,6 +13,7 @@ Integriq is an API gateway and integration hub for Nextcloud. It brings enterpri
| [Reliability](reliability.md) | Retry policy, per-Source circuit breaker, sync dead letters | Implemented |
| [Rules](rules.md) | Authentication, file handling, locking, and audit trail rules | Implemented |
| [Jobs](jobs.md) | Cron-based scheduled task execution | Implemented |
+| [Flow nodes](flow-nodes.md) | Contributed step types for OpenRegister's flow engine | Implemented |
| [Events & Webhooks](events.md) | CloudEvents emission, subscription, and consumer processing | Implemented |
| [Logging & Monitoring](logging.md) | Call logs, sync logs, and Prometheus metrics | Implemented |
| [Configuration Management](configuration-management.md) | Import/export, configuration groups, slug-based references | Implemented |
diff --git a/docs/features/flow-nodes.md b/docs/features/flow-nodes.md
new file mode 100644
index 000000000..573ddc446
--- /dev/null
+++ b/docs/features/flow-nodes.md
@@ -0,0 +1,99 @@
+# Flow nodes
+
+## Overview
+
+OpenRegister runs the fleet's one flow engine. Integriq does not run its own graphs. It contributes step types, so a flow can do what Integriq is good at: call an API, run a synchronization, apply a mapping, ask a person, emit an event.
+
+You build the flow in OpenRegister's flow editor. The Integriq steps appear in the palette when both apps are enabled.
+
+| Node | What the step does |
+|------|--------------------|
+| `openconnector.source-call` | Make one governed API call per item through a configured Source |
+| `openconnector.synchronization-run` | Run a configured Synchronization and hand each synchronised object onward |
+| `openconnector.source-paginate` | Fetch one page of objects from a Source |
+| `openconnector.apply-mapping` | Apply a configured Mapping to every item |
+| `openconnector.contract` / `contract-commit` / `contract-sweep` | The decomposed synchronization's contract steps |
+| `openconnector.fetch-file` | Fetch a file referenced by an item |
+| `openconnector.approval-request` | Pause the run until someone approves or rejects |
+| `openconnector.event-emit` | Emit a CloudEvent for every item |
+
+## Call an API from a flow
+
+Add a `source-call` step. Pick a Source, give it a path and a method:
+
+```json
+{
+ "id": "step-apply-label",
+ "type": "openconnector.source-call",
+ "config": {
+ "source": "demo-forge-api",
+ "endpoint": "/issues/{{issue.number}}/labels",
+ "method": "POST",
+ "body": { "labels": ["{{triage.proposedLabel}}"] },
+ "output": "labelResult"
+ }
+}
+```
+
+The step runs once per item. `{{dotted.path}}` placeholders resolve from each item's record, and the response lands under the key you name in `output`. The call goes through `CallService`, so the Source's enablement, host guard, rate limits and call logging all apply unchanged.
+
+## Why there is no raw-URL node
+
+You cannot type a URL into a flow step. The step names a Source, and the endpoint is a path inside that Source's location. An absolute URL, a `//host` path or a `../` escape is rejected before any request goes out.
+
+This is the whole security model, not a missing convenience. A Source is where an administrator decides which hosts may be called, how often, and with which credential. A URL field in a flow document would hand that decision to every flow author and turn the editor into a request forger. If a host is worth calling, give it a Source first.
+
+Credentials follow the same line. A step has no token field. Authentication comes from the Source's `credentialRef`, resolved by the credential broker at call time. No secret ever sits in a flow document.
+
+## Why an unattributed run fails closed
+
+Every call runs as the flow run's owner, read from the run context. When no owner resolves, the step refuses and raises. There is no fallback to an admin, to the Source's creator, or to nobody.
+
+An anonymous authenticated outbound call is the failure we refuse to ship. A loud error names the gap; a silent fallback hides it behind someone else's identity.
+
+## Ask a person: the approval step
+
+`openconnector.approval-request` parks the run and creates a pending approval request. The approvers see it on the Pending approvals page and in their shared task list, like every other Integriq approval.
+
+```json
+{
+ "id": "approve-publish",
+ "type": "openconnector.approval-request",
+ "config": {
+ "question": "Publish this dataset?",
+ "approverGroup": "data-stewards",
+ "ttlSeconds": 86400
+ }
+}
+```
+
+- **Approved.** The run resumes. The decision, the approver and the comment land on every item under `approval`, so a later step can route on them.
+- **Rejected.** By default the run continues and your reject edge reads `approval.decision`. Set `failOnReject: true` when a no should fail the run.
+- **Expired.** The run fails. An approval nobody answered never counts as answered.
+
+An answer wakes the run immediately. If that wake-up is ever lost, the step re-checks the approval request itself on its next heartbeat, so a decision is never stranded.
+
+## Emit an event
+
+`openconnector.event-emit` sends one CloudEvent per item through the existing event pipeline. Name a `type` and a `source`, and subscriptions pick it up exactly as they would for any other Integriq event.
+
+## Migrate old step-list flows
+
+Flows built in Integriq's earlier step-list editor still exist as ordered `steps[]`. One command translates them onto the engine's graph shape:
+
+```bash
+occ integriq:flow:steps-to-graph # dry run: reports what would happen
+occ integriq:flow:steps-to-graph --apply # writes nodes/edges onto each flow
+```
+
+The migration is additive and repeatable. `steps` stays on the object, a flow that already carries `nodes` is skipped, and a flow the translator cannot express faithfully is refused with the reasons listed. The same pass also runs automatically on upgrade.
+
+Changed your mind? Roll it back:
+
+```bash
+occ integriq:flow:steps-to-graph --rollback --apply
+```
+
+## Next steps
+
+Create a [Source](sources.md) for the API you want to call, then open OpenRegister's flow editor and add a `source-call` step against it.
diff --git a/l10n/nl.js b/l10n/nl.js
index a457d105a..abf663c11 100644
--- a/l10n/nl.js
+++ b/l10n/nl.js
@@ -1516,7 +1516,38 @@ OC.L10N.register(
"Open the documentation to keep going": "Open de documentatie om verder te gaan",
"Where the automation lives": "Waar de automatisering zit",
"Flows are what happens without anyone clicking: a synchronization that starts on a schedule, a webhook that fires when a record changes. This is where you read and edit them. Nothing to build now.": "Flows zijn wat er gebeurt zonder dat iemand klikt: een synchronisatie die op een schema start, een webhook die afgaat wanneer een record verandert. Hier leest en bewerkt u ze. U hoeft nu niets te bouwen.",
- "Open Flows in the menu": "Open Flows in het menu"
+ "Open Flows in the menu": "Open Flows in het menu",
+ "Ask for approval": "Vraag om goedkeuring",
+ "Pause the flow until someone in the approver group approves or rejects. An expired request fails the run.": "Pauzeer de flow tot iemand uit de goedkeurdersgroep goedkeurt of afwijst. Een verlopen verzoek laat de run mislukken.",
+ "What is being asked": "Wat wordt er gevraagd",
+ "Shown to the approvers and written on the request, so a paused flow explains itself.": "Getoond aan de goedkeurders en vastgelegd op het verzoek, zodat een gepauzeerde flow zichzelf uitlegt.",
+ "Members of this group (and admins) may answer. Required: a request nobody owns is a request nobody answers.": "Leden van deze groep (en beheerders) mogen antwoorden. Verplicht: een verzoek zonder eigenaar wordt nooit beantwoord.",
+ "Expires after (seconds)": "Verloopt na (seconden)",
+ "An unanswered request expires and fails the run. Defaults to 24 hours.": "Een onbeantwoord verzoek verloopt en laat de run mislukken. Standaard 24 uur.",
+ "Treat a rejection as a failure": "Behandel een afwijzing als een fout",
+ "Off by default: a \"no\" continues the flow with the decision on the items, so a later step can route on it.": "Standaard uit: een \"nee\" laat de flow doorgaan met het besluit op de items, zodat een latere stap erop kan routeren.",
+ "Field to store the decision in": "Veld waarin het besluit wordt opgeslagen",
+ "The decision is written onto every item under this field. Defaults to \"approval\".": "Het besluit wordt onder dit veld op elk item geschreven. Standaard \"approval\".",
+ "Re-check every (minutes)": "Controleer opnieuw elke (minuten)",
+ "Safety net for a lost answer. Lower is not faster: a decision wakes the run immediately either way.": "Vangnet voor een verloren antwoord. Lager is niet sneller: een besluit wekt de run hoe dan ook direct.",
+ "Say what is being asked (\"question\"), or nobody can answer it.": "Zeg wat er wordt gevraagd (\"question\"), anders kan niemand antwoorden.",
+ "Name the approver group (\"approverGroup\"): an approval without an audience never resolves.": "Noem de goedkeurdersgroep (\"approverGroup\"): een goedkeuring zonder publiek wordt nooit afgerond.",
+ "The \"ttlSeconds\" field must be a positive number of seconds when set.": "Het veld \"ttlSeconds\" moet een positief aantal seconden zijn wanneer het is ingesteld.",
+ "The approval step has no resume slot; the engine did not dispatch it as a resumable node.": "De goedkeuringsstap heeft geen hervattingsslot; de engine heeft hem niet als hervatbare stap aangeroepen.",
+ "This run carries no uuid, so an approval could never answer it. The approval step is only usable in a persisted flow run.": "Deze run heeft geen uuid, dus een goedkeuring zou hem nooit kunnen beantwoorden. De goedkeuringsstap werkt alleen in een opgeslagen flowrun.",
+ "Emit an event": "Verstuur een gebeurtenis",
+ "Emit a CloudEvent for every item, delivered through the configured event subscriptions.": "Verstuur een CloudEvent voor elk item, afgeleverd via de geconfigureerde abonnementen.",
+ "The CloudEvent \"type\", e.g. \"nl.example.object.updated\". Subscriptions match on it.": "Het CloudEvent-\"type\", bijvoorbeeld \"nl.example.object.updated\". Abonnementen matchen erop.",
+ "Event source": "Gebeurtenisbron",
+ "The CloudEvent \"source\" URI identifying the emitter.": "De CloudEvent-\"source\"-URI die de verzender identificeert.",
+ "Subject": "Onderwerp",
+ "Optional CloudEvent \"subject\". Supports {{dotted.path}} placeholders resolved from each item.": "Optioneel CloudEvent-\"subject\". Ondersteunt {{dotted.path}}-plaatshouders, opgelost per item.",
+ "Output key": "Uitvoersleutel",
+ "Item key the emit summary is written under. Defaults to \"eventResult\".": "Itemsleutel waaronder de verstuursamenvatting wordt geschreven. Standaard \"eventResult\".",
+ "Name the event \"type\": an event without a type matches no subscription.": "Noem het gebeurtenis-\"type\": een gebeurtenis zonder type matcht geen enkel abonnement.",
+ "Name the event \"source\": a CloudEvent must say where it came from.": "Noem de gebeurtenis-\"source\": een CloudEvent moet zeggen waar hij vandaan komt.",
+ "Step \"%1$s\" failed to emit event \"%2$s\": %3$s": "Stap \"%1$s\" kon gebeurtenis \"%2$s\" niet versturen: %3$s",
+ "The flow \"%1$s\" cannot be migrated to a graph yet: %2$s unsupported feature(s).": "De flow \"%1$s\" kan nog niet naar een graaf worden gemigreerd: %2$s niet-ondersteunde functie(s)."
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/nl.json b/l10n/nl.json
index b7ef76044..439b5d0d5 100644
--- a/l10n/nl.json
+++ b/l10n/nl.json
@@ -1515,7 +1515,38 @@
"Open the documentation to keep going": "Open de documentatie om verder te gaan",
"Where the automation lives": "Waar de automatisering zit",
"Flows are what happens without anyone clicking: a synchronization that starts on a schedule, a webhook that fires when a record changes. This is where you read and edit them. Nothing to build now.": "Flows zijn wat er gebeurt zonder dat iemand klikt: een synchronisatie die op een schema start, een webhook die afgaat wanneer een record verandert. Hier leest en bewerkt u ze. U hoeft nu niets te bouwen.",
- "Open Flows in the menu": "Open Flows in het menu"
+ "Open Flows in the menu": "Open Flows in het menu",
+ "Ask for approval": "Vraag om goedkeuring",
+ "Pause the flow until someone in the approver group approves or rejects. An expired request fails the run.": "Pauzeer de flow tot iemand uit de goedkeurdersgroep goedkeurt of afwijst. Een verlopen verzoek laat de run mislukken.",
+ "What is being asked": "Wat wordt er gevraagd",
+ "Shown to the approvers and written on the request, so a paused flow explains itself.": "Getoond aan de goedkeurders en vastgelegd op het verzoek, zodat een gepauzeerde flow zichzelf uitlegt.",
+ "Members of this group (and admins) may answer. Required: a request nobody owns is a request nobody answers.": "Leden van deze groep (en beheerders) mogen antwoorden. Verplicht: een verzoek zonder eigenaar wordt nooit beantwoord.",
+ "Expires after (seconds)": "Verloopt na (seconden)",
+ "An unanswered request expires and fails the run. Defaults to 24 hours.": "Een onbeantwoord verzoek verloopt en laat de run mislukken. Standaard 24 uur.",
+ "Treat a rejection as a failure": "Behandel een afwijzing als een fout",
+ "Off by default: a \"no\" continues the flow with the decision on the items, so a later step can route on it.": "Standaard uit: een \"nee\" laat de flow doorgaan met het besluit op de items, zodat een latere stap erop kan routeren.",
+ "Field to store the decision in": "Veld waarin het besluit wordt opgeslagen",
+ "The decision is written onto every item under this field. Defaults to \"approval\".": "Het besluit wordt onder dit veld op elk item geschreven. Standaard \"approval\".",
+ "Re-check every (minutes)": "Controleer opnieuw elke (minuten)",
+ "Safety net for a lost answer. Lower is not faster: a decision wakes the run immediately either way.": "Vangnet voor een verloren antwoord. Lager is niet sneller: een besluit wekt de run hoe dan ook direct.",
+ "Say what is being asked (\"question\"), or nobody can answer it.": "Zeg wat er wordt gevraagd (\"question\"), anders kan niemand antwoorden.",
+ "Name the approver group (\"approverGroup\"): an approval without an audience never resolves.": "Noem de goedkeurdersgroep (\"approverGroup\"): een goedkeuring zonder publiek wordt nooit afgerond.",
+ "The \"ttlSeconds\" field must be a positive number of seconds when set.": "Het veld \"ttlSeconds\" moet een positief aantal seconden zijn wanneer het is ingesteld.",
+ "The approval step has no resume slot; the engine did not dispatch it as a resumable node.": "De goedkeuringsstap heeft geen hervattingsslot; de engine heeft hem niet als hervatbare stap aangeroepen.",
+ "This run carries no uuid, so an approval could never answer it. The approval step is only usable in a persisted flow run.": "Deze run heeft geen uuid, dus een goedkeuring zou hem nooit kunnen beantwoorden. De goedkeuringsstap werkt alleen in een opgeslagen flowrun.",
+ "Emit an event": "Verstuur een gebeurtenis",
+ "Emit a CloudEvent for every item, delivered through the configured event subscriptions.": "Verstuur een CloudEvent voor elk item, afgeleverd via de geconfigureerde abonnementen.",
+ "The CloudEvent \"type\", e.g. \"nl.example.object.updated\". Subscriptions match on it.": "Het CloudEvent-\"type\", bijvoorbeeld \"nl.example.object.updated\". Abonnementen matchen erop.",
+ "Event source": "Gebeurtenisbron",
+ "The CloudEvent \"source\" URI identifying the emitter.": "De CloudEvent-\"source\"-URI die de verzender identificeert.",
+ "Subject": "Onderwerp",
+ "Optional CloudEvent \"subject\". Supports {{dotted.path}} placeholders resolved from each item.": "Optioneel CloudEvent-\"subject\". Ondersteunt {{dotted.path}}-plaatshouders, opgelost per item.",
+ "Output key": "Uitvoersleutel",
+ "Item key the emit summary is written under. Defaults to \"eventResult\".": "Itemsleutel waaronder de verstuursamenvatting wordt geschreven. Standaard \"eventResult\".",
+ "Name the event \"type\": an event without a type matches no subscription.": "Noem het gebeurtenis-\"type\": een gebeurtenis zonder type matcht geen enkel abonnement.",
+ "Name the event \"source\": a CloudEvent must say where it came from.": "Noem de gebeurtenis-\"source\": een CloudEvent moet zeggen waar hij vandaan komt.",
+ "Step \"%1$s\" failed to emit event \"%2$s\": %3$s": "Stap \"%1$s\" kon gebeurtenis \"%2$s\" niet versturen: %3$s",
+ "The flow \"%1$s\" cannot be migrated to a graph yet: %2$s unsupported feature(s).": "De flow \"%1$s\" kan nog niet naar een graaf worden gemigreerd: %2$s niet-ondersteunde functie(s)."
},
"plurals": {}
}
diff --git a/lib/Command/FlowStepsToGraph.php b/lib/Command/FlowStepsToGraph.php
new file mode 100644
index 000000000..6e8719d30
--- /dev/null
+++ b/lib/Command/FlowStepsToGraph.php
@@ -0,0 +1,154 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * @version GIT:
+ *
+ * @link https://www.Integriq.nl
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/specs/flow-orchestration/spec.md
+ */
+
+declare(strict_types=1);
+
+namespace OCA\Integriq\Command;
+
+use OCA\Integriq\Service\FlowGraphMigrationService;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Style\SymfonyStyle;
+
+/**
+ * Migrates (or rolls back) live flow objects between steps and graph shape.
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/tasks.md#2-the-step-to-graph-migration
+ */
+class FlowStepsToGraph extends Command {
+
+ /**
+ * Constructor.
+ *
+ * @param FlowGraphMigrationService $migration The shared migration behaviour.
+ */
+ public function __construct(
+ private readonly FlowGraphMigrationService $migration,
+ ) {
+ parent::__construct();
+
+ }//end __construct()
+
+ /**
+ * Configure the command name, description and options.
+ *
+ * @return void
+ *
+ * @spec exclude Symfony console wiring — framework metadata, no domain behavior.
+ */
+ protected function configure(): void {
+ $this->setName(name: 'integriq:flow:steps-to-graph')
+ ->setDescription(
+ 'Translate live flow objects from steps[] to the OpenRegister nodes/edges graph (dry run unless --apply)'
+ )
+ ->addOption(
+ 'apply',
+ null,
+ InputOption::VALUE_NONE,
+ 'Write the changes; without it the command only reports what would happen'
+ )
+ ->addOption(
+ 'rollback',
+ null,
+ InputOption::VALUE_NONE,
+ 'Remove the written nodes/edges again, leaving steps[] as the only shape'
+ );
+
+ }//end configure()
+
+ /**
+ * Run the migration (or its rollback) and print one row per flow.
+ *
+ * @param InputInterface $input Console input.
+ * @param OutputInterface $output Console output.
+ *
+ * @return integer 0 when every flow migrated or was already done; 1 when any flow was refused.
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/tasks.md#2-the-step-to-graph-migration
+ */
+ protected function execute(InputInterface $input, OutputInterface $output): int {
+ $io = new SymfonyStyle($input, $output);
+ $apply = ($input->getOption('apply') === true);
+ $report = $this->reportFor(input: $input, apply: $apply);
+
+ if ($apply === false) {
+ $io->note('Dry run — nothing was written. Re-run with --apply to write.');
+ }
+
+ $refusals = 0;
+ foreach ($report as $row) {
+ $line = sprintf('[%s] %s (%s)', $row['action'], $row['name'], $row['id']);
+ $output->writeln($line);
+
+ if ($row['action'] === FlowGraphMigrationService::REFUSED) {
+ $refusals++;
+ foreach ($row['reasons'] as $reason) {
+ $output->writeln(' - ' . $reason);
+ }
+ }
+ }
+
+ $io->success(sprintf('%d flow(s) inspected, %d refused.', count($report), $refusals));
+
+ if ($refusals > 0) {
+ return Command::FAILURE;
+ }
+
+ return Command::SUCCESS;
+ }//end execute()
+
+ /**
+ * Run the direction the flags asked for.
+ *
+ * @param InputInterface $input Console input.
+ * @param bool $apply Whether to write.
+ *
+ * @return array}> One row per flow.
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/tasks.md#2-the-step-to-graph-migration
+ */
+ private function reportFor(InputInterface $input, bool $apply): array {
+ if ($input->getOption('rollback') === true) {
+ return $this->migration->rollback(apply: $apply);
+ }
+
+ return $this->migration->migrate(apply: $apply);
+
+ }//end reportFor()
+}//end class
diff --git a/lib/Controller/ApprovalsController.php b/lib/Controller/ApprovalsController.php
index 112466732..8588e6b4b 100644
--- a/lib/Controller/ApprovalsController.php
+++ b/lib/Controller/ApprovalsController.php
@@ -38,6 +38,7 @@
use OCA\Integriq\Service\ActionAuthService;
use OCA\Integriq\Service\ApprovalService;
use OCA\Integriq\Service\EndpointService;
+use OCA\Integriq\Service\EngineSignalService;
use OCA\Integriq\Service\FlowRunnerService;
use OCA\Integriq\Service\SynchronizationService;
use OCA\OpenRegister\Db\ObjectEntity;
@@ -80,6 +81,10 @@ class ApprovalsController extends Controller {
* @param IUserSession $userSession The user session.
* @param IL10N $l The localization service.
* @param LoggerInterface $logger Logger for non-fatal diagnostics.
+ * @param EngineSignalService|null $engineSignal Delivers approval decisions to suspended
+ * OpenRegister engine runs (retire-integriq-flow-schema
+ * Task 1). Nullable + defaulted so pre-existing
+ * positional test instantiations keep working.
*/
public function __construct(
string $appName,
@@ -93,6 +98,7 @@ public function __construct(
private readonly IUserSession $userSession,
private readonly IL10N $l,
private readonly LoggerInterface $logger,
+ private readonly ?EngineSignalService $engineSignal = null,
) {
parent::__construct(appName: $appName, request: $request);
@@ -198,7 +204,26 @@ public function approve(string $id): JSONResponse {
return new JSONResponse(['error' => $e->getMessage()], $e->getHttpStatus());
}
- $comment = $this->request->getParam('comment');
+ return $this->routeApproval(
+ approvalRequest: $approvalRequest,
+ user: $user,
+ comment: $this->request->getParam('comment')
+ );
+
+ }//end approve()
+
+ /**
+ * Dispatch an authorized approve to the resume path its FK selects.
+ *
+ * @param ObjectEntity $approvalRequest The pending, authorized-to-act-on request.
+ * @param IUser $user The approving user.
+ * @param string|null $comment Optional approve comment.
+ *
+ * @return JSONResponse
+ *
+ * @spec openspec/specs/approval-workflow/spec.md
+ */
+ private function routeApproval(ObjectEntity $approvalRequest, IUser $user, ?string $comment): JSONResponse {
$data = $approvalRequest->getObject();
if (empty($data['endpointId']) === false) {
@@ -213,9 +238,16 @@ public function approve(string $id): JSONResponse {
return $this->approveFlowSuspension(approvalRequest: $approvalRequest, user: $user, comment: $comment);
}
- $this->logger->error('ApprovalsController: approval_request has neither endpointId, synchronizationId nor flowRunId', ['id' => $id]);
+ if (empty($data['engineRunUuid']) === false) {
+ return $this->approveEngineSuspension(approvalRequest: $approvalRequest, data: $data, user: $user, comment: $comment);
+ }
+
+ $this->logger->error(
+ 'ApprovalsController: approval_request has neither endpointId, synchronizationId, flowRunId nor engineRunUuid',
+ ['id' => $approvalRequest->getUuid()]
+ );
return new JSONResponse(['error' => $this->l->t('Malformed approval request')], Http::STATUS_INTERNAL_SERVER_ERROR);
- }//end approve()
+ }//end routeApproval()
/**
* Reject a `pending`, non-expired approval_request. Self-contained in
@@ -267,13 +299,7 @@ public function reject(string $id): JSONResponse {
$data = $approvalRequest->getObject();
- // Flow-sourced suspension (flowRunId set): stop the flow_run — no
- // pipeline to re-invoke here (self-contained, per ApprovalService::reject()'s
- // own docblock), but the flow_run's OWN status must still reflect the
- // rejection (flow-orchestration REQ-005).
- if (empty($data['flowRunId']) === false) {
- $this->flowRunnerService->stopFromApprovalOutcome(approvalRequest: $approvalRequest);
- }
+ $this->propagateRejection(approvalRequest: $approvalRequest, data: $data, user: $user, comment: $comment);
return new JSONResponse(
[
@@ -286,6 +312,40 @@ public function reject(string $id): JSONResponse {
}//end reject()
+ /**
+ * Let the suspended run reflect a rejection, per its FK kind.
+ *
+ * Flow-sourced suspension (flowRunId): stop the app-local flow_run — no
+ * pipeline to re-invoke (self-contained, per `ApprovalService::reject()`'s
+ * own docblock), but the flow_run's OWN status must still reflect the
+ * rejection (flow-orchestration REQ-005).
+ *
+ * Engine-run suspension (engineRunUuid): wake the suspended OpenRegister
+ * run with the rejection so the approval node routes or fails it now.
+ * Best-effort by design — the record IS the decision, and the node's
+ * heartbeat re-reads it, so a lost signal costs one heartbeat rather
+ * than the flow.
+ *
+ * @param ObjectEntity $approvalRequest The just-rejected request.
+ * @param array $data The approval_request's object data.
+ * @param IUser $user The rejecting user.
+ * @param string $comment The rejection comment.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/tasks.md#1-the-missing-node
+ */
+ private function propagateRejection(ObjectEntity $approvalRequest, array $data, IUser $user, string $comment): void {
+ if (empty($data['flowRunId']) === false) {
+ $this->flowRunnerService->stopFromApprovalOutcome(approvalRequest: $approvalRequest);
+ }
+
+ if (empty($data['engineRunUuid']) === false) {
+ $this->signalEngineRun(data: $data, decision: 'rejected', user: $user, comment: $comment);
+ }
+
+ }//end propagateRejection()
+
/**
* Resume a suspended endpoint rule-pipeline run and finalize the
* approval_request with the resumed chain's outcome.
@@ -454,6 +514,87 @@ private function approveFlowSuspension(ObjectEntity $approvalRequest, IUser $use
return new JSONResponse($flowRunData, $statusCode);
}//end approveFlowSuspension()
+ /**
+ * Resolve an ENGINE-run approval: finalize the approval_request, then
+ * wake the suspended OpenRegister flow run with the decision.
+ *
+ * The order is deliberate. The record is resolved FIRST because it is
+ * the system of record — the approval node's heartbeat re-reads it, so
+ * a signal that fails to deliver (OpenRegister mid-upgrade, run already
+ * woken) only delays the resume by one heartbeat instead of losing the
+ * decision. `resumeResult` therefore reports the DELIVERY, not the run's
+ * eventual outcome, which the engine owns.
+ *
+ * @param ObjectEntity $approvalRequest The pending, authorized-to-act-on request.
+ * @param array $data The approval_request's object data.
+ * @param IUser $user The approving user.
+ * @param string|null $comment Optional approve comment.
+ *
+ * @return JSONResponse
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/tasks.md#1-the-missing-node
+ */
+ private function approveEngineSuspension(ObjectEntity $approvalRequest, array $data, IUser $user, ?string $comment): JSONResponse {
+ $signalled = $this->signalEngineRun(data: $data, decision: 'approved', user: $user, comment: $comment);
+
+ $resumeResult = 'error';
+ if ($signalled === true) {
+ $resumeResult = 'success';
+ }
+
+ $approvalRequest = $this->approvalService->completeApproval(
+ approvalRequest: $approvalRequest,
+ approver: $user,
+ resumeResult: $resumeResult,
+ comment: $comment
+ );
+
+ $approvalRequestData = $approvalRequest->getObject();
+
+ return new JSONResponse(
+ [
+ 'engineRunUuid' => (string)($data['engineRunUuid'] ?? ''),
+ 'signalled' => $signalled,
+ '_approval' => [
+ 'id' => $approvalRequest->getUuid(),
+ 'status' => ($approvalRequestData['status'] ?? 'approved'),
+ 'resumedAt' => ($approvalRequestData['approvedAt'] ?? null),
+ ],
+ ]
+ );
+
+ }//end approveEngineSuspension()
+
+ /**
+ * Deliver a decision to a suspended OpenRegister engine run, guarded.
+ *
+ * Delegates to {@see EngineSignalService::deliver()} so the approve and
+ * reject paths ship the identical signal. The service dependency is
+ * defaulted (nullable) so pre-existing positional test instantiations
+ * keep working; the container always injects it in production.
+ *
+ * @param array $data The approval_request's object data (`engineRunUuid`/`signalNodeId`).
+ * @param string $decision `approved` or `rejected`.
+ * @param IUser $user The deciding user.
+ * @param string|null $comment Optional decision comment.
+ *
+ * @return boolean True when the signal was delivered.
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/tasks.md#1-the-missing-node
+ */
+ private function signalEngineRun(array $data, string $decision, IUser $user, ?string $comment): bool {
+ if ($this->engineSignal === null) {
+ $this->logger->warning(
+ 'ApprovalsController: no EngineSignalService wired; the engine run resumes on its next heartbeat instead',
+ ['engineRunUuid' => ($data['engineRunUuid'] ?? '')]
+ );
+ return false;
+ }
+
+ return $this->engineSignal->deliver(data: $data, decision: $decision, user: $user, comment: $comment);
+
+ }//end signalEngineRun()
+
/**
* Build the `_approval`-enveloped response body for a resumed endpoint response.
*
diff --git a/lib/Flow/ApprovalRequestNode.php b/lib/Flow/ApprovalRequestNode.php
new file mode 100644
index 000000000..000147d36
--- /dev/null
+++ b/lib/Flow/ApprovalRequestNode.php
@@ -0,0 +1,642 @@
+.decision` — being told "no" is the flow working.
+ * - **expired** — fails closed, always (`FlowStop`, error). An approval nobody
+ * answered must never quietly count as answered.
+ *
+ * WHY THE HEARTBEAT RE-READS THE RECORD
+ * -------------------------------------
+ * A signal can be delivered while the run has not suspended yet, or its
+ * delivery can simply fail. `AwaitSignalNode`'s answer to that is a heartbeat
+ * that re-asks; this node has something better to re-ask than "did anything
+ * arrive?" — the approval_request row, which `ApprovalsController` resolves
+ * regardless of whether the signal made it. A lost signal therefore costs one
+ * heartbeat, never the flow.
+ *
+ * @category Flow
+ * @package OCA\Integriq\Flow
+ *
+ * @author Conduction Development Team
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * @version GIT:
+ *
+ * @link https://www.Integriq.nl
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/specs/flow-orchestration/spec.md
+ */
+
+declare(strict_types=1);
+
+namespace OCA\Integriq\Flow;
+
+use DateTime;
+use OCA\Integriq\Exception\FlowNodeException;
+use OCA\Integriq\Service\ApprovalService;
+use OCA\OpenRegister\Service\Flow\FlowItems;
+use OCA\OpenRegister\Service\Flow\FlowNodeResumeState;
+use OCA\OpenRegister\Service\Flow\FlowStop;
+use OCA\OpenRegister\Service\Flow\FlowSuspension;
+use OCA\OpenRegister\Service\Flow\IFlowNode;
+use OCA\OpenRegister\Service\Flow\IFlowNodeConfigForm;
+use OCA\OpenRegister\Service\Flow\IFlowNodeConfigKeys;
+use OCP\IL10N;
+use OCP\IURLGenerator;
+use OCP\WorkflowEngine\IManager;
+use Psr\Log\LoggerInterface;
+use Throwable;
+use UnexpectedValueException;
+
+/**
+ * Asks a person, parks the run, and carries their answer onto the items.
+ *
+ * @SuppressWarnings(PHPMD.CouplingBetweenObjects) The count is the engine
+ * vocabulary itself — FlowSuspension/FlowStop/FlowItems/FlowNodeResumeState
+ * plus the three node interfaces — the same fan-in every sibling node
+ * carries (they sit in phpmd.baseline.xml for the identical reason).
+ * @SuppressWarnings(PHPMD.StaticAccess) FlowConfigGuard and FlowNodeSupport
+ * are the shared static config guards every Integriq node validates
+ * through; instantiating them would add state to say the same thing.
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/tasks.md#1-the-missing-node
+ */
+class ApprovalRequestNode implements IFlowNode, IFlowNodeConfigKeys, IFlowNodeConfigForm {
+
+ /**
+ * The step type this node answers to.
+ *
+ * FROZEN on `openconnector.*` — the id is written into stored flow
+ * documents, so it survives the openconnector -> integriq app-id rename.
+ *
+ * @var string
+ */
+ public const NODE_ID = 'openconnector.approval-request';
+
+ /**
+ * The context key the engine delivers a signal payload under.
+ *
+ * Mirrors `FlowRunService::SIGNAL_CONTEXT_KEY`. Declared locally (like
+ * `FlowNodeSupport::ON_ERROR_POLICIES`) so reading it never pulls the run
+ * service into scope on an instance without the flow engine.
+ *
+ * @var string
+ */
+ private const SIGNAL_CONTEXT_KEY = 'signal';
+
+ /**
+ * The context key carrying the engine run's uuid.
+ *
+ * Mirrors `FlowRunContext::CONTEXT_RUN`. The uuid is what the approval
+ * resolution later addresses through `FlowRunSignalService::signalAs()`,
+ * so a run that cannot name itself cannot be approved and is refused.
+ *
+ * @var string
+ */
+ private const RUN_CONTEXT_KEY = 'x-openregister-attribution-run';
+
+ /**
+ * Minutes between heartbeats when the step does not choose.
+ *
+ * Matches `AwaitSignalNode`'s default: short enough that a lost signal is
+ * an inconvenience, long enough that a fortnight-long approval stays
+ * cheap.
+ *
+ * @var int
+ */
+ private const DEFAULT_HEARTBEAT_MINUTES = 15;
+
+ /**
+ * The floor a configured heartbeat is clamped to.
+ *
+ * The stock system cron runs every five minutes; asking for less buys the
+ * same behaviour while looking like it bought more.
+ *
+ * @var int
+ */
+ private const MIN_HEARTBEAT_MINUTES = 5;
+
+ /**
+ * The item key the decision payload is written under by default.
+ *
+ * @var string
+ */
+ private const DEFAULT_SIGNAL_KEY = 'approval';
+
+ /**
+ * Constructor.
+ *
+ * @param ApprovalService $approvalService The HITL state machine — persistence, mirror task, notifications.
+ * @param IL10N $l10n Translations.
+ * @param IURLGenerator $urlGenerator For the palette icon.
+ * @param LoggerInterface $logger Run diagnostics.
+ */
+ public function __construct(
+ private readonly ApprovalService $approvalService,
+ private readonly IL10N $l10n,
+ private readonly IURLGenerator $urlGenerator,
+ private readonly LoggerInterface $logger,
+ ) {
+
+ }//end __construct()
+
+ /**
+ * The step type.
+ *
+ * @return string The type identifier.
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/tasks.md#1-the-missing-node
+ */
+ public function getId(): string {
+ return self::NODE_ID;
+ }//end getId()
+
+ /**
+ * Palette name.
+ *
+ * @return string The display name.
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/tasks.md#1-the-missing-node
+ */
+ public function getDisplayName(): string {
+ return $this->l10n->t('Ask for approval');
+ }//end getDisplayName()
+
+ /**
+ * Palette description.
+ *
+ * @return string The description.
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/tasks.md#1-the-missing-node
+ */
+ public function getDescription(): string {
+ return $this->l10n->t(
+ 'Pause the flow until someone in the approver group approves or rejects. An expired request fails the run.'
+ );
+
+ }//end getDescription()
+
+ /**
+ * Palette icon.
+ *
+ * @return string The icon URL.
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/tasks.md#1-the-missing-node
+ */
+ public function getIcon(): string {
+ return $this->urlGenerator->imagePath('core', 'actions/confirm.svg');
+ }//end getIcon()
+
+ /**
+ * Asking for an approval grants no privilege by itself.
+ *
+ * @param int $scope The scope constant.
+ *
+ * @return boolean Whether it is available.
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/tasks.md#1-the-missing-node
+ */
+ public function isAvailableForScope(int $scope): bool {
+ return in_array($scope, [IManager::SCOPE_ADMIN, IManager::SCOPE_USER], true);
+ }//end isAvailableForScope()
+
+ /**
+ * The node's whole config vocabulary.
+ *
+ * @return array The accepted top-level config keys.
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/tasks.md#1-the-missing-node
+ */
+ public function configKeys(): array {
+ return [
+ 'question',
+ 'approverGroup',
+ 'ttlSeconds',
+ 'failOnReject',
+ 'signalKey',
+ 'heartbeatMinutes',
+ 'onError',
+ ];
+ }//end configKeys()
+
+ /**
+ * The fields this node's configuration is edited through.
+ *
+ * @return array> The field descriptions.
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/tasks.md#1-the-missing-node
+ */
+ public function configForm(): array {
+ return [
+ [
+ 'key' => 'question',
+ 'label' => $this->l10n->t('What is being asked'),
+ 'type' => 'text',
+ 'help' => $this->l10n->t('Shown to the approvers and written on the request, so a paused flow explains itself.'),
+ 'required' => true,
+ ],
+ [
+ 'key' => 'approverGroup',
+ 'label' => $this->l10n->t('Approver group'),
+ 'type' => 'text',
+ 'help' => $this->l10n->t('Members of this group (and admins) may answer. Required: a request nobody owns is a request nobody answers.'),
+ 'required' => true,
+ ],
+ [
+ 'key' => 'ttlSeconds',
+ 'label' => $this->l10n->t('Expires after (seconds)'),
+ 'type' => 'number',
+ 'help' => $this->l10n->t('An unanswered request expires and fails the run. Defaults to 24 hours.'),
+ ],
+ [
+ 'key' => 'failOnReject',
+ 'label' => $this->l10n->t('Treat a rejection as a failure'),
+ 'type' => 'boolean',
+ 'help' => $this->l10n->t('Off by default: a "no" continues the flow with the decision on the items, so a later step can route on it.'),
+ ],
+ [
+ 'key' => 'signalKey',
+ 'label' => $this->l10n->t('Field to store the decision in'),
+ 'type' => 'text',
+ 'help' => $this->l10n->t('The decision is written onto every item under this field. Defaults to "approval".'),
+ ],
+ [
+ 'key' => 'heartbeatMinutes',
+ 'label' => $this->l10n->t('Re-check every (minutes)'),
+ 'type' => 'number',
+ 'help' => $this->l10n->t('Safety net for a lost answer. Lower is not faster: a decision wakes the run immediately either way.'),
+ ],
+ ];
+ }//end configForm()
+
+ /**
+ * Reject a configuration the author cannot have meant, at flow-save time.
+ *
+ * @param array $config The step's authored configuration.
+ *
+ * @return void
+ *
+ * @throws UnexpectedValueException When the configuration is unusable.
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/tasks.md#1-the-missing-node
+ */
+ public function validateConfig(array $config): void {
+ FlowConfigGuard::assertNoForbiddenFields(config: $config, l10n: $this->l10n);
+
+ if (trim((string)($config['question'] ?? '')) === '') {
+ throw new UnexpectedValueException(
+ $this->l10n->t('Say what is being asked ("question"), or nobody can answer it.')
+ );
+ }
+
+ if (trim((string)($config['approverGroup'] ?? '')) === '') {
+ throw new UnexpectedValueException(
+ $this->l10n->t('Name the approver group ("approverGroup"): an approval without an audience never resolves.')
+ );
+ }
+
+ if (array_key_exists('ttlSeconds', $config) === true
+ && (is_numeric($config['ttlSeconds']) === false || ((int)$config['ttlSeconds']) < 1)
+ ) {
+ throw new UnexpectedValueException(
+ $this->l10n->t('The "ttlSeconds" field must be a positive number of seconds when set.')
+ );
+ }
+
+ FlowNodeSupport::assertOnError(config: $config, l10n: $this->l10n);
+
+ }//end validateConfig()
+
+ /**
+ * Ask, suspend, and carry the decision onto the items when it arrives.
+ *
+ * @param array $items The input items.
+ * @param array $config The step's authored configuration.
+ * @param array $context Run-level metadata (signal payload, resume slot, run uuid).
+ *
+ * @return array The items, each carrying the decision under the signal key.
+ *
+ * @throws FlowSuspension While the request is pending.
+ * @throws FlowStop When the request was rejected under `failOnReject`, or expired (fail closed).
+ * @throws FlowNodeException When the run cannot be addressed for a later answer.
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/tasks.md#1-the-missing-node
+ */
+ public function execute(array $items, array $config, array $context): array {
+ $this->validateConfig(config: $config);
+
+ $decision = $this->decisionFrom(context: $context);
+ if ($decision !== null) {
+ return $this->applyDecision(items: $items, config: $config, decision: $decision);
+ }
+
+ $resume = ($context[FlowNodeResumeState::CONTEXT_KEY] ?? null);
+ if ($resume instanceof FlowNodeResumeState === false) {
+ // Without a resume slot every heartbeat would open a fresh
+ // request. That is a broken dispatch, not a pending approval.
+ throw new FlowNodeException(
+ message: $this->l10n->t('The approval step has no resume slot; the engine did not dispatch it as a resumable node.')
+ );
+ }
+
+ if ($resume->has('approvalRequestId') === true) {
+ return $this->answerFromRecord(items: $items, config: $config, resume: $resume);
+ }
+
+ $this->openRequest(config: $config, context: $context, resume: $resume);
+
+ throw new FlowSuspension(
+ resumeAt: $this->heartbeatAt(config: $config),
+ reason: sprintf(
+ 'waiting for approval: %s',
+ trim((string)$config['question'])
+ )
+ );
+
+ }//end execute()
+
+ /**
+ * Persist the pending approval_request and stamp the resume slot.
+ *
+ * The slot's `assignee` is the approver group, which is what
+ * OpenRegister's own signal guard (`FlowRunAssignee`) checks a signaller
+ * against — so the engine-side guard and Integriq's own approver-group
+ * authorization name the same audience.
+ *
+ * @param array $config The step's authored configuration.
+ * @param array $context Run-level metadata.
+ * @param FlowNodeResumeState $resume This node's resume slot.
+ *
+ * @return void
+ *
+ * @throws FlowNodeException When the run has no uuid to answer at.
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/tasks.md#1-the-missing-node
+ */
+ private function openRequest(array $config, array $context, FlowNodeResumeState $resume): void {
+ $runUuid = trim((string)($context[self::RUN_CONTEXT_KEY] ?? ''));
+ if ($runUuid === '') {
+ // A request created for an unaddressable run could be approved and
+ // still resume nothing. Refuse loudly instead.
+ throw new FlowNodeException(
+ message: $this->l10n->t(
+ 'This run carries no uuid, so an approval could never answer it. The approval step is only usable in a persisted flow run.'
+ )
+ );
+ }
+
+ $record = $this->approvalService->suspendForEngineRun(
+ engineRunUuid: $runUuid,
+ signalNodeId: $resume->nodeId(),
+ config: $config,
+ requesterUid: trim((string)($context['triggeredBy'] ?? ''))
+ );
+
+ $data = $record->getObject();
+ $resume->merge(
+ values: [
+ 'approvalRequestId' => $record->getUuid(),
+ 'askedAt' => (new DateTime())->format('c'),
+ 'question' => trim((string)$config['question']),
+ 'assignee' => trim((string)$config['approverGroup']),
+ 'expiresAt' => (string)($data['expiresAt'] ?? ''),
+ ]
+ );
+
+ }//end openRequest()
+
+ /**
+ * The heartbeat's answer when no signal made it: ask the record itself.
+ *
+ * The approval_request is the system of record and `ApprovalsController`
+ * resolves it whether or not the signal delivery succeeded, so a resolved
+ * record with no delivered signal means the answer exists and only the
+ * wake-up was lost.
+ *
+ * @param array $items The input items.
+ * @param array $config The step's authored configuration.
+ * @param FlowNodeResumeState $resume This node's resume slot.
+ *
+ * @return array The items carrying the decision, when the record resolved.
+ *
+ * @throws FlowSuspension While the record is still pending and unexpired.
+ * @throws FlowStop When the record expired, was dead-lettered, or was rejected under `failOnReject`.
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/tasks.md#1-the-missing-node
+ */
+ private function answerFromRecord(array $items, array $config, FlowNodeResumeState $resume): array {
+ $requestId = (string)$resume->get(key: 'approvalRequestId', default: '');
+
+ try {
+ $record = $this->approvalService->find(id: $requestId);
+ $data = $record->getObject();
+ } catch (Throwable $e) {
+ // A vanished record can never resolve; waiting longer cannot fix it.
+ throw new FlowStop(
+ reason: sprintf('Approval request %s no longer exists; failing closed.', $requestId),
+ isError: true
+ );
+ }
+
+ $status = (string)($data['status'] ?? 'pending');
+
+ if ($status === 'approved') {
+ return $this->applyDecision(
+ items: $items,
+ config: $config,
+ decision: [
+ 'decision' => 'approved',
+ 'decidedBy' => (string)($data['approverUserId'] ?? ''),
+ 'comment' => (string)($data['comment'] ?? ''),
+ 'approvalRequestId' => $requestId,
+ ]
+ );
+ }
+
+ if ($status === 'rejected') {
+ return $this->applyDecision(
+ items: $items,
+ config: $config,
+ decision: [
+ 'decision' => 'rejected',
+ 'decidedBy' => (string)($data['approverUserId'] ?? ''),
+ 'comment' => (string)($data['comment'] ?? ''),
+ 'approvalRequestId' => $requestId,
+ ]
+ );
+ }
+
+ if ($status === 'dead_letter') {
+ throw new FlowStop(
+ reason: sprintf('Approval request %s was dead-lettered.', $requestId),
+ isError: true
+ );
+ }
+
+ if ($status !== 'pending' || $this->hasExpired(data: $data, resume: $resume) === true) {
+ // `expired` from the sweep, a past `expiresAt` the sweep has not
+ // reached yet, or any state this node does not know: fail closed.
+ throw new FlowStop(
+ reason: sprintf(
+ 'Approval request %s was not answered in time (status: %s); failing closed.',
+ $requestId,
+ $status
+ ),
+ isError: true
+ );
+ }
+
+ throw new FlowSuspension(
+ resumeAt: $this->heartbeatAt(config: $config),
+ reason: sprintf(
+ 'still waiting for approval: %s',
+ (string)$resume->get(key: 'question', default: 'approval')
+ )
+ );
+
+ }//end answerFromRecord()
+
+ /**
+ * Whether the pending record's deadline has passed.
+ *
+ * @param array $data The approval_request's object data.
+ * @param FlowNodeResumeState $resume This node's resume slot (fallback deadline).
+ *
+ * @return boolean True when expired.
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/tasks.md#1-the-missing-node
+ */
+ private function hasExpired(array $data, FlowNodeResumeState $resume): bool {
+ $expiresAt = trim((string)($data['expiresAt'] ?? $resume->get(key: 'expiresAt', default: '')));
+ if ($expiresAt === '') {
+ return false;
+ }
+
+ try {
+ return new DateTime($expiresAt) < new DateTime();
+ } catch (Throwable $e) {
+ // An unreadable deadline must not read as "never expires".
+ return true;
+ }
+
+ }//end hasExpired()
+
+ /**
+ * Write the decision onto every item, honouring `failOnReject`.
+ *
+ * @param array $items The input items.
+ * @param array $config The step's authored configuration.
+ * @param array $decision The decision payload.
+ *
+ * @return array The items, each carrying the decision under the signal key.
+ *
+ * @throws FlowStop When rejected and the step asked to fail on rejection.
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/tasks.md#1-the-missing-node
+ */
+ private function applyDecision(array $items, array $config, array $decision): array {
+ $verdict = strtolower(trim((string)($decision['decision'] ?? '')));
+
+ if (($verdict === 'reject' || $verdict === 'rejected') && ($config['failOnReject'] ?? false) === true) {
+ throw new FlowStop(
+ reason: sprintf(
+ 'Rejected: %s',
+ trim((string)($decision['comment'] ?? $config['question'] ?? 'no reason given'))
+ ),
+ isError: true
+ );
+ }
+
+ $key = trim((string)($config['signalKey'] ?? ''));
+ if ($key === '') {
+ $key = self::DEFAULT_SIGNAL_KEY;
+ }
+
+ // Into every item's record (`json`), like the engine's own
+ // await-signal node: the steps that follow route per item and read
+ // `json.`; an envelope-level key is invisible to a Switch.
+ foreach ($items as $index => $item) {
+ if (is_array($item) === false) {
+ continue;
+ }
+
+ $json = (array)($item[FlowItems::JSON] ?? []);
+ $json[$key] = $decision;
+ $item[FlowItems::JSON] = $json;
+ $items[$index] = $item;
+ }
+
+ return $items;
+
+ }//end applyDecision()
+
+ /**
+ * The decision this node is waiting for, if a signal delivered it.
+ *
+ * Null covers three cases that must all mean "keep waiting": no signal, a
+ * signal that is not a value bag, and a signal carrying no `decision` —
+ * the last so a stray empty resume cannot approve anything.
+ *
+ * @param array $context Run-level metadata.
+ *
+ * @return array|null The decision payload, or null while unanswered.
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/tasks.md#1-the-missing-node
+ */
+ private function decisionFrom(array $context): ?array {
+ $signal = ($context[self::SIGNAL_CONTEXT_KEY] ?? null);
+ if (is_array($signal) === false) {
+ return null;
+ }
+
+ if (trim((string)($signal['decision'] ?? '')) === '') {
+ return null;
+ }
+
+ return $signal;
+
+ }//end decisionFrom()
+
+ /**
+ * When the next heartbeat should wake the run.
+ *
+ * @param array $config The step's authored configuration.
+ *
+ * @return DateTime The wake-up time.
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/tasks.md#1-the-missing-node
+ */
+ private function heartbeatAt(array $config): DateTime {
+ $minutes = (int)($config['heartbeatMinutes'] ?? self::DEFAULT_HEARTBEAT_MINUTES);
+ if ($minutes < self::MIN_HEARTBEAT_MINUTES) {
+ $minutes = self::MIN_HEARTBEAT_MINUTES;
+ }
+
+ return new DateTime(sprintf('+%d minutes', $minutes));
+
+ }//end heartbeatAt()
+}//end class
diff --git a/lib/Flow/EventEmitNode.php b/lib/Flow/EventEmitNode.php
new file mode 100644
index 000000000..0307cbbd5
--- /dev/null
+++ b/lib/Flow/EventEmitNode.php
@@ -0,0 +1,379 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * @version GIT:
+ *
+ * @link https://www.Integriq.nl
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/specs/flow-orchestration/spec.md
+ */
+
+declare(strict_types=1);
+
+namespace OCA\Integriq\Flow;
+
+use OCA\Integriq\Exception\FlowNodeException;
+use OCA\Integriq\Service\EventService;
+use OCA\OpenRegister\Service\Flow\FlowItems;
+use OCA\OpenRegister\Service\Flow\IFlowNode;
+use OCA\OpenRegister\Service\Flow\IFlowNodeConfigForm;
+use OCA\OpenRegister\Service\Flow\IFlowNodeConfigKeys;
+use OCP\IL10N;
+use OCP\IURLGenerator;
+use OCP\WorkflowEngine\IManager;
+use Psr\Container\ContainerInterface;
+use Psr\Log\LoggerInterface;
+use Throwable;
+use UnexpectedValueException;
+
+/**
+ * Emits one CloudEvent per item through the existing event pipeline.
+ *
+ * @SuppressWarnings(PHPMD.CouplingBetweenObjects) The count is the engine
+ * vocabulary itself — the three node interfaces plus FlowItems and the
+ * shared guards — the same fan-in every sibling node carries (they sit in
+ * phpmd.baseline.xml for the identical reason).
+ * @SuppressWarnings(PHPMD.StaticAccess) FlowConfigGuard, FlowNodeSupport
+ * and FlowTemplate are the shared static helpers every Integriq node
+ * validates and templates through; instantiating them would add state to
+ * say the same thing.
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/tasks.md#1-the-missing-node
+ */
+class EventEmitNode implements IFlowNode, IFlowNodeConfigKeys, IFlowNodeConfigForm {
+
+ /**
+ * The step type this node answers to.
+ *
+ * FROZEN on `openconnector.*` — the id is written into stored flow
+ * documents, so it survives the openconnector -> integriq app-id rename.
+ *
+ * @var string
+ */
+ public const NODE_ID = 'openconnector.event-emit';
+
+ /**
+ * Constructor.
+ *
+ * `EventService` is resolved lazily through the container rather than
+ * constructor-injected — the same idiom `FlowRunnerService` uses —
+ * because `EventService`'s delivery path can dispatch flow work of its
+ * own, and an eager constructor edge from the flow palette into it drags
+ * the whole delivery graph into every palette build.
+ *
+ * @param ContainerInterface $container Lazily resolves EventService.
+ * @param IL10N $l10n Translations.
+ * @param IURLGenerator $urlGenerator For the palette icon.
+ * @param LoggerInterface $logger Run diagnostics.
+ */
+ public function __construct(
+ private readonly ContainerInterface $container,
+ private readonly IL10N $l10n,
+ private readonly IURLGenerator $urlGenerator,
+ private readonly LoggerInterface $logger,
+ ) {
+
+ }//end __construct()
+
+ /**
+ * The step type.
+ *
+ * @return string The type identifier.
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/tasks.md#1-the-missing-node
+ */
+ public function getId(): string {
+ return self::NODE_ID;
+ }//end getId()
+
+ /**
+ * Palette name.
+ *
+ * @return string The display name.
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/tasks.md#1-the-missing-node
+ */
+ public function getDisplayName(): string {
+ return $this->l10n->t('Emit an event');
+ }//end getDisplayName()
+
+ /**
+ * Palette description.
+ *
+ * @return string The description.
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/tasks.md#1-the-missing-node
+ */
+ public function getDescription(): string {
+ return $this->l10n->t(
+ 'Emit a CloudEvent for every item, delivered through the configured event subscriptions.'
+ );
+
+ }//end getDescription()
+
+ /**
+ * Palette icon.
+ *
+ * @return string The icon URL.
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/tasks.md#1-the-missing-node
+ */
+ public function getIcon(): string {
+ return $this->urlGenerator->imagePath('core', 'actions/share.svg');
+ }//end getIcon()
+
+ /**
+ * Whether the node is offered in the given scope.
+ *
+ * @param int $scope The scope constant.
+ *
+ * @return boolean Whether it is available.
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/tasks.md#1-the-missing-node
+ */
+ public function isAvailableForScope(int $scope): bool {
+ return in_array($scope, [IManager::SCOPE_ADMIN, IManager::SCOPE_USER], true);
+ }//end isAvailableForScope()
+
+ /**
+ * The node's whole config vocabulary.
+ *
+ * @return array The accepted top-level config keys.
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/tasks.md#1-the-missing-node
+ */
+ public function configKeys(): array {
+ return ['type', 'source', 'subject', 'output', 'onError'];
+ }//end configKeys()
+
+ /**
+ * The fields this node's configuration is edited through.
+ *
+ * @return array> The field descriptions.
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/tasks.md#1-the-missing-node
+ */
+ public function configForm(): array {
+ return [
+ [
+ 'key' => 'type',
+ 'label' => $this->l10n->t('Event type'),
+ 'type' => 'text',
+ 'help' => $this->l10n->t('The CloudEvent "type", e.g. "nl.example.object.updated". Subscriptions match on it.'),
+ 'required' => true,
+ ],
+ [
+ 'key' => 'source',
+ 'label' => $this->l10n->t('Event source'),
+ 'type' => 'text',
+ 'help' => $this->l10n->t('The CloudEvent "source" URI identifying the emitter.'),
+ 'required' => true,
+ ],
+ [
+ 'key' => 'subject',
+ 'label' => $this->l10n->t('Subject'),
+ 'type' => 'text',
+ 'help' => $this->l10n->t('Optional CloudEvent "subject". Supports {{dotted.path}} placeholders resolved from each item.'),
+ ],
+ [
+ 'key' => 'output',
+ 'label' => $this->l10n->t('Output key'),
+ 'type' => 'text',
+ 'help' => $this->l10n->t('Item key the emit summary is written under. Defaults to "eventResult".'),
+ ],
+ ];
+ }//end configForm()
+
+ /**
+ * Reject a configuration the author cannot have meant, at flow-save time.
+ *
+ * @param array $config The step's authored configuration.
+ *
+ * @return void
+ *
+ * @throws UnexpectedValueException When the configuration is unusable.
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/tasks.md#1-the-missing-node
+ */
+ public function validateConfig(array $config): void {
+ FlowConfigGuard::assertNoForbiddenFields(config: $config, l10n: $this->l10n);
+
+ if (trim((string)($config['type'] ?? '')) === '') {
+ throw new UnexpectedValueException(
+ $this->l10n->t('Name the event "type": an event without a type matches no subscription.')
+ );
+ }
+
+ if (trim((string)($config['source'] ?? '')) === '') {
+ throw new UnexpectedValueException(
+ $this->l10n->t('Name the event "source": a CloudEvent must say where it came from.')
+ );
+ }
+
+ if (array_key_exists('output', $config) === true) {
+ FlowConfigGuard::assertOutputKeyAllowed(outputKey: (string)$config['output'], l10n: $this->l10n);
+ }
+
+ FlowNodeSupport::assertOnError(config: $config, l10n: $this->l10n);
+
+ }//end validateConfig()
+
+ /**
+ * Emit one CloudEvent per item through the existing pipeline.
+ *
+ * @param array $items The input items.
+ * @param array $config The step's authored configuration.
+ * @param array $context Run-level metadata.
+ *
+ * @return array The items, each carrying the emit summary under the output key.
+ *
+ * @throws FlowNodeException On a failure the `onError` policy does not absorb.
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/tasks.md#1-the-missing-node
+ */
+ public function execute(array $items, array $config, array $context): array {
+ // An empty page emits nothing and produces no items — the filter
+ // contract, not a failure.
+ if ($items === []) {
+ return [];
+ }
+
+ $this->validateConfig(config: $config);
+
+ $stepId = FlowNodeSupport::stepId(config: $config, context: $context, nodeId: self::NODE_ID);
+ $onError = FlowNodeSupport::onErrorPolicy(config: $config, context: $context);
+ $eventService = $this->container->get(EventService::class);
+
+ $outputKey = trim((string)($config['output'] ?? ''));
+ if ($outputKey === '') {
+ $outputKey = 'eventResult';
+ }
+
+ $out = [];
+ foreach ($items as $index => $item) {
+ $json = [];
+ $rebuilt = [];
+ if (is_array($item) === true) {
+ $json = (array)($item[FlowItems::JSON] ?? []);
+ $rebuilt = $item;
+ }
+
+ $rebuilt[FlowItems::JSON] = $this->emitForItem(
+ eventService: $eventService,
+ json: $json,
+ config: $config,
+ stepId: $stepId,
+ onError: $onError,
+ outputKey: $outputKey
+ );
+ if (array_key_exists(FlowItems::PAIRED_ITEM, $rebuilt) === false) {
+ $rebuilt[FlowItems::PAIRED_ITEM] = ['item' => $index];
+ }
+
+ $out[] = $rebuilt;
+ }//end foreach
+
+ return $out;
+
+ }//end execute()
+
+ /**
+ * Emit one item's event; success lands under the output key, failure is
+ * explicit — a raise, or `__error` state under `continue`.
+ *
+ * @param EventService $eventService The resolved event pipeline.
+ * @param array $json The item's record.
+ * @param array $config The step's authored configuration.
+ * @param string $stepId The step id, for error messages.
+ * @param string $onError The step's error policy.
+ * @param string $outputKey The key the emit summary lands under.
+ *
+ * @return array The item's record, carrying the summary or the error state.
+ *
+ * @throws FlowNodeException On a failure the `onError` policy does not absorb.
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/tasks.md#1-the-missing-node
+ */
+ private function emitForItem(
+ EventService $eventService,
+ array $json,
+ array $config,
+ string $stepId,
+ string $onError,
+ string $outputKey,
+ ): array {
+ try {
+ $subject = trim(FlowTemplate::renderString(
+ template: (string)($config['subject'] ?? ''),
+ json: $json
+ ));
+ if ($subject === '') {
+ $subject = null;
+ }
+
+ $messages = $eventService->emitCloudEvent(
+ type: (string)$config['type'],
+ source: (string)$config['source'],
+ subject: $subject,
+ data: $json
+ );
+
+ $json[$outputKey] = [
+ 'emitted' => true,
+ 'messageCount' => count($messages),
+ ];
+ } catch (Throwable $e) {
+ if ($onError !== 'continue') {
+ throw new FlowNodeException(
+ message: $this->l10n->t(
+ 'Step "%1$s" failed to emit event "%2$s": %3$s',
+ [$stepId, (string)$config['type'], $e->getMessage()]
+ ),
+ details: ['stepId' => $stepId, 'type' => (string)$config['type']],
+ previous: $e
+ );
+ }
+
+ $this->logger->warning(
+ 'EventEmitNode: item failed to emit, carried as error state (onError: continue)',
+ ['stepId' => $stepId, 'exception' => $e]
+ );
+
+ // Explicit error state, never a success-shaped empty summary.
+ $json[FlowNodeSupport::ERROR_KEY] = [
+ 'failed' => true,
+ 'stepId' => $stepId,
+ 'message' => $e->getMessage(),
+ 'type' => (string)$config['type'],
+ ];
+ }//end try
+
+ return $json;
+
+ }//end emitForItem()
+}//end class
diff --git a/lib/Flow/FlowNodeListener.php b/lib/Flow/FlowNodeListener.php
index a90146bab..6569c2b3a 100644
--- a/lib/Flow/FlowNodeListener.php
+++ b/lib/Flow/FlowNodeListener.php
@@ -50,6 +50,11 @@
*
* @template-implements IEventListener
*
+ * @SuppressWarnings(PHPMD.ExcessiveParameterList) One constructor parameter
+ * per contributed node is the whole job of this class: it is the single
+ * registration fan-in, and hiding the nodes behind a collection would trade
+ * a visible list for an invisible one.
+ *
* @spec openspec/changes/integriq-flow-nodes/tasks.md#task-1-flow-node-scaffolding-guarded-registration-shared-helpers
*/
class FlowNodeListener implements IEventListener {
@@ -64,6 +69,8 @@ class FlowNodeListener implements IEventListener {
* @param ContractCommitNode $contractCommitNode The page-level contract-upsert node.
* @param ContractSweepNode $contractSweepNode The guarded stale-object sweep node.
* @param FetchFileNode $fetchFileNode The fetch-file rule node.
+ * @param ApprovalRequestNode $approvalRequestNode The HITL approval step (retire-integriq-flow-schema).
+ * @param EventEmitNode $eventEmitNode The CloudEvent emit step (retire-integriq-flow-schema).
*/
public function __construct(
private readonly SourceCallNode $sourceCallNode,
@@ -74,6 +81,8 @@ public function __construct(
private readonly ContractCommitNode $contractCommitNode,
private readonly ContractSweepNode $contractSweepNode,
private readonly FetchFileNode $fetchFileNode,
+ private readonly ApprovalRequestNode $approvalRequestNode,
+ private readonly EventEmitNode $eventEmitNode,
) {
}//end __construct()
@@ -104,6 +113,8 @@ public function handle(Event $event): void {
$event->registerNode(node: $this->contractCommitNode);
$event->registerNode(node: $this->contractSweepNode);
$event->registerNode(node: $this->fetchFileNode);
+ $event->registerNode(node: $this->approvalRequestNode);
+ $event->registerNode(node: $this->eventEmitNode);
}//end handle()
}//end class
diff --git a/lib/Repair/MigrateFlowStepsToGraph.php b/lib/Repair/MigrateFlowStepsToGraph.php
new file mode 100644
index 000000000..a718803e4
--- /dev/null
+++ b/lib/Repair/MigrateFlowStepsToGraph.php
@@ -0,0 +1,135 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * @version GIT:
+ *
+ * @link https://www.Integriq.nl
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/specs/flow-orchestration/spec.md
+ */
+
+declare(strict_types=1);
+
+namespace OCA\Integriq\Repair;
+
+use OCA\Integriq\Service\FlowGraphMigrationService;
+use OCP\Migration\IOutput;
+use OCP\Migration\IRepairStep;
+use Psr\Container\ContainerInterface;
+use Psr\Log\LoggerInterface;
+use Throwable;
+
+/**
+ * Applies the steps-to-graph flow migration on install/upgrade.
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/tasks.md#2-the-step-to-graph-migration
+ */
+class MigrateFlowStepsToGraph implements IRepairStep {
+
+ /**
+ * Constructor.
+ *
+ * @param ContainerInterface $container Resolves the migration service lazily,
+ * so the OpenRegister class_exists guard
+ * can short-circuit before anything
+ * referencing OR types is constructed.
+ * @param LoggerInterface $logger For refusals and non-fatal failures.
+ */
+ public function __construct(
+ private readonly ContainerInterface $container,
+ private readonly LoggerInterface $logger,
+ ) {
+
+ }//end __construct()
+
+ /**
+ * Human-readable name surfaced by `occ` during install / upgrade.
+ *
+ * @return string
+ *
+ * @spec exclude Repair-step display name for occ output — framework metadata, no domain behavior.
+ */
+ public function getName(): string {
+ return 'Write the OpenRegister nodes/edges graph onto legacy Integriq flow objects (retire-integriq-flow-schema)';
+ }//end getName()
+
+ /**
+ * Migrate every live flow, additively and idempotently.
+ *
+ * @param IOutput $output Repair output channel.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/tasks.md#2-the-step-to-graph-migration
+ */
+ public function run(IOutput $output): void {
+ if (class_exists('OCA\\OpenRegister\\Service\\ObjectService') === false) {
+ // No OpenRegister, no register, no flows — nothing to migrate.
+ return;
+ }
+
+ try {
+ $migration = $this->container->get(FlowGraphMigrationService::class);
+ $report = $migration->migrate(apply: true);
+ } catch (Throwable $e) {
+ // Non-fatal by contract: the legacy runner still executes steps[],
+ // and the occ command re-runs the migration on demand.
+ $this->logger->warning(
+ 'MigrateFlowStepsToGraph: migration pass failed, flows stay on steps[]: ' . $e->getMessage(),
+ ['exception' => $e]
+ );
+
+ return;
+ }
+
+ $migrated = 0;
+ $refused = 0;
+ foreach ($report as $row) {
+ if ($row['action'] === FlowGraphMigrationService::MIGRATED) {
+ $migrated++;
+ }
+
+ if ($row['action'] === FlowGraphMigrationService::REFUSED) {
+ $refused++;
+ $this->logger->warning(
+ 'MigrateFlowStepsToGraph: flow refused, staying on steps[]',
+ ['flowId' => $row['id'], 'name' => $row['name'], 'reasons' => $row['reasons']]
+ );
+ }
+ }
+
+ if (($migrated + $refused) > 0) {
+ $output->info(sprintf(
+ 'Flow steps-to-graph migration: %d migrated, %d refused (see the log), %d total.',
+ $migrated,
+ $refused,
+ count($report)
+ ));
+ }
+
+ }//end run()
+}//end class
diff --git a/lib/Service/ApprovalService.php b/lib/Service/ApprovalService.php
index da532acfa..238658518 100644
--- a/lib/Service/ApprovalService.php
+++ b/lib/Service/ApprovalService.php
@@ -311,6 +311,80 @@ public function suspendForFlow(ObjectEntity $flowRun, int $resumeStepOrder, arra
return $record;
}//end suspendForFlow()
+ /**
+ * Suspend an OpenRegister ENGINE flow run on an
+ * `openconnector.approval-request` step: persist a `pending`
+ * `approval_request` carrying `engineRunUuid`/`signalNodeId` instead of
+ * `flowRunId`/`resumeStepOrder`, and notify the configured approver
+ * group. The engine run resumes through
+ * `FlowRunSignalService::signalAs()` (delivered by
+ * `ApprovalsController`), never through a FlowToken rehydration — the
+ * engine holds the run's own state, so `snapshot` stays empty and this
+ * record remains purely the human-decision system of record
+ * (hitl-on-shared-tasks D-1 unchanged: the mirror task and
+ * notifications behave exactly as for every other suspension kind).
+ *
+ * @param string $engineRunUuid The suspended OpenRegister flow run's uuid.
+ * @param string $signalNodeId The graph node id awaiting the decision, so the
+ * signal addresses the right resume slot.
+ * @param array $config The approval step's config (`question`/`approverGroup`/`ttlSeconds`/`failOnReject`).
+ * @param string $requesterUid The run owner's uid (`context.triggeredBy`), or '' when unattributed.
+ *
+ * @return ObjectEntity The created, `pending` approval_request.
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/tasks.md#1-the-missing-node
+ */
+ public function suspendForEngineRun(
+ string $engineRunUuid,
+ string $signalNodeId,
+ array $config,
+ string $requesterUid = '',
+ ): ObjectEntity {
+ $ttlSeconds = (int)($config['ttlSeconds'] ?? self::DEFAULT_TTL_SECONDS);
+
+ $now = new DateTime();
+ $expiresAt = (clone $now)->add(new DateInterval('PT' . max($ttlSeconds, 1) . 'S'));
+
+ $requesterUserId = $requesterUid;
+ if ($requesterUserId === '') {
+ $requesterUserId = ($this->userSession->getUser()?->getUID() ?? '');
+ }
+
+ // `onReject` mirrors the node's `failOnReject` into the legacy
+ // vocabulary the sweep and the UI already read: a step that fails on
+ // rejection is `error`, one that routes the rejection onward is
+ // `skip`. `onTimeout` is always `error` — the node fails closed on
+ // expiry by requirement, so the record must not promise otherwise.
+ $onReject = 'skip';
+ if (($config['failOnReject'] ?? false) === true) {
+ $onReject = 'error';
+ }
+
+ $record = $this->objectService->saveObject(
+ object: [
+ 'status' => 'pending',
+ 'engineRunUuid' => $engineRunUuid,
+ 'signalNodeId' => $signalNodeId,
+ 'question' => trim((string)($config['question'] ?? '')),
+ 'timing' => 'before',
+ 'snapshot' => [],
+ 'requesterUserId' => $requesterUserId,
+ 'approverGroup' => (string)($config['approverGroup'] ?? ''),
+ 'onReject' => $onReject,
+ 'onTimeout' => 'error',
+ 'createdAt' => $now->format('c'),
+ 'expiresAt' => $expiresAt->format('c'),
+ ],
+ register: self::REGISTER,
+ schema: self::SCHEMA
+ );
+
+ $record = $this->mirrorIntoSharedTask(approvalRequest: $record);
+ $this->notifyApprovers(approvalRequest: $record);
+
+ return $record;
+ }//end suspendForEngineRun()
+
/**
* Create the `approval_request` gating an `api_product_subscription`
* whose chosen tier has `requiresApproval: true` (api-product-gateway
diff --git a/lib/Service/EngineSignalService.php b/lib/Service/EngineSignalService.php
new file mode 100644
index 000000000..228b36030
--- /dev/null
+++ b/lib/Service/EngineSignalService.php
@@ -0,0 +1,128 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * @version GIT:
+ *
+ * @link https://www.Integriq.nl
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/specs/flow-orchestration/spec.md
+ */
+
+declare(strict_types=1);
+
+namespace OCA\Integriq\Service;
+
+use OCP\IUser;
+use Psr\Log\LoggerInterface;
+use Throwable;
+
+/**
+ * Guarded `FlowRunSignalService::signalAs()` delivery for approval decisions.
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/tasks.md#1-the-missing-node
+ */
+class EngineSignalService {
+
+ /**
+ * The engine's signal service, referenced by NAME only.
+ *
+ * The `BrokeredCallService::BROKER_CLASS` idiom: a string keeps the
+ * compile-time reference out of this app, so Integriq keeps working
+ * against an OpenRegister that predates the signal service.
+ *
+ * @var string
+ */
+ private const SIGNAL_SERVICE_CLASS = 'OCA\\OpenRegister\\Service\\Flow\\FlowRunSignalService';
+
+ /**
+ * Constructor.
+ *
+ * @param LoggerInterface $logger Delivery diagnostics.
+ */
+ public function __construct(
+ private readonly LoggerInterface $logger,
+ ) {
+
+ }//end __construct()
+
+ /**
+ * Deliver a decision to the suspended engine run an approval_request gates.
+ *
+ * Uses `FlowRunSignalService::signalAs()` so the engine's own assignee
+ * guard applies — the same audience check Integriq already made through
+ * `isAuthorizedApprover()`, enforced a second time by the engine against
+ * the resume slot's recorded approver group.
+ *
+ * @param array $data The approval_request's object data (`engineRunUuid`/`signalNodeId`).
+ * @param string $decision `approved` or `rejected`.
+ * @param IUser $user The deciding user.
+ * @param string|null $comment Optional decision comment.
+ *
+ * @return boolean True when the signal was delivered.
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/tasks.md#1-the-missing-node
+ */
+ public function deliver(array $data, string $decision, IUser $user, ?string $comment): bool {
+ if (class_exists(self::SIGNAL_SERVICE_CLASS) === false) {
+ $this->logger->warning(
+ 'EngineSignalService: OpenRegister has no FlowRunSignalService; the engine run resumes on its next heartbeat instead',
+ ['engineRunUuid' => ($data['engineRunUuid'] ?? '')]
+ );
+ return false;
+ }
+
+ $nodeId = trim((string)($data['signalNodeId'] ?? ''));
+ if ($nodeId === '') {
+ $nodeId = null;
+ }
+
+ try {
+ \OCP\Server::get(self::SIGNAL_SERVICE_CLASS)->signalAs(
+ runUuid: (string)($data['engineRunUuid'] ?? ''),
+ payload: [
+ 'decision' => $decision,
+ 'decidedBy' => $user->getUID(),
+ 'comment' => (string)($comment ?? ''),
+ ],
+ actorUid: $user->getUID(),
+ nodeId: $nodeId
+ );
+
+ return true;
+ } catch (Throwable $e) {
+ // NOT_SUSPENDED and RUN_NOT_FOUND included: the record already
+ // carries the decision, so the node's heartbeat picks it up.
+ $this->logger->warning(
+ 'EngineSignalService: could not signal engine run, it will resume on its heartbeat: ' . $e->getMessage(),
+ ['engineRunUuid' => ($data['engineRunUuid'] ?? ''), 'exception' => $e]
+ );
+
+ return false;
+ }//end try
+
+ }//end deliver()
+}//end class
diff --git a/lib/Service/FlowGraphMigrationService.php b/lib/Service/FlowGraphMigrationService.php
new file mode 100644
index 000000000..f492464d0
--- /dev/null
+++ b/lib/Service/FlowGraphMigrationService.php
@@ -0,0 +1,267 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * @version GIT:
+ *
+ * @link https://www.Integriq.nl
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/specs/flow-orchestration/spec.md
+ */
+
+declare(strict_types=1);
+
+namespace OCA\Integriq\Service;
+
+use OCA\Integriq\Exception\EntityNotMigratableException;
+use OCA\OpenRegister\Db\ObjectEntity;
+use OCA\OpenRegister\Service\ObjectService as OrObjectService;
+use Psr\Log\LoggerInterface;
+use Throwable;
+
+/**
+ * Reads, translates and rewrites live `flow` objects, both directions.
+ *
+ * @SuppressWarnings(PHPMD.BooleanArgumentFlag) `$apply` is the dry-run/write
+ * switch the occ command exposes as its own flag — the same
+ * dry-run-by-default contract MigrateInlineSecrets and DedupeContracts
+ * already carry (both suppress this rule for the same reason).
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/tasks.md#2-the-step-to-graph-migration
+ */
+class FlowGraphMigrationService {
+
+ /**
+ * Result marker: the flow's graph was written (or would be, on a dry run).
+ *
+ * @var string
+ */
+ public const MIGRATED = 'migrated';
+
+ /**
+ * Result marker: the flow already carries `nodes` and was left alone.
+ *
+ * @var string
+ */
+ public const SKIPPED = 'skipped';
+
+ /**
+ * Result marker: the translator refused the flow; reasons attached.
+ *
+ * @var string
+ */
+ public const REFUSED = 'refused';
+
+ /**
+ * Result marker: the graph was removed (or would be, on a dry run).
+ *
+ * @var string
+ */
+ public const ROLLED_BACK = 'rolled_back';
+
+ /**
+ * Constructor.
+ *
+ * @param FlowStepsToGraphTranslator $translator The pure steps-to-graph translation.
+ * @param OrObjectService $orObjectService OpenRegister object persistence.
+ * @param LoggerInterface $logger Migration diagnostics.
+ */
+ public function __construct(
+ private readonly FlowStepsToGraphTranslator $translator,
+ private readonly OrObjectService $orObjectService,
+ private readonly LoggerInterface $logger,
+ ) {
+
+ }//end __construct()
+
+ /**
+ * Translate every live `flow` object's steps into a graph, in place.
+ *
+ * @param bool $apply False (the default posture for the occ command) reports
+ * what WOULD happen without writing anything.
+ *
+ * @return array}> One row per flow.
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/tasks.md#2-the-step-to-graph-migration
+ */
+ public function migrate(bool $apply = false): array {
+ $report = [];
+
+ foreach ($this->liveFlows() as $flow) {
+ $data = $flow->getObject();
+ $row = [
+ 'id' => (string)$flow->getUuid(),
+ 'name' => (string)($data['name'] ?? ''),
+ 'action' => self::MIGRATED,
+ 'reasons' => [],
+ ];
+
+ if (empty($data['nodes']) === false) {
+ // Idempotence: a graph already written (by this migration or
+ // by hand) is never overwritten — refusing is the rule the
+ // tasks file states verbatim.
+ $row['action'] = self::SKIPPED;
+ $report[] = $row;
+ continue;
+ }
+
+ try {
+ $graph = $this->translator->translate(flow: $data);
+ } catch (EntityNotMigratableException $e) {
+ $row['action'] = self::REFUSED;
+ $row['reasons'] = $e->getReasons();
+ $this->logger->warning(
+ 'FlowGraphMigrationService: flow refused by the translator: ' . $e->getMessage(),
+ ['flowId' => $row['id'], 'reasons' => $e->getReasons()]
+ );
+ $report[] = $row;
+ continue;
+ }
+
+ if ($apply === true) {
+ $data['nodes'] = $graph['nodes'];
+ $data['edges'] = $graph['edges'];
+ $this->orObjectService->saveObject(
+ object: $data,
+ register: FlowRunnerService::REGISTER,
+ schema: FlowRunnerService::SCHEMA_FLOW,
+ uuid: (string)$flow->getUuid()
+ );
+ }
+
+ $report[] = $row;
+ }//end foreach
+
+ return $report;
+
+ }//end migrate()
+
+ /**
+ * Remove the written graph from every live `flow` object, in place.
+ *
+ * The rollback of a migration whose forward direction is additive: it
+ * deletes `nodes`/`edges` and leaves `steps` as the only shape again. A
+ * flow whose `steps` are gone is REFUSED — deleting its graph would
+ * leave nothing executable at all, and how its steps vanished is a
+ * question for a person, not a rollback.
+ *
+ * @param bool $apply False reports what WOULD happen without writing.
+ *
+ * @return array}> One row per flow.
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/tasks.md#2-the-step-to-graph-migration
+ */
+ public function rollback(bool $apply = false): array {
+ $report = [];
+
+ foreach ($this->liveFlows() as $flow) {
+ $data = $flow->getObject();
+ $row = [
+ 'id' => (string)$flow->getUuid(),
+ 'name' => (string)($data['name'] ?? ''),
+ 'action' => self::ROLLED_BACK,
+ 'reasons' => [],
+ ];
+
+ if (empty($data['nodes']) === true && empty($data['edges']) === true) {
+ $row['action'] = self::SKIPPED;
+ $report[] = $row;
+ continue;
+ }
+
+ if (empty($data['steps']) === true) {
+ $row['action'] = self::REFUSED;
+ $row['reasons'] = ['The flow has a graph but no steps; removing the graph would leave it with no executable shape.'];
+ $report[] = $row;
+ continue;
+ }
+
+ if ($apply === true) {
+ unset($data['nodes'], $data['edges']);
+ $this->orObjectService->saveObject(
+ object: $data,
+ register: FlowRunnerService::REGISTER,
+ schema: FlowRunnerService::SCHEMA_FLOW,
+ uuid: (string)$flow->getUuid()
+ );
+ }
+
+ $report[] = $row;
+ }//end foreach
+
+ return $report;
+
+ }//end rollback()
+
+ /**
+ * Every live `flow` object, unfiltered by tenancy — a migration walks
+ * the whole table or it is not a migration.
+ *
+ * @return array The flow objects.
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/tasks.md#2-the-step-to-graph-migration
+ */
+ private function liveFlows(): array {
+ try {
+ $matches = $this->orObjectService->findAll(
+ config: [
+ 'filters' => [
+ 'register' => FlowRunnerService::REGISTER,
+ 'schema' => FlowRunnerService::SCHEMA_FLOW,
+ ],
+ 'limit' => 1000,
+ ],
+ _rbac: false,
+ _multitenancy: false
+ );
+ } catch (Throwable $e) {
+ // A register that does not exist yet (fresh install ordering) has
+ // no flows to migrate; that is a no-op, not a failure.
+ $this->logger->info(
+ 'FlowGraphMigrationService: could not list flows (register not initialised yet?): ' . $e->getMessage()
+ );
+
+ return [];
+ }
+
+ $results = ($matches['results'] ?? $matches);
+
+ return array_values(
+ array_filter(
+ (array)$results,
+ static fn ($row): bool => $row instanceof ObjectEntity
+ )
+ );
+
+ }//end liveFlows()
+}//end class
diff --git a/lib/Service/FlowStepsToGraphTranslator.php b/lib/Service/FlowStepsToGraphTranslator.php
new file mode 100644
index 000000000..eed239f36
--- /dev/null
+++ b/lib/Service/FlowStepsToGraphTranslator.php
@@ -0,0 +1,645 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * @version GIT:
+ *
+ * @link https://www.Integriq.nl
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/specs/flow-orchestration/spec.md
+ */
+
+declare(strict_types=1);
+
+namespace OCA\Integriq\Service;
+
+use OCA\Integriq\Exception\EntityNotMigratableException;
+use OCP\IL10N;
+
+/**
+ * Pure `steps[]` -> `nodes`/`edges` translation for the flow migration.
+ *
+ * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) The class is a total
+ * function over the closed six-type step vocabulary: every type contributes
+ * its own mapping arm and its own refusal checks, and that enumeration IS
+ * the migration. Splitting it across classes would scatter one closed
+ * decision table without removing a single decision.
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/tasks.md#2-the-step-to-graph-migration
+ */
+class FlowStepsToGraphTranslator {
+
+ /**
+ * Legacy step type -> contributed/built-in node type.
+ *
+ * Every entry is a node that exists: the two `integriq-flow-nodes` nodes,
+ * the two nodes `retire-integriq-flow-schema` contributes, and the
+ * engine's own switch anchor for `branch`.
+ *
+ * @var array
+ */
+ private const TYPE_MAP = [
+ 'call' => 'openconnector.source-call',
+ 'mapping' => 'openconnector.apply-mapping',
+ 'synchronization' => 'openconnector.synchronization-run',
+ 'event' => 'openconnector.event-emit',
+ 'approval' => 'openconnector.approval-request',
+ 'branch' => 'openregister.switch',
+ ];
+
+ /**
+ * The graph's entry node id.
+ *
+ * @var string
+ */
+ private const TRIGGER_ID = 'trigger';
+
+ /**
+ * The graph's terminal node id.
+ *
+ * @var string
+ */
+ private const END_ID = 'end';
+
+ /**
+ * Constructor.
+ *
+ * @param IL10N $l10n Translations for the refusal summary.
+ */
+ public function __construct(
+ private readonly IL10N $l10n,
+ ) {
+
+ }//end __construct()
+
+ /**
+ * Translate one flow document's `steps[]` into `nodes` and `edges`.
+ *
+ * @param array $flow The flow's serialised record (needs `steps`, uses `name` in messages).
+ *
+ * @return array{nodes: array>, edges: array>} The graph.
+ *
+ * @throws EntityNotMigratableException When the flow uses a feature the graph cannot express.
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/tasks.md#2-the-step-to-graph-migration
+ */
+ public function translate(array $flow): array {
+ $steps = $this->sortedSteps(steps: (array)($flow['steps'] ?? []));
+
+ $reasons = $this->refusalsFor(steps: $steps);
+ if ($reasons !== []) {
+ throw new EntityNotMigratableException(
+ subject: 'flow',
+ message: $this->l10n->t(
+ 'The flow "%1$s" cannot be migrated to a graph yet: %2$s unsupported feature(s).',
+ [(string)($flow['name'] ?? ($flow['uuid'] ?? 'unnamed')), (string)count($reasons)]
+ ),
+ reasons: $reasons
+ );
+ }
+
+ $nodes = [
+ ['id' => self::TRIGGER_ID, 'type' => 'openregister.trigger-manual', 'config' => []],
+ ];
+ $edges = [];
+
+ $orders = array_map(static fn (array $step): int => (int)$step['order'], $steps);
+ $previousId = self::TRIGGER_ID;
+
+ foreach ($steps as $index => $step) {
+ $id = (string)((int)$step['order']);
+ $nodes[] = $this->nodeFor(step: $step, id: $id);
+
+ // The edge INTO this step from the sequential predecessor. A
+ // branch's own outgoing edges are conditioned below; every other
+ // step chains to the next order, which is exactly the runner's
+ // sequential walk.
+ if ($previousId !== null) {
+ $edges[] = ['id' => $previousId . '-' . $id, 'from' => $previousId, 'to' => $id];
+ }
+
+ $nextId = self::END_ID;
+ if (isset($orders[($index + 1)]) === true) {
+ $nextId = (string)$orders[($index + 1)];
+ }
+
+ if ((string)($step['type'] ?? '') === 'branch') {
+ foreach ($this->branchEdges(step: $step, id: $id, nextId: $nextId) as $edge) {
+ $edges[] = $edge;
+ }
+
+ // The branch's outgoing edges are complete; nothing chains
+ // sequentially out of it.
+ $previousId = null;
+ continue;
+ }
+
+ $previousId = $id;
+ }//end foreach
+
+ $nodes[] = ['id' => self::END_ID, 'type' => 'openregister.end', 'config' => []];
+
+ if ($previousId !== null) {
+ $edges[] = ['id' => $previousId . '-' . self::END_ID, 'from' => $previousId, 'to' => self::END_ID];
+ }
+
+ return [
+ 'nodes' => $nodes,
+ 'edges' => $edges,
+ ];
+
+ }//end translate()
+
+ /**
+ * Every feature of this flow the graph translation cannot express.
+ *
+ * An empty list means the flow is migratable. A non-empty one is the
+ * refusal — migrating anyway would swap declared behaviour for silence
+ * or approximation.
+ *
+ * @param array $steps The steps, sorted by `order`.
+ *
+ * @return array One sentence per unsupported feature.
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/tasks.md#2-the-step-to-graph-migration
+ */
+ public function refusalsFor(array $steps): array {
+ $reasons = [];
+
+ if ($steps === []) {
+ $reasons[] = 'The flow has no steps; there is nothing to migrate.';
+ }
+
+ $seen = [];
+ $orders = [];
+ foreach ($steps as $step) {
+ $order = (int)($step['order'] ?? 0);
+ if (isset($seen[$order]) === true) {
+ $reasons[] = sprintf(
+ 'Duplicate step order %d — FlowRunnerService::run() rejects this flow today, and a graph would silently lose one of the two nodes.',
+ $order
+ );
+ }
+
+ $seen[$order] = true;
+ $orders[] = $order;
+ }
+
+ foreach ($steps as $step) {
+ $order = (int)($step['order'] ?? 0);
+ $type = (string)($step['type'] ?? '');
+
+ if (isset(self::TYPE_MAP[$type]) === false) {
+ $reasons[] = sprintf('Step %d has unsupported type "%s".', $order, $type);
+ continue;
+ }
+
+ if (empty($step['condition']) === false) {
+ $reasons[] = sprintf(
+ 'Step %d carries a run-if `condition`; the graph expresses conditions on branch edges, not as step skips, so this flow needs a manual re-model.',
+ $order
+ );
+ }
+
+ $reasons = array_merge($reasons, $this->stepRefusals(step: $step, order: $order, type: $type, orders: $orders));
+ }//end foreach
+
+ return $reasons;
+
+ }//end refusalsFor()
+
+ /**
+ * Per-type refusals for one step.
+ *
+ * @param array $step The step definition.
+ * @param int $order The step's order.
+ * @param string $type The step's (supported) type.
+ * @param array $orders Every declared order, for branch-target checks.
+ *
+ * @return array One sentence per unsupported feature.
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/tasks.md#2-the-step-to-graph-migration
+ */
+ private function stepRefusals(array $step, int $order, string $type, array $orders): array {
+ $config = (array)($step['config'] ?? []);
+
+ return array_merge(
+ $this->referenceRefusals(step: $step, order: $order, type: $type),
+ $this->configRefusals(config: $config, order: $order, type: $type),
+ $this->branchRefusals(step: $step, order: $order, type: $type, orders: $orders)
+ );
+
+ }//end stepRefusals()
+
+ /**
+ * The refusal for a step whose node requires a `configRef` it lacks.
+ *
+ * @param array $step The step definition.
+ * @param int $order The step's order.
+ * @param string $type The step's (supported) type.
+ *
+ * @return array Zero or one sentence.
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/tasks.md#2-the-step-to-graph-migration
+ */
+ private function referenceRefusals(array $step, int $order, string $type): array {
+ if (in_array($type, ['call', 'mapping', 'synchronization'], true) === false) {
+ return [];
+ }
+
+ if (trim((string)($step['configRef'] ?? '')) !== '') {
+ return [];
+ }
+
+ return [sprintf('Step %d (%s) names no `configRef`; the node it maps to requires the referenced entity.', $order, $type)];
+
+ }//end referenceRefusals()
+
+ /**
+ * The per-type refusals living in a step's `config` block.
+ *
+ * @param array $config The step's config block.
+ * @param int $order The step's order.
+ * @param string $type The step's (supported) type.
+ *
+ * @return array One sentence per unsupported feature.
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/tasks.md#2-the-step-to-graph-migration
+ */
+ private function configRefusals(array $config, int $order, string $type): array {
+ return match ($type) {
+ 'call' => $this->callRefusals(config: $config, order: $order),
+ 'synchronization' => $this->synchronizationRefusals(config: $config, order: $order),
+ 'event' => $this->eventRefusals(config: $config, order: $order),
+ 'approval' => $this->approvalRefusals(config: $config, order: $order),
+ default => [],
+ };
+
+ }//end configRefusals()
+
+ /**
+ * The `call` step features the source-call node cannot express.
+ *
+ * @param array $config The step's config block.
+ * @param int $order The step's order.
+ *
+ * @return array Zero or one sentence.
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/tasks.md#2-the-step-to-graph-migration
+ */
+ private function callRefusals(array $config, int $order): array {
+ if (empty($config['requestConfig']) === true) {
+ return [];
+ }
+
+ return [
+ sprintf(
+ 'Step %d (call) carries a raw `requestConfig`; the source-call node expresses requests as '
+ . 'endpoint/method/query/body/headers, so this step needs a manual re-model.',
+ $order
+ ),
+ ];
+
+ }//end callRefusals()
+
+ /**
+ * The `synchronization` step features the synchronization-run node lacks.
+ *
+ * @param array $config The step's config block.
+ * @param int $order The step's order.
+ *
+ * @return array One sentence per unsupported feature.
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/tasks.md#2-the-step-to-graph-migration
+ */
+ private function synchronizationRefusals(array $config, int $order): array {
+ $reasons = [];
+
+ if (($config['isTest'] ?? false) === true) {
+ $reasons[] = sprintf('Step %d (synchronization) runs in `isTest` mode, which the synchronization-run node does not offer.', $order);
+ }
+
+ if (trim((string)($config['mutationType'] ?? '')) !== '') {
+ $reasons[] = sprintf('Step %d (synchronization) sets a `mutationType`, which the synchronization-run node does not offer.', $order);
+ }
+
+ return $reasons;
+
+ }//end synchronizationRefusals()
+
+ /**
+ * The `event` step configuration the event-emit node would reject.
+ *
+ * @param array $config The step's config block.
+ * @param int $order The step's order.
+ *
+ * @return array Zero or one sentence.
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/tasks.md#2-the-step-to-graph-migration
+ */
+ private function eventRefusals(array $config, int $order): array {
+ if (trim((string)($config['type'] ?? '')) !== '') {
+ return [];
+ }
+
+ return [sprintf('Step %d (event) names no event `type`.', $order)];
+
+ }//end eventRefusals()
+
+ /**
+ * The `approval` step configuration the approval-request node would reject.
+ *
+ * @param array $config The step's config block.
+ * @param int $order The step's order.
+ *
+ * @return array Zero or one sentence.
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/tasks.md#2-the-step-to-graph-migration
+ */
+ private function approvalRefusals(array $config, int $order): array {
+ if (trim((string)($config['approverGroup'] ?? '')) !== '') {
+ return [];
+ }
+
+ return [
+ sprintf(
+ 'Step %d (approval) names no `approverGroup`; the approval-request node requires an audience, '
+ . 'because a request nobody owns never resolves.',
+ $order
+ ),
+ ];
+
+ }//end approvalRefusals()
+
+ /**
+ * The refusals for branch targets that resolve to no step.
+ *
+ * @param array $step The step definition.
+ * @param int $order The step's order.
+ * @param string $type The step's (supported) type.
+ * @param array $orders Every declared order.
+ *
+ * @return array One sentence per dangling target.
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/tasks.md#2-the-step-to-graph-migration
+ */
+ private function branchRefusals(array $step, int $order, string $type, array $orders): array {
+ if ($type !== 'branch') {
+ return [];
+ }
+
+ $reasons = [];
+ foreach ($this->branchTargets(step: $step) as $target) {
+ if (in_array($target, $orders, true) === false) {
+ $reasons[] = sprintf('Step %d (branch) targets step order %d, which does not exist.', $order, $target);
+ }
+ }
+
+ return $reasons;
+
+ }//end branchRefusals()
+
+ /**
+ * The graph node standing in for one step.
+ *
+ * @param array $step The step definition.
+ * @param string $id The node id (the step's order, verbatim).
+ *
+ * @return array The node.
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/tasks.md#2-the-step-to-graph-migration
+ */
+ private function nodeFor(array $step, string $id): array {
+ $type = (string)$step['type'];
+ $node = [
+ 'id' => $id,
+ 'type' => self::TYPE_MAP[$type],
+ 'config' => $this->configFor(step: $step, type: $type),
+ ];
+
+ // The engine reads the policy from the STEP definition
+ // (`$step['onError']`), same key, same vocabulary — carry it over
+ // verbatim so stop/continue/dead_letter behave as authored.
+ if (trim((string)($step['onError'] ?? '')) !== '') {
+ $node['onError'] = (string)$step['onError'];
+ }
+
+ return $node;
+
+ }//end nodeFor()
+
+ /**
+ * The node config standing in for one step's `configRef`/`config`.
+ *
+ * @param array $step The step definition.
+ * @param string $type The step's type.
+ *
+ * @return array The node config.
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/tasks.md#2-the-step-to-graph-migration
+ */
+ private function configFor(array $step, string $type): array {
+ $config = (array)($step['config'] ?? []);
+ $configRef = trim((string)($step['configRef'] ?? ''));
+
+ switch ($type) {
+ case 'call':
+ return [
+ 'source' => $configRef,
+ 'endpoint' => (string)($config['endpoint'] ?? ''),
+ 'method' => (string)($config['method'] ?? 'GET'),
+ 'output' => 'response',
+ ];
+ case 'mapping':
+ // No `input`/`output`: the node then maps the whole item and
+ // replaces it with the result, which is exactly the runner's
+ // output-becomes-next-input threading.
+ return ['mapping' => $configRef];
+ case 'synchronization':
+ $node = [
+ 'synchronization' => $configRef,
+ 'output' => 'syncResult',
+ ];
+ if (array_key_exists('force', $config) === true) {
+ $node['force'] = (bool)$config['force'];
+ }
+
+ return $node;
+ case 'event':
+ $node = [
+ 'type' => (string)($config['type'] ?? ''),
+ 'source' => (string)($config['source'] ?? ''),
+ ];
+ if (trim((string)($config['subject'] ?? '')) !== '') {
+ $node['subject'] = (string)$config['subject'];
+ }
+
+ return $node;
+ case 'approval':
+ return [
+ 'question' => $this->approvalQuestion(step: $step, config: $config),
+ 'approverGroup' => (string)($config['approverGroup'] ?? ''),
+ 'ttlSeconds' => (int)($config['ttlSeconds'] ?? ApprovalService::DEFAULT_TTL_SECONDS),
+ // The runner's `onReject` vocabulary: anything but `skip`
+ // (error, dead_letter) ended the run, so it maps to the
+ // node failing on rejection.
+ 'failOnReject' => ((string)($config['onReject'] ?? 'error')) !== 'skip',
+ ];
+ default:
+ // The branch case: the switch anchor carries no config — its
+ // routing lives on the conditioned edges.
+ return [];
+ }//end switch
+
+ }//end configFor()
+
+ /**
+ * The question an approval step asks, synthesised when the step has none.
+ *
+ * @param array $step The step definition.
+ * @param array $config The step's config block.
+ *
+ * @return string The question.
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/tasks.md#2-the-step-to-graph-migration
+ */
+ private function approvalQuestion(array $step, array $config): string {
+ $question = trim((string)($config['question'] ?? ''));
+ if ($question !== '') {
+ return $question;
+ }
+
+ return sprintf('Approve step %d of this flow.', (int)($step['order'] ?? 0));
+
+ }//end approvalQuestion()
+
+ /**
+ * The conditioned edges leaving a branch step.
+ *
+ * Each `branches[]` entry becomes an edge carrying its JsonLogic
+ * condition verbatim — the engine evaluates edge conditions with the
+ * same JsonLogic the runner used. The default edge (no condition) goes
+ * to `defaultNextStepOrder` when declared and otherwise to the next
+ * sequential step, which is the runner's own fallthrough.
+ *
+ * @param array $step The branch step definition.
+ * @param string $id The branch node's id.
+ * @param string $nextId The sequential successor's node id (or the end node).
+ *
+ * @return array> The edges.
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/tasks.md#2-the-step-to-graph-migration
+ */
+ private function branchEdges(array $step, string $id, string $nextId): array {
+ $edges = [];
+
+ foreach ((array)($step['branches'] ?? []) as $index => $branch) {
+ if (is_array($branch) === false || empty($branch['condition']) === true || isset($branch['nextStepOrder']) === false) {
+ continue;
+ }
+
+ $target = (string)((int)$branch['nextStepOrder']);
+ $edges[] = [
+ 'id' => sprintf('%s-%s-%d', $id, $target, (int)$index),
+ 'from' => $id,
+ 'to' => $target,
+ 'condition' => $branch['condition'],
+ ];
+ }
+
+ $defaultTarget = $nextId;
+ if (isset($step['defaultNextStepOrder']) === true) {
+ $defaultTarget = (string)((int)$step['defaultNextStepOrder']);
+ }
+
+ $edges[] = [
+ 'id' => sprintf('%s-%s-default', $id, $defaultTarget),
+ 'from' => $id,
+ 'to' => $defaultTarget,
+ ];
+
+ return $edges;
+
+ }//end branchEdges()
+
+ /**
+ * The branch targets a branch step declares, for existence checks.
+ *
+ * @param array $step The branch step definition.
+ *
+ * @return array Every referenced step order.
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/tasks.md#2-the-step-to-graph-migration
+ */
+ private function branchTargets(array $step): array {
+ $targets = [];
+
+ foreach ((array)($step['branches'] ?? []) as $branch) {
+ if (is_array($branch) === true && isset($branch['nextStepOrder']) === true) {
+ $targets[] = (int)$branch['nextStepOrder'];
+ }
+ }
+
+ if (isset($step['defaultNextStepOrder']) === true) {
+ $targets[] = (int)$step['defaultNextStepOrder'];
+ }
+
+ return $targets;
+
+ }//end branchTargets()
+
+ /**
+ * Sort steps by `order` ascending — the runner's own execution sequence.
+ *
+ * @param array $steps Raw `steps[]` from the flow record.
+ *
+ * @return array Steps sorted ascending by `order`.
+ *
+ * @spec openspec/changes/retire-integriq-flow-schema/tasks.md#2-the-step-to-graph-migration
+ */
+ private function sortedSteps(array $steps): array {
+ $steps = array_values(array_filter($steps, static fn ($step): bool => is_array($step)));
+ usort(
+ $steps,
+ static fn (array $a, array $b): int => (((int)($a['order'] ?? 0)) <=> ((int)($b['order'] ?? 0)))
+ );
+
+ return $steps;
+
+ }//end sortedSteps()
+}//end class
diff --git a/lib/Settings/integriq_mock_register.json b/lib/Settings/integriq_mock_register.json
index 3d1a0d9a6..129d4186f 100644
--- a/lib/Settings/integriq_mock_register.json
+++ b/lib/Settings/integriq_mock_register.json
@@ -3004,7 +3004,6 @@
"appendOnly": false,
"immutable": false
},
-
"flow_run": {
"slug": "flow_run",
"title": "Flow Run",
@@ -10994,6 +10993,183 @@
"translatedAt": "2026-03-03T09:00:00+00:00",
"created": "2026-03-03T09:00:00+00:00",
"updated": "2026-03-03T09:00:00+00:00"
+ },
+ {
+ "@self": {
+ "register": "integriq",
+ "schema": "flow",
+ "slug": "flow-demo-1"
+ },
+ "name": "Demo: enrich and store",
+ "description": "Calls a source, maps the answer and stores it. Demo data, safe to delete.",
+ "isEnabled": false,
+ "steps": [
+ {
+ "order": 10,
+ "type": "call",
+ "configRef": "00000000-0000-4000-8000-000000000000",
+ "onError": "stop",
+ "config": {
+ "endpoint": "/organisations/123",
+ "method": "GET"
+ }
+ },
+ {
+ "order": 20,
+ "type": "mapping",
+ "configRef": "00000000-0000-4000-8000-000000000000",
+ "onError": "stop"
+ },
+ {
+ "order": 30,
+ "type": "synchronization",
+ "configRef": "00000000-0000-4000-8000-000000000000",
+ "onError": "dead_letter"
+ }
+ ]
+ },
+ {
+ "@self": {
+ "register": "integriq",
+ "schema": "flow",
+ "slug": "flow-demo-2"
+ },
+ "name": "Demo: gated publish",
+ "description": "Asks the approver group before emitting the publish event. Demo data, safe to delete.",
+ "isEnabled": false,
+ "steps": [
+ {
+ "order": 10,
+ "type": "approval",
+ "onError": "stop",
+ "config": {
+ "approverGroup": "demo-approvers",
+ "onReject": "error",
+ "ttlSeconds": 86400
+ }
+ },
+ {
+ "order": 20,
+ "type": "event",
+ "onError": "continue",
+ "config": {
+ "type": "nl.example.dataset.published",
+ "source": "https://example.org/integriq"
+ }
+ }
+ ]
+ },
+ {
+ "@self": {
+ "register": "integriq",
+ "schema": "flow",
+ "slug": "flow-demo-3"
+ },
+ "name": "Demo: branched routing (migrated shape)",
+ "description": "A branch step choosing between two mappings, carrying the migrated nodes/edges graph beside its steps. Demo data, safe to delete.",
+ "isEnabled": false,
+ "steps": [
+ {
+ "order": 10,
+ "type": "branch",
+ "onError": "stop",
+ "branches": [
+ {
+ "condition": {
+ "==": [
+ {
+ "var": "syncInputAmended.kind"
+ },
+ "a"
+ ]
+ },
+ "nextStepOrder": 40
+ }
+ ],
+ "defaultNextStepOrder": 50
+ },
+ {
+ "order": 40,
+ "type": "mapping",
+ "configRef": "00000000-0000-4000-8000-000000000000",
+ "onError": "stop"
+ },
+ {
+ "order": 50,
+ "type": "mapping",
+ "configRef": "00000000-0000-4000-8000-000000000000",
+ "onError": "stop"
+ }
+ ],
+ "nodes": [
+ {
+ "id": "trigger",
+ "type": "openregister.trigger-manual",
+ "config": {}
+ },
+ {
+ "id": "10",
+ "type": "openregister.switch",
+ "config": {},
+ "onError": "stop"
+ },
+ {
+ "id": "40",
+ "type": "openconnector.apply-mapping",
+ "config": {
+ "mapping": "00000000-0000-4000-8000-000000000000"
+ },
+ "onError": "stop"
+ },
+ {
+ "id": "50",
+ "type": "openconnector.apply-mapping",
+ "config": {
+ "mapping": "00000000-0000-4000-8000-000000000000"
+ },
+ "onError": "stop"
+ },
+ {
+ "id": "end",
+ "type": "openregister.end",
+ "config": {}
+ }
+ ],
+ "edges": [
+ {
+ "id": "trigger-10",
+ "from": "trigger",
+ "to": "10"
+ },
+ {
+ "id": "10-40-0",
+ "from": "10",
+ "to": "40",
+ "condition": {
+ "==": [
+ {
+ "var": "syncInputAmended.kind"
+ },
+ "a"
+ ]
+ }
+ },
+ {
+ "id": "10-50-default",
+ "from": "10",
+ "to": "50"
+ },
+ {
+ "id": "40-end",
+ "from": "40",
+ "to": "end"
+ },
+ {
+ "id": "50-end",
+ "from": "50",
+ "to": "end"
+ }
+ ]
}
]
}
diff --git a/lib/Settings/register.d/visual-flow-orchestration.json b/lib/Settings/register.d/visual-flow-orchestration.json
index df1943d57..65184c021 100644
--- a/lib/Settings/register.d/visual-flow-orchestration.json
+++ b/lib/Settings/register.d/visual-flow-orchestration.json
@@ -1,5 +1,5 @@
{
- "$comment": "ADR-037 register fragment (visual-flow-orchestration). Declares the `flow` schema (an ordered, declarative multi-step pipeline of existing Source/Mapping/Synchronization/Endpoint/Approval references) plus its `flow_run`/`flow_run_log` execution-trace schemas, and additively extends `approval_request` (declared by hitl-approval-rule-action.json) with `flowRunId`/`resumeStepOrder` so an `approval` flow step can suspend/resume through the same approval_request state machine. See openspec/changes/archive/2026-07-15-visual-flow-orchestration/design.md Decisions 1/4/6. Deep-merge note: this fragment only adds the two new `approval_request` properties (InitializeRegister::deepMergeConfig unions `components.schemas.approval_request.properties` by key with hitl-approval-rule-action.json's — glob sort places this file after the `h...` fragment alphabetically, so the union always sees the base schema first); it does not repeat approval_request's existing properties/required/seed data.",
+ "$comment": "ADR-037 register fragment (visual-flow-orchestration). Declares the `flow` schema (an ordered, declarative multi-step pipeline of existing Source/Mapping/Synchronization/Endpoint/Approval references) plus its `flow_run`/`flow_run_log` execution-trace schemas, and additively extends `approval_request` (declared by hitl-approval-rule-action.json) with `flowRunId`/`resumeStepOrder` so an `approval` flow step can suspend/resume through the same approval_request state machine. See openspec/changes/archive/2026-07-15-visual-flow-orchestration/design.md Decisions 1/4/6. Deep-merge note: this fragment only adds the two new `approval_request` properties (InitializeRegister::deepMergeConfig unions `components.schemas.approval_request.properties` by key with hitl-approval-rule-action.json's — glob sort places this file after the `h...` fragment alphabetically, so the union always sees the base schema first); it does not repeat approval_request's existing properties/required/seed data. retire-integriq-flow-schema additions: approval_request additionally gains engineRunUuid/signalNodeId/question (engine-run approvals via the openconnector.approval-request node), and flow gains nodes/edges (the steps-to-graph migration writes the graph IN PLACE next to steps, so the two engines dual-run until FlowRunnerService is retired; steps is kept as the rollback shape).",
"components": {
"registers": {
"integriq": {
@@ -119,6 +119,18 @@
"format": "date-time",
"description": "OR-managed update timestamp",
"title": "Updated"
+ },
+ "nodes": {
+ "type": "array",
+ "title": "Nodes",
+ "description": "OpenRegister flow-engine graph nodes ({id, type, config, onError}), written IN PLACE by the steps-to-graph migration (retire-integriq-flow-schema Task 2). While both shapes are present, `steps` remains what FlowRunnerService executes and `nodes`/`edges` is what OpenRegister's engine executes; the migration refuses to overwrite an object that already carries nodes, and `steps` is kept as the rollback shape.",
+ "items": {"type": "object"}
+ },
+ "edges": {
+ "type": "array",
+ "title": "Edges",
+ "description": "OpenRegister flow-engine graph edges ({id, from, to, condition?}), written by the steps-to-graph migration alongside `nodes`. Branch steps' JsonLogic conditions ride on the edges, matching the engine's edge-condition routing.",
+ "items": {"type": "object"}
}
},
"appendOnly": false,
@@ -269,6 +281,21 @@
"type": "integer",
"description": "The `order` of the flow step to resume at once this request is approved — the step immediately after the suspending `approval` step (flow-orchestration REQ-005). Not applicable to the endpoint-rule or Synchronization batch-gate cases.",
"title": "Resume Step Order"
+ },
+ "engineRunUuid": {
+ "type": "string",
+ "description": "Opaque handle naming the suspended OpenRegister ENGINE flow run this request gates (openconnector.approval-request node case); set instead of endpointId/ruleId/synchronizationId/flowRunId. Deliberately NOT a $ref: engine runs live in OpenRegister's own oc_openregister_flow_runs table, not as objects of any schema this register could reference, so there is nothing to relate to (ADR-062 rule 7 does not apply). The decision is delivered through FlowRunSignalService::signalAs(), and the node's heartbeat re-reads this record when that delivery is lost (retire-integriq-flow-schema Task 1).",
+ "title": "Engine Run Uuid"
+ },
+ "signalNodeId": {
+ "type": "string",
+ "description": "Graph node id of the openconnector.approval-request step awaiting the decision, so the signal addresses that node's resume slot (and the engine's assignee guard checks ITS recorded approver group).",
+ "title": "Signal Node Id"
+ },
+ "question": {
+ "type": "string",
+ "description": "What is being asked, verbatim from the approval step's config. Shown to approvers; makes a pending request explain itself.",
+ "title": "Question"
}
}
}
diff --git a/openspec/changes/integriq-flow-nodes/tasks.md b/openspec/changes/integriq-flow-nodes/tasks.md
index 82306ca3b..5f530eeee 100644
--- a/openspec/changes/integriq-flow-nodes/tasks.md
+++ b/openspec/changes/integriq-flow-nodes/tasks.md
@@ -7,7 +7,7 @@ deployed tree before wiring anything. See `design.md`.
## Pre-implementation Gate
-- [ ] `contract.md` reviewed and accepted by hermiq (change `hydra-console-agent-leaves`) before Task 1 starts — it is the named first consumer, and the contract is the node interface its triage agentflow's terminal label-write step depends on
+- [x] `contract.md` reviewed and accepted by hermiq (change `hydra-console-agent-leaves`) before Task 1 starts — it is the named first consumer, and the contract is the node interface its triage agentflow's terminal label-write step depends on
## Implementation Tasks
@@ -27,8 +27,8 @@ for registration shape only.
- GIVEN a colliding node id WHEN registration runs THEN it fails loudly rather than displacing a node
- GIVEN a run context with no `triggeredBy` WHEN `FlowOwner` resolves THEN it raises; no admin, creator or anonymous fallback exists
- GIVEN an item `{"issue":{"number":42}}` WHEN `FlowTemplate` renders `/issues/{{issue.number}}` THEN it yields `/issues/42`; a missing path resolves deterministically and never leaves literal `{{...}}`
-- [ ] Implement
-- [ ] Test
+- [x] Implement
+- [x] Test
### Task 2: SourceCallNode — Source targeting, per-item execution, response mapping
@@ -49,8 +49,8 @@ HTTP client.
- GIVEN a response containing `pairedItem` WHEN mapped THEN the item's provenance and other reserved `FlowItems` keys are unchanged
- GIVEN an empty input list WHEN it executes THEN no call is made and an empty list is returned
- GIVEN any flow-originated call WHEN it completes THEN a CallLog is written
-- [ ] Implement
-- [ ] Test
+- [x] Implement
+- [x] Test
### Task 3: Explicit failure, fail-closed attribution, validation and scope
@@ -75,8 +75,8 @@ do NOT reproduce `HermiqAgentNode`'s `catch (Throwable) { $answer = ''; }` or it
- GIVEN a Source with `credentialRef` WHEN it executes THEN the broker authenticates the call and no secret appears in config, logs or item; an unresolvable credential never becomes an anonymous call
- GIVEN missing `source`, missing `endpoint`, unsupported method or malformed `acceptStatuses` WHEN the flow is saved THEN `UnexpectedValueException` names the field and the flow is not persisted
- GIVEN `isAvailableForScope()` WHEN asked THEN it answers with `IManager::SCOPE_ADMIN`/`SCOPE_USER` and returns false for other values
-- [ ] Implement
-- [ ] Test
+- [x] Implement
+- [x] Test
### Task 4: SynchronizationRunNode with bounded fan-out, seed data, and a live end-to-end run
@@ -104,6 +104,18 @@ confirm a real response lands on the item.
- [ ] Implement
- [ ] Test
+> Status 2026-09-02: the node itself and its unit tests are merged
+> (`lib/Flow/SynchronizationRunNode.php`, `tests/Unit/Flow/SynchronizationRunNodeTest.php`,
+> incl. the rate-limit suspension), so the node half of this task is done. What
+> keeps the boxes open: the demo seed was never wired — `lib/sources.seed.json`
+> exists but holds the PDOK sources and has zero PHP references (no importer
+> reads it; see the environments-and-promotion fragment's `$comment` for the
+> same finding), so the three demo Sources and the demo flow from `design.md`
+> do not land on install — and the live end-to-end run against the seeded demo
+> Source has therefore never happened. Wiring the seed through the consumed
+> register.d `components.objects` mechanism (or the setup wizard's demo-data
+> path) plus the live run is what closes this task.
+
## Verification
- [ ] All tasks checked off
- [ ] `openspec validate` passes
@@ -111,14 +123,14 @@ confirm a real response lands on the item.
- [ ] Code review against spec requirements
## Tests (company-wide ADR-009)
-- [ ] PHPUnit unit tests for new/changed business logic (`tests/Unit/Flow/`)
-- [ ] Newman/Postman tests — N/A: this change adds no HTTP API endpoint, only flow node types
+- [x] PHPUnit unit tests for new/changed business logic (`tests/Unit/Flow/`)
+- [x] Newman/Postman tests — N/A: this change adds no HTTP API endpoint, only flow node types
- [ ] Browser tests (Playwright MCP) — N/A for Integriq: no UI added; palette rendering belongs to OpenRegister's flow editor. Replaced by a live flow run against the seeded demo Source (Task 4)
-- [ ] All tests pass (`composer test`, `composer check:strict`)
+- [x] All tests pass (`composer test`, `composer check:strict`)
## Documentation (company-wide ADR-010)
-- [ ] Feature documentation added in `docs/` — how to call an API from a flow, why there is no raw-URL node, and why an unattributed run fails closed
+- [x] Feature documentation added in `docs/` — how to call an API from a flow, why there is no raw-URL node, and why an unattributed run fails closed
- [ ] Screenshot of both nodes in OpenRegister's flow palette captured and committed to `docs/images/`
## i18n (company-wide hydra ADR-007)
-- [ ] Dutch (`nl_NL`) and English (`en_US`) strings added for both nodes' display names, descriptions and all validation messages
+- [x] Dutch (`nl_NL`) and English (`en_US`) strings added for both nodes' display names, descriptions and all validation messages
diff --git a/openspec/changes/retire-integriq-flow-schema/tasks.md b/openspec/changes/retire-integriq-flow-schema/tasks.md
index 6a445251b..de8b296aa 100644
--- a/openspec/changes/retire-integriq-flow-schema/tasks.md
+++ b/openspec/changes/retire-integriq-flow-schema/tasks.md
@@ -9,23 +9,26 @@ arrival.
## Pre-implementation Gate
- [ ] PO sign-off on the editor change: users lose Integriq's step-list editor and gain OpenRegister's visual flow builder. This is a UX decision, and taking it after the migration is taking it too late.
-- [ ] Confirm whether OpenRegister has an event-EMIT node. The `event` step type has no confirmed counterpart; if none exists this change grows by one contributed node.
-- [ ] Count the live `flow` objects per instance. A migration whose blast radius is unmeasured is a migration whose rollback is unplanned.
+- [x] Confirm whether OpenRegister has an event-EMIT node. The `event` step type has no confirmed counterpart; if none exists this change grows by one contributed node.
+ > Confirmed 2026-09-02 against openregister origin/development `lib/Service/Flow/Nodes/`: no CloudEvent emitter exists (the send-* nodes address people — email, notification, Talk — not systems). The change therefore grew by `openconnector.event-emit`, contributed below.
+- [x] Count the live `flow` objects per instance. A migration whose blast radius is unmeasured is a migration whose rollback is unplanned.
+ > Measured 2026-09-02 on the dev instance (postgres, `oc_openregister_objects` + `oc_openregister_table_1_1`): ONE schema row carries slug `flow` (id 1) and it holds ZERO live objects. The migration is a no-op there; other instances measure themselves with the dry run `occ integriq:flow:steps-to-graph`, which reports one row per flow before anything is written.
## Implementation Tasks
### 1. The missing node
-- [ ] Contribute `openconnector.approval-request` via `RegisterFlowNodesEvent`, implementing `IFlowNode`
-- [ ] Pause/resume rides on `AwaitSignalNode`; the node emits the signal name the approval resolves to
-- [ ] Unit tests: an approved request resumes the flow, a rejected one takes the reject edge, an expired one fails closed
-- [ ] Contribute the event-emit node if the gate above found none
+- [x] Contribute `openconnector.approval-request` via `RegisterFlowNodesEvent`, implementing `IFlowNode`
+- [x] Pause/resume rides on `AwaitSignalNode`; the node emits the signal name the approval resolves to
+- [x] Unit tests: an approved request resumes the flow, a rejected one takes the reject edge, an expired one fails closed
+- [x] Contribute the event-emit node if the gate above found none
### 2. The step-to-graph migration
-- [ ] A pure, tested translator: `steps[]` to `nodes`/`edges`. Step `order` becomes the node id, so `branch` targets stay valid without renumbering
-- [ ] Property tests: a flow with duplicate `order` values is rejected exactly as `FlowRunnerService::run()` rejects it today, rather than silently producing a graph with a lost node
-- [ ] A repair step that rewrites live `flow` objects in place, idempotent, refusing rather than overwriting when an object already carries `nodes`
+- [x] A pure, tested translator: `steps[]` to `nodes`/`edges`. Step `order` becomes the node id, so `branch` targets stay valid without renumbering
+- [x] Property tests: a flow with duplicate `order` values is rejected exactly as `FlowRunnerService::run()` rejects it today, rather than silently producing a graph with a lost node
+- [x] A repair step that rewrites live `flow` objects in place, idempotent, refusing rather than overwriting when an object already carries `nodes`
+ > Delivered 2026-09-02 as `FlowStepsToGraphTranslator` (pure) + `FlowGraphMigrationService` (live objects) + repair step `MigrateFlowStepsToGraph` + occ command `integriq:flow:steps-to-graph` (dry run by default, `--apply` writes, `--rollback --apply` removes the graph again; `steps` is kept through both directions as the rollback shape). The engine-side wiring also landed: `ApprovalService::suspendForEngineRun()`, `EngineSignalService` (guarded `FlowRunSignalService::signalAs` delivery) and the `engineRunUuid`/`signalNodeId`/`question` approval_request schema extension. Tasks 3-5 below stay open on purpose: retiring the runner and the schema only starts once migrated flows have run in anger on the engine.
### 3. Retire the runner
diff --git a/phpstan.neon b/phpstan.neon
index d4791aad3..aa8127c94 100644
--- a/phpstan.neon
+++ b/phpstan.neon
@@ -61,6 +61,12 @@ parameters:
# runtime-only interfaces plus IFlowNodeLogActions, and builds its
# output through FlowItems — same limitation, same remedy.
- lib/Flow/FetchFileNode.php
+ # retire-integriq-flow-schema task 1: the approval-request and
+ # event-emit nodes implement the same runtime-only interfaces and
+ # throw the engine's FlowSuspension/FlowStop — same limitation, same
+ # remedy.
+ - lib/Flow/ApprovalRequestNode.php
+ - lib/Flow/EventEmitNode.php
# SynchronizationLogActions is the run-log helper the flow nodes above
# share. Every one of its users is excluded, so PHPStan sees a trait
# that is "used zero times and is not analysed" — which it refuses to
diff --git a/tests/Unit/Command/FlowStepsToGraphTest.php b/tests/Unit/Command/FlowStepsToGraphTest.php
new file mode 100644
index 000000000..c59f33d8f
--- /dev/null
+++ b/tests/Unit/Command/FlowStepsToGraphTest.php
@@ -0,0 +1,154 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\Integriq\Tests\Unit\Command;
+
+use OCA\Integriq\Command\FlowStepsToGraph;
+use OCA\Integriq\Service\FlowGraphMigrationService;
+use PHPUnit\Framework\MockObject\MockObject;
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Tester\CommandTester;
+
+/**
+ * @covers \OCA\Integriq\Command\FlowStepsToGraph
+ */
+class FlowStepsToGraphTest extends TestCase {
+
+ /**
+ * The migration double.
+ *
+ * @var FlowGraphMigrationService&MockObject
+ */
+ private $migration;
+
+ /**
+ * The command tester.
+ *
+ * @var CommandTester
+ */
+ private CommandTester $tester;
+
+ /**
+ * Wire the command over a migration double.
+ *
+ * @return void
+ */
+ protected function setUp(): void {
+ parent::setUp();
+
+ $this->migration = $this->createMock(FlowGraphMigrationService::class);
+ $this->tester = new CommandTester(new FlowStepsToGraph(migration: $this->migration));
+
+ }//end setUp()
+
+ /**
+ * The default invocation is a dry run of the forward migration.
+ *
+ * @return void
+ */
+ public function testDefaultIsForwardDryRun(): void {
+ $this->migration->expects($this->once())->method('migrate')
+ ->with(false)
+ ->willReturn(
+ [
+ ['id' => 'f-1', 'name' => 'one', 'action' => FlowGraphMigrationService::MIGRATED, 'reasons' => []],
+ ]
+ );
+ $this->migration->expects($this->never())->method('rollback');
+
+ $exit = $this->tester->execute([]);
+
+ $this->assertSame(Command::SUCCESS, $exit);
+ $this->assertStringContainsString('Dry run', $this->tester->getDisplay());
+ $this->assertStringContainsString('[migrated] one (f-1)', $this->tester->getDisplay());
+
+ }//end testDefaultIsForwardDryRun()
+
+ /**
+ * `--apply` writes, and a clean report succeeds.
+ *
+ * @return void
+ */
+ public function testApplyWritesForward(): void {
+ $this->migration->expects($this->once())->method('migrate')
+ ->with(true)
+ ->willReturn(
+ [
+ ['id' => 'f-1', 'name' => 'one', 'action' => FlowGraphMigrationService::MIGRATED, 'reasons' => []],
+ ['id' => 'f-2', 'name' => 'two', 'action' => FlowGraphMigrationService::SKIPPED, 'reasons' => []],
+ ]
+ );
+
+ $exit = $this->tester->execute(['--apply' => true]);
+
+ $this->assertSame(Command::SUCCESS, $exit);
+ $this->assertStringNotContainsString('Dry run', $this->tester->getDisplay());
+ $this->assertStringContainsString('2 flow(s) inspected, 0 refused.', $this->tester->getDisplay());
+
+ }//end testApplyWritesForward()
+
+ /**
+ * `--rollback --apply` drives the other direction.
+ *
+ * @return void
+ */
+ public function testRollbackDrivesTheOtherDirection(): void {
+ $this->migration->expects($this->once())->method('rollback')
+ ->with(true)
+ ->willReturn(
+ [
+ ['id' => 'f-1', 'name' => 'one', 'action' => FlowGraphMigrationService::ROLLED_BACK, 'reasons' => []],
+ ]
+ );
+ $this->migration->expects($this->never())->method('migrate');
+
+ $exit = $this->tester->execute(['--rollback' => true, '--apply' => true]);
+
+ $this->assertSame(Command::SUCCESS, $exit);
+ $this->assertStringContainsString('[rolled_back] one (f-1)', $this->tester->getDisplay());
+
+ }//end testRollbackDrivesTheOtherDirection()
+
+ /**
+ * A refused flow prints its reasons and fails the command.
+ *
+ * @return void
+ */
+ public function testRefusalPrintsReasonsAndFails(): void {
+ $this->migration->method('migrate')->willReturn(
+ [
+ [
+ 'id' => 'f-1',
+ 'name' => 'dupes',
+ 'action' => FlowGraphMigrationService::REFUSED,
+ 'reasons' => ['Duplicate step order 20.'],
+ ],
+ ]
+ );
+
+ $exit = $this->tester->execute([]);
+
+ $this->assertSame(Command::FAILURE, $exit);
+ $this->assertStringContainsString('Duplicate step order 20.', $this->tester->getDisplay());
+ $this->assertStringContainsString('1 flow(s) inspected, 1 refused.', $this->tester->getDisplay());
+
+ }//end testRefusalPrintsReasonsAndFails()
+}//end class
diff --git a/tests/Unit/Controller/ApprovalsControllerTest.php b/tests/Unit/Controller/ApprovalsControllerTest.php
index b0df3abc9..7d76e78bc 100644
--- a/tests/Unit/Controller/ApprovalsControllerTest.php
+++ b/tests/Unit/Controller/ApprovalsControllerTest.php
@@ -26,6 +26,7 @@
use OCA\Integriq\Service\ActionAuthService;
use OCA\Integriq\Service\ApprovalService;
use OCA\Integriq\Service\EndpointService;
+use OCA\Integriq\Service\EngineSignalService;
use OCA\Integriq\Service\FlowRunnerService;
use OCA\Integriq\Service\SynchronizationService;
use OCA\OpenRegister\Db\ObjectEntity;
@@ -71,6 +72,11 @@ class ApprovalsControllerTest extends TestCase {
*/
private $flowRunnerService;
+ /**
+ * @var EngineSignalService|\PHPUnit\Framework\MockObject\MockObject
+ */
+ private $engineSignal;
+
/**
* @var OrObjectService|\PHPUnit\Framework\MockObject\MockObject
*/
@@ -114,6 +120,8 @@ protected function setUp(): void {
$user->method('getUID')->willReturn('alice');
$this->userSession->method('getUser')->willReturn($user);
+ $this->engineSignal = $this->createMock(EngineSignalService::class);
+
$this->controller = new ApprovalsController(
'integriq',
$this->request,
@@ -126,6 +134,7 @@ protected function setUp(): void {
$this->userSession,
$l,
$this->createMock(LoggerInterface::class),
+ $this->engineSignal,
);
}//end setUp()
@@ -275,6 +284,80 @@ public function testApproveFlowHappyPathResumesAndCompletes(): void {
}//end testApproveFlowHappyPathResumesAndCompletes()
+ /**
+ * Approving an ENGINE-run approval_request (engineRunUuid set) finalizes
+ * the record and delivers the approved signal to the suspended
+ * OpenRegister run — retire-integriq-flow-schema Task 1.
+ *
+ * @return void
+ */
+ public function testApproveEngineRunSignalsAndCompletes(): void {
+ $request = $this->entity(
+ ['status' => 'pending', 'approverGroup' => 'woo-approvers', 'engineRunUuid' => 'run-1', 'signalNodeId' => 'approve-1']
+ );
+ $this->approvalService->method('find')->willReturn($request);
+ $this->approvalService->method('isAuthorizedApprover')->willReturn(true);
+
+ $this->engineSignal->expects($this->once())->method('deliver')
+ ->willReturnCallback(
+ function (array $data, string $decision): bool {
+ $this->assertSame('run-1', $data['engineRunUuid']);
+ $this->assertSame('approved', $decision);
+
+ return true;
+ }
+ );
+
+ $this->approvalService->expects($this->once())->method('completeApproval')
+ ->willReturnCallback(
+ function ($approvalRequest, $approver, string $resumeResult): ObjectEntity {
+ $this->assertSame('success', $resumeResult, 'A delivered signal reports success');
+
+ return $this->entity(['status' => 'approved', 'approvedAt' => 'now'], 'approval-1');
+ }
+ );
+
+ $response = $this->controller->approve('approval-1');
+
+ $this->assertSame(200, $response->getStatus());
+ $data = $response->getData();
+ $this->assertTrue($data['signalled']);
+ $this->assertSame('approved', $data['_approval']['status']);
+
+ }//end testApproveEngineRunSignalsAndCompletes()
+
+ /**
+ * A failed delivery still resolves the record — the record is the system
+ * of record and the node's heartbeat re-reads it — but reports the
+ * delivery as an error.
+ *
+ * @return void
+ */
+ public function testApproveEngineRunUndeliveredStillCompletes(): void {
+ $request = $this->entity(
+ ['status' => 'pending', 'approverGroup' => 'woo-approvers', 'engineRunUuid' => 'run-1', 'signalNodeId' => 'approve-1']
+ );
+ $this->approvalService->method('find')->willReturn($request);
+ $this->approvalService->method('isAuthorizedApprover')->willReturn(true);
+
+ $this->engineSignal->method('deliver')->willReturn(false);
+
+ $this->approvalService->expects($this->once())->method('completeApproval')
+ ->willReturnCallback(
+ function ($approvalRequest, $approver, string $resumeResult): ObjectEntity {
+ $this->assertSame('error', $resumeResult, 'A lost delivery is visible on the record');
+
+ return $this->entity(['status' => 'approved', 'approvedAt' => 'now'], 'approval-1');
+ }
+ );
+
+ $response = $this->controller->approve('approval-1');
+
+ $this->assertSame(200, $response->getStatus());
+ $this->assertFalse($response->getData()['signalled']);
+
+ }//end testApproveEngineRunUndeliveredStillCompletes()
+
/**
* TC-11: rejecting a flow-sourced approval_request stops the flow_run
* via FlowRunnerService::stopFromApprovalOutcome() — flow-orchestration
@@ -298,6 +381,37 @@ public function testRejectFlowStopsFlowRun(): void {
}//end testRejectFlowStopsFlowRun()
+ /**
+ * Rejecting an ENGINE-run approval_request delivers the rejected signal
+ * so the approval node routes or fails the suspended run now —
+ * retire-integriq-flow-schema Task 1.
+ *
+ * @return void
+ */
+ public function testRejectEngineRunDeliversRejectedSignal(): void {
+ $request = $this->entity(['status' => 'pending', 'approverGroup' => 'woo-approvers', 'engineRunUuid' => 'run-1']);
+ $this->approvalService->method('find')->willReturn($request);
+ $this->approvalService->method('isAuthorizedApprover')->willReturn(true);
+ $this->request->method('getParam')->willReturn('not like this');
+ $this->approvalService->method('reject')
+ ->willReturn($this->entity(['status' => 'rejected', 'engineRunUuid' => 'run-1', 'rejectedAt' => 'now'], 'approval-1'));
+
+ $this->engineSignal->expects($this->once())->method('deliver')
+ ->willReturnCallback(
+ function (array $data, string $decision): bool {
+ $this->assertSame('run-1', $data['engineRunUuid']);
+ $this->assertSame('rejected', $decision);
+
+ return true;
+ }
+ );
+
+ $response = $this->controller->reject('approval-1');
+
+ $this->assertSame(200, $response->getStatus());
+
+ }//end testRejectEngineRunDeliversRejectedSignal()
+
/**
* TC-9: reject with an empty comment returns 400 and leaves the request
* pending — REQ-004.
diff --git a/tests/Unit/Flow/ApprovalRequestNodeTest.php b/tests/Unit/Flow/ApprovalRequestNodeTest.php
new file mode 100644
index 000000000..ac332efb8
--- /dev/null
+++ b/tests/Unit/Flow/ApprovalRequestNodeTest.php
@@ -0,0 +1,458 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\Integriq\Tests\Unit\Flow;
+
+use OCA\Integriq\Exception\FlowNodeException;
+use OCA\Integriq\Flow\ApprovalRequestNode;
+use OCA\Integriq\Service\ApprovalService;
+use OCA\OpenRegister\Db\ObjectEntity;
+use OCA\OpenRegister\Service\Flow\FlowNodeResumeState;
+use OCA\OpenRegister\Service\Flow\FlowResumeState;
+use OCA\OpenRegister\Service\Flow\FlowStop;
+use OCA\OpenRegister\Service\Flow\FlowSuspension;
+use OCP\IL10N;
+use OCP\IURLGenerator;
+use OCP\WorkflowEngine\IManager;
+use PHPUnit\Framework\MockObject\MockObject;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\LoggerInterface;
+use UnexpectedValueException;
+
+/**
+ * Tests for the approval-request flow node.
+ */
+class ApprovalRequestNodeTest extends TestCase {
+
+ /**
+ * The HITL state machine double.
+ *
+ * @var ApprovalService&MockObject
+ */
+ private $approvalService;
+
+ /**
+ * The node under test.
+ *
+ * @var ApprovalRequestNode
+ */
+ private ApprovalRequestNode $node;
+
+ /**
+ * Build the node with doubles for everything it delegates to.
+ *
+ * @return void
+ */
+ protected function setUp(): void {
+ parent::setUp();
+
+ $this->approvalService = $this->createMock(ApprovalService::class);
+
+ $l10n = $this->createMock(IL10N::class);
+ $l10n->method('t')->willReturnCallback(
+ static function (string $text, $parameters = []): string {
+ if (is_array($parameters) === false || $parameters === []) {
+ return $text;
+ }
+
+ return vsprintf($text, $parameters);
+ }
+ );
+
+ $urlGenerator = $this->createMock(IURLGenerator::class);
+ $urlGenerator->method('imagePath')->willReturn('/core/img/actions/confirm.svg');
+
+ $this->node = new ApprovalRequestNode(
+ approvalService: $this->approvalService,
+ l10n: $l10n,
+ urlGenerator: $urlGenerator,
+ logger: $this->createMock(LoggerInterface::class)
+ );
+
+ }//end setUp()
+
+ /**
+ * A minimal valid step config.
+ *
+ * @param array $overrides Keys to override.
+ *
+ * @return array The config.
+ */
+ private function config(array $overrides = []): array {
+ return array_merge(
+ [
+ 'question' => 'Publish this dataset?',
+ 'approverGroup' => 'data-stewards',
+ ],
+ $overrides
+ );
+
+ }//end config()
+
+ /**
+ * A run context carrying a resume slot, run uuid and owner.
+ *
+ * @param FlowResumeState $state The run-wide resume state.
+ * @param array $overrides Context keys to override or add.
+ *
+ * @return array The context.
+ */
+ private function context(FlowResumeState $state, array $overrides = []): array {
+ return array_merge(
+ [
+ 'x-openregister-attribution-run' => 'run-uuid-1',
+ 'triggeredBy' => 'alice',
+ FlowNodeResumeState::CONTEXT_KEY => $state->forNode(nodeId: 'approve-1'),
+ ],
+ $overrides
+ );
+
+ }//end context()
+
+ /**
+ * The palette metadata is present and app-namespaced.
+ *
+ * @return void
+ */
+ public function testPaletteMetadata(): void {
+ $this->assertSame('openconnector.approval-request', $this->node->getId());
+ $this->assertNotSame('', $this->node->getDisplayName());
+ $this->assertNotSame('', $this->node->getDescription());
+ $this->assertNotSame('', $this->node->getIcon());
+ $this->assertTrue($this->node->isAvailableForScope(IManager::SCOPE_ADMIN));
+ $this->assertTrue($this->node->isAvailableForScope(IManager::SCOPE_USER));
+ $this->assertFalse($this->node->isAvailableForScope(-1));
+
+ }//end testPaletteMetadata()
+
+ /**
+ * The config vocabulary and its edit form agree with each other.
+ *
+ * @return void
+ */
+ public function testConfigKeysAndFormAgree(): void {
+ $keys = $this->node->configKeys();
+ $this->assertContains('question', $keys);
+ $this->assertContains('approverGroup', $keys);
+ $this->assertContains('failOnReject', $keys);
+
+ $form = $this->node->configForm();
+ $this->assertNotSame([], $form);
+ foreach ($form as $field) {
+ $this->assertContains($field['key'], $keys, 'Every form field must be a declared config key');
+ $this->assertNotSame('', (string)$field['label']);
+ }
+
+ }//end testConfigKeysAndFormAgree()
+
+ /**
+ * A step without a question is rejected at save.
+ *
+ * @return void
+ */
+ public function testValidateConfigRequiresQuestion(): void {
+ $this->expectException(UnexpectedValueException::class);
+ $this->expectExceptionMessageMatches('/question/');
+
+ $this->node->validateConfig(config: $this->config(overrides: ['question' => ' ']));
+
+ }//end testValidateConfigRequiresQuestion()
+
+ /**
+ * A step without an approver group is rejected at save.
+ *
+ * @return void
+ */
+ public function testValidateConfigRequiresApproverGroup(): void {
+ $this->expectException(UnexpectedValueException::class);
+ $this->expectExceptionMessageMatches('/approverGroup/');
+
+ $this->node->validateConfig(config: $this->config(overrides: ['approverGroup' => '']));
+
+ }//end testValidateConfigRequiresApproverGroup()
+
+ /**
+ * A credential-bearing field is rejected at save.
+ *
+ * @return void
+ */
+ public function testValidateConfigRejectsCredentialFields(): void {
+ $this->expectException(UnexpectedValueException::class);
+
+ $this->node->validateConfig(config: $this->config(overrides: ['token' => 'secret']));
+
+ }//end testValidateConfigRejectsCredentialFields()
+
+ /**
+ * The first pass persists a pending approval_request and suspends.
+ *
+ * @return void
+ */
+ public function testFirstPassOpensRequestAndSuspends(): void {
+ $record = new ObjectEntity();
+ $record->setUuid('req-1');
+ $record->setObject(['status' => 'pending', 'expiresAt' => '2999-01-01T00:00:00+00:00']);
+
+ $this->approvalService->expects($this->once())
+ ->method('suspendForEngineRun')
+ ->with('run-uuid-1', 'approve-1', $this->config(), 'alice')
+ ->willReturn($record);
+
+ $state = new FlowResumeState();
+ $context = $this->context(state: $state);
+
+ try {
+ $this->node->execute(items: [['json' => []]], config: $this->config(), context: $context);
+ $this->fail('Expected a FlowSuspension');
+ } catch (FlowSuspension $suspension) {
+ $this->assertNotNull($suspension->getResumeAt(), 'The suspension must carry a heartbeat, not wait forever on a signal');
+ $this->assertStringContainsString('Publish this dataset?', $suspension->getMessage());
+ }
+
+ $slot = $state->forNode(nodeId: 'approve-1');
+ $this->assertSame('req-1', $slot->get(key: 'approvalRequestId'));
+ $this->assertSame('data-stewards', $slot->get(key: 'assignee'), 'The engine-side assignee guard must name the approver group');
+ $this->assertTrue($slot->has(key: 'askedAt'));
+
+ }//end testFirstPassOpensRequestAndSuspends()
+
+ /**
+ * A run that cannot name itself opens no request and raises.
+ *
+ * @return void
+ */
+ public function testUnaddressableRunRefuses(): void {
+ $this->approvalService->expects($this->never())->method('suspendForEngineRun');
+
+ $state = new FlowResumeState();
+ $context = $this->context(state: $state, overrides: ['x-openregister-attribution-run' => '']);
+
+ $this->expectException(FlowNodeException::class);
+
+ $this->node->execute(items: [['json' => []]], config: $this->config(), context: $context);
+
+ }//end testUnaddressableRunRefuses()
+
+ /**
+ * An approved signal writes the decision onto every item and continues.
+ *
+ * @return void
+ */
+ public function testApprovedSignalResumesWithDecisionOnItems(): void {
+ $state = new FlowResumeState();
+ $context = $this->context(
+ state: $state,
+ overrides: [
+ 'signal' => [
+ 'decision' => 'approved',
+ 'decidedBy' => 'bob',
+ 'comment' => 'fine by me',
+ ],
+ ]
+ );
+
+ $out = $this->node->execute(
+ items: [['json' => ['a' => 1]], ['json' => ['a' => 2]]],
+ config: $this->config(),
+ context: $context
+ );
+
+ $this->assertCount(2, $out);
+ $this->assertSame('approved', $out[0]['json']['approval']['decision']);
+ $this->assertSame('bob', $out[1]['json']['approval']['decidedBy']);
+ $this->assertSame(1, $out[0]['json']['a'], 'The rest of the record is untouched');
+
+ }//end testApprovedSignalResumesWithDecisionOnItems()
+
+ /**
+ * A rejected signal without failOnReject routes: the decision lands on
+ * the items so the author's reject edge can read it.
+ *
+ * @return void
+ */
+ public function testRejectedSignalRoutesByDefault(): void {
+ $state = new FlowResumeState();
+ $context = $this->context(
+ state: $state,
+ overrides: ['signal' => ['decision' => 'rejected', 'comment' => 'not like this']]
+ );
+
+ $out = $this->node->execute(items: [['json' => []]], config: $this->config(), context: $context);
+
+ $this->assertSame('rejected', $out[0]['json']['approval']['decision']);
+
+ }//end testRejectedSignalRoutesByDefault()
+
+ /**
+ * A rejected signal under failOnReject fails the run.
+ *
+ * @return void
+ */
+ public function testRejectedSignalFailsUnderFailOnReject(): void {
+ $state = new FlowResumeState();
+ $context = $this->context(
+ state: $state,
+ overrides: ['signal' => ['decision' => 'rejected', 'comment' => 'nope']]
+ );
+
+ try {
+ $this->node->execute(
+ items: [['json' => []]],
+ config: $this->config(overrides: ['failOnReject' => true]),
+ context: $context
+ );
+ $this->fail('Expected a FlowStop');
+ } catch (FlowStop $stop) {
+ $this->assertTrue($stop->isError(), 'A rejection under failOnReject is a failure, not a clean stop');
+ $this->assertStringContainsString('nope', $stop->getMessage());
+ }
+
+ }//end testRejectedSignalFailsUnderFailOnReject()
+
+ /**
+ * A signal with no decision is a nudge: the pending request re-suspends.
+ *
+ * @return void
+ */
+ public function testDecisionlessSignalKeepsWaiting(): void {
+ $record = new ObjectEntity();
+ $record->setUuid('req-1');
+ $record->setObject(['status' => 'pending', 'expiresAt' => '2999-01-01T00:00:00+00:00']);
+ $this->approvalService->method('find')->willReturn($record);
+
+ $state = new FlowResumeState();
+ $slot = $state->forNode(nodeId: 'approve-1');
+ $slot->merge(values: ['approvalRequestId' => 'req-1', 'askedAt' => '2026-01-01T00:00:00+00:00', 'question' => 'Publish this dataset?']);
+
+ $context = $this->context(state: $state, overrides: ['signal' => ['ping' => true]]);
+
+ $this->expectException(FlowSuspension::class);
+
+ $this->node->execute(items: [['json' => []]], config: $this->config(), context: $context);
+
+ }//end testDecisionlessSignalKeepsWaiting()
+
+ /**
+ * The heartbeat resolves from the RECORD when the signal was lost: an
+ * approved approval_request resumes the run without any delivered signal.
+ *
+ * @return void
+ */
+ public function testHeartbeatResolvesFromApprovedRecord(): void {
+ $record = new ObjectEntity();
+ $record->setUuid('req-1');
+ $record->setObject(
+ [
+ 'status' => 'approved',
+ 'approverUserId' => 'bob',
+ 'comment' => 'looks good',
+ 'expiresAt' => '2999-01-01T00:00:00+00:00',
+ ]
+ );
+ $this->approvalService->method('find')->with('req-1')->willReturn($record);
+
+ $state = new FlowResumeState();
+ $state->forNode(nodeId: 'approve-1')->merge(
+ values: ['approvalRequestId' => 'req-1', 'askedAt' => '2026-01-01T00:00:00+00:00']
+ );
+
+ $out = $this->node->execute(items: [['json' => []]], config: $this->config(), context: $this->context(state: $state));
+
+ $this->assertSame('approved', $out[0]['json']['approval']['decision']);
+ $this->assertSame('bob', $out[0]['json']['approval']['decidedBy']);
+
+ }//end testHeartbeatResolvesFromApprovedRecord()
+
+ /**
+ * An expired request fails closed — never a silent resume, never more waiting.
+ *
+ * @return void
+ */
+ public function testExpiredRequestFailsClosed(): void {
+ $record = new ObjectEntity();
+ $record->setUuid('req-1');
+ $record->setObject(['status' => 'expired']);
+ $this->approvalService->method('find')->willReturn($record);
+
+ $state = new FlowResumeState();
+ $state->forNode(nodeId: 'approve-1')->merge(
+ values: ['approvalRequestId' => 'req-1', 'askedAt' => '2026-01-01T00:00:00+00:00']
+ );
+
+ try {
+ $this->node->execute(items: [['json' => []]], config: $this->config(), context: $this->context(state: $state));
+ $this->fail('Expected a FlowStop');
+ } catch (FlowStop $stop) {
+ $this->assertTrue($stop->isError(), 'An expired approval is a failure, not a clean stop');
+ $this->assertStringContainsString('req-1', $stop->getMessage());
+ }
+
+ }//end testExpiredRequestFailsClosed()
+
+ /**
+ * A pending request whose deadline has passed fails closed even before
+ * the expiry sweep has marked it.
+ *
+ * @return void
+ */
+ public function testPendingPastDeadlineFailsClosed(): void {
+ $record = new ObjectEntity();
+ $record->setUuid('req-1');
+ $record->setObject(['status' => 'pending', 'expiresAt' => '2020-01-01T00:00:00+00:00']);
+ $this->approvalService->method('find')->willReturn($record);
+
+ $state = new FlowResumeState();
+ $state->forNode(nodeId: 'approve-1')->merge(
+ values: ['approvalRequestId' => 'req-1', 'askedAt' => '2026-01-01T00:00:00+00:00']
+ );
+
+ $this->expectException(FlowStop::class);
+
+ $this->node->execute(items: [['json' => []]], config: $this->config(), context: $this->context(state: $state));
+
+ }//end testPendingPastDeadlineFailsClosed()
+
+ /**
+ * A rejected record found on heartbeat routes exactly like a rejected
+ * signal would — the reject edge reads the decision from the items.
+ *
+ * @return void
+ */
+ public function testHeartbeatResolvesFromRejectedRecord(): void {
+ $record = new ObjectEntity();
+ $record->setUuid('req-1');
+ $record->setObject(
+ ['status' => 'rejected', 'approverUserId' => 'bob', 'comment' => 'no', 'expiresAt' => '2999-01-01T00:00:00+00:00']
+ );
+ $this->approvalService->method('find')->willReturn($record);
+
+ $state = new FlowResumeState();
+ $state->forNode(nodeId: 'approve-1')->merge(
+ values: ['approvalRequestId' => 'req-1', 'askedAt' => '2026-01-01T00:00:00+00:00']
+ );
+
+ $out = $this->node->execute(items: [['json' => []]], config: $this->config(), context: $this->context(state: $state));
+
+ $this->assertSame('rejected', $out[0]['json']['approval']['decision']);
+
+ }//end testHeartbeatResolvesFromRejectedRecord()
+}//end class
diff --git a/tests/Unit/Flow/EventEmitNodeTest.php b/tests/Unit/Flow/EventEmitNodeTest.php
new file mode 100644
index 000000000..b97a58390
--- /dev/null
+++ b/tests/Unit/Flow/EventEmitNodeTest.php
@@ -0,0 +1,264 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\Integriq\Tests\Unit\Flow;
+
+use OCA\Integriq\Exception\FlowNodeException;
+use OCA\Integriq\Flow\EventEmitNode;
+use OCA\Integriq\Flow\FlowNodeSupport;
+use OCA\Integriq\Service\EventService;
+use OCP\IL10N;
+use OCP\IURLGenerator;
+use OCP\WorkflowEngine\IManager;
+use PHPUnit\Framework\MockObject\MockObject;
+use PHPUnit\Framework\TestCase;
+use Psr\Container\ContainerInterface;
+use Psr\Log\LoggerInterface;
+use RuntimeException;
+use UnexpectedValueException;
+
+/**
+ * Tests for the event-emit flow node.
+ */
+class EventEmitNodeTest extends TestCase {
+
+ /**
+ * The event pipeline double.
+ *
+ * @var EventService&MockObject
+ */
+ private $eventService;
+
+ /**
+ * The node under test.
+ *
+ * @var EventEmitNode
+ */
+ private EventEmitNode $node;
+
+ /**
+ * Build the node with doubles for everything it delegates to.
+ *
+ * @return void
+ */
+ protected function setUp(): void {
+ parent::setUp();
+
+ $this->eventService = $this->createMock(EventService::class);
+
+ $container = $this->createMock(ContainerInterface::class);
+ $container->method('get')->with(EventService::class)->willReturn($this->eventService);
+
+ $l10n = $this->createMock(IL10N::class);
+ $l10n->method('t')->willReturnCallback(
+ static function (string $text, $parameters = []): string {
+ if (is_array($parameters) === false || $parameters === []) {
+ return $text;
+ }
+
+ return vsprintf($text, $parameters);
+ }
+ );
+
+ $urlGenerator = $this->createMock(IURLGenerator::class);
+ $urlGenerator->method('imagePath')->willReturn('/core/img/actions/share.svg');
+
+ $this->node = new EventEmitNode(
+ container: $container,
+ l10n: $l10n,
+ urlGenerator: $urlGenerator,
+ logger: $this->createMock(LoggerInterface::class)
+ );
+
+ }//end setUp()
+
+ /**
+ * A minimal valid step config.
+ *
+ * @param array $overrides Keys to override.
+ *
+ * @return array The config.
+ */
+ private function config(array $overrides = []): array {
+ return array_merge(
+ [
+ 'type' => 'nl.example.object.updated',
+ 'source' => 'https://example.org/integriq',
+ ],
+ $overrides
+ );
+
+ }//end config()
+
+ /**
+ * The palette metadata is present and app-namespaced.
+ *
+ * @return void
+ */
+ public function testPaletteMetadata(): void {
+ $this->assertSame('openconnector.event-emit', $this->node->getId());
+ $this->assertNotSame('', $this->node->getDisplayName());
+ $this->assertNotSame('', $this->node->getDescription());
+ $this->assertNotSame('', $this->node->getIcon());
+ $this->assertTrue($this->node->isAvailableForScope(IManager::SCOPE_ADMIN));
+ $this->assertTrue($this->node->isAvailableForScope(IManager::SCOPE_USER));
+ $this->assertFalse($this->node->isAvailableForScope(-1));
+
+ }//end testPaletteMetadata()
+
+ /**
+ * The config vocabulary and its edit form agree with each other.
+ *
+ * @return void
+ */
+ public function testConfigKeysAndFormAgree(): void {
+ $keys = $this->node->configKeys();
+ $this->assertContains('type', $keys);
+ $this->assertContains('source', $keys);
+
+ $form = $this->node->configForm();
+ $this->assertNotSame([], $form);
+ foreach ($form as $field) {
+ $this->assertContains($field['key'], $keys, 'Every form field must be a declared config key');
+ $this->assertNotSame('', (string)$field['label']);
+ }
+
+ }//end testConfigKeysAndFormAgree()
+
+ /**
+ * A step naming no event type is rejected at save.
+ *
+ * @return void
+ */
+ public function testValidateConfigRequiresType(): void {
+ $this->expectException(UnexpectedValueException::class);
+ $this->expectExceptionMessageMatches('/type/');
+
+ $this->node->validateConfig(config: $this->config(overrides: ['type' => '']));
+
+ }//end testValidateConfigRequiresType()
+
+ /**
+ * A step naming no source is rejected at save.
+ *
+ * @return void
+ */
+ public function testValidateConfigRequiresSource(): void {
+ $this->expectException(UnexpectedValueException::class);
+ $this->expectExceptionMessageMatches('/source/');
+
+ $this->node->validateConfig(config: $this->config(overrides: ['source' => ' ']));
+
+ }//end testValidateConfigRequiresSource()
+
+ /**
+ * One event is emitted per item, with the item's record as data and a
+ * per-item templated subject.
+ *
+ * @return void
+ */
+ public function testEmitsOneEventPerItem(): void {
+ $subjects = [];
+ $this->eventService->expects($this->exactly(2))
+ ->method('emitCloudEvent')
+ ->willReturnCallback(
+ function (string $type, string $source, ?string $subject, array $data) use (&$subjects): array {
+ $this->assertSame('nl.example.object.updated', $type);
+ $subjects[] = $subject;
+
+ return [['message' => 'm1']];
+ }
+ );
+
+ $out = $this->node->execute(
+ items: [
+ ['json' => ['issue' => ['number' => 41]]],
+ ['json' => ['issue' => ['number' => 42]]],
+ ],
+ config: $this->config(overrides: ['subject' => 'issue/{{issue.number}}']),
+ context: []
+ );
+
+ $this->assertSame(['issue/41', 'issue/42'], $subjects);
+ $this->assertCount(2, $out);
+ $this->assertTrue($out[0]['json']['eventResult']['emitted']);
+ $this->assertSame(1, $out[1]['json']['eventResult']['messageCount']);
+
+ }//end testEmitsOneEventPerItem()
+
+ /**
+ * An empty input list emits nothing and returns an empty list.
+ *
+ * @return void
+ */
+ public function testEmptyInputEmitsNothing(): void {
+ $this->eventService->expects($this->never())->method('emitCloudEvent');
+
+ $this->assertSame([], $this->node->execute(items: [], config: $this->config(), context: []));
+
+ }//end testEmptyInputEmitsNothing()
+
+ /**
+ * A failed emit raises under the default policy so `onError` decides.
+ *
+ * @return void
+ */
+ public function testFailedEmitRaisesByDefault(): void {
+ $this->eventService->method('emitCloudEvent')->willThrowException(new RuntimeException('broker down'));
+
+ $this->expectException(FlowNodeException::class);
+ $this->expectExceptionMessageMatches('/broker down/');
+
+ $this->node->execute(items: [['json' => []]], config: $this->config(), context: []);
+
+ }//end testFailedEmitRaisesByDefault()
+
+ /**
+ * Under `onError: continue` a failed item carries explicit error state,
+ * structurally distinct from a success — never a success-shaped summary.
+ *
+ * @return void
+ */
+ public function testFailedEmitUnderContinueCarriesErrorState(): void {
+ $calls = 0;
+ $this->eventService->method('emitCloudEvent')->willReturnCallback(
+ static function () use (&$calls): array {
+ $calls++;
+ if ($calls === 1) {
+ throw new RuntimeException('broker down');
+ }
+
+ return [];
+ }
+ );
+
+ $out = $this->node->execute(
+ items: [['json' => ['a' => 1]], ['json' => ['a' => 2]]],
+ config: $this->config(overrides: ['onError' => 'continue']),
+ context: []
+ );
+
+ $this->assertCount(2, $out);
+ $this->assertArrayHasKey(FlowNodeSupport::ERROR_KEY, $out[0]['json']);
+ $this->assertArrayNotHasKey('eventResult', $out[0]['json'], 'A failed item must not be shaped like a success');
+ $this->assertTrue($out[1]['json']['eventResult']['emitted']);
+
+ }//end testFailedEmitUnderContinueCarriesErrorState()
+}//end class
diff --git a/tests/Unit/Flow/FlowNodeListenerTest.php b/tests/Unit/Flow/FlowNodeListenerTest.php
index fdd0961be..3eb48880e 100644
--- a/tests/Unit/Flow/FlowNodeListenerTest.php
+++ b/tests/Unit/Flow/FlowNodeListenerTest.php
@@ -20,15 +20,18 @@
namespace OCA\Integriq\Tests\Unit\Flow;
use OCA\Integriq\Flow\ApplyMappingNode;
+use OCA\Integriq\Flow\ApprovalRequestNode;
use OCA\Integriq\Flow\ContractCommitNode;
use OCA\Integriq\Flow\ContractMatchNode;
use OCA\Integriq\Flow\ContractSweepNode;
+use OCA\Integriq\Flow\EventEmitNode;
use OCA\Integriq\Flow\FetchFileNode;
use OCA\Integriq\Flow\FlowNodeListener;
use OCA\Integriq\Flow\FlowOwner;
use OCA\Integriq\Flow\SourceCallNode;
use OCA\Integriq\Flow\SourcePaginateNode;
use OCA\Integriq\Flow\SynchronizationRunNode;
+use OCA\Integriq\Service\ApprovalService;
use OCA\Integriq\Service\CallService;
use OCA\Integriq\Service\MappingService;
use OCA\Integriq\Service\SynchronizationContractService;
@@ -43,6 +46,7 @@
use OCP\IUserManager;
use OCP\IUserSession;
use PHPUnit\Framework\TestCase;
+use Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface;
/**
@@ -152,6 +156,18 @@ static function (string $text, $parameters = []): string {
l10n: $l10n,
urlGenerator: $urlGenerator,
logger: $this->createMock(LoggerInterface::class)
+ ),
+ approvalRequestNode: new ApprovalRequestNode(
+ approvalService: $this->createMock(ApprovalService::class),
+ l10n: $l10n,
+ urlGenerator: $urlGenerator,
+ logger: $this->createMock(LoggerInterface::class)
+ ),
+ eventEmitNode: new EventEmitNode(
+ container: $this->createMock(ContainerInterface::class),
+ l10n: $l10n,
+ urlGenerator: $urlGenerator,
+ logger: $this->createMock(LoggerInterface::class)
)
);
@@ -177,7 +193,9 @@ public function testEveryNodeAppearsInThePalette(): void {
$this->assertArrayHasKey('openconnector.contract-commit', $nodes);
$this->assertArrayHasKey('openconnector.contract-sweep', $nodes);
$this->assertArrayHasKey('openconnector.fetch-file', $nodes);
- $this->assertCount(8, $nodes);
+ $this->assertArrayHasKey('openconnector.approval-request', $nodes);
+ $this->assertArrayHasKey('openconnector.event-emit', $nodes);
+ $this->assertCount(10, $nodes);
foreach ($nodes as $node) {
$this->assertNotSame('', $node->getDisplayName());
diff --git a/tests/Unit/Repair/MigrateFlowStepsToGraphTest.php b/tests/Unit/Repair/MigrateFlowStepsToGraphTest.php
new file mode 100644
index 000000000..6f673c232
--- /dev/null
+++ b/tests/Unit/Repair/MigrateFlowStepsToGraphTest.php
@@ -0,0 +1,142 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\Integriq\Tests\Unit\Repair;
+
+use OCA\Integriq\Repair\MigrateFlowStepsToGraph;
+use OCA\Integriq\Service\FlowGraphMigrationService;
+use OCP\Migration\IOutput;
+use PHPUnit\Framework\TestCase;
+use Psr\Container\ContainerInterface;
+use Psr\Log\LoggerInterface;
+use RuntimeException;
+
+/**
+ * @covers \OCA\Integriq\Repair\MigrateFlowStepsToGraph
+ */
+class MigrateFlowStepsToGraphTest extends TestCase {
+
+ /**
+ * The step names itself for occ output.
+ *
+ * @return void
+ */
+ public function testGetNameIsNonEmpty(): void {
+ $step = new MigrateFlowStepsToGraph(
+ container: $this->createMock(ContainerInterface::class),
+ logger: $this->createMock(LoggerInterface::class)
+ );
+
+ $this->assertNotSame('', $step->getName());
+
+ }//end testGetNameIsNonEmpty()
+
+ /**
+ * A pass with work reports migrated and refused counts, and logs each
+ * refusal with its reasons.
+ *
+ * @return void
+ */
+ public function testRunAppliesAndReportsCounts(): void {
+ $migration = $this->createMock(FlowGraphMigrationService::class);
+ $migration->expects($this->once())->method('migrate')
+ ->with(true)
+ ->willReturn(
+ [
+ ['id' => 'f-1', 'name' => 'one', 'action' => FlowGraphMigrationService::MIGRATED, 'reasons' => []],
+ ['id' => 'f-2', 'name' => 'two', 'action' => FlowGraphMigrationService::SKIPPED, 'reasons' => []],
+ ['id' => 'f-3', 'name' => 'three', 'action' => FlowGraphMigrationService::REFUSED, 'reasons' => ['Duplicate step order 20.']],
+ ]
+ );
+
+ $container = $this->createMock(ContainerInterface::class);
+ $container->method('get')->with(FlowGraphMigrationService::class)->willReturn($migration);
+
+ $logger = $this->createMock(LoggerInterface::class);
+ $logger->expects($this->once())->method('warning')
+ ->with(
+ $this->stringContains('flow refused'),
+ $this->callback(static fn (array $ctx): bool => $ctx['flowId'] === 'f-3')
+ );
+
+ $reported = null;
+ $output = $this->createMock(IOutput::class);
+ $output->expects($this->once())->method('info')
+ ->willReturnCallback(
+ static function (string $message) use (&$reported): void {
+ $reported = $message;
+ }
+ );
+
+ $step = new MigrateFlowStepsToGraph(container: $container, logger: $logger);
+ $step->run($output);
+
+ $this->assertStringContainsString('1 migrated, 1 refused', (string)$reported);
+
+ }//end testRunAppliesAndReportsCounts()
+
+ /**
+ * A pass with nothing to do stays silent.
+ *
+ * @return void
+ */
+ public function testRunWithNothingToDoStaysSilent(): void {
+ $migration = $this->createMock(FlowGraphMigrationService::class);
+ $migration->method('migrate')->willReturn(
+ [
+ ['id' => 'f-1', 'name' => 'one', 'action' => FlowGraphMigrationService::SKIPPED, 'reasons' => []],
+ ]
+ );
+
+ $container = $this->createMock(ContainerInterface::class);
+ $container->method('get')->willReturn($migration);
+
+ $output = $this->createMock(IOutput::class);
+ $output->expects($this->never())->method('info');
+
+ $step = new MigrateFlowStepsToGraph(
+ container: $container,
+ logger: $this->createMock(LoggerInterface::class)
+ );
+ $step->run($output);
+
+ }//end testRunWithNothingToDoStaysSilent()
+
+ /**
+ * A failure inside the migration is logged, never thrown — an escaping
+ * exception here would abort the upgrade.
+ *
+ * @return void
+ */
+ public function testRunNeverThrows(): void {
+ $container = $this->createMock(ContainerInterface::class);
+ $container->method('get')->willThrowException(new RuntimeException('container broken'));
+
+ $logger = $this->createMock(LoggerInterface::class);
+ $logger->expects($this->once())->method('warning')
+ ->with($this->stringContains('flows stay on steps[]'), $this->anything());
+
+ $step = new MigrateFlowStepsToGraph(container: $container, logger: $logger);
+ $step->run($this->createMock(IOutput::class));
+
+ // Reaching this line IS the assertion: nothing escaped.
+ $this->assertTrue(true);
+
+ }//end testRunNeverThrows()
+}//end class
diff --git a/tests/Unit/Service/ApprovalServiceTest.php b/tests/Unit/Service/ApprovalServiceTest.php
index c17ffdae8..0e042cab7 100644
--- a/tests/Unit/Service/ApprovalServiceTest.php
+++ b/tests/Unit/Service/ApprovalServiceTest.php
@@ -236,6 +236,87 @@ function (array $object) use (&$captured) {
}//end testSuspendForFlowPersistsFlowRunIdAndResumeStepOrder()
+ /**
+ * suspendForEngineRun(): persists a `pending` request addressed at an
+ * OpenRegister ENGINE run — `engineRunUuid`/`signalNodeId` instead of
+ * `flowRunId`/`resumeStepOrder`, an empty snapshot (the engine holds the
+ * run's state), the node's failOnReject mapped into the legacy onReject
+ * vocabulary, and onTimeout pinned to `error` because the node fails
+ * closed on expiry — retire-integriq-flow-schema Task 1.
+ *
+ * @return void
+ */
+ public function testSuspendForEngineRunPersistsEngineAddressing(): void {
+ $this->stubNotificationChain();
+ $group = $this->createMock(\OCP\IGroup::class);
+ $group->method('getUsers')->willReturn([]);
+ $this->groupManager->method('get')->willReturn($group);
+
+ $captured = null;
+ $this->objectService->method('saveObject')->willReturnCallback(
+ function (array $object) use (&$captured) {
+ $captured = $object;
+ return $this->entity($object, 'approval-created');
+ }
+ );
+
+ $result = $this->service->suspendForEngineRun(
+ engineRunUuid: 'run-uuid-1',
+ signalNodeId: 'approve-1',
+ config: [
+ 'question' => 'Publish this dataset?',
+ 'approverGroup' => 'ops-approvers',
+ 'ttlSeconds' => 3600,
+ 'failOnReject' => true,
+ ],
+ requesterUid: 'alice'
+ );
+
+ $this->assertSame('approval-created', $result->getUuid());
+ $this->assertSame('pending', $captured['status']);
+ $this->assertSame('run-uuid-1', $captured['engineRunUuid']);
+ $this->assertSame('approve-1', $captured['signalNodeId']);
+ $this->assertSame('Publish this dataset?', $captured['question']);
+ $this->assertSame('alice', $captured['requesterUserId']);
+ $this->assertSame('ops-approvers', $captured['approverGroup']);
+ $this->assertSame('error', $captured['onReject'], 'failOnReject true maps to the legacy error outcome');
+ $this->assertSame('error', $captured['onTimeout'], 'Expiry always fails closed for engine runs');
+ $this->assertSame([], $captured['snapshot'], 'The engine holds the run state; no FlowToken snapshot');
+ $this->assertArrayNotHasKey('flowRunId', $captured);
+ $this->assertArrayNotHasKey('resumeStepOrder', $captured);
+
+ }//end testSuspendForEngineRunPersistsEngineAddressing()
+
+ /**
+ * suspendForEngineRun(): without failOnReject the record's onReject is
+ * `skip` — a rejection routes onward instead of ending the run.
+ *
+ * @return void
+ */
+ public function testSuspendForEngineRunMapsRoutedRejectionToSkip(): void {
+ $this->stubNotificationChain();
+ $group = $this->createMock(\OCP\IGroup::class);
+ $group->method('getUsers')->willReturn([]);
+ $this->groupManager->method('get')->willReturn($group);
+
+ $captured = null;
+ $this->objectService->method('saveObject')->willReturnCallback(
+ function (array $object) use (&$captured) {
+ $captured = $object;
+ return $this->entity($object, 'approval-created');
+ }
+ );
+
+ $this->service->suspendForEngineRun(
+ engineRunUuid: 'run-uuid-1',
+ signalNodeId: 'approve-1',
+ config: ['question' => 'Ship it?', 'approverGroup' => 'ops-approvers']
+ );
+
+ $this->assertSame('skip', $captured['onReject']);
+
+ }//end testSuspendForEngineRunMapsRoutedRejectionToSkip()
+
/**
* notifyApprovers(): every member of the configured approver group
* receives an actionable notification carrying approve/reject deep
diff --git a/tests/Unit/Service/EngineSignalServiceTest.php b/tests/Unit/Service/EngineSignalServiceTest.php
new file mode 100644
index 000000000..fd7614122
--- /dev/null
+++ b/tests/Unit/Service/EngineSignalServiceTest.php
@@ -0,0 +1,64 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\Integriq\Tests\Unit\Service;
+
+use OCA\Integriq\Service\EngineSignalService;
+use OCP\IUser;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\LoggerInterface;
+
+/**
+ * @covers \OCA\Integriq\Service\EngineSignalService
+ */
+class EngineSignalServiceTest extends TestCase {
+
+ /**
+ * Without OpenRegister's signal service the delivery reports false and
+ * says why, so the approval node's heartbeat is visibly the fallback.
+ *
+ * @return void
+ */
+ public function testDeliverWithoutSignalServiceReturnsFalseAndLogs(): void {
+ if (class_exists('OCA\\OpenRegister\\Service\\Flow\\FlowRunSignalService') === true) {
+ $this->markTestSkipped('A real FlowRunSignalService is present; the guarded branch cannot be exercised here.');
+ }
+
+ $logger = $this->createMock(LoggerInterface::class);
+ $logger->expects($this->once())->method('warning')
+ ->with(
+ $this->stringContains('resumes on its next heartbeat'),
+ $this->callback(static fn (array $ctx): bool => $ctx['engineRunUuid'] === 'run-1')
+ );
+
+ $user = $this->createMock(IUser::class);
+ $user->method('getUID')->willReturn('alice');
+
+ $service = new EngineSignalService(logger: $logger);
+ $delivered = $service->deliver(
+ data: ['engineRunUuid' => 'run-1', 'signalNodeId' => 'approve-1'],
+ decision: 'approved',
+ user: $user,
+ comment: 'fine'
+ );
+
+ $this->assertFalse($delivered);
+
+ }//end testDeliverWithoutSignalServiceReturnsFalseAndLogs()
+}//end class
diff --git a/tests/Unit/Service/FlowGraphMigrationServiceTest.php b/tests/Unit/Service/FlowGraphMigrationServiceTest.php
new file mode 100644
index 000000000..ce3b31f39
--- /dev/null
+++ b/tests/Unit/Service/FlowGraphMigrationServiceTest.php
@@ -0,0 +1,236 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\Integriq\Tests\Unit\Service;
+
+use OCA\Integriq\Service\FlowGraphMigrationService;
+use OCA\Integriq\Service\FlowStepsToGraphTranslator;
+use OCA\OpenRegister\Db\ObjectEntity;
+use OCA\OpenRegister\Service\ObjectService as OrObjectService;
+use OCP\IL10N;
+use PHPUnit\Framework\MockObject\MockObject;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\LoggerInterface;
+
+/**
+ * Tests for the live-object half of the steps-to-graph migration.
+ */
+class FlowGraphMigrationServiceTest extends TestCase {
+
+ /**
+ * The OR persistence double.
+ *
+ * @var OrObjectService&MockObject
+ */
+ private $orObjectService;
+
+ /**
+ * The service under test.
+ *
+ * @var FlowGraphMigrationService
+ */
+ private FlowGraphMigrationService $service;
+
+ /**
+ * Build the service with a real translator and a persistence double.
+ *
+ * @return void
+ */
+ protected function setUp(): void {
+ parent::setUp();
+
+ $l10n = $this->createMock(IL10N::class);
+ $l10n->method('t')->willReturnCallback(
+ static function (string $text, $parameters = []): string {
+ if (is_array($parameters) === false || $parameters === []) {
+ return $text;
+ }
+
+ return vsprintf($text, $parameters);
+ }
+ );
+
+ $this->orObjectService = $this->createMock(OrObjectService::class);
+
+ $this->service = new FlowGraphMigrationService(
+ translator: new FlowStepsToGraphTranslator(l10n: $l10n),
+ orObjectService: $this->orObjectService,
+ logger: $this->createMock(LoggerInterface::class)
+ );
+
+ }//end setUp()
+
+ /**
+ * A flow object with the given record.
+ *
+ * @param string $uuid The uuid.
+ * @param array $data The record.
+ *
+ * @return ObjectEntity The object.
+ */
+ private function flow(string $uuid, array $data): ObjectEntity {
+ $entity = new ObjectEntity();
+ $entity->setUuid($uuid);
+ $entity->setObject($data);
+
+ return $entity;
+
+ }//end flow()
+
+ /**
+ * A migratable flow gets its graph written IN PLACE with steps kept —
+ * and a flow already carrying nodes is skipped, which is what makes a
+ * second run of the same migration a no-op.
+ *
+ * @return void
+ */
+ public function testMigrateWritesAdditivelyAndSkipsMigrated(): void {
+ $legacy = $this->flow(
+ uuid: 'f-1',
+ data: [
+ 'name' => 'legacy',
+ 'steps' => [['order' => 10, 'type' => 'mapping', 'configRef' => 'map-1', 'onError' => 'stop']],
+ ]
+ );
+ $alreadyMigrated = $this->flow(
+ uuid: 'f-2',
+ data: ['name' => 'done', 'steps' => [], 'nodes' => [['id' => 'trigger']]]
+ );
+
+ $this->orObjectService->method('findAll')->willReturn(['results' => [$legacy, $alreadyMigrated]]);
+
+ $written = null;
+ $this->orObjectService->expects($this->once())
+ ->method('saveObject')
+ ->willReturnCallback(
+ function ($object, $register, $schema, $uuid) use (&$written): ObjectEntity {
+ $this->assertSame('integriq', $register);
+ $this->assertSame('flow', $schema);
+ $this->assertSame('f-1', $uuid);
+ $written = $object;
+
+ return new ObjectEntity();
+ }
+ );
+
+ $report = $this->service->migrate(apply: true);
+
+ $this->assertSame(FlowGraphMigrationService::MIGRATED, $report[0]['action']);
+ $this->assertSame(FlowGraphMigrationService::SKIPPED, $report[1]['action'], 'The second run of the migration skips what the first wrote');
+
+ $this->assertNotEmpty($written['nodes']);
+ $this->assertNotEmpty($written['edges']);
+ $this->assertNotEmpty($written['steps'], 'steps stays beside the graph — it is the rollback shape');
+
+ }//end testMigrateWritesAdditivelyAndSkipsMigrated()
+
+ /**
+ * A dry run reports without writing.
+ *
+ * @return void
+ */
+ public function testDryRunWritesNothing(): void {
+ $legacy = $this->flow(
+ uuid: 'f-1',
+ data: [
+ 'name' => 'legacy',
+ 'steps' => [['order' => 10, 'type' => 'mapping', 'configRef' => 'map-1', 'onError' => 'stop']],
+ ]
+ );
+ $this->orObjectService->method('findAll')->willReturn(['results' => [$legacy]]);
+ $this->orObjectService->expects($this->never())->method('saveObject');
+
+ $report = $this->service->migrate(apply: false);
+
+ $this->assertSame(FlowGraphMigrationService::MIGRATED, $report[0]['action']);
+
+ }//end testDryRunWritesNothing()
+
+ /**
+ * A refused flow is reported with its reasons and left untouched.
+ *
+ * @return void
+ */
+ public function testRefusedFlowIsReportedAndUntouched(): void {
+ $duplicated = $this->flow(
+ uuid: 'f-1',
+ data: [
+ 'name' => 'dupes',
+ 'steps' => [
+ ['order' => 20, 'type' => 'mapping', 'configRef' => 'a', 'onError' => 'stop'],
+ ['order' => 20, 'type' => 'mapping', 'configRef' => 'b', 'onError' => 'stop'],
+ ],
+ ]
+ );
+ $this->orObjectService->method('findAll')->willReturn(['results' => [$duplicated]]);
+ $this->orObjectService->expects($this->never())->method('saveObject');
+
+ $report = $this->service->migrate(apply: true);
+
+ $this->assertSame(FlowGraphMigrationService::REFUSED, $report[0]['action']);
+ $this->assertNotSame([], $report[0]['reasons']);
+
+ }//end testRefusedFlowIsReportedAndUntouched()
+
+ /**
+ * The rollback strips nodes/edges where steps remain, and refuses a flow
+ * whose graph is its ONLY shape.
+ *
+ * @return void
+ */
+ public function testRollbackStripsGraphAndRefusesSteplessFlows(): void {
+ $migrated = $this->flow(
+ uuid: 'f-1',
+ data: [
+ 'name' => 'migrated',
+ 'steps' => [['order' => 10, 'type' => 'mapping', 'configRef' => 'map-1', 'onError' => 'stop']],
+ 'nodes' => [['id' => 'trigger']],
+ 'edges' => [['id' => 'e']],
+ ]
+ );
+ $graphOnly = $this->flow(
+ uuid: 'f-2',
+ data: ['name' => 'graph-only', 'nodes' => [['id' => 'trigger']], 'edges' => []]
+ );
+
+ $this->orObjectService->method('findAll')->willReturn(['results' => [$migrated, $graphOnly]]);
+
+ $written = null;
+ $this->orObjectService->expects($this->once())
+ ->method('saveObject')
+ ->willReturnCallback(
+ static function ($object) use (&$written): ObjectEntity {
+ $written = $object;
+
+ return new ObjectEntity();
+ }
+ );
+
+ $report = $this->service->rollback(apply: true);
+
+ $this->assertSame(FlowGraphMigrationService::ROLLED_BACK, $report[0]['action']);
+ $this->assertArrayNotHasKey('nodes', $written);
+ $this->assertArrayNotHasKey('edges', $written);
+ $this->assertNotEmpty($written['steps']);
+
+ $this->assertSame(FlowGraphMigrationService::REFUSED, $report[1]['action'], 'A graph-only flow must not be stripped to nothing');
+
+ }//end testRollbackStripsGraphAndRefusesSteplessFlows()
+}//end class
diff --git a/tests/Unit/Service/FlowStepsToGraphTranslatorTest.php b/tests/Unit/Service/FlowStepsToGraphTranslatorTest.php
new file mode 100644
index 000000000..5102e8853
--- /dev/null
+++ b/tests/Unit/Service/FlowStepsToGraphTranslatorTest.php
@@ -0,0 +1,324 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\Integriq\Tests\Unit\Service;
+
+use OCA\Integriq\Exception\EntityNotMigratableException;
+use OCA\Integriq\Service\FlowStepsToGraphTranslator;
+use OCP\IL10N;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * Tests for the pure steps-to-graph translation.
+ */
+class FlowStepsToGraphTranslatorTest extends TestCase {
+
+ /**
+ * The translator under test.
+ *
+ * @var FlowStepsToGraphTranslator
+ */
+ private FlowStepsToGraphTranslator $translator;
+
+ /**
+ * Build the translator with a pass-through l10n double.
+ *
+ * @return void
+ */
+ protected function setUp(): void {
+ parent::setUp();
+
+ $l10n = $this->createMock(IL10N::class);
+ $l10n->method('t')->willReturnCallback(
+ static function (string $text, $parameters = []): string {
+ if (is_array($parameters) === false || $parameters === []) {
+ return $text;
+ }
+
+ return vsprintf($text, $parameters);
+ }
+ );
+
+ $this->translator = new FlowStepsToGraphTranslator(l10n: $l10n);
+
+ }//end setUp()
+
+ /**
+ * Index the produced nodes by id.
+ *
+ * @param array $graph The translation result.
+ *
+ * @return array Nodes by id.
+ */
+ private function nodesById(array $graph): array {
+ $byId = [];
+ foreach ($graph['nodes'] as $node) {
+ $byId[(string)$node['id']] = $node;
+ }
+
+ return $byId;
+
+ }//end nodesById()
+
+ /**
+ * A linear flow chains trigger, one node per step in order, and end.
+ *
+ * @return void
+ */
+ public function testLinearFlowChainsInOrder(): void {
+ $graph = $this->translator->translate(
+ flow: [
+ 'name' => 'linear',
+ 'steps' => [
+ ['order' => 20, 'type' => 'mapping', 'configRef' => 'map-1', 'onError' => 'stop'],
+ ['order' => 10, 'type' => 'call', 'configRef' => 'src-1', 'config' => ['endpoint' => '/x', 'method' => 'POST'], 'onError' => 'continue'],
+ ],
+ ]
+ );
+
+ $byId = $this->nodesById(graph: $graph);
+ $this->assertArrayHasKey('trigger', $byId);
+ $this->assertArrayHasKey('10', $byId);
+ $this->assertArrayHasKey('20', $byId);
+ $this->assertArrayHasKey('end', $byId);
+
+ $this->assertSame('openconnector.source-call', $byId['10']['type']);
+ $this->assertSame('src-1', $byId['10']['config']['source']);
+ $this->assertSame('/x', $byId['10']['config']['endpoint']);
+ $this->assertSame('POST', $byId['10']['config']['method']);
+ $this->assertSame('continue', $byId['10']['onError']);
+
+ $this->assertSame('openconnector.apply-mapping', $byId['20']['type']);
+ $this->assertSame('map-1', $byId['20']['config']['mapping']);
+
+ $pairs = array_map(static fn (array $edge): string => $edge['from'] . '>' . $edge['to'], $graph['edges']);
+ $this->assertSame(['trigger>10', '10>20', '20>end'], $pairs, 'Execution order is `order` ascending, not array position');
+
+ }//end testLinearFlowChainsInOrder()
+
+ /**
+ * Branch targets survive translation: the branch node's edges point at
+ * the nodes whose ids are the referenced orders, conditions riding on
+ * the edges.
+ *
+ * @return void
+ */
+ public function testBranchTargetsSurviveTranslation(): void {
+ $condition = ['==' => [['var' => 'syncOutputAmended.status'], 'ok']];
+
+ $graph = $this->translator->translate(
+ flow: [
+ 'name' => 'branched',
+ 'steps' => [
+ ['order' => 10, 'type' => 'call', 'configRef' => 'src-1', 'config' => ['endpoint' => '/x'], 'onError' => 'stop'],
+ [
+ 'order' => 30,
+ 'type' => 'branch',
+ 'onError' => 'stop',
+ 'branches' => [
+ ['condition' => $condition, 'nextStepOrder' => 40],
+ ],
+ 'defaultNextStepOrder' => 50,
+ ],
+ ['order' => 40, 'type' => 'mapping', 'configRef' => 'map-1', 'onError' => 'stop'],
+ ['order' => 50, 'type' => 'event', 'config' => ['type' => 't', 'source' => 's'], 'onError' => 'stop'],
+ ],
+ ]
+ );
+
+ $byId = $this->nodesById(graph: $graph);
+ $this->assertSame('openregister.switch', $byId['30']['type']);
+
+ $fromBranch = array_values(
+ array_filter($graph['edges'], static fn (array $edge): bool => $edge['from'] === '30')
+ );
+ $this->assertCount(2, $fromBranch);
+
+ $conditioned = array_values(array_filter($fromBranch, static fn (array $edge): bool => isset($edge['condition'])));
+ $this->assertCount(1, $conditioned);
+ $this->assertSame('40', $conditioned[0]['to'], 'The conditioned edge points at the node whose id is order 40');
+ $this->assertSame($condition, $conditioned[0]['condition'], 'The JsonLogic condition rides the edge verbatim');
+
+ $default = array_values(array_filter($fromBranch, static fn (array $edge): bool => isset($edge['condition']) === false));
+ $this->assertSame('50', $default[0]['to'], 'The default edge points at the node whose id is order 50');
+
+ }//end testBranchTargetsSurviveTranslation()
+
+ /**
+ * A flow with duplicate step orders is refused, not silently collapsed
+ * into a graph missing one of the two nodes.
+ *
+ * @return void
+ */
+ public function testDuplicateOrdersAreRefused(): void {
+ try {
+ $this->translator->translate(
+ flow: [
+ 'name' => 'dupes',
+ 'steps' => [
+ ['order' => 20, 'type' => 'mapping', 'configRef' => 'a', 'onError' => 'stop'],
+ ['order' => 20, 'type' => 'mapping', 'configRef' => 'b', 'onError' => 'stop'],
+ ],
+ ]
+ );
+ $this->fail('Expected an EntityNotMigratableException');
+ } catch (EntityNotMigratableException $refusal) {
+ $this->assertSame('flow', $refusal->getSubject());
+ $this->assertNotSame([], $refusal->getReasons());
+ $this->assertStringContainsString('Duplicate step order 20', implode(' ', $refusal->getReasons()));
+ }
+
+ }//end testDuplicateOrdersAreRefused()
+
+ /**
+ * A branch targeting a step order that does not exist is refused.
+ *
+ * @return void
+ */
+ public function testDanglingBranchTargetIsRefused(): void {
+ $this->expectException(EntityNotMigratableException::class);
+
+ $this->translator->translate(
+ flow: [
+ 'name' => 'dangling',
+ 'steps' => [
+ [
+ 'order' => 10,
+ 'type' => 'branch',
+ 'onError' => 'stop',
+ 'branches' => [
+ ['condition' => ['var' => 'x'], 'nextStepOrder' => 99],
+ ],
+ ],
+ ],
+ ]
+ );
+
+ }//end testDanglingBranchTargetIsRefused()
+
+ /**
+ * The features the graph cannot express faithfully are refused with one
+ * sentence each: step-level conditions, raw requestConfig, isTest runs,
+ * audience-less approvals and unknown types.
+ *
+ * @return void
+ */
+ public function testUnsupportedFeaturesAreRefusedWithReasons(): void {
+ try {
+ $this->translator->translate(
+ flow: [
+ 'name' => 'unsupported',
+ 'steps' => [
+ ['order' => 10, 'type' => 'call', 'configRef' => 'src', 'config' => ['requestConfig' => ['verify' => false]], 'onError' => 'stop'],
+ ['order' => 20, 'type' => 'synchronization', 'configRef' => 'sync', 'config' => ['isTest' => true], 'onError' => 'stop'],
+ ['order' => 30, 'type' => 'approval', 'config' => [], 'onError' => 'stop'],
+ ['order' => 40, 'type' => 'mapping', 'configRef' => 'map', 'condition' => ['var' => 'x'], 'onError' => 'stop'],
+ ['order' => 50, 'type' => 'telegram', 'onError' => 'stop'],
+ ],
+ ]
+ );
+ $this->fail('Expected an EntityNotMigratableException');
+ } catch (EntityNotMigratableException $refusal) {
+ $joined = implode(' ', $refusal->getReasons());
+ $this->assertStringContainsString('requestConfig', $joined);
+ $this->assertStringContainsString('isTest', $joined);
+ $this->assertStringContainsString('approverGroup', $joined);
+ $this->assertStringContainsString('condition', $joined);
+ $this->assertStringContainsString('telegram', $joined);
+ $this->assertCount(5, $refusal->getReasons(), 'Every unsupported feature is named, not just the first');
+ }
+
+ }//end testUnsupportedFeaturesAreRefusedWithReasons()
+
+ /**
+ * The approval step maps its runner-era config onto the
+ * approval-request node: onReject `skip` routes, anything else fails.
+ *
+ * @return void
+ */
+ public function testApprovalConfigMapsOntoTheNode(): void {
+ $graph = $this->translator->translate(
+ flow: [
+ 'name' => 'approvals',
+ 'steps' => [
+ [
+ 'order' => 10,
+ 'type' => 'approval',
+ 'config' => ['approverGroup' => 'stewards', 'onReject' => 'skip', 'ttlSeconds' => 3600],
+ 'onError' => 'stop',
+ ],
+ [
+ 'order' => 20,
+ 'type' => 'approval',
+ 'config' => ['approverGroup' => 'stewards', 'question' => 'Ship it?'],
+ 'onError' => 'stop',
+ ],
+ ],
+ ]
+ );
+
+ $byId = $this->nodesById(graph: $graph);
+
+ $this->assertSame('openconnector.approval-request', $byId['10']['type']);
+ $this->assertFalse($byId['10']['config']['failOnReject'], 'onReject skip routes the rejection onward');
+ $this->assertSame(3600, $byId['10']['config']['ttlSeconds']);
+ $this->assertNotSame('', $byId['10']['config']['question'], 'A question is synthesised when the step has none');
+
+ $this->assertTrue($byId['20']['config']['failOnReject'], 'The runner default (error) fails on rejection');
+ $this->assertSame('Ship it?', $byId['20']['config']['question']);
+
+ }//end testApprovalConfigMapsOntoTheNode()
+
+ /**
+ * Every legacy step type resolves to a node type that exists, so a
+ * migrated flow has no undispatchable step.
+ *
+ * @return void
+ */
+ public function testEveryLegacyTypeHasANode(): void {
+ $graph = $this->translator->translate(
+ flow: [
+ 'name' => 'all-types',
+ 'steps' => [
+ ['order' => 10, 'type' => 'call', 'configRef' => 'src', 'config' => ['endpoint' => '/x'], 'onError' => 'stop'],
+ ['order' => 20, 'type' => 'mapping', 'configRef' => 'map', 'onError' => 'stop'],
+ ['order' => 30, 'type' => 'synchronization', 'configRef' => 'sync', 'config' => ['force' => true], 'onError' => 'stop'],
+ ['order' => 40, 'type' => 'event', 'config' => ['type' => 't', 'source' => 's', 'subject' => 'subj'], 'onError' => 'stop'],
+ ['order' => 50, 'type' => 'approval', 'config' => ['approverGroup' => 'g'], 'onError' => 'stop'],
+ ['order' => 60, 'type' => 'branch', 'branches' => [], 'defaultNextStepOrder' => 10, 'onError' => 'stop'],
+ ],
+ ]
+ );
+
+ $types = array_map(static fn (array $node): string => (string)$node['type'], $this->nodesById(graph: $graph));
+
+ $this->assertSame('openconnector.source-call', $types['10']);
+ $this->assertSame('openconnector.apply-mapping', $types['20']);
+ $this->assertSame('openconnector.synchronization-run', $types['30']);
+ $this->assertSame('openconnector.event-emit', $types['40']);
+ $this->assertSame('openconnector.approval-request', $types['50']);
+ $this->assertSame('openregister.switch', $types['60']);
+
+ $byId = $this->nodesById(graph: $graph);
+ $this->assertTrue($byId['30']['config']['force']);
+ $this->assertSame('subj', $byId['40']['config']['subject']);
+
+ }//end testEveryLegacyTypeHasANode()
+}//end class
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
index 6b9756abe..ccc1fc710 100644
--- a/tests/bootstrap.php
+++ b/tests/bootstrap.php
@@ -402,6 +402,23 @@
if (class_exists('OCA\\OpenRegister\\Service\\Flow\\FlowSuspension') === false) {
require_once $stubsDir . '/OCA/OpenRegister/Service/Flow/FlowSuspension.php';
}
+
+ // retire-integriq-flow-schema: ApprovalRequestNode rides on the
+ // engine's await-signal semantics — it throws FlowStop on a rejected
+ // or expired approval and reads/writes its FlowNodeResumeState slot
+ // (whose parent FlowResumeState must parse first). All three are
+ // verbatim copies of the real OpenRegister files.
+ if (class_exists('OCA\\OpenRegister\\Service\\Flow\\FlowStop') === false) {
+ require_once $stubsDir . '/OCA/OpenRegister/Service/Flow/FlowStop.php';
+ }
+
+ if (class_exists('OCA\\OpenRegister\\Service\\Flow\\FlowResumeState') === false) {
+ require_once $stubsDir . '/OCA/OpenRegister/Service/Flow/FlowResumeState.php';
+ }
+
+ if (class_exists('OCA\\OpenRegister\\Service\\Flow\\FlowNodeResumeState') === false) {
+ require_once $stubsDir . '/OCA/OpenRegister/Service/Flow/FlowNodeResumeState.php';
+ }
}
}
diff --git a/tests/stubs/OCA/OpenRegister/Service/Flow/FlowNodeResumeState.php b/tests/stubs/OCA/OpenRegister/Service/Flow/FlowNodeResumeState.php
new file mode 100644
index 000000000..7d2cf52a2
--- /dev/null
+++ b/tests/stubs/OCA/OpenRegister/Service/Flow/FlowNodeResumeState.php
@@ -0,0 +1,184 @@
+
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * @category Service
+ * @package OCA\OpenRegister\Service\Flow
+ *
+ * @author Conduction Development Team
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * @link https://OpenRegister.app
+ *
+ */
+
+declare(strict_types=1);
+
+namespace OCA\OpenRegister\Service\Flow;
+
+/**
+ * A read/write handle on one node's resume slot.
+ */
+final class FlowNodeResumeState {
+
+ /**
+ * The context key the scoped handle is reachable at.
+ *
+ * Distinct from {@see FlowResumeState::CONTEXT_KEY}: that one holds every
+ * node's slot and is what gets persisted, this one is the single-node view
+ * a node actually uses.
+ *
+ * @var string
+ */
+ public const CONTEXT_KEY = 'resume';
+
+ /**
+ * Constructor.
+ *
+ * @param FlowResumeState $parent The state holding every node's slot.
+ * @param string $nodeId The node this view is scoped to.
+ *
+ */
+ public function __construct(
+ private readonly FlowResumeState $parent,
+ private readonly string $nodeId,
+ ) {
+
+ }//end __construct()
+
+ /**
+ * Which node this slot belongs to.
+ *
+ * A node is handed its own slot but was never told its own NAME, which is
+ * fine while the only thing it does with the slot is read and write it.
+ * It stops being fine as soon as a node has to hand its identity to
+ * something OUTSIDE the run — a task record that must later resume this
+ * exact node, for instance. A run accumulates one awaiting slot per node,
+ * so "resume this run" is not an answer: the resumer has to name the node,
+ * and it can only do that if the node could name itself.
+ *
+ * @return string This node's id within the flow graph.
+ *
+ */
+ public function nodeId(): string {
+ return $this->nodeId;
+ }//end nodeId()
+
+ /**
+ * Whether this node has progress stored from an earlier pass.
+ *
+ * The question a resumed node should ask INSTEAD of `$context['resuming']`.
+ * That flag says the RUN resumed, which is true for every node in the graph
+ * once anything has suspended; this says THIS node has somewhere to
+ * continue from, which is the thing worth branching on.
+ *
+ * @return boolean True when a slot is held.
+ *
+ */
+ public function isResuming(): bool {
+ return ($this->parent->read(nodeId: $this->nodeId) !== []);
+ }//end isResuming()
+
+ /**
+ * Read a value.
+ *
+ * @param string $key The value's key.
+ * @param mixed $default Returned when the key is not held.
+ *
+ * @return mixed The held value, or the default.
+ *
+ */
+ public function get(string $key, mixed $default = null): mixed {
+ $values = $this->parent->read(nodeId: $this->nodeId);
+
+ return ($values[$key] ?? $default);
+ }//end get()
+
+ /**
+ * Whether a key is held.
+ *
+ * @param string $key The value's key.
+ *
+ * @return boolean Whether the key is held.
+ *
+ */
+ public function has(string $key): bool {
+ return array_key_exists($key, $this->parent->read(nodeId: $this->nodeId));
+ }//end has()
+
+ /**
+ * Write a value.
+ *
+ * @param string $key The value's key.
+ * @param mixed $value The value to hold. Must survive a JSON round trip —
+ * the slot is persisted into the run's context column,
+ * so an object handed in here comes back as an array.
+ *
+ * @return void
+ *
+ */
+ public function set(string $key, mixed $value): void {
+ $values = $this->parent->read(nodeId: $this->nodeId);
+ $values[$key] = $value;
+ $this->parent->write(nodeId: $this->nodeId, values: $values);
+
+ }//end set()
+
+ /**
+ * Write several values at once.
+ *
+ * @param array $values The values to merge in.
+ *
+ * @return void
+ *
+ */
+ public function merge(array $values): void {
+ $this->parent->write(
+ nodeId: $this->nodeId,
+ values: array_merge($this->parent->read(nodeId: $this->nodeId), $values)
+ );
+
+ }//end merge()
+
+ /**
+ * Everything this node holds.
+ *
+ * @return array The stored values.
+ *
+ */
+ public function all(): array {
+ return $this->parent->read(nodeId: $this->nodeId);
+ }//end all()
+
+ /**
+ * Drop this node's progress.
+ *
+ * A node rarely needs to call this: the dispatcher clears the slot whenever
+ * a node returns normally, so finishing is enough. It is here for a node
+ * that abandons its stored position while still intending to suspend —
+ * restarting a crawl whose cursor the source has invalidated, say.
+ *
+ * @return void
+ *
+ */
+ public function clear(): void {
+ $this->parent->forget(nodeId: $this->nodeId);
+
+ }//end clear()
+}//end class
diff --git a/tests/stubs/OCA/OpenRegister/Service/Flow/FlowResumeState.php b/tests/stubs/OCA/OpenRegister/Service/Flow/FlowResumeState.php
new file mode 100644
index 000000000..d2e6ca1d0
--- /dev/null
+++ b/tests/stubs/OCA/OpenRegister/Service/Flow/FlowResumeState.php
@@ -0,0 +1,233 @@
+
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * @category Service
+ * @package OCA\OpenRegister\Service\Flow
+ *
+ * @author Conduction Development Team
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * @link https://OpenRegister.app
+ *
+ */
+
+declare(strict_types=1);
+
+namespace OCA\OpenRegister\Service\Flow;
+
+use JsonSerializable;
+
+/**
+ * Per-node progress, persisted across a suspension.
+ */
+final class FlowResumeState implements JsonSerializable {
+
+ /**
+ * The context key the state is reachable at.
+ *
+ * @var string
+ */
+ public const CONTEXT_KEY = 'resumeState';
+
+ /**
+ * Progress slots, keyed by node id.
+ *
+ * @var array>
+ */
+ private array $byNode = [];
+
+ /**
+ * Build the state over stored slots.
+ *
+ * @param array> $byNode The stored slots.
+ *
+ */
+ public function __construct(array $byNode = []) {
+ $this->byNode = $byNode;
+
+ }//end __construct()
+
+ /**
+ * A view of one node's slot.
+ *
+ * @param string $nodeId The node's id in the flow document.
+ *
+ * @return FlowNodeResumeState The scoped handle handed to that node.
+ *
+ */
+ public function forNode(string $nodeId): FlowNodeResumeState {
+ return new FlowNodeResumeState(parent: $this, nodeId: $nodeId);
+ }//end forNode()
+
+ /**
+ * Read a node's slot.
+ *
+ * @param string $nodeId The node's id.
+ *
+ * @return array The stored values, empty when it has none.
+ *
+ */
+ public function read(string $nodeId): array {
+ return ($this->byNode[$nodeId] ?? []);
+ }//end read()
+
+ /**
+ * Replace a node's slot.
+ *
+ * @param string $nodeId The node's id.
+ * @param array $values The values to hold.
+ *
+ * @return void
+ *
+ */
+ public function write(string $nodeId, array $values): void {
+ if ($values === []) {
+ $this->forget(nodeId: $nodeId);
+ return;
+ }
+
+ $this->byNode[$nodeId] = $values;
+
+ }//end write()
+
+ /**
+ * Drop a node's slot.
+ *
+ * Called by the dispatcher when a node returns normally. Progress is only
+ * ever meaningful BETWEEN a suspension and the resume that follows it, so a
+ * node that finished has nothing left to remember — and leaving the slot
+ * behind would hand stale progress to the next pass through the same node,
+ * which is precisely what happens inside a loop.
+ *
+ * @param string $nodeId The node's id.
+ *
+ * @return void
+ *
+ */
+ public function forget(string $nodeId): void {
+ unset($this->byNode[$nodeId]);
+
+ }//end forget()
+
+ /**
+ * Whether any node holds progress.
+ *
+ * @return boolean True when at least one slot is occupied.
+ *
+ */
+ public function isEmpty(): bool {
+ return ($this->byNode === []);
+ }//end isEmpty()
+
+ /**
+ * Every slot.
+ *
+ * @return array> The slots, keyed by node id.
+ *
+ */
+ public function all(): array {
+ return $this->byNode;
+ }//end all()
+
+ /**
+ * Build the state from whatever a stored context happens to hold.
+ *
+ * Total, for the same reason {@see FlowToken::fromArray()} is: a run
+ * persisted before this existed holds nothing, a corrupted column holds a
+ * scalar, and a run handed straight back holds an object already. A run
+ * must not fail over any of those.
+ *
+ * @param mixed $stored The stored value, of any shape.
+ *
+ * @return self The state.
+ *
+ */
+ public static function fromArray(mixed $stored): self {
+ if ($stored instanceof self === true) {
+ return $stored;
+ }
+
+ if (is_array($stored) === false) {
+ return new self();
+ }
+
+ $byNode = [];
+ foreach ($stored as $nodeId => $values) {
+ // A JSON round trip turns a list into integer keys, which are not
+ // node ids; and a slot that is not a value bag cannot be one.
+ if (is_string($nodeId) === false || is_array($values) === false) {
+ continue;
+ }
+
+ $byNode[$nodeId] = $values;
+ }
+
+ return new self($byNode);
+ }//end fromArray()
+
+ /**
+ * The storable form, or null when there is nothing worth storing.
+ *
+ * Only a SUSPENDED run has anywhere to continue from. A terminal one does
+ * not, so keeping its slots would put a stale cursor in front of anyone
+ * reading the run to find out what happened — and the dispatcher has already
+ * cleared every node that returned, so anything still held belongs to a node
+ * the run never came back to.
+ *
+ * Lives here rather than in the run service because it is a question about
+ * this value, not about persistence: the state knows when it is worth
+ * keeping.
+ *
+ * @param boolean $suspended Whether the walk ended suspended.
+ *
+ * @return array>|null The slots, or null to drop them.
+ *
+ */
+ public function storableWhen(bool $suspended): ?array {
+ if ($suspended === false || $this->byNode === []) {
+ return null;
+ }
+
+ return $this->byNode;
+ }//end storableWhen()
+
+ /**
+ * The storable form.
+ *
+ * @return array> The slots.
+ *
+ */
+ public function jsonSerialize(): array {
+ return $this->byNode;
+ }//end jsonSerialize()
+}//end class
diff --git a/tests/stubs/OCA/OpenRegister/Service/Flow/FlowStop.php b/tests/stubs/OCA/OpenRegister/Service/Flow/FlowStop.php
new file mode 100644
index 000000000..8052c2136
--- /dev/null
+++ b/tests/stubs/OCA/OpenRegister/Service/Flow/FlowStop.php
@@ -0,0 +1,87 @@
+
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * @category Service
+ * @package OCA\OpenRegister\Service\Flow
+ *
+ * @author Conduction Development Team
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * @link https://OpenRegister.app
+ *
+ */
+
+declare(strict_types=1);
+
+namespace OCA\OpenRegister\Service\Flow;
+
+use RuntimeException;
+
+/**
+ * Signals that the current run should end here.
+ */
+class FlowStop extends RuntimeException {
+ /**
+ * Constructor.
+ *
+ * @param string $reason Why the run stopped, for the run log.
+ * @param boolean $isError Whether this is a failure (`failed`) rather
+ * than a clean stop (`stopped`).
+ * @param string|null $checkId The oversight check that vetoed the hop, when
+ * the stop came from a gate rather than from a
+ * Stop step. Structured rather than folded into
+ * the reason so "which gate closed" stays a
+ * query instead of a substring search.
+ */
+ public function __construct(
+ string $reason = 'stopped',
+ private readonly bool $isError = false,
+ private readonly ?string $checkId = null,
+ ) {
+ parent::__construct(message: $reason);
+
+ }//end __construct()
+
+ /**
+ * The oversight check that vetoed the hop, when a gate raised this stop.
+ *
+ * @return string|null The check id, or null for an author-requested stop.
+ *
+ */
+ public function checkId(): ?string {
+ return $this->checkId;
+ }//end checkId()
+
+ /**
+ * Whether the run should end as `failed` rather than `stopped`.
+ *
+ * @return boolean True for an error stop.
+ *
+ */
+ public function isError(): bool {
+ return $this->isError;
+ }//end isError()
+}//end class