Skip to content

Joins and splits

Every node routes tokens in two directions, and each direction is a pluggable policy:

  • a split governs the node's outgoing flows: how a firing node fans out;
  • a join governs the node's incoming flows: when a node with several incoming branches fires.

For a picture of each pattern and what the engine does with it, see Join patterns.

A plain task node defaults to an immediate join and an all split, so it just flows straight through. You only set a join or split when a node fans out or converges. A gateway is a node that presets a join/split pair (see below); you can also set them directly on any node for finer control.

The simple rule

Choose the join by what should happen when a path you drew does not come.

Example: a split sends work to steps A, B and C, and all three lead to the same join. The flow to C has a condition, and this time it is false. So nothing goes to C.

  • Wait for all stops on an incident: you drew a path from C into the join, and it will not come. An operator decides what to do.
  • Wait for matching waits for A and B, then goes on. It waits only for the paths actually taken.

Either way, a join never goes on while something is still on its way to it.

Splits (outgoing)

A split chooses which outgoing flows a firing node takes, among those whose condition holds (an unconditioned flow always holds).

Split Takes Pattern
All (all, default) every outgoing flow whose condition holds parallel fork (unconditioned) or inclusive fan-out (conditioned)
First (first) only the first outgoing flow whose condition holds, tried top to bottom exclusive choice
Per member (per_member) every outgoing flow whose condition holds, once for each member of the node's audience one branch per person: ask each of them, then decide on their answers

So the same first split is an exclusive gateway when its flows carry conditions, and all is both the parallel fork and the inclusive fan-out depending on whether the flows are conditioned.

Top to bottom is the order the workflow stores its flows in, the order the Complete modeler lists them. A save from BPMN.io keeps that order, whatever order the diagram lists its elements in: a flow drawn there goes after the flows the workflow already had, and so does a flow whose source or target was renamed.

To change it, drag a flow by its title in the Complete modeler, or use its Move up and Move down buttons. A flow moves only among the flows leaving the same node, and under a First split each of them states its priority in its title. Nodes are reordered the same way; their order changes only how the workflow reads. Sort from start to end, beside Save, orders the nodes along the flows from the start node, each after the nodes that lead to it, and leaves the order of every node's flows as it is.

One branch per item

Say a document must be validated by every member of the "Reviewer" role and by the board chair, and a majority of them decides. Nobody knows, when the workflow is drawn, how many reviewers there will be. A per_member split does it with one flow:

start
  -> Ask every validator (per_member split)
  -> Validate (user task)
  -> Decide (quorum join, by majority)
  -> ...

per_member is one split creating one branch per item: its items are the members of an audience. Any such split works the same way:

  • When a token leaves its node, the split lists its items. For each item, it takes the outgoing flows whose condition holds, as all does. Each condition is tested with the item in the split's variable, so a flow can be taken for some items and not others. An item none of whose flows holds gets no branch.
  • Every token it creates is a branch of its own, and receives its item in that variable, local to the branch.
  • A join counts one branch per item for each flow of the split leading to it, as it counts one per flow of an all split. A second flow from the split, to a notification for instance, leads each item somewhere else and does not change that count.
  • A wait_all join after it requires, for each item it sent a branch to, every flow of the split leading to the join. A flow not taken for one item stops the join on an incident, as for an all split.
  • A split that lists no item raises an incident, as a split that takes no flow does.
  • The items are read when the split runs: what changes afterwards changes no branch.

per_member

  • The node's Audience names who is asked: users, roles and groups, in any mix, each named or read from a variable, as a human step's assignments do. Its members are every user those entries name, each once: someone named by two entries gets one branch. An entry naming no account, such as Everyone, adds nobody.
  • Each branch receives its member's user ID in the split's Member variable (member unless renamed). The Validate step assigns its task with Users by variable on that variable, so each member gets a task of their own.
  • A join counts the branches that arrive, one per task, whoever completes it: a task reassigned or delegated still counts once.
  • Someone who joins the audience after the split ran gets no branch, and someone who leaves it keeps theirs. A role or a group with more than 1000 active members is cut to the first 1000, and a warning is logged, as for its notifications.

