* fix(bpmn): make document PUT If-Match and save a compare-and-swap
The document PUT checked If-Match, reloaded metadata, then saved through
the importer as separate steps. Two writers could both pass If-Match, and
a metadata-only save in that window was silently reverted.
Add IWorkflowDefinitionStore.TryUpdateLatestAsync — load, match, apply,
save as one critical section (memory) or ExecuteUpdate against the loaded
snapshot (EF). ImportDocumentAsync reads metadata inside that swap.
A lost race throws the same 412 the stale If-Match already returns.
Mongo/Dapper/ES stores need the same method before the endpoint is
concurrency-safe on those providers.
Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>
* fix(bpmn): resolve document-service DI and interleave test compile
Drop the cache-manager constructor dependency (only registered when
definition caching is on) and evict via DraftSaving/DraftSaved instead.
Update the export-availability stub construction and the CAS interleave
fixture to parse edited XML through the reader.
Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>
* fix(bpmn): close Greptile P1s on document PUT CAS
Require IsLatest in the EF ExecuteUpdate WHERE so a published-to-draft
loser is Conflict instead of a unique-key failure. Lock Memory CAS on
the shared MemoryStore so scoped wrappers cannot stale-overwrite.
Dispatch WorkflowDefinitionDraftSaving before the CAS persist so a
rejecting handler fails the request before commit.
Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>
* fix(bpmn): reuse published draft identity across DraftSaving and CAS
Allocate the published-to-draft id, version and created-at once before
WorkflowDefinitionDraftSaving. The compare-and-swap still rebuilds from
the just-loaded row so metadata is not frozen from the outer Find, then
reuses that announced identity and keeps handler-added custom properties.
Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>
* test(management): run Memory CAS lock holder off the test thread
The shared-lock test blocked inside TryUpdateLatestAsync on the test
thread, so it never reached the release signal and hung.
Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>
* test(efcore): drop the SQLite CAS harness that cannot match ExecuteUpdate
The in-memory SQLite fixture could not satisfy the store's DateTimeOffset
ORDER BY plus Data snapshot WHERE, so the winner CAS returned Conflict
before the IsLatest loser path ran. Memory already covers that contract.
Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>
* fix(bpmn): persist the prepared DraftSaving draft on document PUT CAS
Prepare the draft, dispatch WorkflowDefinitionDraftSaving so handlers can
mutate or reject, then TryUpdateLatestAsync with If-Match plus the loaded
snapshot and update: _ => draft. A metadata change in the window is 412
instead of overwriting the other write. DraftSaved still fires after CAS.
Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>
* docs(bpmn): align document PUT remarks with prepared-draft CAS
Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>
---------
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
* fix(bpmn): refuse documents with duplicate element ids before recursion
A subProcess nested inside another subProcess with the same id made
EnsureCapabilitiesSatisfied, BpmnWorkBinder.BindScope and the interchange
library's own BpmnXmlWriter recurse without terminating, overflowing the
stack and killing the process (.NET cannot catch StackOverflowException).
Refuse such a document up front, coded bpmn.import.duplicate-element-id
(422), on POST bpmn/import and PUT bpmn/definitions/{id}/document, listing
the duplicated ids.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(bpmn): include process ids in the duplicate-id check
EnsureElementIdsUnique only pooled element ids, never a process
definition's own ProcessId. A top-level process's id is never one of
its own elements, so a subprocess reusing its parent's id (or two
top-level processes sharing an id) went undetected and still
overflowed the stack in EnsureCapabilitiesSatisfied, BpmnWorkBinder
and BpmnXmlWriter the same way a repeated element id does. Add every
top-level process's own id to the pool; a nested process definition's
own id needs no equivalent addition since it is always exactly the
element id that opens it, already counted once via its owner's
elements.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat(bpmn): give BPMN import/export/document refusals stable error codes
Studio has to recognise a BPMN import/export refusal, and extract capability
names and element ids, by matching the server's message text, so any rewording
silently degrades it to a generic error. Add BpmnErrorCodes with a stable code
per refusal (capability-unsupported, binding-invalid, export not-imported/
source-stale/source-version-unknown, and the document PUT's not-found and
precondition codes), sent through an additive BpmnErrorResponse envelope that
keeps today's statusCode/message/errors shape unchanged and adds code/data
alongside it, since FastEndpoints' own error response has no way to surface a
ValidationFailure's error code in this deployment's configuration. Rename the
Import/Export exception cascades to *ErrorResponses to say what they now do.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* refactor(bpmn): fold BpmnErrorResponseFactory and BpmnErrorResponseSender into BpmnErrorResponse
Both were thin, single-purpose wrappers around BpmnErrorResponse used only by
the import/export error mapping and one endpoint — a Middle Man chain. Create
and SendAsync now live as static members on BpmnErrorResponse itself; call
sites are unchanged otherwise, and the wire output is byte-identical.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat(bpmn): add Analyze/Import/Export endpoints to Elsa.Bpmn.Interchange
Thin FastEndpoints wrappers over Bpmn.Interchange, sharing one
BpmnInterchangeDocumentService so Analyze and Import can never disagree
about what a document costs. Import surfaces capability refusal
(BpmnCapabilityRequirements.Analyze, walked into nested processes) with
the missing capability and offending element ids, and reuses
BpmnWorkBinder to bind the root BpmnProcess scope. Export re-reads the
original XML persisted alongside the workflow definition and re-runs it
through BpmnXmlWriter, so retained extension elements, foreign
attributes and BPMN DI layout survive the round trip without being
reconstructed from the reduced Elsa activity graph.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(bpmn): make a stale BPMN export refuse instead of mislead
Export now refuses with 422 (naming the reason) when a workflow definition's
BPMN source is missing or no longer matches the definition's version, rather
than exporting stale or absent content while reporting success. Import
records the definition's version alongside the source XML so Export can
detect drift caused by a later save replacing custom properties wholesale.
Also: the interchange package now consumes the runtime host's declared
capability set from a new public Elsa.Bpmn.Hosting.BpmnRuntimeCapabilities
instead of restating it (one value, one home); the Import endpoint's
capability-refusal message no longer misattributes driving elements across
capabilities; the three BPMN REST endpoints get HTTP-level test coverage
(multipart validation, exception-to-status-code mapping, permission gating);
and the wiki documents the endpoints and Export's known limitation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(bpmn): alert when the library defines a capability Elsa does not declare
Restores the deleted comparison between BpmnRuntimeCapabilities.Declared (ours)
and BpmnHostCapabilities.Full (the library's) — these are two different
constants, not the tautology the earlier deletion assumed. The pinned
Bpmn.Semantics 0.1.1-preview.19 currently defines exactly the four flags Elsa
declares, so capability refusal at import/build is wired but unreachable; this
test is what will say the moment a library bump changes that, and its failure
message names the decision (implement and declare, or leave undeclared on
purpose) rather than just failing silently.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(bpmn): return 400 for a malformed export version and clarify a partial-import refusal
- Export/Endpoint.cs: a non-numeric or out-of-range VersionOptions query value now returns a
400 naming the offending value instead of throwing through FromString and bubbling into a 500.
- BpmnInterchangeDocumentService: the message shown when a definition carries BPMN source but not
its version marker (a second save that never completed after ImportAsync's first) now says so
explicitly, distinct from "never imported" and "stale".
- BpmnInterchangeDocumentService: replace the implicit filter in EnsureCapabilitiesSatisfied's
foreach with an explicit .Where(...), same behaviour.
- Test projects: extract the duplicated ReadAsset/Path.Combine helper in
BpmnInterchangeTestBase and BpmnInterchangeEndpointTests into a single BpmnAssetReader, guarded
against a rooted or nested file name.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(bpmn): write both BPMN import markers in one save
Move the BPMN source XML off the pre-import model and onto the same
explicit save that already records the definition's version, so a
failed or cancelled post-import save leaves neither custom property
behind instead of a partial, undiagnosable state. Update
BpmnAssetReader to use Path.Join instead of Path.Combine so its
rooted/nested-name guard is defence-in-depth rather than the only
thing standing between the code and a wrong path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat(bpmn): bind BPMN work declarations to Elsa activities
Turns the reader's BpmnWorkBinding declarations into the activity nodes a
BpmnProcess scope runs. Six of the seven kinds bind automatically: TimerWait to
Delay, MessageWait/SignalWait to Event, MessagePublish to PublishEvent,
CallProcess to DispatchWorkflow, NestedProcess to a nested BpmnProcess. The
seventh, UnboundTask, is an authoring decision and is read from a new elsa:
vendor extension inside the document, so an exported .bpmn is self-contained.
Every binding for a scope is bound whatever its slot, so a ScopeListener needs
no special case. Each binding gets its own freshly built activity with a
scope-qualified id: ActivityVisitor skips an activity it has already collected,
so one instance shared between two scopes would leave the second scope with no
child in Elsa's identity graph.
The binder lives in Elsa.Bpmn.Interchange because BpmnWorkBinding is a
Bpmn.Interchange type; binding it in Elsa.Bpmn would pull the interchange
library into the execution module's closure, which is the split D12 draws.
Every ambiguity resolves loudly: an unbound task, a dead binding declaration, a
malformed ISO-8601 duration, a call activity with nothing to call, and an
activity type nothing registered all refuse at bind time rather than producing a
process that runs to completion doing none of what the document says.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(bpmn): declare document variables on the bound scope, refuse duplicate input names
BpmnWorkBinder.BindScope never copied BpmnProcessDefinition.Variables onto the
produced BpmnProcess's Elsa Variables, so a document-declared collection variable
was Absent to IBpmnVariableReader and a collection-mode multi-instance over it
faulted the element instead of running once per item. BindScope now declares an
Elsa Variable for each document variable, seeding the declared default as the
JsonElement it already is.
BpmnActivityBindingFormat.Read silently let a second <elsa:input name="..."> with
a duplicate name overwrite the first rather than refusing it, unlike every other
malformed-document case this binder already refuses. It now throws
BpmnBindingException naming the binding and the duplicated input, and the XML doc
now states that rule plus the (verified) XML text-node escaping that already
applies to input JSON.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(bpmn): carry every declared activity input through the binding format
BpmnActivityBindingFormat.Write only found properties whose CLR type derives
from Input, silently dropping attribute-declared inputs like Switch.Cases from
an export. Read accepted any <elsa:input name="..."> without checking the
activity declares it, so a mistyped or stale name imported silently with the
configuration missing since Elsa's deserializer ignores unknown members. Both
now go through IActivityDescriber.GetInputProperties, the same enumeration
ActivityDescriptor.Inputs is built from, so Write and Read agree on what an
activity's inputs are and Read refuses a name that enumeration does not
report.
Also makes BpmnWorkBinder.RefuseUnusedDeclarations filter its loop explicitly
with .Where(...) instead of an implicit if, per static analysis.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* refactor(bpmn): filter undeclared input names explicitly
Express the undeclared-input-name check as an explicit Where filter
instead of an implicit filter inside the loop body, and report every
undeclared name at once rather than only the first. Also fix the
refusal message, which previously named the activity type twice.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs(bpmn): describe the input payload shape accurately
The XML doc on BpmnActivityBindingFormat claimed every <elsa:input> is the
{"typeName":...,"expression":...} wrapper a stored workflow definition uses.
That only holds for Input<T>-typed properties: an [Input]-attributed
plain-typed property such as Switch.Cases is serialized as its own JSON
shape (an array), not the wrapper, which Write already does correctly and
the round-trip test already covers. Correct the doc to describe the payload
as the configured activity serializer's output for that input, dependent on
how the activity declares it, and add a second short example showing the
attribute-declared shape.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* test(bpmn): guard against Elsa.Bpmn* reimplementing Bpmn.* library types
Adds a reflection-based architecture test asserting no type under Elsa.Bpmn
or Elsa.Bpmn.Interchange shares a type name with Bpmn.Model/Bpmn.Semantics
(and Bpmn.Interchange for the interchange assembly), plus a positive check
that both assemblies still depend on their respective library packages -
closing the gap that let elsa-foundation grow a parallel BpmnElement/BpmnGraph
semantics core undetected.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(bpmn): move the library-duplication guard to the interchange test project
Elsa.Bpmn.UnitTests referencing Elsa.Bpmn.Interchange (plus three
redundant PackageReferences already flowing transitively) inverted the
layering the guard exists to protect. Elsa.Bpmn.Interchange.UnitTests
already sees both assemblies transitively with no new references, so
the guard moves there unchanged apart from namespace and doc. Also
drops the silent null-forgive on Assembly.GetName().Name in favor of
an explained fallback.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(bpmn): drop the dependency-direction assertions the compiler already enforces
Mutation testing showed AssertDependsOnPackage and the two positive facts using
it can never go red: any state that would trip them fails the test project's
build first (CS0234), because the guard's own typeof bindings already require
the Bpmn.* packages. Delete the decorative facts and the now-unused helper,
and document that the typeof bindings are load-bearing on purpose.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(bpmn): prove the duplication detector actually detects
Split AssertNoTypeNameCollisions into a thin assertion wrapper around a
new FindTypeNameCollisions helper, and add a fact that points the
detector at the test assembly (which carries a deliberately colliding
BpmnGraph fixture) so CI sees the guard fail as well as pass.
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>