Extract product-agnostic workflow engine from Ablera.Serdica.Workflow into standalone StellaOps.Workflow.* libraries targeting net10.0. Libraries (14): - Contracts, Abstractions (compiler, decompiler, expression runtime) - Engine (execution, signaling, scheduling, projections, hosted services) - ElkSharp (generic graph layout algorithm) - Renderer.ElkSharp, Renderer.ElkJs, Renderer.Msagl, Renderer.Svg - Signaling.Redis, Signaling.OracleAq - DataStore.MongoDB, DataStore.PostgreSQL, DataStore.Oracle WebService: ASP.NET Core Minimal API with 22 endpoints Tests (8 projects, 109 tests pass): - Engine.Tests (105 pass), WebService.Tests (4 E2E pass) - Renderer.Tests, DataStore.MongoDB/Oracle/PostgreSQL.Tests - Signaling.Redis.Tests, IntegrationTests.Shared Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
33 KiB
Serdica Workflow Engine
A declarative, plugin-based workflow engine for long-running insurance business processes. Replaces Camunda BPMN with a native C# fluent DSL, canonical JSON schema, durable signal-based execution, and multi-backend persistence.
Table of Contents
- Architecture Overview
- Workflow Declaration DSL
- Human Tasks
- Transport Calls (Service Tasks)
- Control Flow
- Sub-Workflows & Continuations
- Canonical Definition Schema
- Expression System
- Signal System
- Timeout Architecture
- Retention & Lifecycle
- Authorization
- Plugin System
- Configuration Reference
- Service Surface
- Diagram & Visualization
- Error Handling
Architecture Overview
Execution Flow
Workflow request
-> Workflow runtime service
-> Runtime orchestrator
-> Canonical execution handler
-> Transport adapters
-> Projection store
-> Runtime state store
-> Signal and schedule buses
Key Components
| Component | Responsibility |
|---|---|
| WorkflowRuntimeService | Engine-facing lifecycle and task operations |
| CanonicalWorkflowExecutionHandler | Evaluates canonical step sequences, manages fork/join state |
| WorkflowSignalPumpHostedService | Background consumer for durable signal processing |
| WorkflowRetentionHostedService | Background cleanup of stale/completed instances |
| IWorkflowProjectionStore | Task and instance persistence (Mongo, Oracle, Postgres) |
| IWorkflowRuntimeStateStore | Durable execution state snapshots |
| IWorkflowSignalBus | Signal publishing and delivery |
| Transport Plugins | HTTP, GraphQL, message-bus transports, microservice command transport |
Runtime Providers
| Provider | Name | Use Case |
|---|---|---|
| Serdica.InProcess | In-memory execution | Testing, simple workflows without durability |
| Serdica.Engine | Canonical engine with durable state | Production workflows with signal-based resumption |
Workflow Declaration DSL
Workflows are defined using a strongly-typed C# fluent builder. The DSL compiles to a canonical JSON definition at startup.
Minimal Workflow
public sealed class ApproveApplicationWorkflow
: IDeclarativeWorkflow<ApproveRequest>
{
public string WorkflowName => "ApproveApplication";
public string WorkflowVersion => "1.0.0";
public string DisplayName => "Approve Application";
public IReadOnlyCollection<string> WorkflowRoles => ["DBA", "UR_UNDERWRITER"];
public WorkflowSpec<ApproveRequest> Spec { get; } = WorkflowSpec
.For<ApproveRequest>()
.InitializeState(request => new Dictionary<string, JsonElement>
{
["policyId"] = JsonSerializer.SerializeToElement(request.PolicyId),
})
.StartWith(approveTask)
.Build();
public IReadOnlyCollection<WorkflowTaskDescriptor> Tasks => Spec.TaskDescriptors;
private static readonly WorkflowHumanTaskDefinition<ApproveRequest> approveTask =
WorkflowHumanTask.For<ApproveRequest>(
taskName: "Approve Application",
taskType: "ApproveQTApproveApplication",
route: "business/policies")
.WithPayload(context => new Dictionary<string, JsonElement>
{
["policyId"] = context.StateValues.GetRequired<long>("policyId").AsJsonElement(),
})
.OnComplete(flow => flow.Complete());
}
State Initialization
State can be initialized from the start request using delegates or expressions:
// Delegate-based (typed)
.InitializeState(request => new { policyId = request.PolicyId, status = "NEW" })
// Expression-based (canonical, portable)
.InitializeState(
WorkflowExpr.Object(
WorkflowExpr.Prop("policyId", WorkflowExpr.Path("start.policyId")),
WorkflowExpr.Prop("status", WorkflowExpr.String("NEW"))))
Business Reference
Business references provide a queryable key for workflow instances:
flow.SetBusinessReference(new WorkflowBusinessReferenceDeclaration
{
KeyExpression = WorkflowExpr.Path("state.policyId"),
PartsExpressions =
{
["policyId"] = WorkflowExpr.Path("state.policyId"),
["annexId"] = WorkflowExpr.Path("state.annexId"),
},
})
Human Tasks
Human tasks pause workflow execution and wait for a user action (assign, complete, release).
Defining a Task
var reviewTask = WorkflowHumanTask.For<MyRequest>(
taskName: "Review Changes",
taskType: "ReviewPolicyChanges",
route: "business/policies",
taskRoles: ["UR_UNDERWRITER", "UR_OPERATIONS"])
.WithPayload(context => new Dictionary<string, JsonElement>
{
["policyId"] = context.StateValues.GetRequired<long>("policyId").AsJsonElement(),
})
.WithTimeout(86400) // 24-hour deadline (optional; default: no deadline)
.OnComplete(flow => flow
.WhenExpression(
"Approved?",
WorkflowExpr.Eq(WorkflowExpr.Path("payload.answer"), WorkflowExpr.String("approve")),
approved => approved
.Call("Confirm", confirmAddress, confirmPayload,
WorkflowHandledBranchAction.Complete,
WorkflowHandledBranchAction.Complete)
.Complete(),
rejected => rejected.Complete()));
Task Properties
| Property | Type | Description |
|---|---|---|
TaskName |
string | Unique name within the workflow |
TaskType |
string | UI component type identifier |
Route |
string | Navigation route for the UI |
TaskRoles |
string[] | Roles that can interact with this task |
TimeoutSeconds |
int? | Optional deadline. Null = no deadline (runs until completed or purged) |
DeadlineUtc |
DateTime? | Computed: CreatedOnUtc + TimeoutSeconds. Null if no timeout set |
Task Lifecycle
Created (Pending)
-> Assigned (user claims task)
-> Completed (user submits payload)
-> OnComplete sequence executes
-> Next task activated, or workflow completes
Task Actions & Authorization
| Action | Who Can Perform |
|---|---|
AssignSelf |
Any user with matching effective roles |
AssignOther |
Admin roles only |
AssignRoles |
Admin roles only |
Release |
Current assignee or admin |
Complete |
Current assignee or admin |
Transport Calls (Service Tasks)
Service tasks call external services via pluggable transports. Each call has optional failure and timeout recovery branches.
Call with Address
flow.Call<object>(
"Calculate Premium",
Address.LegacyRabbit("pas_premium_calculate_for_object"),
context => new { policyId = context.StateValues.GetRequired<long>("policyId") },
whenFailure: fail => fail.Complete(), // recovery on failure
whenTimeout: timeout => timeout.Complete(), // recovery on timeout
resultKey: "premiumResult", // store response in state
timeoutSeconds: 120); // per-step timeout override
Address Types
| Address Factory | Transport | Example |
|---|---|---|
Address.Microservice(name, command) |
Microservice command transport | Address.Microservice("PasOperations", "perform") |
Address.LegacyRabbit(command) |
Legacy message-bus transport | Address.LegacyRabbit("pas_premium_calculate") |
Address.Rabbit(exchange, routingKey) |
Exchange/routing-key bus transport | Address.Rabbit("serdica", "policy.create") |
Address.Http(target, path, method?) |
HTTP REST | Address.Http("authority", "/api/users", "GET") |
Address.Graphql(target, query) |
GraphQL | Address.Graphql("serdica", "query { ... }") |
Failure & Timeout Handling
Every Call step supports optional whenFailure and whenTimeout branches:
.Call("Service Task", address, payload,
whenFailure: fail => fail
.SetState("errorOccurred", WorkflowExpr.Bool(true))
.Complete(), // graceful completion on failure
whenTimeout: timeout => timeout
.Call("Retry Alternative", altAddress, altPayload,
WorkflowHandledBranchAction.Complete,
WorkflowHandledBranchAction.Complete)
.Complete())
If neither handler is defined and the transport call fails/times out, the exception propagates and the signal is retried (up to MaxDeliveryAttempts).
Shorthand Actions
.Call("Step", address, payload,
WorkflowHandledBranchAction.Complete, // on failure: complete workflow
WorkflowHandledBranchAction.Complete) // on timeout: complete workflow
Control Flow
Decisions (Conditional Branching)
flow.WhenExpression(
"Is VIP Customer?",
WorkflowExpr.Eq(WorkflowExpr.Path("state.customerType"), WorkflowExpr.String("VIP")),
whenTrue: vip => vip
.Call("VIP Processing", ...)
.Complete(),
whenElse: standard => standard
.Call("Standard Processing", ...)
.Complete());
State Flag Decisions
flow.WhenStateFlag(
"policyExistsOnIPAL",
expectedValue: true,
"Policy exists on IPAL?",
whenTrue: exists => exists.Call("Open For Change", ...),
whenElse: notExists => notExists.Call("Create Policy", ...));
Repeat (Loops)
flow.Repeat(
"Retry Integration",
maxIterations: context => 5,
body: body => body
.Call("Integrate", integrationAddress, payload,
WorkflowHandledBranchAction.Complete,
WorkflowHandledBranchAction.Complete)
.SetState("retryCount", WorkflowExpr.Func("add",
WorkflowExpr.Path("state.retryCount"), WorkflowExpr.Number(1))),
continueWhile: WorkflowExpr.Ne(
WorkflowExpr.Path("state.integrationStatus"),
WorkflowExpr.String("SUCCESS")));
Fork (Parallel Branches)
flow.Fork("Process All Objects",
branch1 => branch1.Call("Process Object A", ...),
branch2 => branch2.Call("Process Object B", ...),
branch3 => branch3.Call("Process Object C", ...));
All branches execute concurrently. The workflow resumes after all branches complete.
Timer (Delay)
flow.Timer("Wait Before Retry",
delay: context => TimeSpan.FromMinutes(5));
External Signal (Wait for Event)
flow.WaitForSignal(
"Wait for Document Upload",
signalName: "documents-uploaded",
resultKey: "uploadedDocuments");
Signals are raised via RaiseExternalSignalAsync and matched by signalName + WaitingToken.
Sub-Workflows & Continuations
SubWorkflow (Inline Execution)
Executes a child workflow inline within the parent. The parent waits for the child to complete.
flow.SubWorkflow(
"Run Review Process",
new WorkflowWorkflowInvocationDeclaration
{
WorkflowName = "ReviewPolicyChanges",
PayloadExpression = WorkflowExpr.Object(
WorkflowExpr.Prop("policyId", WorkflowExpr.Path("state.policyId"))),
});
ContinueWith (Signal-Based)
Starts a new workflow instance asynchronously via the signal bus. The parent completes immediately.
flow.ContinueWith(
"Start Transfer Process",
new WorkflowWorkflowInvocationDeclaration
{
WorkflowName = "TransferPolicy",
PayloadExpression = WorkflowExpr.Path("state"),
});
When to use which:
- SubWorkflow: Child must complete before parent continues. State flows back to parent.
- ContinueWith: Fire-and-forget. Parent completes, child runs independently.
Canonical Definition Schema
Every workflow compiles to a canonical JSON definition (serdica.workflow.definition/v1). This enables:
- Portable workflow definitions (JSON import/export)
- Runtime validation without C# compilation
- Visual designer support
Step Types
| Type | JSON $type |
Description |
|---|---|---|
| Set State | "set-state" |
Assign a value to workflow state |
| Business Reference | "assign-business-reference" |
Set the business reference |
| Transport Call | "call-transport" |
Call an external service |
| Decision | "decision" |
Conditional branch |
| Activate Task | "activate-task" |
Pause for human task |
| Continue With | "continue-with-workflow" |
Start child workflow (async) |
| Sub-Workflow | "sub-workflow" |
Execute child workflow (inline) |
| Repeat | "repeat" |
Loop with condition |
| Timer | "timer" |
Delay execution |
| External Signal | "external-signal" |
Wait for external event |
| Fork | "fork" |
Parallel branches |
| Complete | "complete" |
Terminal step |
Transport Address Types
| Type | JSON $type |
Properties |
|---|---|---|
| Microservice | "microservice" |
microserviceName, command |
| Rabbit | "rabbit" |
exchange, routingKey |
| Legacy Rabbit | "legacy-rabbit" |
command, mode |
| GraphQL | "graphql" |
target, query, operationName? |
| HTTP | "http" |
target, path, method |
Example Canonical Definition
{
"$schemaVersion": "serdica.workflow.definition/v1",
"workflowName": "ApproveApplication",
"workflowVersion": "1.0.0",
"displayName": "Approve Application",
"workflowRoles": ["DBA", "UR_UNDERWRITER"],
"start": {
"initializeStateExpression": {
"$type": "object",
"properties": [
{ "name": "policyId", "expression": { "$type": "path", "path": "start.policyId" } }
]
},
"sequence": {
"steps": [
{
"$type": "call-transport",
"stepName": "Validate Policy",
"timeoutSeconds": 60,
"invocation": {
"address": {
"$type": "legacy-rabbit",
"command": "pas_policy_validate"
},
"payloadExpression": {
"$type": "object",
"properties": [
{ "name": "policyId", "expression": { "$type": "path", "path": "state.policyId" } }
]
}
}
},
{
"$type": "activate-task",
"taskName": "Approve Application",
"timeoutSeconds": 86400
}
]
}
},
"tasks": [
{
"taskName": "Approve Application",
"taskType": "ApproveQTApproveApplication",
"routeExpression": { "$type": "string", "value": "business/policies" },
"taskRoles": [],
"payloadExpression": { "$type": "path", "path": "state" }
}
]
}
Expression System
The expression system evaluates declarative expressions at runtime without recompilation. All expressions are JSON-serializable for canonical portability.
Expression Types
| Type | Builder | Example |
|---|---|---|
| Null | WorkflowExpr.Null() |
JSON null |
| String | WorkflowExpr.String("value") |
"value" |
| Number | WorkflowExpr.Number(42) |
42 |
| Boolean | WorkflowExpr.Bool(true) |
true |
| Path | WorkflowExpr.Path("state.policyId") |
Navigate object graph |
| Object | WorkflowExpr.Object(props...) |
Construct object from named props |
| Array | WorkflowExpr.Array(items...) |
Construct array |
| Function | WorkflowExpr.Func("name", args...) |
Call a registered function |
| Binary | WorkflowExpr.Eq(left, right) |
Comparison/arithmetic |
| Unary | WorkflowExpr.Not(expr) |
Logical negation |
Path Navigation
Paths navigate the execution context:
start.*— Start request fieldsstate.*— Current workflow statepayload.*— Current task completion payloadresult.*— Step result (whenresultKeyis set)
Binary Operators
| Operator | Builder | Description |
|---|---|---|
eq |
WorkflowExpr.Eq(a, b) |
Equal |
ne |
WorkflowExpr.Ne(a, b) |
Not equal |
gt |
WorkflowExpr.Gt(a, b) |
Greater than |
gte |
WorkflowExpr.Gte(a, b) |
Greater or equal |
lt |
WorkflowExpr.Lt(a, b) |
Less than |
lte |
WorkflowExpr.Lte(a, b) |
Less or equal |
and |
WorkflowExpr.And(a, b) |
Logical AND |
or |
WorkflowExpr.Or(a, b) |
Logical OR |
add |
— | Arithmetic addition |
subtract |
— | Arithmetic subtraction |
multiply |
— | Arithmetic multiplication |
divide |
— | Arithmetic division |
Built-in Functions
| Function | Signature | Description |
|---|---|---|
coalesce |
coalesce(value1, value2, ...) |
Returns first non-null argument |
concat |
concat(str1, str2, ...) |
String concatenation |
add |
add(num1, num2, ...) |
Sum of numeric arguments |
first |
first(array) |
First element of array |
if |
if(condition, whenTrue, whenFalse) |
Conditional value |
isNullOrWhiteSpace |
isNullOrWhiteSpace(value) |
Check for null/empty string |
length |
length(value) |
Length of string or array |
mergeObjects |
mergeObjects(obj1, obj2, ...) |
Deep merge objects |
upper |
upper(value) |
Uppercase string |
selectManyPath |
selectManyPath(array, path) |
Map over array elements |
findPath |
findPath(data, path) |
Navigate nested paths |
Custom functions can be registered via IWorkflowFunctionProvider plugins.
Signal System
Signals enable durable, asynchronous communication within workflows. They are persisted to a message queue (Oracle AQ, MongoDB, etc.) and processed by the signal pump.
Signal Types
| Type | Trigger | Purpose |
|---|---|---|
InternalContinue |
ContinueWith step |
Start a child workflow asynchronously |
TimerDue |
Timer step delay expired | Resume workflow after delay |
RetryDue |
Retry delay expired | Resume after backoff |
ExternalSignal |
External signal submission through the workflow service surface | External event notification |
SubWorkflowCompleted |
Child sub-workflow finished | Resume parent workflow |
Signal Envelope
new WorkflowSignalEnvelope
{
SignalId = "unique-id",
WorkflowInstanceId = "target-instance",
RuntimeProvider = "Serdica.Engine",
SignalType = "ExternalSignal",
ExpectedVersion = 5, // Concurrency control
WaitingToken = "wait-token", // Match specific wait
DueAtUtc = null, // null = immediate, DateTime = scheduled
Payload = { ... },
}
Signal Processing Pipeline
Oracle AQ / MongoDB / Postgres
-> WorkflowSignalPumpHostedService (N concurrent workers)
-> WorkflowSignalPumpWorker.RunOnceAsync
-> IWorkflowSignalBus.ReceiveAsync (blocking dequeue)
-> WorkflowSignalProcessor.ProcessAsync (route by type)
-> WorkflowSignalCommandDispatcher
-> WorkflowRuntimeService.StartWorkflowAsync (InternalContinue)
-> WorkflowRuntimeService.ResumeSignalAsync (all others)
Concurrency Control
Version-based optimistic concurrency prevents duplicate signal processing:
- Each signal carries
ExpectedVersion IWorkflowRuntimeStateStore.UpsertAsyncvalidates version matches- On mismatch:
WorkflowRuntimeStateConcurrencyExceptionis thrown - Signal pump treats concurrency conflicts as successful (completes the lease)
Dead Letter Queue
Signals that fail MaxDeliveryAttempts times are moved to the dead-letter queue. Dead letters can be inspected and replayed through the workflow service surface.
Timeout Architecture
Timeouts operate at three independent levels:
Level 1: Per-Step Timeout (Service Tasks)
Each transport call step has an optional timeout that wraps the entire call (including retries) with a CancellationTokenSource.
| Setting | Default | Override |
|---|---|---|
step.TimeoutSeconds |
null | Per-step in workflow declaration |
DefaultTimeoutForServiceTaskCallsSeconds |
3600s (1h) | Code constant (fallback) |
// Per-step override in workflow DSL:
.Call("Slow Service", address, payload, fail, timeout, timeoutSeconds: 300)
// In canonical JSON:
{ "$type": "call-transport", "timeoutSeconds": 300, ... }
Precedence: step.TimeoutSeconds -> DefaultTimeoutForServiceTaskCallsSeconds (1h)
Level 2: Per-Attempt Transport Timeout
Each individual transport attempt (single HTTP request, single RPC call) has its own timeout. This is independent of the step-level timeout.
| Transport | Default | Config Section |
|---|---|---|
| HTTP | 30s | WorkflowHttpTransport.TimeoutSeconds |
| GraphQL | 30s | WorkflowGraphqlTransport.TimeoutSeconds |
| Legacy message-bus transport | 30s | WorkflowLegacyRabbitTransport.DefaultTimeout |
| Exchange/routing-key bus transport | 30s | WorkflowRabbitTransport.DefaultTimeout |
The step timeout wraps all attempts. Example: step timeout 120s + transport timeout 30s = up to 4 retries within the step window.
Level 3: Engine-Wide Execution Timeout
Optional global timeout per workflow operation (start, complete, resume).
| Setting | Default | Config |
|---|---|---|
ExecutionTimeoutSeconds |
null (disabled) | WorkflowEngine.ExecutionTimeoutSeconds |
Set to null for long-running business processes that span days or months.
Human Task Deadlines
| Setting | Default | Override |
|---|---|---|
TimeoutSeconds on activate-task |
null (no deadline) | .WithTimeout(seconds) on task builder |
DeadlineUtc on task summary |
null | Computed: CreatedOnUtc + TimeoutSeconds |
When null, human tasks run indefinitely. Stale/orphaned tasks are cleaned up by the retention service.
Retention & Lifecycle
The retention system automatically manages workflow instance lifecycle.
Configuration
| Setting | Default | Config Section |
|---|---|---|
OpenStaleAfterDays |
30 | WorkflowRetention |
CompletedPurgeAfterDays |
180 | WorkflowRetention |
Retention Job
| Setting | Default | Config Section |
|---|---|---|
Enabled |
true | WorkflowRetentionHostedJob |
RunOnStartup |
false | WorkflowRetentionHostedJob |
InitialDelay |
5 min | WorkflowRetentionHostedJob |
Interval |
24 hours | WorkflowRetentionHostedJob |
LockLease |
2 hours | WorkflowRetentionHostedJob |
Lifecycle Flow
Instance Created (Open)
-> StaleAfterUtc = CreatedOnUtc + OpenStaleAfterDays
[Retention job marks as stale]
Instance Completed
-> PurgeAfterUtc = CompletedOnUtc + CompletedPurgeAfterDays
[Retention job deletes instance, tasks, events, runtime state]
Manual trigger: POST /workflow-retention/run
Authorization
Task authorization uses a pluggable evaluator pattern.
Interface
public interface IWorkflowAssignmentPermissionEvaluator
{
WorkflowAssignmentPermissionDecision Evaluate(WorkflowAssignmentPermissionContext context);
}
Default Plugin: Generic Assignment Permissions
Configured via GenericAssignmentPermissions.AdminRoles (appsettings).
| Action | Admin | Standard User |
|---|---|---|
| AssignSelf | Yes | Yes (if has effective role) |
| AssignOther | Yes | No |
| AssignRoles | Yes | No |
| Release | Yes | Yes (if current assignee) |
| Complete | Yes | Yes (if current assignee) |
Effective Roles
A task's EffectiveRoles combines:
WorkflowRoles— from the workflow definitionTaskRoles— from the task definitionRuntimeRoles— computed at runtime via expression
If TaskRoles are specified, they narrow the effective roles. Otherwise, WorkflowRoles apply.
Plugin System
Plugins extend the workflow engine with backend stores, transports, signal drivers, and workflow definitions.
Plugin Types
| Category | Example Plugins |
|---|---|
| Backend Store | Oracle, MongoDB, Postgres |
| Signal Driver | Redis, Oracle AQ (native) |
| Transport | HTTP, GraphQL, legacy message-bus, exchange/routing-key bus, microservice command |
| Permissions | Generic RBAC |
| Workflow Definitions | Bulstrad (customer-specific) |
Creating a Plugin
public sealed class ServiceRegistrator : IPluginServiceRegistrator
{
public void RegisterServices(IServiceCollection services, IConfiguration configuration)
{
services.AddWorkflowModule("my-module", "1.0.0");
services.AddScoped<IMyService, MyServiceImpl>();
}
}
Loading Order
Plugins load in the order specified by PluginsConfig.PluginsOrder in appsettings. Backend stores must load before transport or workflow plugins.
Marker Interfaces
IWorkflowBackendRegistrationMarker— validates backend plugin is loadedIWorkflowSignalDriverRegistrationMarker— validates signal driver is loaded
Startup validation throws InvalidOperationException if a configured provider is missing its plugin.
Configuration Reference
WorkflowEngine
{
"WorkflowEngine": {
"NodeId": "workflow-node-1",
"MaxConcurrentExecutions": 16,
"MaxConcurrentSignalHandlers": 16,
"ExecutionTimeoutSeconds": 300,
"GracefulShutdownTimeoutSeconds": 30
}
}
WorkflowRuntime
{
"WorkflowRuntime": {
"DefaultProvider": "Serdica.Engine",
"EnabledProviders": ["Serdica.InProcess", "Serdica.Engine"]
}
}
WorkflowAq (Signal Queue)
{
"WorkflowAq": {
"QueueOwner": "SRD_WFKLW",
"SignalQueueName": "WF_SIGNAL_Q",
"ScheduleQueueName": "WF_SCHEDULE_Q",
"DeadLetterQueueName": "WF_DLQ_Q",
"ConsumerName": "WORKFLOW_SERVICE",
"BlockingDequeueSeconds": 30,
"MaxDeliveryAttempts": 10
}
}
WorkflowRetention
{
"WorkflowRetention": {
"OpenStaleAfterDays": 30,
"CompletedPurgeAfterDays": 180
}
}
WorkflowRetentionHostedJob
{
"WorkflowRetentionHostedJob": {
"Enabled": true,
"RunOnStartup": false,
"InitialDelay": "00:05:00",
"Interval": "1.00:00:00",
"LockName": "workflow.retention",
"LockLease": "02:00:00"
}
}
Transport Configuration
{
"WorkflowHttpTransport": {
"TimeoutSeconds": 30,
"RetryCount": 3,
"Targets": {
"authority": { "Url": "http://localhost:52000", "Headers": {} }
}
},
"WorkflowGraphqlTransport": {
"TimeoutSeconds": 30,
"RetryCount": 3,
"Targets": {
"serdica": { "Url": "http://localhost:5100/graphql/" }
}
},
"WorkflowLegacyRabbitTransport": {
"DefaultTimeout": "00:00:30"
},
"WorkflowRabbitTransport": {
"DefaultTimeout": "00:00:30",
"DefaultUserId": "workflow-engine"
}
}
Plugin Loading
{
"PluginsConfig": {
"PluginsDirectory": "PluginBinaries",
"PluginsOrder": [
"assign-permissions",
"workflow-store",
"signal-driver",
"transports",
"workflow-definitions"
]
}
}
Use deployment-specific plugin identifiers in that order: durability first, wake mechanism second, transports after that, and workflow-definition bundles last.
Service Surface
The engine depends on a workflow service surface, but the platform transport and command-mapping layer are intentionally out of scope for this document.
Lifecycle Operations
- Start a workflow instance.
- List workflow instances with filtering (by name, version, status, business reference, instance ID, or multiple instance IDs). Set
IncludeDetails = trueto return each instance's active task and workflow state variables. - Read one workflow instance with tasks, events, and runtime state.
Task Operations
- List tasks by workflow, status, assignee, or business reference.
- Read one task.
- Assign a task to a user or role group.
- Release a task back to the pool.
- Complete a task with payload.
Signal And Operations Management
- Raise an external signal to a waiting instance.
- Inspect dead-lettered signals.
- Replay dead-lettered signals.
- Inspect signal-pump telemetry.
Definitions And Metadata
- List workflow definitions (filterable by name, version, or multiple names).
- Get a single definition by name with optional rendering assets (SVG/PNG/JSON).
- Render a workflow definition as a diagram.
- Render a definition in a specific format (
svg,png, orjsonrender graph). - Expose the canonical schema.
- Validate canonical definitions.
- Expose the installed function catalog and engine metadata.
Definition Deployment
- Import a canonical definition with versioned storage and content-hash deduplication.
- Export a definition with optional rendering package.
- List all versions of a definition with hash, active flag, and metadata.
- Activate a specific version as the active version for a workflow name.
Administration
- Trigger a manual retention sweep.
Diagram & Visualization
The engine can render workflow definitions as visual diagrams.
Layout Engines
| Engine | Description |
|---|---|
| ElkSharp | Port of Eclipse Layout Kernel (default) |
| ElkJS | JavaScript-based ELK via Node.js |
| MSAGL | Microsoft Automatic Graph Layout |
Configuration
{
"WorkflowRendering": {
"LayoutProvider": "ElkSharp"
}
}
Render Pipeline
WorkflowCanonicalDefinition
-> WorkflowRenderGraphCompiler (nodes + edges)
-> WorkflowRenderLayoutEngineResolver (select engine)
-> Layout engine (compute positions)
-> WorkflowRenderDiagramResponse (JSON for UI)
Error Handling
Exception Types
| Exception | Cause | Recovery |
|---|---|---|
WorkflowRuntimeStateConcurrencyException |
Duplicate/stale signal delivery | Auto-handled by signal pump (completes lease) |
BaseResultException |
Business validation failure (not found, denied) | Returns error to caller |
TimeoutException |
Transport or step timeout exceeded | Executes WhenTimeout branch if configured |
NotSupportedException |
Unsupported operation (e.g., null signal store) | Configuration error — check plugin loading |
Signal Retry Behavior
| Scenario | Behavior |
|---|---|
| Transient error | Signal abandoned, retried on next poll |
| Concurrency conflict | Signal completed (not retried) |
| Max delivery attempts exceeded | Signal moved to dead-letter queue |
| Deserialization failure | Signal dead-lettered with error logged |
Observability
- Structured logging via Serilog (all key operations logged with structured properties)
- Signal pump telemetry via
WorkflowSignalPumpTelemetryService(in-memory counters, queryable through the workflow service surface) - W3C trace IDs enabled (
Activity.DefaultIdFormat = W3C)
Compiler & Decompiler
Forward Compiler
WorkflowCanonicalDefinitionCompiler.Compile<TStartRequest>() converts a C# fluent DSL workflow (IDeclarativeWorkflow<T>) into a canonical JSON definition (WorkflowCanonicalDefinition). This runs at startup for all registered workflows.
The compiler also generates a JSON Schema for the start request type, embedded in the canonical definition's startRequest.schema field. This provides a portable, CLR-independent contract for the workflow's input.
Reverse Compiler (Decompiler)
WorkflowCanonicalDecompiler converts a canonical definition back to C# source code using Roslyn SyntaxFactory. Two modes:
Decompile(definition)— produces formatted C# source text including a typed start request class generated from the JSON Schema and the full workflow class with fluent builder chainReconstruct(definition)— produces a newWorkflowCanonicalDefinitionvia deep clone (for structural comparison)
The decompiler uses nameof() for all type and method references (WorkflowExpr.Obj, LegacyRabbitAddress, etc.) to ensure rename safety at compile time.
Round-Trip Verification
The test suite verifies compiler fidelity via real Roslyn dynamic compilation:
Original C# workflow
-> [compile] -> canonical JSON (JSON1)
-> [decompile] -> C# source text
-> [Roslyn CSharpCompilation] -> in-memory assembly
-> [reflection: instantiate workflow]
-> [compile] -> canonical JSON (JSON2)
-> assert JSON1 == JSON2
This catches any information loss in the compile/decompile cycle: missing steps, truncated expressions, wrong addresses, lost failure/timeout branches.
Test results:
- 177/177 decompiled C# files compile cleanly with Roslyn
- Semantic round-trip comparison identifies remaining gaps for iterative improvement
Decompiled Output
Running the RenderAllDecompiledOutputs test generates human-readable output for all workflows:
docs/decompiled-samples/
csharp/ 177 .cs files (Roslyn-formatted C# with typed request models)
json/ 177 .json files (indented canonical definitions with JSON Schema)