The example_member_validation workflow in orchestra_examples is this example.

A split that takes nothing is reported

If a node has outgoing flows and none of them is taken (every condition evaluated false), the branch has nowhere to go: it would end there, and the run could complete looking successful. That is treated as a fault and raises an incident naming the conditions and the values they saw, rather than ending quietly (see Incidents). To end a branch deliberately, give the node a catch-all flow to an end node.

Joins (incoming)

A join decides when a node where several branches meet goes on.

Join Fires when Pair with
Immediate (immediate, default) each token arrives, no waiting a node one token reaches at a time
Wait for all (wait_all) every path drawn into it has delivered; it stops on an incident if one will not a split where every branch must arrive
Wait for matching (matching) every path taken has delivered, and nothing else is on its way a conditional split (only some branches run)
Threshold (threshold) N branches have arrived (early fire) a split where a partial result is enough (N of M)
Quorum (quorum) its quorum condition holds on enough branches, or that becomes unreachable a vote where either outcome can be called early

Branches, passes and the opening split

A split fans out when it can send one token down several flows at once: the all split on a node with more than one outgoing flow, and a split creating one branch per item (such as per_member) on any node. Each token it creates starts a branch, and every token created later inside that branch belongs to it too.

Every join except the immediate one waits for the branches of one split, its opening split. To find it, take the splits that every path from the start node to the join goes through. The opening split is the one of them nearest to the join that fans out. Each time a token goes through that split, it starts a pass, with branches of its own. The join fires once per pass, with that pass's branches only. A loop that comes back to the split starts a new pass, so the join fires again for it. Two passes waiting at the join at the same time never mix.

The join waits only for the branches the split actually started down flows leading to it. A flow whose condition was false started no branch, so it is not waited for. A branch sent down a flow that leads somewhere else is not waited for either.

When several branches meet at an inner join before reaching this one, the inner join sends one token on for all of them, and this join counts that token as one branch.

A branch can also split again before the join. Say S sends work to A, B and C, and C sends work both straight to the join and through a step X. The join then counts every branch drawn into it, at every level: A, B, C's branch straight to the join, and C's branch through X. That is four, and a threshold, a quorum or a collect line counts each of them. Each of them is one of the paths drawn into the join, the paths a wait_all join requires. An exclusive choice inside a branch adds none: only one of its flows is taken. A loop inside a branch adds none either: going through C again brings C's same two branches.

A step whose timer sends branches out while it waits (the spawn timeout action) works differently, because its branches leave at different moments:

  • While the step waits, the join waits for a branch on every flow of the step that leads to the join. The timer can still send one down any of them.
  • A step halted on an incident counts as waiting: a retry brings it back.
  • Once the step has moved on, or its branch is canceled, the join waits only for the branches the step actually sent. For example, a step answered before its timer fired sent no alert, so the join does not wait for an alert.

Some workflows are refused when they are saved, because a join in them could not tell which branches to wait for:

  • a split fans out to the join, but some path to the join avoids that split; close that split's branches with a join of their own, then bring the paths together with an immediate join;
  • a branch can reach the join both through an inner join and around it; draw each path so that it reaches the join only one way;
  • a join requires more branches (a threshold or quorum count) than its opening split can send it;
  • a split creating one branch per item on a node whose timer sends branches out: the items are only known when the timer sends them, so a join could not tell which branches to wait for.

A join that no split fanning out can reach receives one token at a time, and simply passes each one on.

How a join knows which branches are on their way

A branch is on its way to a join while one of its tokens stands on a node from which the join can still be reached. The token can be running, parked, waiting at another join, or halted on an incident that a retry would bring back.

Whether the join can be reached is read from the workflow's flows, all of them, whatever their conditions. A branch that has not yet decided which way to go is still on its way. Only the join's own level is read: a child workflow embedded on the way counts through its subprocess step.

