* 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>
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>
* 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>
* 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>
* 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>
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>
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>
* 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>
* 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>
* 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>
* 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>
* 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>
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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
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>
* feat(bpmn): project interpreter diagnostics onto the scope's execution log
Under Option A only bound work carries an activity id, so gateways, events and
flows had no per-element trace in the journal. BpmnScopeHost now projects each
new BpmnExecutionState.Diagnostics entry onto the scope's own execution log
before Prune() runs, keyed by element id, with a persisted high-water mark so
a resumed scope never re-emits one. The scope's own start and completion stay
out, since they are already journaled as the activity's own lifecycle. Event
names and the payload shape are documented as a public compatibility surface
for elsa-studio#1000 to mirror.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(bpmn): project element and flow diagnostics dropped by the FlowId exclusion
The diagnostics exclusion keyed on Kind == TokenEmitted && FlowId is null/empty
also dropped an error or cancel boundary's own token emission, since a
boundary fires without an inbound flow. Narrow the rule to skip only
diagnostics that name neither an element nor a flow -- the scope's own
terminal Completed summary -- so every diagnostic keyed on an element or a
flow, including a start event's and a boundary's, is projected.
Also make DiagnosticSequence resilient: TryParse instead of Parse, logging a
warning and skipping projection for an id that doesn't match diag:N rather
than faulting the evaluation. Add a reflection-based test that keeps
BpmnDiagnosticEventNames in lockstep with BpmnDiagnosticKind, and record the
diagnostics volume measurement in the wiki.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(bpmn): require exact diag:N ids and seed the diagnostics cursor from prior state
Reject any diagnostic id that is not the exact "diag:" prefix followed by a
non-negative integer, so a malformed id can no longer poison the durable
projection cursor and cause later, genuinely valid, lower-sequence
diagnostics to be skipped forever.
Also seed a missing cursor from the highest valid sequence in the scope's
prior persisted state instead of treating it as zero, so a scope persisted
before diagnostics projection existed does not replay every retained
historical diagnostic as new on its next evaluation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The POST /identity/users response serialized the plain-text password
(including one the caller supplied), the password hash, and the salt.
The response now carries only id, name, roles, tenantId and a nullable
generatedPassword that is populated once, and only when Core generated
the password because none was supplied.
- CreateUserResult gains IsPasswordGenerated so the endpoint can tell a
generated password from a supplied one without re-deriving it.
- Response.FromResult centralises the mapping and omits credential
material.
- Expose Elsa.Identity internals to Elsa.Identity.UnitTests and add
contract tests covering the response shape, the no-echo rule, the
serialized JSON, and UserManager's generated-password flag.
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
* fix(efcore-oracle): migrate LOB columns in V3_6 without in-place datatype alteration
Both Oracle V3_6 migrations were generated as in-place `ALTER TABLE ... MODIFY`
statements that change a column's datatype to or from a LOB type: NCLOB to JSON
for `WorkflowDefinitions.StringData`, and NVARCHAR2(450) to NCLOB for
`ActivityNodeId` on `WorkflowExecutionLogRecords` and `ActivityExecutionRecords`.
Oracle refuses both (ORA-22858 / ORA-22859), so neither migration could ever
apply and the reported ORA-22858 was unavoidable.
Convert the columns the way the ORA-22858 message prescribes instead: add a
temporary column of the target type, copy the values across, drop the original
and rename the temporary one. Because Oracle commits DDL implicitly, a run that
fails partway leaves its earlier statements applied - the reporter's already
committed `OriginalSource` column is exactly that - so every step is guarded
against the state a previous attempt can have left behind. The conversion block
derives what still needs doing from `ALL_TAB_COLUMNS`, skips a conversion that
already completed (including one applied by hand), and raises rather than copy
out of and drop a column whose datatype it does not recognize.
Add an offline regression test that generates the Oracle Management and Runtime
V3_6 scripts through `IMigrator.GenerateScript` without a connection and asserts
that no in-place datatype `MODIFY` is emitted for either column, that the
add/copy/drop/rename sequence appears in order, and that the re-run guards are
present. All 19 cases fail against the previous migrations.
Refs #8011
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* refactor(efcore-oracle): tighten migration helper visibility and index guard, share the script-generation test harness
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* test(efcore-oracle): brace the foreach bodies in the V3_6 migration tests
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(efcore-oracle): refuse to truncate node IDs on downgrade and validate same-named indexes
The Runtime V3_6 downgrade converted ActivityNodeId from NCLOB back to
NVARCHAR2(450) by copying DBMS_LOB.SUBSTR(..., 450, 1), silently
truncating any value the upgraded schema had allowed to grow past 450
characters. EnsureLobLengthAtMost now checks for oversized values while
the column is still a LOB and raises before any data is copied.
CreateIndexIfMissing also treated any index with a matching name as
already done. It now validates the existing index's table, uniqueness
and ordered column list against ALL_INDEXES/ALL_IND_COLUMNS so a
same-named index left behind by schema drift or manual recovery is not
mistaken for the one the migration means to create.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* docs(efcore-oracle): correct the dynamic SQL rationale on the LOB length guard
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(efcore-oracle): preflight both tables before downgrading and escape schema names in migration SQL
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(efcore-oracle): copy every row NULL-preservingly so retries reproduce the current source
The filtered copy `WHERE "<column>" IS NOT NULL` skipped rows whose
nullable source had since become NULL. If a prior conversion committed
the copy and then failed before dropping the source column, an
operator clearing a value before retrying would find the predicate
skip that row, and the stale converted value would be renamed into
place. Replace the filtered UPDATE with an unconditional, NULL-
preserving CASE expression so a retry always reproduces the current
source exactly.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
* fix(efcore): terminate raw SQL statements in the PostgreSQL and SQLite Runtime V3_6 migrations
The V3_6 Runtime migration's two DROP INDEX statements were emitted without a
trailing semicolon on PostgreSQL and SQLite, so `dotnet ef migrations script`
produced syntactically invalid SQL (an unterminated statement inside the
idempotent DO $EF$ block, and unseparated statements in the plain script).
MigrateAsync() was unaffected because EF executes each Sql() call individually.
Add the missing terminators and a regression test that generates the Runtime
migration script offline for both providers (idempotent and plain forms) and
asserts the DROP INDEX statements are properly terminated.
Refs #7912
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* test(efcore): share migration-script setup and cover the schema-prefixed statements
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* test(efcore): cover schema-prefixed migration statements without static state
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
* fix(external-authentication): scope role-deletion impact to the role's tenant
ExternalAuthenticationRoleDeletionDependencyContributor scanned every stored
connection with an empty ConnectionFilter and every configured connection
regardless of its tenant, so a role ID that exists in two tenants could report
another tenant's references as its own impact -- and a configuration entry
owned by another tenant could block a role deletion outright. Remediation had
the same reach: it loaded a dependency's connection by the caller-supplied
owner ID without checking which tenant owned it.
Impact, prevalidation and remediation now only see connections in the role's
tenant context, which is the tenant active on ITenantAccessor while the
role-deletion coordinator runs. Host-scoped connections stay in scope for every
tenant, because the connection registry resolves the host scope for every
signing-in tenant and the provisioner resolves a connection's default role IDs
in the signing-in user's tenant, so a host connection naming a role ID really
does reference that tenant's role. Configuration entries that leave the tenant
blank are host-scoped for the same reason the configuration source materializes
them there. A connection carrying another tenant's ID is out of scope in both
directions, and a connection loaded for remediation that is not in the role's
tenant is treated as absent, which fails the request rather than mutating it.
The stored connections are fetched per applicable scope so another tenant's
rows are never materialized, and both connection stores already honor
ConnectionFilter.Scope; the durable store now has a test pinning that, since
the tenant boundary rests on it.
Refs #8013
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(external-authentication): scan every tenant when deleting a tenant-agnostic role
Role stores expose tenant-agnostic roles (TenantId == "*") from every tenant, but the
role-deletion contributor derived its dependency scan boundary from the ambient tenant only,
so deleting an agnostic role while tenant A was active left references from other tenants
dangling. Resolve the role being deleted once per operation, through the active role store,
and scan every connection and configuration entry regardless of tenant when it is agnostic.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* refactor(external-authentication): share the active role store lookup
Extract the duplicated "active role store is the last registration"
resolution into a single ActiveRoleStore accessor and rename ToScope to
ToConnectionScope for clarity.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(external-authentication): read one connection snapshot and prefer the agnostic role
Reading the host and tenant scopes as two separate store queries let a connection
whose TenantId changed mid-flight fall between the reads and escape both, letting
role deletion proceed while a reference remained. FindConnectionsInRoleTenantScopeAsync
now reads one snapshot and filters it in memory. IsAgnosticRoleAsync resolved a role
by an unqualified ID lookup, which could return the ambient tenant's role instead of
an agnostic role sharing its ID, silently narrowing impact scanning and leaving
JIT-policy references in other tenants dangling; it now checks every role sharing the
ID and gives the agnostic scope deterministic precedence.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* test(external-authentication): correct the scope-filter test comment
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(external-authentication): fail closed when a role ID resolves to more than one role
A same-ID collision between a tenant-scoped role and an agnostic role can only
occur in MemoryRoleStore (durable persistence keys roles by ID alone). In that
case the coordinator's own deletion target is already ambiguous, so widening
or narrowing the scope by guessing is wrong in either direction; throw instead
of picking a side.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(external-authentication): scope role-deletion impact by the resolved role's tenant
Replace the isAgnosticRole flag with ResolveRoleTenantIdAsync, which returns the
resolved role's own TenantId and falls back to the ambient tenant only when the
role cannot be resolved. With multitenancy disabled the EF role store installs
no tenant query filter and can resolve a tenant-owned role by ID regardless of
the ambient tenant, so scoping by the ambient tenant alone left that role's
connection references out of scan while the coordinator deleted it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(external-authentication): require an agnostic replacement when remediating an agnostic role
Authorization for a replacement role still resolves through the ambient tenant's
role services, so a deletion initiated in tenant A could authorize a tenant-A-only
replacement and then write it into tenant B's connection policy, where that role
does not exist. When the deletion target is agnostic, require the replacement
role to be agnostic too.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(external-authentication): require agnostic replacements for host connections and reject ambiguous ones
Extend the agnostic-replacement requirement to host-scoped connections, since a host
connection is served to every signing-in tenant and a tenant-scoped replacement would
resolve in the authorizing tenant but fail to resolve in every other tenant it serves.
Recheck the replacement at removal time through the same agnostic-role resolution used
at validation, instead of trusting whichever same-ID role a plain FindAsync happens to
return, so a replacement collision introduced between validation and mutation is
rejected. Resolve IsAgnosticRoleAsync's candidate directly and return true only when
exactly one matching role is agnostic, so an ambiguous replacement ID is reported as
replacement_role_unavailable_or_unauthorized instead of escaping as an exception.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(external-authentication): keep host-connection replacements allowed for default-tenant roles
Revert the host-scope replacement guard added for host-scoped connections.
IdentityProviderConnectionManagementService forces every managed connection
to host scope, and in a deployment without multitenancy roles are created
scoped to the default tenant rather than agnostic, so requiring an agnostic
replacement for host-scoped connections would make every replacement
remediation impossible in the default deployment. The replacement guard
applies only when the deletion target itself is agnostic, as before.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
* Initial plan
* Publish security notification after role deletion
Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com>
* Fix atomic role deletion notifications
Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com>
* refactor(identity): delegate MemoryRoleStore.DeleteAsync to TryDeleteAsync
Mirrors EFCoreRoleStore.DeleteAsync so the deletion logic exists once
instead of being duplicated between DeleteAsync and TryDeleteAsync.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(identity): scope the atomic role delete capability to a single role ID
Narrow IRoleStoreWithAtomicDelete.TryDeleteAsync to accept a single role
ID instead of a RoleFilter, closing a race where two concurrent deletes
matching multiple roles could each remove one and both report success.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com>
Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>