Bpmn.Interchange, Bpmn.Model and Bpmn.Semantics move from 0.1.1-preview.19 to
0.2.0. The three changes below are one unit: the bump is what makes the other
two true.
Retire the private feed. All three packages are on nuget.org at 0.2.0 --
including Bpmn.Semantics, which had no stable release under 0.1.x. The
bpmn-feedz source and its Bpmn.* packageSourceMapping entry both go, which
removes a setup step for every consumer. This is not merely cleanup: the feedz
feed does not carry 0.2.0 stable, so with the mapping left in place the bump
would not restore at all. Restore now resolves Bpmn.* from nuget.org via the
existing `*` mapping.
Lift IsPackable=false. The comment on the flag named its own removal condition
-- the Bpmn.* packages reaching nuget.org -- and that condition is now met, so
Elsa.Bpmn and Elsa.Bpmn.Interchange begin shipping. Both pack with every
dependency publicly restorable, and both emit a package manifest carrying
runtimeKinds ["elsa.server"], so neither is silently excluded from the catalog.
They were the last two IsPackable=false projects under src/.
Flip the compensation pin to the fixed behaviour. 0.2.0 contains the fix for
valence-works/bpmn#13 (filed from here as #7959), so
CompensationRunCancelledMidReplay went red on the bump exactly as it was built
to. Upstream took the wide fix: every token a cancelled transaction abandons now
gets a real teardown. The head handler still starts twice -- that is the release
being real -- but the first run is now torn down, so the scope is left holding
one live record for the slot instead of two. The applier is deliberately
unpatched; the assertions moved to describe the fix, not to accommodate it.
Note on persisted state: 0.2.0 freezes the payload format at 1.0.0 and state
persisted by 0.1.x no longer deserializes. Elsa persists the library's
BpmnExecutionState into workflow state, so an in-flight BPMN instance does not
survive this bump. Neither module has ever been published, so no released
consumer can be holding such state -- which is why this is the moment to take
the break.
BpmnRuntimeCapabilitiesTests stays green: 0.2.0 defines no capability flag Elsa
does not already declare. The new cancel-end-event requirement is
SubtreeCancellation, which BpmnRuntimeCapabilities.Declared already carries.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The two-record assertion in CompensationRunCancelledMidReplay pins a bug whose
root cause is in Bpmn.Semantics, not in this host, so the tracker that will
actually move is valence-works/bpmn#13. #7959 stays named as provenance -- it
carries the decompiled evidence and the routing rationale -- but is closed as
routed upstream, so it is no longer the thing to watch.
Also records what the current comment did not: both counts become 1 when the fix
lands, and the flip may not be mechanical. Upstream is choosing between tearing
down only the compensation handler it re-starts and tearing down every abandoned
token, and the second also cancels transaction branches still in flight, which
can move other scenarios in this suite.
Comment-only. The assertions and the applier are deliberately unchanged.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
BpmnTestProcesses had grown to 905 lines as one flat static class holding the
fixtures for every BPMN construct family the runtime slice covers, and two
standards reviews flagged it as Divergent Change while judging the split out of
scope for the issue in hand.
It becomes a partial class across six sibling files, one per family -- boundary
events, compensation and transactions, event subprocesses, flow and gateways,
multi-instance -- with the shared element factories (Timer, Cancel, Compensation,
CompensationBoundary, Escalation, Error, Message, EventSubprocess,
EventSubprocessStart) and the Scope/Immediate/Blocking/Faulting builders left in
one place, so no new file duplicates them. Partial rather than separate types
because every call site says BpmnTestProcesses.X and none of them change.
Pure move: all 47 members were carved out programmatically and diffed back
against HEAD, each present exactly once and byte-identical. In particular
EscalationOutOfSubprocess keeps its leading subFirst work item and the comment
explaining why the nested scope's handle counter must run ahead of its parent's.
The identical Compensation/CompensationBoundary helpers in
Elsa.Bpmn.Interchange.IntegrationTests are deliberately left duplicated: the only
assembly both test projects can see is Elsa.Testing.Shared.Integration, which
ships as a NuGet package, so sharing ten lines of test helper would mean adding
an Elsa.Bpmn reference to a published package's dependency graph.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* test(bpmn): event subprocesses, dormant and listener-backed (#7933)
Both flavours, and the three responsibilities the host has for them. The
production diff is empty: the applier and binder need no event-subprocess
code path, which is what #7909 measured and what this pins.
A dormant catcher -- error or escalation -- rides the FaultSignal seam and the
escalation signal path, and reaches the host as an ordinary StartWork for its
body. A listener-backed one gets a second StartWork for its listenerBindingRef
at scope start and a CancelWorkSubtree for it when the scope completes. Both
already apply like any other command.
The host responsibilities, each with the failure it would otherwise hide:
- The listener is armed at scope start and observable in the scope's own ledger
before anything fires, not inferred from a fire that worked.
- A completing scope retires a still-armed listener. Pinned twice: at the root,
where Elsa's own container completion would cancel the child regardless and
only the scope's ledger tells the two apart, and inside a subprocess the
workflow outlives, where a listener left behind is one something could still
resume into.
- The start-element hint reaches the body and nothing else inherits it. The
body's only start event is event-defined, so a body that never received the
hint faults bpmn.start.none-available rather than starting somewhere
plausible; the ordinary subprocess inside it faults bpmn.start.unresolved-hint
if the hint travels where it must not. The scope's invocation correlation is
read back after the body has run work of its own, because the dictionary is
fixed for the scope's lifetime and the hint is read from it.
- A non-interrupting listener fired twice re-arms onto the slot the first fire
vacated, holding one live record and one bookmark at a time. This is the case
the completed-work-removed-before-the-interpreter-is-asked ordering exists
for, and it is now observable; BpmnHostInvariantTests points at it.
The library's declaration rules are pinned as refusals rather than gaps: a body
with more than one start event, a second error-triggered event subprocess in a
scope, and a non-interrupting error event subprocess are each refused when the
scope builds its graph, before any work starts. The last is additionally
dropped at import, with the rest of the document reading as written -- the
dropped body carries an undeclared serviceTask, so an import that still
succeeds is what proves the drop took its bindings with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(bpmn): pin the catch-all escalation event subprocess refusal
Two error-triggered event subprocesses per scope was pinned but its sibling
rule -- at most one code-less catch-all escalation event subprocess -- had
no test. Add TwoCatchAllEscalationEventSubprocesses_AreRefused, mirroring
the error refusal test, and let Escalation() build a code-less definition.
Also record in BpmnCommandApplier why CancelSubtreeAsync's explicit
subtree cancellation is redundant on the scope-completion path (Elsa's own
container-completion behaviour already covers it) while the ledger removal
above it is not, so a future reader does not "simplify" the ledger removal
away.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* test(bpmn): cover compensation, targeted replay and transaction cancellation
W15 turns on compensation boundary events, reverse-order replay, targeted
compensation, transaction subprocesses and cancel end/boundary events. The
measured claim in the design holds: the command applier needs no changes, and
none were made. A compensation handler arrives as an ordinary StartWork
carrying cause=compensation, and the binder already binds it because the reader
emits an ordinary Primary binding for an isForCompensation element.
What is new is the test mass that says so, and each case pins a failure that
otherwise looks like success:
- three registrations replayed in reverse, asserted as an ordered log rather
than as "all three ran"
- a compensate throw naming an activityRef, where the two unselected handlers
are bound work that must stay unrun -- which is also where "a handler is
never scheduled from flow" becomes observable
- a compensation run torn down mid-replay by a cancel end event, so its claimed
but unrun entry is released back to registered and the cancellation's own
replay reaches it; leaving it claimed would cancel with nothing to compensate
and finish looking healthy
- a transaction completing Cancelled with no cancel boundary to route it, which
must fault rather than take the ordinary sequence flow
- two compensation logs, one per scope, in a subprocess and around it
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(bpmn): pin the duplicate live-work record a cancelled transaction leaves
CompensationRunCancelledMidReplay asserted only log counts and Finished, so
the two live ledger records/bookmarks it produces for the releaseSeat slot
went unasserted. Add an explicit assertion on the scope's ledger, and correct
the comment that framed the second handler start as evidence only of the
release working, when it is also the symptom of the interpreter defect
tracked in #7959.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat(bpmn): let a BPMN process start from outside via message, signal, and recurring timer starts
BpmnProcess now implements ITrigger: it walks its own event-defined start events
and emits an EventStimulus per resolved message/signal name (matching what
Event/PublishEvent already key bookmarks on) and a TimerTriggerPayload/CronTriggerPayload
per recurring timer start (matching Elsa.Scheduling's own Timer/Cron path), so
correlation is on name for message/signal and reuses the existing scheduling
mechanism for timers, unmodified.
BpmnWorkBinder.Bind now marks the one scope it returns directly as the workflow's
root scope; every nested scope it produces stays off. Because that flag alone
cannot see composition that happens after it is set (e.g. nesting through an
intermediate Flowchart, the gap left open by #7926's applier-level refusal),
BpmnProcess re-derives entry-point status from the whole workflow graph at
trigger-indexing time and refuses to register regardless of what the flag says.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(bpmn): give each BPMN start trigger its own stimulus name, dedup, and refuse per malformed timer
BpmnProcess.GetTriggerPayloadsAsync now wraps every start-event payload in a
NamedTriggerPayload (the per-payload stimulus naming TriggerIndexer gained in #7950)
instead of the shared TriggerIndexingContext.TriggerName, so a process with both a
message/signal start and a recurring timer start no longer has the last kind processed
claim the name -- and therefore the hash -- for every row.
Two start events (or two event definitions) that resolve to the same stimulus name and
value now collapse to one payload, so StimulusSender no longer starts the workflow twice
for one inbound stimulus. A malformed <timeCycle> interval is refused for its own start
event only, named in the warning like the work binder's own malformed-duration message;
every other valid start event on the process still registers, since letting the exception
propagate would just be swallowed whole by TriggerIndexer's catch-all around
GetTriggerPayloadsAsync, discarding every other start again.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(bpmn): refuse a non-positive BPMN timer interval
XmlConvert.ToTimeSpan accepts PT0S and negative durations; either
registered as a recurring timer trigger turns into a hot loop, since
the scheduler substitutes a ~1ms delay whenever the next execution
time is non-positive. Refuse it through the same per-start-event
refusal path already used for a malformed interval, so the process's
other start events still register.
Also address two small static-analysis findings in the same method:
filter the start-event loop explicitly with .Where(...) instead of an
implicit continue, and combine two genuinely-simple nested if pairs
(message/signal name resolution, and the cron branch) with &&. The
timer interval's own nested if/try-catch is left alone: combining it
would only get harder to read once the non-positive check joins it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(bpmn): refuse a BPMN timer interval below the scheduler's resolution
A positive but sub-resolution interval (e.g. PT0.0000001S) passed the
existing non-positive refusal unchanged and rearmed in the same hot loop
that guard was meant to close, one step down: Elsa.Scheduling's
ScheduledRecurringTask.SetupTimer substitutes a 1ms delay for any
non-positive delay it computes, and SchedulingOptions.MinimumPastDueScheduleDelay
defaults to that same 1ms, so 1ms is the scheduler's own resolution floor,
not a guessed constant. Refuse an interval below it through the same
per-start-event path the malformed and non-positive cases already use, so
the offending start event is skipped and the process's other start events
still register.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* test(bpmn): prove BpmnExecutionState pruning and ledger rehydration survive a real suspend/resume
Prune() was already being called before every persisted write in BpmnScopeHost, but nothing proved
it, and no BPMN test had ever exercised a genuine rehydration: every existing scenario used the
in-memory WorkflowState object straight from the previous run. Add tests that round-trip a
suspended scope's state through Elsa's own IWorkflowStateSerializer -- the boundary that mangled
values before -- and assert the persisted BpmnExecutionState stays bounded across many evaluations
and that a resumed scope with two live units of work matches each completion back to its binding
through the rehydrated BpmnWorkLedger. Both tests were confirmed red by mutation-testing away
Prune() and by returning an empty ledger from BpmnScopeMemory.Load.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(bpmn): prove a nested scope's ledger survives real persistence
Both new tests in the prior commit suspended a root scope with live work
outstanding, but neither crossed a nested scope's own ledger through Elsa's
real IWorkflowStateSerializer -- the intersection of the two things that
have actually broken here: the handle-to-context map, and the serializer
boundary. Add a nested parallel split/join, blocking on both branches inside
an embedded subprocess, round-tripped through the serializer between each
branch's completion, and confirmed red by returning an empty ledger from
BpmnScopeMemory.Load and green with it restored.
Also extract the start/split/left/right/join/after/end topology shared
verbatim by ParallelSplitAndJoin and ParallelSplitAndJoinBlocking into one
private builder parameterised by the branches' work, keeping both public
factories unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat(bpmn): scope variables, trigger opt-out and composability for BpmnProcess
Completes the container W2 left minimal, with the four things it deferred.
Scope variables. BpmnScopeVariables implements IBpmnVariableReader over the
scope's memory register, walking outward so an inner scope sees the enclosing
one's data, and BpmnScopeHost now declares ScopeVariables and hands the reader
to every snapshot. The read is three-valued: false for a name nothing in scope
declares, Null for a declared variable holding nothing, and StoredExternally
for a value JSON cannot carry.
That last case deviates from the issue, deliberately. The issue names the
unmaterialized-driver case, which is not detectable from the container's side:
PersistentVariablesMiddleware loads with no excludeTags, and
VariablePersistenceManager marks a block IsInitialized before testing the
exclusion, so a variable whose driver was never read is indistinguishable from
one whose driver returned null. Closing that needs a change to
Elsa.Workflows.Core, which is out of bounds here, so the reader answers only
what the block actually says and the XML doc records why. The route it does
have is real and in the same spirit: a value the host holds and cannot put on
the wire faults loudly rather than reading as an empty collection.
Trigger opt-out. BpmnProcess.IsRootScope names the BPMN meaning of Elsa's
CanStartWorkflow rather than adding a second flag that could disagree with the
gate TriggerIndexer actually reads. It is off unless something says otherwise,
and the applier refuses to start a BpmnProcess that claims root position as
another scope's work: the damage a mis-flagged subprocess does happens at
publish time, so repairing the object graph at runtime would leave the trigger
registered while every test went green. ITrigger itself remains #7929.
Composability and outcomes. A BpmnProcess in a Flowchart runs and the flowchart
carries on (D11), and a nested transaction completing Cancelled reaches its
parent's completion callback with that outcome intact, which is the only reason
the parent routes the cancel boundary rather than the ordinary sequence flow.
Every guard was mutation-tested red before green: both non-Present answers of
the reader, the reader left unwired, the opt-out's default flipped (7 tests red,
including the pre-existing nested-scope ones), the refusal removed, and the
outcome dropped at each end of the trip to the parent.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(bpmn): apply review findings on scope variables, command batching, and outcome doc
Read a scope variable through Elsa's configured serializer (via IPayloadSerializer,
serialized against the value's own runtime type so a polymorphic value is not wrapped
in Elsa's type-tagged envelope) instead of bare JsonSerializerDefaults, so a value only
Elsa's converters can carry no longer collapses to StoredExternally. Refuse a root-scope
StartWork before any command in the batch is applied, not mid-list, so a refusal cannot
leave scope memory partially mutated under ContinueWithIncidentsStrategy. Document that
BpmnProcess completes with only its interpreter outcome, so a default/null-port
Flowchart connection never fires from it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* refactor(bpmn): filter the pre-scan explicitly
Use commands.OfType<BpmnHostCommand.StartWork>() in ApplyAsync's
root-scope pre-scan instead of a foreach + type-check, matching the
static analysis suggestion. The apply loop below is unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat(bpmn): host-side applier for the Bpmn.Semantics port
Translates the interpreter's three host commands onto ActivityExecutionContext
and feeds its four entry points, plus the minimum BpmnProcess container needed
to exercise them end to end through IWorkflowRunner.
StartWork schedules the bound activity, CancelWorkSubtree calls the public
CancelActivityAsync extension (already recursive), and SignalEnclosingScope
sends a BpmnScopeSignal up the ancestor chain. OnWorkFaulted rides the
FaultSignal seam: it asks the interpreter what BPMN made of the fault and calls
StopPropagation only on a Caught disposition, leaving a Propagated one strictly
alone so an enclosing scope or the incident strategy takes it.
A unit of work is keyed on the child ActivityExecutionContext.Id, recorded in
the scope's own persisted ledger, never on Tag: the completion-callback dispatch
rewrites the receiving context's Tag, so a nested scope wears a different tag
than its parent remembers it by. Interpreter correlation travels on the child's
context rather than on the shared activity instance.
Evaluations go through one queue per workflow instance, so a scope signalled
mid-apply is drained after the command list rather than re-entering the
interpreter. Commands are applied in the order returned.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(bpmn): cover the teardown refusal path
Adds a focused unit test that drives BpmnWorkTeardown.CancelSubtreeAsync
into the NotSupportedException branch by constructing a real context tree
with a scheduled-but-not-invoked descendant, so a regression that silently
drops the detection is caught. Also records why BpmnWorkLedger's
append-only, handle-keyed Records list cannot strand a context on a
duplicate StartWork for a live (BindingRef, IterationId) slot, a case the
port's own guarantee makes unreachable from this applier.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(bpmn): keep a refused teardown from stranding ledger state
A subtree cancellation refused with NotSupportedException is absorbed
into an incident under ContinueWithIncidentsStrategy rather than
crashing, so the end-of-command ledger save was being skipped and the
persisted ledger kept claiming work BPMN had just torn down. Save the
ledger removal before the possible throw instead of after, so a later
completion callback for the stranded activity finds no live record and
is discarded instead of being fed to the interpreter.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Adds the valence-works/bpmn package feed and pins Bpmn.Model,
Bpmn.Semantics and Bpmn.Interchange at 0.1.1-preview.19, then wires
two new module projects consuming those libraries without
reimplementing anything they provide (D12): Elsa.Bpmn for BPMN
execution and Elsa.Bpmn.Interchange for XML import/export, kept as a
separate package so hosts that only execute BPMN don't take the XML
reader. Both are marked IsPackable=false until the Bpmn.* packages
are published to nuget.org. Test projects are added for both, unit
and integration, and all four are wired into Elsa.sln so PR CI
discovers and runs them. This unblocks #7925 and the rest of the
BPMN runtime work in #7909.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>