diff --git a/backend/apps/dev/src/main/resources/config/building-block/case-notification/1-0-0/bpmn/case-notification-process.bpmn b/backend/apps/dev/src/main/resources/config/building-block/case-notification/1-0-0/bpmn/case-notification-process.bpmn
new file mode 100644
index 0000000000..6ad8c1281a
--- /dev/null
+++ b/backend/apps/dev/src/main/resources/config/building-block/case-notification/1-0-0/bpmn/case-notification-process.bpmn
@@ -0,0 +1,46 @@
+
+
+
+
+ Flow_1
+
+
+
+
+ Flow_1
+ Flow_2
+
+
+
+ Flow_2
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/apps/dev/src/main/resources/config/building-block/case-notification/1-0-0/building-block/building-block-definition-main-process-definition.json b/backend/apps/dev/src/main/resources/config/building-block/case-notification/1-0-0/building-block/building-block-definition-main-process-definition.json
new file mode 100644
index 0000000000..0c2c46a95a
--- /dev/null
+++ b/backend/apps/dev/src/main/resources/config/building-block/case-notification/1-0-0/building-block/building-block-definition-main-process-definition.json
@@ -0,0 +1,3 @@
+{
+ "processDefinitionKey": "case-notification-process"
+}
diff --git a/backend/apps/dev/src/main/resources/config/building-block/case-notification/1-0-0/building-block/definition/case-notification.building-block-definition.json b/backend/apps/dev/src/main/resources/config/building-block/case-notification/1-0-0/building-block/definition/case-notification.building-block-definition.json
new file mode 100644
index 0000000000..c807adeca2
--- /dev/null
+++ b/backend/apps/dev/src/main/resources/config/building-block/case-notification/1-0-0/building-block/definition/case-notification.building-block-definition.json
@@ -0,0 +1,7 @@
+{
+ "key": "case-notification",
+ "name": "Case notification",
+ "description": "Started by the 'case-notification-requested' message start event. Demonstrates caseCorrelationService.sendStartMessageToCase: this building block has no start form and cannot be started from the case Start menu.",
+ "versionTag": "1.0.0",
+ "final": false
+}
diff --git a/backend/apps/dev/src/main/resources/config/building-block/case-notification/1-0-0/document/definition/case-notification.schema.document-definition.json b/backend/apps/dev/src/main/resources/config/building-block/case-notification/1-0-0/document/definition/case-notification.schema.document-definition.json
new file mode 100644
index 0000000000..099fcd632c
--- /dev/null
+++ b/backend/apps/dev/src/main/resources/config/building-block/case-notification/1-0-0/document/definition/case-notification.schema.document-definition.json
@@ -0,0 +1,16 @@
+{
+ "$id": "case-notification.schema",
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "title": "Case notification",
+ "type": "object",
+ "properties": {
+ "subject": {
+ "type": "string",
+ "description": "Subject copied from the case by the input mapping of the case definition link"
+ },
+ "handledBy": {
+ "type": "string",
+ "description": "Who acknowledged the notification"
+ }
+ }
+}
diff --git a/backend/apps/dev/src/main/resources/config/building-block/case-notification/1-0-0/form/case-notification-acknowledge.form.json b/backend/apps/dev/src/main/resources/config/building-block/case-notification/1-0-0/form/case-notification-acknowledge.form.json
new file mode 100644
index 0000000000..ffde4c1401
--- /dev/null
+++ b/backend/apps/dev/src/main/resources/config/building-block/case-notification/1-0-0/form/case-notification-acknowledge.form.json
@@ -0,0 +1,41 @@
+{
+ "display": "form",
+ "components": [
+ {
+ "label": "HTML",
+ "content": "This building block was started by a message. The subject below came from the case through the input mapping of the case definition link.",
+ "refreshOnChange": false,
+ "key": "html",
+ "type": "htmlelement",
+ "input": false,
+ "tableView": false
+ },
+ {
+ "label": "Subject",
+ "disabled": true,
+ "tableView": true,
+ "key": "subject",
+ "type": "textfield",
+ "input": true
+ },
+ {
+ "label": "Handled by",
+ "placeholder": "Who picked this up?",
+ "tableView": true,
+ "validate": {
+ "required": true
+ },
+ "key": "handledBy",
+ "type": "textfield",
+ "input": true
+ },
+ {
+ "type": "button",
+ "label": "Acknowledge",
+ "key": "submit",
+ "disableOnInvalid": true,
+ "input": true,
+ "tableView": false
+ }
+ ]
+}
diff --git a/backend/apps/dev/src/main/resources/config/building-block/case-notification/1-0-0/process-link/case-notification-process.process-link.json b/backend/apps/dev/src/main/resources/config/building-block/case-notification/1-0-0/process-link/case-notification-process.process-link.json
new file mode 100644
index 0000000000..19b6437b0b
--- /dev/null
+++ b/backend/apps/dev/src/main/resources/config/building-block/case-notification/1-0-0/process-link/case-notification-process.process-link.json
@@ -0,0 +1,8 @@
+[
+ {
+ "activityId": "AcknowledgeNotificationTask",
+ "activityType": "bpmn:UserTask:create",
+ "processLinkType": "form",
+ "formDefinitionName": "case-notification-acknowledge"
+ }
+]
diff --git a/backend/apps/dev/src/main/resources/config/building-block/case-review/1-0-0/bpmn/case-review-process.bpmn b/backend/apps/dev/src/main/resources/config/building-block/case-review/1-0-0/bpmn/case-review-process.bpmn
new file mode 100644
index 0000000000..a0d2fe8f68
--- /dev/null
+++ b/backend/apps/dev/src/main/resources/config/building-block/case-review/1-0-0/bpmn/case-review-process.bpmn
@@ -0,0 +1,61 @@
+
+
+
+
+ Flow_1
+
+
+
+ Flow_1
+ Flow_2
+
+
+
+
+ Flow_2
+ Flow_3
+
+
+
+ Flow_3
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/apps/dev/src/main/resources/config/building-block/case-review/1-0-0/building-block/building-block-definition-main-process-definition.json b/backend/apps/dev/src/main/resources/config/building-block/case-review/1-0-0/building-block/building-block-definition-main-process-definition.json
new file mode 100644
index 0000000000..929aa6bf68
--- /dev/null
+++ b/backend/apps/dev/src/main/resources/config/building-block/case-review/1-0-0/building-block/building-block-definition-main-process-definition.json
@@ -0,0 +1,3 @@
+{
+ "processDefinitionKey": "case-review-process"
+}
diff --git a/backend/apps/dev/src/main/resources/config/building-block/case-review/1-0-0/building-block/definition/case-review.building-block-definition.json b/backend/apps/dev/src/main/resources/config/building-block/case-review/1-0-0/building-block/definition/case-review.building-block-definition.json
new file mode 100644
index 0000000000..8a13eb3ca4
--- /dev/null
+++ b/backend/apps/dev/src/main/resources/config/building-block/case-review/1-0-0/building-block/definition/case-review.building-block-definition.json
@@ -0,0 +1,7 @@
+{
+ "key": "case-review",
+ "name": "Case review",
+ "description": "Waits for the 'review-approved' message. Demonstrates that a message sent to the case with caseCorrelationService.sendCatchEventMessageToCase reaches a running building block.",
+ "versionTag": "1.0.0",
+ "final": false
+}
diff --git a/backend/apps/dev/src/main/resources/config/building-block/case-review/1-0-0/document/definition/case-review.schema.document-definition.json b/backend/apps/dev/src/main/resources/config/building-block/case-review/1-0-0/document/definition/case-review.schema.document-definition.json
new file mode 100644
index 0000000000..fde2ec2cbe
--- /dev/null
+++ b/backend/apps/dev/src/main/resources/config/building-block/case-review/1-0-0/document/definition/case-review.schema.document-definition.json
@@ -0,0 +1,20 @@
+{
+ "$id": "case-review.schema",
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "title": "Case review",
+ "type": "object",
+ "properties": {
+ "subject": {
+ "type": "string",
+ "description": "Subject copied from the case by the input mapping"
+ },
+ "reviewer": {
+ "type": "string",
+ "description": "Reviewer entered on the start form"
+ },
+ "approvedBy": {
+ "type": "string",
+ "description": "Filled from the variable that came along with the 'review-approved' message"
+ }
+ }
+}
diff --git a/backend/apps/dev/src/main/resources/config/building-block/case-review/1-0-0/form/start-form-case-review.form.json b/backend/apps/dev/src/main/resources/config/building-block/case-review/1-0-0/form/start-form-case-review.form.json
new file mode 100644
index 0000000000..8165e77d1e
--- /dev/null
+++ b/backend/apps/dev/src/main/resources/config/building-block/case-review/1-0-0/form/start-form-case-review.form.json
@@ -0,0 +1,33 @@
+{
+ "display": "form",
+ "components": [
+ {
+ "label": "HTML",
+ "content": "This building block waits for the review-approved message. Start it, then run the Send review-approved message action on the case to let it continue.",
+ "refreshOnChange": false,
+ "key": "html",
+ "type": "htmlelement",
+ "input": false,
+ "tableView": false
+ },
+ {
+ "label": "Reviewer",
+ "placeholder": "Who is going to review this?",
+ "tableView": true,
+ "validate": {
+ "required": true
+ },
+ "key": "reviewer",
+ "type": "textfield",
+ "input": true
+ },
+ {
+ "type": "button",
+ "label": "Start review",
+ "key": "submit",
+ "disableOnInvalid": true,
+ "input": true,
+ "tableView": false
+ }
+ ]
+}
diff --git a/backend/apps/dev/src/main/resources/config/building-block/case-review/1-0-0/process-link/case-review-process.process-link.json b/backend/apps/dev/src/main/resources/config/building-block/case-review/1-0-0/process-link/case-review-process.process-link.json
new file mode 100644
index 0000000000..13a6cbd3a5
--- /dev/null
+++ b/backend/apps/dev/src/main/resources/config/building-block/case-review/1-0-0/process-link/case-review-process.process-link.json
@@ -0,0 +1,8 @@
+[
+ {
+ "activityId": "StartEvent",
+ "activityType": "bpmn:StartEvent:start",
+ "processLinkType": "form",
+ "formDefinitionName": "start-form-case-review"
+ }
+]
diff --git a/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/bpmn/case-messaging.bpmn b/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/bpmn/case-messaging.bpmn
new file mode 100644
index 0000000000..25064e3e79
--- /dev/null
+++ b/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/bpmn/case-messaging.bpmn
@@ -0,0 +1,29 @@
+
+
+
+
+ Flow_1
+
+
+
+ Flow_1
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/bpmn/request-case-notification.bpmn b/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/bpmn/request-case-notification.bpmn
new file mode 100644
index 0000000000..9e6ef242c7
--- /dev/null
+++ b/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/bpmn/request-case-notification.bpmn
@@ -0,0 +1,42 @@
+
+
+
+
+ Flow_1
+
+
+
+ Flow_1
+ Flow_2
+
+
+
+
+ Flow_2
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/bpmn/send-review-approved.bpmn b/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/bpmn/send-review-approved.bpmn
new file mode 100644
index 0000000000..04ba781028
--- /dev/null
+++ b/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/bpmn/send-review-approved.bpmn
@@ -0,0 +1,42 @@
+
+
+
+
+ Flow_1
+
+
+
+ Flow_1
+ Flow_2
+
+
+
+
+ Flow_2
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/building-block-link/case-messaging.case-building-block-links.json b/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/building-block-link/case-messaging.case-building-block-links.json
new file mode 100644
index 0000000000..082ad9f67b
--- /dev/null
+++ b/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/building-block-link/case-messaging.case-building-block-links.json
@@ -0,0 +1,34 @@
+[
+ {
+ "buildingBlockDefinitionKey": "case-review",
+ "buildingBlockDefinitionVersionTag": "1.0.0",
+ "inputMappings": [
+ {
+ "source": "doc:/subject",
+ "target": "doc:/subject"
+ }
+ ],
+ "outputMappings": [
+ {
+ "source": "doc:/approvedBy",
+ "target": "doc:/reviewedBy"
+ }
+ ]
+ },
+ {
+ "buildingBlockDefinitionKey": "case-notification",
+ "buildingBlockDefinitionVersionTag": "1.0.0",
+ "inputMappings": [
+ {
+ "source": "doc:/subject",
+ "target": "doc:/subject"
+ }
+ ],
+ "outputMappings": [
+ {
+ "source": "doc:/handledBy",
+ "target": "doc:/lastNotification"
+ }
+ ]
+ }
+]
diff --git a/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/case/definition/case-messaging.case-definition.json b/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/case/definition/case-messaging.case-definition.json
new file mode 100644
index 0000000000..ac5099083b
--- /dev/null
+++ b/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/case/definition/case-messaging.case-definition.json
@@ -0,0 +1,8 @@
+{
+ "key": "case-messaging",
+ "name": "Case messaging",
+ "versionTag": "1.0.0",
+ "final": false,
+ "canHaveAssignee": true,
+ "autoAssignTasks": false
+}
diff --git a/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/case/tab/case-messaging.case-tab.json b/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/case/tab/case-messaging.case-tab.json
new file mode 100644
index 0000000000..1e9ef16121
--- /dev/null
+++ b/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/case/tab/case-messaging.case-tab.json
@@ -0,0 +1,15 @@
+[
+ {
+ "name": "Summary",
+ "key": "summary",
+ "type": "standard",
+ "contentKey": "summary"
+ },
+ {
+ "name": "Progress",
+ "key": "progress",
+ "type": "standard",
+ "contentKey": "progress",
+ "showTasks": true
+ }
+]
diff --git a/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/document/definition/case-messaging.schema.document-definition.json b/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/document/definition/case-messaging.schema.document-definition.json
new file mode 100644
index 0000000000..e23cb4d55f
--- /dev/null
+++ b/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/document/definition/case-messaging.schema.document-definition.json
@@ -0,0 +1,21 @@
+{
+ "$id": "case-messaging.schema",
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "title": "Case messaging",
+ "type": "object",
+ "properties": {
+ "subject": {
+ "type": "string",
+ "description": "Subject of the case, passed on to the building blocks"
+ },
+ "reviewedBy": {
+ "type": "string",
+ "description": "Written back by the Case review building block after it received the message"
+ },
+ "lastNotification": {
+ "type": "string",
+ "description": "Written back by the Case notification building block that was started by a message"
+ }
+ },
+ "required": ["subject"]
+}
diff --git a/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/form/start-form-case-messaging.form.json b/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/form/start-form-case-messaging.form.json
new file mode 100644
index 0000000000..1dce1956c8
--- /dev/null
+++ b/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/form/start-form-case-messaging.form.json
@@ -0,0 +1,33 @@
+{
+ "display": "form",
+ "components": [
+ {
+ "label": "HTML",
+ "content": "Demo case for case-scoped messaging. After starting: use Start → Case review to get a building block waiting for a message, then Start → Send review-approved message to let it continue. Start → Request case notification starts a building block that has no start form at all.",
+ "refreshOnChange": false,
+ "key": "html",
+ "type": "htmlelement",
+ "input": false,
+ "tableView": false
+ },
+ {
+ "label": "Subject",
+ "placeholder": "What is this case about?",
+ "tableView": true,
+ "validate": {
+ "required": true
+ },
+ "key": "subject",
+ "type": "textfield",
+ "input": true
+ },
+ {
+ "type": "button",
+ "label": "Start",
+ "key": "submit",
+ "disableOnInvalid": true,
+ "input": true,
+ "tableView": false
+ }
+ ]
+}
diff --git a/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/form/start-form-request-case-notification.form.json b/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/form/start-form-request-case-notification.form.json
new file mode 100644
index 0000000000..13c659e118
--- /dev/null
+++ b/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/form/start-form-request-case-notification.form.json
@@ -0,0 +1,22 @@
+{
+ "display": "form",
+ "components": [
+ {
+ "label": "HTML",
+ "content": "Sends the case-notification-requested message to this case. The Case notification building block is linked to this case definition and its main process starts with a message start event, so a new instance is created — even though it has no start form and is not in this Start menu.",
+ "refreshOnChange": false,
+ "key": "html",
+ "type": "htmlelement",
+ "input": false,
+ "tableView": false
+ },
+ {
+ "type": "button",
+ "label": "Request notification",
+ "key": "submit",
+ "disableOnInvalid": true,
+ "input": true,
+ "tableView": false
+ }
+ ]
+}
diff --git a/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/form/start-form-send-review-approved.form.json b/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/form/start-form-send-review-approved.form.json
new file mode 100644
index 0000000000..9b785f8a36
--- /dev/null
+++ b/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/form/start-form-send-review-approved.form.json
@@ -0,0 +1,22 @@
+{
+ "display": "form",
+ "components": [
+ {
+ "label": "HTML",
+ "content": "Sends the review-approved message to every running process of this case. A Case review building block that is waiting for it continues, writes the approvedBy variable that travelled with the message into its own document, and syncs that value back to reviewedBy on this case.",
+ "refreshOnChange": false,
+ "key": "html",
+ "type": "htmlelement",
+ "input": false,
+ "tableView": false
+ },
+ {
+ "type": "button",
+ "label": "Send message",
+ "key": "submit",
+ "disableOnInvalid": true,
+ "input": true,
+ "tableView": false
+ }
+ ]
+}
diff --git a/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/process-document-link/case-messaging.process-document-link.json b/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/process-document-link/case-messaging.process-document-link.json
new file mode 100644
index 0000000000..405e98fb7b
--- /dev/null
+++ b/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/process-document-link/case-messaging.process-document-link.json
@@ -0,0 +1,17 @@
+[
+ {
+ "processDefinitionKey": "case-messaging",
+ "canInitializeDocument": true,
+ "startableByUser": true
+ },
+ {
+ "processDefinitionKey": "send-review-approved",
+ "canInitializeDocument": false,
+ "startableByUser": true
+ },
+ {
+ "processDefinitionKey": "request-case-notification",
+ "canInitializeDocument": false,
+ "startableByUser": true
+ }
+]
diff --git a/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/process-link/case-messaging.process-link.json b/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/process-link/case-messaging.process-link.json
new file mode 100644
index 0000000000..1eabcfb6cc
--- /dev/null
+++ b/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/process-link/case-messaging.process-link.json
@@ -0,0 +1,8 @@
+[
+ {
+ "activityId": "start-event",
+ "activityType": "bpmn:StartEvent:start",
+ "processLinkType": "form",
+ "formDefinitionName": "start-form-case-messaging"
+ }
+]
diff --git a/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/process-link/request-case-notification.process-link.json b/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/process-link/request-case-notification.process-link.json
new file mode 100644
index 0000000000..df3ea76822
--- /dev/null
+++ b/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/process-link/request-case-notification.process-link.json
@@ -0,0 +1,8 @@
+[
+ {
+ "activityId": "start-event",
+ "activityType": "bpmn:StartEvent:start",
+ "processLinkType": "form",
+ "formDefinitionName": "start-form-request-case-notification"
+ }
+]
diff --git a/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/process-link/send-review-approved.process-link.json b/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/process-link/send-review-approved.process-link.json
new file mode 100644
index 0000000000..0f9b26d585
--- /dev/null
+++ b/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/process-link/send-review-approved.process-link.json
@@ -0,0 +1,8 @@
+[
+ {
+ "activityId": "start-event",
+ "activityType": "bpmn:StartEvent:start",
+ "processLinkType": "form",
+ "formDefinitionName": "start-form-send-review-approved"
+ }
+]
diff --git a/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/startable-item/case-messaging.startable-items.json b/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/startable-item/case-messaging.startable-items.json
new file mode 100644
index 0000000000..816f962c2e
--- /dev/null
+++ b/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/startable-item/case-messaging.startable-items.json
@@ -0,0 +1,15 @@
+[
+ {
+ "type": "BUILDING_BLOCK",
+ "key": "case-review",
+ "versionTag": "1.0.0"
+ },
+ {
+ "type": "PROCESS",
+ "key": "send-review-approved"
+ },
+ {
+ "type": "PROCESS",
+ "key": "request-case-notification"
+ }
+]
diff --git a/backend/building-block/src/main/kotlin/com/ritense/buildingblock/configuration/BuildingBlockAutoConfiguration.kt b/backend/building-block/src/main/kotlin/com/ritense/buildingblock/configuration/BuildingBlockAutoConfiguration.kt
index cb71c9afe0..577949820c 100644
--- a/backend/building-block/src/main/kotlin/com/ritense/buildingblock/configuration/BuildingBlockAutoConfiguration.kt
+++ b/backend/building-block/src/main/kotlin/com/ritense/buildingblock/configuration/BuildingBlockAutoConfiguration.kt
@@ -52,6 +52,8 @@ import com.ritense.buildingblock.service.BuildingBlockDefinitionImporter
import com.ritense.buildingblock.service.BuildingBlockDefinitionMainProcessDefinitionImporter
import com.ritense.buildingblock.service.BuildingBlockDefinitionProcessDefinitionService
import com.ritense.buildingblock.service.BuildingBlockDocumentDefinitionService
+import com.ritense.buildingblock.service.BuildingBlockCaseCorrelationBusinessKeyProvider
+import com.ritense.buildingblock.service.BuildingBlockCaseCorrelationStartTargetProvider
import com.ritense.buildingblock.service.BuildingBlockFieldService
import com.ritense.buildingblock.service.BuildingBlockFormDefinitionExporter
import com.ritense.buildingblock.service.BuildingBlockFormDefinitionImporter
@@ -100,6 +102,8 @@ import com.ritense.importer.ValtimoImportService
import com.ritense.plugin.service.BuildingBlockPluginConfigurationResolver
import com.ritense.plugin.service.PluginService
import com.ritense.processdocument.service.BuildingBlockProcessLookup
+import com.ritense.processdocument.service.CaseCorrelationBusinessKeyProvider
+import com.ritense.processdocument.service.CaseCorrelationStartTargetProvider
import com.ritense.processdocument.service.ProcessDocumentAssociationService
import com.ritense.processlink.exporter.BuildingBlockProcessLinkToBuildingBlockMapper
import com.ritense.processlink.mapper.ProcessLinkMapper
@@ -371,6 +375,30 @@ class BuildingBlockAutoConfiguration {
return BuildingBlockProcessLookupImpl(buildingBlockInstanceRepository)
}
+ @Bean
+ @ConditionalOnMissingBean(CaseCorrelationBusinessKeyProvider::class)
+ fun buildingBlockCaseCorrelationBusinessKeyProvider(
+ buildingBlockInstanceRepository: BuildingBlockInstanceRepository,
+ ): CaseCorrelationBusinessKeyProvider {
+ return BuildingBlockCaseCorrelationBusinessKeyProvider(buildingBlockInstanceRepository)
+ }
+
+ @Bean
+ @ConditionalOnMissingBean(CaseCorrelationStartTargetProvider::class)
+ fun buildingBlockCaseCorrelationStartTargetProvider(
+ documentService: DocumentService,
+ caseDefinitionBuildingBlockLinkRepository: CaseDefinitionBuildingBlockLinkRepository,
+ processDefinitionBuildingBlockDefinitionRepository: ProcessDefinitionBuildingBlockDefinitionRepository,
+ repositoryService: RepositoryService,
+ ): CaseCorrelationStartTargetProvider {
+ return BuildingBlockCaseCorrelationStartTargetProvider(
+ documentService,
+ caseDefinitionBuildingBlockLinkRepository,
+ processDefinitionBuildingBlockDefinitionRepository,
+ repositoryService,
+ )
+ }
+
@Bean
@ConditionalOnMissingBean(BuildingBlockInstanceResource::class)
fun buildingBlockInstanceResource(
diff --git a/backend/building-block/src/main/kotlin/com/ritense/buildingblock/service/BuildingBlockCaseCorrelationBusinessKeyProvider.kt b/backend/building-block/src/main/kotlin/com/ritense/buildingblock/service/BuildingBlockCaseCorrelationBusinessKeyProvider.kt
new file mode 100644
index 0000000000..1337ec1451
--- /dev/null
+++ b/backend/building-block/src/main/kotlin/com/ritense/buildingblock/service/BuildingBlockCaseCorrelationBusinessKeyProvider.kt
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2015-2026 Ritense BV, the Netherlands.
+ *
+ * Licensed under EUPL, Version 1.2 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.ritense.buildingblock.service
+
+import com.ritense.buildingblock.repository.BuildingBlockInstanceRepository
+import com.ritense.processdocument.service.CaseCorrelationBusinessKeyProvider
+import java.util.UUID
+
+/**
+ * Makes the building-block instances of a case reachable for case-scoped message correlation.
+ * A building-block process instance runs under its own document id as business key, so the case
+ * business key alone never reaches it.
+ */
+class BuildingBlockCaseCorrelationBusinessKeyProvider(
+ private val buildingBlockInstanceRepository: BuildingBlockInstanceRepository,
+) : CaseCorrelationBusinessKeyProvider {
+
+ override fun getBusinessKeysForCase(caseDocumentId: UUID): List {
+ return buildingBlockInstanceRepository.findAllByCaseDocumentId(caseDocumentId)
+ .map { it.documentId.toString() }
+ }
+}
diff --git a/backend/building-block/src/main/kotlin/com/ritense/buildingblock/service/BuildingBlockCaseCorrelationStartTargetProvider.kt b/backend/building-block/src/main/kotlin/com/ritense/buildingblock/service/BuildingBlockCaseCorrelationStartTargetProvider.kt
new file mode 100644
index 0000000000..0ce39267d2
--- /dev/null
+++ b/backend/building-block/src/main/kotlin/com/ritense/buildingblock/service/BuildingBlockCaseCorrelationStartTargetProvider.kt
@@ -0,0 +1,65 @@
+/*
+ * Copyright 2015-2026 Ritense BV, the Netherlands.
+ *
+ * Licensed under EUPL, Version 1.2 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.ritense.buildingblock.service
+
+import com.ritense.authorization.AuthorizationContext.Companion.runWithoutAuthorization
+import com.ritense.buildingblock.repository.CaseDefinitionBuildingBlockLinkRepository
+import com.ritense.buildingblock.repository.ProcessDefinitionBuildingBlockDefinitionRepository
+import com.ritense.document.service.DocumentService
+import com.ritense.processdocument.service.CaseCorrelationStartTargetProvider
+import java.util.UUID
+import org.operaton.bpm.engine.RepositoryService
+import org.operaton.bpm.model.bpmn.instance.EventDefinition
+import org.operaton.bpm.model.bpmn.instance.MessageEventDefinition
+import org.operaton.bpm.model.bpmn.instance.StartEvent
+
+/**
+ * Makes the building blocks linked to a case definition startable by message. Only the main process
+ * of a link is considered, and always in the version the link pins — engine-level start correlation
+ * on a process definition key would pick the latest deployed version instead, which could start a
+ * building block in a version the case does not use.
+ */
+class BuildingBlockCaseCorrelationStartTargetProvider(
+ private val documentService: DocumentService,
+ private val caseDefinitionBuildingBlockLinkRepository: CaseDefinitionBuildingBlockLinkRepository,
+ private val processDefinitionBuildingBlockDefinitionRepository: ProcessDefinitionBuildingBlockDefinitionRepository,
+ private val repositoryService: RepositoryService,
+) : CaseCorrelationStartTargetProvider {
+
+ override fun getStartTargets(caseDocumentId: UUID, message: String): List {
+ val caseDefinitionId = runWithoutAuthorization {
+ documentService.get(caseDocumentId.toString())
+ }.definitionId().caseDefinitionId()
+
+ return caseDefinitionBuildingBlockLinkRepository.findAllByCaseDefinitionId(caseDefinitionId)
+ .mapNotNull { link ->
+ processDefinitionBuildingBlockDefinitionRepository
+ .findByIdBuildingBlockDefinitionIdAndMain(link.buildingBlockDefinitionId, true)
+ ?.id?.processDefinitionId?.id
+ }
+ .filter { hasMessageStartEvent(it, message) }
+ }
+
+ private fun hasMessageStartEvent(processDefinitionId: String, message: String): Boolean {
+ val bpmnModel = repositoryService.getBpmnModelInstance(processDefinitionId) ?: return false
+ return bpmnModel.getModelElementsByType(StartEvent::class.java).any { startEvent ->
+ startEvent.getChildElementsByType(EventDefinition::class.java)
+ .filterIsInstance()
+ .any { it.message?.name == message }
+ }
+ }
+}
diff --git a/backend/building-block/src/test/kotlin/com/ritense/buildingblock/service/BuildingBlockCaseCorrelationBusinessKeyProviderTest.kt b/backend/building-block/src/test/kotlin/com/ritense/buildingblock/service/BuildingBlockCaseCorrelationBusinessKeyProviderTest.kt
new file mode 100644
index 0000000000..86bdf2b2e0
--- /dev/null
+++ b/backend/building-block/src/test/kotlin/com/ritense/buildingblock/service/BuildingBlockCaseCorrelationBusinessKeyProviderTest.kt
@@ -0,0 +1,65 @@
+/*
+ * Copyright 2015-2026 Ritense BV, the Netherlands.
+ *
+ * Licensed under EUPL, Version 1.2 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.ritense.buildingblock.service
+
+import com.ritense.buildingblock.domain.instance.BuildingBlockInstance
+import com.ritense.buildingblock.repository.BuildingBlockInstanceRepository
+import org.assertj.core.api.Assertions.assertThat
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.whenever
+import java.util.UUID
+
+class BuildingBlockCaseCorrelationBusinessKeyProviderTest {
+
+ private lateinit var buildingBlockInstanceRepository: BuildingBlockInstanceRepository
+ private lateinit var provider: BuildingBlockCaseCorrelationBusinessKeyProvider
+
+ private val caseDocumentId: UUID = UUID.randomUUID()
+
+ @BeforeEach
+ fun setUp() {
+ buildingBlockInstanceRepository = mock()
+ provider = BuildingBlockCaseCorrelationBusinessKeyProvider(buildingBlockInstanceRepository)
+ }
+
+ @Test
+ fun `should return the document id of every building block instance of the case`() {
+ val documentIdOne = UUID.randomUUID()
+ val documentIdTwo = UUID.randomUUID()
+ val instances = listOf(instanceWithDocumentId(documentIdOne), instanceWithDocumentId(documentIdTwo))
+ whenever(buildingBlockInstanceRepository.findAllByCaseDocumentId(caseDocumentId)).thenReturn(instances)
+
+ val businessKeys = provider.getBusinessKeysForCase(caseDocumentId)
+
+ assertThat(businessKeys).containsExactly(documentIdOne.toString(), documentIdTwo.toString())
+ }
+
+ @Test
+ fun `should return no business keys when the case has no building block instances`() {
+ whenever(buildingBlockInstanceRepository.findAllByCaseDocumentId(caseDocumentId)).thenReturn(emptyList())
+
+ assertThat(provider.getBusinessKeysForCase(caseDocumentId)).isEmpty()
+ }
+
+ private fun instanceWithDocumentId(documentId: UUID): BuildingBlockInstance {
+ val instance = mock()
+ whenever(instance.documentId).thenReturn(documentId)
+ return instance
+ }
+}
diff --git a/backend/building-block/src/test/kotlin/com/ritense/buildingblock/service/BuildingBlockCaseCorrelationStartTargetProviderTest.kt b/backend/building-block/src/test/kotlin/com/ritense/buildingblock/service/BuildingBlockCaseCorrelationStartTargetProviderTest.kt
new file mode 100644
index 0000000000..961c792d6d
--- /dev/null
+++ b/backend/building-block/src/test/kotlin/com/ritense/buildingblock/service/BuildingBlockCaseCorrelationStartTargetProviderTest.kt
@@ -0,0 +1,183 @@
+/*
+ * Copyright 2015-2026 Ritense BV, the Netherlands.
+ *
+ * Licensed under EUPL, Version 1.2 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.ritense.buildingblock.service
+
+import com.ritense.buildingblock.domain.CaseDefinitionBuildingBlockLink
+import com.ritense.buildingblock.domain.ProcessDefinitionBuildingBlockDefinition
+import com.ritense.buildingblock.domain.ProcessDefinitionBuildingBlockDefinitionId
+import com.ritense.buildingblock.repository.CaseDefinitionBuildingBlockLinkRepository
+import com.ritense.buildingblock.repository.ProcessDefinitionBuildingBlockDefinitionRepository
+import com.ritense.document.domain.Document
+import com.ritense.document.service.DocumentService
+import com.ritense.processdocument.domain.ProcessDefinitionId
+import com.ritense.valtimo.contract.buildingblock.BuildingBlockDefinitionId
+import com.ritense.valtimo.contract.case_.CaseDefinitionId
+import org.assertj.core.api.Assertions.assertThat
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.mockito.Mockito.RETURNS_DEEP_STUBS
+import org.mockito.kotlin.eq
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.whenever
+import org.operaton.bpm.engine.RepositoryService
+import org.operaton.bpm.model.bpmn.Bpmn
+import org.operaton.bpm.model.bpmn.BpmnModelInstance
+import org.operaton.bpm.model.bpmn.instance.Message
+import org.operaton.bpm.model.bpmn.instance.MessageEventDefinition
+import org.operaton.bpm.model.bpmn.instance.StartEvent
+import java.util.UUID
+
+class BuildingBlockCaseCorrelationStartTargetProviderTest {
+
+ private lateinit var documentService: DocumentService
+ private lateinit var caseDefinitionBuildingBlockLinkRepository: CaseDefinitionBuildingBlockLinkRepository
+ private lateinit var processDefinitionBuildingBlockDefinitionRepository:
+ ProcessDefinitionBuildingBlockDefinitionRepository
+ private lateinit var repositoryService: RepositoryService
+ private lateinit var provider: BuildingBlockCaseCorrelationStartTargetProvider
+
+ private val caseDocumentId: UUID = UUID.randomUUID()
+ private val caseDefinitionId: CaseDefinitionId = CaseDefinitionId.of("bb-case", "1.0.0")
+ private val buildingBlockDefinitionId: BuildingBlockDefinitionId =
+ BuildingBlockDefinitionId.of("notify", "1.0.0")
+ private val processDefinitionId = "notify-process:1:${UUID.randomUUID()}"
+
+ @BeforeEach
+ fun setUp() {
+ documentService = mock()
+ caseDefinitionBuildingBlockLinkRepository = mock()
+ processDefinitionBuildingBlockDefinitionRepository = mock()
+ repositoryService = mock()
+
+ provider = BuildingBlockCaseCorrelationStartTargetProvider(
+ documentService,
+ caseDefinitionBuildingBlockLinkRepository,
+ processDefinitionBuildingBlockDefinitionRepository,
+ repositoryService,
+ )
+
+ val document = mock(defaultAnswer = RETURNS_DEEP_STUBS)
+ whenever(document.definitionId().caseDefinitionId()).thenReturn(caseDefinitionId)
+ whenever(documentService.get(caseDocumentId.toString())).thenReturn(document)
+ }
+
+ @Test
+ fun `should return the pinned main process definition when it declares the message start event`() {
+ linkBuildingBlock()
+ mockMainProcessDefinition(processDefinitionId)
+ mockBpmnModel(processDefinitionId, messageStartEventName = MESSAGE)
+
+ assertThat(provider.getStartTargets(caseDocumentId, MESSAGE)).containsExactly(processDefinitionId)
+ }
+
+ @Test
+ fun `should not return a main process that declares a different message start event`() {
+ linkBuildingBlock()
+ mockMainProcessDefinition(processDefinitionId)
+ mockBpmnModel(processDefinitionId, messageStartEventName = "some-other-message")
+
+ assertThat(provider.getStartTargets(caseDocumentId, MESSAGE)).isEmpty()
+ }
+
+ @Test
+ fun `should not return a main process without a message start event`() {
+ linkBuildingBlock()
+ mockMainProcessDefinition(processDefinitionId)
+ mockBpmnModel(processDefinitionId, messageStartEventName = null)
+
+ assertThat(provider.getStartTargets(caseDocumentId, MESSAGE)).isEmpty()
+ }
+
+ @Test
+ fun `should skip building blocks without a main process definition`() {
+ linkBuildingBlock()
+ whenever(
+ processDefinitionBuildingBlockDefinitionRepository
+ .findByIdBuildingBlockDefinitionIdAndMain(eq(buildingBlockDefinitionId), eq(true))
+ ).thenReturn(null)
+
+ assertThat(provider.getStartTargets(caseDocumentId, MESSAGE)).isEmpty()
+ }
+
+ @Test
+ fun `should return no targets when the case definition has no building block links`() {
+ whenever(caseDefinitionBuildingBlockLinkRepository.findAllByCaseDefinitionId(caseDefinitionId))
+ .thenReturn(emptyList())
+
+ assertThat(provider.getStartTargets(caseDocumentId, MESSAGE)).isEmpty()
+ }
+
+ @Test
+ fun `should skip a main process whose bpmn model cannot be read`() {
+ linkBuildingBlock()
+ mockMainProcessDefinition(processDefinitionId)
+ whenever(repositoryService.getBpmnModelInstance(processDefinitionId)).thenReturn(null)
+
+ assertThat(provider.getStartTargets(caseDocumentId, MESSAGE)).isEmpty()
+ }
+
+ private fun linkBuildingBlock() {
+ val link = CaseDefinitionBuildingBlockLink(
+ caseDefinitionId = caseDefinitionId,
+ buildingBlockDefinitionId = buildingBlockDefinitionId,
+ inputMappings = emptyList(),
+ outputMappings = emptyList(),
+ )
+ whenever(caseDefinitionBuildingBlockLinkRepository.findAllByCaseDefinitionId(caseDefinitionId))
+ .thenReturn(listOf(link))
+ }
+
+ private fun mockMainProcessDefinition(processDefinitionId: String) {
+ val mainProcessDefinition = ProcessDefinitionBuildingBlockDefinition(
+ id = ProcessDefinitionBuildingBlockDefinitionId(
+ processDefinitionId = ProcessDefinitionId.of(processDefinitionId),
+ buildingBlockDefinitionId = buildingBlockDefinitionId,
+ ),
+ main = true,
+ )
+ whenever(
+ processDefinitionBuildingBlockDefinitionRepository
+ .findByIdBuildingBlockDefinitionIdAndMain(eq(buildingBlockDefinitionId), eq(true))
+ ).thenReturn(mainProcessDefinition)
+ }
+
+ private fun mockBpmnModel(processDefinitionId: String, messageStartEventName: String?) {
+ whenever(repositoryService.getBpmnModelInstance(processDefinitionId))
+ .thenReturn(bpmnModel(messageStartEventName))
+ }
+
+ private fun bpmnModel(messageStartEventName: String?): BpmnModelInstance {
+ val model = Bpmn.createExecutableProcess("notify-process")
+ .startEvent("start")
+ .endEvent()
+ .done()
+ if (messageStartEventName != null) {
+ val startEvent = model.getModelElementById("start")
+ val message = model.newInstance(Message::class.java)
+ message.name = messageStartEventName
+ model.definitions.addChildElement(message)
+ val messageEventDefinition = model.newInstance(MessageEventDefinition::class.java)
+ messageEventDefinition.message = message
+ startEvent.addChildElement(messageEventDefinition)
+ }
+ return model
+ }
+
+ private companion object {
+ private const val MESSAGE = "case-notification"
+ }
+}
diff --git a/backend/building-block/src/test/kotlin/com/ritense/buildingblock/service/BuildingBlockMessageStartIT.kt b/backend/building-block/src/test/kotlin/com/ritense/buildingblock/service/BuildingBlockMessageStartIT.kt
new file mode 100644
index 0000000000..7cb4ee1581
--- /dev/null
+++ b/backend/building-block/src/test/kotlin/com/ritense/buildingblock/service/BuildingBlockMessageStartIT.kt
@@ -0,0 +1,304 @@
+/*
+ * Copyright 2015-2026 Ritense BV, the Netherlands.
+ *
+ * Licensed under EUPL, Version 1.2 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.ritense.buildingblock.service
+
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.fasterxml.jackson.databind.node.ObjectNode
+import com.ritense.authorization.AuthorizationContext.Companion.runWithoutAuthorization
+import com.ritense.buildingblock.BaseIntegrationTest
+import com.ritense.buildingblock.domain.CaseDefinitionBuildingBlockLink
+import com.ritense.buildingblock.domain.ProcessDefinitionBuildingBlockDefinition
+import com.ritense.buildingblock.domain.ProcessDefinitionBuildingBlockDefinitionId
+import com.ritense.buildingblock.processlink.domain.BuildingBlockInputMapping
+import com.ritense.buildingblock.processlink.domain.BuildingBlockOutputMapping
+import com.ritense.buildingblock.processlink.domain.BuildingBlockSyncTiming
+import com.ritense.buildingblock.repository.BuildingBlockInstanceRepository
+import com.ritense.buildingblock.repository.CaseDefinitionBuildingBlockLinkRepository
+import com.ritense.buildingblock.repository.ProcessDefinitionBuildingBlockDefinitionRepository
+import com.ritense.document.domain.impl.JsonSchemaDocument
+import com.ritense.document.domain.impl.request.NewDocumentRequest
+import com.ritense.document.service.DocumentService
+import com.ritense.processdocument.domain.ProcessDefinitionId
+import com.ritense.processdocument.service.CaseCorrelationService
+import com.ritense.valtimo.contract.buildingblock.BuildingBlockDefinitionId
+import com.ritense.valtimo.contract.case_.CaseDefinitionId
+import org.assertj.core.api.Assertions.assertThat
+import org.junit.jupiter.api.Test
+import org.operaton.bpm.engine.RepositoryService
+import org.operaton.bpm.engine.RuntimeService
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.transaction.annotation.Transactional
+import java.util.UUID
+
+/**
+ * Integration test for starting building blocks by message. A case pins a specific building-block
+ * version, so [CaseCorrelationService] starts the main process definition the link points at rather
+ * than letting the engine pick the latest deployed version of the process definition key.
+ */
+@Transactional
+class BuildingBlockMessageStartIT @Autowired constructor(
+ private val buildingBlockInstanceRepository: BuildingBlockInstanceRepository,
+ private val caseCorrelationService: CaseCorrelationService,
+ private val caseDefinitionBuildingBlockLinkRepository: CaseDefinitionBuildingBlockLinkRepository,
+ private val documentService: DocumentService,
+ private val objectMapper: ObjectMapper,
+ private val processDefinitionBuildingBlockDefinitionRepository: ProcessDefinitionBuildingBlockDefinitionRepository,
+ private val repositoryService: RepositoryService,
+ private val runtimeService: RuntimeService,
+) : BaseIntegrationTest() {
+
+ @Test
+ fun `should start a linked building block through its message start event`() {
+ linkNotifyBuildingBlock(NOTIFY_VERSION)
+ val caseDocumentId = createCaseDocument()
+
+ val processInstances = caseCorrelationService.sendStartMessageToCase(
+ START_MESSAGE,
+ caseDocumentId.toString(),
+ mapOf("messagePayload" to "from-case")
+ )
+
+ assertThat(processInstances).hasSize(1)
+ val instance = buildingBlockInstanceRepository.findAllByCaseDocumentId(caseDocumentId).single()
+ assertThat(instance.caseDocumentId).isEqualTo(caseDocumentId)
+ assertThat(instance.documentId).isNotEqualTo(caseDocumentId)
+ assertThat(instance.definition.id).isEqualTo(notifyDefinitionId(NOTIFY_VERSION))
+
+ // The building-block bootstrap rewrote the business key from the case to the BB document.
+ val runningInstance = runtimeService.createProcessInstanceQuery()
+ .processInstanceId(instance.processInstanceId!!)
+ .singleResult()
+ assertThat(runningInstance.businessKey).isEqualTo(instance.documentId.toString())
+ assertThat(runtimeService.getVariable(instance.processInstanceId!!, "messagePayload"))
+ .isEqualTo("from-case")
+ }
+
+ @Test
+ fun `should apply the input mappings of the case definition link to the new building block document`() {
+ linkNotifyBuildingBlock(NOTIFY_VERSION)
+ val caseDocumentId = createCaseDocument(firstName = "Jan")
+
+ caseCorrelationService.sendStartMessageToCase(START_MESSAGE, caseDocumentId.toString())
+
+ val instance = buildingBlockInstanceRepository.findAllByCaseDocumentId(caseDocumentId).single()
+ val buildingBlockDocument = runWithoutAuthorization {
+ documentService.get(instance.documentId.toString())
+ } as JsonSchemaDocument
+ assertThat(buildingBlockDocument.content().asJson().get("notificationText").asText()).isEqualTo("Jan")
+ }
+
+ @Test
+ fun `should sync the output mappings back to the case when the started building block ends`() {
+ linkNotifyBuildingBlock(NOTIFY_VERSION)
+ val caseDocumentId = createCaseDocument(firstName = "Jan")
+
+ caseCorrelationService.sendStartMessageToCase(START_MESSAGE, caseDocumentId.toString())
+
+ val instance = buildingBlockInstanceRepository.findAllByCaseDocumentId(caseDocumentId).single()
+ runWithoutAuthorization {
+ runtimeService.correlateMessage(READY_MESSAGE, instance.documentId.toString())
+ }
+
+ val caseDocument = runWithoutAuthorization {
+ documentService.get(caseDocumentId.toString())
+ } as JsonSchemaDocument
+ assertThat(caseDocument.content().asJson().get("resultFromBb").asText()).isEqualTo("Jan")
+ }
+
+ @Test
+ fun `should start the building block version the case link pins, not the latest deployed one`() {
+ linkNotifyBuildingBlock(NOTIFY_VERSION)
+ val caseDocumentId = createCaseDocument()
+ val pinnedProcessDefinitionId = notifyProcessDefinitionId(NOTIFY_VERSION)
+ val latestProcessDefinitionId = notifyProcessDefinitionId(NOTIFY_NEWER_VERSION)
+ assertThat(pinnedProcessDefinitionId).isNotEqualTo(latestProcessDefinitionId)
+
+ val processInstances = caseCorrelationService.sendStartMessageToCase(START_MESSAGE, caseDocumentId.toString())
+
+ assertThat(processInstances).hasSize(1)
+ assertThat(processInstances.single().processDefinitionId).isEqualTo(pinnedProcessDefinitionId)
+ }
+
+ @Test
+ fun `should not start anything for a message no linked building block declares`() {
+ linkNotifyBuildingBlock(NOTIFY_VERSION)
+ val caseDocumentId = createCaseDocument()
+
+ val processInstances = caseCorrelationService.sendStartMessageToCase(
+ "message-nobody-declares",
+ caseDocumentId.toString()
+ )
+
+ assertThat(processInstances).isEmpty()
+ assertThat(buildingBlockInstanceRepository.findAllByCaseDocumentId(caseDocumentId)).isEmpty()
+ assertThat(runningInstanceCount(NOTIFY_PROCESS_KEY)).isZero()
+ }
+
+ @Test
+ fun `should not start a linked building block whose main process has no message start event`() {
+ linkNotifyBuildingBlock(NOTIFY_VERSION)
+ linkBezwaarBuildingBlock()
+ val caseDocumentId = createCaseDocument()
+
+ caseCorrelationService.sendStartMessageToCase(START_MESSAGE, caseDocumentId.toString())
+
+ val instances = buildingBlockInstanceRepository.findAllByCaseDocumentId(caseDocumentId)
+ assertThat(instances).hasSize(1)
+ assertThat(instances.single().definition.id.key).isEqualTo(NOTIFY_KEY)
+ assertThat(runningInstanceCount(BEZWAAR_PROCESS_KEY)).isZero()
+ }
+
+ @Test
+ fun `should start a building block from a case process through the bpmn expression`() {
+ linkNotifyBuildingBlock(NOTIFY_VERSION)
+ val caseDocumentId = createCaseDocument()
+
+ runWithoutAuthorization {
+ runtimeService.startProcessInstanceByKey(
+ SENDER_PROCESS_KEY,
+ caseDocumentId.toString(),
+ emptyMap()
+ )
+ }
+
+ val instance = buildingBlockInstanceRepository.findAllByCaseDocumentId(caseDocumentId).single()
+ assertThat(instance.definition.id).isEqualTo(notifyDefinitionId(NOTIFY_VERSION))
+ }
+
+ private fun linkNotifyBuildingBlock(versionTag: String) {
+ ensureMainProcessLink(notifyDefinitionId(versionTag), NOTIFY_PROCESS_KEY)
+ caseDefinitionBuildingBlockLinkRepository.save(
+ CaseDefinitionBuildingBlockLink(
+ caseDefinitionId = CaseDefinitionId.of(CASE_DEFINITION_KEY, CASE_DEFINITION_VERSION),
+ buildingBlockDefinitionId = notifyDefinitionId(versionTag),
+ inputMappings = listOf(
+ BuildingBlockInputMapping(source = "doc:/contact/firstName", target = "/notificationText")
+ ),
+ outputMappings = listOf(
+ BuildingBlockOutputMapping(
+ source = "doc:/notificationText",
+ target = "doc:/resultFromBb",
+ syncTiming = BuildingBlockSyncTiming.END
+ )
+ ),
+ )
+ )
+ }
+
+ private fun linkBezwaarBuildingBlock() {
+ val bezwaarDefinitionId = BuildingBlockDefinitionId.of(BEZWAAR_KEY, BEZWAAR_VERSION)
+ ensureMainProcessLink(bezwaarDefinitionId, BEZWAAR_PROCESS_KEY)
+ caseDefinitionBuildingBlockLinkRepository.save(
+ CaseDefinitionBuildingBlockLink(
+ caseDefinitionId = CaseDefinitionId.of(CASE_DEFINITION_KEY, CASE_DEFINITION_VERSION),
+ buildingBlockDefinitionId = bezwaarDefinitionId,
+ inputMappings = emptyList(),
+ outputMappings = emptyList(),
+ )
+ )
+ }
+
+ /** The notify link maps `contact.firstName` into the BB document, which requires a string. */
+ private fun createCaseDocument(firstName: String = "Jan"): UUID {
+ val content = objectMapper.createObjectNode()
+ content.putObject("contact").put("firstName", firstName)
+ return runWithoutAuthorization {
+ documentService.createDocument(
+ NewDocumentRequest(
+ CASE_DEFINITION_KEY,
+ CASE_DEFINITION_KEY,
+ CASE_DEFINITION_VERSION,
+ content as ObjectNode
+ )
+ ).resultingDocument()
+ .orElseThrow { IllegalStateException("Case document not created") }
+ .id()
+ .getId()
+ }
+ }
+
+ private fun notifyDefinitionId(versionTag: String) = BuildingBlockDefinitionId.of(NOTIFY_KEY, versionTag)
+
+ /**
+ * Recreates the main-process link of a deployed building block when it is missing. Other
+ * (non-transactional) integration tests in this module clear
+ * `process_definition_building_block_definition`, so the deployed state cannot be relied upon.
+ */
+ private fun ensureMainProcessLink(
+ buildingBlockDefinitionId: BuildingBlockDefinitionId,
+ processDefinitionKey: String
+ ) {
+ if (
+ processDefinitionBuildingBlockDefinitionRepository
+ .findByIdBuildingBlockDefinitionIdAndMain(buildingBlockDefinitionId, true) != null
+ ) {
+ return
+ }
+ processDefinitionBuildingBlockDefinitionRepository.save(
+ ProcessDefinitionBuildingBlockDefinition(
+ id = ProcessDefinitionBuildingBlockDefinitionId(
+ processDefinitionId = ProcessDefinitionId.of(
+ processDefinitionId(buildingBlockDefinitionId, processDefinitionKey)
+ ),
+ buildingBlockDefinitionId = buildingBlockDefinitionId,
+ ),
+ main = true,
+ )
+ )
+ }
+
+ private fun notifyProcessDefinitionId(versionTag: String): String =
+ processDefinitionId(notifyDefinitionId(versionTag), NOTIFY_PROCESS_KEY)
+
+ private fun processDefinitionId(
+ buildingBlockDefinitionId: BuildingBlockDefinitionId,
+ processDefinitionKey: String
+ ): String {
+ val versionTag = "BB:${buildingBlockDefinitionId.key}:${buildingBlockDefinitionId.versionTag}"
+ return repositoryService.createProcessDefinitionQuery()
+ .processDefinitionKey(processDefinitionKey)
+ .versionTag(versionTag)
+ .orderByProcessDefinitionVersion()
+ .desc()
+ .list()
+ .firstOrNull()
+ ?.id
+ ?: throw IllegalStateException("Process '$processDefinitionKey' ($versionTag) not deployed")
+ }
+
+ private fun runningInstanceCount(processDefinitionKey: String): Int {
+ return runtimeService.createProcessInstanceQuery()
+ .processDefinitionKey(processDefinitionKey)
+ .list()
+ .size
+ }
+
+ companion object {
+ private const val CASE_DEFINITION_KEY = "bb-case"
+ private const val CASE_DEFINITION_VERSION = "1.0.0"
+ private const val NOTIFY_KEY = "notify"
+ private const val NOTIFY_VERSION = "1.0.0"
+ private const val NOTIFY_NEWER_VERSION = "1.1.0"
+ private const val NOTIFY_PROCESS_KEY = "notify-process"
+ private const val BEZWAAR_KEY = "bezwaar"
+ private const val BEZWAAR_VERSION = "1.0.0"
+ private const val BEZWAAR_PROCESS_KEY = "building-block-process"
+ private const val SENDER_PROCESS_KEY = "case-start-message-sender"
+ private const val START_MESSAGE = "case-notification"
+ private const val READY_MESSAGE = "test-ready"
+ }
+}
diff --git a/backend/building-block/src/test/kotlin/com/ritense/buildingblock/service/CaseCorrelationBuildingBlockIT.kt b/backend/building-block/src/test/kotlin/com/ritense/buildingblock/service/CaseCorrelationBuildingBlockIT.kt
new file mode 100644
index 0000000000..309529b6cc
--- /dev/null
+++ b/backend/building-block/src/test/kotlin/com/ritense/buildingblock/service/CaseCorrelationBuildingBlockIT.kt
@@ -0,0 +1,231 @@
+/*
+ * Copyright 2015-2026 Ritense BV, the Netherlands.
+ *
+ * Licensed under EUPL, Version 1.2 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.ritense.buildingblock.service
+
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.ritense.authorization.AuthorizationContext.Companion.runWithoutAuthorization
+import com.ritense.buildingblock.BaseIntegrationTest
+import com.ritense.buildingblock.domain.CaseDefinitionBuildingBlockLink
+import com.ritense.buildingblock.domain.instance.BuildingBlockInstance
+import com.ritense.buildingblock.repository.BuildingBlockInstanceRepository
+import com.ritense.buildingblock.repository.CaseDefinitionBuildingBlockLinkRepository
+import com.ritense.document.domain.impl.JsonSchemaDocumentId
+import com.ritense.document.domain.impl.request.NewDocumentRequest
+import com.ritense.document.service.DocumentService
+import com.ritense.processdocument.repository.ProcessDocumentInstanceRepository
+import com.ritense.processdocument.service.CaseCorrelationService
+import com.ritense.valtimo.contract.buildingblock.BuildingBlockDefinitionId
+import com.ritense.valtimo.contract.case_.CaseDefinitionId
+import org.assertj.core.api.Assertions.assertThat
+import org.junit.jupiter.api.Test
+import org.operaton.bpm.engine.HistoryService
+import org.operaton.bpm.engine.RuntimeService
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.transaction.annotation.Transactional
+import java.util.UUID
+
+/**
+ * Integration test for case-scoped message correlation towards building blocks. A building-block
+ * process instance runs under its own document id as business key, so the case business key alone
+ * never reaches it — [CaseCorrelationService] fans the message out over the case and all of its
+ * building-block instances.
+ */
+@Transactional
+class CaseCorrelationBuildingBlockIT @Autowired constructor(
+ private val buildingBlockInstanceRepository: BuildingBlockInstanceRepository,
+ private val caseCorrelationService: CaseCorrelationService,
+ private val caseDefinitionBuildingBlockLinkRepository: CaseDefinitionBuildingBlockLinkRepository,
+ private val documentService: DocumentService,
+ private val objectMapper: ObjectMapper,
+ private val historyService: HistoryService,
+ private val processDocumentInstanceRepository: ProcessDocumentInstanceRepository,
+ private val runtimeService: RuntimeService,
+) : BaseIntegrationTest() {
+
+ @Test
+ fun `should deliver a message to the running building block of a case`() {
+ linkBuildingBlockToCaseDefinition()
+ val caseDocumentId = createCaseDocument()
+ val instance = startAdHocBuildingBlock(caseDocumentId)
+
+ val results = caseCorrelationService.sendCatchEventMessageToCase(TEST_MESSAGE, caseDocumentId.toString())
+
+ assertThat(results).hasSize(1)
+ assertThat(isRunning(instance.processInstanceId!!)).isFalse()
+ }
+
+ @Test
+ fun `should deliver a message to every building block instance of a case`() {
+ linkBuildingBlockToCaseDefinition()
+ val caseDocumentId = createCaseDocument()
+ val instanceOne = startAdHocBuildingBlock(caseDocumentId)
+ val instanceTwo = startAdHocBuildingBlock(caseDocumentId)
+ assertThat(instanceOne.documentId).isNotEqualTo(instanceTwo.documentId)
+
+ val results = caseCorrelationService.sendCatchEventMessageToCase(TEST_MESSAGE, caseDocumentId.toString())
+
+ assertThat(results).hasSize(2)
+ assertThat(isRunning(instanceOne.processInstanceId!!)).isFalse()
+ assertThat(isRunning(instanceTwo.processInstanceId!!)).isFalse()
+ }
+
+ @Test
+ fun `should not deliver a message to building blocks of another case`() {
+ linkBuildingBlockToCaseDefinition()
+ val targetCaseDocumentId = createCaseDocument()
+ val otherCaseDocumentId = createCaseDocument()
+ val targetInstance = startAdHocBuildingBlock(targetCaseDocumentId)
+ val otherInstance = startAdHocBuildingBlock(otherCaseDocumentId)
+
+ val results = caseCorrelationService.sendCatchEventMessageToCase(TEST_MESSAGE, targetCaseDocumentId.toString())
+
+ assertThat(results).hasSize(1)
+ assertThat(isRunning(targetInstance.processInstanceId!!)).isFalse()
+ assertThat(isRunning(otherInstance.processInstanceId!!)).isTrue()
+ }
+
+ @Test
+ fun `should accept a building block document id and resolve it to the owning case`() {
+ linkBuildingBlockToCaseDefinition()
+ val caseDocumentId = createCaseDocument()
+ val instance = startAdHocBuildingBlock(caseDocumentId)
+
+ val results = caseCorrelationService.sendCatchEventMessageToCase(TEST_MESSAGE, instance.documentId.toString())
+
+ assertThat(results).hasSize(1)
+ assertThat(isRunning(instance.processInstanceId!!)).isFalse()
+ }
+
+ @Test
+ fun `should set variables on the receiving building block process`() {
+ linkBuildingBlockToCaseDefinition()
+ val caseDocumentId = createCaseDocument()
+ val instance = startAdHocBuildingBlock(caseDocumentId)
+ val processInstanceId = instance.processInstanceId!!
+
+ caseCorrelationService.sendCatchEventMessageToCase(
+ TEST_MESSAGE,
+ caseDocumentId.toString(),
+ mapOf("messagePayload" to "from-case")
+ )
+
+ val variable = historyService.createHistoricVariableInstanceQuery()
+ .processInstanceId(processInstanceId)
+ .variableName("messagePayload")
+ .singleResult()
+ assertThat(variable?.value).isEqualTo("from-case")
+ }
+
+ @Test
+ fun `should not associate a building block process with the case document`() {
+ linkBuildingBlockToCaseDefinition()
+ val caseDocumentId = createCaseDocument()
+ val instance = startAdHocBuildingBlock(caseDocumentId)
+
+ caseCorrelationService.sendCatchEventMessageToCase(TEST_MESSAGE, caseDocumentId.toString())
+
+ val caseAssociations = processDocumentInstanceRepository
+ .findAllByProcessDocumentInstanceIdDocumentId(JsonSchemaDocumentId.existingId(caseDocumentId))
+ assertThat(caseAssociations.map { it.processDocumentInstanceId().processInstanceId().toString() })
+ .doesNotContain(instance.processInstanceId)
+ }
+
+ @Test
+ fun `should return an empty result when nothing in the case is subscribed`() {
+ linkBuildingBlockToCaseDefinition()
+ val caseDocumentId = createCaseDocument()
+ startAdHocBuildingBlock(caseDocumentId)
+
+ val results = caseCorrelationService.sendCatchEventMessageToCase("no-one-listens-to-this", caseDocumentId.toString())
+
+ assertThat(results).isEmpty()
+ }
+
+ @Test
+ fun `should deliver a message thrown from a case process through the bpmn expression`() {
+ linkBuildingBlockToCaseDefinition()
+ val caseDocumentId = createCaseDocument()
+ val instance = startAdHocBuildingBlock(caseDocumentId)
+
+ runWithoutAuthorization {
+ runtimeService.startProcessInstanceByKey(
+ SENDER_PROCESS_KEY,
+ caseDocumentId.toString(),
+ emptyMap()
+ )
+ }
+
+ assertThat(isRunning(instance.processInstanceId!!)).isFalse()
+ }
+
+ private fun linkBuildingBlockToCaseDefinition() {
+ caseDefinitionBuildingBlockLinkRepository.save(
+ CaseDefinitionBuildingBlockLink(
+ caseDefinitionId = CaseDefinitionId.of(CASE_DEFINITION_KEY, CASE_DEFINITION_VERSION),
+ buildingBlockDefinitionId = BuildingBlockDefinitionId.of(BUILDING_BLOCK_KEY, BUILDING_BLOCK_VERSION),
+ inputMappings = emptyList(),
+ outputMappings = emptyList(),
+ )
+ )
+ }
+
+ private fun createCaseDocument(): UUID {
+ return runWithoutAuthorization {
+ documentService.createDocument(
+ NewDocumentRequest(
+ CASE_DEFINITION_KEY,
+ CASE_DEFINITION_KEY,
+ CASE_DEFINITION_VERSION,
+ objectMapper.createObjectNode()
+ )
+ ).resultingDocument()
+ .orElseThrow { IllegalStateException("Case document not created") }
+ .id()
+ .getId()
+ }
+ }
+
+ /** Starts the building block as a case action and returns its freshly created instance. */
+ private fun startAdHocBuildingBlock(caseDocumentId: UUID): BuildingBlockInstance {
+ val known = buildingBlockInstanceRepository.findAllByCaseDocumentId(caseDocumentId).map { it.id }
+ runWithoutAuthorization {
+ runtimeService.startProcessInstanceByKey(
+ BUILDING_BLOCK_PROCESS_KEY,
+ caseDocumentId.toString(),
+ emptyMap()
+ )
+ }
+ return buildingBlockInstanceRepository.findAllByCaseDocumentId(caseDocumentId)
+ .single { it.id !in known }
+ }
+
+ private fun isRunning(processInstanceId: String): Boolean {
+ return runtimeService.createProcessInstanceQuery()
+ .processInstanceId(processInstanceId)
+ .singleResult() != null
+ }
+
+ companion object {
+ private const val BUILDING_BLOCK_KEY = "bezwaar"
+ private const val BUILDING_BLOCK_VERSION = "1.0.0"
+ private const val CASE_DEFINITION_KEY = "bb-case"
+ private const val CASE_DEFINITION_VERSION = "1.0.0"
+ private const val BUILDING_BLOCK_PROCESS_KEY = "building-block-process"
+ private const val SENDER_PROCESS_KEY = "case-message-sender"
+ private const val TEST_MESSAGE = "test-ready"
+ }
+}
diff --git a/backend/building-block/src/test/resources/config/building-block/notify/1-0-0/bpmn/notify-process.bpmn b/backend/building-block/src/test/resources/config/building-block/notify/1-0-0/bpmn/notify-process.bpmn
new file mode 100644
index 0000000000..61ee0bf85e
--- /dev/null
+++ b/backend/building-block/src/test/resources/config/building-block/notify/1-0-0/bpmn/notify-process.bpmn
@@ -0,0 +1,48 @@
+
+
+
+
+ Flow_1
+
+
+
+
+ Flow_1
+ Flow_2
+
+
+
+
+ Flow_2
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/building-block/src/test/resources/config/building-block/notify/1-0-0/building-block/building-block-definition-main-process-definition.json b/backend/building-block/src/test/resources/config/building-block/notify/1-0-0/building-block/building-block-definition-main-process-definition.json
new file mode 100644
index 0000000000..52806b9755
--- /dev/null
+++ b/backend/building-block/src/test/resources/config/building-block/notify/1-0-0/building-block/building-block-definition-main-process-definition.json
@@ -0,0 +1,3 @@
+{
+ "processDefinitionKey": "notify-process"
+}
diff --git a/backend/building-block/src/test/resources/config/building-block/notify/1-0-0/building-block/definition/notify.building-block-definition.json b/backend/building-block/src/test/resources/config/building-block/notify/1-0-0/building-block/definition/notify.building-block-definition.json
new file mode 100644
index 0000000000..6bb756191b
--- /dev/null
+++ b/backend/building-block/src/test/resources/config/building-block/notify/1-0-0/building-block/definition/notify.building-block-definition.json
@@ -0,0 +1,6 @@
+{
+ "key": "notify",
+ "name": "Notify",
+ "versionTag": "1.0.0",
+ "final": true
+}
diff --git a/backend/building-block/src/test/resources/config/building-block/notify/1-0-0/document/definition/notify.schema.document-definition.json b/backend/building-block/src/test/resources/config/building-block/notify/1-0-0/document/definition/notify.schema.document-definition.json
new file mode 100644
index 0000000000..51d256a23e
--- /dev/null
+++ b/backend/building-block/src/test/resources/config/building-block/notify/1-0-0/document/definition/notify.schema.document-definition.json
@@ -0,0 +1,12 @@
+{
+ "$id": "notify.schema",
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "title": "Notify",
+ "type": "object",
+ "properties": {
+ "notificationText": {
+ "type": "string",
+ "description": "Text of the notification"
+ }
+ }
+}
diff --git a/backend/building-block/src/test/resources/config/building-block/notify/1-1-0/bpmn/notify-process.bpmn b/backend/building-block/src/test/resources/config/building-block/notify/1-1-0/bpmn/notify-process.bpmn
new file mode 100644
index 0000000000..994d1e821e
--- /dev/null
+++ b/backend/building-block/src/test/resources/config/building-block/notify/1-1-0/bpmn/notify-process.bpmn
@@ -0,0 +1,48 @@
+
+
+
+
+ Flow_1
+
+
+
+
+ Flow_1
+ Flow_2
+
+
+
+
+ Flow_2
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/building-block/src/test/resources/config/building-block/notify/1-1-0/building-block/building-block-definition-main-process-definition.json b/backend/building-block/src/test/resources/config/building-block/notify/1-1-0/building-block/building-block-definition-main-process-definition.json
new file mode 100644
index 0000000000..52806b9755
--- /dev/null
+++ b/backend/building-block/src/test/resources/config/building-block/notify/1-1-0/building-block/building-block-definition-main-process-definition.json
@@ -0,0 +1,3 @@
+{
+ "processDefinitionKey": "notify-process"
+}
diff --git a/backend/building-block/src/test/resources/config/building-block/notify/1-1-0/building-block/definition/notify.building-block-definition.json b/backend/building-block/src/test/resources/config/building-block/notify/1-1-0/building-block/definition/notify.building-block-definition.json
new file mode 100644
index 0000000000..24026c523e
--- /dev/null
+++ b/backend/building-block/src/test/resources/config/building-block/notify/1-1-0/building-block/definition/notify.building-block-definition.json
@@ -0,0 +1,6 @@
+{
+ "key": "notify",
+ "name": "Notify",
+ "versionTag": "1.1.0",
+ "final": true
+}
diff --git a/backend/building-block/src/test/resources/config/building-block/notify/1-1-0/document/definition/notify.schema.document-definition.json b/backend/building-block/src/test/resources/config/building-block/notify/1-1-0/document/definition/notify.schema.document-definition.json
new file mode 100644
index 0000000000..51d256a23e
--- /dev/null
+++ b/backend/building-block/src/test/resources/config/building-block/notify/1-1-0/document/definition/notify.schema.document-definition.json
@@ -0,0 +1,12 @@
+{
+ "$id": "notify.schema",
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "title": "Notify",
+ "type": "object",
+ "properties": {
+ "notificationText": {
+ "type": "string",
+ "description": "Text of the notification"
+ }
+ }
+}
diff --git a/backend/building-block/src/test/resources/config/case/bb-case/1-0-0/bpmn/case-message-sender.bpmn b/backend/building-block/src/test/resources/config/case/bb-case/1-0-0/bpmn/case-message-sender.bpmn
new file mode 100644
index 0000000000..dacaf9c533
--- /dev/null
+++ b/backend/building-block/src/test/resources/config/case/bb-case/1-0-0/bpmn/case-message-sender.bpmn
@@ -0,0 +1,42 @@
+
+
+
+
+ Flow_1
+
+
+ Flow_1
+ Flow_2
+
+
+
+ Flow_2
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/building-block/src/test/resources/config/case/bb-case/1-0-0/bpmn/case-start-message-sender.bpmn b/backend/building-block/src/test/resources/config/case/bb-case/1-0-0/bpmn/case-start-message-sender.bpmn
new file mode 100644
index 0000000000..0bc03f9fb7
--- /dev/null
+++ b/backend/building-block/src/test/resources/config/case/bb-case/1-0-0/bpmn/case-start-message-sender.bpmn
@@ -0,0 +1,42 @@
+
+
+
+
+ Flow_1
+
+
+ Flow_1
+ Flow_2
+
+
+
+ Flow_2
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/process-document/src/main/kotlin/com/ritense/processdocument/autoconfigure/ProcessDocumentsAutoConfiguration.kt b/backend/process-document/src/main/kotlin/com/ritense/processdocument/autoconfigure/ProcessDocumentsAutoConfiguration.kt
index bba134424f..3bc01cde6f 100644
--- a/backend/process-document/src/main/kotlin/com/ritense/processdocument/autoconfigure/ProcessDocumentsAutoConfiguration.kt
+++ b/backend/process-document/src/main/kotlin/com/ritense/processdocument/autoconfigure/ProcessDocumentsAutoConfiguration.kt
@@ -43,6 +43,10 @@ import com.ritense.processdocument.repository.OperatonProcessDefinitionCaseDefin
import com.ritense.processdocument.repository.ProcessDefinitionCaseDefinitionRepository
import com.ritense.processdocument.repository.ProcessDocumentInstanceRepository
import com.ritense.processdocument.repository.TaskQuickSearchRepository
+import com.ritense.processdocument.service.CaseCorrelationBusinessKeyProvider
+import com.ritense.processdocument.service.CaseCorrelationService
+import com.ritense.processdocument.service.CaseCorrelationServiceImpl
+import com.ritense.processdocument.service.CaseCorrelationStartTargetProvider
import com.ritense.processdocument.service.CaseDefinitionProcessLinkService
import com.ritense.processdocument.service.CaseTaskListSearchService
import com.ritense.processdocument.service.CorrelationService
@@ -164,6 +168,25 @@ class ProcessDocumentsAutoConfiguration {
)
}
+ @ProcessBean
+ @Bean
+ @ConditionalOnMissingBean(CaseCorrelationService::class)
+ fun caseCorrelationService(
+ runtimeService: RuntimeService,
+ correlationService: CorrelationService,
+ caseDocumentResolver: CaseDocumentResolver,
+ caseCorrelationBusinessKeyProviders: List,
+ caseCorrelationStartTargetProviders: List,
+ ): CaseCorrelationService {
+ return CaseCorrelationServiceImpl(
+ runtimeService = runtimeService,
+ correlationService = correlationService,
+ caseDocumentResolver = caseDocumentResolver,
+ businessKeyProviders = caseCorrelationBusinessKeyProviders,
+ startTargetProviders = caseCorrelationStartTargetProviders,
+ )
+ }
+
@ProcessBean
@Bean("processService")
@ConditionalOnMissingBean(ProcessDocumentsService::class)
diff --git a/backend/process-document/src/main/kotlin/com/ritense/processdocument/service/CaseCorrelationBusinessKeyProvider.kt b/backend/process-document/src/main/kotlin/com/ritense/processdocument/service/CaseCorrelationBusinessKeyProvider.kt
new file mode 100644
index 0000000000..ba154347cb
--- /dev/null
+++ b/backend/process-document/src/main/kotlin/com/ritense/processdocument/service/CaseCorrelationBusinessKeyProvider.kt
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2015-2026 Ritense BV, the Netherlands.
+ *
+ * Licensed under EUPL, Version 1.2 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.ritense.processdocument.service
+
+import java.util.UUID
+
+/**
+ * Extension point that contributes business keys of additional process instances belonging to a
+ * case, e.g. building-block instances that run under their own document id. Used by
+ * [CaseCorrelationService] to fan a message out over every process instance of a case.
+ *
+ * Implemented by the building-block module when present; absent when an app does not include it.
+ */
+fun interface CaseCorrelationBusinessKeyProvider {
+
+ /**
+ * Returns the business keys of the process instances this provider knows about for the given
+ * case, excluding the case's own processes (those already use the case document id).
+ */
+ fun getBusinessKeysForCase(caseDocumentId: UUID): List
+}
diff --git a/backend/process-document/src/main/kotlin/com/ritense/processdocument/service/CaseCorrelationService.kt b/backend/process-document/src/main/kotlin/com/ritense/processdocument/service/CaseCorrelationService.kt
new file mode 100644
index 0000000000..3820b6d0e8
--- /dev/null
+++ b/backend/process-document/src/main/kotlin/com/ritense/processdocument/service/CaseCorrelationService.kt
@@ -0,0 +1,166 @@
+/*
+ * Copyright 2015-2026 Ritense BV, the Netherlands.
+ *
+ * Licensed under EUPL, Version 1.2 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.ritense.processdocument.service
+
+import org.operaton.bpm.engine.delegate.DelegateExecution
+import org.operaton.bpm.engine.runtime.MessageCorrelationResult
+import org.operaton.bpm.engine.runtime.ProcessInstance
+
+/**
+ * Sends BPMN messages to every process instance that belongs to a case: the case's own processes
+ * and every building-block instance of that case.
+ *
+ * Where [CorrelationService] correlates on a single business key, this service resolves the case a
+ * message is meant for and fans the message out over all business keys of that case. This makes
+ * building blocks — which run under their own document id as business key — reachable from case
+ * processes and vice versa.
+ *
+ * Available in BPMN as `caseCorrelationService`, e.g.
+ * `${caseCorrelationService.sendCatchEventMessageToCase("income-verified", execution)}`.
+ */
+interface CaseCorrelationService {
+
+ /**
+ * Sends [message] to all subscribed process instances of the case the [execution] belongs to.
+ *
+ * @throws IllegalStateException when the current case cannot be determined from the execution.
+ */
+ fun sendCatchEventMessageToCase(
+ message: String,
+ execution: DelegateExecution
+ ): List
+
+ /**
+ * Sends [message] to all subscribed process instances of the case the [execution] belongs to,
+ * with [variables] as alternating name/value pairs.
+ */
+ fun sendCatchEventMessageToCase(
+ message: String,
+ execution: DelegateExecution,
+ vararg variables: Any?
+ ): List
+
+ /**
+ * Sends [message] to all subscribed process instances of the case the [execution] belongs to,
+ * with [variables] set on the receiving process instances.
+ */
+ fun sendCatchEventMessageToCase(
+ message: String,
+ execution: DelegateExecution,
+ variables: Map?
+ ): List
+
+ /**
+ * Sends [message] to all subscribed process instances of the case identified by
+ * [caseDocumentId], for example a related case. A building-block document id is accepted too
+ * and is normalized to the case it belongs to.
+ *
+ * @throws IllegalArgumentException when [caseDocumentId] is not a valid document id.
+ */
+ fun sendCatchEventMessageToCase(
+ message: String,
+ caseDocumentId: String
+ ): List
+
+ /**
+ * Sends [message] to all subscribed process instances of the case identified by
+ * [caseDocumentId], with [variables] as alternating name/value pairs.
+ */
+ fun sendCatchEventMessageToCase(
+ message: String,
+ caseDocumentId: String,
+ vararg variables: Any?
+ ): List
+
+ /**
+ * Sends [message] to all subscribed process instances of the case identified by
+ * [caseDocumentId], with [variables] set on the receiving process instances.
+ */
+ fun sendCatchEventMessageToCase(
+ message: String,
+ caseDocumentId: String,
+ variables: Map?
+ ): List
+
+ /**
+ * Starts a new instance of every building block that is linked to the case the [execution]
+ * belongs to and whose main process declares a message start event named [message]. The
+ * building block is started in the version the case link pins, not the latest deployed one.
+ *
+ * The returned process instances carry the case business key as it was at start time; the
+ * building-block bootstrap rewrites it to the new building-block document id within the same
+ * transaction.
+ *
+ * @throws IllegalStateException when the current case cannot be determined from the execution.
+ */
+ fun sendStartMessageToCase(
+ message: String,
+ execution: DelegateExecution
+ ): List
+
+ /**
+ * Starts the matching building blocks of the case the [execution] belongs to, with [variables]
+ * as alternating name/value pairs.
+ */
+ fun sendStartMessageToCase(
+ message: String,
+ execution: DelegateExecution,
+ vararg variables: Any?
+ ): List
+
+ /**
+ * Starts the matching building blocks of the case the [execution] belongs to, with [variables]
+ * set on the started process instances.
+ */
+ fun sendStartMessageToCase(
+ message: String,
+ execution: DelegateExecution,
+ variables: Map?
+ ): List
+
+ /**
+ * Starts the matching building blocks of the case identified by [caseDocumentId], for example a
+ * related case. A building-block document id is accepted too and is normalized to the case it
+ * belongs to.
+ *
+ * @throws IllegalArgumentException when [caseDocumentId] is not a valid document id.
+ */
+ fun sendStartMessageToCase(
+ message: String,
+ caseDocumentId: String
+ ): List
+
+ /**
+ * Starts the matching building blocks of the case identified by [caseDocumentId], with
+ * [variables] as alternating name/value pairs.
+ */
+ fun sendStartMessageToCase(
+ message: String,
+ caseDocumentId: String,
+ vararg variables: Any?
+ ): List
+
+ /**
+ * Starts the matching building blocks of the case identified by [caseDocumentId], with
+ * [variables] set on the started process instances.
+ */
+ fun sendStartMessageToCase(
+ message: String,
+ caseDocumentId: String,
+ variables: Map?
+ ): List
+}
diff --git a/backend/process-document/src/main/kotlin/com/ritense/processdocument/service/CaseCorrelationServiceImpl.kt b/backend/process-document/src/main/kotlin/com/ritense/processdocument/service/CaseCorrelationServiceImpl.kt
new file mode 100644
index 0000000000..5167693823
--- /dev/null
+++ b/backend/process-document/src/main/kotlin/com/ritense/processdocument/service/CaseCorrelationServiceImpl.kt
@@ -0,0 +1,242 @@
+/*
+ * Copyright 2015-2026 Ritense BV, the Netherlands.
+ *
+ * Licensed under EUPL, Version 1.2 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.ritense.processdocument.service
+
+import com.ritense.authorization.AuthorizationContext.Companion.runWithoutAuthorization
+import com.ritense.processdocument.helper.GetJsonSchemaDocumentHelper.getJsonSchemaDocumentIdOrNull
+import com.ritense.valtimo.contract.document.CaseDocumentResolver
+import io.github.oshai.kotlinlogging.KotlinLogging
+import java.util.UUID
+import org.operaton.bpm.engine.RuntimeService
+import org.operaton.bpm.engine.delegate.DelegateExecution
+import org.operaton.bpm.engine.runtime.MessageCorrelationResult
+import org.operaton.bpm.engine.runtime.ProcessInstance
+
+class CaseCorrelationServiceImpl(
+ private val runtimeService: RuntimeService,
+ private val correlationService: CorrelationService,
+ private val caseDocumentResolver: CaseDocumentResolver,
+ private val businessKeyProviders: List,
+ private val startTargetProviders: List,
+) : CaseCorrelationService {
+
+ override fun sendCatchEventMessageToCase(
+ message: String,
+ execution: DelegateExecution
+ ): List {
+ return sendCatchEventMessageToCase(message, execution, null as Map?)
+ }
+
+ override fun sendCatchEventMessageToCase(
+ message: String,
+ execution: DelegateExecution,
+ vararg variables: Any?
+ ): List {
+ return sendCatchEventMessageToCase(message, execution, toVariableMap(*variables))
+ }
+
+ override fun sendCatchEventMessageToCase(
+ message: String,
+ execution: DelegateExecution,
+ variables: Map?
+ ): List {
+ return sendCatchEventMessageToCase(message, resolveCaseDocumentId(execution), variables)
+ }
+
+ override fun sendCatchEventMessageToCase(
+ message: String,
+ caseDocumentId: String
+ ): List {
+ return sendCatchEventMessageToCase(message, caseDocumentId, null as Map?)
+ }
+
+ override fun sendCatchEventMessageToCase(
+ message: String,
+ caseDocumentId: String,
+ vararg variables: Any?
+ ): List {
+ return sendCatchEventMessageToCase(message, caseDocumentId, toVariableMap(*variables))
+ }
+
+ override fun sendCatchEventMessageToCase(
+ message: String,
+ caseDocumentId: String,
+ variables: Map?
+ ): List {
+ return sendCatchEventMessageToCase(message, resolveCaseDocumentId(caseDocumentId), variables)
+ }
+
+ private fun sendCatchEventMessageToCase(
+ message: String,
+ caseDocumentId: UUID,
+ variables: Map?
+ ): List {
+ val caseBusinessKey = caseDocumentId.toString()
+
+ // Delivery to the case's own processes keeps the behaviour of CorrelationService, including
+ // the ProcessDocumentInstance association.
+ val caseResults = correlationService.sendCatchEventMessageToAll(message, caseBusinessKey, variables)
+
+ // Building blocks run under their own document id as business key. They get the message too,
+ // but deliberately without a case association: their processes belong to the BB document.
+ val additionalResults = businessKeyProviders
+ .flatMap { it.getBusinessKeysForCase(caseDocumentId) }
+ .distinct()
+ .filterNot { it == caseBusinessKey }
+ .flatMap { correlateAll(message, it, variables) }
+
+ val results = caseResults + additionalResults
+ if (results.isEmpty()) {
+ logger.warn {
+ "No process instance of case '$caseBusinessKey' was subscribed to message '$message'."
+ }
+ }
+ return results
+ }
+
+ override fun sendStartMessageToCase(
+ message: String,
+ execution: DelegateExecution
+ ): List {
+ return sendStartMessageToCase(message, execution, null as Map?)
+ }
+
+ override fun sendStartMessageToCase(
+ message: String,
+ execution: DelegateExecution,
+ vararg variables: Any?
+ ): List {
+ return sendStartMessageToCase(message, execution, toVariableMap(*variables))
+ }
+
+ override fun sendStartMessageToCase(
+ message: String,
+ execution: DelegateExecution,
+ variables: Map?
+ ): List {
+ return sendStartMessageToCase(message, resolveCaseDocumentId(execution), variables)
+ }
+
+ override fun sendStartMessageToCase(
+ message: String,
+ caseDocumentId: String
+ ): List {
+ return sendStartMessageToCase(message, caseDocumentId, null as Map?)
+ }
+
+ override fun sendStartMessageToCase(
+ message: String,
+ caseDocumentId: String,
+ vararg variables: Any?
+ ): List {
+ return sendStartMessageToCase(message, caseDocumentId, toVariableMap(*variables))
+ }
+
+ override fun sendStartMessageToCase(
+ message: String,
+ caseDocumentId: String,
+ variables: Map?
+ ): List {
+ return sendStartMessageToCase(message, resolveCaseDocumentId(caseDocumentId), variables)
+ }
+
+ private fun sendStartMessageToCase(
+ message: String,
+ caseDocumentId: UUID,
+ variables: Map?
+ ): List {
+ val processInstances = startTargetProviders
+ .flatMap { it.getStartTargets(caseDocumentId, message) }
+ .distinct()
+ .map { correlateStartMessage(message, caseDocumentId.toString(), it, variables) }
+
+ if (processInstances.isEmpty()) {
+ logger.warn {
+ "No process definition linked to case '$caseDocumentId' declares a start event " +
+ "for message '$message'."
+ }
+ }
+ return processInstances
+ }
+
+ private fun resolveCaseDocumentId(execution: DelegateExecution): UUID {
+ val documentId = execution.getJsonSchemaDocumentIdOrNull()
+ ?: throw IllegalStateException(
+ "Cannot determine the current case for process instance '${execution.processInstanceId}': " +
+ "the business key is not a document id. " +
+ "Use the overload that accepts an explicit case document id."
+ )
+ return resolveCaseDocumentId(documentId)
+ }
+
+ private fun resolveCaseDocumentId(caseDocumentId: String): UUID {
+ val documentId = try {
+ UUID.fromString(caseDocumentId)
+ } catch (e: IllegalArgumentException) {
+ throw IllegalArgumentException(
+ "Cannot send a message to case '$caseDocumentId': the value is not a valid document id.", e
+ )
+ }
+ return resolveCaseDocumentId(documentId)
+ }
+
+ /**
+ * Resolving the owning case is an internal step of message delivery, so it runs in system
+ * context — like the document lookups in [CorrelationServiceImpl]. Message correlation itself
+ * is not permission-checked either, and expressions are also evaluated from async jobs and
+ * timers, which carry no user context.
+ */
+ private fun resolveCaseDocumentId(documentId: UUID): UUID {
+ return runWithoutAuthorization { caseDocumentResolver.resolveCaseDocumentId(documentId) }
+ }
+
+ private fun correlateAll(
+ message: String,
+ businessKey: String,
+ variables: Map?
+ ): List {
+ val builder = runtimeService.createMessageCorrelation(message)
+ builder.processInstanceBusinessKey(businessKey)
+ variables?.run { builder.setVariables(variables) }
+ return builder.correlateAllWithResult()
+ }
+
+ private fun correlateStartMessage(
+ message: String,
+ businessKey: String,
+ processDefinitionId: String,
+ variables: Map?
+ ): ProcessInstance {
+ val builder = runtimeService.createMessageCorrelation(message)
+ builder.processDefinitionId(processDefinitionId)
+ builder.processInstanceBusinessKey(businessKey)
+ variables?.run { builder.setVariables(variables) }
+ return builder.correlateStartMessage()
+ }
+
+ private fun toVariableMap(vararg variables: Any?): Map? {
+ return if (variables.isNotEmpty()) {
+ (0 until variables.size / 2).associate { i -> variables[i * 2] as String to variables[i * 2 + 1] }
+ } else {
+ null
+ }
+ }
+
+ private companion object {
+ private val logger = KotlinLogging.logger {}
+ }
+}
diff --git a/backend/process-document/src/main/kotlin/com/ritense/processdocument/service/CaseCorrelationStartTargetProvider.kt b/backend/process-document/src/main/kotlin/com/ritense/processdocument/service/CaseCorrelationStartTargetProvider.kt
new file mode 100644
index 0000000000..10cf710818
--- /dev/null
+++ b/backend/process-document/src/main/kotlin/com/ritense/processdocument/service/CaseCorrelationStartTargetProvider.kt
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2015-2026 Ritense BV, the Netherlands.
+ *
+ * Licensed under EUPL, Version 1.2 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.ritense.processdocument.service
+
+import java.util.UUID
+
+/**
+ * Extension point that contributes process definitions which may be started by message for a case,
+ * e.g. the main processes of the building blocks linked to the case definition.
+ *
+ * Targets are returned as concrete process definition ids rather than keys: a case pins a specific
+ * building-block version, and engine-level start correlation would always pick the latest deployed
+ * version of a key.
+ *
+ * Implemented by the building-block module when present; absent when an app does not include it.
+ */
+fun interface CaseCorrelationStartTargetProvider {
+
+ /**
+ * Returns the ids of the process definitions that declare a message start event named [message]
+ * and may be started for the given case.
+ */
+ fun getStartTargets(caseDocumentId: UUID, message: String): List
+}
diff --git a/backend/process-document/src/test/kotlin/com/ritense/processdocument/service/CaseCorrelationServiceImplTest.kt b/backend/process-document/src/test/kotlin/com/ritense/processdocument/service/CaseCorrelationServiceImplTest.kt
new file mode 100644
index 0000000000..a4ddde8599
--- /dev/null
+++ b/backend/process-document/src/test/kotlin/com/ritense/processdocument/service/CaseCorrelationServiceImplTest.kt
@@ -0,0 +1,379 @@
+/*
+ * Copyright 2015-2026 Ritense BV, the Netherlands.
+ *
+ * Licensed under EUPL, Version 1.2 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.ritense.processdocument.service
+
+import com.ritense.valtimo.contract.document.CaseDocumentResolutionException
+import com.ritense.valtimo.contract.document.CaseDocumentResolver
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.mockito.Mockito.RETURNS_DEEP_STUBS
+import org.mockito.kotlin.any
+import org.mockito.kotlin.anyOrNull
+import org.mockito.kotlin.eq
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.never
+import org.mockito.kotlin.times
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+import org.operaton.bpm.engine.RuntimeService
+import org.operaton.bpm.engine.delegate.DelegateExecution
+import org.operaton.bpm.engine.runtime.MessageCorrelationBuilder
+import org.operaton.bpm.engine.runtime.MessageCorrelationResult
+import org.operaton.bpm.engine.runtime.ProcessInstance
+import java.util.UUID
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+import kotlin.test.assertFailsWith
+
+class CaseCorrelationServiceImplTest {
+
+ lateinit var runtimeService: RuntimeService
+ lateinit var correlationService: CorrelationService
+ lateinit var caseDocumentResolver: CaseDocumentResolver
+ lateinit var builder: MessageCorrelationBuilder
+
+ val messageName = "test-message"
+ val caseDocumentId: UUID = UUID.randomUUID()
+ val buildingBlockDocumentId: UUID = UUID.randomUUID()
+ val processInstanceId: String = UUID.randomUUID().toString()
+
+ @BeforeEach
+ fun setUp() {
+ runtimeService = mock(defaultAnswer = RETURNS_DEEP_STUBS)
+ correlationService = mock()
+ caseDocumentResolver = mock()
+
+ builder = mock()
+ whenever(runtimeService.createMessageCorrelation(any())).thenReturn(builder)
+ whenever(builder.processInstanceBusinessKey(any())).thenReturn(builder)
+ whenever(builder.setVariables(any())).thenReturn(builder)
+ whenever(builder.correlateAllWithResult()).thenReturn(emptyList())
+ }
+
+ // --- Case resolution from an execution ---
+
+ @Test
+ fun `should deliver to case and building blocks when execution runs on the case itself`() {
+ whenever(caseDocumentResolver.resolveCaseDocumentId(caseDocumentId)).thenReturn(caseDocumentId)
+ val service = caseCorrelationService(providerReturning(buildingBlockDocumentId.toString()))
+ val caseResult = mock()
+ whenever(
+ correlationService.sendCatchEventMessageToAll(
+ eq(messageName), eq(caseDocumentId.toString()), anyOrNull