-
Notifications
You must be signed in to change notification settings - Fork 0
concepts capabilities
Not every host can do everything BPMN requires. A host declares what it can do, and a definition needing more than that is refused when the graph is built — not degraded, not partially executed, not warned about at run time.
This page explains why refusing is the correct behavior, and why the alternative is worse than it looks.
[Flags]
public enum BpmnHostCapabilities
{
None = 0,
SubtreeCancellation = 1,
ScopeSignalling = 2,
IterationScopes = 4,
ScopeVariables = 8,
Full = SubtreeCancellation | ScopeSignalling | IterationScopes | ScopeVariables
}| Capability | What the host is promising |
|---|---|
SubtreeCancellation |
It can stop a unit of work and everything started underneath it, recursively. |
ScopeSignalling |
It can deliver a notification from a child scope to its enclosing scope. |
IterationScopes |
It can run the same bound work several times, keeping each instance's frame distinct. |
ScopeVariables |
It can hold and read container-scoped variables through IBpmnVariableReader. |
Full is every capability. It is a claim, not a default. None is a legitimate declaration and runs
a large fraction of real BPMN correctly.
You declare capabilities twice, and they must agree: once when building the graph, and once in each
BpmnHostSnapshot.
var graph = BpmnGraph.Build(definition, boundWork, BpmnHostCapabilities.SubtreeCancellation
| BpmnHostCapabilities.ScopeVariables);BpmnGraph.Build throws BpmnCapabilityException when the declared set does not cover what the
definition requires, naming every unmet capability and the elements that need it.
You do not have to discover a mismatch by catching an exception. A definition's requirements are computed statically:
var requirements = BpmnCapabilityRequirements.Analyze(definition);
var missing = requirements.MissingFrom(MyCapabilities);
if (missing != BpmnHostCapabilities.None)
{
foreach (var (capability, elementIds) in requirements.DrivingElementIds)
Console.WriteLine($"{capability} is needed by {string.Join(", ", elementIds)}");
return Rejected(missing);
}Required is the union of everything the definition needs; DrivingElementIds says which elements
drive each capability. That is enough to produce a good error at import time, in your own vocabulary,
pointing at the elements in the document the user just uploaded — rather than an exception at
deployment.
requirements.ThrowIfUnmet(available, processId) is the same check with the library's own message,
which is what Build calls internally.
Only the definition you pass is analyzed. A nested process carried as bound work is a separate scope with its own graph, so analyze and build it separately.
The import analysis surfaces the same requirements per process, so analyzing a .bpmn file answers
"can my host run this?" before anything is built.
Because the check is static, it depends only on the definition. It cannot be defeated by data, and it does not vary between instances.
Here is the concrete case. It is not hypothetical, and it is why the rule exists.
A process has a subprocess with an interrupting boundary timer attached. The timer fires.
BPMN says exactly what happens: the subprocess and everything running inside it is terminated, and one token continues along the boundary path. One live branch.
Now suppose the host cannot cancel a work subtree. Everything else still works. The timer fires, the interpreter routes the boundary path, a token continues. But the subprocess is still running, because nothing stopped it. When its own work eventually completes, its normal outgoing path continues too.
Two live branches where BPMN says one.
Downstream of that: a parallel join that will never fire because it is receiving arrivals it was never designed for, or fires twice. A task executed twice. A payment taken twice. A compensation log with registrations from work that was supposed to have been destroyed.
That is not a degraded execution of the model. It is a different and incorrect execution of it, and nothing in the resulting state says so. The process looks like it is running fine. It is running wrong.
Degrade at run time. Fire the boundary event, skip the cancellation, keep going. The failure is silent, arbitrarily delayed, and manifests as a data-integrity problem far from its cause. Nobody debugging a doubled payment three weeks later is going to trace it back to a missing capability.
Fault at run time. Fire the boundary event, discover the cancellation cannot be performed, fault the instance. Correct in the sense that nothing wrong is committed — but it faults in production, inside a live instance, on a path that may only be reached by a rare timeout. The capability was missing all along; the process merely had not happened to need it yet. Discovering a structural mismatch through a production incident is the worst available time.
Refuse at build time. The definition contains an interrupting boundary event. Interrupting boundary events require subtree cancellation. The host did not declare it. Refuse, name the element and the capability, stop.
The third is the only one where the mismatch is discovered at the moment it can be fixed, by the person who can fix it, with the information needed to fix it. The determination is purely structural — it depends on the definition and the declaration, not on which paths a particular instance takes — so there is no reason to wait.
A host that cannot honor a construct must not be allowed to pretend it did.
There is no partial credit in a process model. A boundary event either interrupts or it does not; an escalation either reaches its parent or it does not. A construct executed approximately is not a weaker version of that construct — it is a different behavior wearing its name, and it is worse than an absent one because it is invisible.
The build-time refusal converts an invisible correctness problem into a visible deployment problem.
If you can implement everything, declare Full and stop thinking about this. Most hosts can.
If you cannot, you are not locked out. A large fraction of real BPMN uses none of these
capabilities: linear processes, exclusive, parallel and inclusive gateways, tasks, plain start and
end events, and intermediate message and timer catches. A host declaring None runs those correctly
and is refused only where it would be wrong.
Add capabilities as you need them. Each one is a discrete piece of host machinery, and the refusal message tells you which one a document is asking for. That is a much better roadmap than a list of features nobody has asked for yet.
Check requirements at the boundary of your system, not at deployment.
BpmnCapabilityRequirements.Analyze exists so a user uploading a diagram is told immediately, in
your product's language, that your system does not support the construct they drew.
A branch that never executes still counts. A definition is refused even when the unsupported construct sits on a path that a particular instance would never reach. That is a real loss of permissiveness, accepted deliberately: a clear refusal up front beats a process that works until the day it does not.
| Construct | Requires | Why |
|---|---|---|
| Interrupting boundary event | SubtreeCancellation |
It tears its host activity down. |
| Catch boundary event (timer, message, signal), even non-interrupting | SubtreeCancellation |
Its armed listener is torn down when its host completes. |
| Event subprocess | SubtreeCancellation |
An interrupting one drains the scope; every scope listener is retired when the scope completes. |
| Event-based gateway | SubtreeCancellation |
The losing branches are torn down when the first catch wins. |
| Escalation throw or end event, escalation boundary event | ScopeSignalling |
A throw signals the enclosing scope, and a scope re-signals what it cannot match itself. |
| Multi-instance activity | IterationScopes |
Each instance needs its own frame. |
| Collection-mode multi-instance | ScopeVariables |
Its instance count and items come from a declared variable. |
| Compensation boundary event | nothing | It arms no listener. |
| Exclusive, parallel and inclusive gateways | nothing | |
| Tasks, plain start and end events, sequence flows | nothing |
Note the second row. A non-interrupting catch boundary event still needs subtree cancellation, because the listener it arms has to be retired when its host finishes. It is easy to assume only interrupting events tear anything down; the analyzer knows better.
BpmnCapabilityRequirements.Analyze(definition) is the authority for a specific document, and the
graph builder is the authority overall.
- The host port — what a host implements.
- Hosting the interpreter — how to declare and honor capabilities.
- Supported BPMN constructs — what the library supports before capabilities enter the picture.
- ADR 0004: Missing host capabilities refuse at graph-build time
Overview
Guides
- Building a process in code
- Getting started
- Hosting the interpreter
- Reading and writing BPMN XML
- Simulating a process
Concepts
Reference
Decision records
- ADR 0001: BPMN semantics ship as a host-agnostic library
- ADR 0002: The host port is synchronous and command-returning
- ADR 0003: Faults are a verdict, not a command
- ADR 0004: Missing host capabilities refuse at graph-build time
- ADR 0005: The library owns a versioned payload format
- ADR 0006: Vendor extensions are retained as typed data
- Decision records