Workflow Nodes
From the <start> node, the engine walks nodes connected by next edges until an
<end>. This page is the reference for every node type.
Common attributes
All nodes share:
| Attribute | Required | Description |
|---|---|---|
|
yes |
unique within the workflow; the target of |
|
no |
human-readable label (shown in the designer and in approvals) |
|
no |
free documentation |
Canvas positions and edge routes are not node attributes: the designer keeps them
in the definition’s own <layout> block, which the engine ignores.
Except for a diverging split (which branches through its flows) and end,
every node has a required single next.
Expressions and scripts
All conditions (split flows, wait conditions) and script bodies are
JavaScript, evaluated against the running record. Available bindings:
| Name | Description |
|---|---|
|
the record (fields readable and, in scripts, writable) |
|
the record’s fields before the save that triggered the run (update
trigger); first walk only. Unlike in a trigger’s |
|
the trigger snapshot: |
|
the run’s variables — set by earlier nodes, and kept across a pause so a value set before an approval is still there after it |
|
read-only view of the record of whatever invoked the graph you are
in, and its variables: the calling run inside a |
|
which pass over the current node the walk is on, so a loop can count without a field (see Bounding a loop that comes back to a park) |
|
after a |
|
after an |
|
on the path an |
|
when a message or a signal woke this run: |
|
logger: |
|
translation function |
agent, task and error hold the last answer of their kind on the path the run
took, and a park does not lose them — whether a wait, a task or a
background node made it, the walk that resumes reads what the walk that
parked read. Each branch of a split starts from what the split read; after a
join none of them is bound, since no single branch’s answer is the one to read,
so copy what a later node needs into vars before the join. parent reads the
same on every walk of a run. old and event describe the save that started the
run and are read on its first walk only — up to the first park — so copy into
vars what a node after a park needs from them.
Unlike the trigger’s sandboxed when, node expressions execute against the
full record — reference fields and collections may be traversed.
|
Running in the background
An agent, email or script node can run after the walk that reached it has
committed, on a worker of its own, instead of inside it. The run stops at the node
the way it stops at a wait; everything before it is saved; then the node runs and
the run carries on from there, reading agent, task, error and parent as it
would have inline. old and event are gone by then, as after any park.
<agent id="classify" ref="support-triage" next="route"> (1)
<prompt><![CDATA[Classify ${self.subject}]]></prompt>
</agent>
<email id="notify" template="id:42" async="false" next="done"/> (2)
<script id="rebuild" async="true" next="done"> (3)
<![CDATA[ __bean__('com.example.Stats').rebuild(self); ]]>
</script>
| 1 | in the background: async defaults to true on an agent |
| 2 | sent inside the walk: async="false" opts an email out |
| 3 | in the background: a script defaults to false, so a slow one opts in |
This is for work that is slow or talks to something outside the database — a model, a mail server, a heavy service:
-
The user who saved the record does not wait for it. The save returns as soon as the walk up to the node commits.
-
A failure fails only the node and what follows it. Nothing before it is rolled back, because it has already committed.
What it costs:
-
The node’s effect is not visible when the save returns, only a moment later. Meanwhile the record’s form shows the run as pending on its workflow button, and reloads the record once the node commits, asking first when it has unsaved edits. Keep a quick script inline when the user should see its result straight away.
-
A background node runs at most once. If the walk that ran it never commits — the server stopped, or a later node rolled the transaction back — the next resume does not run it again: the node fails with an unknown outcome, which an error boundary on it can catch, so someone can check whether the email left or the script’s work landed.
-
A
callwhose child contains a background node can stop, so the limits on a child that waits apply to it (see call).
The resume starts right after the commit. If it never does — the server stopped in
between — the scheduler picks the run up once
workflow.scheduler.resume-grace-seconds (default 120) has passed.
workflow.scheduler.resume-pool-size sets how many background nodes run at once,
per server; it defaults to the scheduler’s own pool size.
script
Runs an inline JavaScript body against the record. Add async="true" to run a
slow script in the background (see Running in the background).
<script id="prepare" next="done">
<![CDATA[
self.note = _t('Confirmed on') + ' ' + self.confirmDate;
vars.total = self.totalAmount; // visible to later nodes as vars.total
save(); // persist self
var task = create('com.axelor.team.db.TeamTask'); (1)
task.name = 'Follow up ' + self.name;
save(task);
]]>
</script>
| 1 | create(name) accepts a model’s fully qualified name or a custom model name |
Beyond mutating self, a script may:
-
save()— persist the record’s pending changes -
create(name)/save(obj)— build and persist new records (real or custom models) -
assign to
vars.<name>— pass values to later nodes, including a followingcalliterating over produced records. Variables survive a pause, so one set before ataskis still readable after it -
fail(message)/fail(code, message)— end the run as failed. A code names the failure so a boundary can catch it; see Catching a failure -
track(note)— leave a note on the record’s own message stream -
log.info(…)/log.warn(…)/log.error(…)/log.debug(…)— write to the server log -
_t(key)— translate;_t(key, a, b)also substitutes{0},{1}
Stopping the run yourself
Some things a graph cannot express as a condition, and the honest answer is to stop:
<script id="check" next="charge">
<![CDATA[
if (!self.customer.billingAddress) {
fail(_t('Customer has no billing address'));
}
]]>
</script>
The run ends as failed at that node, and the sentence you passed is what the
run viewer, the run history and the server log all show. Without fail the only
way to stop is to throw, and the author’s message then arrives wrapped in
whatever shape the script engine gave it.
Give the failure a name — fail('NO_STOCK', …) — and it stops being the end of
the run: an error boundary on this node can catch it and route somewhere else.
See Catching a failure.
Three things worth knowing:
-
Nothing is rolled back. A workflow fired by a save runs after that save has committed, so there is no longer anything to undo — and the transaction the workflow does run in is shared with every other workflow firing on the same event, so failing does not discard their work either.
failstops this run and records why; it does not reverse what already happened. -
Nobody is shown the message when the trigger was a save: the record was saved successfully, and it was. A workflow run manually returns the message to its caller, which is the one case where a person is still waiting.
-
A
try/catchin your script will swallow it, because it is an exception like any other. Do not wrap afailin one and expect the run to stop.
To end a run without failing, route to an <end terminate="true"/>. That
keeps the ending visible in the diagram, which a helper would hide.
|
Leaving a note on the record
track(note) posts a comment on the record’s own message stream, where somebody
reading the record will find it — unlike log, which reaches only whoever is
reading the server log.
<script id="note" next="done">
<![CDATA[
track(_t('Approved automatically: under the review threshold'));
]]>
</script>
The note is authored by whoever triggered the run when there is one, and by nobody on a scheduled run. It does not email anyone directly — the record’s followers are notified by the same machinery that handles any other comment.
A workflow that fires on every update and calls track will fill the
record’s stream. Gate it on the change that is worth remarking on.
|
Renders an existing Axelor Message email template for the workflow record and queues the result with the sender configured on that template.
<email id="notify" template="id:42" next="done"/>
| Attribute | Required | Description |
|---|---|---|
|
yes |
opaque template reference selected in the workflow
designer. Existing definitions may contain a template name for compatibility;
new selections use a stable |
|
yes |
the node to continue with after the message is queued |
|
no |
|
The template must be an email template configured for the workflow’s entity or
custom model. The Axelor Message add-on, or another WorkflowEmailProvider
implementation, must be available at runtime.
A missing or incompatible template and an expected generation or queueing failure fail the node and may be handled by an error boundary on it. An unexpected provider defect is not catchable; it fails the run, like an unexpected exception from any other node.
split
| This section is the attribute reference. For a walkthrough of every shape a split and a join make — a choice, a fan-out, a race, a quorum, a rejection that stops everything — and the rules behind them, see Branching and Joining. |
Where the path diverges — the only node that branches. mode is required and
says how its ordered <flow> children are chosen: a split has one role, so the
author says which kind up front, rather than the same word meaning something
different depending on how many flows lead out.
<split id="check" mode="one">
<flow id="f1" when="self.totalAmount > 10000" next="approve" title="Large order"/>
<flow id="f2" when="self.customer.blocked" next="reject" title="Blocked customer"/>
<flow id="f3" default="true" next="notify"/>
</split>
| Attribute | Required | Description |
|---|---|---|
|
yes |
how the flows are chosen — see below |
|
yes |
the flow’s stable identity, independent of its |
|
yes* |
JavaScript condition. Required on every flow of a |
|
yes |
branch target |
|
no |
marks the fallback flow, taken when nothing matched. At
most one per split, and it carries no |
|
no |
branch label, shown on the arrow in the designer |
The three modes, in BPMN’s terms:
mode |
Glyph | Behaviour |
|---|---|---|
|
✕ |
the first flow whose condition holds wins; nothing else is
evaluated. Its alternatives converge again at an ordinary node — there is
nothing to synchronise, so a |
|
○ |
every flow whose condition holds is taken, or the default when none match. Its branches meet at the join naming this split. |
|
+ |
every flow is taken, unconditionally. Its flows carry no |
Unlike a plain routing element, mode has no default — the author always
states which kind of split this is.
Without a default flow, a one or some split that matches nothing fails the
run rather than ending it quietly. The symptom is misleading, so it is worth
recognising: the run fails at the split, the trigger fires again on the next
update, and every node before the split runs a second time — so what you
notice is duplicated work upstream, not a split that did not match. The error
names both fixes, and they mean different things:
-
add a
<flow default="true">— "carry on anyway"; -
put a
<wait on="condition">before the split — "this has not happened yet". A split decides on what the record says now; it never waits.
Give a default flow to any one or some split whose conditions do not
provably cover every case.
|
join
For worked examples of any and quorum races, nested splits, and the
complete list of validation rules with their fixes, see
Branching and Joining.
|
Where a some/all split’s branches come back together. for names the split
this join synchronises — exactly one join names each such split, and it must be
a some or all one: naming a one split (whose alternatives already converge
at an ordinary node) is rejected as join-for-invalid, since there is nothing to
synchronise.
<split id="checks" mode="all" title="Run every check">
<flow id="f1" next="credit" title="Credit"/>
<flow id="f2" next="stock" title="Stock"/>
</split>
<script id="credit" next="merge"><![CDATA[ /* ... */ ]]></script>
<script id="stock" next="merge"><![CDATA[ /* ... */ ]]></script>
<join id="merge" for="checks" wait="all" next="done"/>
| Attribute | Required | Description |
|---|---|---|
|
yes |
the id of the |
|
yes |
the completion policy — see below |
|
with |
how many arrivals that policy waits for |
|
yes |
the node to continue to once the policy is satisfied |
wait is the completion policy:
wait |
Behaviour |
|---|---|
|
every selected branch must arrive before the join continues. |
|
the first arrival continues, cancelling the rest. |
|
the first |
The count is a separate attribute rather than a third kind of value wait could
hold, so each attribute means one thing — the same reason a split’s mode is a
closed vocabulary. quorum is required by wait="quorum" and rejected on the
other two (join-quorum-required, join-quorum-unexpected) rather than quietly
ignored: a count sitting on an all join would read as a threshold that was
being honoured. quorum="1" is allowed and means what any means.
A join has no mode, no when, no default: it names a split and a policy,
nothing else.
Racing branches: any and N
wait="any" is the "either of these will do" shape — two approvals where one
suffices, or a review racing a deadline:
<split id="reviews" mode="all" title="Ask both">
<flow id="mgr" next="manager" title="Manager"/>
<flow id="adm" next="admin" title="Administrator"/>
</split>
<task id="manager" user="alice" next="approved" title="Manager approval"/>
<task id="admin" user="bob" next="approved" title="Admin approval"/>
<join id="approved" for="reviews" wait="any" next="publish"/>
The first approval carries the run past the join. The other branch is cancelled and its approval withdrawn, so it disappears from that person’s inbox rather than sitting there for work that is over.
wait="quorum" is the same shape with a threshold — "any two of the three
directors", where it does not matter which two:
<join id="approved" for="reviews" wait="quorum" quorum="2" next="publish"/>
That is what a count is for, and it is the only way to say it: a threshold is
evaluated against the branches the split activated, so it cannot be rebuilt by
nesting joins. Nesting gives you conditions over distinct branches (all of
X plus any of Y and Z); a quorum is a threshold over interchangeable ones,
and writing "any 2 of 3" as a tree of joins would need the same approval to
appear twice — two tasks for one person.
Only arrivals count towards the threshold. A branch that reaches an <end>
before the join is neither an arrival nor a rejection — it just means one
fewer branch that could still arrive. So a rejected approval counts if its
flow leads to the join; route it to an <end> if it should not.
Two rules keep a race from waiting for something that can never come:
-
A
quorumabove the split’s flow count is refused when the workflow is saved (join-quorum-invalid) — two flows can never produce three arrivals. -
A threshold the day’s branches can no longer meet fails the run, with a message naming the join and the counts. A barrier that can never release would otherwise leave the record waiting for ever.
A non-cancelling boundary (cancel="false") may not appear inside a
race (boundary-cancel-invalid). Its escape branch is a separate run
that no branch owns, so cancelling a losing branch could not stop it — and
"the remaining branches are cancelled" would not be true. Use cancel="true",
move the boundary outside the race, or make the join wait="all".
|
Branches run one after another, in declaration order, against the same record and the same variables — a split is about which paths are taken, not about running them at the same time.
A branch of a some/all split may wait — on a task or a wait —
because such a split always has exactly one join, so there is always a barrier
for a woken branch to arrive at. When one branch parks, the engine writes down
where every branch of that activation got to, and the join counts them back in
as they finish: the run stays waiting until the last branch it is owed
reports back, and the join then runs exactly once. Completing one approval
never carries the run past a barrier its siblings have not reached.
A branch may wait more than once before its join — two approvals in a row is an ordinary shape — and each branch that waits on a timer keeps its own wake time, so branches with different delays wake independently.
A split may also sit inside a branch of another split, and wait there. Joining the inner join carries that branch on towards the outer one, so the outer join waits for it exactly as it waits for a branch that parked directly.
If a branch fails, the fork fails with it: the run is failed and the
approvals its siblings were holding are withdrawn, rather than left clickable on
a run that is already over. Deleting the record does the same for every branch:
there is nothing left to approve, so the run is cancelled and its approvals go
with it.
Branches may not share work before the join
One structural rule: two branches of the same split may not share any node
before their join, except the join itself — or an <end>, since an end does no
work. Settling on the same end from two branches is fine, including a shared
terminate end: reaching it from either side ends the whole run, so there is
nothing to run twice. That is the shape behind "a rejection on either side stops
the process":
<split id="both" mode="all" title="Both approvals">
<flow id="f1" next="manager" title="Manager"/>
<flow id="f2" next="finance" title="Finance"/>
</split>
<task id="manager" role="sales.manager" next="managerOutcome"/>
<split id="managerOutcome" mode="one" title="Manager says">
<flow id="f1" when="task.rejected" next="stop" title="Rejected"/>
<flow id="f2" default="true" next="merge" title="Approved"/>
</split>
<task id="finance" role="finance.team" next="financeOutcome"/>
<split id="financeOutcome" mode="one" title="Finance says">
<flow id="f1" when="task.rejected" next="stop" title="Rejected"/>
<flow id="f2" default="true" next="merge" title="Approved"/>
</split>
<!-- reaching this cancels whatever the other approver was still holding -->
<end id="stop" terminate="true" title="Stopped"/>
<join id="merge" for="both" wait="all" next="note" title="Both approved"/>
Anything with real work in it — a script, another task —
must instead be given its own node past the join, or moved into its own
<call>, rather than shared between branches ahead of it.
Persist once after the join
Every branch of one run shares a single in-run record, so a fork should accumulate its changes and persist them once past the join rather than save in each branch:
<split id="route" mode="some" title="On confirm">
<flow id="f1" when="self.amount > 1000" next="flag-value"/>
<flow id="f2" when="self.customer.vip" next="flag-vip"/>
<flow id="f3" default="true" next="merge"/>
</split>
<script id="flag-value" next="merge"><![CDATA[ self.needsApproval = true; ]]></script>
<script id="flag-vip" next="merge"><![CDATA[ self.priority = 'high'; ]]></script>
<join id="merge" for="route" wait="all" next="persist"/>
<script id="persist" next="done"><![CDATA[ save(); ]]></script>
Saving in each branch instead writes the record once per matching branch: every write runs the record’s save hooks and can re-trigger a workflow watching a field that branch just set, and it exposes partly-updated states to anything watching. One save past the join goes from the old state to the final one.
This applies to some/all. A one split runs exactly one branch, so
the save() in the branch that acts is already a single write and needs no
join.
wait
Parks the run durably until a timer elapses, a condition on the record becomes true, or a message or signal arrives from elsewhere.
<!-- resume after 2 days -->
<wait id="cooldown" on="timer" delay="2" unit="day" next="remind"/>
<!-- resume at a date carried by the record -->
<wait id="due" on="timer" until="self.reviewDate" next="review"/>
<!-- resume when the record reaches a state -->
<wait id="paid" on="condition" when="self.status == 'PAID'" next="close"/>
<!-- resume when another workflow says this order was paid -->
<wait id="paid" message="order-paid" key="self.id" next="close"/>
<!-- resume when the books are closed, whichever order this run is about -->
<wait id="closed" signal="books-closed" next="report"/>
<!-- ...but do not wait for it forever -->
<wait id="paid" on="condition" when="self.status == 'PAID'" next="close"/>
<boundary id="chase" host="paid" on="timer"
delay="7" unit="day" next="chaseAccounts"/>
| Attribute | Required | Description |
|---|---|---|
|
one of these |
|
|
timer |
relative wake-up ( |
|
timer |
expression producing the wake-up date/time (alternative to
|
|
condition |
JavaScript condition; if already true when the node is reached, the walk continues immediately without parking |
|
one of these |
the message name to listen for, matching the |
|
one of these |
the signal name to listen for, matching the |
|
with |
which occurrence of that name to wait for, as an
expression on the record. Read once, when the run parks. It must not be empty —
see send. Refused with |
|
no |
how many times the run may park here before it is failed — see Bounding a loop that comes back to a park |
|
yes |
the node to continue with after the wait |
A timer wait is woken by the scheduler; a condition wait is re-evaluated when the
record is updated; a message or signal wait is woken by a send elsewhere. Any of them can be watched by a boundary.
Neither correlating wait has a deadline of its own, and neither does a condition wait
on a record nobody touches. They wait forever unless you give them one — attach a
<boundary on="timer"> and say where to go instead.
send
Announces that something has happened, by name, to whoever is waiting for it. With
message, every run parked on that name and that key continues; with signal, every
run parked on that name does (see Sending a signal). The walk passes straight through.
<!-- the payment workflow, on Invoice -->
<send id="tell" message="order-paid" key="self.order.id" next="done"/>
<!-- with data along for the ride -->
<send id="tellMore" message="order-paid" key="self.order.id" next="done">
<payload><![CDATA[ ({ amount: self.amountPaid, method: self.paymentMode.code }) ]]></payload>
</send>
| Attribute | Required | Description |
|---|---|---|
|
one of these |
the message name, spelled the same way as the |
|
one of these |
a signal name, in place of |
|
with |
which occurrence of that name this is, as an expression on the sender’s record. It must not evaluate to empty: a message nothing can be matched to is a send that silently does not happen, so the node fails instead |
|
no |
a child element, not an attribute: an expression returning an
object, carried on the message and read by whoever it wakes as |
|
no |
|
|
no |
how long to keep offering this, in seconds. Empty takes
|
|
yes |
the node to continue with |
A send never waits for a receiver and never fails because there was none. That is what lets one workflow tell another something without either knowing the other exists — the difference between this and a call, which calls a named child and is coupled to what it does.
The name and the key
The name says what happened; the key says what it happened to. Both sides have to
agree on both, and nothing checks them across definitions — a <send message="order-paid">
and a <wait message="order_paid"> will simply never meet. Because runs are anchored to a
record, the key is usually an id:
<!-- on Order: wait to hear that this order was paid -->
<wait id="paid" message="order-paid" key="self.id" next="ship"/>
<boundary id="giveUp" host="paid" on="timer" delay="7" unit="day" next="cancel"/>
<!-- on Invoice, a different workflow: say that it was -->
<send id="tell" message="order-paid" key="self.order.id" next="done"/>
The boundary is the point. Nothing about a message park is special to the deadline that bounds it, because a message park is a park.
What a message carries
A <payload> is an expression returning an object, evaluated as the sending node
runs and delivered with the message. The receiver reads it as message.data:
<!-- on Invoice: say what was paid, not only that something was -->
<send id="tell" message="order-paid" key="self.order.id" next="done">
<payload><![CDATA[ ({ amount: self.amountPaid, on: self.paymentDate }) ]]></payload>
</send>
<!-- on Order, woken by it -->
<script id="record" next="e"><![CDATA[
self.settledAmount = message.data.amount;
save();
]]></script>
|
A payload is a snapshot; the record is the present. Where both could answer, the record is right. The payload records what was true when the sender committed. The record carries what is true now, at the moment the receiver wakes — and those are different moments, because delivery is asynchronous and a park may have waited days in between. So send what a receiver could not work out later: an amount before a later credit
note, which branch the sender took, a total as it stood at the time. Do not send a
copy of fields it can read for itself — a |
The payload is checked only for parsing when the definition is saved. What the
expression returns is not knowable until it runs, so the shape of a payload is an
agreement between the two sides, exactly like the name they correlate on. A receiver
reading a field the sender never sent gets undefined, not an error.
Two things a payload may not contain, both refused at run time with the node failing rather than sending something misleading:
-
A record.
({ order: self.order })or({ all: self })fails naming the path. Send the fields the receiver needs, or an id for it to read the record by. -
More than
workflow.message.payload-maxcharacters of serialised JSON (8192 by default). A payload quietly cut in half is worse than a send that says it was too big.
Dates and times arrive as ISO-8601 text ("1980-05-17"), and message.data is an
empty object when the sender wrote no payload — never null, so message.data.amount
can be read without guarding first.
Delivery, and sending before the receiver is ready
The row is written in the sending run’s own transaction and delivered afterwards, by the scheduler. That gap is deliberate: it is what makes sending before the receiver has parked work. A message that matches nothing yet stays pending and is offered again on the next poll, so a run that parks a moment later is still woken.
Two consequences worth knowing:
-
A send is not rolled back by a later failure in the same walk. The row is already committed, and it will be delivered. Put a send after the work it reports, not before.
-
ttlbounds how long the sender keeps retrying, and nothing else. How long a receiver waits is bounded the way every park is: with a timer boundary on the waiting node.
Expecting exactly one listener
By default a message reaches every run listening for that name and key — several is ordinary, because runs are anchored to records and more than one workflow may care about the same one.
expect="one" says that was not meant to happen. It reports rather than prevents:
delivery is asynchronous, so by the time the count is known every match has already
been woken and nothing can be taken back. What it buys is that the message says so.
Messages that went nowhere are kept rather than deleted, and their status is where a
correlation failure becomes visible at all:
| Status | Meaning |
|---|---|
|
not matched yet, and offered again by a later poll |
|
matched at least one listener, which was woken |
|
never matched, and past its |
|
|
Sending a signal
A <send> with signal in place of message announces that something has happened,
by name, to everyone waiting for it. Every run
parked on that name continues, whichever record each of them is about; the walk passes
straight through.
<!-- on FiscalPeriod: tell every order run that the quarter is closed -->
<send id="tell" signal="books-closed" next="done"/>
| Attribute | Required | Description |
|---|---|---|
|
yes |
the signal name, spelled the same way as the |
|
no |
refused: a signal is addressed by name alone |
|
no |
as for a message. Having no key a signal cannot address a record, but it can still describe one — and a receiver reading the payload is reading about the broadcaster’s record, never its own |
|
no |
|
|
no |
how long to keep offering this, in seconds. Empty takes
|
|
yes |
the node to continue with |
Everything else it shares with a message: the row is committed with the rest of the node’s work and delivered by a later poll, so a signal sent before its receivers have parked still reaches them; it never waits for a receiver; and it is not undone by a later failure in the same walk.
A signal is not a message with the key left out
They are separate address spaces that happen to share a name column. A signal reaches only the runs waiting for a signal of that name, and a message only those waiting for a message of it — even when the name is the same:
<!-- these two never meet, though both name order-paid -->
<wait id="one" message="order-paid" key="self.id" next="a"/>
<send id="all" signal="order-paid" next="b"/>
This is why a signal is named by its own signal attribute rather than by a message
with key omitted, on the sender and the receiver alike. A missing attribute that
quietly widened who is reached is the one mistake nothing here would report: the runs
you did not mean to wake simply wake.
Use a message when the news is about one record — "invoice 4711 was paid". Use a signal when it is about none in particular — "the books are closed for the quarter".
call
Calls another workflow — once for the current record (a call activity), or once per element of a collection (multi-instance). Which one you get depends only on whether you asked for an iteration.
<!-- delegate to another workflow for this record -->
<call id="delegate" ref="order-billing" next="done"/>
<!-- run the child for each produced record (multi-instance) -->
<call id="each-task" ref="task-init" each="vars.newTasks" next="done"/>
<!-- run the child for each record matched by a query -->
<call id="late-lines" ref="line-escalation"
filter="self.order.id = :id AND self.status = 'late'"
maxSize="500" batchSize="50" next="done"/>
| Attribute | Required | Description |
|---|---|---|
|
yes |
name of the called workflow — author it as a |
|
no |
expression yielding one saved record to run on. A collection
is refused — use |
|
no |
expression yielding a collection to run on, one child per
element. A scalar is refused — use |
|
no |
JPQL filter selecting records on the called workflow’s model, one child per match |
— |
|
|
|
no |
upper bound of iterated elements, with |
|
no |
with |
|
yes |
the node to continue with once the call is done |
Inside the called workflow, the calling run is visible as the read-only parent
binding.
Waiting: for one child, or for every element
A <call> with no selector at all calls the child once, on this record.
Because there is exactly one child, the caller can wait for it:
<call id="approval" ref="order-approval" next="ship"/>
If order-approval parks — on a task, or a wait — then this run parks
here too, at the call node, and resumes at ship when the child reaches its
end. A child that fails fails the caller, at the call node, with the
child’s own error attached, unless an error boundary on the
call node catches it.
Because the caller parks, it can be interrupted like any other park: attach a boundary to give the call a deadline or a get-out condition.
<call id="approval" ref="order-approval" next="ship"/>
<boundary id="too-slow" host="approval" on="timer"
delay="7" unit="day" next="escalate"/>
Everything else — any of record, each or filter — is multi-instance: the
elements run one after another inside the caller’s walk.
An element may park, which is how one approval per line is written:
<call id="approve-lines" ref="line-approval" each="self.lines" next="invoice"/>
Each element becomes its own child run, and each opens its own approval. The
caller waits at the call node until every element has come back, then
carries on to next — once, however many elements there were. Elements that run
to completion without parking behave exactly as they always did, and nothing is
written down for them.
A failing element ends the iteration: the approvals its siblings were holding go back, their runs are cancelled, and the caller fails at the call. There is no partial result to reason about.
maxSize caps how many elements a node may run, and it is worth setting when
they park — each one is a run row and an approval in somebody’s inbox, not just a
step in a long transaction.
A filter source does not support custom models, and still refuses a child
that can park:
its records are read a page at a time, so they are not all in hand when the
barrier has to be written — use each over a collection instead. And an
iteration inside a branch of a split refuses too.
|
A call activity inside a branch of a split fork parks like any other branch: the
park belongs to the branch rather than to the run, and the child’s completion
brings that branch back to walk on to its join. A failed child fails the branch,
which takes the activation down with it — the siblings' approvals go back, the
same as any branch that fails.
subflow
Runs the graph written inside it, rather than naming one to run. Same
selectors, same waiting, same next — the only difference is where the child
graph comes from, and everything else follows from that.
<subflow id="per-line" each="self.lines" model="com.axelor.sale.db.SaleOrderLine"
next="invoice">
<start id="s" next="check"/>
<script id="check" next="approve"><![CDATA[
vars.total = vars.total + self.price;
]]></script>
<task id="approve" user="admin" next="e"/>
<end id="e"/>
</subflow>
| Attribute | Required | Description |
|---|---|---|
child nodes |
yes |
the graph to run, written inside the element the way a
|
|
no |
the model the selected records are, checked against what the selector yields. Omit it and the elements are not type-checked — here the model is a check, not an identity |
|
no |
the custom (JSON) model name, when |
|
no |
exactly as on a |
|
exactly as on a |
Which of the two to write
A <call> names another definition. That definition has an identity, a model and
a revision of its own, anyone may call it, and it is isolated: it starts a run
of its own, with its own variables, reading the caller’s only through the
read-only parent.
A <subflow> carries its graph, and that graph runs inside the caller’s run.
It has no identity — nothing else can run it, it is deployed and revised with its
caller, and a run parked inside it stays on the revision its caller was pinned to
rather than drifting onto a newer one. It shares the caller’s variables, and
everything that happens in it belongs to the run: its parks are the run’s parks,
and an approval it raises appears on the run’s record whatever the body is
standing on.
That last part is the one to read twice. A <subflow each="self.lines"> runs its
body over each line, so self inside it is a line — but the approvals it opens
are filed against the order, and only there. Forty lines out for approval are
forty approvals on one order, told apart by the line each one names: a node’s
title is static, so the approval carries the element it was raised over and the
inbox shows that beside the title. A model with no name field has nothing to
show there, and those rows do read alike. A <call each="self.lines"> does the
opposite: forty runs, each about a line, with its approval on that line.
So: reuse across definitions is a <call>; a region of this process is a
<subflow>. They are BPMN’s callActivity and subProcess, and mean the same
things — a call activity starts a new process instance, an embedded sub-process
is part of the one it is written in.
Its start carries no trigger
A <subflow> is entered from its own node and from nowhere else, so on,
message, watch, repeat, delay and unit are all refused on its <start>.
when is allowed and is the useful one: it gates each element, exactly as a
called workflow’s own start condition does.
Node ids are scoped to it, so it may reuse ids its caller already has — handy when the same shape is written twice.
And a <subflow> may not contain another. One level is what a diagram can show
and a reader can hold; a region that wants a region of its own is a workflow of
its own to <call>.
Sharing the caller’s variables
There is one rule, and it is the one every scope in the engine follows:
A read walks outward. A write lands where the name is already found, and is local to the body when it is found nowhere.
So an author says which they meant by where they first wrote the name, and declares nothing:
<script id="init" next="per-line"><![CDATA[ vars.total = 0; ]]></script>
<subflow id="per-line" each="self.lines" model="com.axelor.sale.db.SaleOrderLine"
next="report">
<start id="s" next="add"/>
<script id="add" next="e"><![CDATA[
vars.total = vars.total + self.price; // the caller's — it named it first
vars.note = 'seen'; // its own — scratch
]]></script>
<end id="e"/>
</subflow>
<script id="report" next="e"><![CDATA[
self.checked = vars.total; // the sum of every line
// vars.note is not here: it went with the body
]]></script>
Both halves survive a pause. A body that parks on an approval finds its own names again when it resumes, and the caller sees what the body wrote to its names — but never what the body kept to itself.
A deadline on the whole region
This is the reason the node exists.
A boundary watches a stretch of process, not a node. Attach one to a
<subflow> and it is armed from the moment the walk enters the region until the
moment it leaves — however many threads are inside, and wherever each of them has
stopped.
<subflow id="per-line" each="self.lines" model="com.axelor.sale.db.SaleOrderLine"
next="invoice">
<start id="s" next="approve"/>
<task id="approve" role="sales.manager" next="e"/>
<end id="e"/>
</subflow>
<boundary id="chase" host="per-line" on="timer" delay="7" unit="day"
next="escalate"/>
"Every line approved within seven days, or escalate." The seven days are measured
from when the order reached the node, not from when any one line happened to park.
If the deadline passes with two of forty still out, the boundary fires once: the
two approvals still open are withdrawn, nothing is left waiting, and the run
continues from escalate. A non-interrupting boundary (cancel="false") runs its
own branch beside the region and leaves it going.
This is the one thing a <call> cannot do. Its elements are runs of their own
with nothing holding them together, so a deadline on the node could only ever
mean a deadline per element — which is a boundary inside the child, and what
BPMN prescribes there. boundary-host-invalid still says so for a <call>.
A timer or condition boundary needs a region that can stop: a body that runs
straight through has no more to interrupt than a script does, and
boundary-host-invalid says so. An error boundary needs no park and
catches a failure raised anywhere inside.
Waiting, and the limits
A body parks in the run: when it stops, the run stops at the subflow node, and an
iterating body opens one approval per element and waits for all of them before
the run carries on past next.
One limit is worth knowing: a filter source needs model, since a query has
to be a query against something.
task
A human gate: the run parks on a durable task until a person completes (or
rejects) it, then continues through its single next. To route on the outcome,
follow it with a split on the task binding.
<task id="approve" title="Approve large order"
role="sales.manager" comment="always" next="after"/>
<boundary id="chase" host="approve" on="timer"
delay="3" unit="day" next="escalate"/> (1)
<split id="after" mode="one">
<flow id="f1" when="task.rejected" next="cancel" title="Rejected"/>
<flow id="f2" default="true" next="confirm"/>
</split>
| 1 | nobody answered in three days: the approval is withdrawn and the run leaves by this edge instead. See boundary. |
| Attribute | Required | Description |
|---|---|---|
|
no |
expression resolving to a user (e.g. |
|
no |
a user login; assigned and auto-claimed |
|
no |
a role name; its members form the candidate pool |
|
no |
a group code; its members form the candidate pool |
|
no |
which action(s) require a comment: |
|
no |
default |
|
no |
default |
|
no |
default |
|
no |
how many times the run may park here before it is failed — see Bounding a loop that comes back to a park |
|
no |
JavaScript condition — see Gating approval on a condition |
|
no |
default |
|
no |
a child element: the message shown to someone who tries to
approve while |
|
yes |
the node the run resumes with after completion |
A deadline on an approval is a boundary attached to it, not an attribute here.
An assignee (assignee/user) and a candidate pool (role/group) may coexist.
| At least one of them must resolve to somebody, otherwise the run fails rather than parking a task nobody can see. Pooled tasks must be claimed before acting. |
Pending tasks surface in the My Approvals inbox and on the record form’s toolbar approvals badge — see User Tasks & Approvals.
Gating approval on a condition
when, on its own, only gates completion: a person can still always decide, but
Approve is refused while it is false and allowed once it holds. autoComplete
is a separate, opt-in step further — letting the condition resolve the task
itself, with nobody deciding at all:
<task id="approve" role="sales.manager" next="ship"
when="self.amount <= self.approver.limit" autoComplete="true">
<blocked><![CDATA[
Amount ${self.amount} exceeds your approval limit of ${self.approver.limit}.
]]></blocked>
</task>
-
False when someone tries to approve — the approval is refused with
<blocked>, rendered against the record as it is now, or with a generic statement that the condition does not hold where the node authored none; the task stays open and pending. Reject and an admin’s Force complete make no such claim, so neither is affected. -
Already true when the node is reached, with
autoCompleteset — the task is never opened for anyone. A real, completedMetaWorkflowTaskis still recorded (outcomecondition, nobody assigned), so a downstreamsplitor script readstask.*exactly as it would after a person answered. -
Becomes true while the task is still pending, with
autoCompleteset — the record is re-evaluated on every update the same way a conditionwaitis, and a task whose condition now holds completes itself (outcomecondition) without anyone acting.
Without autoComplete, the last two never happen: the condition only ever gates
a person’s own decision, and a pending task stays pending — however true it
becomes — until somebody clicks Approve. autoComplete and <blocked>
both require when, and are rejected without it.
Restricting how a task may be closed
Some approvals should only ever be completed, never rejected — a step that is a
formality once reached, or one where "no" has to be escalated rather than
recorded. allowReject="false" refuses a rejection on the task entirely; the
person can still act, just not that way. allowForceComplete="false" similarly
withdraws the one override even an admin normally has, for a task where nobody
should be able to skip the check it exists to make, and allowCancel="false"
withdraws the other: the run cannot be abandoned from an approval that has to be
answered. All three default true, and none is related to when, which never
gates any of them.
Note what allowCancel="false" does not do. A run parked on such a task can
still be ended — by a boundary that withdraws the task on a deadline or an
event, which is a route the workflow itself declares. What it refuses is the
unplanned exit: an administrator discarding the run from the approval screen.
Each setting is snapshotted onto the task when it parks, so the approval screens draw only the buttons the node allows — a withdrawn action is absent rather than offered and then refused. The service checks again when the action is attempted: the snapshot shapes the screen, it never authorizes.
Bounding a loop that comes back to a park
A rework loop — reject, fix, submit again — is an ordinary shape, and nothing
stops it going round for ever. maxVisits on a task or a wait is the
one guard the engine can honour: when the run reaches the node for the
maxVisits+1-th time, it fails there instead of parking again, with a message
naming the node and the limit.
<task id="review" role="reviewers" maxVisits="3" next="outcome"/>
Only the nodes that park carry a limit, deliberately: their count is durable
across resumes at every trace level, while an ordinary node’s is known only
within the current segment of the walk, and the engine does not promise what it
cannot keep. Scripts can read the same count as visit. When a node declares no
maxVisits, the workflow.node.max-visits option applies; unset, there is no
limit.
boundary
A way out of a park that the park’s own trigger may never provide. A <wait> for
a condition is re-evaluated only when its record changes, so if nothing ever
changes it waits forever; an approval waits as long as the person does. A
<boundary> gives either of them a deadline, or a second condition, and somewhere
to go.
It is a node of its own, attached to the node it watches by host. The run never
arrives at it — it holds no token. While its host is parked, it races the host’s
own trigger.
<task id="approve" role="sales.manager" next="ship"/>
<boundary id="chase" host="approve" on="timer"
delay="7" unit="day" next="escalate"/> (1)
<boundary id="expire" host="approve" on="timer"
until="self.quoteValidUntil" next="abort"/> (2)
<boundary id="abandon" host="approve" on="condition"
when="self.status == 'CANCELED'" next="abort"/> (3)
| 1 | a deadline counted from the moment the run parked |
| 2 | a deadline the record already carries |
| 3 | a condition on the record |
| Attribute | Required | Description |
|---|---|---|
|
yes |
the node this boundary watches. For |
|
one of these |
|
|
timer |
how long after the run parked ( |
|
timer |
an expression naming the instant the deadline falls, such as
|
|
condition |
a JavaScript condition on the record, re-read whenever the record changes |
|
one of these |
the message to watch for while the host is
parked — the same |
|
one of these |
the signal to watch for, as a |
|
no |
|
|
yes |
the node to continue from |
|
no |
|
|
no |
how many times a reminder fires, each occurrence one |
|
no |
a label for the designer and the run viewer |
Attachment is stated rather than inferred. An edge into a node that can park
reads equally as "watch this" and as "wait, then carry on", so only host tells
the two apart.
By default (cancel true), taking a boundary closes the park: a waiting
approval is withdrawn — nobody answered it — and the run continues from the
boundary’s own next. Which way the run went is therefore visible from the edge
it left by, with no condition to test afterwards.
On a <call> call activity, closing the park means cancelling the call: the
child workflow is ended, along with its own approvals and anything it called in
turn. "Wait for the approval sub-process, but give up after a week" is one
boundary and no bookkeeping.
Boundaries on one host are tried in the order they are declared, and before the
host’s own trigger, so one declared first wins over a later one and over the
wait’s own condition in the same pass. A cancelling boundary ends the pass there.
A non-cancelling one fires and the pass goes on: a condition wait’s own when is
still tested in that same pass, a timer wait’s own deadline is re-tested only when
the scheduler wakes it, and an approval has no trigger of its own to test.
A boundary’s next may lead anywhere, including back into the main path. It may
not lead back to its own host: that would close the park and open a new one,
replacing the approval somebody is holding with a fresh one that has lost its
assignee, comments and history. For a non-cancelling boundary the host is never
closed either way, but a next back to it would still open a second, independent
park on the same node inside the branch.
A withdrawn approval still records why it closed, so a deadline stays
distinguishable from an approval an administrator cancelled: a timer boundary
leaves outcome as timeout and sets timedOut.
A delay is measured from when the run parked, on the revision it pinned
— so it neither drifts when the record is touched repeatedly, nor moves if a later
revision changes the delay. An until follows its field instead: it is read when
the run parks and again whenever the record changes, so pushing self.dueDate out
grants an extension and moving it into the past says the deadline has gone by. If
the expression stops resolving to a date — the field was cleared, say — the instant
already armed is kept rather than failing a park that was waiting quite happily. The
same holds for the until of a wait.
|
A reminder that does not close the park
A boundary declared cancel="false" fires without closing anything: the park
stays open, and the run starts a second, independent path from the boundary’s own
next alongside it — see Monitoring
runs for where that path shows up.
<task id="approve" role="sales.manager" next="ship"/>
<boundary id="remind" host="approve" on="timer" delay="2" unit="day"
cancel="false" next="notify-manager"/>
<boundary id="expire" host="approve" on="timer"
delay="7" unit="day" next="abort"/>
A timer boundary like remind fires once per park. To chase somebody more than
once, give it a repeatCount rather than writing one boundary per reminder — each
occurrence falls one delay further from the park than the last.
<boundary id="remind" host="approve" on="timer" delay="1" unit="day"
cancel="false" repeatCount="3" next="notify-manager"/>
That nudges the approver a day after the run parked, again on days two and three, and then goes quiet. Every occurrence starts its own escape branch. Occurrences already overdue — after downtime, say — are taken one per scheduler pass rather than all at once.
repeatCount describes a reminder, so it needs cancel="false" and a
delay/unit pair, and must be at least 2. A cancelling boundary ends the park
the first time it fires, and an until names a single instant that cannot recur,
so the validator rejects repeatCount on either.
|
A condition boundary is edge-triggered instead: it fires when its when becomes
true, and is armed again once the condition has dropped and comes true a second
time. It needs no count.
A run whose own walk has already finished holds at waiting — never completed or
failed — while a branch it spawned is still going, and settles once the last one
ends.
A non-cancelling boundary may not sit inside a race — a some/all
split whose join is wait="any" or wait="quorum". Its escape branch is a run
of its own that no branch owns, so cancelling the losing branch could not stop
it, and "the remaining branches are cancelled" would not be true
(boundary-cancel-invalid). See
Branching and Joining.
|
Catching a failure
on="error" is the odd one out. Its host is a node that runs rather than one
that parks, nothing arms it, and it is consulted where the failure is raised
rather than by the scheduler. It is how a path that did not work out becomes a
route rather than the end of the run:
<script id="reserve" next="ship"><![CDATA[
if (self.stock < self.qty) fail('NO_STOCK', 'only ' + self.stock + ' left');
]]></script>
<boundary id="backorder" host="reserve" on="error"
code="NO_STOCK" next="orderMore"/> (1)
<boundary id="anyFailure" host="reserve" on="error"
next="raiseTicket"/> (2)
| 1 | catches one named failure |
| 2 | catches any failure; written after the named one, because boundaries are tried in declaration order |
Three things to know before relying on it.
A code catches only a failure raised under that name; no code catches any.
fail() and an <end error="…"> raise a named failure, and an agent that did
not answer or a send the email provider refused raise one without a name. A
boundary without a code also catches what merely escaped — a null dereference
in a script, a bad field name — so a mistake there takes the boundary’s path
instead of failing the run. The node’s visit still records the error, so check
the run history when a handler fires more often than expected. A failure that
left the transaction unable to commit, such as a constraint violation on save, is
never caught: nothing the handler wrote could be saved.
Nothing is rolled back. A failing node’s writes are committed with the rest of
the dispatch, so the handler resumes from a record the failed node may already
have changed. Treat a handler as compensation, not as a retry: it should assume
the work was partly done. The one exception is a multi-instance call, whose
elements run inside the caller’s own transaction — there a failing element does
take the caller back with it, as the warning under call says.
A failure reaches no run that is not an ancestor of the failing one. It
travels up — a <call> child’s uncaught failure is offered to an error
boundary on the <call> node that called it — and never sideways to an
unrelated run. That is the whole difference from a broadcast.
The node is still recorded as failed, whether or not a boundary catches it: a boundary changes where the run goes next, not what happened.
The boundary’s path reads what it caught as error: error.message is the
failure’s text and error.code its name, or null when it has none. Like agent,
it stays bound along that path, across any park, until another caught failure
replaces it or a join ends the branches:
<script id="report" next="done"><![CDATA[
log.info('reserve failed: ' + error.message);
]]></script>
What the validator refuses
Each of these is caught when the workflow is saved or loaded, naming the boundary and its host.
| Code | Meaning and fix |
|---|---|
|
Every boundary names an existing node in |
|
A |
|
An |
|
A |
|
A boundary says what it watches for exactly once: |
|
A timer boundary carries |
|
|
|
A timer boundary carries no |
|
A condition boundary carries |
|
Every boundary has a |
|
|
|
An |
|
|
|
A |
|
The expression does not parse as JavaScript. |
agent
Invokes an AI agent and exposes its answer to the following nodes. The <prompt>
child is the prompt template: literal text with ${…} placeholders, each a
JavaScript expression evaluated against the record — self, old, the vars
bag, and the other bindings, the same scope as script and when. Wrap it in
<![CDATA[…]]> (like a script body) so <, >, and & in the prompt are safe.
<agent id="classify" ref="support-triage" next="route">
<prompt>
<![CDATA[
Classify the urgency of this ticket for customer ${self.customer.name}:
${self.description}
]]>
</prompt>
</agent>
<split id="route" mode="one">
<flow id="f1" when="agent.result.urgency == 'high'" next="escalate"/>
<flow id="f2" default="true" next="queue"/>
</split>
Only ${ is special — write \${ for a literal one; everything else, including
backticks and backslashes, passes through verbatim. A predefined context line is
prepended automatically, so the agent knows which workflow, step, and record it is
acting on without you restating it.
| Attribute | Required | Description |
|---|---|---|
|
yes |
the code of the AI agent to invoke |
|
yes |
the node to continue with |
|
no |
|
The agent receives the record identity and the run’s variables. Its answer
is bound as agent.text (raw text) and agent.result (parsed JSON when the
answer is JSON, else the raw text). Agents are provided by the built-in
AI service.
Declaring the answer you need
The split above reads agent.result.urgency, which is a promise nobody made. If
the agent answers in prose instead of JSON, agent.result is that prose, urgency
is absent, and the split quietly takes its default branch: the run succeeds and
the ticket is routed wrongly. Nothing distinguishes the agent did not answer in the
shape we wanted from the field was absent.
An <output> child closes that gap. It is described to the agent as part of the
prompt, and the answer is checked against it before any later node can read it.
<agent id="classify" ref="support-triage" next="route">
<output>
<field name="urgency" type="string" required="true">
<option value="high"/>
<option value="normal"/>
<option value="low"/>
</field>
<field name="score" type="number"/>
<field name="reasons" type="array"/>
</output>
<prompt><![CDATA[ Classify this ticket: ${self.description} ]]></prompt>
</agent>
| Attribute | Required | Description |
|---|---|---|
|
yes |
the key the workflow reads off |
|
no |
|
|
no |
when |
|
no |
what the field means, shown to the agent; never checked |
A string field may also list the values it accepts:
<field name="urgency" type="string" required="true">
<option value="high"/>
<option value="normal"/>
<option value="low"/>
</field>
The list is sent to the agent and checked on the way back. Matching is exact
and case-sensitive: High is not high, and nothing is trimmed or repaired —
a near-miss is precisely what a split would route on wrongly rather than
notice. Options are for strings only; on any other type they would read as a
constraint and do nothing, so they are rejected.
A model that has called tools often wraps its answer in a code fence or leads with a
sentence. Where a shape is declared, the one JSON object the answer holds is read out
of that wrapping; text with no object in it still fails the node. Three things fail
the node, each naming what went wrong: an answer that is not a
JSON object at all, a missing required field, and a field whose value has the
wrong type. Failing is the point — the alternative is routing a record on a field
the agent never gave. An optional field may simply be absent, and the workflow
decides what that means.
There is no nesting: a nested shape is type="object", and what you read inside it
is at your own risk. Declaring nothing keeps the old behaviour exactly — whatever
the agent said is bound as-is.
| The shape is an instruction to the agent, not a constraint on how it generates. It is checked on arrival, which is why a mismatch is an error you can read rather than a silent one. |
This call runs with nobody watching it. The agent never sees a CLIENT
tool (there is no browser behind this step) or any tool that would need a person’s
approval, including one whose requiresApproval is off by default — there is
nobody here to grant it. See Security for the full rule.
Grant this agent only tools safe to run unattended, and expect its answer, not a
side effect, to be what the rest of the workflow acts on.
|
end
Terminates the walk. A workflow may have several end nodes.
<end id="done"/>
<end id="stop" terminate="true"/>
<end id="giveUp" error="UNFULFILLABLE"/>
| Attribute | Required | Description |
|---|---|---|
|
no |
stop the whole run here, not just this path |
|
no |
end the run as a failure under this name rather than as a
success. Contradicts |
A plain <end> ends the path that reached it; inside a fork, its siblings carry
on. terminate="true" is BPMN’s terminate end event — the run stops, every
branch still in flight is cancelled, and the approvals they were holding are
withdrawn from the inboxes holding them. It is the shape behind "a rejection
anywhere stops the process":
<task id="review" role="reviewers" next="outcome"/>
<split id="outcome" mode="one">
<flow id="f1" when="task.rejected" next="stop" title="Rejected"/>
<flow id="f2" default="true" next="merge"/>
</split>
<end id="stop" terminate="true"/>
A terminated run is not a failure — it did what it was asked — and is recorded
with the status terminated, so it can be told apart from one that simply ran out
of graph (completed).
error is the opposite: the run ends failed, under a name of the author’s
choosing. It is BPMN’s error end event, and the declarative half of
fail(code, message) — a path that means the process could not be completed,
said in the graph rather than inside a script:
<split id="outcome" mode="one">
<flow id="f1" when="self.stock == 0" next="giveUp"/>
<flow id="f2" default="true" next="ship"/>
</split>
<end id="giveUp" error="UNFULFILLABLE"/>
Nothing inside the same run can catch it: a boundary attaches to a host, and an
end is not something anything attaches to. Its reader is whoever called this
workflow — a <boundary on="error" code="UNFULFILLABLE"> on their <call>
node. See Catching a failure.