diff --git a/test/integration/Elsa.Bpmn.IntegrationTests/Scenarios/HostPort/BpmnCompensationTests.cs b/test/integration/Elsa.Bpmn.IntegrationTests/Scenarios/HostPort/BpmnCompensationTests.cs
new file mode 100644
index 000000000..57969042d
--- /dev/null
+++ b/test/integration/Elsa.Bpmn.IntegrationTests/Scenarios/HostPort/BpmnCompensationTests.cs
@@ -0,0 +1,161 @@
+using Elsa.Workflows;
+using Elsa.Workflows.IncidentStrategies;
+using Xunit.Abstractions;
+
+namespace Elsa.Bpmn.IntegrationTests.Scenarios.HostPort;
+
+///
+/// Compensation, transaction cancellation, and the two things about them that are the host's to get right: a
+/// compensation handler binds work but is reached only by replay, and a transaction that completes Cancelled
+/// carries that outcome to its enclosing scope.
+///
+///
+///
+/// The compensation log, the reverse ordering, and the claim/release all belong to the interpreter. What arrives here
+/// is an ordinary StartWork, applied like any other, so these are process-level tests rather than tests of a
+/// compensation-specific code path — there is none.
+///
+///
+/// Every process runs under . Compensation's failure mode is a quiet one: a replay that
+/// claims nothing, a handler that is skipped, an unroutable cancellation treated as an ordinary completion all leave
+/// a workflow that finished and reported nothing. Faulting rather than absorbing into an incident is what keeps a
+/// teardown this host cannot honour from being buried under work that carried on regardless.
+///
+///
+public class BpmnCompensationTests(ITestOutputHelper testOutputHelper)
+{
+ private readonly BpmnTestHost _host = new(testOutputHelper);
+
+ [Fact(DisplayName = "A compensate end event replays every registered handler, in reverse registration order")]
+ public async Task CompensatedBookings_ReplaysEveryHandlerInReverseRegistrationOrder()
+ {
+ // The whole log, not a set of Contains assertions: "all three handlers ran" is also true of a replay that
+ // walked the log forwards, and of one that ran them in an order nothing decided. Three registrations are what
+ // makes the difference between reversed and merely permuted visible.
+
+ // Act
+ var result = await _host.RunAsync(BpmnTestProcesses.CompensatedBookings(_host.Log), typeof(FaultStrategy));
+
+ // Assert
+ Assert.Equal(
+ [
+ "executed:bookFlight", "executed:bookHotel", "executed:bookCar",
+ "executed:undoCar", "executed:undoHotel", "executed:undoFlight"
+ ],
+ _host.Log.Entries);
+
+ Assert.Empty(result.WorkflowState.Incidents);
+ Assert.Equal(WorkflowSubStatus.Finished, result.WorkflowState.SubStatus);
+ }
+
+ [Fact(DisplayName = "A compensate throw event naming an activityRef replays only that activity's handler")]
+ public async Task TargetedCompensation_ReplaysOnlyTheNamedActivitysHandler()
+ {
+ // This is also where "a compensation handler is never scheduled from flow" is observable: undoFlight and
+ // undoCar are bound as this scope's work exactly like undoHotel is, and the only reason they do not run is
+ // that nothing selected them. A container that scheduled handlers from the graph would run all three.
+
+ // Act
+ var result = await _host.RunAsync(BpmnTestProcesses.TargetedCompensation(_host.Log), typeof(FaultStrategy));
+
+ // Assert: only the named activity's handler ran, and the throw then routed its outbound flow.
+ Assert.Equal(
+ [
+ "executed:bookFlight", "executed:bookHotel", "executed:bookCar",
+ "executed:undoHotel", "executed:after"
+ ],
+ _host.Log.Entries);
+
+ Assert.Empty(result.WorkflowState.Incidents);
+ Assert.Equal(WorkflowSubStatus.Finished, result.WorkflowState.SubStatus);
+ }
+
+ [Fact(DisplayName = "A compensation run torn down mid-replay releases the log entries it claimed and never ran")]
+ public async Task CompensationRunCancelledMidReplay_ReleasesTheEntriesItNeverRan()
+ {
+ // The quiet failure this pins: an entry claimed by a run that was torn down before it ran stays Claimed, which
+ // makes it invisible to every later selection. The transaction would then cancel with nothing left to
+ // compensate, complete, route its cancel boundary, and finish looking entirely healthy -- with refundCard
+ // never having run and no trace that it was skipped.
+
+ // Arrange: chargeCard and reserveSeat registered in that order, so the replay claims both and runs them in
+ // reverse. releaseSeat is the head and blocks; refundCard is claimed and has not started.
+ await _host.RunAsync(BpmnTestProcesses.CompensationRunCancelledMidReplay(_host.Log), typeof(FaultStrategy));
+
+ Assert.Equal(1, _host.Log.Occurrences("executed:releaseSeat"));
+ Assert.DoesNotContain("executed:refundCard", _host.Log.Entries);
+
+ // Act: the other branch cancels the transaction, which stops the replay's coordinating token.
+ await _host.FinishWorkAsync("fraudCheck");
+
+ // Assert: the released entries are registered again, so the cancellation's own replay claims them -- the head
+ // handler starting a second time is both the first half of the release being real *and* the symptom of
+ // #7959: BpmnInterpreter.CancelTransaction drops the live work it is abandoning without ever producing a
+ // PendingTeardown/CancelWorkSubtree command for it, so the host's original ledger record and bookmark for
+ // the releaseSeat slot survive untouched alongside the replay's freshly started one. The scope now holds two
+ // live records for the same (BindingRef, IterationId) slot -- pinned below so this fails the moment #7959
+ // lands, at which point it should be changed to assert a single record.
+ Assert.Equal(2, _host.Log.Occurrences("executed:releaseSeat"));
+
+ var releaseSeatBindingRef = BpmnTestProcesses.BindingRef("releaseSeat");
+ var releaseSeatLiveRecords = _host.LiveWorkOf("sub").Count(record => record.BindingRef == releaseSeatBindingRef);
+ Assert.Equal(2, releaseSeatLiveRecords);
+
+ // And the entry that was claimed but never ran is reached once that head handler finishes: the other half.
+ var result = await _host.FinishWorkAsync("releaseSeat");
+
+ Assert.Equal(1, _host.Log.Occurrences("executed:refundCard"));
+
+ // The transaction still completes Cancelled, so the enclosing scope takes the boundary path and not the
+ // ordinary sequence flow.
+ Assert.Contains("executed:unwind", _host.Log.Entries);
+ Assert.DoesNotContain("executed:after", _host.Log.Entries);
+
+ Assert.Empty(result.WorkflowState.Incidents);
+ Assert.Equal(WorkflowSubStatus.Finished, result.WorkflowState.SubStatus);
+ }
+
+ [Fact(DisplayName = "A transaction completing Cancelled with no cancel boundary attached faults, rather than completing quietly")]
+ public async Task CancelledTransactionWithoutCancelBoundary_Faults()
+ {
+ // The conservative direction, and the interpreter takes it: graph validation cannot see into the nested
+ // definition to know a cancel end event is in there, so an unroutable cancellation is only discoverable while
+ // running. Treating it as an ordinary completion would send the token down the sequence flow that leads to
+ // 'after' -- a transaction that cancelled itself, followed by the work it cancelled itself to avoid.
+
+ // Act
+ var result = await _host.RunAsync(BpmnTestProcesses.CancelledTransactionWithoutCancelBoundary(_host.Log), typeof(FaultStrategy));
+
+ // Assert
+ Assert.DoesNotContain("executed:after", _host.Log.Entries);
+ Assert.Equal(WorkflowSubStatus.Faulted, result.WorkflowState.SubStatus);
+
+ var incident = Assert.Single(result.WorkflowState.Incidents);
+
+ Assert.Contains("bpmn.transaction.cancelled-unhandled", incident.Exception!.Message);
+ }
+
+ [Fact(DisplayName = "A subprocess replays its own compensation log, and the enclosing scope replays the subprocess itself")]
+ public async Task CompensationInsideSubprocess_ReplaysEachScopesOwnLog()
+ {
+ // Two logs, one per scope. The body's two handlers run in its own reverse order before it completes, and the
+ // enclosing scope's single registration -- the subprocess's own successful completion, made compensable by the
+ // boundary attached to it -- is what its compensate end event replays. A scope reaching into another's log
+ // would show up here as a handler running in the wrong scope's replay, or twice.
+
+ // Act
+ var result = await _host.RunAsync(BpmnTestProcesses.CompensationInsideSubprocess(_host.Log), typeof(FaultStrategy));
+
+ // Assert
+ Assert.Equal(
+ [
+ "executed:subCharge", "executed:subShip",
+ "executed:subRecall", "executed:subRefund",
+ "executed:undoSub"
+ ],
+ _host.Log.Entries);
+
+ Assert.Empty(result.WorkflowState.Incidents);
+ Assert.Equal(WorkflowSubStatus.Finished, result.WorkflowState.SubStatus);
+ }
+}
diff --git a/test/integration/Elsa.Bpmn.IntegrationTests/Scenarios/HostPort/BpmnTestProcesses.cs b/test/integration/Elsa.Bpmn.IntegrationTests/Scenarios/HostPort/BpmnTestProcesses.cs
index c0950c93f..58f7c5d4a 100644
--- a/test/integration/Elsa.Bpmn.IntegrationTests/Scenarios/HostPort/BpmnTestProcesses.cs
+++ b/test/integration/Elsa.Bpmn.IntegrationTests/Scenarios/HostPort/BpmnTestProcesses.cs
@@ -261,6 +261,217 @@ internal static class BpmnTestProcesses
return Scope("scope", definition, nested, Immediate("after", log), Immediate("unwind", log));
}
+ ///
+ /// Three bookings, each carrying a compensation boundary event, and a compensate end event that replays the lot.
+ ///
+ ///
+ /// Three rather than two: with two, a handler order that merely happened to be reversed is indistinguishable from
+ /// one that swapped a pair, and a replay that walked the log forwards would still run every handler. The three
+ /// handlers bind work like anything else, and nothing in the graph flows into them — the only thing that can run
+ /// them is the replay.
+ ///
+ public static BpmnProcess CompensatedBookings(BpmnTestLog log)
+ {
+ var definition = new BpmnProcessBuilder("compensated-bookings")
+ .StartEvent("start")
+ .Task("bookFlight", bindingRef: BindingRef("bookFlight"))
+ .Task("bookHotel", bindingRef: BindingRef("bookHotel"))
+ .Task("bookCar", bindingRef: BindingRef("bookCar"))
+ .EndEvent("undoEverything", null, Compensation())
+ .Element(CompensationBoundary("flightCompensated", attachedTo: "bookFlight", handler: "undoFlight"))
+ .Element(CompensationBoundary("hotelCompensated", attachedTo: "bookHotel", handler: "undoHotel"))
+ .Element(CompensationBoundary("carCompensated", attachedTo: "bookCar", handler: "undoCar"))
+ .Element(CompensationHandler("undoFlight"))
+ .Element(CompensationHandler("undoHotel"))
+ .Element(CompensationHandler("undoCar"))
+ .ConnectSequence("start", "bookFlight", "bookHotel", "bookCar", "undoEverything")
+ .Build();
+
+ return Scope(
+ "scope",
+ definition,
+ Immediate("bookFlight", log),
+ Immediate("bookHotel", log),
+ Immediate("bookCar", log),
+ Immediate("undoFlight", log),
+ Immediate("undoHotel", log),
+ Immediate("undoCar", log));
+ }
+
+ ///
+ /// The same three bookings, compensated by an intermediate throw event naming one of them in its
+ /// activityRef, and a task after the throw that its outbound flow reaches once the replay is done.
+ ///
+ public static BpmnProcess TargetedCompensation(BpmnTestLog log)
+ {
+ var definition = new BpmnProcessBuilder("targeted-compensation")
+ .StartEvent("start")
+ .Task("bookFlight", bindingRef: BindingRef("bookFlight"))
+ .Task("bookHotel", bindingRef: BindingRef("bookHotel"))
+ .Task("bookCar", bindingRef: BindingRef("bookCar"))
+ .IntermediateThrowEvent("undoHotelOnly", Compensation(activityRef: "bookHotel"))
+ .Task("after", bindingRef: BindingRef("after"))
+ .EndEvent("end")
+ .Element(CompensationBoundary("flightCompensated", attachedTo: "bookFlight", handler: "undoFlight"))
+ .Element(CompensationBoundary("hotelCompensated", attachedTo: "bookHotel", handler: "undoHotel"))
+ .Element(CompensationBoundary("carCompensated", attachedTo: "bookCar", handler: "undoCar"))
+ .Element(CompensationHandler("undoFlight"))
+ .Element(CompensationHandler("undoHotel"))
+ .Element(CompensationHandler("undoCar"))
+ .ConnectSequence("start", "bookFlight", "bookHotel", "bookCar", "undoHotelOnly", "after", "end")
+ .Build();
+
+ return Scope(
+ "scope",
+ definition,
+ Immediate("bookFlight", log),
+ Immediate("bookHotel", log),
+ Immediate("bookCar", log),
+ Immediate("undoFlight", log),
+ Immediate("undoHotel", log),
+ Immediate("undoCar", log),
+ Immediate("after", 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.
+ ///
+ ///
+ ///
+ /// The shape is what makes the release observable. chargeCard and reserveSeat both complete and
+ /// register, in that order, so the replay the intermediate throw opens claims both and runs them in reverse:
+ /// releaseSeat first, which blocks, leaving refundCard claimed and never started. Cancelling the
+ /// transaction from the other branch stops the replay's coordinating token, which is the only thing in scope that
+ /// tears a run down mid-flight.
+ ///
+ ///
+ /// fraudCheck blocks so a test decides when the cancellation happens, rather than racing the replay.
+ ///
+ ///
+ public static BpmnProcess CompensationRunCancelledMidReplay(BpmnTestLog log)
+ {
+ var body = new BpmnProcessBuilder("mid-replay-cancel-body")
+ .Transaction()
+ .StartEvent("subStart")
+ .ParallelGateway("subSplit")
+ .Task("chargeCard", bindingRef: BindingRef("chargeCard"))
+ .Task("reserveSeat", bindingRef: BindingRef("reserveSeat"))
+ .IntermediateThrowEvent("rollBack", Compensation())
+ .EndEvent("rolledBack")
+ .Task("fraudCheck", bindingRef: BindingRef("fraudCheck"))
+ .EndEvent("subCancelled", null, Cancel())
+ .Element(CompensationBoundary("cardCompensated", attachedTo: "chargeCard", handler: "refundCard"))
+ .Element(CompensationBoundary("seatCompensated", attachedTo: "reserveSeat", handler: "releaseSeat"))
+ .Element(CompensationHandler("refundCard"))
+ .Element(CompensationHandler("releaseSeat"))
+ .ConnectSequence("subStart", "subSplit")
+ .Connect("subSplit", "chargeCard")
+ .ConnectSequence("chargeCard", "reserveSeat", "rollBack", "rolledBack")
+ .Connect("subSplit", "fraudCheck")
+ .ConnectSequence("fraudCheck", "subCancelled")
+ .Build();
+
+ var definition = new BpmnProcessBuilder("mid-replay-cancel")
+ .StartEvent("start")
+ .SubProcess("sub", bindingRef: BindingRef("sub"), isTransaction: true)
+ .Task("after", bindingRef: BindingRef("after"))
+ .EndEvent("end")
+ .BoundaryEvent("cancelled", attachedTo: "sub", eventDefinition: Cancel())
+ .Task("unwind", bindingRef: BindingRef("unwind"))
+ .EndEvent("unwound")
+ .ConnectSequence("start", "sub", "after", "end")
+ .ConnectSequence("cancelled", "unwind", "unwound")
+ .Build();
+
+ var nested = Scope(
+ "sub",
+ body,
+ Immediate("chargeCard", log),
+ Immediate("reserveSeat", log),
+ Blocking("fraudCheck", log),
+ Immediate("refundCard", log),
+ Blocking("releaseSeat", log));
+
+ return Scope("scope", definition, nested, Immediate("after", log), Immediate("unwind", log));
+ }
+
+ ///
+ /// A transaction subprocess that cancels itself from the inside, with nothing on the enclosing scope to route the
+ /// cancellation.
+ ///
+ ///
+ /// The same shape as minus the cancel boundary event. Graph
+ /// validation cannot see into the nested definition to know a cancel end event is in there, so an unroutable
+ /// cancellation is an execution-time rule: the enclosing scope faults rather than treating the transaction as an
+ /// ordinary completion and carrying on down the sequence flow.
+ ///
+ public static BpmnProcess CancelledTransactionWithoutCancelBoundary(BpmnTestLog log)
+ {
+ var body = new BpmnProcessBuilder("unroutable-cancel-body")
+ .Transaction()
+ .StartEvent("subStart")
+ .Task("subWork", bindingRef: BindingRef("subWork"))
+ .EndEvent("subCancelled", null, Cancel())
+ .ConnectSequence("subStart", "subWork", "subCancelled")
+ .Build();
+
+ var definition = new BpmnProcessBuilder("unroutable-cancel")
+ .StartEvent("start")
+ .SubProcess("sub", bindingRef: BindingRef("sub"), isTransaction: true)
+ .Task("after", bindingRef: BindingRef("after"))
+ .EndEvent("end")
+ .ConnectSequence("start", "sub", "after", "end")
+ .Build();
+
+ var nested = Scope("sub", body, Immediate("subWork", log));
+
+ return Scope("scope", definition, nested, Immediate("after", log));
+ }
+
+ ///
+ /// A compensation log inside an embedded subprocess, and a second one in the enclosing scope that compensates the
+ /// subprocess itself.
+ ///
+ ///
+ /// Two logs, one per scope, and neither can see the other: the body replays its own two handlers before it
+ /// completes, and the enclosing scope registers exactly one entry — the subprocess's own successful completion,
+ /// which its attached compensation boundary makes compensable — and replays that.
+ ///
+ public static BpmnProcess CompensationInsideSubprocess(BpmnTestLog log)
+ {
+ var body = new BpmnProcessBuilder("compensating-subprocess-body")
+ .StartEvent("subStart")
+ .Task("subCharge", bindingRef: BindingRef("subCharge"))
+ .Task("subShip", bindingRef: BindingRef("subShip"))
+ .EndEvent("subUndo", null, Compensation())
+ .Element(CompensationBoundary("subChargeCompensated", attachedTo: "subCharge", handler: "subRefund"))
+ .Element(CompensationBoundary("subShipCompensated", attachedTo: "subShip", handler: "subRecall"))
+ .Element(CompensationHandler("subRefund"))
+ .Element(CompensationHandler("subRecall"))
+ .ConnectSequence("subStart", "subCharge", "subShip", "subUndo")
+ .Build();
+
+ var definition = new BpmnProcessBuilder("compensation-inside-subprocess")
+ .StartEvent("start")
+ .SubProcess("sub", bindingRef: BindingRef("sub"))
+ .EndEvent("undoOuter", null, Compensation())
+ .Element(CompensationBoundary("subCompensated", attachedTo: "sub", handler: "undoSub"))
+ .Element(CompensationHandler("undoSub"))
+ .ConnectSequence("start", "sub", "undoOuter")
+ .Build();
+
+ var nested = Scope(
+ "sub",
+ body,
+ Immediate("subCharge", log),
+ Immediate("subShip", log),
+ Immediate("subRefund", log),
+ Immediate("subRecall", log));
+
+ return Scope("scope", definition, nested, Immediate("undoSub", log));
+ }
+
/// An embedded subprocess with one task in it, and one task after it in the enclosing scope.
public static BpmnProcess NestedSubprocess(BpmnTestLog log)
{
@@ -341,6 +552,30 @@ internal static class BpmnTestProcesses
private static BpmnEventDefinition Cancel() => new(BpmnEventDefinitionTypes.Cancel);
+ private static BpmnEventDefinition Compensation(string? activityRef = null) =>
+ activityRef is null
+ ? new(BpmnEventDefinitionTypes.Compensation)
+ : new(BpmnEventDefinitionTypes.Compensation, new Dictionary(StringComparer.Ordinal) { [BpmnEventDefinitionProperties.ActivityRef] = activityRef });
+
+ ///
+ /// A compensation boundary event: dormant, arming nothing, and reaching its handler by association rather than by
+ /// a sequence flow. cannot carry the handler association, so this
+ /// one is written out.
+ ///
+ private static BpmnElement CompensationBoundary(string elementId, string attachedTo, string handler) =>
+ new(elementId,
+ BpmnElementTypes.BoundaryEvent,
+ attachedToRef: attachedTo,
+ eventDefinitions: [Compensation()],
+ compensationHandlerElementId: handler);
+
+ ///
+ /// A compensation handler: a task that binds work like any other, takes no sequence flows, and is invoked only by
+ /// compensation replay.
+ ///
+ private static BpmnElement CompensationHandler(string elementId) =>
+ new(elementId, BpmnElementTypes.Task, bindingRef: BindingRef(elementId), isForCompensation: true);
+
private static BpmnEventDefinition Escalation(string code) =>
new(BpmnEventDefinitionTypes.Escalation, new Dictionary(StringComparer.Ordinal) { [BpmnEventDefinitionProperties.Code] = code });
diff --git a/test/integration/Elsa.Bpmn.Interchange.IntegrationTests/Scenarios/Binding/BpmnWorkBinderTests.cs b/test/integration/Elsa.Bpmn.Interchange.IntegrationTests/Scenarios/Binding/BpmnWorkBinderTests.cs
index d371c5c7d..c926f3520 100644
--- a/test/integration/Elsa.Bpmn.Interchange.IntegrationTests/Scenarios/Binding/BpmnWorkBinderTests.cs
+++ b/test/integration/Elsa.Bpmn.Interchange.IntegrationTests/Scenarios/Binding/BpmnWorkBinderTests.cs
@@ -201,6 +201,43 @@ public class BpmnWorkBinderTests(ITestOutputHelper testOutputHelper) : BpmnBindi
Assert.Equal("Escalated", ValueOf(Assert.IsType(WorkForRef(scope, ListenerRef("escalationHandler"))).EventName));
}
+ [Fact(DisplayName = "A compensation handler is bound like any other work, and only compensation replay runs it")]
+ public async Task CompensationHandler_IsBoundAndOnlyReachedByReplay()
+ {
+ // An isForCompensation element binds work and takes no sequence flows, and the reader emits an ordinary
+ // Primary binding for it — so the binder needs no case for it, which is exactly why this is worth pinning.
+ // A binder that skipped such a binding would still produce a scope that builds and publishes; the failure
+ // would surface only once a replay asked for work the scope maps to nothing. Running the bound scope is what
+ // turns that into an assertion.
+ //
+ // The throw names one of the two hosts, so the other handler is bound and reachable and still must not run:
+ // nothing in the graph flows into a compensation handler, and only the replay's own selection reaches it.
+ var definition = new BpmnProcessBuilder(ProcessId)
+ .StartEvent("start")
+ .Element(BoundElement("book", BpmnElementTypes.ServiceTask, new WriteLine("booked")))
+ .Element(BoundElement("pay", BpmnElementTypes.ServiceTask, new WriteLine("paid")))
+ .IntermediateThrowEvent("undoBookOnly", Compensation(activityRef: "book"))
+ .EndEvent("end")
+ .Element(CompensationBoundary("bookCompensated", attachedTo: "book", handler: "undoBook"))
+ .Element(CompensationBoundary("payCompensated", attachedTo: "pay", handler: "undoPay"))
+ .Element(CompensationHandler("undoBook", new WriteLine("unbooked")))
+ .Element(CompensationHandler("undoPay", new WriteLine("unpaid")))
+ .ConnectSequence("start", "book", "pay", "undoBookOnly", "end")
+ .Build();
+
+ var scope = Bind(definition, Unbound("book"), Unbound("pay"), Unbound("undoBook"), Unbound("undoPay"));
+
+ // Bound: a handler is an entry in the same map as any other binding ref, under no special slot.
+ var undoBookActivityId = scope.WorkBindings[Ref("undoBook")];
+ var undoPayActivityId = scope.WorkBindings[Ref("undoPay")];
+
+ var result = await Services.GetRequiredService().RunAsync(scope);
+
+ Assert.Equal(1, result.Journal.ActivityExecutionContexts.Count(context => context.Activity.Id == undoBookActivityId));
+ Assert.Equal(0, result.Journal.ActivityExecutionContexts.Count(context => context.Activity.Id == undoPayActivityId));
+ Assert.Empty(result.WorkflowState.Incidents);
+ }
+
[Fact(DisplayName = "An unbound task the document does not bind is refused, naming the element")]
public void UnboundTask_WithNoDeclarationIsRefused()
{
@@ -271,6 +308,27 @@ public class BpmnWorkBinderTests(ITestOutputHelper testOutputHelper) : BpmnBindi
private static BpmnElement Element(string elementId, string elementType) => new(elementId, elementType, bindingRef: Ref(elementId));
+ private static BpmnEventDefinition Compensation(string? activityRef = null) =>
+ activityRef is null
+ ? new(BpmnEventDefinitionTypes.Compensation)
+ : new(BpmnEventDefinitionTypes.Compensation, new Dictionary(StringComparer.Ordinal) { [BpmnEventDefinitionProperties.ActivityRef] = activityRef });
+
+ /// A compensation boundary event, which reaches its handler by association rather than by a sequence flow.
+ private static BpmnElement CompensationBoundary(string elementId, string attachedTo, string handler) =>
+ new(elementId,
+ BpmnElementTypes.BoundaryEvent,
+ attachedToRef: attachedTo,
+ eventDefinitions: [Compensation()],
+ compensationHandlerElementId: handler);
+
+ /// An unbound task marked as a compensation handler, carrying the activity binding the document declares for it.
+ private BpmnElement CompensationHandler(string elementId, IActivity activity) =>
+ new(elementId,
+ BpmnElementTypes.ServiceTask,
+ bindingRef: Ref(elementId),
+ isForCompensation: true,
+ extensions: BpmnActivityBindingFormat.Attach(null, Format.Write(activity)));
+
private static BpmnWorkBinding.TimerWait Timer(string elementId, string isoDuration) =>
new(ProcessId, elementId, Ref(elementId), BpmnBindingSlot.Primary, isoDuration);