diff --git a/src/modules/Elsa.Bpmn.Interchange/Binding/BpmnActivityBindingFormat.cs b/src/modules/Elsa.Bpmn.Interchange/Binding/BpmnActivityBindingFormat.cs
new file mode 100644
index 000000000..f1c920230
--- /dev/null
+++ b/src/modules/Elsa.Bpmn.Interchange/Binding/BpmnActivityBindingFormat.cs
@@ -0,0 +1,277 @@
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using Bpmn.Model;
+using Elsa.Bpmn.Interchange.Exceptions;
+using Elsa.Workflows;
+using Elsa.Workflows.Activities;
+using Elsa.Workflows.Models;
+
+namespace Elsa.Bpmn.Interchange.Binding;
+
+///
+/// The elsa: vendor extension that records which Elsa activity performs a BPMN task the document describes but
+/// does not implement, and the one place the names making up that format are defined.
+///
+///
+///
+/// Where it lives. Inside the BPMN document, as a vendor extension on the element it binds — not in a side
+/// envelope. An exported .bpmn is therefore self-contained and re-importable by itself. Bpmn.Interchange
+/// retains any extension element it does not own as typed foreign content and writes it back where it came from, which
+/// is what makes this survive a read-modify-write cycle; the library never interprets it.
+///
+///
+/// The format. Namespace URI https://elsaworkflows.io/schemas/bpmn/v1, conventional prefix elsa.
+/// One <elsa:activityBinding> element inside the BPMN element's <bpmn:extensionElements>:
+///
+///
+/// -
+/// activityType — attribute, required
+///
+/// The Elsa activity type name as the activity registry keys it, e.g. Elsa.WriteLine. This is
+/// , not a CLR type name.
+///
+///
+/// -
+/// <elsa:input name="…"> — child element, zero or more, name unique within the binding
+///
+/// One per configured activity input — every property reports
+/// for the activity's CLR type, which is both every Input<T>-typed property and every plain-typed
+/// property carrying [Input] (e.g. Switch.Cases); that is the same enumeration
+/// ActivityDescriptor.Inputs is built from, so this format writes exactly the inputs Elsa itself considers
+/// the activity to have. name is the input's property name as it appears in the activity's own JSON
+/// (camelCase). The element's text is that single input value serialized by Elsa's configured activity
+/// serializer — the same serializer a stored workflow definition is written and read through — so its shape
+/// depends on how the activity declares the input: an Input<T>-typed property carries the
+/// {"typeName":…,"expression":…} wrapper, which is what makes every expression type Elsa knows about
+/// round-trip unchanged, while a plain-typed property carrying [Input] (e.g. Switch.Cases) carries
+/// that value's own JSON — a collection such as Switch.Cases exports as a JSON array, not the wrapper.
+/// No second encoding of activity inputs has to be kept in step with Elsa's own; an implementer must not assume
+/// a single wrapper shape, only that the text is whatever Elsa's activity serializer produced for that input and
+/// that reading it back through the same serializer reconstructs it. A second <elsa:input> naming an
+/// input already declared is refused rather than silently taking the later one, and a name the activity
+/// type does not declare an input for is refused rather than importing an activity quietly missing that configuration — Elsa's
+/// deserializer ignores an unknown JSON member without complaint, so nothing else would ever say so. Both are
+/// refused the same way an unregistered activity type or a call activity with no calledElement is refused
+/// elsewhere in this binder.
+///
+///
+///
+///
+/// Escaping. An input's text is JSON, carried as ordinary XML element text — not wrapped in
+/// <![CDATA[…]]>. <, > and & inside the JSON (for example, inside a string
+/// literal) are therefore XML-escaped as <, > and & the way any XML text
+/// node escapes them; Bpmn.Interchange reads and writes this content through System.Xml.Linq, whose
+/// standard text-node escaping decodes it back to the original JSON automatically. Nothing else needs to escape or
+/// unescape this text: hands the writer plain JSON, and reads
+/// already decoded.
+///
+/// Example, an Input<T>-typed input wrapped as an expression:
+///
+/// <bpmn:serviceTask id="notify">
+/// <bpmn:extensionElements>
+/// <elsa:activityBinding activityType="Elsa.WriteLine">
+/// <elsa:input name="text">{"typeName":"String","expression":{"type":"JavaScript","value":"getMessage()"}}</elsa:input>
+/// </elsa:activityBinding>
+/// </bpmn:extensionElements>
+/// </bpmn:serviceTask>
+///
+/// Example, an [Input]-attributed plain-typed input carrying its own JSON shape (here, an array):
+///
+/// <bpmn:exclusiveGateway id="route">
+/// <bpmn:extensionElements>
+/// <elsa:activityBinding activityType="Elsa.Switch">
+/// <elsa:input name="cases">[{"label":"case one","condition":{"type":"Literal","value":true}}]</elsa:input>
+/// </elsa:activityBinding>
+/// </bpmn:extensionElements>
+/// </bpmn:exclusiveGateway>
+///
+///
+/// Position is the key. The element it sits inside is the element it binds; nothing records a binding ref.
+/// That is deliberate: a binding ref is derived by the reader from the element id and a configurable prefix
+/// (BpmnImportOptions.BindingRefPrefix), so writing one into the document would make an exported file depend on
+/// the import setting that happened to be in force when it was produced.
+///
+///
+/// This is a compatibility surface. Changing , ,
+/// , or
+/// breaks every previously exported .bpmn file: the old element stops being recognised and silently becomes
+/// unrelated foreign content, which reads back as a task nobody bound. Anything that reads or writes this shape —
+/// Elsa Studio included — has to agree with these constants, so a change here is a versioning decision, not a rename.
+///
+///
+/// Disclosure. An exported .bpmn carries the binding configuration verbatim, which includes input
+/// expressions: literal values, JavaScript, C#, Liquid, connection or endpoint names — whatever the author put on the
+/// activity. Exporting a process is therefore a disclosure of its implementation detail, and a .bpmn from a
+/// production tenant should be handled with the same care as the workflow definition it was built from. Nothing is
+/// redacted, on purpose: a quietly redacted export produces a file that still looks executable and is not, and the
+/// failure only shows up as wrong behaviour after someone re-imports it.
+///
+///
+public sealed class BpmnActivityBindingFormat(IActivitySerializer activitySerializer, IActivityDescriber activityDescriber)
+{
+ /// The namespace URI of the elsa: BPMN vendor extension. See the remarks on this type before changing it.
+ public const string NamespaceUri = "https://elsaworkflows.io/schemas/bpmn/v1";
+
+ /// The conventional prefix for . Cosmetic in XML, but used verbatim in diagnostics.
+ public const string NamespacePrefix = "elsa";
+
+ /// The local name of the binding element.
+ public const string BindingElementName = "activityBinding";
+
+ /// The local name of the attribute naming the Elsa activity type.
+ public const string ActivityTypeAttributeName = "activityType";
+
+ /// The local name of a single-input child element.
+ public const string InputElementName = "input";
+
+ /// The local name of the attribute naming the input an element configures.
+ public const string InputNameAttributeName = "name";
+
+ private static readonly BpmnQName BindingQName = new(NamespaceUri, BindingElementName);
+ private static readonly BpmnQName InputQName = new(NamespaceUri, InputElementName);
+
+ ///
+ /// The activity binding declared on the given retained content, or null when it declares none.
+ ///
+ public static BpmnExtensionElement? Find(BpmnExtensions? extensions) =>
+ extensions?.ExtensionElements.FirstOrDefault(element => element.Name == BindingQName);
+
+ ///
+ /// The given retained content with as its activity binding, replacing any it already
+ /// carried and leaving every other retained element in place.
+ ///
+ ///
+ /// Adding rather than replacing would leave two activityBinding elements on one BPMN element, and a reader
+ /// taking the first of them would silently apply the older one.
+ ///
+ public static BpmnExtensions Attach(BpmnExtensions? extensions, BpmnExtensionElement binding)
+ {
+ extensions ??= BpmnExtensions.Empty;
+
+ return extensions with
+ {
+ ExtensionElements = extensions.ExtensionElements.Where(element => element.Name != BindingQName).Append(binding).ToList()
+ };
+ }
+
+ ///
+ /// The binding element declaring that performs the work, with each of its configured
+ /// inputs serialized.
+ ///
+ public BpmnExtensionElement Write(IActivity activity)
+ {
+ // The same set IActivityDescriber.DescribeActivityAsync builds an ActivityDescriptor.Inputs from — every
+ // property carrying an Input, and every plain-typed property carrying [Input] (e.g. Switch.Cases). Filtering
+ // on "derives from Input" alone, as this used to, silently drops the latter kind's configuration from the
+ // export.
+ //
+ // Ordered by name so that exporting the same activity twice produces the same bytes, which is what makes a
+ // .bpmn file diffable and a round-trip test meaningful.
+ var inputs = activityDescriber.GetInputProperties(activity.GetType())
+ .Select(property => (Name: JsonNamingPolicy.CamelCase.ConvertName(property.Name), Value: property.GetValue(activity)))
+ .Where(input => input.Value is not null)
+ .OrderBy(input => input.Name, StringComparer.Ordinal)
+ .Select(input => new BpmnExtensionElement(InputQName, [Attribute(InputNameAttributeName, input.Name)], null, activitySerializer.Serialize(input.Value!)))
+ .ToList();
+
+ return new(BindingQName, [Attribute(ActivityTypeAttributeName, activity.Type)], inputs);
+ }
+
+ ///
+ /// The activity a binding element declares, built through Elsa's own activity serializer so that it is
+ /// indistinguishable from the same activity loaded out of a stored workflow definition.
+ ///
+ ///
+ /// The element is malformed, names an activity type nothing registered, or names an input the activity type does
+ /// not declare.
+ ///
+ public IActivity Read(BpmnExtensionElement element)
+ {
+ var activityType = AttributeOf(element, ActivityTypeAttributeName)
+ ?? throw new BpmnBindingException($"An <{NamespacePrefix}:{BindingElementName}> element declares no '{ActivityTypeAttributeName}', so there is nothing to build.");
+
+ var activityJson = new JsonObject
+ {
+ ["type"] = activityType
+ };
+
+ // Every name seen so far, in the order the document declares them, so a second with the same
+ // name is refused rather than silently overwriting activityJson[name] and leaving the earlier one's
+ // configuration invisible.
+ var seenInputNames = new List();
+ var seenInputNameSet = new HashSet(StringComparer.Ordinal);
+
+ foreach (var input in element.Children.Where(child => child.Name == InputQName))
+ {
+ var name = AttributeOf(input, InputNameAttributeName)
+ ?? throw new BpmnBindingException($"An <{NamespacePrefix}:{InputElementName}> element of the '{activityType}' binding declares no '{InputNameAttributeName}'.");
+
+ if (!seenInputNameSet.Add(name))
+ throw new BpmnBindingException($"The '{activityType}' binding declares the input '{name}' more than once. Each <{NamespacePrefix}:{InputElementName}> must name a distinct input.");
+
+ seenInputNames.Add(name);
+ activityJson[name] = Parse(input.Value, name, activityType);
+ }
+
+ IActivity activity;
+
+ try
+ {
+ activity = activitySerializer.Deserialize(activityJson.ToJsonString());
+ }
+ catch (Exception exception) when (exception is JsonException or NotSupportedException)
+ {
+ throw new BpmnBindingException($"The binding to activity type '{activityType}' could not be deserialized: {exception.Message}");
+ }
+
+ // Elsa's activity serializer answers an unregistered type with a NotFoundActivity rather than throwing, and
+ // that placeholder only fails once it executes — by which time the workflow has already started and the
+ // process is mid-flight. Refusing at bind time turns "this .bpmn needs a module you have not installed" into a
+ // sentence naming the type, at the point where someone can still do something about it.
+ if (activity is NotFoundActivity)
+ throw new BpmnBindingException($"The binding names activity type '{activityType}', which is not registered in this application. Install or enable the module providing it before importing this document.");
+
+ // Elsa's own JSON deserialization ignores a member the target type does not declare, so a mistyped or
+ // stale input name would otherwise import silently as an activity missing that configuration, with no
+ // diagnostic anywhere. IActivityDescriber.GetInputProperties is the same enumeration Write reads from and
+ // ActivityDescriptor.Inputs is built from, so a name is accepted here exactly when Write could have produced
+ // it.
+ if (seenInputNames.Count > 0)
+ {
+ var declaredInputNames = activityDescriber.GetInputProperties(activity.GetType())
+ .Select(property => JsonNamingPolicy.CamelCase.ConvertName(property.Name))
+ .ToHashSet(StringComparer.Ordinal);
+
+ var undeclaredInputNames = seenInputNames.Where(name => !declaredInputNames.Contains(name)).ToList();
+
+ if (undeclaredInputNames.Count > 0)
+ {
+ var noun = undeclaredInputNames.Count == 1 ? "an input" : "inputs";
+ var names = string.Join(", ", undeclaredInputNames.Select(name => $"'{name}'"));
+
+ throw new BpmnBindingException($"The '{activityType}' binding declares {noun} {names}, which '{activityType}' does not have.");
+ }
+ }
+
+ return activity;
+ }
+
+ private static JsonNode? Parse(string? json, string inputName, string activityType)
+ {
+ try
+ {
+ return JsonNode.Parse(json ?? "null");
+ }
+ catch (JsonException exception)
+ {
+ throw new BpmnBindingException($"Input '{inputName}' of the '{activityType}' binding does not hold valid JSON: {exception.Message}");
+ }
+ }
+
+ private static BpmnForeignAttribute Attribute(string name, string value) => new(new(null, name), value);
+
+ // An unprefixed XML attribute belongs to no namespace, which is what the reader records for these; comparing on
+ // the local name alone would also match a same-named attribute some other vendor put in its own namespace.
+ private static string? AttributeOf(BpmnExtensionElement element, string name) =>
+ element.Attributes.FirstOrDefault(attribute => string.IsNullOrEmpty(attribute.Name.Namespace) && attribute.Name.LocalName == name)?.Value;
+}
diff --git a/src/modules/Elsa.Bpmn.Interchange/Binding/BpmnWorkBinder.cs b/src/modules/Elsa.Bpmn.Interchange/Binding/BpmnWorkBinder.cs
new file mode 100644
index 000000000..00ab2de66
--- /dev/null
+++ b/src/modules/Elsa.Bpmn.Interchange/Binding/BpmnWorkBinder.cs
@@ -0,0 +1,177 @@
+using System.Xml;
+using Bpmn.Interchange;
+using Bpmn.Model;
+using Elsa.Bpmn.Activities;
+using Elsa.Bpmn.Interchange.Exceptions;
+using Elsa.Scheduling.Activities;
+using Elsa.Workflows;
+using Elsa.Workflows.Memory;
+using Elsa.Workflows.Runtime.Activities;
+
+namespace Elsa.Bpmn.Interchange.Binding;
+
+///
+/// Turns the reader's declarations into the Elsa activities a
+/// scope runs. This is the seam where BPMN vocabulary becomes Elsa work.
+///
+///
+///
+/// Six of the seven binding kinds bind to an activity this repository already has. Only
+/// is an authoring decision, which is right: BPMN genuinely does not say
+/// what a serviceTask does. Its answer is read from the elsa:activityBinding extension element inside
+/// the document — see , including what exporting one discloses.
+///
+///
+/// Every binding for a scope is bound, whatever its slot. A
+/// binding — the listener an event subprocess arms while its enclosing scope runs — is an entry in the same
+/// map as any other, under its own binding ref. It needs no special case here
+/// because the interpreter arms it at scope start on its own; singling it out would be a second code path with
+/// nothing different to do.
+///
+///
+/// Distinct activity instances per scope. A binding ref is unique within a scope, not across scopes, so two
+/// scopes can legitimately declare the same one. Every binding gets its own freshly constructed activity, nothing is
+/// cached or reused between scopes, and each activity is given a scope-qualified id. That is not a tidiness point:
+/// ActivityVisitor collects activities into a set and skips one it has already seen, so a single instance
+/// appearing under two scopes becomes one node in Elsa's identity graph and the second scope's child is missing from
+/// it — a graph that builds, publishes, and then never runs half the process.
+///
+///
+/// This binder lives in Elsa.Bpmn.Interchange rather than Elsa.Bpmn because
+/// is a Bpmn.Interchange type: binding it in Elsa.Bpmn would pull the interchange library into the
+/// execution module's dependency closure, which is exactly the package split D12 draws. The direction it needs is
+/// available — Elsa.Bpmn.Interchange already references Elsa.Bpmn, so and the
+/// four activity targets are all in reach.
+///
+///
+/// Nothing here decides root position: is left off on every scope this produces,
+/// including the outermost one, so a bound process is safe to nest by default and whoever composes it into a workflow
+/// says explicitly that it is an entry point.
+///
+///
+public sealed class BpmnWorkBinder(BpmnActivityBindingFormat format)
+{
+ ///
+ /// Binds one process definition, and every process nested inside it, into a scope.
+ ///
+ /// The process to bind.
+ /// Every binding the read produced, across all processes in the document.
+ /// A binding cannot be turned into an activity.
+ public BpmnProcess Bind(BpmnProcessDefinition definition, IReadOnlyCollection bindings)
+ {
+ var scope = BindScope(definition, bindings);
+ scope.Id = definition.ProcessId;
+ return scope;
+ }
+
+ private BpmnProcess BindScope(BpmnProcessDefinition definition, IReadOnlyCollection bindings)
+ {
+ var scope = new BpmnProcess
+ {
+ Process = definition
+ };
+
+ // A document-declared variable is invisible to the interpreter's IBpmnVariableReader port until it resolves
+ // through Elsa's own ExpressionExecutionContext.GetVariable, which walks Container.Variables — not
+ // BpmnProcessDefinition.Variables. Declaring one here for each is what makes a collection-mode multi-instance
+ // over a document-declared collection readable instead of Absent. The declared default, when there is one,
+ // travels as the JsonElement it already is: the reader serializes whatever the memory block holds, so nothing
+ // here needs to interpret BpmnVariableDeclaration.TypeHint.
+ foreach (var declaration in definition.Variables)
+ {
+ scope.Variables.Add(new Variable(declaration.Name, declaration.DefaultValue is { } defaultValue ? (object)defaultValue : null));
+ }
+
+ // Element ids whose elsa:activityBinding was actually used. A declaration nothing consumed is refused below.
+ var consumed = new HashSet(StringComparer.Ordinal);
+
+ foreach (var binding in bindings.Where(binding => string.Equals(binding.ProcessId, definition.ProcessId, StringComparison.Ordinal)))
+ {
+ var activity = CreateActivity(definition, binding, bindings, consumed);
+
+ // Scope-qualified and deterministic. The binding ref alone is unique only within its scope, and an id that
+ // repeats across scopes gives two logical positions one identity in bookmarks and persisted state.
+ activity.Id = $"{binding.ProcessId}:{binding.BindingRef}";
+
+ scope.Activities.Add(activity);
+ scope.WorkBindings[binding.BindingRef] = activity.Id;
+ }
+
+ RefuseUnusedDeclarations(definition, consumed);
+
+ return scope;
+ }
+
+ private IActivity CreateActivity(BpmnProcessDefinition definition, BpmnWorkBinding binding, IReadOnlyCollection bindings, ISet consumed) =>
+ binding switch
+ {
+ BpmnWorkBinding.TimerWait timer => new Delay(IsoDurationOf(timer)),
+ BpmnWorkBinding.MessageWait message => new Event(message.MessageName),
+ BpmnWorkBinding.SignalWait signal => new Event(signal.SignalName),
+ BpmnWorkBinding.MessagePublish publish => new PublishEvent
+ {
+ EventName = new(publish.MessageName)
+ },
+ BpmnWorkBinding.CallProcess call => new DispatchWorkflow
+ {
+ WorkflowDefinitionId = new(CalledElementOf(call)),
+ WaitForCompletion = new(call.WaitForCompletion)
+ },
+ BpmnWorkBinding.NestedProcess nested => BindScope(nested.Definition, bindings),
+ BpmnWorkBinding.UnboundTask unbound => ReadDeclaredActivity(definition, unbound, consumed),
+ // The binding hierarchy is closed, so this is reachable only from a library version that added a kind this
+ // binder has never heard of. Skipping it would produce a scope whose interpreter starts work nothing maps.
+ _ => throw new BpmnBindingException($"The BPMN work binding kind '{binding.GetType().Name}' declared by element '{binding.ElementId}' is not supported by this binder.")
+ };
+
+ private IActivity ReadDeclaredActivity(BpmnProcessDefinition definition, BpmnWorkBinding.UnboundTask unbound, ISet consumed)
+ {
+ var element = definition.Elements.FirstOrDefault(element => string.Equals(element.ElementId, unbound.ElementId, StringComparison.Ordinal));
+
+ if (BpmnActivityBindingFormat.Find(element?.Extensions) is not { } declaration)
+ {
+ throw new BpmnBindingException(
+ $"BPMN element '{unbound.ElementId}' of process '{unbound.ProcessId}' is a '{unbound.TaskType}': the document says what it is for, not how to perform it, and nothing binds it to an Elsa activity. "
+ + $"Declare one with an <{BpmnActivityBindingFormat.NamespacePrefix}:{BpmnActivityBindingFormat.BindingElementName}> element inside the element's .");
+ }
+
+ consumed.Add(unbound.ElementId);
+
+ return format.Read(declaration);
+ }
+
+ ///
+ /// Refuses an elsa:activityBinding on an element that has no unbound task to bind.
+ ///
+ ///
+ /// Six of the seven kinds bind on their own and never consult a declaration, so one written on a timer, a
+ /// subprocess or a gateway configures nothing. Ignoring it is the quiet answer: the author sees their expression in
+ /// the file, the process runs, and the activity they configured never executes. Refusing says so.
+ ///
+ private static void RefuseUnusedDeclarations(BpmnProcessDefinition definition, ISet consumed)
+ {
+ foreach (var element in definition.Elements.Where(element => BpmnActivityBindingFormat.Find(element.Extensions) is not null && !consumed.Contains(element.ElementId)))
+ {
+ throw new BpmnBindingException(
+ $"BPMN element '{element.ElementId}' ({element.ElementType}) of process '{definition.ProcessId}' carries an <{BpmnActivityBindingFormat.NamespacePrefix}:{BpmnActivityBindingFormat.BindingElementName}> element, but its work is not an unbound task, so nothing would ever run it. "
+ + "Only a task the document describes without implementing takes an authored activity binding.");
+ }
+ }
+
+ private static TimeSpan IsoDurationOf(BpmnWorkBinding.TimerWait timer)
+ {
+ try
+ {
+ return XmlConvert.ToTimeSpan(timer.IsoDuration);
+ }
+ catch (Exception exception) when (exception is FormatException or OverflowException or ArgumentNullException)
+ {
+ throw new BpmnBindingException($"BPMN element '{timer.ElementId}' declares the timer duration '{timer.IsoDuration}', which is not an ISO-8601 duration Elsa can wait for.");
+ }
+ }
+
+ private static string CalledElementOf(BpmnWorkBinding.CallProcess call) =>
+ !string.IsNullOrWhiteSpace(call.CalledElement)
+ ? call.CalledElement
+ : throw new BpmnBindingException($"BPMN element '{call.ElementId}' is a call activity that names no calledElement, so there is no workflow definition to dispatch.");
+}
diff --git a/src/modules/Elsa.Bpmn.Interchange/Exceptions/BpmnBindingException.cs b/src/modules/Elsa.Bpmn.Interchange/Exceptions/BpmnBindingException.cs
new file mode 100644
index 000000000..3bea8f624
--- /dev/null
+++ b/src/modules/Elsa.Bpmn.Interchange/Exceptions/BpmnBindingException.cs
@@ -0,0 +1,12 @@
+namespace Elsa.Bpmn.Interchange.Exceptions;
+
+///
+/// Thrown when a BPMN document's work bindings cannot be turned into Elsa activities.
+///
+///
+/// Every refusal this type carries is a design-time one: the document, or the elsa:activityBinding in it, does
+/// not say enough to build a runnable graph. Refusing here rather than binding something plausible is deliberate — the
+/// alternatives (an unbound task quietly dropped, a call activity dispatching nothing, a timer of zero) all produce a
+/// workflow that runs to completion looking healthy while doing none of what the document says.
+///
+public class BpmnBindingException(string message) : Exception(message);
diff --git a/src/modules/Elsa.Bpmn.Interchange/Features/BpmnInterchangeFeature.cs b/src/modules/Elsa.Bpmn.Interchange/Features/BpmnInterchangeFeature.cs
index 66a368b5c..f30e2de77 100644
--- a/src/modules/Elsa.Bpmn.Interchange/Features/BpmnInterchangeFeature.cs
+++ b/src/modules/Elsa.Bpmn.Interchange/Features/BpmnInterchangeFeature.cs
@@ -1,13 +1,21 @@
using Elsa.Bpmn.Features;
+using Elsa.Bpmn.Interchange.Binding;
using Elsa.Features.Abstractions;
using Elsa.Features.Attributes;
using Elsa.Features.Services;
+using Microsoft.Extensions.DependencyInjection;
namespace Elsa.Bpmn.Interchange.Features;
///
/// Provides BPMN XML interchange support to the system.
///
+///
+/// The task-to-activity binding this module reads lives inside the BPMN document as an elsa: vendor extension,
+/// so an exported .bpmn carries binding configuration — including input expressions — verbatim. Exporting a
+/// process discloses its implementation detail; see for the format and for why
+/// nothing is redacted.
+///
[DependsOn(typeof(BpmnFeature))]
public class BpmnInterchangeFeature : FeatureBase
{
@@ -15,4 +23,11 @@ public class BpmnInterchangeFeature : FeatureBase
public BpmnInterchangeFeature(IModule module) : base(module)
{
}
+
+ ///
+ public override void Apply()
+ {
+ Services.AddSingleton();
+ Services.AddSingleton();
+ }
}
diff --git a/src/modules/Elsa.Bpmn.Interchange/ShellFeatures/BpmnInterchangeFeature.cs b/src/modules/Elsa.Bpmn.Interchange/ShellFeatures/BpmnInterchangeFeature.cs
index 074179929..2d0297359 100644
--- a/src/modules/Elsa.Bpmn.Interchange/ShellFeatures/BpmnInterchangeFeature.cs
+++ b/src/modules/Elsa.Bpmn.Interchange/ShellFeatures/BpmnInterchangeFeature.cs
@@ -1,4 +1,5 @@
using CShells.Features;
+using Elsa.Bpmn.Interchange.Binding;
using Elsa.Bpmn.ShellFeatures;
using Elsa.Common.ShellFeatures;
using JetBrains.Annotations;
@@ -9,6 +10,12 @@ namespace Elsa.Bpmn.Interchange.ShellFeatures;
///
/// Provides BPMN XML interchange support to the system.
///
+///
+/// The task-to-activity binding this module reads lives inside the BPMN document as an elsa: vendor extension,
+/// so an exported .bpmn carries binding configuration — including input expressions — verbatim. Exporting a
+/// process discloses its implementation detail; see for the format and for why
+/// nothing is redacted.
+///
[ShellFeature(
DisplayName = "BPMN Interchange",
Description = "Provides BPMN XML interchange (import/export) capabilities for workflows.",
@@ -18,5 +25,7 @@ public class BpmnInterchangeFeature : IShellFeature
{
public void ConfigureServices(IServiceCollection services)
{
+ services.AddSingleton();
+ services.AddSingleton();
}
}
diff --git a/test/integration/Elsa.Bpmn.Interchange.IntegrationTests/Scenarios/Binding/BpmnActivityBindingFormatTests.cs b/test/integration/Elsa.Bpmn.Interchange.IntegrationTests/Scenarios/Binding/BpmnActivityBindingFormatTests.cs
new file mode 100644
index 000000000..7bf2dacb8
--- /dev/null
+++ b/test/integration/Elsa.Bpmn.Interchange.IntegrationTests/Scenarios/Binding/BpmnActivityBindingFormatTests.cs
@@ -0,0 +1,175 @@
+using Bpmn.Model;
+using Elsa.Bpmn.Interchange.Binding;
+using Elsa.Bpmn.Interchange.Exceptions;
+using Elsa.Expressions.Models;
+using Elsa.Workflows.Activities;
+using Xunit.Abstractions;
+
+namespace Elsa.Bpmn.Interchange.IntegrationTests.Scenarios.Binding;
+
+///
+/// The elsa:activityBinding extension element: what it holds, and that what goes in comes back out.
+///
+///
+/// This element is a compatibility surface — Elsa Studio writes it and every exported .bpmn is bound to it —
+/// so the round-trip is asserted on the element's own shape, not only on the activity that comes back. A change to a
+/// name or to how an input is encoded shows up here as a failing assertion rather than as files that stop importing.
+///
+public class BpmnActivityBindingFormatTests(ITestOutputHelper testOutputHelper) : BpmnBindingTestBase(testOutputHelper)
+{
+ [Fact(DisplayName = "A written binding names the activity type and carries one element per configured input")]
+ public void Write_ProducesTheDocumentedShape()
+ {
+ var element = Format.Write(new WriteLine("hello"));
+
+ Assert.Equal(new BpmnQName(BpmnActivityBindingFormat.NamespaceUri, BpmnActivityBindingFormat.BindingElementName), element.Name);
+ Assert.Equal("Elsa.WriteLine", AttributeOf(element, BpmnActivityBindingFormat.ActivityTypeAttributeName));
+
+ var input = Assert.Single(element.Children);
+
+ Assert.Equal(new BpmnQName(BpmnActivityBindingFormat.NamespaceUri, BpmnActivityBindingFormat.InputElementName), input.Name);
+ Assert.Equal("text", AttributeOf(input, BpmnActivityBindingFormat.InputNameAttributeName));
+ Assert.Contains("\"typeName\"", input.Value);
+ Assert.Contains("hello", input.Value);
+ }
+
+ [Fact(DisplayName = "A binding written then read back carries its literal inputs intact")]
+ public void WriteThenRead_PreservesALiteralInput()
+ {
+ var element = Format.Write(new WriteLine("hello"));
+
+ var activity = Assert.IsType(Format.Read(element));
+
+ Assert.Equal("Elsa.WriteLine", activity.Type);
+ Assert.Equal("hello", ValueOf(activity.Text));
+ }
+
+ [Fact(DisplayName = "A binding written then read back carries an expression, not just a literal")]
+ public void WriteThenRead_PreservesAnExpression()
+ {
+ // The reason the disclosure note on BpmnActivityBindingFormat is not theoretical: an exported .bpmn carries
+ // expression source verbatim. It also proves the encoding is Elsa's own input JSON rather than a value dump —
+ // a value-only format would silently degrade this input to a literal, or to nothing.
+ var element = Format.Write(new WriteLine(new Expression("JavaScript", "getSecretMessage()")));
+
+ Assert.Contains("getSecretMessage()", Assert.Single(element.Children).Value);
+
+ var activity = Assert.IsType(Format.Read(element));
+
+ Assert.Equal("JavaScript", activity.Text.Expression!.Type);
+ Assert.Equal("getSecretMessage()", ValueOf(activity.Text));
+ }
+
+ [Fact(DisplayName = "A binding written then read back carries an attribute-declared input intact, not only an Input-typed one")]
+ public void WriteThenRead_PreservesAnAttributeDeclaredInput()
+ {
+ // Switch.Cases is ICollection: [Input] on a plain-typed property, not one that derives from Input.
+ // Write used to enumerate only properties whose CLR type derives from Input, which silently dropped this kind
+ // of configuration from the export.
+ var activity = new Switch
+ {
+ Cases = { new SwitchCase { Label = "case one", Condition = new Expression("Literal", true) } }
+ };
+
+ var element = Format.Write(activity);
+
+ Assert.Contains(element.Children, child => AttributeOf(child, BpmnActivityBindingFormat.InputNameAttributeName) == "cases");
+
+ var read = Assert.IsType(Format.Read(element));
+ var readCase = Assert.Single(read.Cases);
+
+ Assert.Equal("case one", readCase.Label);
+ Assert.Equal(true, readCase.Condition.Value);
+ }
+
+ [Fact(DisplayName = "A binding naming an input the activity type does not declare is refused, not silently dropped")]
+ public void Read_RefusesAnUnknownInputName()
+ {
+ // Elsa's own deserializer ignores a JSON member the target type does not declare, so a mistyped or stale
+ // input name would otherwise import as an activity quietly missing that configuration, with no diagnostic
+ // anywhere. WriteLine declares "text", not "txt".
+ var inputName = new BpmnQName(BpmnActivityBindingFormat.NamespaceUri, BpmnActivityBindingFormat.InputElementName);
+ var nameAttribute = Attribute(BpmnActivityBindingFormat.InputNameAttributeName, "txt");
+
+ var element = new BpmnExtensionElement(
+ new(BpmnActivityBindingFormat.NamespaceUri, BpmnActivityBindingFormat.BindingElementName),
+ [Attribute(BpmnActivityBindingFormat.ActivityTypeAttributeName, "Elsa.WriteLine")],
+ [new BpmnExtensionElement(inputName, [nameAttribute], null, "{\"typeName\":\"String\",\"expression\":{\"type\":\"Literal\",\"value\":\"hello\"}}")]);
+
+ var exception = Assert.Throws(() => Format.Read(element));
+
+ Assert.Contains("txt", exception.Message);
+ }
+
+ [Fact(DisplayName = "A binding naming an activity type nothing registered is refused, not turned into a placeholder")]
+ public void Read_RefusesAnUnregisteredActivityType()
+ {
+ // Elsa answers an unknown activity type with a NotFoundActivity that only throws once it executes. Accepting
+ // it here would import cleanly, publish cleanly, and fail in the middle of a running process.
+ var element = new BpmnExtensionElement(
+ new(BpmnActivityBindingFormat.NamespaceUri, BpmnActivityBindingFormat.BindingElementName),
+ [new(new(null, BpmnActivityBindingFormat.ActivityTypeAttributeName), "Contoso.NoSuchActivity")]);
+
+ var exception = Assert.Throws(() => Format.Read(element));
+
+ Assert.Contains("Contoso.NoSuchActivity", exception.Message);
+ }
+
+ [Fact(DisplayName = "A binding declaring no activity type is refused")]
+ public void Read_RefusesAnElementWithNoActivityType()
+ {
+ var element = new BpmnExtensionElement(new(BpmnActivityBindingFormat.NamespaceUri, BpmnActivityBindingFormat.BindingElementName));
+
+ Assert.Throws(() => Format.Read(element));
+ }
+
+ [Fact(DisplayName = "A binding declaring the same input name twice is refused, not resolved last-wins")]
+ public void Read_RefusesADuplicateInputName()
+ {
+ // Two children for the same activity type. The second would otherwise silently win,
+ // leaving the author's first configuration in the file but never applied — exactly the quiet wrong answer
+ // this binder refuses everywhere else (an unbound task, a dead declaration, an unregistered activity type, a
+ // malformed timer duration, a call activity with no calledElement).
+ var inputName = new BpmnQName(BpmnActivityBindingFormat.NamespaceUri, BpmnActivityBindingFormat.InputElementName);
+ var nameAttribute = Attribute(BpmnActivityBindingFormat.InputNameAttributeName, "text");
+
+ var element = new BpmnExtensionElement(
+ new(BpmnActivityBindingFormat.NamespaceUri, BpmnActivityBindingFormat.BindingElementName),
+ [Attribute(BpmnActivityBindingFormat.ActivityTypeAttributeName, "Elsa.WriteLine")],
+ [
+ new BpmnExtensionElement(inputName, [nameAttribute], null, "{\"typeName\":\"String\",\"expression\":{\"type\":\"Literal\",\"value\":\"first\"}}"),
+ new BpmnExtensionElement(inputName, [nameAttribute], null, "{\"typeName\":\"String\",\"expression\":{\"type\":\"Literal\",\"value\":\"second\"}}")
+ ]);
+
+ var exception = Assert.Throws(() => Format.Read(element));
+
+ Assert.Contains("text", exception.Message);
+ }
+
+ [Fact(DisplayName = "Attaching a binding replaces the previous one and leaves other retained content alone")]
+ public void Attach_ReplacesTheBindingAndKeepsForeignContent()
+ {
+ var foreign = new BpmnExtensionElement(new("http://camunda.org/schema/1.0/bpmn", "properties"));
+ var extensions = new BpmnExtensions(ExtensionElements: [foreign, Format.Write(new WriteLine("first"))]);
+
+ var updated = BpmnActivityBindingFormat.Attach(extensions, Format.Write(new WriteLine("second")));
+
+ Assert.Contains(foreign, updated.ExtensionElements);
+ Assert.Equal("second", ValueOf(Assert.IsType(Format.Read(BpmnActivityBindingFormat.Find(updated)!)).Text));
+
+ // Two bindings on one element would leave a reader taking the first of them applying the older one.
+ Assert.Single(updated.ExtensionElements, element => element.Name.LocalName == BpmnActivityBindingFormat.BindingElementName);
+ }
+
+ [Fact(DisplayName = "Retained content declaring no binding reports none")]
+ public void Find_ReturnsNullWhenNothingIsDeclared()
+ {
+ Assert.Null(BpmnActivityBindingFormat.Find(null));
+ Assert.Null(BpmnActivityBindingFormat.Find(BpmnExtensions.Empty));
+ }
+
+ private static string? AttributeOf(BpmnExtensionElement element, string name) =>
+ element.Attributes.FirstOrDefault(attribute => attribute.Name.LocalName == name)?.Value;
+
+ private static BpmnForeignAttribute Attribute(string name, string value) => new(new(null, name), value);
+}
diff --git a/test/integration/Elsa.Bpmn.Interchange.IntegrationTests/Scenarios/Binding/BpmnBindingTestBase.cs b/test/integration/Elsa.Bpmn.Interchange.IntegrationTests/Scenarios/Binding/BpmnBindingTestBase.cs
new file mode 100644
index 000000000..38e487d85
--- /dev/null
+++ b/test/integration/Elsa.Bpmn.Interchange.IntegrationTests/Scenarios/Binding/BpmnBindingTestBase.cs
@@ -0,0 +1,58 @@
+using Bpmn.Interchange;
+using Bpmn.Model;
+using Elsa.Bpmn.Interchange.Binding;
+using Elsa.Extensions;
+using Elsa.Testing.Shared;
+using Elsa.Workflows;
+using Elsa.Workflows.Models;
+using Microsoft.Extensions.DependencyInjection;
+using Xunit.Abstractions;
+
+namespace Elsa.Bpmn.Interchange.IntegrationTests.Scenarios.Binding;
+
+///
+/// The application the binder and the elsa: extension format are exercised in.
+///
+///
+/// A real container rather than substitutes: both halves go through Elsa's configured activity serializer and its
+/// activity registry, and a stand-in for either would agree with whatever the test expected instead of with Elsa.
+///
+public abstract class BpmnBindingTestBase : IAsyncLifetime
+{
+ private readonly IServiceProvider _services;
+
+ protected BpmnBindingTestBase(ITestOutputHelper testOutputHelper)
+ {
+ _services = new TestApplicationBuilder(testOutputHelper)
+ .ConfigureElsa(elsa => elsa.UseBpmnInterchange())
+ .Build();
+
+ Binder = _services.GetRequiredService();
+ Format = _services.GetRequiredService();
+ }
+
+ protected BpmnWorkBinder Binder { get; }
+
+ protected BpmnActivityBindingFormat Format { get; }
+
+ /// The application the binder is exercised in, for a test that needs to run a bound scope end to end.
+ protected IServiceProvider Services => _services;
+
+ public Task InitializeAsync() => _services.PopulateRegistriesAsync();
+
+ public Task DisposeAsync() => Task.CompletedTask;
+
+ /// Elsa's own identity graph over the bound scope, which is what a published workflow is built from.
+ protected async Task> IdentityGraphOfAsync(IActivity root) =>
+ (await _services.GetRequiredService().VisitAsync(root)).Flatten().ToList();
+
+ /// The binding ref the reader assigns to an element, using its default prefix.
+ protected static string Ref(string elementId) => $"{BpmnXmlReader.DefaultBindingRefPrefix}-{elementId}";
+
+ /// An element carrying an authored activity binding.
+ protected BpmnElement BoundElement(string elementId, string elementType, IActivity activity) =>
+ new(elementId, elementType, bindingRef: Ref(elementId), extensions: BpmnActivityBindingFormat.Attach(null, Format.Write(activity)));
+
+ /// The literal or expression value an activity input carries.
+ protected static T ValueOf(Input input) => (T)input.Expression!.Value!;
+}
diff --git a/test/integration/Elsa.Bpmn.Interchange.IntegrationTests/Scenarios/Binding/BpmnWorkBinderTests.cs b/test/integration/Elsa.Bpmn.Interchange.IntegrationTests/Scenarios/Binding/BpmnWorkBinderTests.cs
new file mode 100644
index 000000000..a61064e73
--- /dev/null
+++ b/test/integration/Elsa.Bpmn.Interchange.IntegrationTests/Scenarios/Binding/BpmnWorkBinderTests.cs
@@ -0,0 +1,280 @@
+using System.Text.Json;
+using Bpmn.Interchange;
+using Bpmn.Model;
+using Elsa.Bpmn.Activities;
+using Elsa.Bpmn.Interchange.Binding;
+using Elsa.Bpmn.Interchange.Exceptions;
+using Elsa.Scheduling.Activities;
+using Elsa.Workflows;
+using Elsa.Workflows.Activities;
+using Elsa.Workflows.Runtime.Activities;
+using Microsoft.Extensions.DependencyInjection;
+using Xunit.Abstractions;
+
+namespace Elsa.Bpmn.Interchange.IntegrationTests.Scenarios.Binding;
+
+///
+/// Turning BpmnWorkBinding declarations into the activities a BpmnProcess scope runs.
+///
+public class BpmnWorkBinderTests(ITestOutputHelper testOutputHelper) : BpmnBindingTestBase(testOutputHelper)
+{
+ private const string ProcessId = "main";
+
+ [Fact(DisplayName = "A timer wait binds to a Delay of the declared ISO-8601 duration")]
+ public void TimerWait_BindsToADelay()
+ {
+ var scope = Bind(Definition(Element("wait", BpmnElementTypes.IntermediateCatchEvent)), Timer("wait", "PT5M"));
+
+ var delay = Assert.IsType(WorkOf(scope, "wait"));
+
+ Assert.Equal(TimeSpan.FromMinutes(5), ValueOf(delay.TimeSpan));
+ }
+
+ [Fact(DisplayName = "A timer wait declaring something that is not an ISO-8601 duration is refused")]
+ public void TimerWait_RefusesADurationElsaCannotWaitFor()
+ {
+ // A duration quietly defaulted to zero is a timer boundary event that fires the moment it is armed, which
+ // cancels the task it guards before that task has done anything.
+ var exception = Assert.Throws(() => Bind(Definition(Element("wait", BpmnElementTypes.IntermediateCatchEvent)), Timer("wait", "5 minutes")));
+
+ Assert.Contains("5 minutes", exception.Message);
+ }
+
+ [Fact(DisplayName = "A message wait binds to an Event on the message name")]
+ public void MessageWait_BindsToAnEvent()
+ {
+ var scope = Bind(
+ Definition(Element("receive", BpmnElementTypes.ReceiveTask)),
+ new BpmnWorkBinding.MessageWait(ProcessId, "receive", Ref("receive"), BpmnBindingSlot.Primary, "OrderPlaced"));
+
+ var @event = Assert.IsType(WorkOf(scope, "receive"));
+
+ Assert.Equal("OrderPlaced", ValueOf(@event.EventName));
+
+ // A catch inside a scope is not a way into the workflow; only the process's own start events are, and saying
+ // so is the enclosing BpmnProcess's decision, not this activity's.
+ Assert.False(@event.CanStartWorkflow);
+ }
+
+ [Fact(DisplayName = "A signal wait binds to an Event on the signal name")]
+ public void SignalWait_BindsToAnEvent()
+ {
+ var scope = Bind(
+ Definition(Element("await", BpmnElementTypes.IntermediateCatchEvent)),
+ new BpmnWorkBinding.SignalWait(ProcessId, "await", Ref("await"), BpmnBindingSlot.Primary, "Cancelled"));
+
+ Assert.Equal("Cancelled", ValueOf(Assert.IsType(WorkOf(scope, "await")).EventName));
+ }
+
+ [Fact(DisplayName = "A message publish binds to a PublishEvent on the message name")]
+ public void MessagePublish_BindsToAPublishEvent()
+ {
+ var scope = Bind(
+ Definition(Element("send", BpmnElementTypes.SendTask)),
+ new BpmnWorkBinding.MessagePublish(ProcessId, "send", Ref("send"), BpmnBindingSlot.Primary, "OrderShipped"));
+
+ Assert.Equal("OrderShipped", ValueOf(Assert.IsType(WorkOf(scope, "send")).EventName));
+ }
+
+ [Fact(DisplayName = "A call activity binds to a DispatchWorkflow on the called element, keeping fire-and-forget")]
+ public void CallProcess_BindsToADispatchWorkflow()
+ {
+ var scope = Bind(
+ Definition(Element("call", BpmnElementTypes.CallActivity)),
+ new BpmnWorkBinding.CallProcess(ProcessId, "call", Ref("call"), BpmnBindingSlot.Primary, "shipping-process", false));
+
+ var dispatch = Assert.IsType(WorkOf(scope, "call"));
+
+ Assert.Equal("shipping-process", ValueOf(dispatch.WorkflowDefinitionId));
+
+ // BPMN has no standard way to say "fire and forget", so the library carries it on the binding. Dropping it
+ // here turns an asynchronous call into one the enclosing scope waits on, and the process stops where it used
+ // to carry on.
+ Assert.False(ValueOf(dispatch.WaitForCompletion));
+ }
+
+ [Fact(DisplayName = "A call activity naming no called element is refused")]
+ public void CallProcess_RefusesACallWithNothingToCall()
+ {
+ Assert.Throws(() => Bind(
+ Definition(Element("call", BpmnElementTypes.CallActivity)),
+ new BpmnWorkBinding.CallProcess(ProcessId, "call", Ref("call"), BpmnBindingSlot.Primary, null, true)));
+ }
+
+ [Fact(DisplayName = "A nested process binds to a BpmnProcess scope that binds its own work")]
+ public void NestedProcess_BindsToANestedScope()
+ {
+ var body = new BpmnProcessDefinition("sub", Elements: [BoundElement("subWork", BpmnElementTypes.ServiceTask, new WriteLine("nested"))]);
+
+ var scope = Bind(
+ Definition(Element("sub", BpmnElementTypes.SubProcess)),
+ new BpmnWorkBinding.NestedProcess(ProcessId, "sub", Ref("sub"), BpmnBindingSlot.Primary, body),
+ Unbound("subWork", processId: "sub"));
+
+ var nested = Assert.IsType(WorkOf(scope, "sub"));
+
+ Assert.Same(body, nested.Process);
+ Assert.Equal("nested", ValueOf(Assert.IsType(WorkOf(nested, "subWork")).Text));
+
+ // A nested scope's start events are internal to the process around it. The command applier refuses a nested
+ // scope that says otherwise, so a binder that marked one would only fail once the process ran.
+ Assert.False(nested.IsRootScope);
+ }
+
+ [Fact(DisplayName = "A document-declared variable is copied onto the bound scope, and drives a collection-mode multi-instance")]
+ public async Task DocumentDeclaredVariable_DrivesACollectionModeMultiInstance()
+ {
+ // BpmnScopeVariables.TryRead resolves purely through Elsa's own ExpressionExecutionContext.GetVariable, which
+ // walks Container.Variables — never BpmnProcessDefinition.Variables. A collection the document declares but
+ // the binder never copies onto the scope is Absent to the interpreter, and a collection-mode multi-instance
+ // over it faults the element instead of running once per item. Running the bound scope for real, rather than
+ // only inspecting its shape, is what catches that: a structural assertion on scope.Variables would still pass
+ // if the interpreter could not actually see the value.
+ const string collectionVariableName = "items";
+
+ var each = new BpmnElement(
+ "each",
+ BpmnElementTypes.ServiceTask,
+ bindingRef: Ref("each"),
+ loopCharacteristics: new BpmnLoopCharacteristics(isSequential: false, collectionVariable: collectionVariableName),
+ extensions: BpmnActivityBindingFormat.Attach(null, Format.Write(new WriteLine("iterated"))));
+ var after = BoundElement("after", BpmnElementTypes.ServiceTask, new WriteLine("after"));
+
+ var definition = new BpmnProcessBuilder(ProcessId)
+ .Variable(collectionVariableName)
+ .StartEvent("start")
+ .Element(each)
+ .Element(after)
+ .EndEvent("end")
+ .ConnectSequence("start", "each", "after", "end")
+ .Build();
+
+ // The default travels as the declaration's own JsonElement, seeded here the way an imported .bpmn would carry
+ // it: the document names the variable and gives it a value, and the binder is what makes both visible.
+ definition = definition with
+ {
+ Variables = [new BpmnVariableDeclaration(collectionVariableName, null, JsonSerializer.SerializeToElement(new[] { "alpha", "beta", "gamma" }))]
+ };
+
+ var scope = Bind(definition, Unbound("each"), Unbound("after"));
+
+ Assert.Contains(scope.Variables, variable => variable.Name == collectionVariableName);
+
+ var eachActivityId = scope.WorkBindings[Ref("each")];
+ var afterActivityId = scope.WorkBindings[Ref("after")];
+
+ var result = await Services.GetRequiredService().RunAsync(scope);
+
+ Assert.Equal(3, result.Journal.ActivityExecutionContexts.Count(context => context.Activity.Id == eachActivityId));
+ Assert.Equal(1, result.Journal.ActivityExecutionContexts.Count(context => context.Activity.Id == afterActivityId));
+ Assert.Empty(result.WorkflowState.Incidents);
+ }
+
+ [Fact(DisplayName = "A ScopeListener binding is bound like any other, under its own binding ref")]
+ public void ScopeListenerSlot_BindsWithNoSpecialCase()
+ {
+ // An event subprocess element carries two bindings: its body, and the listener its enclosing scope arms while
+ // it runs. Both are entries in the same map; the interpreter arms the listener at scope start on its own.
+ var element = new BpmnElement(
+ "escalationHandler",
+ BpmnElementTypes.SubProcess,
+ bindingRef: Ref("escalationHandler"),
+ triggeredByEvent: true,
+ listenerBindingRef: ListenerRef("escalationHandler"));
+
+ var scope = Bind(
+ Definition(element),
+ new BpmnWorkBinding.NestedProcess(ProcessId, "escalationHandler", Ref("escalationHandler"), BpmnBindingSlot.Primary, new BpmnProcessDefinition("escalationHandler")),
+ new BpmnWorkBinding.MessageWait(ProcessId, "escalationHandler", ListenerRef("escalationHandler"), BpmnBindingSlot.ScopeListener, "Escalated"));
+
+ Assert.IsType(WorkForRef(scope, Ref("escalationHandler")));
+ Assert.Equal("Escalated", ValueOf(Assert.IsType(WorkForRef(scope, ListenerRef("escalationHandler"))).EventName));
+ }
+
+ [Fact(DisplayName = "An unbound task the document does not bind is refused, naming the element")]
+ public void UnboundTask_WithNoDeclarationIsRefused()
+ {
+ // The failure that has to be loud. Skipping the binding instead would produce a scope whose interpreter
+ // schedules work for a binding ref nothing maps, which surfaces much later and much further away.
+ var exception = Assert.Throws(() => Bind(Definition(Element("approve", BpmnElementTypes.UserTask)), Unbound("approve")));
+
+ Assert.Contains("approve", exception.Message);
+ Assert.Contains(BpmnActivityBindingFormat.BindingElementName, exception.Message);
+ }
+
+ [Fact(DisplayName = "An unbound task the document binds resolves to the declared activity, inputs intact")]
+ public void UnboundTask_WithADeclarationResolves()
+ {
+ var scope = Bind(
+ Definition(BoundElement("approve", BpmnElementTypes.UserTask, new WriteLine("approved"))),
+ Unbound("approve"));
+
+ Assert.Equal("approved", ValueOf(Assert.IsType(WorkOf(scope, "approve")).Text));
+ }
+
+ [Fact(DisplayName = "An activity binding on an element with no unbound task to bind is refused")]
+ public void DeadDeclaration_IsRefused()
+ {
+ // Six of the seven kinds never consult a declaration, so one written on a timer configures nothing. Ignoring
+ // it leaves the author's expression sitting in the file while the activity they configured never executes.
+ var element = new BpmnElement(
+ "wait",
+ BpmnElementTypes.IntermediateCatchEvent,
+ bindingRef: Ref("wait"),
+ extensions: BpmnActivityBindingFormat.Attach(null, Format.Write(new WriteLine("never runs"))));
+
+ var exception = Assert.Throws(() => Bind(Definition(element), Timer("wait", "PT1M")));
+
+ Assert.Contains("wait", exception.Message);
+ }
+
+ [Fact(DisplayName = "Two scopes declaring the same binding ref get their own activity instance and their own node")]
+ public async Task SameBindingRefInTwoScopes_ProducesDistinctActivityNodes()
+ {
+ // A binding ref is unique within a scope, not across scopes. One instance shared between two scopes still
+ // builds and still publishes: ActivityVisitor collects activities into a set and skips one it has already
+ // seen, so the second scope simply has no child in the identity graph and half the process never runs.
+ var body = new BpmnProcessDefinition("sub", Elements: [BoundElement("work", BpmnElementTypes.ServiceTask, new WriteLine("inner"))]);
+
+ var scope = Bind(
+ Definition(BoundElement("work", BpmnElementTypes.ServiceTask, new WriteLine("outer")), Element("sub", BpmnElementTypes.SubProcess)),
+ Unbound("work"),
+ new BpmnWorkBinding.NestedProcess(ProcessId, "sub", Ref("sub"), BpmnBindingSlot.Primary, body),
+ Unbound("work", processId: "sub"));
+
+ var outer = WorkOf(scope, "work");
+ var inner = WorkOf(Assert.IsType(WorkOf(scope, "sub")), "work");
+
+ Assert.NotSame(outer, inner);
+ Assert.NotEqual(outer.Id, inner.Id);
+
+ // The invariant stated the way Elsa sees it: two nodes, one per logical position.
+ var nodes = await IdentityGraphOfAsync(scope);
+ var texts = nodes.Select(node => node.Activity).OfType().Select(activity => ValueOf(activity.Text)).Order().ToList();
+
+ Assert.Equal(new[] { "inner", "outer" }, texts);
+ }
+
+ private BpmnProcess Bind(BpmnProcessDefinition definition, params BpmnWorkBinding[] bindings) => Binder.Bind(definition, bindings);
+
+ private static BpmnProcessDefinition Definition(params BpmnElement[] elements) => new(ProcessId, Elements: elements);
+
+ private static BpmnElement Element(string elementId, string elementType) => new(elementId, elementType, bindingRef: Ref(elementId));
+
+ private static BpmnWorkBinding.TimerWait Timer(string elementId, string isoDuration) =>
+ new(ProcessId, elementId, Ref(elementId), BpmnBindingSlot.Primary, isoDuration);
+
+ private static BpmnWorkBinding.UnboundTask Unbound(string elementId, string processId = ProcessId) =>
+ new(processId, elementId, Ref(elementId), BpmnBindingSlot.Primary, BpmnElementTypes.ServiceTask);
+
+ private static string ListenerRef(string elementId) => $"{Ref(elementId)}-listener";
+
+ /// The activity the scope maps the given element's work to, resolved the way the host resolves it.
+ private static IActivity WorkOf(BpmnProcess scope, string elementId) => WorkForRef(scope, Ref(elementId));
+
+ private static IActivity WorkForRef(BpmnProcess scope, string bindingRef)
+ {
+ Assert.True(scope.WorkBindings.TryGetValue(bindingRef, out var activityId), $"The scope maps no work to binding ref '{bindingRef}'.");
+ return Assert.Single(scope.Activities, activity => activity.Id == activityId);
+ }
+}
diff --git a/test/unit/Elsa.Bpmn.Interchange.UnitTests/BpmnLibraryDuplicationGuardTests.cs b/test/unit/Elsa.Bpmn.Interchange.UnitTests/BpmnLibraryDuplicationGuardTests.cs
index 7401f9010..f52ab4d00 100644
--- a/test/unit/Elsa.Bpmn.Interchange.UnitTests/BpmnLibraryDuplicationGuardTests.cs
+++ b/test/unit/Elsa.Bpmn.Interchange.UnitTests/BpmnLibraryDuplicationGuardTests.cs
@@ -94,9 +94,19 @@ public class BpmnLibraryDuplicationGuardTests
.ToList();
}
+ // The declaring-type walk is load-bearing, not defensive. A collection expression targeting an
+ // IReadOnlyList makes the compiler synthesize a <>z__ReadOnlySingleElementList into the
+ // assembly; that outer type is filtered by its name, but its nested Enumerator struct is named
+ // plainly and carries no CompilerGeneratedAttribute of its own, so it reads as a hand-written type
+ // colliding with the identically synthesized one in Bpmn.Model. That is a false positive: nothing a
+ // person wrote is ever nested inside a <>-named type, and this guard's own fixture (BpmnGraph,
+ // nested in an ordinary class) still trips the detector. Without this, the guard fails the moment
+ // anyone writes a single-element collection expression in Elsa.Bpmn*, which teaches people to work
+ // around it rather than to trust it.
private static bool IsCompilerGenerated(Type type) =>
type.Name.StartsWith('<') ||
- type.IsDefined(typeof(CompilerGeneratedAttribute), inherit: false);
+ type.IsDefined(typeof(CompilerGeneratedAttribute), inherit: false) ||
+ (type.DeclaringType is { } declaringType && IsCompilerGenerated(declaringType));
///
/// Exists solely as this guard's own fixture: its name deliberately collides with