Commit graph

1113 commits

Author SHA1 Message Date
Sipke Schoorstra 68a5aa6bd7
fix(runtime): honor transactional outbox for in-workflow PublishEvent (#8177)
* fix(runtime): route PublishEvent through transactional workflow-dispatch outbox

In-workflow async PublishEvent and IEventPublisher now dispatch via
IWorkflowDispatcher so TransactionalWorkflowDispatcher applies when
UseTransactionalOutbox is enabled. Fixes #8150.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* test(runtime): fix PublishEvent outbox conformance tests

Register KeyValueFeature so the dispatch outbox store can persist
TriggerWorkflows items, and avoid expression-tree pattern matching in
the EventPublisher unit tests.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* test(runtime): assert PublishEvent outbox delivery creates consumer

ProcessAsync only queues the trigger; start the background command
processor and wait until ConsumeOrderShippedEventWorkflow is created.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-09-15 22:10:29 +02:00
Sipke Schoorstra 81d630ea1c
fix(workflows): persist named WithVariable values across suspend/resume (#8166)
Named WithVariable(name, value) never set a storage driver, so values
were memory-only and vanished after bookmark resume. Default it to
workflow instance storage, matching the parameterless overload.

Fixes #8159

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-09-15 00:58:15 +02:00
Sipke Schoorstra 2138f0997b
fix(scheduling): purge orphan Delay/Timer/Cron/StartAt bookmarks on startup (#8161)
* fix(scheduling): purge orphan Delay/Timer/Cron/StartAt bookmarks on startup

Reconcile stored scheduling bookmarks against the workflow-instance store
during CreateSchedulesStartupTask so missing and finished instances are
skipped and deleted instead of being re-scheduled on every rebuild.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix(scheduling): add missing WorkflowStatus using in bookmark reconciler

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix(scheduling): revalidate orphan bookmarks immediately before purge

Collect only bookmark IDs during paged rebuild, reload those rows, and
re-run classification so a candidate whose instance became Running is
not deleted. Still purge missing/blank/terminal bookmarks only.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix(scheduling): schedule revived bookmarks and bound reconcile batches

Revalidation now schedules bookmarks whose instance became Running,
chunks reload/classify/delete by StartupSchedulePageSize, and skips
reconcile when IWorkflowInstanceStore or IBookmarkManager is absent.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-09-14 21:03:54 +02:00
Sipke Schoorstra 7e22a12081
fix(expressions): keep C# variable accessor lookup keys as real names (#8160)
* fix(expressions): keep C# variable accessor lookup keys as real names

Pascalize only the generated C# property identifier. Get/Set still use
the original variable.Name so camelCase names like orderId resolve the
same way as JS, Liquid, and Python.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* test(expressions): disambiguate Options.Create in accessor tests

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-09-14 20:00:31 +02:00
Sipke Schoorstra 6503c0cb92
fix(workflows): resolve GetInput serializer options per host (#8158)
* fix(workflows): resolve GetInput serializer options per host

Remove the process-wide JsonSerializerOptions cache from ExpressionExecutionContextExtensions so DI converters registered on a later host remain visible after the first GetInput. Share CloneForValueConversion with WorkflowInstanceStorageDriver reads.

Closes #8131

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix(workflows): match storage write/read JSON reference semantics

Use the same cloned payload-serializer options for WorkflowInstanceStorageDriver Write and Read. Forcing ReferenceHandler.Preserve on read only treated ordinary $ref properties as metadata and broke restore.

Closes the P1 on #8158.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix(workflows): serialize storage values by runtime type

SerializeToNode(object, options) used the compile-time object converter, so PolymorphicObjectConverter wrapped arrays as _items metadata. Write now uses the runtime type with the same cloned host options as Read.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* test(workflows): assert stored array conversion is non-null

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix(workflows): restore _type for object-typed storage values

Serialize object-typed variables through the polymorphic converter so aliased CLR types keep a _type discriminator. Typed arrays still serialize by runtime type so they stay JSON arrays.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix(workflows): deserialize object variables through polymorphic options

ConvertTo(object) returns the stored JsonObject because it is assignable to object. Read object-typed variables with the payload serializer so _type restores the concrete CLR type.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-09-14 19:07:24 +02:00
Sipke Schoorstra 5193bea262
fix(workflows): default CreateBookmarkArgs.IncludeActivityInstanceId to true (#8157)
Unset CreateBookmarkArgs objects were hashing without ActivityInstanceId,
so CreateBookmark(stimulus) and new CreateBookmarkArgs { Stimulus = … }
silently diverged from CreateBookmark() and the documented overload default.

Explicit IncludeActivityInstanceId = false call sites are unchanged.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-09-14 16:10:12 +02:00
Sipke Schoorstra 3c4549752d
fix(runtime): preserve bookmark identity on unmatched-resume enqueue (#8154)
Copy ActivityInstanceId from metadata and ActivityTypeName from the
typed SendAsync path onto NewBookmarkQueueItem so BookmarkQueueProcessor
retries with the same filter identity as the live resume.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-09-14 15:29:07 +02:00
Sipke Schoorstra ed94f88b01
test(runtime): lock InternalState and activity-execution store conformance (#8153)
Unskip WorkflowAsActivityInternal by scoping lookups to the instance.
Add Memory/EF cases for journal ordered vs default FindMany paging and
activity-execution FindMany/FindManySummaries filter and projection parity.
Honor StartedAt order on EF FindManySummaries so Studio list summaries
cannot drift from Memory.

Fixes #8121

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-09-14 15:10:18 +02:00
Sipke Schoorstra 9ee53abdaa
fix(runtime): execution log default order Timestamp+Sequence; GetLastEntry Sequence-aware (#8152)
* fix(runtime): order execution log Find by Timestamp then Sequence

Align Memory and EF default Find/FindMany with Timestamp then Sequence
so same-ms batches are stable across stores. Make Journal/GetLastEntry
order by Sequence descending so the last event is not arbitrary.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix(runtime): tie default execution-log order with Id

Sequence is per execution context, so distinct instances can share
Timestamp+Sequence. Add Id as the unique default sort key so Memory and
EF offset pages stay deterministic.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-09-14 14:00:20 +02:00
Sipke Schoorstra 90c29fb589
test(diagnostics): add InMemory/Sqlite StructuredLog store conformance (#8151)
* test(diagnostics): add InMemory/Sqlite StructuredLog store conformance

Add a shared IStructuredLogStore matrix that locks Take clamp, SourceId
tie-break order, ListSources registry/heartbeat, DroppedEvents vs storage
diagnostics, and portable exact-case filters after #8147/#8148.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* test(diagnostics): lock Sequence and Id query tie-breaks

Cover the later IStructuredLogStore order stages independently so a
SourceId-only assertion cannot hide Sequence or Id regressions.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* test(diagnostics): isolate Sequence tie-break from Id order

Use IDs that sort opposite Sequence so a skip-Sequence, order-by-Id
implementation cannot pass the shared matrix.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-09-14 13:20:51 +02:00
Sipke Schoorstra 507522469b
fix(diagnostics): align Relational structured-log sort and ListSources with InMemory (#8148)
* fix(diagnostics): align Relational structured-log sort and ListSources with InMemory

Include SourceId in the Relational ORDER BY tie-break chain, MarkSeen on the durable write path, and prefer the in-process source registry for ListSources so heartbeat and identity metadata survive Sqlite persistence.

Fixes #8117

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix(diagnostics): keep MarkSeen monotonic and ListSources IDs distinct

Do not regress LastSeen when a delayed older flush replays MarkSeen. Use Ordinal keys when merging registry sources so case-distinct IDs stay distinct.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-09-14 09:05:01 +02:00
Sipke Schoorstra 0be3d5fb99
fix(secrets): close post-merge tenancy gaps (#8140)
Closes #8103.
2026-09-14 08:09:21 +02:00
Sipke Schoorstra 55e3e4091d
fix(diagnostics): honor MaxRecentLogQuerySize for relational Take (#8147)
* fix(diagnostics): honor MaxRecentLogQuerySize for relational Take

Relational recent-log queries used a hard-coded null→100 default and
1000 clamp, so enabling Sqlite silently dropped the page size from the
InMemory contract of StructuredLogsOptions.MaxRecentLogQuerySize.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* test(diagnostics): disambiguate Options.Create in SQL builder tests

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix(diagnostics): normalize negative MaxRecentLogQuerySize before Clamp

Math.Clamp throws when the configured ceiling is negative. Both
InMemory and Relational now share ClampRecentLogQueryTake, which treats
a negative max as zero so query construction cannot fail.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-09-14 08:07:17 +02:00
Sipke Schoorstra fec3d561d9
fix(user-tasks): include tags in EF and VNext safe search (#8146)
* fix(user-tasks): include tags in EF and VNext safe search

Safe search must cover tags per the persistence contract. InMemory already
matched tags; EF inspected only title/summary/reference/task type, and VNext
had the same gap. Search TagsJson with a bounded contains so tag-only hits
are visible without a new table.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix(user-tasks): make EF tag search case-insensitive and tag-scoped

SQLite Contains on TagsJson is case-sensitive and can match JSON array
syntax between tags. Lowercase the payload for tag contains, and skip
tag matching when the query itself contains JSON punctuation, matching
InMemory/VNext individual-tag semantics.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix(user-tasks): keep tag punctuation searchable on EF

The previous JSON-structure guard skipped all tag matching when the
query contained brackets or quotes, so a tag like review[urgent] was
invisible on EF while InMemory and VNext found it. Only skip the tag
path when the query spans the serialized tag delimiter.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix(user-tasks): match EF tags per element, not JSON text

Map Tags as a primitive collection on the existing TagsJson column so
safe search uses Any(tag.Contains) with ToLower. That is the same
per-tag, case-insensitive semantics as InMemory/VNext: punctuation
inside a tag stays searchable, and text that only spans JSON array
syntax cannot match.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-09-14 07:27:31 +02:00
Sipke Schoorstra 440ffaf719
test(secrets): add File/InMemory/EF tenancy conformance matrix (#8143)
* test(secrets): add File/InMemory/EF tenancy conformance matrix

Shared ISecretRepository scenarios for per-tenant uniqueness, Get/List
isolation, deleted replace, and default-tenant duplicate rejection.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix(secrets): enforce default-tenant uniqueness via empty TenantId

Stamp leftover null TenantId values to "" before rebuilding the
per-tenant unique index, matching Labels. Fail loud on leftover
duplicate names instead of silently deduping. New EF writes in the
default tenant persist "".

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix(secrets): make default-tenant uniqueness migrate on SQLite

Use the schema-less Secrets table name, let CreateIndex fail loudly
when leftover duplicates remain, and isolate the tenant-aware EF model
cache from the non-tenant fixture.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* test(secrets): avoid Path.Combine drop in uniqueness migration tests

Reject rooted provider project paths and use Path.Join when locating
Elsa.sln so CodeQL does not warn about discarded path segments.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix(secrets): enforce Oracle default-tenant uniqueness via NVL index

Oracle stores '' as NULL, so a TenantId IS NOT NULL filtered unique index
left default-tenant secret names uncovered. Use NVL(TenantId, CHR(1)) so
those rows share one index key. Stamp stays for preflight grouping; leftover
duplicates still fail loud with no silent dedupe.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-09-14 07:03:16 +02:00
Sipke Schoorstra 249bde780b
fix(user-tasks): align query sort and cursor contract across providers (#8144)
* fix(user-tasks): align query sort and cursor contract across providers

Implement the REST updated sort on EF and VNext, drop the InMemory-only
completed sort, and use one always-ascending Id tiebreaker plus the
production JSON base64url cursor codec in every repository.

Closes #8110

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix(user-tasks): keep title cursor ties comparison-consistent

Use the same string.Compare relation for title ordering and cursor
tie detection so culture-equal Unicode forms are not skipped. Assert
updated sort order directly so a created fallback cannot pass.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix(user-tasks): use one ordinal comparer for title sort and cursors

OrderBy, title-tie detection, and cursor filtering now share
StringComparer.Ordinal on the LINQ-to-objects providers so Unicode
variants cannot skip a page.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix(user-tasks): share default title comparison across providers

InMemory and VNext title sort/cursors now use the same OrderBy plus
string.Compare relation as EF (SQL collation / current culture) so a
title cursor stays portable. Ties still use Compare == 0, not ordinal ==.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* docs(user-tasks): title cursors are provider-scoped, not portable

Restore InMemory/VNext ordinal title OrderBy/tie/cursor (EF stays
column-collation). Document that title pages must be recreated after a
provider or collation change; other sorts plus Id stay portable.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-09-14 06:43:03 +02:00
Sipke Schoorstra d0c7653ccf
fix(workflows): preserve last-good variable on storage serialize failure (#8139)
Closes #8129.

WorkflowInstanceStorageDriver: serialize failure keeps last-good value
(no destructive Remove); read convert failure returns null / StrictMode
throws instead of poison untyped JsonNode.
2026-09-14 03:07:59 +00:00
Sipke Schoorstra 1ca11c23c8
fix(secrets): enforce tenant isolation in default repositories (#8137)
Closes #8103.

File/InMemory Secret repositories: ambient tenant stamp, TenantVisibility
filter, per-tenant uniqueness, validate replacement IDs before mutate,
ctor binary-compat, null-tenant uniqueness normalize.
2026-09-14 02:32:02 +00:00
Marko Lahma ddc95d734d
Stop attaching Array.prototype to dictionary-like objects in JavaScript expressions (#7890)
* fix(javascript): stop attaching Array.prototype to dictionary-like objects

The custom `WrapObjectDelegate` installed by `JintJavaScriptEvaluator` duplicated
what Jint already does, and got it wrong in two ways.

Jint's default wrap handler is `ObjectWrapper.Create(engine, target, type)`, and
`ObjectWrapper` attaches `Array.prototype` to array-like wrappers by itself when
`Options.Interop.AttachArrayPrototype` is enabled (the default). Jint's own
array-likeness test deliberately excludes dictionary-like types, including
string-keyed generic dictionaries.

The handler we installed instead:

* Called `ObjectWrapper.Create(engine, target)`, dropping the declared `type`
  argument, so members were resolved against the runtime type rather than the
  declared one.
* Used `ObjectArrayHelper.DetermineIfObjectIsArrayLikeClrCollection`, which only
  excludes the non-generic `IDictionary`. `ExpandoObject` does not implement
  that interface, so it came out array-like.

Both the `variables` container and the `args` container are `ExpandoObject`
instances, which meant `Object.getPrototypeOf(variables) === Array.prototype`
was true and `variables.map`, `variables.filter`, `variables.reduce` and friends
were all visible on them, with `variables.length` reporting `0` instead of
`undefined`.

Removing the handler restores Jint's default, which handles every case the
custom one was written for: `List<T>`, `T[]`, `HashSet<T>`, `ImmutableArray<T>`,
`Queue<T>` and `Stack<T>` all still get `Array.prototype`, while dictionaries and
`ExpandoObject` no longer do.

`ObjectArrayHelper` is public, so it is marked obsolete rather than deleted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0179sA2T7HuRfRfSc2JirFik

* test(javascript): cast the ExpandoObject to its dictionary interface

`new ExpandoObject() as IDictionary<string, object>` reads as a conversion that
might fail and gives the variable a nullable declared type, when `ExpandoObject`
implements the interface unconditionally. A direct cast states that, and matches
the BCL's `IDictionary<string, object?>` annotation exactly so the value type
argument lines up too.

The two other `as IDictionary<string, object>` uses in this test project
(JintJavaScriptFunctionBehaviorTests) are deliberately left alone: there the
operand is the untyped result of a script evaluation, so the `as` is a genuine
type test paired with `Assert.NotNull`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0179sA2T7HuRfRfSc2JirFik

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 03:52:37 +02:00
Sipke Schoorstra 1ad089c160
fix(user-tasks): honor MembershipResolutionMode in EF Available scope (#8132)
EF Available ORed live candidates with snapshot members and ignored mode,
so a worklist could show tasks the access policy would refuse to Claim.
Match policy/InMemory: Snapshot uses expanded members only; Live uses
candidates; exclusions stay first. Drop VNext SnapshotGroups from
eligibility and add conformance for Snapshot vs Live visibility.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-09-14 03:02:50 +02:00
Sipke Schoorstra e4478af2be
Fix Alterations ownership parity and MySQL retry (#8133)
Closes #8125
2026-09-14 02:51:34 +02:00
Sipke Schoorstra 54209e24c2
fix(alterations): atomic EF tenant ownership on Save/SaveMany (#8128)
Closes #8125.

Alterations-local CAS via ExecuteUpdate+INSERT with ambient ownership gate,
same-tenant race retry, multi-TFM setters, persistence compatibility ctors.
2026-09-13 23:38:40 +00:00
Sipke Schoorstra 815d7b7f70
Fix MemoryRoleStore global role ID uniqueness (#8127)
* fix(identity): enforce global role ID uniqueness

* fix(identity): preserve role tenant on updates

* test(identity): cover role tenant rehoming
2026-09-13 23:28:38 +02:00
Sipke Schoorstra d4c00f13ed
fix(runtime): read InternalState from logPersistenceConfig (#8126)
* fix(runtime): read InternalState from logPersistenceConfig

Honor Studio-written customProperties.logPersistenceConfig.internalState
and fall back to workflow internalState, then default/ResolveMode.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* test(runtime): register activity context in InternalState evaluator tests

ActivityTestFixture.BuildAsync does not add the context to
WorkflowExecutionContext.ActivityExecutionContexts, which
GetPersistenceDefaultsAsync requires.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix(runtime): evaluate workflow InternalState with root context

Use the workflow/root ExpressionExecutionContext for workflow-level
internalState (parity with default), and keep the activity context
for the activity override. Look up component records by activity name.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-09-13 23:09:36 +02:00
Sipke Schoorstra ab93c67623
test(alterations): share InMemory/EF store conformance scenarios (#8124)
* test(alterations): share InMemory/EF store conformance scenarios

Add a shared Memory + EF Core (SQLite) conformance matrix for
IAlterationPlanStore and IAlterationJobStore so tenant visibility,
tenant stamp, Find/Count, job PlanId/status queries, serialized
round-trip, and cross-tenant Save isolation fail in one place.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* test(alterations): share InMemory/EF store conformance scenarios

Lock Memory and EF/SQLite Alterations plan/job store contracts in one
suite: tenant visibility, Save stamp, Find-by-Id, PlanId/status queries,
serialized round-trip, and cross-tenant Save isolation.

EF Save treated visible * rows as upsert targets, so a named tenant
could steal them. Add a minimal replace guard so EF matches Memory.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* test(alterations): narrow expected save conflict handling

* test(alterations): keep conformance scope contract-focused

* chore(alterations): restore unchanged store files

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-09-13 22:18:09 +02:00
Sipke Schoorstra d14ba421e0
fix(runtime): close drain cancel-dispose race (#8123)
* fix(runtime): close cancel-dispose race

* fix(runtime): recover disposed active checkpoints

* fix(runtime): guard disposed checkpoint recovery

* test(runtime): share suspended instance fixture

* fix(runtime): serialize cycle CTS lifecycle

* fix(runtime): serialize cycle CTS cleanup

* test(runtime): update lifecycle comment

* fix(runtime): avoid CTS callback disposal deadlock

* fix(runtime): preserve cancellation recovery races

* fix(runtime): preserve cancel state after callback errors

* test(runtime): clean up blocked drain callbacks

* fix(runtime): guard linked token cancellation cleanup

* fix(runtime): preserve fatal cancellation callback failures

* fix(common): classify fatal exceptions inside aggregates

* fix(runtime): await deferred recovery after drain cancellation

* fix(runtime): clarify deferred recovery timeout

* test(runtime): await cancellation cleanup before disposal assertion
2026-09-13 21:28:33 +02:00
Sipke Schoorstra fe9217bdfa
test(labels): share InMemory/EF store conformance scenarios (#8122)
* test(labels): share InMemory/EF store conformance scenarios

Add one abstract Labels store suite and run it against InMemory and
EF Core (SQLite) so tenant isolation, NormalizedName uniqueness,
cascade delete, ReplaceAsync, and association lookups stay aligned.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* test(labels): drop provider-specific uniqueness and paging asserts

SaveMany name swaps trip EF BulkUpsert unique indexes, and Memory
ToPage counts after Skip/Take. Keep the shared matrix on contracts
both InMemory and EF/SQLite honor.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* test(labels): map conformance matrix to the #8091 lock list

Keep one scenario per issue invariant so the InMemory/EF harness stays
small and readable: cascade delete, ReplaceAsync, association
finds/deletes, ListAsync order/paging, tenant stamp/isolation, and
per-tenant NormalizedName uniqueness.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* test(labels): cover cross-tenant lookup conformance

* test(labels): use Path.Join for sqlite fixture

* test(labels): lock ID-only ReplaceAsync and SaveMany tenant stamp

Vary every field except Id on the removed association so ReplaceAsync
cannot match the stored row by payload. Also stamp null-tenant batches
through SaveManyAsync on both label and association stores.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* test(labels): stamp null TenantId under a named ambient tenant

Cover SaveAsync and SaveManyAsync for labels and associations when
the ambient tenant is tenant-a, so a default-only stamp cannot pass.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* test(labels): reject tenant-A association leakage under tenant-B

Tenant-B FindByVersion and FindByLabelIds now require assoc-b and
assoc-star, and fail if assoc-a is visible.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-09-13 18:58:36 +02:00
Sipke Schoorstra f3e77fe3e7
test(workflows): share Memory/EF store conformance scenarios (#8115)
* test(workflows): share Memory/EF store conformance scenarios

Add a shared management/runtime store matrix that runs the same uniqueness
and tenant-isolation assertions against Memory and EF Core (SQLite). Memory
now rejects a second version row for the same (DefinitionId, Version, TenantId)
so that DefinitionId+Version uniqueness can fail closed on both paths.

Closes #8088

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* test(workflows): lock bookmark and log Find semantics

Add the remaining #8088 store-contract row: bookmark, activity-execution,
and execution-log Find/FindMany filters, paging, and same-Id upsert, run
against both Memory and EF/SQLite.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* test(workflows): correct execution-log ExcludeActivityType assertion

Exclude WriteLine only when another activity type is present, so the
shared Find row asserts a real filter match on both providers.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix(management): reject duplicate definition version keys in SaveMany

MemoryWorkflowDefinitionStore.SaveManyAsync now throws when a batch
contains distinct Ids that share (DefinitionId, Version, TenantId),
matching EF unique-index failure instead of silently dropping rows.

Give the label-filter list fixture distinct versions so two red
definition rows no longer collide on the default Version=1 key.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-09-13 18:07:09 +02:00
Sipke Schoorstra f477fc8b07
fix(runtime): bound concurrent pre-cancel snapshot Finds during drain (#8113)
* fix(runtime): bound concurrent pre-cancel snapshot Finds during drain

Unbounded Task.WhenAll of per-cycle Finds self-contends under large
live-cycle N: more 250ms timeouts, more drainInduced excludes, more
Interrupted misses. Cap snapshot Finds at 16. The 250ms budget still
starts only after a slot is acquired so queued Finds are not fail-open
excluded by waiting.

Phase C stays sequential. No store-contract change.

Closes #8083

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix(runtime): exclude null pre-cancel snapshots from drainInduced

A successful Find that returns no row is unknown pre-state, not a
confirmed non-Cancelled snapshot. Joining drainInduced let Phase C
rewrite a later Finished/Cancelled as Interrupted. Timeout/error
already excluded; null now does too.

Closes nothing extra; keeps #8083 fail-open exclude.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix(runtime): promote null snapshot after Phase A force-cancel

A successful pre-cancel Find that returns no row is not a persisted user-cancel, but excluding it from drainInduced skipped Interrupted persist after deadline-breach force-cancel of a live cycle (DeadlineBreachPersistsInterrupted). Join drainInduced only after we ourselves cancel that handle. Timeout/error and confirmed Cancelled snapshots stay excluded.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix(runtime): promote null snapshot only when TryCancel transitions

Cancel() is a no-op on an already-disposed handle, so treating every Cancel() call as drain-induced could rewrite a Finished/Cancelled row the runner committed while snapshot was in flight. TryCancel reports a real transition; only those ids join drainInduced after a null Find.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-09-13 17:18:35 +02:00
Sipke Schoorstra 4b15b0166d
fix(labels): enforce per-tenant uniqueness on NormalizedName (#8112)
* fix(labels): enforce per-tenant uniqueness on NormalizedName

Finish the unused Label.NormalizedName contract the same way Secrets
does: unique (TenantId, NormalizedName) in EF, fail-closed Memory saves,
and keep NormalizedName in sync with Name.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix(labels): clone Memory label reads so a rejected rename cannot persist

Find/List handed out live store refs. Labels.Update mutates Name (and
NormalizedName) on that instance before Save; a uniqueness rejection
then left the stored row already renamed. Clone-on-read matches Memory
identity stores from #8108.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix(labels): stamp null TenantId, fail-loud on leftover duplicates, cap names at 255

Follow the Architect steer for Greptile P1s: do not auto-delete duplicate
labels; UPDATE Labels SET TenantId = '' WHERE TenantId IS NULL on every
provider before CreateIndex; keep SQL Server/Oracle filtered unique
indexes; HasMaxLength(255) on Name and NormalizedName with no silent
truncate.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix(labels): fail-loud preflight for leftover keys and over-length names

No silent dedupe: every provider lists leftover (TenantId, NormalizedName)
keys and aborts before CreateIndex. SQL Server/Oracle keep their filtered
unique indexes. PostgreSQL/SQLite keep provider column types. Providers
that narrow Name/NormalizedName to 255 preflight over-length Ids first.
Memory uniqueness semantics are unchanged.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* test(labels): match Oracle filtered-index escape in migration assertion

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-09-13 16:16:01 +02:00
Sipke Schoorstra b23a10c2a4
test(ext-auth): share store conformance scenarios (#8096)
* Add external authentication store conformance tests

* Cover concurrent external auth token takes

* Harden external authentication conformance races

* Fix concurrent registry version advances

* chore: retrigger hosted checks

* chore: retry code scanning

* ci: retry CodeQL checks

* test(ext-auth): make registry race deterministic

* test(ext-auth): coordinate conformance races

* test(ext-auth): close concurrency review gaps

* test(ext-auth): coordinate after reader disposal

* test(ext-auth): preserve race coordination failures
2026-09-13 15:58:40 +02:00
Sipke Schoorstra 1126f536f8
fix(identity): enforce per-tenant uniqueness in Memory identity stores (#8108)
* fix(identity): enforce per-tenant uniqueness in Memory identity stores

Mirror EF PerTenantIdentityUniqueness on Memory user, role, and application
stores so a second Id cannot claim the same Name or ClientId within a tenant.
Same-Id upserts may still rename themselves.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix(identity): keep Memory identity finds from mutating stored rows

Clone user, role, and application rows on read so a rejected Save of a
Find result cannot leave a colliding name or client id in the store.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-09-13 15:26:08 +02:00
Sipke Schoorstra 21e12b3fbd
fix(identity): honor tenant isolation in Memory user and application stores (#8107)
* fix(identity): honor tenant isolation in Memory user and application stores

Align MemoryUserStore and MemoryApplicationStore with EF SetTenantIdFilter
via TenantVisibility: null TenantId is default-tenant-only, and applications
filter by ambient tenant. Stamp missing TenantId on write like ApplyTenantId.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix(identity): delete memory users and applications under tenant lock

DeleteAsync now removes under MemoryStore.Sync with TenantVisibility in the
same predicate, so a same-Id replacement from another tenant cannot be wiped.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-09-13 14:27:51 +02:00
Sipke Schoorstra 373f99d163
fix(alterations): honor tenant isolation in Memory alteration stores (#8106)
* fix(alterations): honor tenant isolation in Memory alteration stores

Stamp ambient tenant on Memory plan/job saves and filter find/count/list
with TenantVisibility so default Alterations persistence matches EF
SetTenantIdFilter / ApplyTenantId. Locks Save under MemoryStore.Sync.

Fixes #8093

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix(alterations): refuse Memory Save when Id belongs to another tenant

Fail closed on Save/SaveMany if the ID already exists and is not visible
to the ambient tenant, matching TriggerStore collision style. Visible
same-tenant upsert is unchanged.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix(alterations): refuse named-tenant overwrite of agnostic Memory rows

* is visible to every tenant, so EnsureIdAvailable now allows replacing
a * plan/job only when the ambient tenant is also *. Named-tenant Save
and SaveMany fail closed and leave the shared row in place.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-09-13 14:04:46 +02:00
Sipke Schoorstra 326e886a41
fix(labels): honor tenant isolation in InMemory label stores (#8102)
* fix(labels): honor tenant isolation in InMemory label stores

Stamp ambient TenantId on save and filter find/list/delete/replace
through TenantVisibility so Memory Labels match EF SetTenantIdFilter
and ApplyTenantId. Labels contracts have no TenantAgnostic flag.

Closes #8089

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* test(labels): account for * visibility when asserting tenant-b leftovers

Tenant B correctly sees tenant-agnostic associations; assert those rows
remain alongside the other tenant's data after delete/replace.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix(labels): delete visible Memory rows in one locked step

Find-then-Delete(id) could remove another tenant's same-ID replacement
that landed between the visibility check and the remove. DeleteWhere
under MemoryStore.Sync keeps the check and removal together.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-09-13 04:16:09 -07:00
Sipke Schoorstra f4762308a2
fix(persistence): honor tenant isolation in Memory workflow stores (#8100)
* fix(persistence): honor tenant isolation in Memory workflow stores

Apply the EF SetTenantIdFilter admission rule on Memory definition,
trigger, and bookmark query paths so ambient tenant and TenantAgnostic
match IgnoreQueryFilters instead of leaking cross-tenant rows.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix(persistence): stop Memory definition delete from wiping other tenants

DeleteAsync collected logical DefinitionIds from tenant-visible rows,
then removed every in-memory row with those IDs. Shared DefinitionIds
across tenants therefore deleted tenant B when tenant A deleted.

Keep the all-versions-of-DefinitionId Memory delete, but apply the same
TenantVisibility rule (or TenantAgnostic bypass) to the final removal.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-09-13 02:56:01 -07:00
Sipke Schoorstra d2d3109024
fix(runtime): enforce MemoryTriggerStore logical uniqueness (#8098)
* fix(runtime): enforce MemoryTriggerStore logical uniqueness

MemoryTriggerStore upserted only by Id, so two records with different Ids
but the same (WorkflowDefinitionId, Hash, ActivityId, TenantId) were
accepted in memory and rejected under EF. Mirror EFCoreTriggerStore:
distinct-by-logical-key, skip already-present keys on ReplaceAsync,
reject Save* collisions, and stamp the current tenant when unset.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix(runtime): keep FindAsync first-match and use a structural trigger key

FindAsync must return the first matching trigger. SingleOrDefault threw
when a valid filter (for example WorkflowDefinitionId) matched several
distinct logical keys. Restore FirstOrDefault to match ITriggerStore and
EF. Represent the logical key as a record so fields that contain U+001F
cannot collide.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-09-13 02:20:28 -07:00
Sipke Schoorstra 0ac7184226
fix(bpmn): make document PUT If-Match and save a compare-and-swap (#8092)
* 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>
2026-09-13 01:40:43 -07:00
Sipke Schoorstra 1f88a204df
Enforce unique external refresh token hashes (#8095) 2026-09-13 01:10:32 -07:00
Sipke Schoorstra 7f1f1e50e9
Skip HTTP workflow route matching outside base path (#7757)
* Skip HTTP workflow route matching outside base path

* Handle tenant-prefixed HTTP workflow base paths

Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com>

* Narrow tenant-prefixed HTTP base-path matching

Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com>

* Fix sibling-prefix HTTP base-path matching

* Limit HTTP base-path precheck depth

* Use resolved tenant paths for HTTP routing

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com>
2026-09-12 18:12:37 -07:00
Sipke Schoorstra 9cce8d91af
fix(bpmn): keep a top-level call activity's fire-and-forget flag through the document PUT (#8082)
* fix(bpmn): keep a top-level call activity's fire-and-forget flag through the document PUT

A call activity's call options (vw:waitForCompletion) live only on its CallProcess
work binding, which the bpmnDefinitions document cannot carry, so the document PUT
rebuilt every call activity fresh and silently turned a fire-and-forget call into a
waiting one. Reuse the stored options for a top-level call activity whose element id
and calledElement are unchanged, mirroring how a kept subprocess already carries its
own bindings across; a changed calledElement still binds fresh rather than inheriting
options authored for a different process.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(bpmn): read the stored BPMN source once per document PUT

ImportDocumentAsync's two carry-across helpers each independently
looked up SourceXmlCustomPropertyKey and parsed it, so every document
PUT read and parsed the stored BPMN source twice. Read and parse it
once in ImportDocumentAsync and pass the result to both helpers.

Also extends the no-op PUT ETag-stability theory to the
top-level-call-activity.bpmn fixture, which was not previously covered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(bpmn): filter call activities with Where

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 11:41:50 -07:00
Sipke Schoorstra 933d1739bd
fix(bpmn): refuse documents with duplicate element ids before recursion (#8080)
* 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>
2026-09-12 11:26:07 -07:00
Sipke Schoorstra 091e3bc0e4
fix(bpmn): let a process with only a plain start event publish (#8081)
* fix(bpmn): let a process with only a plain start event publish (#8078)

An imported root BpmnProcess is an ITrigger with CanStartWorkflow set
(IsRootScope). For a process whose start events carry no event definition
it rightly returns no payloads, but TriggerIndexer then stored a
null-payload placeholder row and ValidateWorkflowRequestHandler refused
publication with "Trigger should have a payload". That blocked import,
bind, publish and run for most Camunda models.

Adds an additive, opt-in seam: TriggerIndexingContext.RegistersNoTriggers.
A trigger that sets it and returns no payloads gets no row. It has no
effect when the trigger returns payloads, and a trigger that throws still
gets the placeholder, so a failure is never read as a deliberate decline.
Every other ITrigger indexes exactly as before.

BpmnProcess sets it only for a root scope none of whose start events
carries an event definition. A declared start that resolves to nothing,
such as a process whose only start is a refused timer, keeps the
placeholder and still fails publication. Nested scopes are opted out
before that check and are unchanged, and IsRootScope/CanStartWorkflow
semantics are untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(bpmn): publish for real in the stale-after-publish tests

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(bpmn): name the publish helper for what it does

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 11:07:59 -07:00
Sipke Schoorstra fcb46a6b6f
fix(bpmn): let the graph hash alone decide BPMN source staleness (#8079)
* fix(bpmn): let the graph hash alone decide BPMN source staleness

A metadata-only save (a rename, a variable edit) bumps a published definition
to a new draft version without touching the graph, but the stale check
compared the version unconditionally, refusing export/document GET even
though the graph the stored source describes had not moved. Once the
graph-hash marker is present it now decides staleness on its own; the version
check remains only as a fallback for definitions imported before that marker
existed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(bpmn): share the simulated-publish helper

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 10:10:39 -07:00
Sipke Schoorstra 9f4d33269d
fix(bpmn): keep subprocess bodies through the document PUT (#8077)
* fix(bpmn): keep subprocess bodies through the document PUT

BpmnDefinitions lists only top-level processes: Bpmn.Interchange carries the
body of an embedded subprocess, transaction or event subprocess as the
subprocess element's NestedProcess work binding. The document GET therefore
never returned the bodies, and the PUT wrote the posted document without any
bindings, so BpmnXmlWriter wrote every subprocess empty and the re-import
replaced the definition with one whose subprocesses were empty.

The PUT now re-reads the stored source and hands the writer the stored body of
every subprocess element the posted document still declares, matched by
element id, together with everything bound inside it (a call activity's
vw:waitForCompletion lives only on its CallProcess binding). A removed
subprocess's body is never written back, not even under a new element that
reuses its bindingRef, and a subprocess with a stored body but no bindingRef
is refused rather than emptied. The GET body, the ETag and the error codes are
unchanged; nested content stays uneditable through the document.

Bpmn.Interchange 0.2.0 also retains a copy of a subprocess's interpreted
multiInstanceLoopCharacteristics inside its body. Handed back verbatim, that
copy would override a posted marker on the next read and add another copy on
every PUT, so the PUT drops it wherever the element carries a marker in the
model.

The D4 round-trip test now covers subprocess-boundary-events.bpmn and
transaction-compensation.bpmn, copied from Studio's fixtures, plus two
nested-scope assets, going through the endpoints' JSON options.

Refs #8072

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(bpmn): link the loop-marker workaround to its upstream issue

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 09:50:43 -07:00
Sipke Schoorstra 3bee566657
feat(bpmn): give BPMN interchange refusals stable error codes (#8067)
* 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>
2026-09-12 00:10:12 -07:00
Sipke Schoorstra e54ced5662
feat(bpmn): declare BpmnProcess's Done and Cancelled outcomes as flow ports (#8066)
Elsa.Bpmn.Activities.BpmnProcess completed with the interpreter's Done or
Cancelled outcome but declared no outcomes, so a Flowchart composing it only
saw Studio's synthesized default port and the Cancelled outcome was
unreachable. Declares both via [FlowNode(BpmnInterpreter.DoneOutcomeName,
BpmnInterpreter.CancelledOutcomeName)] (both const in Bpmn.Semantics 0.2.0),
adds a descriptor test asserting the two flow ports, and a composition test
routing a cancelled transaction down the Cancelled port.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 23:16:21 -07:00
Sipke Schoorstra d0dd7c9ef5
fix(bpmn): detect designer-edited drafts as stale BPMN source (#8065)
An unpublished draft is saved in place, so a designer save that edits a
bound activity's inputs changes StringData without changing Version,
which the export/document staleness check missed. Import now also
records a content hash of the graph (Bpmn:SourceGraphHash) and
ResolveSourceXml refuses as stale when it no longer matches, falling
back to the version-only check for definitions imported before this
marker existed.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 22:46:58 -07:00
Sipke Schoorstra 22c1aea472
fix(bpmn): the document PUT preserves the definition's non-BPMN metadata (#8063)
* fix(bpmn): preserve definition metadata when the document PUT rebinds it

The document PUT edits a definition's BPMN document, not the whole definition, so it must not
reset the author's name, description, variables, options and custom properties the way a
whole-definition import intentionally does. BpmnInterchangeDocumentService.ImportAsync now accepts
the existing definition to preserve that metadata from, which ImportDocumentAsync passes; POST
bpmn/import stays a whole-definition import.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(bpmn): preserve IsReadonly and restore ImportAsync's public signature

Review findings on the prior metadata-preservation fix: IsReadonly was still
overwritten by the document PUT because it was missing from the preserved
field set, and the public ImportAsync(string, string?, string?, string?,
CancellationToken) signature had been changed in place by appending an
optional preserveMetadataFrom parameter. Restore that signature exactly and
move the shared import logic into a private ImportCoreAsync(..., preserveMetadataFrom,
CancellationToken), called by both the public ImportAsync and
ImportDocumentAsync, with IsReadonly now carried alongside the other
non-BPMN metadata. Extend the endpoint tests to also set and assert inputs,
outputs, outcomes, tool version and IsReadonly on both the preserve and
replace paths.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(bpmn): refuse document PUT when the target definition has vanished

ImportDocumentAsync fell back to the whole-definition import path when
its definition lookup returned null, silently creating a definition
under the requested id with reset metadata instead of reporting that
the PUT's target disappeared (e.g. deleted between the endpoint's
existence/ETag check and this lookup). It now throws
BpmnDefinitionNotFoundException, which the shared exception cascade
maps to 404 for the document PUT endpoint; POST bpmn/import is
unaffected since it never passes a preservation source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 22:15:47 -07:00
Sipke Schoorstra e28e35635b
feat(bpmn): GET and PUT the BPMN document as library-format JSON (#8060)
* feat(bpmn): add document GET/PUT endpoints for BPMN JSON round-tripping (W21)

Studio holds a BPMN process as the library's own JSON payload, not as .bpmn
XML, so binding a task (W11) or moving a shape (W14) had no write path back
to the server: bpmn/import only takes XML, and saving the activity JSON
through the ordinary definition save leaves Bpmn:SourceXml stale, so export
then refuses with 422.

Adds GET/PUT bpmn/definitions/{definitionId}/document, sharing the same
analyze-then-commit path Import runs (BpmnInterchangeDocumentService.ReadDocument/
ImportDocumentAsync), so a document read by GET and written back unchanged by
PUT can never disagree with what Import or Export would do with the same
bytes. Records which process a document was imported from
(Bpmn:SourceProcessId) so a multi-process document keeps importing the same
process on every edit. Binds and writes the document body with plain
System.Text.Json defaults, not FastEndpoints' configured serializer, since
Bpmn.Model's JSON payload format (integer enums, explicit property names)
disagrees with Elsa's own API-wide JSON conventions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(bpmn): deduplicate document endpoint response and exception handling

Import and the document Put endpoint shared a byte-identical Response type and
an identical exception-to-status-code cascade; Export and the document Get
endpoint shared an identical cascade too. Extract both into a shared
BpmnImportResponse and two small cascade helpers under Endpoints/Bpmn, used by
all four endpoints with unchanged status codes and error messages. Also add
endpoint coverage for PUT against a definition imported before
Bpmn:SourceProcessId existed, for both the single- and two-process cases.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(bpmn): add optimistic concurrency to the document endpoints and restore Import.Response

GET bpmn/definitions/{id}/document now returns a strong ETag derived from the
definition's Version, its Bpmn:SourceVersion custom property, and a new
Bpmn:DocumentRevision counter (needed because an unpublished draft is edited
in place, so Version/SourceVersion alone do not always change on save). PUT
requires a matching If-Match: missing returns 428, stale returns 412 (checked
before any import work), and a successful PUT returns a new, different ETag.

Also restores the public Elsa.Bpmn.Interchange.Endpoints.Bpmn.Import.Response
type the prior dedupe commit renamed to BpmnImportResponse without cause,
which would have broken source and binary consumers of the preview package;
Import and the document Put endpoint now both use Import.Response again.

Also replaces a foreach-with-continue over a JSON array with .Where(...) in
the document Put test helper, per CodeQL, with no behaviour change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(bpmn): derive the document ETag from the stored document and graph

The document ETag combined the definition's Version, Bpmn:SourceVersion and a
Bpmn:DocumentRevision counter only the document PUT incremented. An unpublished
draft is edited in place under the same version, and both the workflow
definition importer and the designer's save replace CustomProperties
wholesale, so a POST bpmn/import into the same definition or a designer save
of the draft left all three unchanged. A client holding the pre-write ETag
could then PUT and silently overwrite that write.

The ETag is now a SHA-256 hash over the stored definition's id, version, BPMN
source (Bpmn:SourceXml) and activity graph (StringData), computed in one place
for GET and for PUT's precondition check. Every writer changes at least one
input: a document PUT or an import rewrites the source, a designer save
rewrites the graph, a new draft version changes the id and version. Identical
stored content now yields an identical ETag, so a PUT of unchanged content
returns the ETag GET did.

Bpmn:DocumentRevision and the PUT's pre-import read of it are gone. The ETag a
PUT returns is computed from the definition its import persisted. If-Match
must equal the current strong ETag exactly (weak tags and lists never match,
412), and the wildcard "*" is refused along with a missing header (428),
since it matches whatever is stored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 21:16:32 -07:00