A definition is JSON DSL, not BPMN XML. DefinitionCompiler uses System.Text.Json with case-insensitive properties and camelCase enums. Deployment stores the original JSON, tenant, version, and SHA-256 checksum. Instances reference immutable snapshots.
The pipeline separates creating an immutable definition version from changing the tenant or office binding used by new instances. A successful simulation is evidence for one path, not a substitute for persistence, concurrency, and authorization tests.
1. Seven built-in nodes
| type | Semantics | Important fields |
|---|---|---|
| startEvent | single entry | name, nodePrefix |
| endEvent | terminal, multiple allowed | name |
| userTask | creates human work and suspends Execution | participants, multiInstance, rollback, config |
| serviceTask | invokes a host handler | implementation |
| exclusiveGateway | first matching branch | edge condition and sort |
| parallelGateway | all branches and scoped join | unconditional edges |
| inclusiveGateway | every matching branch | conditions and join scope |
2. Minimal deployable approval
{ "key": "matter-intake-approval", "name": "Matter intake approval", "variables": { "disputeAmount": 0, "crossBorder": false }, "nodes": [ { "id": "start", "type": "startEvent", "name": "Submit", "nodePrefix": "A" }, { "id": "partner", "type": "userTask", "name": "Lead partner approval", "nodePrefix": "B", "participants": { "roles": ["litigation-partner"], "positions": [], "orgUnits": [], "userIds": [], "virtualRoles": [] }, "config": { "requireComment": true, "allowAddParticipant": true, "allowTransfer": true, "allowDelegate": true, "quickReplies": ["Open the matter", "Conflict check pending"] } }, { "id": "end", "type": "endEvent", "name": "Completed" } ], "edges": [ { "source": "start", "target": "partner", "sort": 0 }, { "source": "partner", "target": "end", "sort": 0 } ]}DefinitionCompiler only guarantees basic deserialization. Graph validation is a separate operation. DeployAsync parses but does not call DefinitionValidator, so production release must validate first.
3. Expressions
DefaultExpressionEvaluator supports variables, literals, comparisons, AND, and OR. It does not execute methods, arbitrary scripts, SQL, or object navigation.
{ "nodes": [{ "id": "route", "type": "exclusiveGateway", "name": "Matter grading" }], "edges": [ { "source": "route", "target": "committee", "condition": "${disputeAmount >= 1000000 && crossBorder == true}", "sort": 10 }, { "source": "route", "target": "partner", "sort": 99 } ]}At most one edge should be unconditional as the default. Calculate complex domain decisions before start (for example an investor-state arbitration clause flag) and pass stable variables instead of embedding business policy in DSL.
4. Participants
ParticipantRule supports roles, positions, organizational units, explicit users, virtual roles, and a resolver key. A named IParticipantResolver is used first and explicit users are merged. Otherwise IParticipantProvider expands directory rules.
If the resolved list is empty, UserTaskBehavior leaves the node automatically. A critical approval can therefore auto-pass after directory misconfiguration. Release tests must exercise zero-assignee behavior with real tenant directory data.
5. Multi-instance
Parallel creates work for every participant; Sequential creates one at a time. CompletionCondition can read nrOfInstances, nrOfCompletedInstances, and nrOfActiveInstances.
{ "id": "conflict-committee", "type": "userTask", "name": "Conflict-of-interest committee", "participants": { "userIds": ["101", "102", "103"] }, "multiInstance": { "mode": "parallel", "completionCondition": "${nrOfCompletedInstances >= 2}" }}Test simultaneous threshold completion, sibling cancellation, variable merge, and nesting under parallel gateways.
6. Rollback, task config, and data scope
RollbackRule supports start, prev, selected, and target lists. A Reject request can supply targetNodeId. UserTaskConfig declares required comment, quick replies, auto-commit, handoff switches, withdrawal time limit, and CC recipients.
FlowDataScopeConfig is present in the model, but the platform checker currently receives only View/Operate/Manage plus task/instance identity. DSL rules are not passed into that checker.
7. Timers
TimerConfig accepts ISO 8601 duration and remind, escalate, or transfer actions. The JobHost workflow-timer job scans due entries every five minutes by default (BackgroundJobs:workflow-timer:IntervalSeconds overrides), and its execution scope already registers INotificationChannel → WorkflowNotificationAdapter, so reminder notifications commit atomically with Fired state.
{ "timers": [ { "type": "boundary", "duration": "P2D", "action": "escalate", "escalateTo": "managing-partner" } ]}Timer reliability is still the known weak spot: processing saves Fired before executing the action, so a crash between the two steps leaves no automatic recovery to Pending, and there is no cross-instance claim/lease. Use timers for reminders and escalation, not as a hard SLA.
8. Node listeners
Listener types are expression, notification, and callback. Expression only evaluates a variable; it does not mutate context. Notification recipients come from __listenerRecipients. Callback reuses the business status port. The NodeListenerDispatcher logs each failure and then rethrows — listeners now participate in transactional correctness rather than being silently isolated, so a listener exception fails the whole transition command. Definition authors must give every listener implementation reliability matching the main transaction.
9. Validation and simulation
Validator checks IDs, dangling edges, self-loops, start/end nodes, ingress/egress, gateway conditions, user-task participants, and reachability. Unreachable nodes are warnings. It does not statically verify every timer action or configuration policy.
Simulator stops at UserTask, caps depth at 100, selects only one branch for exclusive/inclusive preview, and only the first parallel branch. It is a designer preview rather than coverage proof.
DefinitionValidationResult validation = await repository.ValidateDefinitionAsync(json, cancellationToken);// Error issues block release; reachability warnings still require an explicit review.if (!validation.IsValid) return RejectWithIssues(validation.Issues);
Result<SimulationResult> simulation = await repository.SimulateAsync( json, new Dictionary<string, object?> { ["disputeAmount"] = 1200000m, ["crossBorder"] = true }, cancellationToken);
// Simulation covers this input only; integration tests exercise persisted execution.return await repository.DeployAsync( "matter-intake-approval", "Matter intake approval", json, currentUserId, tenantId, cancellationToken);10. Business binding and visual authoring
A definition root can now declare businessBinding.entityName, and a UserTask can declare formFields. Bindable entities and fields come from compile-time [BitzWorkflow] and [BitzFlowScope] metadata instead of a browser-maintained catalog. A formFields item currently has only fieldName and visibility, where visibility is editable, readOnly, or hidden. Requiredness comes from FlowScope metadata and is not a required DSL property.
The full-page frontend designer consumes the server designer-schema and business metadata catalog, and drafts use Revision-based optimistic concurrency. Its /draft/publish path repeats authoritative validation, while the legacy POST /api/workflow/definitions/ path still requires only compilable JSON. See Visual designer for endpoints, permissions, version visibility, and browser recovery boundaries.
11. Definition release checklist
- Stable key with one business meaning.
- Every user task resolves assignees in real tenant data.
- Defaults, missing variables, and type mismatches are tested.
- Parallel and inclusive joins cannot deadlock or finish early.
- Variables contain no secrets or oversized objects.
- DefinitionId, JSON, version, and checksum enter release evidence.
- Validation, simulation matrix, and database integration tests pass.
To produce this DSL from the BitzOrcas five-table set or a Saury step tree, use the Workflow Migrator. The tool emits structure and a sidecar state map; it does not implement business callbacks.