* 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>
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>
* 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>
* 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>
* 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>
* 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>
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(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>
* 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>
* 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): 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>
* 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>
* 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>
* 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>
* 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>
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>
* 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>
* 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>