When a branch leaves for nodes from which the join cannot be reached, ends, or is canceled, a join waiting for it decides again with what can still arrive. A wait_all join stops on an incident instead (see below).

A join never fires while a token of its pass is still on its way to it. A branch can split again before the join, with one path straight into the join and another through a step X. The join then waits for the token coming through X too, and its fire takes every token that arrived.

A loop upstream of a matching join

A token in a loop that can come back to the join counts as on its way, so a matching join waits for it. When that is not what you want, use a wait_all join.

wait_all or matching?

The two differ when a path drawn into the join does not come.

  • wait_all requires every path drawn into it. A path does not come when:
    • a split that sends tokens down several flows does not take its flow towards the join (its condition was false);
    • a token goes where the join can no longer be reached, or ends there;
    • a token is canceled on the way.

As soon as that happens, the join halts on an incident that says what happened, for example "the flow from n_c to n_x was not taken". An operator fires it with what arrived, or cancels the instance. While another token of the same pass is halted on an incident of its own, the join waits: once that incident is resolved, it decides again. - matching waits only for the paths actually taken, and goes on with what arrived.

Some things are not a path that does not come:

  • an exclusive choice (a first split) that takes one of its flows, as long as that flow still leads to the join;
  • a step's timer that never fired;
  • a path that first meets an inner join: that join decides about it.

After a split where every branch must finish, wait_all says what you mean. After a conditional split, or when a branch may legitimately end elsewhere, use matching.

When you save a workflow in the form, it warns you about each flow that can stop a wait_all join this way: a flow with a condition at a split that sends tokens down several flows, or a choice that can lead where the join cannot be reached. The workflow is saved all the same: sometimes the incident is what you want.

Conditions on a join's incoming flows

A join reads no condition on its own incoming flows. Which branches it waits for comes from the split: put the conditions on the split's outgoing flows. A condition left on a flow into a join still decides whether a token can take that flow, like on any flow. For a wait_all join, a flow not taken at a split that sends tokens down several flows is a path that does not come, so the join stops on an incident. Use matching when that is expected.

After a fire: the branches the join went without

A join can fire before every branch of its pass has arrived: a threshold or quorum join decides early, a join timeout fires any join, and an operator can fire any join from an incident. Its When it fires setting decides what becomes of the branches it fired without:

n_decision:
  type: passthrough
  join:
    plugin: threshold
    settings:
      count: 1
      on_fire: ignore   # cancel (the default), cancel_split or ignore
  • Cancel the branches heading here (cancel, the default): the fire cancels the tokens of the pass still on their way to this join (see How a join knows which branches are on their way), with their open tasks. Nobody is left holding a task for a decision already made. Tokens of the pass that can no longer reach the join run on: side work going to another join or to an end is kept.
  • Cancel the split (cancel_split): the fire cancels everything still running under the nearest split that the winning branch and the others all came out of, wherever it is heading, and closes that split. Use it when the side work only matters while the decision is open, for example a reminder loop that would otherwise keep the instance running.
  • Ignore (ignore): the fire cancels nothing. The branches still running finish their own work, tasks included. One that reaches the join afterwards is ignored there: consumed without firing the join again, and recorded in the audit trail as ignored.

With either cancel setting, a branch caught in the middle of a step stops at its next step. A token halted on an incident is canceled too, with the tokens halted below it, inside an embedded child included, and their incidents are resolved as canceled.

A token that has not chosen its way yet

A token counts as heading to the join as long as the join can still be reached from where it stands, whatever the conditions on the flows. So with the default setting, a token on a step that could go to the join or elsewhere is canceled, even if it would have gone elsewhere.

Once a pass has fired the join, a token of that pass that does not come from the fire is one of two things:

  • a late branch, one the fire went without (for example, one a timer sent out after the fire): it is ignored, whatever the setting;
  • a surplus, a second token of a branch that already reached the join (for example, from a timer that fires twice down the same flow). It waits at the join. Once nothing else in the instance can move, it halts on an incident. There an operator cancels it, or fires the join again with it.

