diff --git a/doc/wiki/bpmn-workflows.md b/doc/wiki/bpmn-workflows.md
index eb1283eea..d1c326631 100644
--- a/doc/wiki/bpmn-workflows.md
+++ b/doc/wiki/bpmn-workflows.md
@@ -28,7 +28,7 @@ elsa.AddBpmnInterchange();
- **`Activities`** — the Elsa activities bound to this scope (one per `BpmnWorkBinding`).
- **`IsRootScope`** — left `false` on every scope the binder produces; the caller sets it to `true` to mark the outermost scope as a workflow entry point.
-`BpmnProcess` completes with the interpreter's outcome name (e.g. `BpmnInterpreter.DoneOutcomeName`). It does **not** complete with `Outcomes.Default`, so connections from it must target explicit outcome ports.
+`BpmnProcess` completes with the interpreter's outcome name — `BpmnInterpreter.DoneOutcomeName` ("Done") normally, or `BpmnInterpreter.CancelledOutcomeName` ("Cancelled") when a cancel end event cancelled a transaction. It does **not** complete with `Outcomes.Default`. Both outcomes are declared flow ports (`[FlowNode(BpmnInterpreter.DoneOutcomeName, BpmnInterpreter.CancelledOutcomeName)]`), so connections from it target one of them explicitly.
## Work Ledger
diff --git a/src/modules/Elsa.Bpmn/Activities/BpmnProcess.cs b/src/modules/Elsa.Bpmn/Activities/BpmnProcess.cs
index 6db6601b8..c2a03ed33 100644
--- a/src/modules/Elsa.Bpmn/Activities/BpmnProcess.cs
+++ b/src/modules/Elsa.Bpmn/Activities/BpmnProcess.cs
@@ -2,6 +2,7 @@ using System.Runtime.CompilerServices;
using System.Text.Json.Serialization;
using System.Xml;
using Bpmn.Model;
+using Bpmn.Semantics;
using Elsa.Bpmn.Hosting;
using Elsa.Bpmn.Signals;
using Elsa.Extensions;
@@ -9,6 +10,7 @@ using Elsa.Scheduling;
using Elsa.Scheduling.Bookmarks;
using Elsa.Workflows;
using Elsa.Workflows.Activities;
+using Elsa.Workflows.Activities.Flowchart.Attributes;
using Elsa.Workflows.Attributes;
using Elsa.Workflows.Models;
using Elsa.Workflows.Runtime;
@@ -33,13 +35,15 @@ namespace Elsa.Bpmn.Activities;
/// continuation, and its outcome is what a conditional sequence flow in the enclosing scope selects on.
///
///
-/// Composing this activity into a Flowchart: it completes with only the interpreter's outcome name (e.g.
-/// BpmnInterpreter.DoneOutcomeName, and CancelledOutcomeName where relevant) — not with
-/// Outcomes.Default, which an ordinary activity's null result also produces and which additionally matches a
-/// null-port connection. A Connection built with the default/null-port shorthand will therefore never fire
-/// from this activity; always target an explicit outcome port.
+/// Composing this activity into a Flowchart: it completes with only the interpreter's outcome name —
+/// normally, or
+/// when a cancel end event cancelled a transaction — never with Outcomes.Default, which an ordinary
+/// activity's null result also produces and which additionally matches a null-port connection. Both outcomes are
+/// declared flow ports, so a Connection targets one of them explicitly; the default/null-port shorthand will
+/// never fire from this activity.
///
///
+[FlowNode(BpmnInterpreter.DoneOutcomeName, BpmnInterpreter.CancelledOutcomeName)]
[Activity("Elsa", "BPMN", "Executes a BPMN process scope.")]
[System.ComponentModel.Browsable(false)]
public class BpmnProcess : Container, ITrigger
diff --git a/test/integration/Elsa.Bpmn.IntegrationTests/Scenarios/Composition/BpmnCompositionTests.cs b/test/integration/Elsa.Bpmn.IntegrationTests/Scenarios/Composition/BpmnCompositionTests.cs
index 7ed0628ba..ec8f50a20 100644
--- a/test/integration/Elsa.Bpmn.IntegrationTests/Scenarios/Composition/BpmnCompositionTests.cs
+++ b/test/integration/Elsa.Bpmn.IntegrationTests/Scenarios/Composition/BpmnCompositionTests.cs
@@ -50,6 +50,38 @@ public class BpmnCompositionTests(ITestOutputHelper testOutputHelper)
Assert.Equal(WorkflowSubStatus.Finished, result.WorkflowState.SubStatus);
}
+ [Fact(DisplayName = "A BPMN process composed into a flowchart and connected on both outcomes routes down the one it completes with")]
+ public async Task BpmnProcessInsideAFlowchart_RoutesDownTheCancelledPortWhenTheProcessCancels()
+ {
+ // The gap this closes: the activity declares both outcomes as flow ports, so a flowchart can connect the
+ // Cancelled port explicitly rather than only ever seeing Studio's synthesized default port.
+
+ // Arrange
+ var process = BpmnTestProcesses.CancelledTransaction(_host.Log);
+ var onCancelled = Work("on-cancelled");
+ var onDone = Work("on-done");
+
+ var flowchart = new Flowchart
+ {
+ Start = process,
+ Activities = { process, onCancelled, onDone },
+ Connections =
+ {
+ new Connection(new Endpoint(process, BpmnInterpreter.CancelledOutcomeName), new Endpoint(onCancelled)),
+ new Connection(new Endpoint(process, BpmnInterpreter.DoneOutcomeName), new Endpoint(onDone))
+ }
+ };
+
+ // Act
+ var result = await _host.RunAsync(flowchart);
+
+ // Assert: only the Cancelled branch ran.
+ Assert.Contains("executed:work", _host.Log.Entries);
+ Assert.Contains("executed:on-cancelled", _host.Log.Entries);
+ Assert.DoesNotContain("executed:on-done", _host.Log.Entries);
+ Assert.Equal(WorkflowSubStatus.Finished, result.WorkflowState.SubStatus);
+ }
+
[Fact(DisplayName = "A nested scope runs with the trigger opt-out off, which is its default")]
public async Task NestedScope_RunsWithTheTriggerOptOutOff()
{
diff --git a/test/integration/Elsa.Bpmn.IntegrationTests/Scenarios/HostPort/BpmnTestProcesses.Compensation.cs b/test/integration/Elsa.Bpmn.IntegrationTests/Scenarios/HostPort/BpmnTestProcesses.Compensation.cs
index fa0983d98..e551fdbe9 100644
--- a/test/integration/Elsa.Bpmn.IntegrationTests/Scenarios/HostPort/BpmnTestProcesses.Compensation.cs
+++ b/test/integration/Elsa.Bpmn.IntegrationTests/Scenarios/HostPort/BpmnTestProcesses.Compensation.cs
@@ -191,6 +191,23 @@ internal static partial class BpmnTestProcesses
return Scope("scope", definition, nested, Immediate("after", log));
}
+ ///
+ /// A root scope that is itself a transaction, cancelled from within by its own cancel end event — nothing nests
+ /// it, so the scope's own completion outcome is Cancelled rather than Done.
+ ///
+ public static BpmnProcess CancelledTransaction(BpmnTestLog log)
+ {
+ var definition = new BpmnProcessBuilder("cancelled-transaction")
+ .Transaction()
+ .StartEvent("start")
+ .Task("work", bindingRef: BindingRef("work"))
+ .EndEvent("cancelled", null, Cancel())
+ .ConnectSequence("start", "work", "cancelled")
+ .Build();
+
+ return Scope("scope", definition, Immediate("work", log));
+ }
+
///
/// A transaction subprocess that starts a compensation replay on one branch and cancels itself on the other while
/// that replay is still running, so the replay's claimed-but-unrun log entries are torn down mid-run.
diff --git a/test/unit/Elsa.Bpmn.UnitTests/BpmnProcessDescriptorTests.cs b/test/unit/Elsa.Bpmn.UnitTests/BpmnProcessDescriptorTests.cs
new file mode 100644
index 000000000..62b9202f1
--- /dev/null
+++ b/test/unit/Elsa.Bpmn.UnitTests/BpmnProcessDescriptorTests.cs
@@ -0,0 +1,34 @@
+using System.Reflection;
+using Bpmn.Semantics;
+using Elsa.Bpmn.Activities;
+using Elsa.Workflows;
+using Elsa.Workflows.Models;
+using NSubstitute;
+using Xunit;
+
+namespace Elsa.Bpmn.UnitTests;
+
+public class BpmnProcessDescriptorTests
+{
+ [Fact(DisplayName = "BpmnProcess's descriptor declares Done and Cancelled as its only flow ports")]
+ public async Task DescribeActivityAsync_DeclaresDoneAndCancelledAsOnlyFlowPorts()
+ {
+ var defaultValueResolver = Substitute.For();
+ var propertyUIHandlerResolver = Substitute.For();
+
+ defaultValueResolver.GetDefaultValue(Arg.Any()).Returns((object?)null);
+ propertyUIHandlerResolver
+ .GetUIPropertiesAsync(Arg.Any(), Arg.Any