Execution semantics
Instance lifecycle
Section titled “Instance lifecycle”A deployed BPMN process is started as a process instance, which runs to one of three states:
- Running — at least one token is active or waiting.
- Completed — every token has been consumed and no join is left open. The root scope’s variables are hoisted to the process result.
- Incident — the instance hit a condition it can’t resolve on its own and is parked for an operator to look at (see Error handling below).
An instance steps forward in passes: each pass advances whatever tokens can currently move, then commits the result — the process status, any suspend state, and any dispatched child jobs — in a single transaction. Between passes, an instance that’s waiting on something (a script job, a timer, an external event) suspends and releases its worker slot rather than holding a worker idle.
Token semantics
Section titled “Token semantics”Execution is token-based: a token occupies exactly one element at a time and moves along sequence flows as elements complete. A token can be:
- Active — currently being processed this pass.
- Waiting for a job — a script or service task has been dispatched as a child job; the token parks until it completes.
- Waiting for an event — a user task or a message catch event; the token parks until an external signal arrives.
- Waiting for a timer — parked until a computed fire time, which is calculated once and never recomputed on resume.
- Waiting for a scope — the anchor token at a sub-process boundary; see below.
- Consumed — the token has reached an end event or been absorbed by a join.
Gateways join by set, not by count. A parallel or inclusive gateway’s join records exactly which incoming sequence flows are expected and which have arrived, as sets — not a count of arrivals. That distinction matters for loops: two arrivals down the same branch (a loop iterating back through a join) don’t accidentally satisfy a join meant for two different branches. A duplicate arrival on an already-satisfied flow starts a fresh join instance instead of silently double-counting.
Sub-processes run as child scopes. A token reaching an embedded sub-process activates a child scope and itself parks as the anchor at that node — it is not consumed. The scope drains, and the anchor advances, once every token inside it has been consumed and no join inside it is still open.
Async and exclusive activities
Section titled “Async and exclusive activities”Orvanta honours three Flowable BPMN extension attributes as forced quiescence boundaries. They apply uniformly to every flow-node type the parser recognises — tasks, gateways, events, and sub-processes — not only activities, matching where Flowable itself declares them (on the shared abstract FlowNode type, not any one subclass).
-
flowable:async="true"forces a boundary before the element runs: the step that reaches it parks the token without executing the element and commits. The next pull of that same process job releases the token and runs it for real. This gives an async element the “may run on a different worker, in a fresh transaction” property Flowable’s own async continuation has — without a second queued job row; Orvanta re-pulls the same process job instead.The attribute is
async, notasyncBefore—asyncBeforeis a Camunda-only spelling that real Flowable does not honour on its ownasyncattribute. Orvanta’s dialect handling deliberately collapses the Flowable/Activiti/Camunda namespaces for every dialect attribute, soflowable:asyncBefore="true"is accepted here as a documented over-approximation: harmless, since across 1,754 real Flowable test processes, none use that spelling. Truthiness is an exact, case-insensitive match against"true"—async="1","yes", or"TRUE "(trailing space) are allfalse, matching Flowable’s own parser. -
flowable:asyncLeave="true"(aliasasyncAfter) forces the equivalent boundary on the leave side — after the element’s own work is recorded, but before the token advances along its outgoing flow. Unlike the start-side attribute,asyncAfteris honoured identically across all three dialects: Flowable’s own converter resolvesasyncAfterthe same Flowable → Activiti → Camunda way it resolvesasyncLeaveitself, so there is no over-approximation on the leave side. -
flowable:exclusive(defaulttrue) governs whether an async continuation must run as the sole writer of the instance’s status row. Orvanta’s process job is already single-owner by construction (aFOR UPDATE SKIP LOCKEDclaim), soexclusive="true"is free and exactly faithful.exclusive="false"— which asks the continuation to tolerate concurrent mutation of the same status row — is accepted but ignored: a safe over-approximation, since running exclusively where the model merely permits concurrency can only change throughput, never an instance’s outcome. It’s recorded as a dropped attribute rather than silently absorbed.
Error handling
Section titled “Error handling”Orvanta distinguishes two kinds of failure:
Caught errors are normal control flow, not incidents. An errorEndEvent raises an error that propagates up to the nearest enclosing interrupting boundary error event. If something catches it, the scope is torn down and the token routes into the handler branch — this never surfaces as an operator-facing incident, by design.
Uncaught failures park the instance as an incident, visible to an operator, with one of these causes:
| Incident | When it happens |
|---|---|
| Activity failed | A task’s child job failed, or an error end event went uncaught. |
| Join starved | A join is open but every token in its scope has already been consumed — it can structurally never complete. |
| No outgoing path | A gateway split had no truthy condition and no default flow. |
| Gateway condition failed | A gateway’s condition expression raised, timed out, or didn’t evaluate to valid JSON. This is deliberately distinct from “no outgoing path” — an error is never quietly treated as false. |
| Input transform failed | A task’s input binding failed to evaluate. |
| Unsupported element | Execution reached an element outside the supported set — a defence-in-depth check for processes deployed before validation caught it. |
| Engine error | An internal failure (database error, an unresolvable model). Recorded on the instance so it’s never silent in the worker log only. |
| Stranded | An internal consistency guard fired and re-parked the instance. Recorded so the repair is visible, not just performed. |
An operator can retry an activity failed incident from the instance’s incident list. Retrying re-runs specifically the failed activity that raised it — it doesn’t restart the process from an arbitrary point, and it doesn’t re-run anything else that already completed.
Raising a business error from a script
Section titled “Raising a business error from a script”A script or service task’s bound script can assert a modelled error directly, instead of the engine inferring one from whatever crashed. The script attaches an orvanta_error_code string to the value it throws — in Python, orvanta.throw_business_error(code, msg); in TypeScript/JavaScript, throwBusinessError(code, msg); in any other supported language, whatever mechanism that language has for attaching an attribute to a thrown value, under the same name. The executor’s generated wrapper reads it out of its catch block and writes it into the job’s failure envelope. That envelope is what the boundary match above keys on: an asserted code always wins over the deprecated exception-class-name fallback older processes still rely on.
This matters because of what it changes about the two failure paths already described. An asserted business error that a boundary event catches routes into the handler branch, the same as an errorEndEvent — it never becomes an incident. Left uncaught, it still parks as an “Activity failed” incident, but skips the retry budget entirely and dead-letters on the first failure, since re-running a script that deliberately asserted an outcome would just assert it again.
Not every script language can do this. The errorCode has to survive being carried out of whatever failure channel the language’s executor actually has, and three different things stop that:
| Why not | Languages | Detail |
|---|---|---|
| There is no thrown value to attach a code to | bash, ansible | Bash has only an exit status: an integer in 0..=255, where a BPMN errorCode is an arbitrary string. Ansible’s failure is worse still — a failed play aborts before any result file is written, so a task result never crosses the process boundary at all. Both are permanent, not pending. |
| The runtime discards it | nu | Nushell’s wrapper has no catch, so an error aborts the pipeline before the result file is saved. Adding one would not be enough: measured against the pinned nushell 0.101, error make accepts an extra record key without complaint and then silently drops it, and the record catch binds is a fixed five-field shape that carries none of it. The language offers no stable carrier to key on. |
| Genuinely cannot — the “script” is a query, not a program | postgresql, mysql, mssql, bigquery, snowflake, oracledb, duckdb, graphql | These are in-process query executors. There’s no user code holding an exception, and the driver reports a failure as a single message string. This is a structural property of running a query rather than a program, not something a future wrapper closes. |
Supported today: bun, bunnative, csharp, dart, deno, go, groovy, java, kotlin, mongodb, nativets, php, powershell, python3, rlang, ruby, rust — 17 of the platform’s 28 script languages.
Two of those needed a mechanism other than “attach an attribute to the thrown value”, and it’s worth knowing which. Rust is the only language where the carrier is a type rather than a member: with no dynamic member access, the wrapper matches on an OrvantaBusinessError that the generated main.rs and your inner.rs share as two modules of one crate — so still no import and no SDK. C#, Go, Java and Kotlin can’t bolt a field onto a value they didn’t define either, so each uses what its own type system offers — Exception.Data["orvanta_error_code"], an OrvantaErrorCode() string method, a public field or a getOrvantaErrorCode() getter. The identifier is the same everywhere; only the syntax for declaring it changes.
You find out at deploy time, not at 3 a.m. If a task bound to one of the unsupported languages sits behind an interrupting error boundary that names a specific errorCode — including a boundary on an enclosing sub-process — deploying (or previewing) the process returns a warning naming the task, the boundary, the code it waits for, and which of the reasons above applies. It’s a warning, not a rejection: the process still deploys and every step still runs, it’s the handler branch that will never be taken. Putting a catch-all <flowable:mapException> on the task clears it, because then the code comes from the model rather than from the script. A catch-all boundary — one with no errorRef — is deliberately not warned about: it still catches these tasks’ failures today.
The engine never silently mis-executes: every element either has real handling or raises an explicit incident. There’s no reachable no-op fallback for an element kind the engine doesn’t recognize — that’s caught at deploy time instead (see Element reference).
Licence changes don’t stop running instances
Section titled “Licence changes don’t stop running instances”If a workspace’s licence tier changes to something that no longer entitles it to BPMN Flow, instances that are already running keep executing to completion. Entitlement is enforced at the API layer — creating a new process definition, editing an existing one, or starting a new instance — not inside the execution engine itself. This is deliberate: a licence change should never halt automation already in flight. Only new edits and new starts are affected.
Version pinning & migration
Section titled “Version pinning & migration”When a process instance starts, it pins to whichever definition version is deployed at that moment, and every step for the rest of that instance’s life resolves that same pinned version — never whatever is deployed now.
- A running instance completes on the version it started on. Redeploying a flow never touches an instance already in flight; only a new start picks up the new version. A workspace can legitimately have many instances of the same flow running against several different versions at once.
- A new start always uses the latest deployed version. There’s no way to start a fresh instance pinned to an older version.
- Deleting a flow with live instances is refused. A pinned version is a real reference a running instance depends on, so deleting the flow while any instance is still pinned to it is rejected outright rather than silently orphaning them. Archive the flow instead — archiving stops new starts while leaving every deployed version intact for instances still running against it.
Migration is a validated pointer swap, not a re-run. A running instance’s pin isn’t permanently fixed — it can be migrated to repoint it at a newer version. Migrating changes nothing about the instance’s accumulated state; it only moves which definition its next step resolves against, and only after that move has been checked to be safe. There’s no partial or best-effort migration: a version pair either passes validation and the swap is applied, or it fails and the instance keeps running on its current pin, unchanged.
A (from version, to version) pair passes validation only when both hold:
- Nothing the instance may still reference was removed. Every element, connecting flow, and correlatable message name the running instance could still touch on the old version must still exist on the new one. The new version is free to add elements, branches, and messages — it just can’t remove or rename anything the instance depends on.
- Nothing that survived changed what it means. An element id that exists in both versions must still be the same kind of element, in the same enclosing scope, resolving the same message name if it did before — and a join’s exact set of incoming connections must be identical between the two versions. This last one is the case that would otherwise fail silently: a join’s expected arrivals are computed once, when its branch splits, and never recalculated — add or remove a branch into an existing join and an instance already waiting there ends up expecting arrivals that can now never happen, with nothing raising an error until it’s eventually flagged as stuck.
Accepted, for example: fixing a script binding, tightening a gateway’s routing condition, or adding a new branch off an existing gateway that doesn’t feed back into anything the instance already touched. None of that changes any surviving element’s identity, scope, or connections, so the pair passes and any instance pinned to the old version can be safely repointed.
Rejected, for example: deleting a task the process used, renaming an element, moving a task into a new sub-process, adding a third branch into a parallel join that used to have two, or rebinding a message-catching event from one message to another. Each of these is refused — not because the new version is broken, but because a running instance still depends on the old identity, scope, connection set, or message binding.
When a pair fails, every reason is reported at once, not just the first:
| Reason | What it means |
|---|---|
| Element removed | An element the instance may still reference is missing from the new version. |
| Element kind changed | The id survives, but now names a different kind of element (a script task became a user task, for example). |
| Element moved to a different scope | The id survives, but now sits inside a different sub-process — a running token would write its results into the wrong scope. |
| Connection removed | A sequence flow the instance may still be travelling along, or holding a reference to in its state, no longer exists. |
| Message no longer correlatable | A message name the instance could be waiting on has no matching message definition in the new version. |
| Message binding changed | A catch event still resolves to a message, but not the one the instance is actually waiting for. |
| Incoming connections changed | A join’s set of incoming connections differs from the version the instance is pinned to — the case that would otherwise stall silently instead of raising an error. |
| Process identity changed | The process’s own root identifier changed; every running instance’s root state is recorded against the old one. |
Everything else about the new version is free to change regardless — script contents, routing conditions, retry settings, and anything purely new — as long as it doesn’t remove or repoint something a running instance already depends on.
There’s no CLI command or UI action to trigger a migration yet (tracked as orvanta-platform#1075) — moving an instance’s pin, individually or in bulk, is a validated action reachable only through the API today. The read side is further along: the version rollup panel on a flow’s runs view (or the workspace-wide runs view) already shows, for every pinned version with running instances, how many instances are on it and whether it’s migratable to the latest version — and if not, why.
Current limitations
Section titled “Current limitations”- Only the constructs on the element reference execute. Anything else —
callActivity, multi-instance markers, event sub-processes, most boundary event types beyond interrupting error, and several others — fails deploy-time validation rather than running with reduced fidelity. - Gateway and task expressions evaluate as JavaScript, not JUEL, which matters when importing Flowable/Camunda XML — see Importing from Flowable/Camunda.
extensionElements-based I/O mappings and listeners from Flowable/Camunda XML are captured but not yet wired into execution.
See also
Section titled “See also”- Element reference: exactly which elements the semantics above apply to.
- Importing from Flowable/Camunda: how these semantics interact with imported process definitions.
- Decision models: how a
businessRuleTaskfits into this token model.