A token that comes from the fire is not late: a loop that comes round to the join without going back through the split starts from the fire's token, so its new round counts and can fire the join again.

Threshold (N of M): firing early

Where wait_all waits for every branch, a threshold join fires the moment the configured number of branches have arrived. Set it on the node where the branches meet, with a count:

n_decision:
  type: passthrough
  join:
    plugin: threshold
    settings:
      count: 2          # fire once two branches arrive

Use it for a partial result, "proceed once two of three reviewers approve," without waiting on the slowest branch. Like the other joins that wait for branches it can merge a variable from each arrived branch, so a tally (say, the approvals) is available to the outgoing flows, here over the branches that arrived in time. What becomes of the branches still running is its When it fires setting.

The count is never lowered to the branches that run. When every branch the join waits for has arrived and they are still fewer than count (a conditional split ran fewer branches than it asks for), the join can no longer fire: the branch that settles it halts on an incident, where an operator fires the join with the branches that arrived, or lowers the count the instance runs and retries. To wait for every branch that ran, whatever their number, use wait_all or matching instead. A workflow whose count is above the branches that can reach the join, at every level, is refused when it is saved.

Quorum: deciding either way early

A threshold join counts arrivals; a quorum join counts the branches its quorum condition holds on. The condition is a flow condition, tested on each arrived branch with that branch's own variables, the way a flow tests the token leaving on it. The join fires the moment the decision is settled, in either direction:

  • the condition holds on enough branches (the quorum is reached), or
  • so few can still make it hold that the quorum is out of reach (enough have come back without it).

A branch counts as it was when it arrived: whether the condition held on it is kept on its token, so a variable changed afterwards does not change its vote.

The condition says what is counted: a vote equal to approved (a comparison, which the editor starts on, with equals), a value other than rejected, one of several outcomes (an any of comparisons), an amount over a limit. Counting approvals, a "two of three approvals" review passes the instant the second approval arrives, and fails the instant the second rejection arrives, without waiting on the last reviewer. As with the threshold join, the branches still running are then canceled, or left to finish when the join is set to Ignore.

n_decide:
  type: passthrough
  join:
    plugin: quorum
    settings:
      quorum_condition:         # tested on each branch
        plugin: comparison
        settings: { variable: vote, operator: '==', value: approved }
      quorum_unit: count        # count, percent or majority
      quorum_value: 2           # branches it must hold on
      quorum_result: approvals  # how many it held on, written when the join fires
      quorum_reached: approved  # whether that reached the quorum
      scope: token
  split: { plugin: first }

The join only fires; the outgoing flows route on its result. When it fires, whichever way the quorum went, the join writes to quorum_result how many branches the condition held on, in the join's scope. A comparison condition then tells the outcomes apart: approvals >= 2 on the flow taken when the quorum is reached, approvals < 2 on the other. The join also writes to quorum_reached whether the quorum was reached, TRUE or FALSE, so the flows can test that instead: approved is not empty on one, is empty on the other. It is the one to route on with a percentage or a majority (below), where the number of branches it takes depends on how many ran.

The variables the condition reads must be written token-locally (each task writing its payload token-scoped), so each branch carries its own.

To route on the branches' values themselves, or keep them after the join, also gather them with a collect line, for example vote|votes, and route with a count condition over the list. The result variable cannot be the target of a collect line: both are written when the join fires, so one would overwrite the other.

How many branches make the quorum is set by quorum_unit:

Unit Quorum Example
count (default) quorum_value branches 2 of any number of reviewers
percent quorum_value percent of the branches, rounded up 50: 2 of 4, 2 of 3
majority more than half the branches (quorum_value is not used) 3 of 4, 2 of 2

The share is always taken of the branches the join waits for, never of the branches arrived so far: with four reviewers at 50%, a first approval is 1 of the 2 needed, not 100% of the arrivals. After an inclusive split, the branches it waits for are those the split took, so a share is always within their reach. A count is not lowered to them: when fewer branches run than the count, the quorum is out of reach from the start, so the first branch to arrive settles it as not reached. A workflow whose count is above the branches that can reach the join, at every level, is refused when it is saved.

