diff --git a/components/engine/engine-bpm-flowable/src/main/java/org/eclipse/dirigible/components/engine/bpm/flowable/config/BpmFlowableConfig.java b/components/engine/engine-bpm-flowable/src/main/java/org/eclipse/dirigible/components/engine/bpm/flowable/config/BpmFlowableConfig.java
index d2194ff8a70..22575f29594 100644
--- a/components/engine/engine-bpm-flowable/src/main/java/org/eclipse/dirigible/components/engine/bpm/flowable/config/BpmFlowableConfig.java
+++ b/components/engine/engine-bpm-flowable/src/main/java/org/eclipse/dirigible/components/engine/bpm/flowable/config/BpmFlowableConfig.java
@@ -20,6 +20,7 @@
import org.eclipse.dirigible.engine.java.runtime.ClientClassLoaderHolder;
import org.flowable.engine.ProcessEngine;
import org.flowable.engine.ProcessEngineConfiguration;
+import org.flowable.engine.impl.bpmn.parser.factory.DefaultListenerFactory;
import org.flowable.spring.SpringProcessEngineConfiguration;
import org.flowable.spring.boot.actuate.endpoint.ProcessEngineEndpoint;
import org.flowable.spring.boot.actuate.info.FlowableInfoContributor;
@@ -127,7 +128,15 @@ private SpringProcessEngineConfiguration createProcessEngineConfig(DataSource da
// error (message published for {error}) instead of dead-lettering; everything else is
// untouched. The engine's initBehaviorFactory injects the expression manager into this factory
// later.
- config.setActivityBehaviorFactory(new ResilientActivityBehaviorFactory(new ResilientClassDelegateFactory()));
+ ResilientClassDelegateFactory classDelegateFactory = new ResilientClassDelegateFactory();
+ config.setActivityBehaviorFactory(new ResilientActivityBehaviorFactory(classDelegateFactory));
+
+ // The same ClassDelegate seam for the listener path: Flowable builds its own
+ // DefaultListenerFactory carrying a stock DefaultClassDelegateFactory, so a flowable:class
+ // execution or task listener would be instantiated reflectively and never reach the client bean
+ // container - a constructor collaborator fails, an @Inject field silently reads null (#7222).
+ // The engine keeps a pre-set listener factory and only injects the expression manager into it.
+ config.setListenerFactory(new DefaultListenerFactory(classDelegateFactory));
return config;
}
diff --git a/components/engine/engine-bpm-flowable/src/main/java/org/eclipse/dirigible/components/engine/bpm/flowable/delegate/ClientDelegateBeans.java b/components/engine/engine-bpm-flowable/src/main/java/org/eclipse/dirigible/components/engine/bpm/flowable/delegate/ClientDelegateBeans.java
index 80e7f788569..4991f238852 100644
--- a/components/engine/engine-bpm-flowable/src/main/java/org/eclipse/dirigible/components/engine/bpm/flowable/delegate/ClientDelegateBeans.java
+++ b/components/engine/engine-bpm-flowable/src/main/java/org/eclipse/dirigible/components/engine/bpm/flowable/delegate/ClientDelegateBeans.java
@@ -17,12 +17,14 @@
import org.flowable.engine.delegate.JavaDelegate;
/**
- * Wires a client {@link JavaDelegate} through the client bean container, for the two paths that
- * instantiate one: {@code flowable:class} ({@link ResilientClassDelegate}) and
- * {@code flowable:delegateExpression="${JavaTask}"} ({@link DirigibleJavaCallDelegate}).
+ * Wires a client class Flowable instantiates through the client bean container: a
+ * {@link JavaDelegate} on either of its two paths - {@code flowable:class}
+ * ({@link ResilientClassDelegate}) and {@code flowable:delegateExpression="${JavaTask}"}
+ * ({@link DirigibleJavaCallDelegate}) - and a {@code flowable:class} execution or task listener,
+ * which shares the first one.
*
*
- * A delegate is created by Flowable, so it is never a container-owned bean and {@code @Inject}
+ * Such a class is created by Flowable, so it is never a container-owned bean and {@code @Inject}
* could not reach it; {@link ClientBeanFactory#createUnmanaged(Class)} constructs it with the
* container's own injection rules without registering it. Empty means the class declares no
* injection point, and the caller keeps its own plain instantiation.
@@ -40,7 +42,7 @@ private ClientDelegateBeans() {}
/**
* The container-wired instance of {@code type}, or empty when there is nothing to wire.
*
- * @param the delegate type
+ * @param the client type
* @param type the client class Flowable is about to instantiate
* @return the wired instance, or empty when the class declares no injection point, Spring is not
* initialized (a standalone engine), or no client generation has been built yet
diff --git a/components/engine/engine-bpm-flowable/src/main/java/org/eclipse/dirigible/components/engine/bpm/flowable/delegate/ResilientClassDelegate.java b/components/engine/engine-bpm-flowable/src/main/java/org/eclipse/dirigible/components/engine/bpm/flowable/delegate/ResilientClassDelegate.java
index 9b9153f6932..969f4ffc62f 100644
--- a/components/engine/engine-bpm-flowable/src/main/java/org/eclipse/dirigible/components/engine/bpm/flowable/delegate/ResilientClassDelegate.java
+++ b/components/engine/engine-bpm-flowable/src/main/java/org/eclipse/dirigible/components/engine/bpm/flowable/delegate/ResilientClassDelegate.java
@@ -20,17 +20,24 @@
import org.flowable.engine.impl.bpmn.parser.FieldDeclaration;
/**
- * The {@link ClassDelegate} every {@code flowable:class} service task runs through (created by
- * {@link ResilientClassDelegateFactory}), adding the intent DSL's step resilience: when the
- * delegate's FINAL failed attempt happens on a task carrying an intent {@code onError} error
- * boundary, the failure is converted into the caught BPMN error instead of dead-lettering - see
+ * The {@link ClassDelegate} every {@code flowable:class} element runs through - a service task, and
+ * since #7222 an execution or task listener too (all created by
+ * {@link ResilientClassDelegateFactory}).
+ *
+ *
+ * On a service task it adds the intent DSL's step resilience: when the delegate's FINAL failed
+ * attempt happens on a task carrying an intent {@code onError} error boundary, the failure is
+ * converted into the caught BPMN error instead of dead-lettering - see
* {@link IntentStepResilience}. A {@code BpmnError} the delegate throws itself, and any failure on
* a task without the intent boundary, keep the stock behaviour (the superclass handles both).
*
*
- * It is also where a client delegate gets its collaborators: {@link #instantiateDelegate} routes
- * the class through the client bean container, so a {@code flowable:class} delegate is wired like
- * every other client class - see {@link ClientDelegateBeans}.
+ * It is also where a client class gets its collaborators: {@link #instantiateDelegate} routes the
+ * class through the client bean container, so a {@code flowable:class} delegate or
+ * listener is wired like every other client class - see {@link ClientDelegateBeans}. A
+ * listener's failure keeps the stock behaviour: {@link #execute} is the service-task entry point,
+ * and Flowable's own {@code notify} paths never reach it, so nothing about a listener is converted
+ * into a step error.
*/
class ResilientClassDelegate extends ClassDelegate {
diff --git a/components/engine/engine-bpm-flowable/src/main/java/org/eclipse/dirigible/components/engine/bpm/flowable/delegate/ResilientClassDelegateFactory.java b/components/engine/engine-bpm-flowable/src/main/java/org/eclipse/dirigible/components/engine/bpm/flowable/delegate/ResilientClassDelegateFactory.java
index ba35344eaa8..84e17a5c687 100644
--- a/components/engine/engine-bpm-flowable/src/main/java/org/eclipse/dirigible/components/engine/bpm/flowable/delegate/ResilientClassDelegateFactory.java
+++ b/components/engine/engine-bpm-flowable/src/main/java/org/eclipse/dirigible/components/engine/bpm/flowable/delegate/ResilientClassDelegateFactory.java
@@ -18,11 +18,18 @@
import org.flowable.engine.impl.bpmn.parser.FieldDeclaration;
/**
- * Creates {@link ResilientClassDelegate}s for every {@code flowable:class} service task (the shape
- * of Flowable's own {@code DefaultClassDelegateFactory}), so the intent DSL's {@code onError} error
- * routing has its conversion hook on the one path all {@code delegate:} steps run through. Wired
- * into the engine by {@code BpmFlowableConfig} via a {@code ResilientActivityBehaviorFactory}
- * carrying this factory.
+ * Creates {@link ResilientClassDelegate}s for every {@code flowable:class} element (the shape of
+ * Flowable's own {@code DefaultClassDelegateFactory}), so the intent DSL's {@code onError} error
+ * routing has its conversion hook on the one path all {@code delegate:} steps run through, and so
+ * every client class the engine instantiates reaches the client bean container.
+ *
+ *
+ * {@code BpmFlowableConfig} wires this one factory into both places Flowable creates a
+ * {@code ClassDelegate} from: the service-task path, through a
+ * {@code ResilientActivityBehaviorFactory} carrying it, and the execution- / task-listener path,
+ * through a {@code DefaultListenerFactory} carrying it - which is what the second {@code create}
+ * overload below serves. Without that second registration a {@code flowable:class} listener is
+ * built by stock reflection and its collaborators read {@code null} (#7222).
*/
public class ResilientClassDelegateFactory implements ClassDelegateFactory {
diff --git a/components/engine/engine-bpm-flowable/src/test/java/org/eclipse/dirigible/components/engine/bpm/flowable/delegate/ResilientListenerFactoryTest.java b/components/engine/engine-bpm-flowable/src/test/java/org/eclipse/dirigible/components/engine/bpm/flowable/delegate/ResilientListenerFactoryTest.java
new file mode 100644
index 00000000000..45ebc30c5b2
--- /dev/null
+++ b/components/engine/engine-bpm-flowable/src/test/java/org/eclipse/dirigible/components/engine/bpm/flowable/delegate/ResilientListenerFactoryTest.java
@@ -0,0 +1,104 @@
+/*
+ * Copyright (c) 2010-2026 Eclipse Dirigible contributors
+ *
+ * All rights reserved. This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at
+ * http://www.eclipse.org/legal/epl-v20.html
+ *
+ * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0
+ */
+package org.eclipse.dirigible.components.engine.bpm.flowable.delegate;
+
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.flowable.bpmn.model.FlowableListener;
+import org.flowable.bpmn.model.ImplementationType;
+import org.flowable.engine.ProcessEngine;
+import org.flowable.engine.delegate.DelegateExecution;
+import org.flowable.engine.delegate.JavaDelegate;
+import org.flowable.engine.impl.bpmn.parser.factory.DefaultListenerFactory;
+import org.flowable.engine.impl.bpmn.parser.factory.ListenerFactory;
+import org.flowable.engine.impl.cfg.ProcessEngineConfigurationImpl;
+import org.flowable.engine.impl.cfg.StandaloneInMemProcessEngineConfiguration;
+import org.junit.jupiter.api.Test;
+
+/**
+ * A {@code flowable:class} execution or task listener must reach the same client-bean seam a
+ * {@code flowable:class} service task does (#7222). Flowable builds its own
+ * {@link DefaultListenerFactory} carrying a stock {@code DefaultClassDelegateFactory}, so without
+ * the registration {@code BpmFlowableConfig} makes, a client listener is instantiated reflectively
+ * and its collaborators read {@code null} - the #7058 symptom, one artefact type over.
+ *
+ *
+ * This pins the mechanism against a real engine: that a pre-set listener factory survives the
+ * engine's own {@code initListenerFactory} (and gets its expression manager), and that both
+ * listener kinds then come out as {@link ResilientClassDelegate}s, whose
+ * {@code instantiateDelegate} is the seam. The defect case is pinned alongside, so a dropped
+ * registration fails here. The end-to-end proof that the collaborators are really injected is
+ * {@code JavaDelegateInjectionIT}.
+ */
+class ResilientListenerFactoryTest {
+
+ /** A client-shaped listener class; only the type Flowable creates for it is under test here. */
+ public static class SampleListener implements JavaDelegate {
+
+ @Override
+ public void execute(DelegateExecution execution) {
+ // Never invoked: the factory call under test only creates the ClassDelegate.
+ }
+ }
+
+ @Test
+ void aClassListenerIsCreatedThroughTheClientBeanSeam() {
+ withEngine(true, configuration -> {
+ ListenerFactory factory = configuration.getListenerFactory();
+
+ assertInstanceOf(ResilientClassDelegate.class, factory.createClassDelegateExecutionListener(classListener()),
+ "a flowable:class execution listener must be built by the resilient delegate, which wires the client bean container");
+ assertInstanceOf(ResilientClassDelegate.class, factory.createClassDelegateTaskListener(classListener()),
+ "a flowable:class task listener must be built by the resilient delegate, which wires the client bean container");
+ });
+ }
+
+ @Test
+ void theConfiguredListenerFactoryIsKeptAndGetsTheExpressionManager() {
+ withEngine(true, configuration -> {
+ assertInstanceOf(DefaultListenerFactory.class, configuration.getListenerFactory(),
+ "the engine must keep the configured listener factory instead of building its own");
+ assertNotNull(((DefaultListenerFactory) configuration.getListenerFactory()).getExpressionManager(),
+ "the engine injects the expression manager into a pre-set listener factory - an expression listener needs it");
+ });
+ }
+
+ @Test
+ void withoutTheRegistrationTheListenerIsAStockClassDelegate() {
+ withEngine(false, configuration -> assertTrue(!(configuration.getListenerFactory()
+ .createClassDelegateExecutionListener(
+ classListener()) instanceof ResilientClassDelegate),
+ "the defect this pins: Flowable's own listener factory bypasses the client bean seam"));
+ }
+
+ private static FlowableListener classListener() {
+ FlowableListener listener = new FlowableListener();
+ listener.setImplementationType(ImplementationType.IMPLEMENTATION_TYPE_CLASS);
+ listener.setImplementation(SampleListener.class.getName());
+ return listener;
+ }
+
+ private static void withEngine(boolean registerListenerFactory,
+ java.util.function.Consumer assertions) {
+ StandaloneInMemProcessEngineConfiguration configuration = new StandaloneInMemProcessEngineConfiguration();
+ configuration.setJdbcUrl("jdbc:h2:mem:listener-factory-test-" + registerListenerFactory + ";DB_CLOSE_DELAY=1000");
+ if (registerListenerFactory) {
+ configuration.setListenerFactory(new DefaultListenerFactory(new ResilientClassDelegateFactory()));
+ }
+ ProcessEngine engine = configuration.buildProcessEngine();
+ try {
+ assertions.accept((ProcessEngineConfigurationImpl) engine.getProcessEngineConfiguration());
+ } finally {
+ engine.close();
+ }
+ }
+}
diff --git a/components/engine/engine-intent/CLAUDE.md b/components/engine/engine-intent/CLAUDE.md
index e5a2bf6ca62..0c7579f6472 100644
--- a/components/engine/engine-intent/CLAUDE.md
+++ b/components/engine/engine-intent/CLAUDE.md
@@ -408,7 +408,7 @@ Semantics worth knowing:
- **Decision steps**: `if` + `then` are mandatory; `else` is optional and receives the gateway-default flow (so the conditioned branch can actually be skipped - without `else` the default falls through to the next step in the chain). `then`/`else` must name a declared step or the literal `end`, and **neither may name the decision itself** - an exclusive gateway has no wait state, so a branch back to the gateway emits a self-targeting sequence flow Flowable spins on, exactly as a step's `next: ` does (#7226 / #7292); it is refused with the same sentence. A multi-step cycle THROUGH a wait state is legitimate and untouched, as are `onError: ` (an unbounded retry) and a timer boundary's `then: ` (re-open the task). The parser validates all of this so a typo - or a spin - fails at parse time instead of producing BPMN Flowable rejects or spins on.
- **`setField` service task + `next` step routing (declarative field-set glue).** A `serviceTask` with `args: { setField: , value: }` sets a `string`/`text` field of the process's **trigger entity** to a literal value, generated as a `gen/events//.java` `JavaDelegate` (`SetFieldSupport` → the `setters` glue collection → `SetField.java.template`) instead of scaffolding a hand-written `custom.` stub - it persists the set column via the targeted single-column `updateProperty(id, "", value)` (a workflow write, not a user edit, so it must not re-fire `onUpdate` reactions; only the set column is in the UPDATE statement, so a concurrent write to any other column cannot be reverted). The canonical use is an approve/reject outcome: the form completes the task with the chosen `action` as a process variable, a `decision` branches on `action == 'approve'`, and the two branches are `setField` tasks (`status=ACTIVE` / `status=REJECTED`). **`args: { next: }`** on any step overrides its linear successor - needed because the BPMN generator builds a **linear** chain, so without it the first branch (`activate`) would fall through into the second (`reject`); `next: done` makes the branches converge. The `then`/`else` fall-through is deliberately NOT auto-converted to a diamond (LoanApproval's `curatorReview` relies on falling through to `notifyMember`), so convergence is explicit via `next`. Scope: literal string values only (the parser validates `setField` is a string/text field of the trigger entity and that `value` is present; `next` must name a declared step or `end`). Non-string fields and expression values are future work.
- **`setRelationField` (set a status modelled as a to-one relation).** The generic counterpart to `setField` for a status that is a **FK to a settings/nomenclature entity** (e.g. `Status`) rather than a string column: `args: { setRelationField: , value: }` sets the relation's FK property to the integer **seed id** (unquoted — `entity. = ;`), via the **same** `SetFieldSupport` → `setters` glue → `SetField.java.template` path (the `Setter` carries a `relation` flag; the template branches `#if($relation == "true")` to emit the unquoted assignment). Unlike `setField` (serviceTask only), `setRelationField` is allowed on a **serviceTask** (bound directly by `appendServiceTask`, like `setField`) **and** on a **userTask** (the BPMN inserts the setter `JavaDelegate` right after the task — exactly like the Writer — so e.g. the Approve user task sets `Status=APPROVED` the moment it completes; `BpmnIntentGenerator` builds a `setterByProcessTask` map of user-task setters and `augmentWithResolvers` appends them in a `[writer, setter]` after-task chain, carrying `next` onto the last delegate). The parser validates the relation is a `manyToOne`/`oneToOne` of the trigger entity and that `value` is an integer id. This replaced an unimplemented `setStatus` idea — there is no `setStatus` keyword. **The `-transitioned` topic:** both setter shapes persist via the targeted `updateProperty` (no `-updated` re-fire, deliberately) but DO publish the fresh entity JSON (re-loaded after the write) on `---transitioned` — the dedicated status-reached channel. **Publication is deferred to the end of the synchronous BPMN chain** (`Process.executeAfterCommit` — a Flowable COMMITTED transaction listener): service tasks that follow the setter in the same chain (a number-generation delegate) complete before any consumer can react, so a consumer that re-loads the source by id observes their writes instead of racing them (an auto-posted journal entry used to catch the create-time UUID placeholder as `documentNumber`). **On a mid-chain FAILURE this is correct by design, not a lost event:** the status write commits in its own session (client-Java writes are per-operation — cloud-native, no ambient cross-step transaction), while the deferred publish only fires on the Flowable COMMITTED event; if a later task rolls the chain back, the publish deliberately does NOT fire, and the transition is unwound by the flow's compensation / error path (a compensating status set), never by a DB rollback. Checks + Saga-style compensation, NOT distributed transactions, are the consistency model here — do not "fix" this by enrolling the entity write in the BPMN transaction. Reactions/notifications keep binding `-updated`/create topics and never see it (no loops); a posting-glue or integration consumer binds `-transitioned` to observe workflow transitions that are otherwise event-silent (the Wave-0 accounting spike's core finding: an Issue step was invisible to every entity event). Emitted by `SetField.java.template`; covered by the `set_field_glue_...` IT assertion. **Placement rule (applies to `setField` too, documented for the editor AI in `intent-assistant-guide.md`):** when a task is **followed by a decision** (Approve/Reject), put the status set on a **`serviceTask` on the chosen branch**, NOT on the user task — setting it on the task makes a Reject transition `DRAFT → APPROVED → CANCELLED` (an artificial APPROVED hop) before the cancel branch overrides it. Set on the task **only** for a **single-action** task with no following decision (no branch ⇒ no transient state). The `sales-invoices` showcase follows this (Approve task has no set; an `activate` serviceTask sets APPROVED on the approve branch; single-action `issue`/`send` set on the task).
-- **`delegate` service task (call a reusable, author-named client `JavaDelegate`).** `args: { delegate: , fields: { : , ... } }` binds a serviceTask to a hand-written client delegate via **`flowable:class`** (NOT the `${JavaTask}` dispatcher). This is the *fourth* service-task shape alongside `setField`/`setRelationField` (→ generated `gen.events..` via `${JavaTask}`+`handler`), `call` (→ `${JSTask}` TS handler), and the bare fallback (→ `custom.` + a scaffolded stub). Why `flowable:class` and not `${JavaTask}`: `DirigibleJavaCallDelegate` (`${JavaTask}`) reads only its `handler` field, so it can't pass parameters; `flowable:class` (resolved through `BpmFlowableConfig`'s `ClientAwareClassLoader`, which consults the client class loader) lets Flowable **inject** the declared `fields` as delegate fields — so one *general* delegate serves many steps. (Since #7058 the delegate's own **collaborators** are wired by the client bean container on BOTH paths — a constructor or `@Inject` field over the project's `@Component`s — so `fields:` is about per-step *parameters*, not about reaching services; a delegate must still never be a `@Component` itself.) `BpmnIntentGenerator.appendServiceTask` branches to `appendDelegateServiceTask` (emitting `flowable:class` + one `` per `fields` entry, in declaration order); `ServiceTaskHandlerGenerator` **skips** delegate steps (no `custom/` stub — the developer owns the class, which may live in *another* published project since client Java compiles in one cross-project batch). Parser: `delegate` is serviceTask-only, mutually exclusive with `setField`/`setRelationField`/`call`, and `fields` values must be scalars. **Worked example:** `sample-intent-multi-model` — `sales-invoices`' `generateNumber` step (after `issue`) binds `custom.sales_invoices.DocumentNumberGeneratorDelegate` with `fields: { type: "Sales Invoice" }`. **The delegate lives in the document's OWN project** (sales-invoices), because it must load/save the invoice through the generated `SalesInvoiceRepository` (validations, events, i18n — NEVER the generic `Store`; see the engine-java guide's repository-only rule): it reads the record id from the `Id` process variable, `findById`s it, asks the reusable `custom.numbers.DocumentNumberGenerator` (in the `numbers` project — a codbex-number-generator port over its own `NumberRepository`, entity-agnostic) for the next formatted number of the injected `type`, sets `entity.Number`, and persists via `updateWithoutEvent` (workflow write). Only the entity-agnostic generator is shared; the entity-touching delegate is per-project. Covered by `IntentEngineIT.delegate_service_task_binds_a_client_java_delegate_via_flowable_class_with_injected_fields`.
+- **`delegate` service task (call a reusable, author-named client `JavaDelegate`).** `args: { delegate: , fields: { : , ... } }` binds a serviceTask to a hand-written client delegate via **`flowable:class`** (NOT the `${JavaTask}` dispatcher). This is the *fourth* service-task shape alongside `setField`/`setRelationField` (→ generated `gen.events..` via `${JavaTask}`+`handler`), `call` (→ `${JSTask}` TS handler), and the bare fallback (→ `custom.` + a scaffolded stub). Why `flowable:class` and not `${JavaTask}`: `DirigibleJavaCallDelegate` (`${JavaTask}`) reads only its `handler` field, so it can't pass parameters; `flowable:class` (resolved through `BpmFlowableConfig`'s `ClientAwareClassLoader`, which consults the client class loader) lets Flowable **inject** the declared `fields` as delegate fields — so one *general* delegate serves many steps. (Since #7058 the delegate's own **collaborators** are wired by the client bean container on BOTH paths — a constructor or `@Inject` field over the project's `@Component`s — so `fields:` is about per-step *parameters*, not about reaching services; a delegate must still never be a `@Component` itself. #7222 wires the same seam into Flowable's separate `ListenerFactory` registration, so a `flowable:class` **execution or task listener** is injected too - a listener is not a step, so it gets no `onError` conversion.) `BpmnIntentGenerator.appendServiceTask` branches to `appendDelegateServiceTask` (emitting `flowable:class` + one `` per `fields` entry, in declaration order); `ServiceTaskHandlerGenerator` **skips** delegate steps (no `custom/` stub — the developer owns the class, which may live in *another* published project since client Java compiles in one cross-project batch). Parser: `delegate` is serviceTask-only, mutually exclusive with `setField`/`setRelationField`/`call`, and `fields` values must be scalars. **Worked example:** `sample-intent-multi-model` — `sales-invoices`' `generateNumber` step (after `issue`) binds `custom.sales_invoices.DocumentNumberGeneratorDelegate` with `fields: { type: "Sales Invoice" }`. **The delegate lives in the document's OWN project** (sales-invoices), because it must load/save the invoice through the generated `SalesInvoiceRepository` (validations, events, i18n — NEVER the generic `Store`; see the engine-java guide's repository-only rule): it reads the record id from the `Id` process variable, `findById`s it, asks the reusable `custom.numbers.DocumentNumberGenerator` (in the `numbers` project — a codbex-number-generator port over its own `NumberRepository`, entity-agnostic) for the next formatted number of the injected `type`, sets `entity.Number`, and persists via `updateWithoutEvent` (workflow write). Only the entity-agnostic generator is shared; the entity-touching delegate is per-project. Covered by `IntentEngineIT.delegate_service_task_binds_a_client_java_delegate_via_flowable_class_with_injected_fields`.
- **`wait` step = park the process on an entity event (message catch + correlation glue).** `- { name: awaitReply, kind: wait, args: { onCreate: CaseMessage, via: case, when: "internal == 0", next: work } }` — the process parks on a BPMN `` + `intermediateCatchEvent` (message name = `` PascalCase) until the named entity event resumes it. Exactly one of `onCreate`/`onUpdate` (never `onDelete` — a deleted record cannot resume a wait) naming a declared entity; `via:` is the **event** entity's to-one relation walking to the trigger entity (required when they differ, forbidden when the event entity IS the trigger entity; same-model only); `when:` is the single-comparison guard grammar over the EVENT record (`NotificationSupport.guard`). Glue: `ProcessWaitSupport` → the `waits` collection in `.glue` → `Wait.java.template` — a self-describing `MessageHandler` on the event entity's topic that resolves the ProcessId-carrying record (via the FK, or `findById` of the event record itself in the direct case — re-loaded because the payload snapshot may predate the trigger's ProcessId write-back), and calls `Process.correlateMessageEvent(processId, message, Map.of())` wrapped fail-soft (an instance not parked on the message is a no-op, never an error). The process MUST have a trigger entity — its `ProcessIds` stamp for THIS process is the correlation contract (`ProcessStamps.idFor`; the single `ProcessId` holds whichever flow started last, so correlating on it resumes a stranger's wait once a record carries several - #6862). Requested/shaped in upstream discussion #6327.
- **`timeout:` / `expire:` on a userTask = boundary timers (reminders, SLAs, date-driven expiry).** Two optional map args on a user task, each with a `then:` routed like a decision branch (declared step or `end`; route the main flow around the branch steps with `next`, as with decision branches): `timeout: { after: P3D, then: remind }` emits a **non-cancelling** `boundaryEvent` (`cancelActivity="false"`) with a literal `timeDuration` — the task stays claimable while the reminder/escalation branch runs; `expire: { until: validUntil, then: markExpired }` emits a **cancelling** one (`cancelActivity="true"`) with `timeDate` bound to the `${__ExpireDate}` process variable — the task is withdrawn and the flow continues at `then`. The expire date is **re-read at task entry**: `ProcessTimerSupport` derives a loader `JavaDelegate` (the `timerLoaders` glue collection → `TimerLoader.java.template`) that `augmentWithResolvers` inserts right before the task, loading the trigger entity by id and publishing the field as a `java.util.Date` (Flowable arms the timer from the object directly — no string parsing). A `date` field expires at the start of the day AFTER it (the field names the last valid day); a `timestamp` at its instant; a `null` field or missing record arms 9999-12-31 so the timer never effectively fires. Parser: userTask-only, `after:` must parse as an ISO-8601 `Duration`/`Period`, `until:` must be a `date`/`timestamp` field of the trigger entity, `then:` validated like a decision target. The boundary shapes ride the host task's bottom edge in the DI; the layered layout ranks each boundary branch through a pseudo-flow from the HOST task. The Flowable async executor is already active (`BpmFlowableConfig.setAsyncExecutorActivate(true)`), so timer jobs fire with no new runtime. Caveat: a loop/jump straight into the user task (a reject-loop `else: approve`) re-enters the task WITHOUT re-running the inserted loader — the expire variable keeps its previous value for that pass. Requested/shaped in upstream discussion #6328.
- **`abortOn:` on a process = cancel the in-flight instance when the document transitions into a terminal status (BPM events wave 2).** `abortOn: { status: [4, 5], then: markVoid }` — a `-transitioned` of the trigger entity into any listed EntityStatus seed id cancels the whole running instance (pending user tasks, parked waits, armed boundary timers). Emitted as an **interrupting message event subprocess** (`` with an `isInterrupting="true"` message start on `Abort` → optional cleanup serviceTask → `terminateEventDefinition`), NOT by wrapping the main flow — chosen over the proposal's subProcess-wrap sketch because it needs no restructuring of the flat step layout and still kills everything in scope. Glue: `ProcessAbortSupport` → the `aborts` collection in `.glue` → `Abort.java.template` — a `MessageHandler` on the entity's `-transitioned` topic (the channel transitions/setters already publish) that matches the status list (`entity. == || …`) and correlates `Abort` on the instance THIS process stamped in `ProcessIds` (`ProcessStamps.idFor`, falling back to `ProcessId` for records stamped before that column existed), fail-soft. `then:` omitted or `end` = terminate; a declared `serviceTask` cleanup (setField/setRelationField) is **abort-only** — `BpmnIntentGenerator` filters it out of the main linear chain (`steps.removeIf`) and re-emits it inside the event subprocess (its setter glue is still generated by `SetFieldSupport`). Parser (`validateAbortOn`): integer `status` (scalar or list), trigger entity with a `function: EntityStatus` relation, `then` = `end`/a setField-setRelationField serviceTask that is NOT explicitly routed to from the main flow (`next`/`then`/`else`). DI: the event subprocess is a fixed-placement container box below the main lane (BPMN-2.0 expanded-subprocess children carry absolute plane coordinates). Requested/shaped in upstream discussion #6340. **Consumers:** the orphaned-Inbox-task hole (cancel a SalesOrder mid-confirm), and the structural replacement for a cancelling `expire:` guard (kf quotations drops its `custom/` guard delegate). Caveat: `then` cleanup is one serviceTask (a multi-step cleanup chain is future work).
diff --git a/components/engine/engine-java/CLAUDE.md b/components/engine/engine-java/CLAUDE.md
index babefddab01..f458223adaf 100644
--- a/components/engine/engine-java/CLAUDE.md
+++ b/components/engine/engine-java/CLAUDE.md
@@ -97,8 +97,8 @@ One Spring-singleton container, rebuilt per `ClientClassLoader` generation.
use the platform-internal `BeanProvider` (that's core-only; `JavaRepository.store()` uses it because
it is platform code).
- **`createUnmanaged(Class)` wires a client class the container does NOT own** — today exactly one
- thing: a client `JavaDelegate`, which Flowable instantiates itself and which therefore never becomes
- a bean (#7058). Same rules as a `@Component` (constructor / field `@Inject` / collection, by type
+ family: a client class **Flowable** instantiates itself and which therefore never becomes a bean —
+ a `JavaDelegate` (#7058) and a `flowable:class` execution / task listener (#7222). Same rules as a `@Component` (constructor / field `@Inject` / collection, by type
with the parameter or field name disambiguating, `@PostConstruct`), resolved against the **live**
singletons — it constructs no bean, so a cycle is impossible on this path — and the instance is
**not registered** (it never appears in `get` / `getAll` / `instanceOf`, and a failure here is not a
@@ -245,7 +245,7 @@ then `JavaClassRegistry` + `JavaHandler.handle`. A `JavaHandler` that is also `@
as the container-built (injected) singleton; a plain `JavaHandler` (no `@Component`) is instantiated per
request via its no-arg constructor.
-## `JavaDelegate` (BPMN service tasks) — injected, but never a bean
+## `JavaDelegate` and BPMN listeners — injected, but never a bean
A client `JavaDelegate` is created by **Flowable**, not by the container, so it is not a `@Component`
and must not be annotated as one: that would build a fully-injected singleton the engine never runs,
@@ -261,6 +261,23 @@ through `ClientBeanFactory.createUnmanaged` (see the container section):
new generation.
- **`${JavaTask}` + a `handler` field** — `DirigibleJavaCallDelegate`, fresh per execution.
+**The same is true of a `flowable:class` execution or task listener, and it takes a SECOND engine
+registration (#7222).** A listener is not created by the activity-behaviour factory: Flowable's
+`ProcessEngineConfigurationImpl.initListenerFactory` builds its own `DefaultListenerFactory` carrying a
+stock `DefaultClassDelegateFactory`, and `createClassDelegateExecutionListener` /
+`createClassDelegateTaskListener` call **that**. So configuring only
+`setActivityBehaviorFactory(new ResilientActivityBehaviorFactory(new ResilientClassDelegateFactory()))`
+left the listener path on plain reflection — a constructor collaborator failed to instantiate and an
+`@Inject` field silently read `null`, the #7058 symptom one artefact type over. `BpmFlowableConfig`
+therefore also does `setListenerFactory(new DefaultListenerFactory(classDelegateFactory))` with the
+**same** factory instance (the engine keeps a pre-set listener factory and only injects the expression
+manager into it), so both listener kinds come out as `ResilientClassDelegate`s and share
+`instantiateDelegate`. What a listener does **not** get is the intent step resilience: `execute` is the
+service-task entry point and Flowable's `notify` paths never reach it, so a listener failure keeps the
+stock behaviour — a listener is not a step, and nothing in the DSL emits one. Pinned by
+`ResilientListenerFactoryTest` (both listener kinds, plus the defect case) and
+`JavaDelegateInjectionIT.both_listener_kinds_wire_their_collaborators`.
+
A delegate annotated `@Component` is reported at **publish** as a Problems entry on its source
(`ComponentContainer.wiringWarnings()`), not as a WARN per step execution — see the container section.
diff --git a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/JavaDelegateInjectionIT.java b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/JavaDelegateInjectionIT.java
index 0b5f654d5e1..63ff35feb70 100644
--- a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/JavaDelegateInjectionIT.java
+++ b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/JavaDelegateInjectionIT.java
@@ -41,10 +41,11 @@
* delegate paths in one process - {@code flowable:class} and
* {@code flowable:delegateExpression="${JavaTask}"} - with constructor, field and collection
* injection, a {@code @PostConstruct}-only delegate, and the two delegates that declare no
- * injection point at all (which must keep being built exactly as before). Two further processes pin
- * the behaviour that is easiest to regress: an unsatisfiable dependency must fail the STEP and not
- * the deployment, and a recompiled collaborator must reach the delegate Flowable caches on the
- * parsed activity.
+ * injection point at all (which must keep being built exactly as before). Three further processes
+ * pin the behaviour that is easiest to regress: an unsatisfiable dependency must fail the STEP and
+ * not the deployment, a recompiled collaborator must reach the delegate Flowable caches on the
+ * parsed activity, and a {@code flowable:class} execution or task listener - created through a
+ * different engine registration from the service-task one - must reach the same seam (#7222).
*/
// One Dirigible boot for the whole class: the fixture is deployed once and every method starts its
// own process instance, so the per-method context reset inherited from IntegrationTest would only
@@ -56,6 +57,7 @@ class JavaDelegateInjectionIT extends IntegrationTest {
private static final String INJECTION_PROCESS = "java-delegate-injection";
private static final String UNSATISFIED_PROCESS = "java-delegate-unsatisfied";
private static final String VERSION_PROCESS = "java-delegate-version";
+ private static final String LISTENER_PROCESS = "java-delegate-listener-injection";
private static final String VERSION_PROVIDER_PATH =
IRepositoryStructure.PATH_REGISTRY_PUBLIC + "/" + PROJECT + "/delegateinjection/VersionProvider.java";
@@ -134,6 +136,17 @@ void a_recompiled_collaborator_reaches_the_cached_delegate() {
assertHistoricVariable(startProcess(VERSION_PROCESS), "version", "v2");
}
+ @Test
+ void both_listener_kinds_wire_their_collaborators() {
+ String instanceId = startProcess(LISTENER_PROCESS);
+
+ // flowable:executionListener, single constructor taking the @Component collaborator. Before
+ // #7222 the listener factory instantiated it reflectively, so this one could not even be
+ // built - and the @Inject task listener below ran with its field reading null.
+ assertRuntimeVariable(instanceId, "executionListenerRate", "42");
+ assertRuntimeVariable(instanceId, "taskListenerRate", "42");
+ }
+
private String startProcess(String processDefinitionKey) {
String body = "{\"processDefinitionKey\":\"" + processDefinitionKey + "\",\"businessKey\":\"" + processDefinitionKey
+ "\",\"parameters\":\"{}\"}";
@@ -157,12 +170,27 @@ private void assertHistoricVariable(String processInstanceId, String name, Strin
ASSERTION_TIMEOUT_SECONDS);
}
+ /**
+ * The instance waits at its user task, so the listeners' writes are read from the RUNTIME set. Note
+ * the key: the runtime endpoint serves Flowable's own variable entities, whose property is
+ * {@code name} - {@code variableName} is the HISTORIC endpoint's spelling.
+ */
+ private void assertRuntimeVariable(String processInstanceId, String name, String expectedValue) {
+ restAssuredExecutor.execute(() -> given().when()
+ .get("/services/bpm/bpm-processes/instance/" + processInstanceId + "/variables")
+ .then()
+ .statusCode(200)
+ .body("name", hasItem(name))
+ .body("find { it.name == '" + name + "' }.value", equalTo(expectedValue)),
+ ASSERTION_TIMEOUT_SECONDS);
+ }
+
private void assertNoRuntimeVariable(String processInstanceId, String name) {
restAssuredExecutor.execute(() -> given().when()
.get("/services/bpm/bpm-processes/instance/" + processInstanceId + "/variables")
.then()
.statusCode(200)
- .body("variableName", not(hasItem(name))));
+ .body("name", not(hasItem(name))));
}
private void write(String path, String content) {
diff --git a/tests/tests-integrations/src/main/resources/JavaDelegateInjectionIT/delegateinjection/CtorInjectedExecutionListener.java b/tests/tests-integrations/src/main/resources/JavaDelegateInjectionIT/delegateinjection/CtorInjectedExecutionListener.java
new file mode 100644
index 00000000000..dc2d381d3c4
--- /dev/null
+++ b/tests/tests-integrations/src/main/resources/JavaDelegateInjectionIT/delegateinjection/CtorInjectedExecutionListener.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright (c) 2010-2026 Eclipse Dirigible contributors
+ *
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v2.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/legal/epl-v20.html
+ *
+ * SPDX-FileCopyrightText: Eclipse Dirigible contributors
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package delegateinjection;
+
+import org.flowable.engine.delegate.DelegateExecution;
+import org.flowable.engine.delegate.ExecutionListener;
+
+/**
+ * Constructor injection in a {@code flowable:executionListener class="..."}. Like a delegate, a
+ * listener is created by the engine and is therefore never a container-owned bean - and until
+ * #7222 the listener path did not reach the container at all, so this class could not even be
+ * instantiated (no no-arg constructor).
+ */
+public class CtorInjectedExecutionListener implements ExecutionListener {
+
+ private final RateProvider rates;
+
+ public CtorInjectedExecutionListener(RateProvider rates) {
+ this.rates = rates;
+ }
+
+ @Override
+ public void notify(DelegateExecution execution) {
+ execution.setVariable("executionListenerRate", rates.rate());
+ }
+}
diff --git a/tests/tests-integrations/src/main/resources/JavaDelegateInjectionIT/delegateinjection/FieldInjectedTaskListener.java b/tests/tests-integrations/src/main/resources/JavaDelegateInjectionIT/delegateinjection/FieldInjectedTaskListener.java
new file mode 100644
index 00000000000..abe1a1d1eb5
--- /dev/null
+++ b/tests/tests-integrations/src/main/resources/JavaDelegateInjectionIT/delegateinjection/FieldInjectedTaskListener.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2010-2026 Eclipse Dirigible contributors
+ *
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v2.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/legal/epl-v20.html
+ *
+ * SPDX-FileCopyrightText: Eclipse Dirigible contributors
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package delegateinjection;
+
+import org.eclipse.dirigible.sdk.component.Inject;
+import org.flowable.task.service.delegate.DelegateTask;
+import org.flowable.task.service.delegate.TaskListener;
+
+/**
+ * Field injection in a {@code flowable:taskListener class="..."} - the failure mode #7222 is about:
+ * the class instantiates either way, and before the fix the injected field simply read
+ * {@code null}.
+ */
+public class FieldInjectedTaskListener implements TaskListener {
+
+ @Inject
+ private RateProvider rates;
+
+ @Override
+ public void notify(DelegateTask delegateTask) {
+ delegateTask.setVariable("taskListenerRate", rates.rate());
+ }
+}
diff --git a/tests/tests-integrations/src/main/resources/JavaDelegateInjectionIT/listeners.bpmn b/tests/tests-integrations/src/main/resources/JavaDelegateInjectionIT/listeners.bpmn
new file mode 100644
index 00000000000..8061d8b7509
--- /dev/null
+++ b/tests/tests-integrations/src/main/resources/JavaDelegateInjectionIT/listeners.bpmn
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+