A branch that splits again before the join can bring the quorum several votes, one per branch it splits into (see Branches, passes and the opening split). Before it has split, the quorum counts every vote it could still bring, so it never decides "not reached" while that branch could still reach the quorum. A percentage or a majority needs to know how many branches there are in all, which such a branch only tells once it has split. So a quorum by percent or majority that a branch reaches after splitting again, with several branches into the join, is refused when the workflow is saved: bring that branch's branches together with a join of their own first, or use a count.

To set the quorum per instance, name a variable in quorum_value_variable: the join reads the value from it, in the arriving branch's view, instead of quorum_value, in the unit chosen. Set it before the split, so every branch reads the same. A variable that is not set, or holds anything but a whole number from 1 (to 100 for a percentage), raises an incident rather than deciding on a number nobody chose.

The quorum_condition is required: with none, the quorum has nothing to count and waits for every branch. It is a setting of its own, not a collect line, so the lists the join writes are free for anything else, and a distinct list cannot change what is counted.

Quorum vs the synchronous count

Routing on a count condition after a wait_all join also decides pass or fail, but only once every branch has voted. The quorum join is the early-decision form: it stops as soon as the outcome cannot change.

Giving up on a slow branch

Any join that waits for branches can give up on a deadline: "collect every approval, but give up after seven days." The deadline is not a join setting but a timeout of the join's node, join_timeout, which fires the join with whatever has arrived:

n_gather:
  type: passthrough
  join:
    plugin: matching
  features:
    join_timeout:
      deadline_settings:
        duration: P7D        # seconds or an ISO-8601 duration

It has the deadline providers, anchors, actions and outcome a task's timeout has. See Timeouts at a join.

The split/join pairings (and gateways)

Most useful combinations correspond to a BPMN gateway, which is just a node that presets the pair:

Pattern Split Join Gateway
Parallel all wait_all parallel
Inclusive all matching inclusive
Exclusive first immediate exclusive

Using a gateway node is the readable way to express these; setting the join and split directly on a plain task node gives the same behavior with finer control (for example a wait_all join and a merge policy on an ordinary script node, as the quorum tally does).

Merge policy (collecting branch values)

A join that waits for several branches (wait_all, matching, threshold or quorum) can gather variables from the joined branches onto the continuing token, the quorum-tally pattern: as lists, or as one number computed from the values. The merge policy is the join plugin's own settings:

Setting Meaning
collect the collect lines, each {from, into, reducer}: the variable read from each joined branch, the variable the line writes to, and what it writes (none merges nothing)
scope instance (shared with the whole process) or token (local to the continuing branch), for every line

In the editor the collect lines are typed one per line, as source|target, or source|target|reducer to write something else than the list:

vote|votes
comment|comments
department|departments_seen|distinct
amount|total_amount|sum
Reducer What the line writes
list (or none) the values, as a list
distinct the list without its repeats
count how many branches set the source
sum the sum of the values, 0 when no branch set the source
avg their average, NULL when no branch set the source
min, max the smallest or the largest value, NULL when no branch set the source

The editor refuses a line of another shape or with another reducer, a reserved __ name, and a target used by two lines, whose second value would overwrite the first. A workflow saved another way (an import, a recipe) with a reducer there is none of is refused too.

When the join fires, each line reads its from variable as each joined branch sees it: from the branch's own tokens and their ancestors. It writes what it computes from those values to its into variable. A count flow condition downstream can then decide on a list, e.g. "at least two of the collected votes are approved", and a comparison condition on a number, e.g. total_amount >= 10000.

n_tally:
  type: passthrough
  join:
    plugin: wait_all
    settings:
      collect:
        - from: vote       # each reviewer wrote their vote here (token-scoped)
          into: votes      # gathered into this list on the continuing token
          reducer: list
      scope: token
  split:
    plugin: first        # then route exclusively on the count

The lists follow the order the branches arrived in at the join: the first value is the first arrival's. With distinct, a value already in the list is not added again, so the occurrence kept is the first arrival's. Values are compared strictly, after a structured {outcome, comment} payload is reduced to its outcome: "1" and 1 are two values, and a structured value is compared as a whole.

Numbers: sum, avg, min and max

sum, avg, min and max take numbers only: an integer, a decimal, or a text holding one, as a webform answer is. A value that is not a number (another text, a list, TRUE or FALSE, NULL) stops the join from firing, and the branch that would have fired it halts on an incident naming each such value, the line that read it and the flow it came down. Leaving the value out on its own would quietly change the number the workflow routes on. The operator then corrects the values or the workflow and retries, or chooses Fire the join without the values that are not numbers: the lines are then computed from the numbers alone, and each value left out is logged.

On a join that fires before every branch arrived (a threshold or quorum join, or one its timeout fired), a line covers the branches that arrived before the join fired, as a list does: an average is the average of those.

The lists do not line up branch by branch

Each line is its own list. A branch that never set a line's source adds nothing to that list, and distinct leaves repeats out, so the second vote and the second comment may come from different branches. To keep a branch's vote with its comment, keep the branches' variables with Branch variables, or give each branch a variable name of its own.

With matching the lists hold the values of only the branches that ran, which is what makes it the right join for an applicability-based tally.

The collected variable is the branch-local one

A line's from is read as each branch sees it, so it is normally a token-scoped (branch-local) variable. The quorum's vote is set with completion_scope: token, so the reviewers' votes do not overwrite each other. A token-scoped variable is visible only to its own branch, never to a sibling branch. That is exactly why it works for collecting values: one variable is gathered after the branches join.

Counting the arrivals

Three reviewers are asked, and the join gives up after a week. The step after it needs to know how many answered: "2 of 3". Name two variables on the join and every fire writes them:

n_gather:
  join:
    plugin: matching
    settings:
      arrived_variable: answers    # how many branches arrived
      expected_variable: asked     # how many the join waited for
  • Every fire writes them, whatever fired the join: the last arrival, a threshold or quorum deciding early, its timeout, or an operator from an incident.
  • When every branch arrived, the two are equal. A flow condition such as answers < asked tells a fire that went on with fewer, without writing the number of branches into the condition. That matters after an inclusive split, where it changes from one pass to the next.
  • They count branches of the split. A branch that split again on its way counts once. A branch that can no longer arrive (it ended elsewhere, or was canceled) is not counted as waited for, at a join that stops waiting for it.
  • They are written with the merge, in its scope.

Branch variables (keeping what the branches set)

Where branches meet, a synchronizing join ends every token that arrived and goes on with one new token, created under the token the branches split at. The steps after the join therefore see the instance variables and the token-scoped variables set before the split, but not the ones a branch set token-locally after it: a validation branch's decision or comment is gone past the join. The merge above carries one variable across as a list, which fits a tally; the Branch variables setting carries the plain values themselves. It is on the four synchronizing joins (wait all, matching, threshold, quorum), next to the merge settings:

branch_variables The new token gets
drop (default) none of the branches' token-scoped variables
first each variable from the earliest arrival that set it
last each variable from the latest arrival that set it, the way an instance-scoped variable keeps its last write
n_review_done:
  type: passthrough
  join:
    plugin: wait_all
    settings:
      branch_variables: last   # each variable from the last branch that set it
  • The values are taken variable by variable: a branch that never set a variable does not hide another branch's value for it.
  • Only what a branch set after the split counts. A value set before it is seen past the join anyway, so a branch that left it as it was does not pass it on as its own.
  • Only the tokens the fire joins take part. A branch an early fire cancels did not finish its work, so its values are left out, and so are a late branch's that a join set to Ignore lets go.
  • Arrivals are taken in the order they reached the join, which is the order of their token IDs.
  • The values are written on the new token, so the steps after the join read them as token-scoped variables. A token-scoped merge into a variable of the same name wins: its list is written after them. An instance-scoped one does not: the new token's own value comes first in its view.

A join fired by a single branch meets nothing, so this setting does not apply to it: no other branch's values can clash with its own, and the branch goes on as it is, seeing everything it saw, the values it set after the split included. This happens, for example, at the matching join after an inclusive split that took only one flow, and at a threshold or quorum join, or one its timeout fires, while only one branch has arrived. The branch's token goes on itself, or, when the fire cancels the other branches, a new token is created under it, outside the split the fire closes. The merge is written as usual, as a list of one value.

Keeping makes the result depend on the arrival order

With first or last, two instances of the same process can end with different values when their branches finish in a different order. Name each branch's variables apart (legal_decision, finance_decision) when the steps after the join need all of them.

When to use matching: concrete examples

Each of these has a conditional split that starts only some branches, and branches that may end elsewhere: matching waits for the paths taken that can still arrive, where wait_all would stop on an incident for the others.

A worked example: conditions on the split only

A decision before the fork records what to do (here two instance-scoped booleans). The split carries the conditions; the flows into the matching join carry none:

flowchart LR
  d["Choose channels<br/>sets notify_email, notify_sms<br/>(instance-scoped, before the fork)"]
  d --> s{{"split: all"}}
  s -->|notify_email| e["Send email"]
  s -->|notify_sms| m["Send SMS"]
  e --> j{{"join: matching"}}
  m --> j
  j --> done["Log delivery"]
flows:
  # split -> branches
  - id: f_email
    from: g_split
    to: n_email
    condition: { plugin: comparison, settings: { variable: notify_email, operator: '==', value: true } }
  - id: f_sms
    from: g_split
    to: n_sms
    condition: { plugin: comparison, settings: { variable: notify_sms, operator: '==', value: true } }
  # branches -> join: no condition
  - id: f_email_join
    from: n_email
    to: g_join
  - id: f_sms_join
    from: n_sms
    to: g_join
  • Pick email only: the split takes f_email, so only n_email runs. When it reaches the join, nothing can come down f_sms_join any more, so the join goes on.
  • Pick both: when the first branch arrives, the other is still on its way, so the join waits for it.
  • A branch may change notify_email or notify_sms after the split: the join does not read them.

The shipped example_inclusive expresses the same idea with a single channel choice and an any condition; two booleans are shown here for readability.

  • Multi-channel notifications: the user picks email / SMS / push; only the chosen channels send, and the join waits for the selected ones. (Shipped as example_inclusive.)
  • Applicability-based review: Legal reviews only if there is a contract, Finance only if the amount exceeds a threshold, Security only if it touches PII. The join waits for just the applicable reviews; a merge collects each applicable reviewer's verdict so a count can require no rejections.
  • Optional data enrichment: geocode if an address is present, credit-check if an amount is set, KYC if the customer is new. The join waits for whichever enrichments were triggered.
  • Multi-target publishing: website always, plus social / newsletter / partner feed when flagged. The join waits for the selected destinations' confirmations.
  • Order fulfillment sub-tasks: gift-wrap if requested, customs paperwork if international, age verification if restricted. The join waits for the sub-tasks that apply before shipping.
  • Conditional integrations: sync to CRM if customer-facing, ERP if it affects inventory, analytics always. The join waits for the integrations that fired.

Configuring joins and splits

  • In the Complete modeler each routing-capable node shows a Join and/or Split select. The chosen plugin's description appears under the select, and any settings it has (a merging join's collect / into / scope and Branch variables) appear in a collapsible section below.
  • In config a node carries join and split as {plugin, settings} maps; a gateway node presets them and is not edited directly. The merge policy lives in the join's settings.
  • Examples to read: example_parallel (all + wait_all), example_inclusive (all + matching), example_quorum (wait_all + merge + a count condition), and example_member_validation (per_member + a quorum by majority).
  • A per_member split shows an Audience section on its node in the Complete modeler, with the same entries editor as a human step's assignments, and its Member variable under the split.